diff --git a/README.md b/README.md index ab5bc56..9d9ce25 100644 --- a/README.md +++ b/README.md @@ -92,24 +92,172 @@ ``` **数据流**(单篇文章的一生): + +下面这张图是当前生产实际链路。**抓**(RSS scheduler) → **翻**(translation_loop) → **增强**(enrichment_loop) → **展示**(/api/v1/articles/{id})。 + ``` -RSS Feed → feedparser → FetchedItem - ↓ -url_hash = SHA1(url) + ON CONFLICT DO NOTHING ← 去重 - ↓ (新文章入库,translation_status=pending) - ↓ -[translation_loop] 1篇/秒 Semaphore(1) - ↓ 调 腾讯 TMT → body_zh_text/html - ↓ status: pending → ok - ↓ -[enrichment_loop] 扫描 *_status=pending 的已译文章 - ↓ 调 Agnes LLM: 排版 → 分类 → 插图 → 点评 - ↓ 4 任务独立 try/except,共享 chat_sem + image_sem 限速 - ↓ status: ok / failed - ↓ -[文章详情接口] 原文 + 译文 + AI 排版版 + 分类 + 插图 + 点评 全部展示 +┌───────────────────────────────────────────────────────────────────────┐ +│ APScheduler 1 篇/秒(由 `backend/app/workers/__main__.py` 启动) │ +├───────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ translation_ │ │ translation_ │ │ enrichment_ │ │ +│ │ loop │ │ loop │ │ loop │ │ +│ │ 1 篇/秒 │ │ 独立(不并发抓) │ │ 2 秒/轮 + 8 并发│ │ +│ │ │ │ │ │ SELECT 待增强 │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +└───────────────────────────────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ │ │ + │ fetch_one_source │ translate_article │ enrich_article + │ (RSS 抓取) │ (spark/zhipu/tencent) │ (Agnes 4 任务) + │ │ │ + ▼ ▼ ▼ +┌───────────────────────────────────────────────────────────────────────┐ +│ articles 表 │ +│ 24 个核心字段 —— 见下方"字段生命周期"表 │ +└───────────────────────────────────────────────────────────────────────┘ ``` +下面用一条时间线 + 字段状态表,把"一篇文章从 RSS 出现到用户在前端看到"的全部状态变化说清楚。 + +### 时间线(典型情况,2026-06 链路) + +``` +T+0s RSS 抓取(每 30~60 分钟一次,按 source 配置) + │ + ▼ +T+1s feedparser 解析 → 提取 url / title / body_text / lang_src + │ url_hash = SHA1(url) + │ INSERT ... ON CONFLICT (url_hash) DO NOTHING ← 去重 + │ + ▼ 新行落地: + │ translation_status = 'pending' + │ format_status = 'n/a' + │ classify_status = 'n/a' + │ image_ai_status = 'n/a' + │ commentary_status = 'n/a' + │ category = NULL + │ title_zh / body_zh_* = NULL + │ fetched_at = now() + +T+5~30s translation_loop 扫到这行 + │ pipeline.translate_article(id) + │ + ├── 选引擎(service.py,优先级降序): + │ 1) spark ← Spark Lite (免费,默认) + │ 2) zhipu ← GLM-4-Flash (免费,2.3s/篇) + │ 3) tencent ← 腾讯云 TMT (按月配额;en-gb 会先 normalize) + │ 4) tencent_maas / agnes / local (备用,通常不用) + │ + ├── 翻译 title + body(按 4500 字符切 chunk) + │ 每个 chunk 走同一条引擎链 + │ + ▼ +T+10~60s 翻译完成,严格 status 判定(`pipeline.py:197-212`): + │ 1) 检查 AuthFailure / TMT error marker + │ 2) 检查 body 是否等于原文(未翻译) + │ 3) 以上任一命中 → translation_status = 'failed' + │ 4) 否则 status = 'ok' 或 'partial' + │ + ▼ +T+15~30s enrichment_loop 扫到这行(translation_status='ok') + │ pipeline.enrich_article(id) + │ + ├── 4 任务(独立 try/except,任一失败不阻塞其他): + │ 1) classify ← LLM 返回 {"categories":[...]} → category 字段(逗号分隔多标签) + │ 2) format ← LLM 重排 → body_zh_formatted(含固定 CSS 容器) + │ 3) image ← 文生图,prompt 用正文第一段 + │ 4) commentary ← LLM 点评 + │ + ▼ +T+30~90s 4 任务全部跑完 + │ status 分别落到: ok / failed / pending + │ + ▼ +T+任意 前端拉 /api/v1/articles/{id} + │ 展示:原文 + 译文(LLM 排版版) + 分类 + 插图 + 点评 + │ + ▼ + 用户看到完整文章 +``` + +### 字段生命周期表(`articles` 表) + +> 每一行是**一篇文章在某个时间点**的字段状态快照。**√** = 字段已填,NULL/`'n/a'`/`'pending'` 表示还没走到。 + +| 阶段 | translation_status | title_zh | body_zh_text | body_zh_formatted | image_ai_url | category | commentary | *_status (4 个 LLM) | +|---|---|---|---|---|---|---|---|---| +| **抓取后** | `pending` | NULL | NULL | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` | +| **翻译中** | `pending` | NULL | NULL | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` | +| **翻译完成** | `ok` | √ | √ | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` | +| **classify 完** | `ok` | √ | √ | NULL | NULL | √ | NULL | `n/a` `ok` `n/a` `n/a` | +| **format 完** | `ok` | √ | √ | √ | NULL | √ | NULL | `ok` `ok` `n/a` `n/a` | +| **image 完** | `ok` | √ | √ | √ | √ | √ | NULL | `ok` `ok` `ok` `n/a` | +| **commentary 完** | `ok` | √ | √ | √ | √ | √ | √ | `ok` `ok` `ok` `ok` | + +> **状态值含义**: +> - `n/a` — 默认值,从未跑过 enrich +> - `pending` — 任务入队但还没处理 +> - `ok` — 成功 +> - `failed` — 该任务失败(其他任务继续) + +### 翻译引擎优先级 + +> 降序排列,前者不可用时降级后者。完整逻辑见 `backend/app/services/translation/service.py`。 + +| 序位 | 引擎 | 配置 | 默认 model | 配额/限速 | 失败处理 | +|---|---|---|---|---|---| +| 1 | **spark** (Spark Lite) | `SPARK_API_PASSWORD` | `lite` | Lite 免费(企业级按 token) | 失败 → zhipu | +| 2 | **zhipu** (GLM-4-Flash) | `ZHIPU_API_KEY` | `glm-4-flash` | 免费(并发有限流) | 失败 → tencent | +| 3 | **tencent** (腾讯云 TMT) | `TENCENTCLOUD_SECRET_ID/SECRET` | n/a | 月 500 万字符(可调) | 配额满 → maas | +| 4 | tencent_maas (腾讯 MaaS u2) | `TENCENT_MAAS_API_KEY` | `u2` | 无配额 | 失败 → agnes | +| 5 | agnes (通用 LLM) | `AGNES_API_KEY` | n/a | 按 token | 失败 → local | +| 6 | local (本地 NLLB-200) | `LOCAL_TRANSLATE_ENABLED=true` | `nllb-200-distilled-600M` | 本地 CPU 推理 | 全部失败 → skip | + +**关键细节**: +- **en-gb / zh-cn / ja-jp** 这类 BCP-47 区域码会被 `tencent.py:_normalize_lang` 切成主语言(`en` / `zh` / `ja`),否则 TMT 直接 400 报错。 +- **缓存**: `translation:cache:{sha1(src|tgt|text)}` 在 Redis 存 30 天;同篇同 src/tgt 不重译。 +- **配额**: `translation:month:YYYYMM` Redis 累加,腾讯 TMT 用满后自动切 maas。 + +### LLM 智能增强(4 任务并发限速) + +> 入口 `backend/app/services/llm/enrichment.py`,常驻循环见 `enrichment_loop()`。 + +``` +enrich_article(id): + ┌─ classify → LlmClient.chat(llm 0.2) → 解析 {"categories":[…]} → category 字段 + ├─ format → LlmClient.chat(llm 0.3) → 排版后 HTML, 包到固定 CSS div + ├─ image → LlmClient.generate_image(...) → URL 写到 image_ai_url + └─ commentary → LlmClient.chat(llm 0.6) → 200 字中文点评 +``` + +**4 任务独立 try/except** — 任一失败只标该任务 `status=failed`,其他继续。最终落库结果示例: +```json +{"format": "ok", "classify": "ok", "image": "ok", "commentary": "ok"} +{"format": "ok", "classify": "failed:RuntimeError", "image": "ok", "commentary": "ok"} +``` + +**限速**: +- LlmClient 内部 `chat_sem + image_sem`(各 1 并发),`interval_sec=2.0` 每次调用后等 2s +- enrichment_loop 并发 8 篇(`ENRICHMENT_BATCH_SIZE=8`),`Semaphore(3)` 再降一次并发上限(防 Agnes 限流) + +**状态健康度参考**: +- 174/174/174/174 = 100% OK(理想) +- 0 failed 是健康线 +- 翻译后 30 秒内 enrich 入队是正常时延 + +### 故障模式速查 + +| 现象 | 排查 | 修复 | +|---|---|---| +| 文章停在 `pending` | `docker compose exec -T worker python -c "from app.config import settings; print(settings.tencentcloud_secret_id)"` 确认 TENCENT 配了;lang_src 是否带区域后缀 | en-gb 已被 normalize 兜底;真没 TENCENT key 走 spark/zhipu | +| `format_status=failed` 但其他 3 项 OK | `docker compose logs worker \| grep "enrich format failed"` 看异常 | 多数是 prompt 模板里 `{}` 没转义(`enrich_article` 内置 `_safe_format` 兜底) | +| 大量文章停在 `n/a n/a n/a n/a` | `curl /admin/health` 看 LLM 状态;`.env` 里 `AGNES_API_KEY` 是否过期 | 换新 key → `docker compose up -d worker` | +| 翻译报 `LLM 401` | `curl -X POST apihub.agnes-ai.com/v1/chat/completions -H "Authorization: Bearer $AGNES_API_KEY"` 单测 | Agnes key 失效,控制台重置 | +| worker 启动后 enrichment_loop 没起 | `docker compose logs worker \| grep "enrichment_loop started"` | 看不到 = enrichment_loop 协程没启动,查 __main__.py 调度 + 重启 | +| RSS 抓不到新文章 | `/admin/health` 看 `consecutive_failures`;`docker compose logs worker \| grep "fulltext fetch failed"` | 大概率反爬,暂停源 + 改 UA(已知 case:DW fulltext 需真实浏览器 UA) | + --- ## 技术栈