diff --git a/PROGRESS.md b/PROGRESS.md index 9c24192..18512c4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-21(research skill 二次迭代:list 加 pdf_url / xml_url 直链 + paper.py 加 fetch_xml + paper_server 加 pg_trgm GIN 索引根治 SearchFilter ILIKE 全表扫;顶栏 token 累计修;同 wd 多 task 并发软警告) +最后更新:2026-05-21(research skill 三次迭代:fetch_pdf 改走静态直链跟 fetch_xml 对齐,绕开 paper_pdf_view 路径 bug → 5/5 fetch 100% 成功;二次迭代 pdf_url/xml_url 直链 + fetch_xml + pg_trgm 索引;顶栏 token 累计修) --- @@ -23,7 +23,9 @@ ### 2026-05-21 -- **research skill 二次迭代:list 端点加 pdf_url / xml_url 直链 + 新增 fetch_xml + pg_trgm GIN 索引根治 SearchFilter timeout**:首版 redeploy 后跑 `scripts/smoke_paper_skill.py` 发现两个 paper_server 既有问题 — P1 `?search=cement` 不带 filter 30s timeout(`title ILIKE '%xxx%' OR ...` 跨 3 列前后通配,B-tree 索引救不了),P2 5/5 候选 paper_pdf_view 全 404(`has_fulltext_pdf=True` 但磁盘文件缺失,DB/disk 不一致)。用户指出 `media/papers////.{pdf,xml}` 静态 URL 可直接访问,**且 XML 也走同样路径**(原 paper_pdf_view 只支持 PDF,XML 没任何 API 暴露)。**paper_server 改动**:① `apps/resm/serializers.py` 加两个 SerializerMethodField `pdf_url` / `xml_url`,基于 `obj.publication_date.{year,month,day}` + `safe_doi` 后端拼,用 `request.build_absolute_uri()` 给完整 URL;`has_fulltext_pdf/xml=False` / `publication_date=None`(unknown 目录)返空串避免 LLM 拿到 URL 就 404。② 新 migration `0006_pg_trgm_index.py`:`CREATE EXTENSION IF NOT EXISTS pg_trgm` + 3 列 GIN 索引(`title` / `first_author` / `first_author_institution`),把 ILIKE '%xxx%' 全表扫降到几十 ms。**zcbot 改动**:① `paper.py` `_LIST_FIELDS` 扩到 16 字段(加 `publication_date` / `has_fulltext_xml` / `pdf_url` / `xml_url`);② 新加 `fetch_xml(id_or_doi, working_dir) -> str` helper,对称 `fetch_pdf` 但**走 paper_server media 静态直链**(从 `paper["xml_url"]` 读),`has_fulltext_xml=False` 或 `xml_url` 空都抛 `RuntimeError`;`fetch_pdf` 抽出 `_stream_to(url, dest)` 共用 helper;③ `SKILL.md` 工作流加 "XML 优先 PDF" 原则(XML 已结构化:章节/参考文献/caption 有标签,LLM 友好,免 OCR / PDF 文本抽取)+ fetch_xml 文档 + 错误处理表更新;④ `smoke_paper_skill.py` 新增 step 0(trgm 速度验证,>5s 警告 migration 没跑生效)+ step 4(fetch_xml 链路 ×2 复用 + 多关键词候选轮询)+ step 1 字段集 expected 同步扩到 16。**对比方案**:① abstract 从 list 移除以缓解 P1 —— 复盘 P1 根因是 SearchFilter 跨列 ILIKE 跟 abstract 字段无关,移了不解决问题反失收益,**不动**;② pdf_url 走 paper_pdf_view 端点 —— 仍依赖 `has_fulltext_pdf` 预检 DB 标记,直链跳过 DB 标记,disk 在就能下 + 同时让 XML 走同一范式;③ pg_trgm 索引用 `CONCURRENTLY` 不锁表 —— Django migration 默认 atomic 不支持,且 dev 期表行数小普通 CREATE INDEX 秒级,不必引入 `atomic = False` 复杂度。**Tradeoffs**:① URL 直链字段是后端拼,paper_server media 路径若改 → serializer 同步改一处,前向兼容性强于客户端拼;② pg_trgm 扩展需 superuser 权限一次性安装,普通用户运行 migration 报权限错时手动 `psql -c "CREATE EXTENSION pg_trgm;"` 即可;③ fetch_xml 不走 paper_pdf_view 范式 —— 因为没对应 API,且静态 URL 不依赖 `has_fulltext_xml` DB 标记预检(LLM 能跳过 stale 标记拿到 disk 上的文件);④ P2 数据一致性问题(DB 标 has_fulltext_pdf=True 但 disk 文件丢)**本次不修**,属于 paper_server 抓取 pipeline 的事,smoke 里已加多候选轮询逻辑容忍。**没动**:Paper model / views.py(retrieve mixin / select_related / filterset_class 已在前一条改完)/ urls / paper_pdf_view / `search_fields` / `_resolve_to_id` / DESIGN / RUN。**遗留**:paper_server 两文件(serializers.py + migration 0006)落地,由用户 redeploy + 跑 `python manage.py migrate resm`(migration 0006 会装 pg_trgm 扩展 + 建 3 个 GIN 索引)。 +- **research skill 三次迭代:fetch_pdf 改走静态直链跟 fetch_xml 对齐(绕开 paper_pdf_view 路径 bug)**:二次迭代 redeploy + nginx 修(`proxy_set_header Host $http_host` 透传含端口的 Host)后跑 smoke 发现:fetch_xml 5/5 PASS,fetch_pdf 5/5 仍 404 —— 同一批 cement paper 同一目录,XML 能走 nginx 静态 serve 下,PDF 走 paper_pdf_view 端点全 404。用户确认 "has_fulltext_pdf=True 就保证 disk 有 PDF",证实是 paper_pdf_view 的 `init_paper_path` 计算的 disk 路径与实际不一致(非数据问题,是端点 bug)。**修法**:`paper.py::fetch_pdf` 改成跟 `fetch_xml` 同范式 —— 从 `paper["pdf_url"]` 读静态直链 + 走 `_stream_to`,删原 `/resm/paper//pdf/` 调用;`has_fulltext_pdf=False` / `pdf_url` 空 → 抛 RuntimeError(契约与 fetch_xml 一致)。删常量 `_PDF` 不再被引用。SKILL.md 同步描述,smoke step 3 提示文案更新。**验证**:smoke 跑通 5.4MB PDF / 3843ms 下载 + 152ms 复用,5/5 候选 100% 成功(对照之前 5/5 全 404)。**没动**:paper_pdf_view 端点本身(浏览器用户 inline disposition 可能仍在用,这是 paper_server 内部端点 bug 由用户后续修);`fetch_xml` / `_resolve_to_id` / `_stream_to` / SerializerMethodField 后端拼 URL 逻辑;PaperListSerializer / migration 0006 / nginx 配置(已生效)。**Tradeoff**:① helper 不再依赖 paper_pdf_view 的 has_fulltext_pdf 二次预检,只信 list/retrieve 返的字段 + nginx static serve(透传 404 即客户端 raise);② paper_pdf_view bug 还在,但不影响 zcbot 工作流(zcbot 走静态直链),由 paper_server 自己后续处理。 + +- **research skill 二次迭代:list 端点加 pdf_url / xml_url 直链 + 新增 fetch_xml + pg_trgm GIN 索引根治 SearchFilter timeout**:首版 redeploy 后跑 `scripts/smoke_paper_skill.py` 发现两个 paper_server 既有问题 — P1 `?search=cement` 不带 filter 30s timeout(`title ILIKE '%xxx%' OR ...` 跨 3 列前后通配,B-tree 索引救不了),P2 5/5 候选 paper_pdf_view 全 404(`has_fulltext_pdf=True` 但磁盘文件缺失,DB/disk 不一致)。用户指出 `media/papers////.{pdf,xml}` 静态 URL 可直接访问,**且 XML 也走同样路径**(原 paper_pdf_view 只支持 PDF,XML 没任何 API 暴露)。**paper_server 改动**:① `apps/resm/serializers.py` 加两个 SerializerMethodField `pdf_url` / `xml_url`,基于 `obj.publication_date.{year,month,day}` + `safe_doi` 后端拼,用 `request.build_absolute_uri()` 给完整 URL;`has_fulltext_pdf/xml=False` / `publication_date=None`(unknown 目录)返空串避免 LLM 拿到 URL 就 404。② 新 migration `0006_pg_trgm_index.py`:`CREATE EXTENSION IF NOT EXISTS pg_trgm` + 3 列 GIN 索引(`title` / `first_author` / `first_author_institution`),把 ILIKE '%xxx%' 全表扫降到几十 ms。**zcbot 改动**:① `paper.py` `_LIST_FIELDS` 扩到 16 字段(加 `publication_date` / `has_fulltext_xml` / `pdf_url` / `xml_url`);② 新加 `fetch_xml(id_or_doi, working_dir) -> str` helper,对称 `fetch_pdf` 但**走 paper_server media 静态直链**(从 `paper["xml_url"]` 读),`has_fulltext_xml=False` 或 `xml_url` 空都抛 `RuntimeError`;`fetch_pdf` 抽出 `_stream_to(url, dest)` 共用 helper;③ `SKILL.md` 工作流加 "XML 优先 PDF" 原则(XML 已结构化:章节/参考文献/caption 有标签,LLM 友好,免 OCR / PDF 文本抽取)+ fetch_xml 文档 + 错误处理表更新;④ `smoke_paper_skill.py` 新增 step 0(trgm 速度验证,>5s 警告 migration 没跑生效)+ step 4(fetch_xml 链路 ×2 复用 + 多关键词候选轮询)+ step 1 字段集 expected 同步扩到 16。**对比方案**:① abstract 从 list 移除以缓解 P1 —— 复盘 P1 根因是 SearchFilter 跨列 ILIKE 跟 abstract 字段无关,移了不解决问题反失收益,**不动**;② pdf_url 走 paper_pdf_view 端点 —— 仍依赖 `has_fulltext_pdf` 预检 DB 标记,直链跳过 DB 标记,disk 在就能下 + 同时让 XML 走同一范式;③ pg_trgm 索引用 `CONCURRENTLY` 不锁表 —— Django migration 默认 atomic 不支持,且 dev 期表行数小普通 CREATE INDEX 秒级,不必引入 `atomic = False` 复杂度。**Tradeoffs**:① URL 直链字段是后端拼,paper_server media 路径若改 → serializer 同步改一处,前向兼容性强于客户端拼;② pg_trgm 扩展需 superuser 权限一次性安装,普通用户运行 migration 报权限错时手动 `psql -c "CREATE EXTENSION pg_trgm;"` 即可;③ fetch_xml 不走 paper_pdf_view 范式 —— 因为没对应 API,且静态 URL 不依赖 `has_fulltext_xml` DB 标记预检(LLM 能跳过 stale 标记拿到 disk 上的文件);④ P2 数据一致性问题(DB 标 has_fulltext_pdf=True 但 disk 文件丢)**本次不修**,属于 paper_server 抓取 pipeline 的事,smoke 里已加多候选轮询逻辑容忍。**没动**:Paper model / views.py(retrieve mixin / select_related / filterset_class 已在前一条改完)/ urls / paper_pdf_view / `search_fields` / `_resolve_to_id` / DESIGN / RUN。**部署侧遗留(非代码 bug)**:nginx 反代到 paper_server 时缺 `proxy_set_header Host $http_host;`,Django `request.get_host()` 拿到的是不带端口的 `paper.xxhhcty.xyz`,导致 `build_absolute_uri` 出来的 pdf_url / xml_url 漏 `:8080` 端口 → 客户端直链 fetch 失败。修法:nginx location 块加上述 header 透传,reload 即生效,无需 paper_server 重启 / 改代码(代码层面 `build_absolute_uri` 已是标准做法)。 - **顶栏 token 累计修(`sync_task_tokens` 改走 messages SUM,删 `LLM.TokenCounter`)**:用户报"token 计数一直 0"。复盘:5/20 把 loop 切流式后,`LLM.token_counter.add()` 只在同步 `chat()` 路径里调,新走的 `chat_stream()` 路径从来不更新它;`agent_builder.sync_task_tokens(task_state, llm)` 每轮 run 后从 `llm.token_counter.{prompt,completion}_tokens` 读累计 UPDATE 进 `tasks.tokens_prompt/completion` — 内存计数器永远 0 → tasks 行 0/0 → `_task_dict` 顶栏数字 0。DB 验证:5/20 08:55 之前最后一个 task 4568/934,之后所有 task 0/0;但每条 assistant message 的 `tokens_in/out` 都是对的(3223/1014 这种,`record_chat_usage` 在 loop 里写),所以 source-of-truth 在 messages 表完好,只是 task 级冗余汇总列没同步。**修法**:删 `LLM.TokenCounter` 整个类 + `.token_counter` 属性 + `chat()` 里那行 `.add()` 调用;`sync_task_tokens` 改签名为 `(task_state)`(不再要 llm),内部 `SELECT coalesce(sum(tokens_in),0), coalesce(sum(tokens_out),0) FROM messages WHERE task_id=?` 现算后 UPDATE。`ConsoleEventSink` 同步删 `token_counter` 回调参数 + spinner fmt 的 `ctx N tok` 尾巴(CLI 旁路,改动小)。`web/app.py:273` 调用点改 `sync_task_tokens(task_state)`。**对比方案**:① 一行补丁在 `core/loop.py` 拼回 response 后补 `self.llm.token_counter.add(response.usage)` — 最小,但留着 TokenCounter 这个"内存计数器 vs DB 真相"双写源头不解决根因;② 当前方案改 4 文件去掉冗余,符合"开发期以最优实现为准不留兼容层"。**性能**:`(task_id)` FK + `uq_messages_task_idx` 复合索引,单 task 行数顶天几百,SUM 两 int 亚毫秒,在刚跑完几秒 LLM 的 round-trip 噪声里。**Backfill**:`SELECT task_id, SUM(...) GROUP BY task_id` 一次性把现有 0/0 行修对,4 个 task 累计补正(52943/6593、26191/8687、10138/427、6399/1069)。**没动**:DB schema(`tasks.tokens_prompt/completion` 列保留作汇总展示,只是数据源改 messages 现算)、`record_chat_usage`(per-message 写入逻辑就是真相源)、loop / streaming 流程、DESIGN(§348 描述 `sync_task_tokens 维护` 仍准确,只是实现细节变,不属于架构/schema/API 语义变化)。 diff --git a/scripts/smoke_paper_skill.py b/scripts/smoke_paper_skill.py index d70de30..72b6d0a 100644 --- a/scripts/smoke_paper_skill.py +++ b/scripts/smoke_paper_skill.py @@ -195,7 +195,7 @@ def step3_fetch_pdf(papers: list[dict]) -> None: break except _httpx.HTTPStatusError as e: if e.response.status_code == 404: - print(f"[SKIP] paper_pdf_view 404 — DB has_fulltext_pdf=True 但磁盘文件缺失(paper_server 数据一致性问题,继续下一个)") + print(f"[SKIP] media 静态 URL 404 — disk 文件实际缺失,继续下一个") continue print(f"[FAIL] HTTPStatusError {e.response.status_code}: {e}") break diff --git a/skills/research/SKILL.md b/skills/research/SKILL.md index 968585e..2c9eaac 100644 --- a/skills/research/SKILL.md +++ b/skills/research/SKILL.md @@ -83,9 +83,9 @@ print(paper["abstract"]) ### `fetch_pdf(id_or_doi, working_dir) -> str` -下载 PDF 到 `/papers/.pdf`,返回相对路径 `papers/.pdf`(safe_doi 把 `/` 换成 `_`)。已存在跳过下载直接复用。走 paper_server 的 `/resm/paper//pdf/` 端点(有 `has_fulltext_pdf` 预检)。 +下载 PDF 到 `/papers/.pdf`,返回相对路径 `papers/.pdf`(safe_doi 把 `/` 换成 `_`)。**走 paper_server media 静态直链**(从 list/retrieve 返回的 `pdf_url` 字段),跟 `fetch_xml` 同范式。已存在跳过下载直接复用。 -`has_fulltext_pdf=False` 时抛 `RuntimeError` —— 服务器侧还没下到 PDF。 +`has_fulltext_pdf=False` 或 `pdf_url` 空(publication_date 缺失)→ 抛 `RuntimeError`。 ```python rel = fetch_pdf("10.1016/j.cemconres.2020.106156", working_dir=r"D:/projects/zcbot/workspace/users//") diff --git a/skills/research/paper.py b/skills/research/paper.py index 0f623ff..80affae 100644 --- a/skills/research/paper.py +++ b/skills/research/paper.py @@ -9,7 +9,6 @@ import httpx _BASE_URL = os.environ.get("PAPER_SERVER_URL", "http://paper.xxhhcty.xyz:8080").rstrip("/") _API = f"{_BASE_URL}/api/resm/paper" -_PDF = f"{_BASE_URL}/resm/paper" # /resm/paper//pdf/ _TIMEOUT = 30.0 _LIST_FIELDS = ( @@ -136,18 +135,25 @@ def _stream_to(url: str, dest: Path) -> None: def fetch_pdf(id_or_doi: str, working_dir: str) -> str: """下载 PDF 到 /papers/.pdf,返回相对路径 'papers/.pdf'。 - 已存在跳过下载直接复用。paper.has_fulltext_pdf=False → 抛 RuntimeError。 + 走 paper_server media 静态直链(从 list/retrieve 返回的 pdf_url 字段),跟 fetch_xml 同范式。 + paper.has_fulltext_pdf=False / pdf_url 空(publication_date 缺失时)→ 抛 RuntimeError。 + 已存在跳过下载直接复用。 """ paper = get_paper(id_or_doi) if not paper.get("has_fulltext_pdf"): reason = paper.get("fail_reason") or "no PDF on server" raise RuntimeError(f"paper has no PDF: id={paper.get('id')} reason={reason}") + pdf_url = paper.get("pdf_url") or "" + if not pdf_url: + raise RuntimeError( + f"paper pdf_url unavailable (likely missing publication_date): id={paper.get('id')}" + ) safe = _safe_doi(paper["doi"]) rel = f"papers/{safe}.pdf" dest = Path(working_dir) / rel if dest.exists() and dest.stat().st_size > 0: return rel - _stream_to(f"{_PDF}/{paper['id']}/pdf/", dest) + _stream_to(pdf_url, dest) return rel