Commit Graph

63 Commits

Author SHA1 Message Date
mavis
363fd42f80 fix(postgres): zhparser 从源码编译(无现成 apt 包) 2026-06-15 18:48:19 +08:00
mavis
557b7a708e feat(search): 装 zhparser 中文分词 + 0010 迁移修正
- Dockerfile.postgres: 从 alpine 切到 debian bookworm,apt 装 postgresql-16-zhparser
- docker-compose.yml: postgres 改用 build 指向 Dockerfile.postgres
- 0010 迁移: CREATE EXTENSION zhparser + 建 chinese_zh text search config +
  重建 articles.title_zh_tsv 用 chinese_zh + 重写 refresh_search_keywords()
2026-06-15 18:46:09 +08:00
mavis
c3aa0f0cb6 feat(search): 智能搜索建议 - 固化候选词表 (search_keywords + search_title_suggestions)
后端:
- alembic 0009: 两张固化表 + GIN prefix_keys 索引 + articles trigger
- /api/v1/search/suggestions: 混合 A(高频词 ts_stat) + B(真实标题) + 冷启动 fallback
- worker 每日 03:00 + 启动时刷新 search_keywords
- 顺便填 commit 11 TODO: articles.title_zh_tsv + GIN 索引(未来 FTS 基础)

前端:
- NInput -> NAutoComplete + debounce 250ms
- 选标题 -> 跳详情;选关键词 -> 填入 + 触发搜索
- AbortController 防 race condition

性能: prefix_keys @> ARRAY[prefix] 走 GIN 亚毫秒,100w 行也稳
2026-06-15 18:26:35 +08:00
xiaji
8dfa302b96 fix(search): 搜索不限时间,搜全量历史
原 list_articles 默认套 24h 时间过滤(line 47-48):
    if since is None and until is None:
        since = _default_since_24h()

但用户搜索时(q 不为空)的意图是"找到含某关键字的文章",
跟时间无关;24h 默认会让搜索**找不到 24h 之前的文章**,
即使那篇文章确实包含关键字。

修法:仅在 q 为空时套 24h 默认;有 q 时不套。
显式传 since/until 仍生效(用户要限定时间就显式传)。

不影响:
- 普通 list(无 q)仍默认 24h(Feed 首页行为不变)
- 显式传 since/until 的 list 行为不变
- 搜索响应 schema 不变(前端无改动)

测试:open web -> Feed -> 搜索 "美联储" -> 看到 24h 之前的
相关文章也能命中。
2026-06-15 07:25:36 +08:00
xiaji
02d18a1157 feat(search): 搜索覆盖标题/正文/评论
原 /api/v1/articles?q=xxx 只搜 title + body_text(外文原文),
导致:
- 搜中文译文里出现的词搜不到
- 搜双 provider 评论里的"主题词"搜不到
- 搜摘要搜不到

扩展为搜索 7 个字段(命中任一即可):
- title           (原标题,外文)
- body_text       (原文,外文)
- title_zh        (中文标题)
- body_zh_text    (中文译文,纯文本)
- summary_zh      (中文摘要)
- commentary      (Angel 评论)
- commentary_meituan (美团评论)

不去搜 body_zh_formatted(HTML 包了 <p class="diary-para">),
避免 ilike 跟 HTML 标签字符串误匹配,纯文本走 body_zh_text 即可。

性能:
- ilike '%xxx%' 走不了 B-tree 索引,PG 会 seq scan
- 当前文章量级别(几千篇)下完全 OK,延迟 < 50ms
- 未来 10w+ 文章量时改 PG full-text search(to_tsvector + GIN)
  或外部 ES;现阶段不做

无 schema/migration 改动;无前端改动(后端响应 schema 没变)。
2026-06-15 07:20:10 +08:00
xiaji
6a45f84857 perf(llm): 短新闻 enrich 跳过时不再标 'n/a',避免 enrichment_loop 反复扫
问题:
  enrichment_loop 的 in-loop 过滤 (line 616) 把 'n/a' 当成
  "未 enrich" 信号:
    if any(s in ("pending", "failed", "n/a") for s in statuses):
        todo_ids.append(a.id)

  短新闻入表时 format_status='n/a'、image_ai_status='n/a'
  (commit 1 决定),而 enrich_article 在 is_short 跳过这两任务时
  保留 'n/a'。后果:enrichment_loop 永远认为"这两任务未完成",
  每 ~3.5 分钟反复把同一篇短新闻捞进队列 enrich 一次。

  实测(commit 10 修复前 1 小时数据):
  - 9 篇短新闻 × 18 次 enrich = 131 次完全 ok
  - 131 - 9 = 122 次纯浪费(占 95%)
  - 每篇 enrich 内部 Angel + 美团两个 LLM 调用是并行的
  - 浪费配额:Agnes 免费 plan 风险、Angel 月配额、美团限速窗口

修法:
  enrich 跳过某任务时,把对应 status 标 'ok' (语义 = 该任务
  "已完成" = 不需要做),而不是 'n/a' (语义 = 任务存在但跳过)。
  改动 2 处:format 跳过 + image 跳过。
  短新闻 enrich 后,所有 *_status 都是 'ok',enrichment_loop
  看到后不再扫,只跑一次。

注释同步更新:解释 'n/a' vs 'ok' 的语义区别,提醒后续修改者
不要把 'n/a' 写回 status 字段。

存量修复(不写入 commit,运维动作):
  修复前入库存量的短新闻 format/image_ai_status 仍是 'n/a',
  deploy 后第一次循环会把它们 enrich 一遍改成 'ok'。如果你想
  立刻修,跑:
    UPDATE articles
    SET format_status='ok', image_ai_status='ok'
    WHERE is_short_news=true
      AND (format_status='n/a' OR image_ai_status='n/a');

不改:
  - enrichment_loop 的 SQL/loop 过滤条件(不动 'n/a' 状态值,
    仍有合法场景使用:meituan_client 未配置时 commentary_meituan
    会标 'n/a'。但生产环境 meituan key 已配置,该 case 不存在)
  - ingest 路由的 *_status='n/a' 初始化(那是在入库阶段,
    enrichment_loop 不会立即扫到,有 2 秒起步延迟 +
    is_short_news/body_zh_text 过滤)
  - comment_status 等其他 status 字段(短新闻也会跑,设 'ok')

性能影响(预估):
  - 修复前:每篇短新闻每小时 18 次 enrich × 1 路串行 ~18 秒/次
    = 占 enrichment_loop 单 worker 18 * 18s = 5.4 分钟/小时
  - 修复后:每篇短新闻只 enrich 1 次,约 5 秒
  - 节省:短新闻 LLM 调用约减少 95%
  - 副作用:长新闻 enrich 不再被短新闻"挤占"semaphore 资源,
    长新闻 enrich 速度会加快(从当前 ~18s 串行,可能回到 ~5s)
2026-06-14 22:03:06 +08:00
xiaji
a81b373b8b chore(tz): 时区统一为 Asia/Shanghai
仓库里有两处时区声明写成 Asia/Hong_Kong,虽然跟 Shanghai
都是 UTC+8 在 C 库时区行为上一致(无夏令时),但:
- VPS 物理位置虽然在香港,本机 /etc/localtime 已指向 Shanghai
- 配置文件/默认值/示例 env 写成 Shanghai 更准确反映项目
  实际部署位置
- /etc/timezone 文件本机之前是 Etc/UTC 跟 timedatectl 不一致
  (本次单独在服务器上同步,不入 commit — 是运维状态)

改动:
- .env.example: TZ Asia/Hong_Kong -> Asia/Shanghai
- backend/app/config.py: settings.tz 默认值 Asia/Hong_Kong
  -> Asia/Shanghai(只有日志打了一下,不影响实际行为)

不影响:
- 生产 .env 文件没动(里面有 JWT_SECRET 等真实值,不该入库;
  新机器按 .env.example 复制出来就是 Shanghai)
- docker-compose.yml 不动 — api/worker service 用 env_file: .env
  模式,TZ 已经自动注入容器(验证过 TZ=Asia/Hong_Kong 在容器内可见)
- 应用代码所有 datetime.now() 都是 naive datetime,实际行为
  不变(数据库存的是 UTC 绝对时刻,PG TIMESTAMPTZ 转换正确)
- 文档/README 不动 — line 4 写"香港 VPS"是物理位置描述,
  时区是上海,两者并存(都是 UTC+8)不矛盾
2026-06-14 21:12:36 +08:00
xiaji
55e20e923a perf(translate): translation_loop 跳过中文源头,省 TMT 配额
中文 RSS 长新闻(原文就是中文)走 TMT 中翻中,纯粹浪费
月配额(500 万字符)且产生无意义译文。前端 commit 6
已经隐藏"译文"板块;本 commit 在后端拦截,从源头不跑翻译。

改动:
- translation_loop SQL 加 WHERE lang_src IS NULL OR NOT LIKE 'zh%'
  - lang_src 为 NULL 时仍走翻译(英文 RSS 没设 language_src 的合法场景)
  - LIKE 'zh%' 覆盖 zh / zh-CN / zh-Hans / zh-TW 等区域码
- translate_article() 函数内加防御性 guard:中文源直接返
  并把 translation_status 改 'n/a',避免反复入队
  (主路径 SQL 过滤已足够,这里是兜底,应对手动 reset status 的情况)

不影响:
- 短新闻(commit 1 已是 translation_status='n/a',根本不进队列)
- 外文 RSS(走翻译)
- 历史已被错误翻译的中文长新闻:保留 translation_status='ok'
  + body_zh_text 中文(空跑产生的) — commit 6 前端已隐藏,
  不影响用户感知;回滚存量不在本 commit 范围(独立 SQL 即可,
  风险与收益需要单独评估)
- enrichment_loop(commit 1 已经能扫到中文源头的 is_short_news,
  长新闻 lang_src=zh 仍能被 enrichment 处理,排版+插图+评论都跑)

范围:仅 backend/app/workers/pipeline.py,+20/-2 行。
2026-06-14 20:57:11 +08:00
xiaji
6ae5dfae60 fix(ingest): rerun_translation 拒绝短新闻
短新闻(API Push)是中文原生,translation_status 固定为 n/a,
不应当走翻译链路。

前端 ArticleDetail.vue 已经在按钮上加了 v-if='isOwner && !isShort'
(commit 3),web UI 上 owner 看不到重译按钮;但 curl 直接调
POST /admin/translation/rerun/{id} 仍能绕过前端触发翻译,
把 status 改 pending 并入队 translate_article。

本 commit 加后端 guard:is_short_news=True 直接返 400,
跟前端形成双保险。

防呆:
- 修法:在 art 找到后、状态字段重置前先 raise
- 影响范围:仅 rerun_translation 路由,其他 rerun 路径不动
- 不需要 schema/迁移
2026-06-14 20:27:53 +08:00
xiaji
e274246056 feat(ingest): API Push 前端层 + 文档 + 端到端联通
后端(支持 api_push source 创建/调度):
- schemas/source.py:SourceIn.url 改成 str(允许 api_push 的 api-push:// 占位)
- admin.py create_source 简化 url 传递
- workers/__main__.py:_rebuild_jobs 跳过 api_push 源(它是被动接收,不抓取)
- workers/pipeline.py:run_once 也加同条件,api_push 不进抓取循环

前端:
- api/articles.ts:ArticleListItem 加 is_short_news(required)/source_ref;
  ArticleDetail 加 external_id;导出 IngestTokenOut;adminApi 加
  list/create/revoke ingest token 三个方法
- views/Feed.vue:卡片根 class 短新闻加 short-card(淡蓝底 #f6f9fc +
  左侧 3px 蓝色色条 #4f9eff);元信息栏加 📰 短讯 角标;长新闻摘要
  body_zh_text 截前 200 字,短新闻不截取保留换行(white-space: pre-wrap);
  短新闻不显示 AI 插图
- views/ArticleDetail.vue:tag 行加 📰 短讯 + source_ref 角标;短新闻
  路径下隐藏翻译状态/重译/原文链接按钮;正文区短新闻直接渲染
  body_zh_text,跳过译文/原文/AI 配图卡片;Angel + 美团双评论卡片
  都保留
- views/AdminSources.vue:kind 加 api_push 选项;api_push 源 URL 字段
  变只读占位、隐藏抓取间隔;列表操作列加 🔑 Token 按钮;
  弹窗支持生成(raw_token 一次性显示 + 复制)/列表/撤销

文档:
- docs/api-push.md:调用方契约 + 三层去重 + 限速 + lifecycle +
  owner 操作手册 + curl/Python 示例 + 重试策略 + 故障排查
- README.md:关键特性加 API Push;API 概览加 /api/v1/ingest 和
  3 个 /admin/.../ingest-tokens 端点
2026-06-14 16:15:21 +08:00
xiaji
07534eb144 feat(ingest): API Push 短新闻接口层
- POST /api/v1/ingest:鉴权(X-Ingest-Token) + 限速(每 token 2 篇/秒,
  Redis 滑动桶,INGEST_RATE_PER_SEC 可调) + 三层去重(L1 external_id /
  L2 content_hash / L3 DB UNIQUE 兜底,均带 reason)
- 写入字段:is_short_news=True、translation/format/image_ai_status='n/a'、
  classify_status=(有 tags?'ok':'pending')、commentary_{angel,meituan}_status='pending'、
  body_zh_text=body_text(走统一路径,前端/prompt 不用改)
- services/fetchers/api_push.py:compute_content_hash + synthesize_url +
  normalize_published_at + build_initial_status 纯函数
- schemas/ingest.py:IngestPayload(title 1-200/body 1-5000/tags 去重去空) +
  IngestResponse(article_id/content_hash/status/reason/matched_external_id)
- admin.py:POST/GET/DELETE /admin/sources/{id}/ingest-tokens — owner 生成
  (raw_token 仅一次性返回)、列出、撤销
- schemas/article.py:ArticleListItem 加 is_short_news/source_ref;
  ArticleDetail 加 is_short_news/source_ref/external_id
- main.py:挂 ingest router;config.py + .env.example:ingest_rate_per_sec 默认 2

短新闻由 commit 1 enrichment_loop 自动接管 classify + 双 provider commentary,
跳过 format/image。
2026-06-14 16:04:45 +08:00
xiaji
3091f291b2 feat(ingest): API Push 短新闻数据层
- alembic 0008:articles 加 is_short_news/external_id/source_ref/content_hash
  (UNIQUE);sources.kind 加 'api_push';api_tokens 加 purpose + source_id
- SourceKind.API_PUSH enum;Article/ApiToken model 加新字段
- enrichment_article 短新闻跳过 format/image;
  enrichment_loop SQL 加 is_short_news 路径(并入'可 enrich' 条件)
- 入库侧由 commit 2(ingest 接口)负责:写 body_zh_text=body_text,
  format/image/commentary_meituan_status='n/a',
  classify/commentary_status='pending'(带 tags 时 classify='ok')

无迁移爆炸半径:articles.url 保持 NOT NULL,短新闻合成 api-push:// 占位
2026-06-14 15:51:22 +08:00
xiaji
7057992136 fix(alembic 0007): 改用 sa.Column 内嵌 ForeignKey 形式,避免列数不匹配
之前用独立 sa.ForeignKeyConstraint 报 ArgumentError,
改用 sa.Column(..., sa.ForeignKey(...), ...) 形式(
跟 model 里的 mapped_column 形式对齐)。
2026-06-13 21:15:22 +08:00
xiaji
4ca05b8b7d fix: 修两个 bug
1. ArticleRead.user_id 改 Integer(users.id 是 Integer,不是 BigInteger)
   alembic 0007 同样改 Integer
2. ArticleDetail.vue toggleRead 重复 catch 块导致 build 失败
   (edit 时新加的 catch 跟 toggleStar 残留的 catch 撞了)
2026-06-13 21:10:22 +08:00
xiaji
6c71ab2e79 feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。

设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段

前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
    顶部加 '隐藏已读' 开关,默认 ON
    每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
    已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
xiaji
8b3c7caf87 chore: 去掉 active_ip 的临时 debug log(已通过限流验证)
smoke_limit2 验证成功(临时 kick 真实 IP + 30 假 IP → 真实 IP 登录返 429,
限流逻辑按设计工作)。
清理掉 [CHECK] [TOUCH_DEP] stderr 调试 log,恢复代码干净。
2026-06-13 20:29:08 +08:00
xiaji
0d5f29fd37 fix(active_ip): 修 debug log 引用未定义变量导致 500
bug: 之前 smoke 加 print 时把 print 放到了 'limit = ...' 之前,
Python 看到函数内有 'limit =' 就把整个 limit 视为 local,
print 在赋值前引用 → UnboundLocalError → 整个 login 返 500。

修: 把 print 挪到 limit 赋值之后。
2026-06-13 19:16:58 +08:00
xiaji
7c5aff6345 debug: 临时加 stderr log 看 IP 提取 + touch_ip 调用 2026-06-13 19:12:23 +08:00
xiaji
b500613d22 feat(auth): 限制同时在线 IP 数 (默认 30, 第 31 拒绝)
背景: 防 token 泄漏被滥用 + 限共享账号人数。

- 新增 app/services/active_ip.py:
    Redis ZSET 'active_ips' 存 IP,score=last_seen_unix
    登录/refresh 时 check_or_register_login_ip():
        IP 已在 set → 刷新 score,放行(老用户重连)
        IP 不在 set + ZCARD < 30 → 加入,放行
        IP 不在 set + ZCARD >= 30 → raise 429
    每个已认证请求 _resolve_user() 调 touch_ip_dependency()
        滑动 TTL,30 天没活动自动从 set 剔除
- get_client_ip() 取真实 IP,优先级 X-Forwarded-For > X-Real-IP > client.host
    trust_x_forwarded_for 默认 True(生产 Caddy/Nginx 后面)
- config 加 3 个开关:
    site_max_active_ips: int = 30
    site_active_ip_idle_days: int = 30
    trust_x_forwarded_for: bool = True
- admin.py 加 3 个端点:
    GET  /admin/active-ips       — 看当前活跃 IP 列表 + last_seen
    POST /admin/active-ips/kick  — 强制踢出指定 IP(body={ip})
    DELETE /admin/active-ips/{ip}— 简写踢出
- 注: refresh 也算 IP 占用(拿到 access token 就能用)
    但已存在的 IP 直接放行,不会踢自己
2026-06-13 18:22:40 +08:00
xiaji
16536fe3a0 feat(meituan): 政治类文章拦截 + 写'无可奉告' + Angel 并发 3→1
- llm_settings 加 meituan_blocked_topics / blocked_keywords / no_comment_text
- alembic 0006 迁移,默认 topics=[时政/国际/军事/政治/战争/冲突/制裁/选举], 默认文案='无可奉告'
- enrichment._is_meituan_blocked 预检:category 命中 topic 或 关键词 → 直接写'无可奉告',不调美团 API
- 命中后 commentary_meituan_model='policy-block' 标识非真实生成
- enrichment_loop Semaphore(3)→(1),Agnes 免费 plan 不再 429
- 前端 AdminLlmSettings 美团卡片加 3 字段 UI(主题/关键词/固定文案)
2026-06-12 22:44:00 +08:00
xiaji
aaf728f3f4 feat(admin): Angel(Agnes) provider 凭据 DB 化 + 安全 key_set 字段
- llm_settings.agnes_api_key           TEXT   (DB key 优先,.env 兜底)
- llm_settings.agnes_base_url_override VARCHAR (留空 = 用 .env)
- alembic 0005_agnes_key 迁移
- LlmSettingOut.agnes_api_key_set (bool) 替代直接回传 key
- LlmSettingUpdate 加 agnes_api_key / agnes_base_url_override(可空可清空)
- providers.get_angel_client 改用 DB key 优先
- enrichment.py 改为 get_angel_client() 工厂调用(热改 key 不需重启)
- /admin/llm/settings/test 走 get_angel_client(测的是 DB 里的 key)
- 前端 AdminLlmSettings 在'总开关 + 模型'卡里加 Angel api_key 输入框 +
  base_url 覆盖 + 已配置/未配置指示灯 + 清空按钮
- 顶部'测连接'按钮复用(测的就是 Angel)
2026-06-12 20:43:54 +08:00
xiaji
bc36a1fc38 feat(commentary): 双 provider 评论 — Angel(Agnes) + 美团大模型(LongCat)
- 新增 articles.commentary_meituan{_status,_model,_error} 4 列 + commentary_engine
- LlmSetting 加 meituan_api_key/base_url/chat_model/interval_sec/enabled/commentary_prompt
- 新 app/services/llm/providers.py 工厂,支持多 provider 客户端
- enrichment 流程改为 commentary_angel + commentary_meituan 并行(asyncio.gather),
  任一 provider 失败不影响另一个
- enrichment_loop 状态判定:任一 provider 状态不是 ok 都视为待 enrich
- alembic 0004_dual_commentary 迁移
- 前端 Feed 卡片 + ArticleDetail 详情页各加一条'美团评论'卡
- AdminLlmSettings 加美团 provider 配置卡(独立 api_key 编辑器,不回显明文)
- LlmSettingOut.meituan_api_key_set (bool) 替代直接回传 key
- 默认 URL https://api.longcat.chat/openai/v1 / 默认模型 LongCat-2.0-Preview
2026-06-12 19:00:00 +08:00
Mavis
e4733ab495 style(ai): 插图默认尺寸 768x512 → 512x384(更省 CDN 流量)
512x384 是 4:3 经典比例,后端 DEFAULT_IMAGE_SIZE 常量值。
老文章(已 enrich)的 image_ai_url 仍是 768x512 不会自动变;
新 enrich 走 512x384。Setting.image_size 字段保留(用户在 UI 改生效),
但默认行为不依赖它,避免被改大。
2026-06-11 23:25:05 +08:00
Mavis
e96e1cf1ff style(article): 正文 p 字号 17→19px,行高 1.7→1.75(阅读更舒服)
- 后端 enrichment.ARTICLE_BODY_FONT_SIZE: 17 → 19(新写库的 LLM 排版版)
- 前端 style.css .article-body / .diary-para: 17 → 19,line-height 1.7 → 1.75
- 768px 媒体查询: 16 → 18,line-height 1.8 → 1.85
- 480px 媒体查询: 15.5 → 17

老文章也立即变大(前端 .article-body 兜底);新排版文章用新 inline style。
后端要等下一次 enrich 才落新行(原 inline style 还是 17px),
如果想老排版版立即也变,在 /admin/llm 手动 trigger 几篇 enrich 即可。
2026-06-11 22:53:38 +08:00
Mavis
24478604b1 bfddfdfdfewe 2026-06-11 17:24:46 +08:00
Mavis
fd7817b881 fix(translate): 拦截引擎错误 marker + pipeline 严格 status 判定,避免 TMT AuthFailure 伪装 ok 2026-06-11 10:01:19 +08:00
Mavis
6293f82a3a chore(translate): 降频 2秒/次 + 改 spark 为 wss WebSocket 鉴权(智谱/zhipu=第一) 2026-06-11 09:34:01 +08:00
Mavis
4b8d776aac feat(scripts): 添加 retranslate_history 脚本,支持软/硬重译 + dry-run + 按源/数量过滤 2026-06-11 09:28:26 +08:00
Mavis
2e0e5ea80c fix(translate): 调整优先级为 zhipu→spark(智谱第一,星火第二) 2026-06-11 09:28:03 +08:00
Mavis
3f183d14db feat(translate): service.py 接 zhipu / config 加 zhipu_* / .env.example 加 ZHIPU 配置
配 zhipu.py(zhipu 模块)一起使用:
- service.py: 新增 _zhipu_translator(),引擎选择链路 spark → zhipu → tencent → maas → agnes → local
- config.py: 新增 zhipu_api_key / zhipu_base_url / zhipu_model / zhipu_interval_sec
- .env.example: 补 ZHIPU_* 字段说明

留空 zhipu_api_key = spark 不可用时直接降级 tencent(向后兼容)
2026-06-10 23:48:14 +08:00
Mavis
b6fc1b322f feat(translate): 加智谱 GLM 作为第二序位翻译引擎(spark → zhipu → tencent)
- 新增 app/services/translation/zhipu.py: OpenAI 兼容协议客户端,
  URL = https://open.bigmodel.cn/api/paas/v4/chat/completions,
  鉴权 = Bearer <api_key>, model = glm-4-flash(默认,免费)/glm-4-air/glm-4.5 等
- service.py 引擎链路调整为: spark → zhipu → tencent(配额)→ maas → agnes → local
- 配置: 新增 ZHIPU_API_KEY / ZHIPU_BASE_URL / ZHIPU_MODEL / ZHIPU_INTERVAL_SEC
  (留空 ZHIPU_API_KEY = spark 不可用时直接降级 tencent,向后兼容)
- 实测 GLM-4-Flash 2.3s 返回 OK;GLM-4.7-Flash 当前限流 1305
2026-06-10 23:47:55 +08:00
Mavis
a8e93cf7c7 fix(translate): pipeline 写库用 service 返回的 engine(之前写死 tencent)
pipeline.translate_article 调 translation_service.translate() 后,
自己写 engine_label='tencent',忽略 service 实际返回的 engine。
效果:即使 service 内部按 spark 优先链路跑成功了,
DB 里 translation_engine 也只显示 tencent。

修法: 改用 tr_title.engine(or 默认 tencent 兜底)。

附带: deploy_pull.py 加 sys.stdout.reconfigure(encoding='utf-8'),
Windows GBK 终端下 Unicode 字符不会再 UnicodeEncodeError。
2026-06-10 23:31:03 +08:00
Mavis
b27643123e feat(translate): 加星火 Spark(Lite)作为优先翻译引擎
- 新增 app/services/translation/spark.py: OpenAI 兼容协议客户端,
  URL = https://spark-api-open.xf-yun.com/v1/chat/completions,
  鉴权 = Bearer <APIPassword>, model = lite(默认)/generalv3.5/4.0Ultra 可切换
- service.py 引擎链路调整为: spark → tencent(配额)→ maas → agnes → local。
  优先级降序: spark 配了 key 就用它,失败再走 tencent(继续吃配额,不绕过)。
  要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。
- 配置: 新增 SPARK_API_PASSWORD / SPARK_BASE_URL / SPARK_MODEL / SPARK_INTERVAL_SEC
  (留空 SPARK_API_PASSWORD = 走原 tencent 主链路,向后兼容)
- 缓存白名单 / 配额计数逻辑保持原行为,只把 spark 加入允许缓存的引擎集合
2026-06-10 23:14:20 +08:00
Mavis
921e674a30 feat(translate): 加 Agnes 翻译 fallback,buffer 改 0.5
腾讯 TMT 月度配额快满时(腾讯后台口径已用 2M/5M),翻译降级链:

1. tencent TMT(主,按月配额)
2. tencent MaaS u2(第二级,翻译专用,无配额)
3. agnes 通用 LLM(第三级,质量次之但够用)
4. local NLLB(最后兜底)

新增 backend/app/services/translation/agnes.py: AgnesTranslator
复用 LlmClient 做限速 + 重试,系统 prompt 强约束只输出译文,
去除 "以下是翻译" 等常见 LLM 翻译前缀。

service.py 改动:
- fallback 链 maas -> agnes -> local
- cache 接受 agnes 结果(30天)
- add_usage 只算 tencent TMT

buffer 调整: TENCENT_TMT_QUOTA_BUFFER 0.05 -> 0.5
腾讯云后台按请求字节计费,与我们 redis 字符累加口径差约 2.5x;
按腾讯后台口径 redis 累加到 1M 字符即触发降级(对应腾讯约 2.5M 字节 =50% 用量),
留足 buffer,避免月底真爆。
2026-06-10 17:44:47 +08:00
Mavis
764de4e85c fix(worker): enrichment_loop 改并发 + 加大 batch
之前每轮只跑 3 篇串行,587 篇待 enrich 队列要 4.7h 才清完。

改动:
- ENRICHMENT_BATCH_SIZE: 3 -> 8
- ENRICHMENT_INTERVAL_SEC: 5 -> 2
- 处理 todo_ids 改 asyncio.gather 并发 3 篇
- LlmClient 内部 interval_sec 限速不变,这里只加并发上限

效果:每分钟 ~7 篇 -> 587 篇约 84 分钟清完。

排查过程中还发现根因: llm_settings 表空行导致 enrichment 静默跳过,
已手动 INSERT 默认 LlmSetting(id=1, enabled=true) 触发循环。
2026-06-10 17:20:53 +08:00
Mavis
81c83ced8d feat(feed): 列表展示翻译正文摘要 + 页码分页
首页 Feed.vue 改造:
- 卡片在中文标题下直接展示 body_zh_text(前 220 字)
  用户不进详情就能看到译文正文,提升阅读效率
- 配图(image_ai_url 或 image_url)也直接显示在卡片中
- 把原标题作为副标题(灰色,辅助参考)

分页从 cursor 无限滚动换成 page + page_size:
- 后端 /articles 加 page/page_size 参数,返回 total/total_pages
- 干掉 _encode_cursor/_decode_cursor
- 前端用 n-pagination,显示 1,2,3,4,5 + 快速跳转
- 筛选/搜索变化自动回到第 1 页
- 切页自动滚到顶部
2026-06-10 12:07:04 +08:00
Mavis
3e56fed541 feat(translate): 接入腾讯 MaaS u2 作为 TMT 备用翻译通道
新通道:腾讯 MaaS u2 模型(云知声),OpenAI 兼容协议
- 端点:https://maas-api.hivoice.cn/v1
- 模型:u2(翻译专用,实测 + 锁定 prompt 后译文质量稳定)
- 备用链路:TMT 配额耗尽 / TMT 失败时自动降级到 MaaS

关键 prompt 工程(锁定):
- 必须用 user 提供的固定中文 prompt,否则 u2 会把译文放进 reasoning_content 而 content 返乱码
- 限定只接 EN/JA → ZH
- 中文输入固定返回拒绝文案

新增/改动:
- backend/app/services/translation/tencent_maas.py: 新建
- backend/app/services/translation/service.py: 备用链 maas → local,初始化失败友好降级
- backend/app/config.py: 加 tencent_maas_* 4 个配置
- .env.example: 文档化
2026-06-09 17:33:45 +08:00
Mavis
a5bfb7d49a fix(worker): enrichment_loop 永远只扫老文章(已 enrich),新文章被排到最后
bug 复现:
- order_by translated_at asc nullslast
- 老文章已 enrich,translated_at 有值,排前
- 新文章 translated_at=NULL,nullslast 排最后,limit5 永远拿不到

修复:
- order_by 改为 Article.id.asc()(新文章 id 大)
- ENRICHMENT_BATCH_SIZE 1→3(并发候选)
- 文章间 sleep 0.5→0.2s

效果:enrichment_loop 现在会持续 enrich 新进文章,首页列表会逐步有分类/评论
2026-06-09 17:07:07 +08:00
Mavis
474299baf9 feat(feed): 首页列表展示分类标签 + LLM 评论预览
- 后端 ArticleListItem schema 加 commentary / commentary_status / image_ai_url
- 后端 articles.list 接口把以上字段写入响应
- 前端 API 类型同步
- 前端 Feed.vue 卡片:
  * 分类 tag(逗号分隔,多 tag)
  * 评论预览(蓝色引线块,140 字截断,带状态点)
  * 用户点进详情页前就能看到 LLM 点评钩子
2026-06-09 15:59:48 +08:00
Mavis
76e95908e8 fix(llm): _safe_format 防 ValueError,模板里示例 JSON 也能正常 format
bug: classify_prompt 默认值里含示例 JSON {\\"categories\\": [...]},str.format
     看到花括号就试图解析为 placeholder/format spec,遇到 \\" 时抛:
       ValueError: Invalid format specifier ' [\\"时政\\"]' for object of type 'str'

修复:
- 引入 placeholder_re 提取所有合法 {varname} 占位符,stash 成 sentinel
- 剩余的 { / } 一律 escape 成 {{ / }},str.format 自然还原
- 用户显式写的 {{ / }}(标准转义)单独 stash,不被重复 escape
- 极端情况(KeyError/IndexError/ValueError)兜底:按原文返回,记录 warn

8 个本地单测全过(含示例 JSON 模板 / 老 prompt 缺变量 / 用户显式 {{ 场景)
2026-06-09 15:14:53 +08:00
Mavis
728e8c9be3 feat(api): LlmSetting/Source 暴露 blocklist_tags,admin 编辑入口就绪
- schemas/source.SourceOut/In/Update 加 blocklist_tags
- admin.create_source 透传 blocklist_tags
- admin.update_source 走 setattr 通用,自动支持新字段
- admin_llm.get_settings 在 row=None 分支返回默认值时补 blocklist_tags=[]
- update_settings 走 setattr 通用,自动支持新字段
2026-06-09 14:35:54 +08:00
Mavis
da895c2c5f feat(llm): classify 前置 + 黑名单 drop 删文章 + 排版用 .diary-para
- enrichment._enrich_classify 前置,返回 (drop, categories)
  - 注入 {blocklist} 占位符到 prompt(全局 + per-source 合并)
  - drop=True → 整篇 DELETE,后 3 步直接 skip
  - 兜底:即使 LLM 没正确返回 drop 字段,本地也匹配一次
- enrichment._enrich_format 排版段落 class 名固定为 diary-para
  - CSS 仍内联到 style,前端 .diary-para 兜底
- enrichment._merge_blocklist: 全局 + per-source 合并去重保序
- schemas/llm.LlmSettingOut/Update 暴露 blocklist_tags
- DEFAULT_PROMPTS.classify_prompt 加 {blocklist} + drop 字段说明
2026-06-09 14:34:18 +08:00
Mavis
d0d1014505 feat(db): 0003 migration + LlmSetting/Source 模型加 blocklist_tags
- 新增 alembic 0003: sources.blocklist_tags + llm_settings.blocklist_tags(JSONB)
- 两层配置:全局(llm_settings) + per-source(sources),合并去重后注入 classify prompt
- 默认空数组,不影响存量数据;admin API 在下个 commit 暴露编辑入口
2026-06-09 14:30:38 +08:00
Mavis
8d73f4fb28 fix(llm+worker+deploy): 兼容老 prompt 模板 + 消除 startup_run 日志噪音
- enrichment: 新增 _safe_format (基于 _SafeDict),缺失占位符保留原样不抛 KeyError。
  _enrich_format / _enrich_classify / _enrich_image / _enrich_commentary
  全部走 _safe_format,数据库里老 prompt(不支持 {body})不再让整条 article 卡住。
  复现: 388183 classify 一直 KeyError,enrichment_loop 反复重试它,316 篇全卡在 n/a。
- workers.__main__: startup_run 从 IntervalTrigger(minutes=0) 改成 DateTrigger
  (只跑一次),消除 'maximum number of running instances reached' 刷屏 WARNING。
- deploy_pull: 改 _connect 自动识别 RSA / Ed25519 / ECDSA key(原硬编码 Ed25519Key)
2026-06-08 21:20:43 +08:00
Mavis
380e8b124e feat(llm): 排版容器固定CSS + 插图用正文第一段 + 适中尺寸
- enrichment._enrich_format:把排版好的段落包到带固定 CSS 的 <div class=article-body> 里
  (font: system-ui / 17px / line-height 1.7 / color #3e3e3e / p margin-bottom 1.5em)
  CSS 同时内联到 style 属性,前端 .article-body 全局类做兑底
- enrichment._enrich_image:prompt 改用 body_zh_text 的第一段(原为 title);
  新增 {body} 占位符,image_prompt_template 默认模板同步改写
- 插图尺寸写死为 768x512(适中);image_size 字段保留供用户手改但默认行为不依赖它
- 分类明确多标签(2-5 个),提示词加 {body} 变量,容错读 categories/tags 两种 key
- AdminLlmSettings.vue:placeholder / 变量说明同步更新
2026-06-08 20:53:21 +08:00
Mavis
4cc5d41e39 fix(orm): Article 模型加 body_zh_formatted/image_ai_url/4 个 *_status 字段(同步 0002 migration) 2026-06-08 16:08:39 +08:00
Mavis
a5548d6e64 fix(fetcher): fulltext 抓取用真实浏览器 UA,绕过 NHK 等 403 2026-06-08 15:55:30 +08:00
Mavis
6b5828c1c0 fix(translation): 规范化 BCP-47 lang_src(避免 en-gb/zh-cn 等被 TMT 拒) 2026-06-08 15:49:03 +08:00
Mavis
ba2298da0a chore: 集成 LLM 增强 — config/main/articles schema/workers + .env.example 加 Agnes 配置 2026-06-08 14:24:23 +08:00
Mavis
ffd667f0dc feat(llm): 新增 LLM 智能增强服务(Agnes client + 4 项 enrichment 任务 + admin API + migration) 2026-06-08 14:24:00 +08:00