Files
news/continuation.md
T
simon ff911cf6f7 feat: Token Plan 迁移与 .env 热加载,并修复日报 AI 摘要为空
Token Plan 迁移 / 配置热加载:
- configs/llm_models.yaml: 各场景切到 Token Plan(deepseek-v4.1-flash / qwen3.6-flash)
- 新增 configs/runtime_env.py: .env 按 (mtime_ns, size) 热加载并同步 os.environ,
  统一 env_get 取值;llm / embedding / vectorstore / mcp / pipeline 改用 env_get
- configs/loader.py / scripts/run_scheduler.py 等配套调整
- 新增 tests/test_hot_reload.py

日报 AI 摘要为空修复(2026-09-25):
- 根因: 推理模型的 reasoning token 与正文共用 max_tokens, 预算 1500 被"思考"
  占满 -> text_tokens=0 / finish_reason=length, 摘要静默为空且不重试
- daily_report 场景新增 max_tokens(默认 4000, YAML 保存即热生效);
  LLMConfig 支持可选 max_tokens; 分块预算 800 -> 2000
- _llm_call 拆出 _call_once, 正文为空时自动加倍预算重试(上限 16000),
  用尽才降级返回空串; 网络异常重试语义不变
- docs/user-guide.md 新增 FAQ; continuation.md 记录本次排查
- 已重跑 2026-09-25 日报(report_id=357)补回 466 字摘要

测试: 相关用例 56 passed(test_hot_reload 12 passed);
      ruff 无新增问题; 3 个 crawler 既有失败与本改动无关
2026-09-25 11:13:37 +08:00

628 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# continuation.md
> `checkpoint` @ 2026-08-22
---
## 本次完成 (2026-09-25) — 日报 AI 摘要为空修复(推理模型 reasoning 占满 max_tokens)
**现象**:用户反馈 2026-09-25 日报没有 AI 摘要。`news_report` 中 `id=357`(2026-09-25, finance) `ai_summary` 为 `NULL`;当天 07:13:55 日志:
```
WARNING | scheduler.reporter:_llm_call - AI 摘要可能被截断: max_tokens=1500 finish_reason=length 实际输出 0 字符
```
**根因(证据链闭合)**:
- 日报场景(daily_report)调用 `deepseek-v4.1-flash`(Token Plan),这是**推理模型**:`reasoning_content` 的 token 与正文**共用** `max_tokens` 预算
- 用当天真实素材原样复现:**`reasoning_tokens=1500` / `text_tokens=0` / `content=0 字符` / `finish_reason=length`** —— 预算被"思考"全部吃掉,正文为空
- 同素材把预算提到 4000:`finish_reason=stop`、reasoning 938 + text 337、摘要 523 字 ✓
- 代码缺陷:`scheduler/reporter.py:_llm_call` 只取 `message.content`,空内容**不抛异常** → `_generate_ai_summary` 返回 `""` → `ai_summary=None` 入库,pipeline 仍标 report ✅(**静默失败、无重试**)
- **非本次 Token Plan 迁移引入**:历史同为空的还有 9-12 / 9-13 / 9-15 / 9-18,当时用的是 `deepseek-v4-flash`(provider=deepseek),同样是推理类模型 → 长期间歇性缺陷
**修复(方案 B:配置化 + 代码兜底)**:
1. `configs/llm_models.yaml`:`daily_report` 新增 `max_tokens: 4000`(注释说明 reasoning 共用预算);YAML 保存即热生效
2. `llm/client.py`:`LLMConfig` 新增可选字段 `max_tokens`;`load_llm_config` 用新增的 `_pick_optional_int` 读取场景配置(未配置 = `None`,调用方回退内置默认)
3. `scheduler/reporter.py`:
- 新增常量 `DEFAULT_SUMMARY_MAX_TOKENS=4000` / `DEFAULT_SUMMARY_CHUNK_MAX_TOKENS=2000` / `MAX_SUMMARY_MAX_TOKENS=16000` / `_MAX_BUDGET_ESCALATIONS=2`
- 新增 `_summary_max_tokens()`(场景配置 > 内置默认)、`_chunk_max_tokens()`(不超过合并预算)
- `_llm_call` 拆出 `_call_once`;保持网络异常指数退避重试语义不变;新增**空正文 + finish_reason=length 时自动加倍预算重试**(上限 16000),用尽后返回空串降级(不抛异常)
- 分块预算 800 → 2000;合并预算 1500 → 配置值(4000)
**验证**:
- 线上复现 → 修复后回归:`uv run a-share report --date 20260925` 重跑,**无截断告警**,`report_id=357` 原地更新(幂等 upsert),`ai_summary` 466 字 ✓
- 测试:新增 `TestReasoningBudgetEscalation`(升级恢复 / 场景值优先 / 用尽降级返回空 / 上限)+ `test_llm.py` 的 `max_tokens` 场景配置、`_pick_optional_int`、真实 YAML 预算 ≥4000 回归保护 → `tests/test_report_builder.py tests/test_llm.py` **56 passed**
- `ruff` 改动文件无新增问题(5 条 N806/SIM115 为 reporter.py 既有);`mypy` 仅剩 `llm/client.py:86` 既有告警
**运维动作**:`report` 步骤由调度器**进程内**执行,已 `sudo systemctl restart a-share-research`(10:55,重启后任务同步正常),明早 07:00 起生效。
**遗留与后续**:
- `scheduler/stock_reporter.py:285` 个股日报 `max_tokens=500`,配置模型 `qwen3.6-flash` 亦属推理类,**同类隐患**(当前个股日报禁用);启用前建议一并按本方案处理
- 空摘要目前只降级为"无摘要",未做告警;可考虑连续 N 天为空时推送通知
- 本次改动尚未 git commit(工作区还混有 9-22 Token Plan 迁移的未提交改动,避免混提)
---
## 本次完成 (2026-09-10) — cninfo 抓取压穿内存导致整机冻结的修复
**现象**:2026-09-06 / 09-08 / 09-10 连续三次早上 06:0x 整机冻结,看门狗(硬件 2min)硬复位。
**根因(证据链闭合)**:
- 三次冻结时刻 = `cninfo` 公告管道 06:00 定时任务:`logs/scheduler_error.log` 显示 09-10 06:00:03.180~.888 **0.7 秒内打印 15 条「抓取…公告」**(15 只股票协程同时进入渲染),随后日志全无直到 06:14:08 重启
- `data/raw/cninfo/` 缺 20260906/08/10 三个目录(存续目录 mtime 均为 06:02,而落盘在 `_save_items()` 中、抓取全部完成后才执行 → 崩在写数据之前)
- `pcp-pmie` 09-10 06:01:20 报 **load 65**(4 核);DNS 全面超时(frpc/dockerd resolver)
- `journalctl --list-boots` 与三次重启时刻吻合
**代码缺陷**(`crawler/cninfo.py`):
1. `_render_page` **每次调用都 `async with AsyncWebCrawler(...)` 新建完整 Chromium**(最贵的错误)
2. `crawl_watchlist` 用 `asyncio.as_completed` 对 watchlist **全量并发**(15 只)
3. **无任何并发限制**;service 亦无资源限制(`CPUQuota=infinity`、`TasksMax=9626`)
4. 本机 `cgroup_disable=memory` → **`MemoryMax` 不可用**(只会 `cpuset cpu io pids`)
**实测(本机 8GB)**:基线 chrome=0 → **2 并发峰值 1713MB / 20 进程 = 857MB/实例**;外推 15 并发 ≈**12.5GB** ≫ 7.9GB RAM → 必然压穿(时好时坏是 zram 与时序侥幸)
**修复(第一步:限并发)**:
- `crawler/cninfo.py`:新增 `MAX_RENDER_CONCURRENCY`(默认 **2**,env `CNINFO_RENDER_CONCURRENCY` 可覆盖)+ 模块级 `_render_sem` 信号量,`_render_page` 全程持槽(含浏览器启停)
- 顺带清理 3 个既有死导入(`time`/`datetime`/`Any`)
**实测验证(2026-09-10 20:43 实跑 `a-share cninfo`)**:
| 指标 | 修复前(15 并发) | 修复后(2 并发) |
|------|----------------|---------------|
| Chromium 内存峰值 | ≈12.5GB(外推) | **1796 MB** |
| 进程峰值 | ≈150 | **22** |
| load 峰值 | **65** | **2.75** |
| available 最低 | 压穿冻结 | **3561 MB** |
| 耗时 | 崩(无 END) | **441 s**(上限 900s) |
| 结果 | 无数据 | 抓 34 条/存 23 条,补上 09-10 缺失目录 ✅ |
**测试**:新增 `tests/test_cninfo.py` 8 个(并发上限/串行/异常释放槽位/env 解析);全量 **291 passed**(3 个 crawler 基线失败无关);ruff 干净
**遗留与后续优化**:
- 耗时由 127~145s 增至 441s(用时间换内存安全),`cninfo_crawl` 超时 900s 余量由 6 倍降至 2 倍;如嫌慢可 `CNINFO_RENDER_CONCURRENCY=3`(峰值约 2.6GB,仍安全)
- **第二步(未做)**:复用单个 `AsyncWebCrawler` + `arun_many()` 批量渲染(浏览器组 15→1,可同时提速降内存)
- **第三步(未做)**:`_fetch_irm_requests()` 是同步 `requests.get` 却在 async 中直接调用,**阻塞事件循环**;可改 `asyncio.to_thread`
- 长期:改用 cninfo 公告 POST JSON API(`hisAnnouncement/query`)彻底去掉浏览器依赖
- 未执行 systemd 资源限制:`CPUQuota` 对内存型崩溃基本无效(反而延长驻留),`TasksMax` 过小会让抓取永久失败
---
## 本次完成 (2026-08-23) — MCP 新闻查询服务确认与修复
**用户需求**:实现新闻查询 MCP 服务(阅读文档步骤,确认是否已实现)。
**确认结论:核心实现完整且可用(此前已实现,本次验证 + 修复一处配置错误)**:
| 文档要求 | 现状 | 验证 |
|----------|------|------|
| `scripts/run_mcp_server.py`(stdio/SSE 入口) | ✅ 存在 | stdio 端到端调用通过 |
| `mcp_server/tools.py` 5 个工具 | ✅ 齐全 | `tools/list` 返回 5 工具,`tools/call` 真实检索成功 |
| pyproject 依赖 `mcp>=1.0` | ✅ 已声明 | 已安装 |
| `tests/test_mcp.py` | ✅ 10 个测试 | 全过 |
| `.mcp.json`(Claude Code 配置) | ❌ **配置错误** | 见下 |
**发现并修复的差距**:
- `.mcp.json` 被跟踪的内容是 **Mac 上另一项目(serena)的残留配置**(路径指向 `/Users/summer/...`),与本项目无关 → 按 `docs/user-guide.md` 8.2 重写为 `a-share-research` 配置
- 补充 1 个测试(`test_fmt_results_multi_source_tag`,验证 A2 多源展示)
**端到端验证**(真实调用,非 mock):
1. 5 个工具函数直接调用:全部返回检索结果 ✓
2. SSE 模式 `--sse 8765`:`GET /sse` 返回 `event: endpoint` 握手 ✓
3. **MCP 协议层**(stdio 客户端 → `scripts.run_mcp_server`):`tools/list` 返回 5 个工具与文档一致;`tools/call search_news("国务院常务会议")` 命中《李强主持召开国务院常务会议》等 ✓
**全量测试**:283 passed(新增 1 个多源展示),3 个 crawler 基线失败与本次无关;ruff 干净
**遗留**:无(仅既有 reporter 5 个 ruff 问题、Qdrant 本地模式性能警告)
---
## 本次完成 (2026-08-23) — P1-2/P1-3 补跑步骤与时区一致性修复
**P1-2(守护进程补跑误含 cninfo 三步)**:
- 原因:`scripts/run_scheduler.py` 启动补跑逻辑 `steps = [k for k in STEP_COMMANDS if k != "report"]` 只排除 report,**漏掉 cninfo_crawl/cninfo_extract/cninfo_pdf**;错过定时任务重启补跑时会额外执行整套公告管道(且 cninfo_crawl 无 --date,补跑历史日期静默空转)
- 修复:提取共享常量 `scheduler.pipeline.DEFAULT_NEWS_STEPS`(= 全链路去除 report 与 cninfo 三步),定时任务、补跑、run_pipeline 默认三处统一引用
**P1-3(调度时区与 date.today() 不一致)**:
- 原因:cron 触发器显式用 `Asia/Shanghai`,但 `date.today()`/`datetime.now()` 取**系统时区**;若系统时区非上海(如容器 UTC),07:00 上海(=前一日 23:00 UTC)触发时日期会错一天,整条 pipeline 落错日目录
- 修复:新增 `scheduler/timeutil.py`(schedule_tz/today_str/now,env `SCHEDULE_TZ` 可覆盖,默认 Asia/Shanghai);`run_scheduler` 定时/补跑/`--once`、`pipeline.run_step` crawler 补跑保护、`reporter.generate_report` 兜底日期全部改用它
**测试**:
- 新增 6 个(DEFAULT_NEWS_STEPS 不含 cninfo/run_pipeline 默认步骤行为/补跑源码引用/时区两例/--once 缺省日期),并入 `tests/test_incremental.py`
- 全量 **282 passed**,3 个 crawler 基线失败与本次无关;ruff 干净(5 个 reporter 既有问题未动)
**验证**:DEFAULT_NEWS_STEPS = [crawler, xwlb, extractor, dedup, llm, embedding, qdrant] ✓;today_str = 20260823(Asia/Shanghai)✓
---
## 本次完成 (2026-08-22) — 多源新闻记录链路修复(方案 A + B)
**用户需求**:一条新闻有多个来源时,全部来源都要记录并可见;此前"找不到多源"。
**诊断结论**:
- M3→M4 多源记录逻辑**本身正常**:指纹库 227 条多源、当日 sources.json 11 条、events 313 条全含 sources、MySQL news_event.sources 填充率 100% 且有 7 条真实多源(如许家印案 `["cls","sina"]`)
- 用户"看不到"的原因:① 展示层(日报 HTML/CLI/MCP)只渲染单源 `source_id`;② 知识库链路 M5→M6 **真断点**——`EmbeddingResult` 无 `sources` 字段、Qdrant payload 只写 `source_id`、`SearchResult` 无 `sources`
**修复内容**:
- B1 模型层: `embedding/models.py` `EmbeddingResult.sources` + validator(主源居首/去重/旧产物兜底);`vectorstore/models.py` `SearchResult.sources`
- B2 `scripts/run_embedding.py`: `_build_text_from_event/_build_text_from_article` 透传 sources 进 EmbeddingResult
- B3 `scripts/run_qdrant_ingest.py`: payload 写 `sources`(M5 产物 → 回查 M4 → 兜底 [主源])
- B4 `vectorstore/client.py`: 检索读取 payload.sources
- A1 `scheduler/reporter.py`: 日报 HTML 多源时显示「财联社 / 新浪 📰」
- A2 `a_share_cli/main.py` + `mcp_server/tools.py`: 检索展示「cecn / cscn [多源]」,MCP 返回 sources 数组
- 存量回填 `scripts/backfill_qdrant_sources.py`(新):以指纹库 source_ids 为权威源回填 Qdrant payload
- ⚠️ 坑:本地文件模式 Qdrant 写入逐点 ≈0.4~0.65s,**全量 3.3 万条回填需 4~6 小时**且占用单进程锁,不可行
- 改为**定向回填**:只有多源 point 需要 sources(单源点展示层兜底 source_id),仅回填指纹库 227 条多源 → **1m57s 完成 226 条**(1 条不在集合)
- 执行时机:避开定时调度窗口(22:00 pipeline 结束后 22:34 执行)
**验证**:
- 新增 `tests/test_multisource.py` 10 个(validator/`_result_to_point` 三优先级/文本构造透传/回填加载)
- 全量 **276 passed**(3 个 crawler 基线失败与本次无关);ruff 干净(reporter 5 个 N806/SIM115 为既有问题未动)
- 今日 M5/M6 重跑:313 条全含 sources(11 条多源);CLI 检索显示「来源: cecn / cscn [多源]」✓,MCP 返回 `sources: ['cecn','cscn']` ✓
- 存量回填:226 条多源 point payload 已补 sources;CLI 检索「碧根果反倾销」显示「来源: cnstock / cscn [多源]」✓;日报 HTML 冒烟测试多源渲染「财联社 / 新浪财经 / 东方财富 📰」✓
**待办/遗留**:
- 单源存量 point 无 sources 字段(展示层兜底 source_id,行为不变,如需统一可后续补)
- P1-2(补跑 steps 含 cninfo)、P1-3(时区一致性),待用户决策
---
## 本次完成 (2026-08-22) — P1-1 修复 dedup 高重复率返回码语义
**问题**:`run_dedup` 重复率 > 5% 时返回 1;`scheduler/pipeline.py` 曾把 `dedup` 的 rc=1 无条件视为成功并打日志"无新数据场景"。结果:高重复率(可能是正常无新数据,也可能是抓取源/指纹库异常)被一刀切掩盖,真实异常无法上报。
**修复(分离"执行成功"与"统计告警")**:
- `scripts/run_dedup.py`:
- 生产模式(默认):重复率仅作 **WARNING 告警**,不影响退出码(执行成功即 0);阈值常量 `_DUP_RATE_THRESHOLD = 0.05`
- 新增 `--strict` 验收模式:保留 M3 验收门槛,重复率 > 5% 时返回 1(供人工验收)
- 新增统计快照 `data/deduped/{date}/stats.json`(原子写):unique/duplicates/total/dup_rate/layers/fingerprint_total/generated_at,供运维排查与监控
- 顺带修复:空日场景 `sources.json` 写入前补 `mkdir`(原会 FileNotFoundError)
- `scheduler/pipeline.py`:移除 `dedup` rc=1 特判,恢复"非 0 即失败"的统一语义
**测试**:
- 新增 `tests/test_run_dedup.py` 7 个(生产模式高重复返回 0 / --strict 高重复返回 1 / --strict 低重复返回 0 / 空日返回 0 / stats.json 结构与数值 / pipeline 不再掩蔽失败)
- 全量 **266 passed**,3 个 crawler 基线失败与本次无关
**生产实测**:同日重跑重复率 100% → 生产模式 `rc=0` + WARNING + stats.json 完整;`--strict` 同场景 `rc=1`(用非管道方式核实退出码)
**待办/遗留**:
- P1-2(守护进程补跑 steps 含 cninfo 三步)、P1-3(调度时区与 date.today() 一致性),待用户决策
---
## 本次完成 (2026-08-22) — P0-3/P0-4 补跑日期语义修复
**P0-3(crawler 补跑历史日期静默空转)**:
- 原因:`crawler/storage.py` 落盘硬编码 `date.today()`(engine 不传 day),`pipeline --once --date {历史}` 时下游读历史目录为空,且 `run_extractor` 对空目录返回成功 → 全链路静默空转
- 修复:`run_step()` 开头加补跑保护——`date_str != 今天` 时跳过 crawler(首页只含当天内容,历史文章已滚走,补抓不可能),WARNING 日志 + `tail_msg` 说明,返回 success(补跑复用已有 raw 数据是正确行为);xwlb 不跳过(API 支持任意历史日期)
**P0-4(report 硬编码 date.today() 忽略传入日期)**:
- 原因:`run_step("report")` 未使用入参 `date_str`,取当前时间;补跑历史日期+--report 会生成今天的空日报并覆盖当天已有日报(唯一键 file_name='')
- 修复:一行改动 `report_date = date_str`;`reporter.generate_report(day_str)` 本身已支持任意日期,无需其它改动
**测试**:
- 新增 4 个(补跑跳过不执行子进程/当天正常执行/report 收到传入日期/全链路仅 crawler 跳过),并入 `tests/test_incremental.py`
- 修正 3 个既有测试(`tests/test_scheduler.py`)适配新行为(用当天日期测正常执行路径)
- 全量 **259 passed**,3 个 crawler 基线失败与本次无关
**待办/遗留**:
- ~~P1-1(dedup rc=1 静默转成功)~~ 已修复(见上方小节);余:守护进程补跑 steps 含 cninfo_*、调度时区与 date.today() 一致性,待用户决策
- 既有小问题:`tests/test_scheduler.py` 部分 run_pipeline 测试未传 state_path,会写真实 `data/pipeline/state.json`(既有行为,未改)
---
## 本次完成 (2026-08-22) — xwlb 日期错位修复(方案 A)
**背景问题**:
- P0-1: `run_xwlb` 硬编码抓前一天并落盘到「数据日」目录,而下游 extractor 按「处理日」扫目录 → 60 天联播数据从未进入知识库(实测: `data/processed/xwlb/` 仅 20260622 一天)
- P0-2: `run_xwlb` 用 `open("a")` 裸追加,同一天被多次调度触发导致 index.jsonl 重复行(实测 20260821: 70 行仅 24 唯一)
**业务约束(保持不变)**: 当天日报需要前一天晚上(19:00 播出)的新闻联播内容。
**方案 A 实施**:
1. `scripts/run_xwlb.py` 重构:
- 新增 `--date` 参数(处理日,默认今天),与管道其它步骤日期语义一致
- 内部 `数据日 = 处理日 - 1`(业务约束:抓前一晚已播出的联播)
- **落盘目录改为处理日** `data/raw/xwlb/{处理日}/` → extractor/dedup/llm/embedding/qdrant 零改动即可处理
- **幂等落盘**: 落盘前读已有 index.jsonl 构建 `url_hash` 集合,已存在条目跳过;index.jsonl 重写为去重完整集(原子写),不再裸追加
2. `scheduler/pipeline.py`: xwlb 步骤命令加 `--date {date}`
3. `scripts/run_extractor.py`: xwlb 假 HTML 无时间节点 → 新增 `_fill_xwlb_publish_time`,从 url(`xwlb://YYYY-MM-DD/sid`)兜底解析真实播出日填充 `publish_time`(否则检索时间过滤/排序失效)
4. **历史死数据 A1 清理**: 删除 `data/raw/xwlb/` 62 个旧目录(20260622~20260822)+ `data/processed/xwlb/`(仅 20260622)
**验证(生产端到端,20260822)**:
- 新增 `tests/test_run_xwlb.py` 9 个测试(处理日目录、幂等、空数据、坏日期),全部通过
- 全量测试 **255 passed**(3 个 crawler 基线失败与本次无关)
- 实跑: 处理日=20260822 抓数据日 20260821 联播 23 条 → 二次运行新增 0/跳过 23(幂等)→ M2 提取 23/23 → M3 唯一 23 → M4 LLM 23/23 → M5 23/23 → M6 Qdrant 净增 23
- 检索验证: `a-share search "国务院常务会议"` top2 命中 xwlb《李强主持召开国务院常务会议》,时间 2026-08-21(真实播出日)✓
- 日报路径不受影响(仍走 `reporter._collect_xwlb` API 直读)
**运维规则新增**: 每次代码升级完成后必须执行 `sudo systemctl restart a-share-research.service`
**待办/遗留**:
- ~~P0-3(crawler 无 --date,补跑历史日期静默空转)、P0-4(report 硬编码 date.today())~~ 已修复(见上方小节)
- P1(dedup rc=1 静默/补跑含 cninfo/时区)未修,待用户决策
- `run_extractor --force` 重提取会重复追加 `data/processed/{src}/{date}/index.jsonl`(仅影响该辅助索引,下游读 *.json 不受影响),暂不修
- 历史 60 天联播未入库(按用户决策 A1 清理,不回补)
---
## 本次完成 (2026-08-22) — 文档清理与重构
**目标:** 清理历史上多个 AI agent 文档残留,重构项目文档为 4 个核心文件。
**删除的过时/残留文件 (4 个):**
1. `project_plan.md` (700 行) — 9 个 Milestone 全部完成,计划文档已无价值
2. `docs/agent_prompt.md` (80 行) — Cherry Studio Agent 使用说明,非本项目核心文档
3. `docs/optimization_plan.md` (144 行) — 8 项优化中 5 项已完成,剩余不再跟踪
4. `docs/report_db_design.md` (330 行) — M10 中间设计稿,逻辑已下沉到代码
**新建/重写的文档 (5 个):**
| 文件 | 行数 | 说明 |
|------|------|------|
| `docs/architecture.md` | ~500 | 项目架构:11 包职责与导出 API、核心数据模型、配置体系、产物目录 |
| `docs/user-guide.md` | ~500 | 用户手册:15 个 CLI 子命令全量 + 增量/断点续跑 + MCP + systemd + FAQ |
| `README.md` | ~120 | 精简为项目入口:简介 + 文档索引 + 命令速查 |
| `docs/db-schema.md` | 保留 | 日报表结构(API/前端对接,无需改动) |
| `deploy/README.md` | 删除 | 已在服务器上直接修改,无需部署文档 |
**docs/ 最终目录:**
```
docs/
├── architecture.md # 项目架构(新增)
├── user-guide.md # 用户手册(重写)
└── db-schema.md # 日报表结构(保留)
```
**验证:**
- 所有文档中文撰写,Markdown 格式,层次清晰
- architecture.md 准确反映 11 个包的实际公开 API(基于源码 `__init__.py` 导出)
- user-guide.md 覆盖全部 15 个 CLI 子命令(crawl/extract/dedup/events/embed/ingest/pipeline/search/status/report/report-import/cninfo/stock-report/watchlist/discover/add-entry)
- 无过时信息残留(不再提 M9 Agent/HTML 日报/旧版 Mac 部署等)
**待办:**
- git 提交本次改动(包括 2026-08-12 增量处理改动,仍未提交)
- 同步 pi5(代码 + 文档),重启 a-share-research
---
---
## 当前状态
| 项目 | 值 |
| --- | --- |
| 新闻源 | 14 个(13 Web + 1 API: xwlb 新闻联播) |
| Qdrant | 本地文件模式 `data/qdrant_storage/` |
| 日报 | **M10 完成并已部署 pi5: 结构化入库 MySQL;日报按当天日期生成(新闻 30h 回溯 / xwlb 取前一日 / 公告调研近 15 日)** |
| DB 连接 | pi 上 systemd 服务 `a-share-db-tunnel` 常驻(0.0.0.0:13306 → doorcome.cn:3306);**pi5 直连 192.168.1.10:13306** |
| 调度器 | APScheduler,systemd `a-share-research.service`(pi5);每天 07:00 首次任务生成日报(12/18/22 点不生成) |
| LLM | 场景化配置 `configs/llm_models.yaml`(4 场景: event_extraction/daily_report/stock_report/embedding);YAML 优先、`.env` 兜底;模型必须显式配置,无内置兜底 |
| 去重 | 多源记录:指纹库 `source_ids` 列 + uniques JSON `sources` 字段 + `data/deduped/{day}/sources.json` |
| 增量/断点 | M2/M4/M5 产物存在即跳过(`--force` 全量);`pipeline --once --resume` 断点续跑(状态 `data/pipeline/state.json`) |
| 服务器 | `pi@192.168.1.160`(生产)/ `pi@192.168.1.10`(DB 隧道宿主) |
| 抓取方式 | js_render=false → httpx 直连;js_render=true → Playwright |
---
## 本次完成 (2026-08-12) — 增量处理与 pipeline 断点续跑
**目标:** ① 全链路中断后可从断点恢复;② 各子任务排除已处理文件,避免全量重跑与重复 API 计费。
**1. 各步骤增量处理(产物存在即跳过,`--force` 全量):**
- M2 `run_extractor.py`:输出目录已有 `{url_hash}.json` 即跳过提取,仅回补 index 行;`--force` 重建;成功率统计含跳过项(修复全跳过时误报 rc=1)
- M4 `run_event_extraction.py`:`data/events/{day}/{url_hash}.json` 已存在即跳过(**不重复调用 LLM API**);`--force` 全量;failed.jsonl 只保留本次失败、index 累积追加
- M5 `run_embedding.py`:`data/embeddings/{day}/{url_hash}.json` 已存在即跳过(**不重复调用 embed API**);`--force` 全量
- M1(seen_urls 增量)/ M3(指纹库判重)/ M6(upsert 幂等)为既有能力,README 汇总成表
**2. pipeline 断点续跑(scheduler/pipeline.py + run_scheduler.py):**
- 新增 `data/pipeline/state.json`(按日期隔离,记录每步骤 ok/failed + 退出码 + 耗时),原子写
- `run_pipeline(resume=True)` 跳过连续成功前缀,从首个失败/未执行步骤继续执行到结尾
- `pipeline --once --resume`(默认全量不变;`--resume` 与 `--steps` 互斥报错);定时守护模式不受影响
**验证(Mac 本地,20260616 数据 100 篇):**
- pytest **225 passed**(新增 tests/test_incremental.py 10 个:M2/M4/M5 跳过、状态记录、resume 续跑、resume 全完成 noop、--resume+--steps 互斥);crawler 3 个基线失败仍与本次无关
- ruff 零新增(9 个基线错误不变)
- 端到端:M2 增量重跑 140 条跳过 126 条,2.5s 完成、rc=0(修复前误报失败);state.json 正确记录 extractor ok
**效果评估(100 篇规模中断重跑场景):**
- M4 减少约 100 次 LLM API 调用、M5 减少约 100 次 embedding API 调用 → 中断恢复不再重复计费,耗时从分钟级降至秒级
- M2 重跑从全量 GNE 提取(分钟级)降至约 2.5s
- 断点恢复操作:中断后直接重跑同一条 `pipeline --once --resume` 命令即可
**待办:**
- 同步 pi5(代码 + 文档),重启 `a-share-research`;首次同步后 pi5 的 `data/pipeline/state.json` 不存在 → resume 按全量处理,行为安全
- git 提交(本次改动尚未提交)
---
## 本次完成 (2026-08-05) — 日报可靠性修复与取数逻辑优化
**目标:** 解决日报 AI 摘要偶发失败;修正日报日期与 xwlb/新闻取数语义。
**1. AI 摘要可靠性(llm/client.py + scheduler/reporter.py):**
- 去掉内置默认模型兜底(`deepseek-chat`/`qwen-plus`),模型必须显式配置(`DEEPSEEK_MODEL`/`QWEN_MODEL` → `LLM_MODEL`),缺失即报错,避免静默用错模型
- `_llm_call` 增加指数退避重试:`_LLM_RETRY_TIMES`(默认 3 次)、`_LLM_RETRY_BACKOFF_SEC`(默认 2.0s,可 .env 覆盖),全部失败才抛异常
- 确认 AI 摘要模型:`deepseek` + `deepseek-v4-flash`(生产实测)
**2. 日报取数逻辑(scheduler/pipeline.py + reporter.py):**
- pipeline report 步骤:`report_date = date.today()`(原为昨天+回溯 3 天)
- `_collect_news_events`:读当天+前一天事件目录,按 `publish_time` 过滤最近 30 小时(`_NEWS_LOOKBACK_HOURS=30`);时区统一(naive 假定本地时区);无时间戳事件保留
- `_collect_xwlb`:固定查 `day_str` 前一日(《新闻联播》19:00 播出,早间日报只能取昨晚已播出的一期);`source_date` 同步为实际来源日
- **公告/调研/互动保持原设置:近 15 日(`STOCK_REPORT_DAYS=15`),irm 互动仍跳过**——未受 30h 改动影响
**验证(pi5):**
- 单测 9 个(30h 回溯/带时区时间戳/重试/模型缺失报错/xwlb 前一日)全部通过;全量 212 passed + 3 crawler 预存在失败
- 生产端到端 report_id=191(2026-08-05):news 20 + cninfo 20 + xwlb 34(08-04 联播),AI 摘要 2076 字
- 生产服务已重启生效
**本次代码尚未 git 提交(见待办)。**
---
## 本次完成 (2026-08-03 22:00) — pi5 部署与生产测试
**操作:** M10 代码全量同步 pi5 + 生产环境验收(所有测试在 pi5 执行,Mac 不再作为测试环境)。
**文件同步:** rsync 本地 → `pi5:/home/pi/news/`(排除 .venv/data/logs/.git/.env/configs/*.yaml);清除 pi5 根目录 6 月 17 日旧版散文件(已 tar 备份 /tmp/news_backup_20260803.tar.gz);pi5 `uv sync` 装 pymysql。
**DB 隧道修复:** pi 原 autossh 参数 `-L 13306:0.0.0.0:3306` 未生效(0.0.0.0 被当远端目标),改为 `-L 0.0.0.0:13306:127.0.0.1:3306` 并持久化为 systemd 服务 `a-share-db-tunnel`(enabled + active)。
**测试结果(pi5):**
- 全量 pytest:**208 passed, 3 failed**(3 个失败均为 crawler retry mock 预先存在问题,与 M10 无关)
- `report-import` 幂等:已存在文件正确 skipped
- 生产端到端:`a-share report --date 20260802/20260803` → report_id=181/182 入库成功(AI 摘要正常,57/58 事件)
- DB 总量:finance 52 + intl 128 = 180 行(历史 177 + 端到端测试 2 + 本机 1)
- 生产服务 `a-share-research` 已重启 active,22:00 定时任务起用新代码
**已知问题:**
- `tests/test_crawler.py` 3 个 retry 测试失败(预先存在,crawl4ai mock 行为)
- Mac 本机 anaconda/uv python 出站到 192.168.1.10 被拦截(EHOSTUNREACH;nc/bash/系统 python 正常)——仅影响本机,pi5 不受影响;Mac 本地用 `127.0.0.1` + ssh 隧道绕过
- 生产 pi5 的 `.env` 里 `NEWS_DB_HOST=192.168.1.10`(直连);Mac 本地 `.env` 为 `127.0.0.1`(隧道)——**两处 .env 不同,勿互相覆盖**
---
## 本次完成 (2026-08-03) — M10 日报结构化入库
**目标:** 日报前后端分离的数据层——日报内容结构化存入 MySQL(`news_` 前缀表),历史 178 份日报 HTML 解析入库;本项目不做 API/前端(用户另行实现)。
**表结构(myquant 库,见 docs/db_schema.md):**
- `news_report`:主表,唯一键 (report_date, report_type, file_name);`file_name=''` 表示新生成日报(每天每类型一行,重复生成覆盖)
- `news_event`:事件明细,section ∈ xwlb/news/cninfo/intl
**新增/修改文件:**
- `report_db/`(models/schema/db):连接、建表、幂等 save_report
- `report_import/`(parser/importer):历史 HTML 解析(表头驱动列映射)+ 批量导入
- `scheduler/reporter.py`:`generate_report()` 完全切换为结构化入库;`_render_html/_upload` 标记废弃保留
- `a_share_cli/main.py`:新增 `report-import` 子命令;`report` 命令改为入库提示
- `pyproject.toml`:`uv add pymysql`(唯一新增依赖)
- `.env`:新增 `NEWS_DB_*`(连接 127.0.0.1:13306,需 ssh 隧道)、`REPORT_HISTORY_DIR`
- `docs/report_db_design.md`(实现逻辑)、`docs/db_schema.md`(表结构,供 API/前端)
**验证结果:**
- 历史 178 份全部入库:scanned=178 imported=7(首轮)+170 skipped=171 failed=0;DB 177 行(finance 49 + intl 128,1 个跨目录同名文件被幂等合并)+ 事件 4222 条
- 端到端:`a-share report --date 20260616` → report_id=180 入库成功,无 HTML 产出
- 单测 24 个通过(parser 19 + builder 4 + models/import 补充)
**命令:**
```bash
ssh -L 13306:127.0.0.1:13306 pi # Mac 开发连库隧道
uv run a-share report --date YYYYMMDD # 生成日报入库
uv run a-share report-import # 历史导入(幂等)
```
**注意:** 本机 .env 无 LLM API key,AI 摘要会降级 WARNING(不影响入库);生产 pi5 需配置 NEWS_DB_PASSWORD 且解决 13306 隧道可达性(见待确认项)。
---
## 历史 (2026-07-17) — 禁用个股日报
**操作:** `STOCK_REPORT_TIME=` 设为空,`run_scheduler.py` 加空值守卫。
**文件:**
- `scripts/run_scheduler.py` L127-141:`STOCK_REPORT_TIME` 为空时跳过注册并输出 `已禁用个股日报`
- Pi `.env`:`STOCK_REPORT_TIME=08:00` → `STOCK_REPORT_TIME=`
**恢复方法:** 改回 `STOCK_REPORT_TIME=08:00`,重启 `a-share-research`。
---
## 本次完成 (2026-07-13) — eeo 反爬修复 + 静态直连 + 超时保护
### 问题
eeo(经济观察网)在 Pi 上用 Playwright 连续抓取后触发反爬,服务器故意不响应导致 `page.goto()` 超时(30s → 60s 均超时)。而在本机 Mac 上一切正常——反爬是 **IP 级别**的,Pi 的 IP 已被限流。
### 根因
eeo 是纯静态页面(`js_render: false`),但 Crawl4AI 始终走 Playwright。eeo 检测到 headless Chrome 的连续请求模式后,对后续请求挂起 TCP 连接直至超时。
### 修复
**`crawler/engine.py` — 三个改动:**
1. **新增 `_fetch_static()`**:`js_render=False` 且无 `wait_for` 的源用 `httpx` 直连,绕过 Playwright 反爬。HTML → Markdown 用 `markdownify` + BeautifulSoup 清洗。
2. **新增 `_TimeoutGuard`**:同源连续超时 2 次后自动跳过剩余请求,防止整个 pipeline 被单一故障源拖死。
3. **`_crawl_once` 分流逻辑**:
```
js_render=False 且无 wait_for → _fetch_static() (httpx)
其他 → Playwright
```
**`configs/sources.yaml` — eeo 源两处调整:**
- 删除 `wait_for: "css:body"`(静态页面无需等待 CSS 选择器)
- `page_timeout_ms: 30000 → 60000`(恢复 Crawl4AI 默认值)
### 测试结果
| 环境 | Playwright (修复前) | httpx (修复后) |
|------|---------------------|-----------------|
| Mac 本地 | 全部通过 | 36 篇 / 7 秒 |
| Pi 服务器 | **前 3 个请求全部 15s 超时** | 36 篇 / 9 秒,0 失败 |
### 验证方法
Pi 上本地复现了反爬(Playwright 前 3 个 URL 全部超时),同批 URL 用 httpx 全部瞬间返回——证明方案有效。
---
## 历史 (2026-06-24)
### 1. 环境变量全面审计与修复
**触发:** 服务器 `dedup` 步骤超时 120s。
**修复 6 个文件:**
| # | 文件 | 修改 |
|---|------|------|
| 1 | `scheduler/pipeline.py` | 超时解析重写:`TIMEOUT_{NAME}` > `PIPELINE_STEP_TIMEOUT` > 硬编码 > 1800s |
| 2 | `a_share_cli/main.py` | 新增 `_resolve_timeout()`,CLI 命令超时改为读环境变量 |
| 3 | `crawler/cninfo.py` | `CNINFO_PDF_BASE` 从硬编码改为 `os.environ.get()` |
| 4 | `scheduler/reporter.py` | `STOCK_REPORT_DAYS` 默认值 7→15;`max_tokens` 600→1500 |
| 5 | `.env` | 清除 10 个死配置;`EMBEDDING_MODEL`→`LOCAL_EMBEDDING_MODEL`;补全遗漏参数 |
| 6 | `.env.example` | 重写为仅包含实际生效配置 |
**审计结果:**
| 类别 | 数量 | 处理 |
|------|------|------|
| ✅ 正常工作 | 23 | — |
| ❌ 死配置 | 10 | 已清除 |
| ⚠️ 默认值不一致 | 1 | 已统一 |
| 🔧 硬编码 | 1 | 已修复 |
### 2. AI 摘要截断修复
**触发:** 日报 AI 摘要内容极少(137 字),末尾被截断。
**根因:** `_llm_call()` `max_tokens=600` 太低,deepseek-v4-flash 输出被硬截断。
**修复:** `max_tokens` 三处提升 + 截断检测日志。
| 位置 | 修复前 | 修复后 |
|------|--------|--------|
| `_llm_call()` 默认 | 600 | 1500 |
| 分块摘要 | 400 | 800 |
| 合并摘要 | 600 | 1500 |
**验证:** 手动生成日报,摘要从 137 字 → 486 字,结构完整无截断。
---
## 超时优先级(最终设计)
```
TIMEOUT_{NAME} 环境变量 ← 单步精确控制
↓ 未设
PIPELINE_STEP_TIMEOUT 环境变量 ← 全局兜底 (覆盖所有硬编码)
↓ 未设
STEP_TIMEOUTS 硬编码字典 ← 代码内默认值
↓ 步骤不在字典中
1800s ← 最终兜底
```
---
## 服务器 .env 当前配置
```env
# ---- 调度 ----
SCHEDULE_TIMES=07:00,12:00,18:00,22:00
CNINFO_SCHEDULE_TIME=06:00
STOCK_REPORT_TIME=08:00
STOCK_REPORT_DAYS=15
# ---- Pipeline 超时 ----
PIPELINE_STEP_TIMEOUT=3600
TIMEOUT_CRAWLER=900
TIMEOUT_XWLB=60
TIMEOUT_EXTRACTOR=300
TIMEOUT_DEDUP=900
TIMEOUT_LLM=900
TIMEOUT_EMBEDDING=900
TIMEOUT_QDRANT=1800
TIMEOUT_CNINFO_CRAWL=900
TIMEOUT_CNINFO_EXTRACT=900
TIMEOUT_CNINFO_PDF=900
```
---
## 调度器
| 时间 | 步骤 |
|------|------|
| 06:00 | cninfo 公告管道 |
| 07:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant→**日报** |
| 08:00 | 个股日报 |
| 12:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
| 18:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
| 22:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
---
## 常用命令
```bash
# 全链路 + 日报
uv run a-share pipeline --once --report
# 仅日报 (指定日期)
/home/pi/.local/bin/uv run python -m scripts.run_scheduler --once --date 20260623 --steps report
# 检索
uv run a-share search "关键词" --top 5
# 同步到服务器
scp <file> pi@192.168.1.160:/home/pi/news/<path>/
# 重启服务
ssh pi@192.168.1.160 "sudo systemctl restart a-share-research"
```
---
## 已知技术债务
| 问题 | 文件 | 状态 |
|------|------|------|
| QdrantClient HTTP 超时硬编码 10s | `vectorstore/client.py:93` | 无可配环境变量 |
| DashScope HTTP 超时硬编码 60s | `embedding/remote.py:81` | 无可配环境变量 |
| 超时解析逻辑在 pipeline.py 和 main.py 各一份 | 两处 | 可抽取公共函数 |
| `deepseek-v4-flash` 概率性返回空 | — | 分块处理 + 失败跳过 |
| wallstreetcn JS 渲染拦截 | — | 正文提取率 0% |
| eeo 列表页仍走 Playwright(仅文章页走 httpx) | `crawler/engine.py` | 列表页目前正常,暂不改动 |
---
## 架构备忘:静态/动态抓取分流
```
SourceConfig.js_render == False 且 wait_for == None
→ _fetch_static() → httpx 直连 (无浏览器, 快, 不被反爬)
→ markdownify + BeautifulSoup (清洗 script/style 后转 Markdown)
SourceConfig.js_render == True 或 wait_for 有值
→ _crawl_once() → Playwright (现有逻辑)
```
`_TimeoutGuard`:
- 同源连续超时 2 次 → `skip=True`
- 后续请求在 `semaphore` 内检查 `guard.skip`,立即返回 `Skipped`
- 一次成功即重置计数器
---
## 记忆
- `never-change-llm-model.md` — 绝不允许修改大模型配置
- `deploy-html.md` — user_guide.html 同步到 Pi 和 Web 服务器
- `sync-sources-yaml.md` — 修改 sources.yaml 前从 Pi 拉取,改完推回
- `update-user-guide.md` — 更新 md+html+部署