Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ __pycache__/
env/
venv/
.env
.env.*
**/.env
**/.env.*
!.env.example
!**/.env.example
.venv
build/
dist/
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
| **文献卡片** | Structured Card(LLM 提取 method/dataset/conclusion/limitations),懒加载 + DB 缓存 |
| **导出增强** | BibTeX/RIS/Markdown/CSL-JSON(Zotero 原生导入),Next.js proxy route 修复 |
| **写作辅助** | Related Work 草稿生成(基于 saved papers + topic),[AuthorYear] 引用格式,一键复制 |
| **每日推送** | DailyPaper 生成后自动推送摘要到 Email/Slack/钉钉支持 API 手动触发和 ARQ Cron 定时推送;MinerU 图表提取与 Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS)待集成 |
| **每日推送** | DailyPaper 生成后自动推送到 Email/Slack/钉钉支持 API 手动触发 + ARQ Cron 定时);已集成 MinerU v4 主方法图提取(公网 URL + inline data URL 回退)、LLM Digest + Judge 评分卡片、Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS) |
| **Model Provider** | 多 LLM 提供商管理(OpenAI/Anthropic/OpenRouter/Ollama),API Key Keychain 安全存储,任务级路由,连接测试 |
| **Deadline Radar** | 会议截止日期追踪,CCF 分级过滤,Research Track 关键词匹配 |
| **论文发现** | 种子论文扩展(引用/被引/共作者),Discovery Graph 可视化,论文集合(Collections)管理 |
Expand All @@ -35,7 +35,7 @@
| DailyPaper | ✅ 可用 | `/research/paperscool/daily` | `daily-paper` | 报告生成 + LLM 增强 + Judge + 保存,完整可用 |
| LLM-as-Judge | ✅ 可用 | `/research/paperscool/analyze` | `--with-judge` | 5 维评分 + 多轮校准 + 推荐分级 + Token Budget,SSE 增量推送 |
| Analyze SSE | ✅ 可用 | `/research/paperscool/analyze` | — | Judge / Trend / Insight 三通道 SSE 流式,前端逐卡片渲染 |
| Push/Notify | 🟡 基本可用 | `/research/paperscool/daily` | `--notify` | Email/Slack/钉钉 已落地;Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS)+ MinerU 图表提取待集成 |
| Push/Notify | ✅ 可用 | `/research/paperscool/daily` | `--notify` | Email/Slack/钉钉 + Apprise 多渠道(Telegram/Discord/企业微信/飞书/RSS);MinerU v4 主方法图(含 inline 回退)+ LLM Digest/Judge 卡片已落地 |
| 学者追踪 | 🟡 基本可用 | `/track` | `track` | 多 Agent 管线 + PIS 评分完整;依赖 Semantic Scholar API Key |
| 深度评审 | 🟡 基本可用 | `/review` | `review` | 模拟同行评审流程完整;输出质量取决于 LLM 后端配置 |
| Paper2Code | 🟡 基本可用 | `/gen-code`(兼容) + `/research/repro/context/*` | `gen-code` | 编排 + RAG + CodeMemory 完整;执行层计划迁移为 AgentSwarm/Codex 专业执行器 |
Expand Down Expand Up @@ -172,7 +172,9 @@ Search → Build Report → LLM Enrichment → Judge Scoring → Filter → Save

### Email 推送

![Email Notification](asset/notify.png)
![Email Notification](asset/ui/dailypaperdemo.png)

示例模板包含:导读摘要、Must Read 分层、Judge 评分、Digest Card、主方法图(可公开 URL 或 inline data URL 回退)。

## 快速开始

Expand Down Expand Up @@ -463,7 +465,12 @@ DB 持久化(统一主数据模型 Paper/Scholar/Event/Run)、任务队列/

### Phase 6 — 每日推送优化

MinerU PDF 图表提取(主方法图自动识别)、推送内容增强(一句话总结 + 结构化摘要)、Apprise 多渠道统一推送层(Telegram/Discord/企业微信/飞书/RSS)、HuggingFace Daily Papers API 数据源接入。
已完成:
- MinerU v4 官方任务流接入(`/extract/task` + 轮询任务结果),主方法图自动识别。
- 邮件渲染增强:支持主方法图展示(公开 URL)与 `data:image/...` inline 回退(适配 zip 内部图)。
- 推送内容增强:导读摘要 + 一句话总结 + Digest Card + Judge 评分信息。
- Apprise 多渠道统一推送层(Telegram/Discord/企业微信/飞书/RSS)落地。
- HuggingFace Daily Papers API 数据源接入并纳入 DailyPaper 流程。

## 文档索引

Expand Down
Binary file added asset/ui/dailypaperdemo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
779 changes: 779 additions & 0 deletions docs/proposals/agentswarm-design.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ OPENAI_BASE_URL=
# ----------------------------
SEMANTIC_SCHOLAR_API_KEY=
GITHUB_TOKEN=
MINERU_API_KEY=
# MinerU Cloud API v4 async task endpoint
MINERU_API_BASE_URL=https://mineru.net/api/v4
# MinerU model_version, e.g. vlm / pipeline
MINERU_MODEL_VERSION=vlm
# Max wait for task polling in seconds
MINERU_MAX_WAIT_SECONDS=180
# MinerU Cloud limits: URL input only, <=200MB, <=600 pages, github/aws URL may timeout

# CCS download (optional, ACM access URL)
ACM_LIBRARY_URL=
Expand Down Expand Up @@ -101,6 +109,8 @@ PAPERBOT_DAILYPAPER_ENABLE_JUDGE=true
PAPERBOT_DAILYPAPER_JUDGE_RUNS=1
PAPERBOT_DAILYPAPER_JUDGE_MAX_ITEMS=5
PAPERBOT_DAILYPAPER_JUDGE_TOKEN_BUDGET=0
PAPERBOT_DAILYPAPER_ENABLE_FIGURES=false
PAPERBOT_DAILYPAPER_FIGURES_MAX_ITEMS=5

# ----------------------------
# Daily push notifications
Expand Down
110 changes: 78 additions & 32 deletions src/paperbot/api/routes/repro_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from paperbot.api.streaming import StreamEvent, wrap_generator
from paperbot.application.services.p2c.models import (
GenerateContextRequest as P2CRequest,
RawPaperData,
new_context_pack_id,
)
from paperbot.application.services.p2c.orchestrator import ExtractionOrchestrator
Expand All @@ -44,6 +45,9 @@ class GenerateContextPackRequest(BaseModel):
project_id: Optional[str] = None
track_id: Optional[int] = None
depth: Literal["fast", "standard", "deep"] = "standard"
# Optional inline paper data — when provided, skips the input router lookup.
title: Optional[str] = None
abstract: Optional[str] = None


class CreateSessionRequest(BaseModel):
Expand All @@ -65,17 +69,23 @@ async def _generate_stream(request: GenerateContextPackRequest):
)

# Persist initial "running" record so the frontend can poll status.
await asyncio.to_thread(
_store.save,
pack_id=pack_id,
user_id=request.user_id,
paper_id=request.paper_id,
depth=request.depth,
pack_data={},
project_id=request.project_id,
confidence_overall=0.0,
warning_count=0,
)
try:
await asyncio.to_thread(
_store.save,
pack_id=pack_id,
user_id=request.user_id,
paper_id=request.paper_id,
depth=request.depth,
pack_data={},
project_id=request.project_id,
confidence_overall=0.0,
warning_count=0,
)
except Exception as exc:
Logger.warning(
f"[M2] store_save_failed pack_id={pack_id} error={exc} (continuing without persistence)",
file=LogFiles.ERROR,
)

yield StreamEvent(type="status", data={"pack_id": pack_id, "status": "running"})

Expand Down Expand Up @@ -107,15 +117,22 @@ async def on_stage_complete(stage_name: str, observations: list, warnings: list)
}
for o in observations
]
await asyncio.to_thread(
_store.save_stage_result,
pack_id=pack_id,
stage_name=stage_name,
status="completed",
result_data={"observations": [o.to_full() for o in observations]},
confidence=confidence,
duration_ms=0,
)
try:
await asyncio.to_thread(
_store.save_stage_result,
pack_id=pack_id,
stage_name=stage_name,
status="completed",
result_data={"observations": [o.to_full() for o in observations]},
confidence=confidence,
duration_ms=0,
)
except Exception as exc:
Logger.warning(
f"[M2] store_save_stage_result_failed pack_id={pack_id} "
f"stage={stage_name} error={exc}",
file=LogFiles.ERROR,
)
Logger.info(
f"[M2] stage_complete pack_id={pack_id} stage={stage_name} obs={len(observations)} warnings={len(warnings)}",
file=LogFiles.API,
Expand Down Expand Up @@ -150,9 +167,24 @@ async def on_stage_complete(stage_name: str, observations: list, warnings: list)
orchestrator = ExtractionOrchestrator()
result_holder: list = []

# When title+abstract are provided inline, build RawPaperData directly
# so the orchestrator skips the input router (which cannot handle studio IDs).
inline_raw: Optional[RawPaperData] = None
if request.title is not None and request.abstract is not None:
inline_raw = RawPaperData(
paper_id=request.paper_id,
title=request.title,
abstract=request.abstract,
source_adapter="inline",
)

async def _run() -> None:
try:
pack = await orchestrator.run(p2c_request, on_stage_complete=on_stage_complete)
pack = await orchestrator.run(
p2c_request,
raw_paper=inline_raw,
on_stage_complete=on_stage_complete,
)
result_holder.append(pack)
await queue.put(_DONE)
except ValueError as exc:
Expand All @@ -175,8 +207,16 @@ async def _run() -> None:
break
elif item is _ERROR:
err = result_holder[0]
await asyncio.to_thread(_store.update_status, pack_id, status="failed")
yield StreamEvent(type="error", data=err)
err_message = err.get("message", "Generation failed") if isinstance(err, dict) else str(err)
try:
await asyncio.to_thread(_store.update_status, pack_id, status="failed")
except Exception as exc:
Logger.warning(
f"[M2] store_update_status_failed pack_id={pack_id} "
f"status=failed error={exc}",
file=LogFiles.ERROR,
)
yield StreamEvent(type="error", data=err, message=err_message)
return
else:
yield item # StreamEvent from on_stage_complete
Expand All @@ -196,15 +236,21 @@ async def _run() -> None:
pack_dict = _asdict(pack)
pack_dict["context_pack_id"] = pack_id # align with our DB record

await asyncio.to_thread(
_store.update_status,
pack_id,
status="completed",
pack_data=pack_dict,
confidence_overall=pack.confidence.overall,
warning_count=len(pack.warnings),
objective=pack.objective,
)
try:
await asyncio.to_thread(
_store.update_status,
pack_id,
status="completed",
pack_data=pack_dict,
confidence_overall=pack.confidence.overall,
warning_count=len(pack.warnings),
objective=pack.objective,
)
except Exception as exc:
Logger.warning(
f"[M2] store_update_failed pack_id={pack_id} error={exc}",
file=LogFiles.ERROR,
)
Logger.info(
f"[M2] generation_completed pack_id={pack_id} observations={len(pack.observations)} warnings={len(pack.warnings)}",
file=LogFiles.API,
Expand Down
31 changes: 22 additions & 9 deletions src/paperbot/api/routes/runbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,15 @@ def _normalize_user_directory(raw_path: str, field_name: str) -> Path:

if not os.path.isabs(normalized):
normalized = str((Path.cwd() / normalized).resolve(strict=False))
normalized_real = os.path.realpath(normalized)

for prefix in _allowed_workdir_prefixes():
prefix_str = str(prefix)
if normalized == prefix_str:
prefix_real = os.path.realpath(prefix_str)
if normalized_real == prefix_real:
return prefix
if normalized.startswith(prefix_str + os.sep):
suffix = normalized[len(prefix_str):].lstrip("/\\")
if normalized_real.startswith(prefix_real + os.sep):
suffix = normalized_real[len(prefix_real):].lstrip("/\\")
candidate = (prefix / suffix).resolve(strict=False) if suffix else prefix
if _is_under_prefix(candidate, prefix):
return candidate
Comment on lines +162 to 169

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Root-prefix edge case can reject valid paths.

At Line 165, normalized_real.startswith(prefix_real + os.sep) breaks when prefix_real is / (checks for //). That makes valid children under root fail normalization.

Suggested fix
-        if normalized_real.startswith(prefix_real + os.sep):
+        try:
+            under_prefix = os.path.commonpath([normalized_real, prefix_real]) == prefix_real
+        except ValueError:
+            under_prefix = False
+        if under_prefix and normalized_real != prefix_real:
             suffix = normalized_real[len(prefix_real):].lstrip("/\\")
             candidate = (prefix / suffix).resolve(strict=False) if suffix else prefix
-            if _is_under_prefix(candidate, prefix):
-                return candidate
-            raise HTTPException(status_code=403, detail=f"{field_name} is not allowed")
+            return candidate
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/paperbot/api/routes/runbook.py` around lines 162 - 169, The check that
gated child paths uses "normalized_real.startswith(prefix_real + os.sep)" which
fails when prefix_real is root ("/"); change the conditional that decides
children to handle the root-prefix edge case by allowing any absolute path under
"/" — e.g. replace that condition with a combined check like "if prefix_real ==
os.sep or normalized_real == prefix_real or
normalized_real.startswith(prefix_real + os.sep):" so the code still computes
suffix and candidate as before and then validates with _is_under_prefix(prefix,
candidate).

Expand Down Expand Up @@ -231,15 +233,26 @@ async def add_allowed_dir(body: AddAllowedDirRequest):
detail="runtime allowlist mutation is disabled"
)

try:
resolved = _normalize_user_directory(body.directory, field_name="directory")
except HTTPException:
raise
raw = body.directory.strip()
if not raw or "\x00" in raw:
raise HTTPException(status_code=400, detail="invalid directory path")

# Normalize and validate against configured safe prefixes before touching FS.
# This prevents path-injection style access to arbitrary locations.
resolved = _normalize_user_directory(raw, field_name="directory")

denied_roots = {Path(denied).resolve() for denied in _DENIED_PATHS}
if resolved in denied_roots:
raise HTTPException(
status_code=403,
detail=f"adding '{resolved}' is not allowed — path is too broad or sensitive",
)

if str(resolved) in _DENIED_PATHS:
# Deny home directory itself — too broad
if resolved == Path.home().resolve():
raise HTTPException(
status_code=403,
detail=f"adding '{resolved}' is not allowed — path is too broad or sensitive"
detail="adding home directory is not allowed — too broad",
)

if not resolved.exists() or not resolved.is_dir():
Expand Down
Loading
Loading