From d6f70b02f4b7a9846b7d846b5a878f8160d7df83 Mon Sep 17 00:00:00 2001 From: Jeamee Date: Mon, 24 Aug 2026 16:49:57 +0800 Subject: [PATCH] Import Human Voice Rewrite Demo (PRD v1.1) + Dockerfile for Coolify --- .dockerignore | 12 + .gitignore | 5 + Dockerfile | 12 + ...e Rewrite Demo — 使用说明(给接手同事).md | 103 ++ README.md | 72 + _e2e_stub_server.py | 228 +++ evidence.py | 121 ++ index.html | 1369 +++++++++++++++++ llm.py | 248 +++ local_start.sh | 56 + main.py | 203 +++ prompts.py | 392 +++++ requirements.txt | 5 + schemas.py | 169 ++ start.bat | 34 + test_demo.py | 551 +++++++ 16 files changed, 3580 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Human Voice Rewrite Demo — 使用说明(给接手同事).md create mode 100644 README.md create mode 100644 _e2e_stub_server.py create mode 100644 evidence.py create mode 100644 index.html create mode 100644 llm.py create mode 100644 local_start.sh create mode 100644 main.py create mode 100644 prompts.py create mode 100644 requirements.txt create mode 100644 schemas.py create mode 100644 start.bat create mode 100644 test_demo.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14faad9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.env +.venv +__pycache__ +*.pyc +.pytest_cache +.git +.gitignore +local_start.sh +start.bat +README.md +test_demo.py +_e2e_stub_server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b79f891 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b3a66cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Human Voice Rewrite Demo — 使用说明(给接手同事).md b/Human Voice Rewrite Demo — 使用说明(给接手同事).md new file mode 100644 index 0000000..6a99178 --- /dev/null +++ b/Human Voice Rewrite Demo — 使用说明(给接手同事).md @@ -0,0 +1,103 @@ +# Human Voice Rewrite Demo — 使用说明(给接手同事) + +这是一个**可以自己改、自己调**的独立 Demo:前端(单页 HTML)+ 后端(FastAPI)+ 真实 +OpenRouter 大模型调用。产品逻辑按《Human_Voice_Rewrite_PRD_v1.1》实现:AI 读懂与诊断 → +顾问人工重写 → AI 全文复检与精准返工。前台外观对齐 v6 Core Color Match 两栏原型。 + +## 一、快速启动 + +环境要求:本机有 Python 3.10+(macOS 一般自带;Windows 到 python.org 安装并勾选 +Add to PATH)。 + +**macOS / Linux**: + +```bash +./local_start.sh # 若提示无执行权限,先 chmod +x local_start.sh +``` + +**Windows**:双击 `start.bat`(或在 cmd / PowerShell 里运行 `start.bat`)。 + +> ⚠️ **必须用上面的启动脚本启动,不要直接运行 `uvicorn main:app`**: +> 脚本负责从 `.env` 把 OpenRouter Key 导出到环境变量。直接跑 uvicorn 服务虽然能起来 +> (健康检查正常),但所有 AI 调用会报「分析失败:缺少 OpenRouter Key」。 + +首次运行会自动创建 `.venv` 并安装依赖(需要联网)。看到 +`Human Voice Rewrite Demo: http://127.0.0.1:8000` 后,浏览器打开该地址即可。 + +- Key 已内置在本目录 `.env`(从公司后端环境变量导出,见第五节保密提醒) +- 端口冲突:macOS/Linux 用 `HVR_PORT=8001 ./local_start.sh`;Windows 先 + `set HVR_PORT=8001`(PowerShell 用 `$env:HVR_PORT=8001`)再运行 `start.bat` +- 停止:终端 `Ctrl+C` + +## 二、目录结构 + +| 文件 | 作用 | 同事常改的点 | +|---|---|---| +| `index.html` | 前端单页(两栏 Workbench、聚焦/全文、批注、调后端 API) | 示例文书 `SAMPLE_ESSAY`、示例题目 `SAMPLE_PROMPT`、页面文案 | +| `main.py` | FastAPI 入口与五个业务端点 | 一般不动 | +| `prompts.py` | **AI 提示词**(分析/诊断/复检/写作起点/参考片段)+ Pattern 库 | **调 AI 行为和输出质量主要改这里** | +| `llm.py` | OpenRouter 客户端(模型、温度、超时、重试) | 模型默认值 `DEFAULT_MODEL`、`temperature`、`max_tokens` | +| `schemas.py` | 前后端接口数据结构(对齐 PRD v1.1 §31) | 增删字段时前后端同步改 | +| `evidence.py` | Pattern / Annotation 证据核对(原文命不中则不画高亮) | 一般不动 | +| `local_start.sh` / `start.bat` | 一键启动(建 venv、读 .env、起服务) | 端口、env 路径 | +| `.env` | OpenRouter Key + 代理地址 | **换 Key / 换代理在这里** | +| `test_demo.py` | 离线测试(不消耗模型调用) | 改了逻辑后跑 `.venv/bin/pytest` | +| `README.md` | 项目说明 | — | + +## 三、三阶段业务流(真实模型) + +1. **文章理解** `POST /api/analyze`:全文理解 + 每段中文理解 + 语义锚点(PRD §31.1) +2. **人味诊断** `POST /api/diagnose`:命中 P01–P12 + 每段 Rewrite Direction / Inline + Annotation / Context Hint / Scaffold(PRD §31.2);顾问在对话里补充的理解和约束优先 +3. **全文复检** `POST /api/recheck`:六维度验收,通过即结束;需返工只返回一个段落 + 单一 + 返工目标,并绑定 `rewrite_version`(PRD §31.3);`/api/scaffold`、`/api/reference` + 只在顾问主动展开时使用,并写入 exposure 记录 + +前端流程:进入页(可粘贴自己的文章,自动分段)→ 开始分析 → 对话确认/纠偏 → 诊断 → +两栏逐段手写(左原文+批注,右安静写作区)→ 提交复检。段落数量按实际 `1–N` 动态渲染。 + +## 四、可以调什么(按频率) + +| 想调什么 | 改哪里 | +|---|---| +| 换模型 | `llm.py` 的 `DEFAULT_MODEL`,或启动前 `export HVR_LLM_MODEL=别的模型` | +| 输出更稳/更发散 | `llm.py` 的 `temperature`(默认 0.3)、`max_tokens`(默认 16000) | +| 复检更严/更松 | `prompts.py` 的 `build_recheck_prompt`(High Precision 规则在系统提示里) | +| Pattern 判定行为 | `prompts.py` 的 `PATTERN_LIBRARY`(P01–P12 定义 + 改写动作) | +| 示例文书/题目 | `index.html` 顶部的 `SAMPLE_ESSAY` / `SAMPLE_PROMPT` | +| 接口字段 | `schemas.py`(改后 `index.html` 里对应渲染处同步) | +| 重试/超时 | `llm.py` `complete_json`(任何失败自动重试 1 次)与 `timeout` | + +改完重启服务即可生效(后端代码改动重启启动脚本;纯前端改 `index.html` 直接刷新页面)。 + +## 五、Key 与保密提醒(重要) + +- `.env` 内是**公司真实 OpenRouter Key**(含区域代理地址),已随包分发,仅限内部使用: + - 不要把整个文件夹/压缩包发到外网、公开仓库、微信群外; + - 同事间拷贝前先确认接收人是内部人员; + - 若怀疑泄露,请更换 `prodream_backend/.env` 里的 + `PRODREAM_BACKEND_OPENROUTER_API_KEY` 并同步更新本包 `.env`。 +- 本目录在仓库里是 gitignored 的,不会误提交。 + +## 六、常见问题 + +| 现象 | 处理 | +|---|---| +| `错误:找不到环境变量文件` | 本目录缺 `.env`,或设置了 `HVR_BACKEND_ENV` 指向了不存在的文件 | +| 服务能启动、健康检查正常,但分析时报`分析失败:缺少 OpenRouter Key` | 没用启动脚本,直接跑了 `uvicorn main:app`。用 `start.bat` / `local_start.sh` 启动(脚本负责从 `.env` 导出 Key 到环境变量) | +| 端口被占用 | `HVR_PORT=8001 ./local_start.sh`(Windows 先 `set HVR_PORT=8001`) | +| 分析时报 `调用大模型失败(HTTP …)` | 看错误后半段:Key 失效→换 `.env`;403→代理问题(检查 `.env` 的代理地址);超时→网络 | +| `模型未返回内容(可能被截断或拒绝)` | 已自动重试 1 次仍失败,多半是网络抖动,点页面「重试」即可 | +| 页面空白 | 确认是从 `http://127.0.0.1:8000` 访问(不是直接双击 index.html,那样没有后端) | +| 想完全不用代理直连 | 把 `.env` 里 `PRODREAM_BACKEND_OPENROUTER_PROXY_URL` 那行删掉(国内网络可能不通) | +| 右侧显示「已保存」 | 只表示草稿已持久化,不等于这段已经改完,也不等于 AI 通过 | + +## 七、V1.1 不要再按旧 Demo 理解的几点 + +- Workbench 是两栏,不再有独立第三栏 Writing Coach +- `已保存` ≠ `已改写`;提交复检只看各段 `rewrite_text` 是否非空 +- 段落导航和进度必须按实际段数动态生成,不能写死 4 段 +- Recheck 检查的是提交时的 Snapshot;提交后继续改会进入新版本 +- 只有顾问真正展开过的 Scaffold / Reference 才会进入复制检测 +- 分析中(AI 初审 / 诊断 / 复检进行中)在对话页输入框输入并点发送:输入框内显示红色「AI 分析中,请稍候…」,已输入文本不清除(可继续输入,发送会被拦截,不会丢字) +- 刷新位置规则:改写进行中刷新 → 回 Workbench(改写与进度保留);理解/诊断结果态刷新 → 回对话页(历史卡完整);复检结果已返回(pass 卡/返工卡在对话页)刷新 → 停留对话页;只有复检请求进行中刷新才回 Workbench(可重新提交复检) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9243d71 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Human Voice Rewrite Demo(独立 Demo,接入真实 OpenRouter) + +按 `Human_Voice_Rewrite_PRD_v1.1` 做成打开即用的本地 Demo:前台交互对齐 +`Human Voice Rewrite Demo v6 · Core Color Match`,底层状态、版本、返工、保存 +与数据规则按 PRD 实现,不因演示而简化。所有 AI 内容走真实大模型(OpenRouter)。 + +> 分发给同事请直接给整个文件夹(或压缩包):含 `.env`(内置 Key)与 +> 「使用说明.md」。macOS/Linux 跑 `./local_start.sh`,Windows 双击 `start.bat`。 + +## 启动 + +> ⚠️ **必须用启动脚本启动,不要直接运行 `uvicorn main:app`**: +> 启动脚本负责从 `.env` 把 `OPENROUTER_API_KEY` 导出到环境变量,后端才读得到 Key。 +> 直接跑 uvicorn 服务虽能起来(健康检查正常),但 AI 调用会报 +> 「分析失败:缺少 OpenRouter Key」。 + +**Windows**:双击 `start.bat`(或在 cmd / PowerShell / Git Bash 中运行 `start.bat`), +看到 `Human Voice Rewrite Demo: http://127.0.0.1:8000` 后浏览器打开该地址。 + +**macOS / Linux**: + +```bash +./local_start.sh +# 打开 http://127.0.0.1:8000 +``` + +脚本会: + +1. 首次运行自动创建 `.venv` 并安装 `requirements.txt`; +2. 读取 Key(优先级:`HVR_BACKEND_ENV` 指定 > `../../prodream_backend/.env` 仓库环境 > + 本目录 `.env` 分发包);缺失时明确报错退出; +3. 若 `.env` 配了 `PRODREAM_BACKEND_OPENROUTER_PROXY_URL`,则作为正向代理 + (区域代理约定,httpx `proxy=`)访问 OpenRouter,否则直连 + `https://openrouter.ai/api/v1`。 + +环境变量覆盖:`HVR_LLM_MODEL`(默认 `deepseek/deepseek-v4-flash-0731`)、 +`HVR_PORT`(默认 8000)、`HVR_BACKEND_ENV`(后端 env 路径)、 +`OPENROUTER_BASE_URL` / `OPENROUTER_PROXY_URL`。 + +端口冲突:macOS/Linux 用 `HVR_PORT=8001 ./local_start.sh`;Windows 先 +`set HVR_PORT=8001`(PowerShell 用 `$env:HVR_PORT=8001`)再运行 `start.bat`。 +停止:终端 `Ctrl+C`。 + +(Mac 分发包拿到后如果 `./local_start.sh` 没有执行权限,先 `chmod +x local_start.sh`。) + +## 使用 + +- 进入页可输入/粘贴自己的文章(自动分段、实时字数),或「载入示例文书」; +- 「开始分析」→ 真实模型生成全文理解与分段理解;对话中可纠正理解、补充约束; +- 「理解准确,继续分析」→ 真实模型生成 Human Voice Diagnosis; +- 「确认方向,进入逐段改写」→ 两栏 Workbench:左「原文与改写提示」/ 右「我的人工改写」; + 支持动态段落导航、聚焦 / 全文双模式、原文同色 Evidence 批注; + Progressive Help 需主动展开(写作起点 → 参考片段);改写自动保存且与进度分离; +- 「完成全文改写,提交复检」→ 真实模型全文复检:通过即结束;需要返工则定位目标段, + 只给一个返工目标,并绑定 Recheck Snapshot。 + +## 接口 + +| 端点 | 说明 | +|---|---| +| `GET /api/health` | 健康检查 | +| `POST /api/analyze` | 文章理解(PRD §31.1) | +| `POST /api/diagnose` | Human Voice Diagnosis(PRD §31.2,含 annotations / evidence 校验) | +| `POST /api/recheck` | 全文复检(PRD §31.3,绑定 rewrite_version) | +| `POST /api/scaffold` | Progressive Help Level 1:写作起点(PRD §22.2) | +| `POST /api/reference` | Progressive Help Level 2:参考片段(PRD §22.3) | + +## 测试 + +```bash +.venv/bin/pytest -q +``` diff --git a/_e2e_stub_server.py b/_e2e_stub_server.py new file mode 100644 index 0000000..495b769 --- /dev/null +++ b/_e2e_stub_server.py @@ -0,0 +1,228 @@ +"""E2E stub server: deterministic LLM payloads per workflow stage. + +Injects a dispatcher into main_mod.get_client so the real FastAPI app, +pydantic validation, evidence sanitization and the frontend run unchanged. +Run on the same port the real server uses (index.html fetches /api/* same-origin). +""" +import uvicorn +import main as main_mod + +ANALYZE = { + "essay_summary": "文章以一支静止的铅笔开头,描述作者遇到一个无法立即解决的问题,并从中学会了耐心,最后回到书桌前继续面对。", + "prompt_alignment": {"prompt_intent": "", "current_alignment": "", "optional_opportunity": ""}, + "paragraphs": [ + { + "id": "p1", + "original_text": "The pencil in my hand was still. I wanted to find the answer.", + "natural_meaning_zh": "我手里的铅笔静止不动,我想要找到答案。", + "semantic_anchor": "铅笔静止暗示思考停滞,内心渴望答案。", + "optional_content_opportunity": "", + }, + { + "id": "p2", + "original_text": "I met a problem that I could not solve right away.", + "natural_meaning_zh": "我遇到了一个无法立刻解决的问题。", + "semantic_anchor": "明确点出问题的存在,且无法立即解决。", + "optional_content_opportunity": "", + }, + { + "id": "p3", + "original_text": "I learned to be patient.", + "natural_meaning_zh": "我学会了耐心。", + "semantic_anchor": "核心教训是耐心,但未说明如何学到。", + "optional_content_opportunity": "", + }, + { + "id": "p4", + "original_text": "Now, I sit at my desk again.", + "natural_meaning_zh": "现在,我再次坐在书桌前。", + "semantic_anchor": "回到起点,暗示重新面对问题,但未说明结果。", + "optional_content_opportunity": "", + }, + ], +} + +DIAGNOSE = { + "overall_diagnosis": "文章极短,但每一段都呈现典型的生成式写作模式:抽象陈述、缺乏具体细节、结构过于简化和对称。", + "patterns": [ + { + "pattern_id": "P02", + "name": "Explicit Lesson", + "category": "growth", + "affected_paragraphs": ["p3"], + "evidence": ["I learned to be patient."], + "why_ai_like": "总结式结尾是生成模型最顺手的收束方式。", + "human_impact": "直接说教,读者看不到具体如何学会。", + "transformation_rule": "Lesson -> Change in Judgment", + }, + { + "pattern_id": "P08", + "name": "Circular Ending", + "category": "structure", + "affected_paragraphs": ["p4"], + "evidence": ["Now, I sit at my desk again."], + "why_ai_like": "首尾呼应结构容易讨巧。", + "human_impact": "只剩结构对称,没有新信息。", + "transformation_rule": "Close on a Concrete Action", + }, + ], + "paragraph_briefs": [ + { + "paragraph_id": "p1", + "confirmed_meaning": "铅笔静止暗示思考停滞,内心渴望答案。", + "rewrite_goal": "把静止的铅笔转化为具体的场景与内心活动。", + "ai_focus": "抽象陈述;缺乏具体细节", + "must_preserve": [], + "annotations": [ + { + "pattern_id": "P01", + "category": "rhetoric", + "kind_label": "修辞包装", + "title": "象征开头", + "evidence": ["The pencil in my hand was still."], + "observation": "用静止铅笔暗示停滞,但没给任何具体场景。", + "rewrite_action": "补一个真实瞬间:笔停在纸上的位置、当时我在做什么。", + "why_ai_like": "象征开头是生成模型高频起笔方式。", + } + ], + "context_hint": "上一段已完成 0 个反射,这一段只需建立画面。", + "scaffold": "The pencil stayed in my hand because ______.", + "reference_snippet": "The pencil had stopped halfway across the page.", + }, + { + "paragraph_id": "p2", + "confirmed_meaning": "明确点出问题的存在,且无法立即解决。", + "rewrite_goal": "把问题具体化,写出具体内容和无法解决的原因。", + "ai_focus": "抽象陈述", + "must_preserve": [], + "annotations": [ + { + "pattern_id": "P01", + "category": "rhetoric", + "kind_label": "修辞包装", + "title": "泛化问题", + "evidence": ["a problem that I could not solve"], + "observation": "问题本身是空洞的泛指。", + "rewrite_action": "写出问题具体是什么。", + "why_ai_like": "泛化名词让生成模型无需真实素材。", + } + ], + "context_hint": "上一段已建立画面,这一段写具体困境。", + "scaffold": "The problem was ______.", + "reference_snippet": "", + }, + { + "paragraph_id": "p3", + "confirmed_meaning": "核心教训是耐心,但未说明如何学到。", + "rewrite_goal": "把耐心转化为具体行为,并保留 secret code。", + "ai_focus": "总结式说教", + "must_preserve": ["secret code"], + "annotations": [ + { + "pattern_id": "P02", + "category": "growth", + "kind_label": "抽象总结", + "title": "直接点题", + "evidence": ["I learned to be patient."], + "observation": "用一句总结构束教训。", + "rewrite_action": "写出耐心在行为上如何体现,保留 secret code。", + "why_ai_like": "教训总结是生成模型收束习惯。", + } + ], + "context_hint": "上一段已写具体困境,这一段写困境中的一次具体选择。", + "scaffold": "Each day I ______ instead of ______.", + "reference_snippet": "", + }, + { + "paragraph_id": "p4", + "confirmed_meaning": "回到起点,暗示重新面对问题,但未说明结果。", + "rewrite_goal": "把回到起点转化为具体行动,暗示改变但不总结。", + "ai_focus": "结构对称", + "must_preserve": [], + "annotations": [ + { + "pattern_id": "P08", + "category": "structure", + "kind_label": "结构路标", + "title": "首尾呼应", + "evidence": ["Now, I sit at my desk again."], + "observation": "用呼应结构收尾,没有新内容。", + "rewrite_action": "写一个具体的下一步动作。", + "why_ai_like": "首尾对称让文章看起来完整。", + } + ], + "context_hint": "上一段已写具体选择,这一段以行动收尾,不写总结。", + "scaffold": "This time, I ______.", + "reference_snippet": "", + }, + ], + "optional_suggestion": "若有真实素材,可补当时问题发生的具体场景。", +} + +RECHECK_PASS = { + "status": "pass", + "checked_rewrite_version": "rv_1", + "global_checks": { + "semantic_preservation": "pass", + "voice_consistency": "pass", + "pattern_reduction": "pass", + "new_pattern": "pass", + "coherence": "pass", + "reference_copying": "pass", + "word_limit": "pass", + }, + "revision_targets": [], +} + +SCAFFOLD = {"paragraph_id": "p1", "scaffold": "The pencil stayed in my hand because ______."} +REFERENCE = {"paragraph_id": "p1", "starter": "The pencil had stopped halfway across the page.", "reference_snippet": "The pencil had stopped halfway across the page."} + +RECHECK_REVISION = { + "status": "revision_required", + "checked_rewrite_version": "rv_1", + "global_checks": { + "semantic_preservation": "pass", + "voice_consistency": "pass", + "pattern_reduction": "pass", + "new_pattern": "pass", + "coherence": "pass", + "reference_copying": "pass", + "word_limit": "pass", + }, + "revision_targets": [ + { + "paragraph_id": "p3", + "blocking_issue": "新版本仍是 Explicit Lesson:直接总结学到了什么。", + "evidence": ["gradually came to realize"], + "single_revision_goal": "不要总结我学到了什么,写判断怎么变化。", + } + ], +} + + +class StubCompleter: + def complete_json(self, system, user, validate=None): + # NOTE: system prompts share a _TASK_CHAIN prefix (contains 全文复检 etc.) + # so dispatch must use stage-unique keywords, most specific first. + if "给我一个写作起点" in system: + return SCAFFOLD + if "Level 2" in system: + return REFERENCE + if "Human Voice Diagnosis" in system: + return DIAGNOSE + if "最终阶段" in system: + # mockFillFail injects revision-case wording; detect it to serve the + # revision_required path, otherwise the golden-path pass response. + # NOTE: cannot use "gradually came to realize" — previous_revision_target + # is embedded in the prompt (with its evidence quoting that phrase), so it + # matches on every resubmission even when the current text is fixed. + if "uncertainty is an important part of growth" in user: + return RECHECK_REVISION + return RECHECK_PASS + return ANALYZE + + +main_mod.get_client = lambda: StubCompleter() + +if __name__ == "__main__": + uvicorn.run(main_mod.app, host="127.0.0.1", port=8000, log_level="warning") diff --git a/evidence.py b/evidence.py new file mode 100644 index 0000000..caa0415 --- /dev/null +++ b/evidence.py @@ -0,0 +1,121 @@ +"""Evidence validation for Diagnosis (PRD §31.2.1 / §35.2). + +Evidence must appear in the corresponding Original Text. Unmatched +evidence is discarded so the frontend never paints fictional anchors. +Patterns without any verified evidence are kept in the diagnosis table +but lose their evidence list (no fake highlights). +""" + +from __future__ import annotations + +import re + +from schemas import Annotation, DiagnoseResponse, Pattern + +_WS = re.compile(r"\s+") + +PATTERN_CATEGORY: dict[str, str] = { + "P01": "growth", + "P02": "growth", + "P03": "rhetoric", + "P04": "repeat", + "P05": "rhetoric", + "P06": "repeat", + "P07": "growth", + "P08": "structure", + "P09": "growth", + "P10": "growth", + "P11": "repeat", + "P12": "rhetoric", +} + +KIND_LABEL: dict[str, str] = { + "rhetoric": "修辞包装", + "repeat": "重复 / 工整", + "growth": "抽象 / 总结", + "structure": "结构 / 路标", +} + +VALID_CATEGORIES = set(KIND_LABEL) + + +def normalize(s: str) -> str: + return _WS.sub(" ", s).strip().lower() + + +def evidence_in_text(evidence: str, text: str) -> bool: + ev = normalize(evidence) + if not ev: + return False + return ev in normalize(text) + + +def verified_evidence(items: list[str], texts: list[str]) -> list[str]: + haystacks = [normalize(t) for t in texts] + hits: list[str] = [] + seen: set[str] = set() + for raw in items: + ev = normalize(raw) + if not ev or ev in seen: + continue + if any(ev in h for h in haystacks): + hits.append(raw) + seen.add(ev) + return hits + + +def category_of(pattern_id: str, fallback: str = "") -> str: + cat = (fallback or "").strip().lower() + if cat in VALID_CATEGORIES: + return cat + return PATTERN_CATEGORY.get((pattern_id or "").upper(), "rhetoric") + + +def sanitize_patterns(patterns: list[Pattern], paragraphs: list[str]) -> list[Pattern]: + """Keep patterns, but drop evidence that cannot be found in the original.""" + out: list[Pattern] = [] + for pat in patterns: + hits = verified_evidence(pat.evidence, paragraphs) if pat.evidence else [] + cat = category_of(pat.pattern_id, pat.category) + out.append(pat.model_copy(update={"evidence": hits, "category": cat})) + return out + + +def sanitize_annotation(note: Annotation, paragraph_text: str, index: int) -> Annotation | None: + hits = verified_evidence(note.evidence, [paragraph_text]) if note.evidence else [] + if not hits: + return None + cat = category_of(note.pattern_id, note.category) + return note.model_copy( + update={ + "annotation_id": note.annotation_id or f"a{index + 1}", + "evidence": hits, + "category": cat, + "kind_label": note.kind_label or KIND_LABEL.get(cat, "修辞包装"), + } + ) + + +def sanitize_diagnosis(resp: DiagnoseResponse, paragraphs: list[str]) -> DiagnoseResponse: + """Filter fictional evidence and cap per-paragraph annotations at 3.""" + patterns = sanitize_patterns(resp.patterns, paragraphs) + briefs = [] + for brief in resp.paragraph_briefs: + idx = _paragraph_index(brief.paragraph_id) + text = paragraphs[idx] if 0 <= idx < len(paragraphs) else "" + notes: list[Annotation] = [] + for i, note in enumerate(brief.annotations): + cleaned = sanitize_annotation(note, text, i) + if cleaned: + notes.append(cleaned) + if len(notes) >= 3: + break + briefs.append(brief.model_copy(update={"annotations": notes})) + return resp.model_copy(update={"patterns": patterns, "paragraph_briefs": briefs}) + + +def _paragraph_index(pid: str) -> int: + digits = "".join(ch for ch in (pid or "") if ch.isdigit()) + if not digits: + return -1 + return int(digits) - 1 diff --git a/index.html b/index.html new file mode 100644 index 0000000..464be80 --- /dev/null +++ b/index.html @@ -0,0 +1,1369 @@ + + + + + +智能改稿 · Human Voice Rewrite Demo + + + +
+
+
Student Desk/李小娟-Essay Writing/Common App Essay
+
Common App · Prompt 1SavedDraft v1•••
+
+ +
+
+
+
+

Beyond the Answer: The Beauty of the Struggle

+
+ + +
+
+ +
+
+
+
+
+ +
+
+ +
+ + + + +
+ + + + diff --git a/llm.py b/llm.py new file mode 100644 index 0000000..01d29ba --- /dev/null +++ b/llm.py @@ -0,0 +1,248 @@ +"""OpenRouter chat-completions client (S2 seam). + +OpenAI-compatible direct client — zero heavy deps (httpx only). Key / base URL +/ model all come from the environment; the client is injectable for tests +(transport or a stub subclass). No call-quota logic by design (CONTRACT C3: +no artificial limits on real calls; only ordinary robustness retries). +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from typing import Any, Callable + +import httpx + +logger = logging.getLogger("hvr.llm") + +DEFAULT_MODEL = "deepseek/deepseek-v4-flash-0731" +DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" + +_JSON_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$") + + +class LLMError(Exception): + """User-readable upstream failure (HTTP/network/parse-after-retry).""" + + +def resolve_env(env: dict[str, str] | None = None) -> dict[str, str]: + """Resolve runtime config. Key stays external (CONTRACT C2).""" + env = env or dict(os.environ) + api_key = env.get("OPENROUTER_API_KEY") or env.get("PRODREAM_BACKEND_OPENROUTER_API_KEY") or "" + base_url = (env.get("OPENROUTER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") + # 正向代理(含 Basic Auth),如后端 .env 的 PRODREAM_BACKEND_OPENROUTER_PROXY_URL + proxy_url = env.get("OPENROUTER_PROXY_URL") or env.get("PRODREAM_BACKEND_OPENROUTER_PROXY_URL") or "" + model = env.get("HVR_LLM_MODEL") or DEFAULT_MODEL + return {"api_key": api_key, "base_url": base_url, "proxy_url": proxy_url, "model": model} + + +def _join_content_parts(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, dict): + parts.append(str(part.get("text") or "")) + return "".join(parts) + return "" + + +def message_text(data: dict[str, Any]) -> str: + """Read assistant text from an OpenAI-compatible completion payload. + + DeepSeek V4 / OpenRouter reasoning models often put thinking in + ``reasoning`` / ``reasoning_content`` and leave ``content`` empty when + the shared output budget is exhausted. + """ + try: + choice = data["choices"][0] + message = choice.get("message") or {} + except (KeyError, IndexError, TypeError) as exc: + raise LLMError("大模型响应缺少内容字段") from exc + text = _join_content_parts(message.get("content")) + if text.strip(): + return text + finish = str(choice.get("finish_reason") or "") + has_reasoning = bool( + message.get("reasoning") + or message.get("reasoning_content") + or message.get("reasoning_details") + ) + extra: list[str] = [] + if finish: + extra.append(f"finish_reason={finish}") + if has_reasoning: + extra.append("思考过程占满了输出额度") + suffix = ";".join(extra) if extra else "可能被截断或拒绝" + raise LLMError(f"模型未返回内容({suffix})") + + +def reasoning_config(disable_reasoning: bool = False) -> dict[str, Any]: + """OpenRouter rejects payloads that set both ``effort`` and ``max_tokens``.""" + if disable_reasoning: + return {"effort": "none", "exclude": True} + return {"max_tokens": 2048} + + +def extract_json(content: str | None) -> dict[str, Any]: + """Parse model output as JSON. Strips ```json fences and trims to the + outermost balanced {...} region.""" + if not content or not content.strip(): + raise LLMError("模型未返回内容(可能被截断或拒绝)") + text = _JSON_FENCE.sub("", content.strip()).strip() + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + start = text.find("{") + end = text.rfind("}") + if start != -1 and end > start: + try: + parsed = json.loads(text[start : end + 1]) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + raise LLMError("模型输出不是有效的 JSON(解析失败)") + + +def _readable_error(resp: httpx.Response) -> str: + """从上游错误响应提取一行用户可读信息(优先 error.message), + 不把完整 JSON body 甩给页面(PRD §11 失败兜底)。""" + try: + body = resp.json() + if isinstance(body, dict): + msg = (body.get("error") or {}).get("message", "") if isinstance(body.get("error"), dict) else "" + if msg: + return msg[:300] + except Exception: + pass + return resp.text[:200] + + +class LlmClient: + """Thin OpenAI-compatible client. `transport`/`client` injectable for tests.""" + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + model: str | None = None, + proxy: str | None = None, + timeout: float = 180.0, + max_tokens: int = 16000, + temperature: float = 0.3, + transport: httpx.BaseTransport | None = None, + ) -> None: + cfg = resolve_env() + self.api_key = api_key or cfg["api_key"] + self.base_url = base_url or cfg["base_url"] + self.model = model or cfg["model"] + self.proxy = proxy if proxy is not None else cfg["proxy_url"] + self.timeout = timeout + self.max_tokens = max_tokens + self.temperature = temperature + if not self.api_key: + raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)") + self._transport = transport + + # -- internal ---------------------------------------------------------- + def _post(self, payload: dict[str, Any]) -> httpx.Response: + kwargs: dict[str, Any] = {"timeout": self.timeout} + if self._transport is not None: + kwargs["transport"] = self._transport + # trust_env=False + 显式 proxy:直连配置的 base_url,不继承本机 Surge 等系统代理; + # 区域代理(PRODREAM_BACKEND_OPENROUTER_PROXY_URL)作为正向代理传入 + with httpx.Client(trust_env=False, proxy=self.proxy or None, **kwargs) as client: + resp = client.post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + return resp + + def _complete( + self, + system: str, + user: str, + json_reminder: bool = False, + disable_reasoning: bool = False, + ) -> str: + messages = [{"role": "system", "content": system}] + if json_reminder: + messages.append( + {"role": "system", "content": "只输出符合要求的 JSON,不要输出任何其它文字、解释或代码块标记。"} + ) + messages.append({"role": "user", "content": user}) + payload: dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "response_format": {"type": "json_object"}, + } + # DeepSeek V4 Flash 的思考 token 与正文共用输出额度;不限制时 + # diagnose 这类长 JSON 经常只返回空 content(页面上就是 502)。 + # OpenRouter:effort 与 max_tokens 只能二选一。 + payload["reasoning"] = reasoning_config(disable_reasoning) + try: + resp = self._post(payload) + except httpx.HTTPError as exc: # network / timeout + raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc + if resp.status_code != 200: + raise LLMError(f"调用大模型失败(HTTP {resp.status_code}):{_readable_error(resp)}") + return message_text(resp.json()) + + # -- public ------------------------------------------------------------ + def complete_json( + self, + system: str, + user: str, + validate: Callable[[dict[str, Any]], str | None] | None = None, + ) -> dict[str, Any]: + """Structured completion with robustness retries (SEAMS default: + any LLMError (parse failure / empty content / transient upstream + failure) is retried once, the retry carrying a JSON-only reminder + and turning reasoning off so the answer is not crowded out). + No quota logic. + + `validate` is an optional semantic check on the parsed JSON: return + None when acceptable, or a one-line Chinese explanation when not + (e.g. per-paragraph arrays must match the input paragraph count). + A failing check is treated exactly like a parse failure — logged, + retried once, only surfaced after both attempts fail.""" + last_error: LLMError | None = None + for attempt in range(2): + try: + content = self._complete( + system, + user, + json_reminder=(attempt == 1), + disable_reasoning=(attempt == 1), + ) + parsed = extract_json(content) + if validate is not None: + problem = validate(parsed) + if problem: + raise LLMError(problem) + if attempt == 1: + logger.warning("llm retry succeeded after first failure: %s", last_error) + return parsed + except LLMError as exc: + last_error = exc + if attempt == 0: + logger.warning("llm call failed, retrying once: %s", exc) + assert last_error is not None + logger.error("llm call failed after 2 attempts: %s", last_error) + raise last_error diff --git a/local_start.sh b/local_start.sh new file mode 100644 index 0000000..3f872ca --- /dev/null +++ b/local_start.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Human Voice Rewrite Demo 一键启动 +# Key 读取顺序: +# 1) $HVR_BACKEND_ENV 显式指定 +# 2) 仓库环境 ../../prodream_backend/.env(本仓库开发时) +# 3) 本目录 .env(独立分发包:内置 Key 与代理地址) +# 首次运行自动创建独立 .venv 并安装依赖 +# 用法: ./local_start.sh (端口可用 HVR_PORT 覆盖,默认 8000) +set -euo pipefail +cd "$(dirname "$0")" + +if [ ! -d .venv ]; then + echo "首次运行:创建独立虚拟环境并安装依赖…" + python3 -m venv .venv + .venv/bin/pip install -q --upgrade pip + .venv/bin/pip install -q -r requirements.txt +fi + +BACKEND_ENV="${HVR_BACKEND_ENV:-}" +if [ -z "$BACKEND_ENV" ]; then + if [ -f "../../prodream_backend/.env" ]; then + BACKEND_ENV="../../prodream_backend/.env" + elif [ -f ".env" ]; then + BACKEND_ENV=".env" + fi +fi +if [ -z "$BACKEND_ENV" ] || [ ! -f "$BACKEND_ENV" ]; then + echo "错误:找不到环境变量文件。可用 HVR_BACKEND_ENV 指定,或在本目录放置 .env" >&2 + exit 1 +fi + +# 只提取需要的 key,绝不 source 整个 .env(dotenv 格式不是 bash 语法,直接执行会出错) +pick() { # pick KEY -> 值(去首尾引号) + local line value + line="$(grep -E "^$1=" "$2" 2>/dev/null | head -1 || true)" + value="${line#*=}" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} +API_KEY="$(pick PRODREAM_BACKEND_OPENROUTER_API_KEY "$BACKEND_ENV")" +PROXY_URL="$(pick PRODREAM_BACKEND_OPENROUTER_PROXY_URL "$BACKEND_ENV")" + +if [ -z "$API_KEY" ]; then + echo "错误:$BACKEND_ENV 中没有配置 PRODREAM_BACKEND_OPENROUTER_API_KEY" >&2 + exit 1 +fi + +export OPENROUTER_API_KEY="$API_KEY" +export OPENROUTER_PROXY_URL="$PROXY_URL" +PORT="${HVR_PORT:-8000}" + +echo "Human Voice Rewrite Demo: http://127.0.0.1:${PORT}" +exec .venv/bin/uvicorn main:app --host 127.0.0.1 --port "$PORT" diff --git a/main.py b/main.py new file mode 100644 index 0000000..825588a --- /dev/null +++ b/main.py @@ -0,0 +1,203 @@ +"""Human Voice Rewrite Demo backend (PRD v1.1). + +Serves the static demo page and domain endpoints that run the real +OpenRouter pipeline. Run via ./local_start.sh. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse + +from evidence import sanitize_diagnosis +from llm import LLMError, LlmClient +from prompts import ( + build_analyze_prompt, + build_diagnose_prompt, + build_recheck_prompt, + build_reference_prompt, + build_scaffold_prompt, + normalize_diagnose_payload, +) +from schemas import ( + AnalyzeRequest, + AnalyzeResponse, + DiagnoseRequest, + DiagnoseResponse, + GlobalChecks, + RecheckRequest, + RecheckResponse, + ReferenceRequest, + ReferenceResponse, + RevisionTarget, + ScaffoldRequest, + ScaffoldResponse, +) + +app = FastAPI(title="Human Voice Rewrite Demo", version="1.1.0") + +client: LlmClient | None = None +HERE = Path(__file__).resolve().parent + + +def get_client() -> LlmClient: + global client + if client is None: + client = LlmClient() + return client + + +def run_json(phase: str, system: str, user: str, check: Callable[[dict[str, Any]], str | None] | None = None) -> dict: + try: + return get_client().complete_json(system, user, validate=check) + except LLMError as exc: + raise HTTPException(status_code=502, detail=f"{phase}失败:{exc}") from exc + + +def _count_check(expected: int, key: str) -> Callable[[dict[str, Any]], str | None]: + """返回语义校验器:LLM 输出里按段组织的数组条目数必须与输入段数一致。 + + 不一致时返回一行中文说明(触发 complete_json 重试一次;两次都失败才变 + 502 单行错误)——真因:真实 LLM 偶发返回合法 JSON 但段落条目不全, + 此前无数量校验被静默接受,AI 初审出现"4 段标题只显示 1 段"(用户实测)。""" + + def check(data: dict[str, Any]) -> str | None: + got = len(data.get(key) or []) + if got != expected: + return f"结果段落数与输入不一致(输入 {expected} 段,返回 {got} 段)" + return None + + return check + + +def _friendly_validation(exc: Exception) -> str: + """把 pydantic ValidationError 压缩成一行用户可读摘要(PRD §11 失败兜底: + 返回用户可读错误,不把完整校验堆栈甩给页面)。""" + errs = getattr(exc, "errors", lambda: [])() + if not errs: + return f"结果格式不符合预期:{exc}" + parts = [] + for e in errs[:2]: + loc = " → ".join(str(x) for x in e.get("loc", [])) + parts.append(f"{loc}:{e.get('msg', '')}" if loc else e.get("msg", "")) + more = f"(共 {len(errs)} 处)" if len(errs) > 2 else "" + return "结果格式不符合预期:" + ";".join(parts) + more + + +@app.get("/api/health") +def health() -> dict: + return {"status": "ok"} + + +@app.post("/api/analyze", response_model=AnalyzeResponse) +def analyze(req: AnalyzeRequest) -> AnalyzeResponse: + if not any(p.strip() for p in req.paragraphs): + raise HTTPException(status_code=422, detail="文本为空,无法开始分析") + system, user = build_analyze_prompt(req.prompt, req.word_limit, req.paragraphs, req.constraints) + data = run_json("分析", system, user, check=_count_check(len(req.paragraphs), "paragraphs")) + try: + return AnalyzeResponse.model_validate(data) + except Exception as exc: # pydantic ValidationError etc. + raise HTTPException(status_code=502, detail=f"分析{_friendly_validation(exc)}") from exc + + +@app.post("/api/diagnose", response_model=DiagnoseResponse) +def diagnose(req: DiagnoseRequest) -> DiagnoseResponse: + system, user = build_diagnose_prompt( + req.prompt, + req.word_limit, + req.paragraphs, + req.confirmed_anchors, + req.global_constraints, + req.paragraph_constraints, + req.initial_analysis, + ) + data = run_json("诊断", system, user, check=_count_check(len(req.paragraphs), "paragraph_briefs")) + data = normalize_diagnose_payload(data) + try: + resp = DiagnoseResponse.model_validate(data) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"诊断{_friendly_validation(exc)}") from exc + return sanitize_diagnosis(resp, req.paragraphs) + + +@app.post("/api/recheck", response_model=RecheckResponse) +def recheck(req: RecheckRequest) -> RecheckResponse: + if len(req.original_paragraphs) != len(req.rewrite_paragraphs): + raise HTTPException(status_code=422, detail="原文段数与改写段数不一致") + if any(not p.strip() for p in req.rewrite_paragraphs): + raise HTTPException(status_code=422, detail="存在空段:全部段落完成改写后才能提交复检") + system, user = build_recheck_prompt(req) + data = run_json("复检", system, user) + if isinstance(data.get("status"), str): + data["status"] = "pass" if data["status"] == "pass" else "revision_required" + if not data.get("checked_rewrite_version"): + data["checked_rewrite_version"] = req.rewrite_version or "" + try: + resp = RecheckResponse.model_validate(data) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"复检{_friendly_validation(exc)}") from exc + if resp.status == "revision_required" and not resp.revision_targets: + resp = RecheckResponse( + status="revision_required", + checked_rewrite_version=resp.checked_rewrite_version or req.rewrite_version, + global_checks=resp.global_checks or GlobalChecks(), + revision_targets=[ + RevisionTarget( + paragraph_id="p1", + blocking_issue="模型判定需要返工但未指明段落", + single_revision_goal="重读当前段改写并修正最明显的问题", + ) + ], + ) + return resp + + +@app.post("/api/scaffold", response_model=ScaffoldResponse) +def scaffold(req: ScaffoldRequest) -> ScaffoldResponse: + system, user = build_scaffold_prompt( + req.paragraph_id, + req.original_text, + req.semantic_anchor, + req.rewrite_goal, + req.global_constraints, + req.paragraph_constraints, + ) + data = run_json("写作起点", system, user) + try: + resp = ScaffoldResponse.model_validate(data) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"写作起点{_friendly_validation(exc)}") from exc + resp.paragraph_id = resp.paragraph_id or req.paragraph_id + return resp + + +@app.post("/api/reference", response_model=ReferenceResponse) +def reference(req: ReferenceRequest) -> ReferenceResponse: + goal = req.rewrite_goal or req.primary_goal + system, user = build_reference_prompt( + req.paragraph_id, + req.original_text, + req.semantic_anchor, + goal, + req.global_constraints, + req.paragraph_constraints, + ) + data = run_json("参考片段", system, user) + try: + resp = ReferenceResponse.model_validate(data) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"参考片段{_friendly_validation(exc)}") from exc + snippet = resp.reference_snippet or resp.starter + resp.paragraph_id = resp.paragraph_id or req.paragraph_id + resp.starter = snippet + resp.reference_snippet = snippet + return resp + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(HERE / "index.html", media_type="text/html") diff --git a/prompts.py b/prompts.py new file mode 100644 index 0000000..7344576 --- /dev/null +++ b/prompts.py @@ -0,0 +1,392 @@ +"""Prompt engineering for the three-stage Human Voice Rewrite workflow. + +Implements PRD v1.1 §44 的核心产品约束 —— 任务链不是 "make it more human": +理解作者原意 → Semantic Anchor → 识别实际存在的生成式 Pattern → +Pattern → Human Rewrite Method → 不完整写作支架 → 全文复检 → 只阻塞真问题。 + +优先级(PRD §10.1):顾问确认理解 > 顾问补充约束 > 模型初始理解。 +Recheck(PRD §28):第一轮 High Recall,最终复检 High Precision。 +""" + +from __future__ import annotations + +import json + +from evidence import KIND_LABEL, PATTERN_CATEGORY +from schemas import AnalyzeResponse, RecheckRequest + +# -------------------------------------------------------------------------- +# PRD §13 V1 AI Pattern Library —— 作为分析 taxonomy 注入,不直接全部展示用户 +PATTERN_LIBRARY: list[dict[str, str]] = [ + {"id": "P01", "name": "Trait Declaration", "signal": "I became more resilient", "rule": "Trait → Behavior / Mental Reaction"}, + {"id": "P02", "name": "Explicit Lesson", "signal": "I learned that...", "rule": "Lesson → Change in Judgment"}, + {"id": "P03", "name": "Aphorism", "signal": "X was not Y. X was Z.", "rule": "Aphorism → Personal Observation"}, + {"id": "P04", "name": "Parallelism", "signal": "A/B/C;连续相同句式", "rule": "Break Symmetry"}, + {"id": "P05", "name": "Metaphor Stack", "signal": "同一个意思连续多个比喻", "rule": "Keep One Motif"}, + {"id": "P06", "name": "Over-explanation", "signal": "同一意义多次解释", "rule": "Semantic Compression"}, + {"id": "P07", "name": "Perfect Growth", "signal": "failure → resilience → growth", "rule": "Calibrated Change"}, + {"id": "P08", "name": "Explicit Transition", "signal": "Slowly / Now I realized / At first", "rule": "Natural Transition"}, + {"id": "P09", "name": "Abstract Nouns", "signal": "growth / resilience / uncertainty", "rule": "Abstract → Experience / Verb"}, + {"id": "P10", "name": "Over-closure", "signal": "结尾连续多次升华", "rule": "One Ending Idea"}, + {"id": "P11", "name": "Synonym Rewrite", "signal": "learned → realized → understood", "rule": "Reframe, Don't Replace"}, + {"id": "P12", "name": "Literary Overpackaging", "signal": "大量拟人、抽象意象", "rule": "De-rhetorize"}, +] + +_TASK_CHAIN = """你的任务链(每一步都不能跳过): +1. 先理解作者原意,不评价质量; +2. 为全文与每一段建立 Semantic Anchor(必须保留的核心意思); +3. 识别文章中实际存在的生成式写作 Pattern(不是判断"是不是 AI 写的"); +4. 把每个 Pattern 映射成明确的人工改写动作(Transformation Rule); +5. 提供有限度的写作支架(起笔方向),不替顾问完成关键表达; +6. 全文复检只阻塞真正需要返工的问题,给出单一返工目标。""" + + +def _paragraphs_block(paragraphs: list[str]) -> str: + return "\n\n".join(f"{text}" for idx, text in enumerate(paragraphs, start=1)) + + +def _constraints_block(global_constraints: list[str], paragraph_constraints: dict[str, list[str]]) -> str: + lines: list[str] = [] + if global_constraints: + lines.append("顾问全局要求:" + ";".join(global_constraints)) + if paragraph_constraints: + lines.append("顾问分段要求:" + ";".join( + f"{pid} -> {';'.join(v)}" for pid, v in paragraph_constraints.items() if v + )) + if lines: + return "\n".join(lines) + return "(无)" + + +# -------------------------------------------------------------------------- +# 阶段一:文章理解(PRD §8/§9/§31.1) +def build_analyze_prompt( + prompt: str, + word_limit: int | None, + paragraphs: list[str], + constraints: list[str], +) -> tuple[str, str]: + system = f"""{_TASK_CHAIN} + +你是 Human Voice Rewrite 工作流的第一阶段:文章理解。这一轮不输出 AI 味问题,只确认你真正读懂了这篇文章。 + +要求: +- 全文与每段都给出接近自然翻译的中文理解;段落理解要让顾问基本能懂原英文在表达什么,禁止只写"建立冲突""人物成长"这类结构术语。 +- semantic_anchor 是"这段必须保留的核心意思",用于防止后续改写改偏。 +- 结合 Essay Prompt 判断文章如何回应题目(prompt 为空时 prompt_alignment 各字段给空字符串,不要编造题目)。 +- optional_content_opportunity 只判断"哪些地方如果有真实学生素材会更有人味";禁止编造新人物、新事件、新时间、新数字、新事实、新情绪。没有素材机会就写空字符串。 +- 不做综合质量评分。 +- paragraphs 数组必须与输入段落数完全一致:每段一条、按输入顺序;禁止合并、遗漏或拆分段落。 +- 只输出 JSON,结构: +{{"essay_summary": "", "prompt_alignment": {{"prompt_intent": "", "current_alignment": "", "optional_opportunity": ""}}, "paragraphs": [{{"id": "p1", "original_text": "", "natural_meaning_zh": "", "semantic_anchor": "", "optional_content_opportunity": ""}}]}}""" + + user = ( + f"Essay Prompt:{prompt or '(无,隐藏 prompt 对应分析)'}\n" + f"Word Limit:{word_limit if word_limit else '(未提供,不检查)'}\n" + f"已有顾问约束:{';'.join(constraints) if constraints else '(无)'}\n\n" + f"文书全文(已按段切分):\n{_paragraphs_block(paragraphs)}\n\n" + f"请输出 JSON。" + ) + return system, user + + +# -------------------------------------------------------------------------- +# 阶段二:Human Voice Diagnosis(PRD §11–§16 / §31.2) +def build_diagnose_prompt( + prompt: str, + word_limit: int | None, + paragraphs: list[str], + confirmed_anchors: list[str], + global_constraints: list[str], + paragraph_constraints: dict[str, list[str]], + initial_analysis: AnalyzeResponse | None, +) -> tuple[str, str]: + library = "\n".join( + f"{p['id']} {p['name']}(category={PATTERN_CATEGORY.get(p['id'], 'rhetoric')};" + f"常见表现:{p['signal']};改写动作:{p['rule']})" + for p in PATTERN_LIBRARY + ) + categories = ";".join(f"{k}={v}" for k, v in KIND_LABEL.items()) + system = f"""{_TASK_CHAIN} + +你是 Human Voice Rewrite 工作流的第二阶段:Human Voice Diagnosis。回答"为什么这篇会有 AI 感、哪几段具体有什么问题、人工重写时应该怎么处理"。 + +输出必须能直接支撑 Workbench 左侧 Focus Mode,而不是只生成一段独立 Coach 文本。 + +要求: +- 输出 Generative Writing Patterns,不输出 AI 概率 / AI Score / 是否 AI 写的判断。 +- 只输出当前文章实际命中的 Pattern;没有确凿原文证据不得硬凑。单段默认最多 1–3 个主要 Pattern / Annotation。 +- evidence 必须是该段原文中的原句或原短语,逐字引用;禁止改写原文当作证据;禁止输出原文不存在的片段。 +- 每个 Pattern 都要说明为什么生成模型容易这么写(why_ai_like)与为什么影响本人感(human_impact)。 +- category 只能是 rhetoric / repeat / growth / structure({categories})。 +- paragraph_briefs 必须与输入段落数完全一致(每段一条、按输入顺序),禁止合并、遗漏或拆分段落,字段含义: + - confirmed_meaning:这段在讲什么(以顾问确认后的语义锚点为准,接近自然中文) + - rewrite_goal:本轮人工要完成的变化,禁止只写"更自然 / 更具体 / 更像人" + - ai_focus:当前段最主要的 1–3 类 AI 味问题,必须能对应原文 evidence;顾问要求保留的表达不得再作为删除目标 + - annotations:锚定到原文 Evidence 的批注卡。observation=这里发生了什么;rewrite_action=具体怎么处理(必须可执行);why_ai_like=为什么这样会显 AI + - context_hint:和上下文怎么接。说明上一段已完成什么、当前段只需要完成什么、下一段才该发生什么、哪些 Reflection 不要提前写 + - scaffold:给顾问的轻量写作起点(填空 / 思考顺序 / 一句不完整起笔),不得构成完整段落,不得一次给多个成稿选项 + - reference_snippet:当前段 1–2 句局部参考,只用于说明改写动作,不得写成完整段落 +- 顾问约束优先级最高(PRD §10.1):confirmed_anchors 非空条目即顾问确认后的语义锚点;顾问要求保留的表达(must_preserve)不得再建议删除。 +- optional_suggestion 只给"有真实素材时可以补什么"的可选建议并明确标记可选;没有真实素材时禁止编造,此时给空字符串。 +- 不重新规划文章结构、不换故事、不评分。 +- 只输出 JSON,结构: +{{"overall_diagnosis": "", "patterns": [{{"pattern_id": "P05", "name": "Metaphor Stack", "category": "rhetoric", "affected_paragraphs": ["p1"], "evidence": [""], "why_ai_like": "", "human_impact": "", "transformation_rule": "Keep One Motif"}}], "paragraph_briefs": [{{"paragraph_id": "p1", "confirmed_meaning": "", "rewrite_goal": "", "ai_focus": "", "must_preserve": [], "annotations": [{{"pattern_id": "P05", "category": "rhetoric", "kind_label": "修辞包装", "title": "", "evidence": [""], "observation": "", "rewrite_action": "", "why_ai_like": ""}}], "context_hint": "", "scaffold": "", "reference_snippet": ""}}], "optional_suggestion": ""}}""" + + anchors = "\n".join( + f"{pid}:{anchor}" + for pid, anchor in zip([f"p{i}" for i in range(1, len(paragraphs) + 1)], confirmed_anchors) + if anchor + ) + user = ( + f"Essay Prompt:{prompt or '(无)'}\n" + f"Word Limit:{word_limit if word_limit else '(未提供)'}\n" + f"顾问确认后的各段语义锚点(以此为准):\n{anchors or '(无,以你的理解为默认)'}\n\n" + f"顾问约束:\n{_constraints_block(global_constraints, paragraph_constraints)}\n\n" + f"第一轮理解摘要(参考):\n{initial_analysis.essay_summary if initial_analysis else '(无)'}\n\n" + f"Pattern 参考库(只输出实际命中的):\n{library}\n\n" + f"文书全文:\n{_paragraphs_block(paragraphs)}\n\n" + f"请输出 JSON。" + ) + return system, user + + +# -------------------------------------------------------------------------- +# 阶段三:全文复检(PRD §26–§29 / §31.3) +def build_recheck_prompt(req: RecheckRequest) -> tuple[str, str]: + diagnosis_block = "" + if req.diagnosis: + diagnosis_block = json.dumps( + { + "overall_diagnosis": req.diagnosis.overall_diagnosis, + "patterns": [ + { + "pattern_id": p.pattern_id, + "name": p.name, + "category": p.category, + "affected_paragraphs": p.affected_paragraphs, + "evidence": p.evidence, + "transformation_rule": p.transformation_rule, + } + for p in req.diagnosis.patterns + ], + }, + ensure_ascii=False, + ) + previous_block = "" + if req.previous_recheck: + previous_block = json.dumps( + { + "status": req.previous_recheck.status, + "checked_rewrite_version": req.previous_recheck.checked_rewrite_version, + "revision_targets": [t.model_dump() for t in req.previous_recheck.revision_targets], + }, + ensure_ascii=False, + ) + target_block = "" + if req.previous_revision_target: + target_block = json.dumps(req.previous_revision_target.model_dump(), ensure_ascii=False) + system = f"""{_TASK_CHAIN} + +你是 Human Voice Rewrite 工作流的最终阶段:全文复检(High Precision —— 只阻塞真正需要返工的问题)。 +这是独立 Recheck Prompt,不要复用第一轮 Diagnosis 的教学口吻。 + +用六个维度检查改后全文(对照原文): +1. Semantic Preservation:每段是否保持已确认核心意思; +2. Voice Consistency:全文是否像同一个学生在写(语言复杂度、句长、情绪表达、修辞程度、抽象程度、语气); +3. Original Pattern Reduction:第一轮主要 Pattern 是否真正缓解; +4. New Pattern Emergence:是否出现新的替代模板(例如把 I learned... 换成 I gradually came to realize...,表面词变、底层仍是 Explicit Lesson); +5. Scaffold / Reference Copying:是否明显高度套用用户实际看过的支架或参考(对照 scaffolds_shown / references_shown)。未实际展开的参考不得作为"用户复制"的依据; +6. Global Coherence:上下文是否自然、是否重复同一 Reflection、是否风格跳变、是否碎片化、{('字数是否超过 ' + str(req.word_limit)) if req.word_limit else '字数(未提供则不检查,word_limit 给 pass)'}。 + +要求: +- 只输出真正 Blocking 的问题;轻微风格差异、个人风格选择一律 pass,不得因此返工(Stop Rule:剩余问题主要属于个人风格选择时给 pass)。 +- 返工时只允许 revision_targets 恰一条(首个阻塞段落),每条含单一返工目标 single_revision_goal —— 一句话说明这次只返工一件事。 +- 第一轮诊断提过且顾问已合理处理的问题,不得原样重复阻塞,除非它造成了新的严重全文问题并说明冲突。 +- 顾问约束优先级最高;顾问要求保留的表达不得因保留本身而被阻塞。 +- 若提供了 Previous Recheck / Previous Revision Target:先判断上次 Blocking Issue 是否真正解决,避免反复提出已处理的轻微问题。 +- 只输出 JSON,结构: +{{"status": "pass", "checked_rewrite_version": "{req.rewrite_version or 'rv_1'}", "global_checks": {{"semantic_preservation": "pass", "voice_consistency": "pass", "pattern_reduction": "pass", "new_pattern": "pass", "coherence": "pass", "reference_copying": "pass", "word_limit": "pass"}}, "revision_targets": [{{"paragraph_id": "p3", "blocking_issue": "", "evidence": [""], "single_revision_goal": ""}}]}} +其中 status 为 "pass" 时 revision_targets 为空数组;global_checks 每项取值 "pass" 或 "fail"。""" + + paired = "\n\n".join( + f"第 {i} 段({pid})\n原文:\n{orig}\n\n顾问改写:\n{rew}" + for i, (pid, orig, rew) in enumerate( + zip( + [f"p{i}" for i in range(1, len(req.original_paragraphs) + 1)], + req.original_paragraphs, + req.rewrite_paragraphs, + ), + start=1, + ) + ) + anchors = "\n".join( + f"{pid}:{anchor}" + for pid, anchor in zip([f"p{i}" for i in range(1, len(req.original_paragraphs) + 1)], req.confirmed_anchors) + if anchor + ) + user = ( + f"Essay Prompt:{req.prompt or '(无)'}\n" + f"Word Limit:{req.word_limit if req.word_limit else '(未提供)'}\n" + f"本次 Rewrite Version:{req.rewrite_version or '(未提供)'}\n" + f"顾问确认后的语义锚点:\n{anchors or '(无)'}\n\n" + f"顾问约束:\n{_constraints_block(req.global_constraints, req.paragraph_constraints)}\n\n" + f"第一轮 Diagnosis(用于检查原 Pattern 是否缓解):\n{diagnosis_block or '(无)'}\n\n" + f"顾问实际展开过的写作起点:\n{';'.join(req.scaffolds_shown) if req.scaffolds_shown else '(无)'}\n\n" + f"顾问实际展开过的参考片段:\n{';'.join(req.references_shown) if req.references_shown else '(无)'}\n\n" + f"上一轮 Recheck:\n{previous_block or '(无,这是首次复检)'}\n\n" + f"上一轮返工目标:\n{target_block or '(无)'}\n\n" + f"全文对照(这是锁定的 Recheck Snapshot,不要对照用户提交后可能继续改动的版本):\n{paired}\n\n" + f"请输出 JSON。" + ) + return system, user + + +# -------------------------------------------------------------------------- +# Progressive Help Level 1:写作起点(PRD §22.2) +def build_scaffold_prompt( + paragraph_id: str, + original_text: str, + semantic_anchor: str, + rewrite_goal: str, + global_constraints: list[str], + paragraph_constraints: list[str], +) -> tuple[str, str]: + system = f"""{_TASK_CHAIN} + +你是 Human Voice Rewrite 的渐进写作支架(Level 1:顾问主动点击「给我一个写作起点」)。 + +要求: +- 只给轻量 Scaffold:填空、思考顺序、一句不完整起笔,或中文问题提示。 +- 不构成完整段落;不一次提供多个成稿选项;不自动写成可提交答案。 +- 必须贴合语义锚点,服务于当前段 rewrite_goal,并遵守顾问约束。 +- 只输出 JSON:{{"scaffold": ""}}""" + + user = ( + f"段落:{paragraph_id}\n" + f"原文:\n{original_text}\n\n" + f"语义锚点(必须保留的核心意思):{semantic_anchor}\n" + f"本轮改写目标:{rewrite_goal}\n" + f"顾问约束:\n{_constraints_block(global_constraints, {paragraph_id: paragraph_constraints} if paragraph_constraints else {})}\n\n" + f"请输出 JSON。" + ) + return system, user + + +# -------------------------------------------------------------------------- +# Progressive Help Level 2:参考片段(PRD §22.3) +def build_reference_prompt( + paragraph_id: str, + original_text: str, + semantic_anchor: str, + rewrite_goal: str, + global_constraints: list[str], + paragraph_constraints: list[str], +) -> tuple[str, str]: + system = f"""{_TASK_CHAIN} + +你是 Human Voice Rewrite 的渐进写作支架(Level 2:顾问仍卡住时看一个参考片段)。 + +要求: +- 只给当前段 1–2 句局部参考(starter / reference_snippet)。 +- 禁止生成完整当前段;禁止生成完整全文;禁止写成可直接提交的答案。 +- 参考只用于说明改写动作,不需要沿用同一句式。 +- 必须贴合语义锚点,服务于当前段 rewrite_goal,并遵守顾问约束。 +- 只输出 JSON:{{"starter": "", "reference_snippet": ""}} + 两个字段写同一段 1–2 句参考即可。""" + + user = ( + f"段落:{paragraph_id}\n" + f"原文:\n{original_text}\n\n" + f"语义锚点(必须保留的核心意思):{semantic_anchor}\n" + f"本轮改写目标:{rewrite_goal}\n" + f"顾问约束:\n{_constraints_block(global_constraints, {paragraph_id: paragraph_constraints} if paragraph_constraints else {})}\n\n" + f"请输出 JSON。" + ) + return system, user + + +def _as_text(value, sep=";") -> str: + """模型偶发把字符串字段返回成数组(如 ai_focus 给了 1–3 类问题的列表, + 2026-08-21 用户实测 4 段全中)——统一规整为字符串,避免 pydantic + 「Input should be a valid string」502;None 归空串(空串是合法默认值)。""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return sep.join(str(x).strip() for x in value if str(x).strip()) + return str(value) + + +def _as_list(value): + """反向:必须为数组的字段(must_preserve / evidence)模型偶发给成字符串时包一层。""" + if isinstance(value, list): + return value + if isinstance(value, str) and value.strip(): + return [value] + return [] + + +def normalize_diagnose_payload(data: dict) -> dict: + """Tolerate older / loose model fields so Focus Mode still receives a V1.1 shape.""" + if not isinstance(data, dict): + return data + if "overall_diagnosis" in data: + data["overall_diagnosis"] = _as_text(data["overall_diagnosis"]) + if "optional_suggestion" in data: + data["optional_suggestion"] = _as_text(data["optional_suggestion"]) + for pat in data.get("patterns") or []: + if not isinstance(pat, dict): + continue + if not pat.get("category"): + pat["category"] = PATTERN_CATEGORY.get(str(pat.get("pattern_id") or "").upper(), "rhetoric") + for k in ("name", "why_ai_like", "human_impact", "transformation_rule"): + if k in pat: + pat[k] = _as_text(pat[k]) + if "affected_paragraphs" in pat: + pat["affected_paragraphs"] = _as_list(pat["affected_paragraphs"]) + if "evidence" in pat: + pat["evidence"] = _as_list(pat["evidence"]) + for brief in data.get("paragraph_briefs") or []: + if not isinstance(brief, dict): + continue + if not brief.get("rewrite_goal") and brief.get("primary_goal"): + brief["rewrite_goal"] = brief["primary_goal"] + if not brief.get("ai_focus") and brief.get("rewrite_guidance"): + brief["ai_focus"] = brief["rewrite_guidance"] + if not brief.get("confirmed_meaning") and brief.get("semantic_anchor"): + brief["confirmed_meaning"] = brief["semantic_anchor"] + if not brief.get("context_hint"): + parts = [brief.get("context_before") or "", brief.get("context_after") or ""] + brief["context_hint"] = " ".join(p for p in parts if p) + if isinstance(brief.get("scaffold"), list): + brief["scaffold"] = "\n".join(str(x) for x in brief["scaffold"] if x) + if not brief.get("reference_snippet") and brief.get("reference"): + brief["reference_snippet"] = brief["reference"] + for k in ("confirmed_meaning", "rewrite_goal", "ai_focus", "context_hint", "reference_snippet", "scaffold"): + if k in brief: + brief[k] = _as_text(brief[k]) + if "must_preserve" in brief: + brief["must_preserve"] = _as_list(brief["must_preserve"]) + notes = brief.get("annotations") + if not isinstance(notes, list): + brief["annotations"] = [] + notes = brief["annotations"] + for i, note in enumerate(notes): + if not isinstance(note, dict): + continue + if not note.get("annotation_id"): + note["annotation_id"] = f"a{i + 1}" + if not note.get("category"): + note["category"] = PATTERN_CATEGORY.get(str(note.get("pattern_id") or "").upper(), "rhetoric") + if not note.get("kind_label"): + note["kind_label"] = KIND_LABEL.get(note["category"], "修辞包装") + if not note.get("rewrite_action") and note.get("action"): + note["rewrite_action"] = note["action"] + for k in ("kind_label", "title", "observation", "rewrite_action", "why_ai_like"): + if k in note: + note[k] = _as_text(note[k]) + if "evidence" in note: + note["evidence"] = _as_list(note["evidence"]) + return data diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6a1c443 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115 +uvicorn>=0.30 +httpx>=0.27 +pydantic>=2.7 +pytest>=8 diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..aabc547 --- /dev/null +++ b/schemas.py @@ -0,0 +1,169 @@ +"""Pydantic schemas for the Human Voice Rewrite demo API. + +Aligned with PRD v1.1 §31 (AI 输出结构建议): +- §31.1 First Analysis -> AnalyzeRequest / AnalyzeResponse +- §31.2 Human Voice Diagnosis -> DiagnoseRequest / DiagnoseResponse +- §31.3 Recheck -> RecheckRequest / RecheckResponse +Plus Progressive Help endpoints (PRD §22: Scaffold → Reference). +""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +# ---------------------------------------------------------------- analyze +class AnalyzeRequest(BaseModel): + prompt: str = "" + word_limit: int | None = None + paragraphs: list[str] = Field(min_length=1) + constraints: list[str] = [] # advisor constraints already known before first pass + + +class ParagraphUnderstanding(BaseModel): + id: str = "" + original_text: str = "" + natural_meaning_zh: str = "" # 接近自然翻译的一段中文 + semantic_anchor: str = "" # 必须保留的核心意思 + optional_content_opportunity: str = "" + + +class PromptAlignment(BaseModel): + prompt_intent: str = "" + current_alignment: str = "" + optional_opportunity: str = "" + + +class AnalyzeResponse(BaseModel): + essay_summary: str = "" + prompt_alignment: PromptAlignment = PromptAlignment() + paragraphs: list[ParagraphUnderstanding] = [] + + +# ---------------------------------------------------------------- diagnose +class DiagnoseRequest(BaseModel): + prompt: str = "" + word_limit: int | None = None + paragraphs: list[str] = Field(min_length=1) + # 优先级(PRD §10.1):顾问确认理解 > 顾问补充约束 > 模型初始理解 + confirmed_anchors: list[str] = [] # 与 paragraphs 等长;非空条目覆盖模型初判 + global_constraints: list[str] = [] + paragraph_constraints: dict[str, list[str]] = {} + initial_analysis: AnalyzeResponse | None = None + + +PatternCategory = Literal["rhetoric", "repeat", "growth", "structure"] + + +class Pattern(BaseModel): + pattern_id: str = "" + name: str = "" + category: str = "" # rhetoric / repeat / growth / structure + affected_paragraphs: list[str] = [] + evidence: list[str] = [] # 必须能在原文中找到的片段 + why_ai_like: str = "" + human_impact: str = "" + transformation_rule: str = "" + + +class Annotation(BaseModel): + annotation_id: str = "" + pattern_id: str = "" + category: str = "rhetoric" + kind_label: str = "" + title: str = "" + evidence: list[str] = [] + observation: str = "" + rewrite_action: str = "" + why_ai_like: str = "" + + +class ParagraphBrief(BaseModel): + paragraph_id: str = "" + confirmed_meaning: str = "" + rewrite_goal: str = "" + ai_focus: str = "" + must_preserve: list[str] = [] + annotations: list[Annotation] = [] + context_hint: str = "" + scaffold: str = "" + reference_snippet: str = "" + + +class DiagnoseResponse(BaseModel): + overall_diagnosis: str = "" + patterns: list[Pattern] = [] + paragraph_briefs: list[ParagraphBrief] = [] + optional_suggestion: str = "" # PRD §16 Optional 进阶建议(不阻塞) + + +# ---------------------------------------------------------------- recheck +class GlobalChecks(BaseModel): + semantic_preservation: str = "pass" + voice_consistency: str = "pass" + pattern_reduction: str = "pass" + new_pattern: str = "pass" + coherence: str = "pass" + reference_copying: str = "pass" + word_limit: str = "pass" + + +class RevisionTarget(BaseModel): + paragraph_id: str = "p1" + blocking_issue: str = "" + evidence: list[str] = [] + single_revision_goal: str = "" # 单一明确返工目标 + + +class RecheckResponse(BaseModel): + status: Literal["pass", "revision_required"] + checked_rewrite_version: str = "" + global_checks: GlobalChecks + revision_targets: list[RevisionTarget] = [] + + +class RecheckRequest(BaseModel): + prompt: str = "" + word_limit: int | None = None + original_paragraphs: list[str] = Field(min_length=1) + confirmed_anchors: list[str] = [] + global_constraints: list[str] = [] + paragraph_constraints: dict[str, list[str]] = {} + rewrite_paragraphs: list[str] = Field(min_length=1) + diagnosis: DiagnoseResponse | None = None + references_shown: list[str] = [] # 仅用户实际展开过的参考 + scaffolds_shown: list[str] = [] # 仅用户实际展开过的支架 + rewrite_version: str = "" + previous_recheck: RecheckResponse | None = None + previous_revision_target: RevisionTarget | None = None + + +# ---------------------------------------------------------------- progressive help +class ScaffoldRequest(BaseModel): + paragraph_id: str + original_text: str + semantic_anchor: str + rewrite_goal: str = "" + global_constraints: list[str] = [] + paragraph_constraints: list[str] = [] + + +class ScaffoldResponse(BaseModel): + paragraph_id: str = "" + scaffold: str = "" + + +class ReferenceRequest(BaseModel): + paragraph_id: str + original_text: str + semantic_anchor: str + rewrite_goal: str = "" + primary_goal: str = "" # 兼容旧字段名 + global_constraints: list[str] = [] + paragraph_constraints: list[str] = [] + + +class ReferenceResponse(BaseModel): + paragraph_id: str = "" + starter: str = "" # 1-2 句英文局部参考,非整段 + reference_snippet: str = "" diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..7dd046a --- /dev/null +++ b/start.bat @@ -0,0 +1,34 @@ +@echo off +REM Human Voice Rewrite Demo - Windows һ +REM ҪѰװ Python 3.10+δװȵ python.org װѡ Add to PATH +REM ע⣺ļ뱣 ANSI(GBK) 롣ñ༭Ϊ UTF-8лᱻ Windows ʾʧܡ +setlocal +cd /d "%~dp0" + +if not exist ".venv\Scripts\python.exe" ( + echo ״У⻷װ... + py -3 -m venv .venv + if errorlevel 1 ( + echo py -3 ãȷѰװ Python 3.10+ ѡ Add to PATH + pause + exit /b 1 + ) + ".venv\Scripts\python.exe" -m pip install -q --upgrade pip + ".venv\Scripts\python.exe" -m pip install -q -r requirements.txt +) + +REM .env ȡ Key ֵַţ +for /f "usebackq tokens=1,* delims==" %%a in (".env") do ( + if "%%a"=="PRODREAM_BACKEND_OPENROUTER_API_KEY" set "OPENROUTER_API_KEY=%%b" + if "%%a"=="PRODREAM_BACKEND_OPENROUTER_PROXY_URL" set "OPENROUTER_PROXY_URL=%%b" +) +if "%OPENROUTER_API_KEY%"=="" ( + echo .env û PRODREAM_BACKEND_OPENROUTER_API_KEY + pause + exit /b 1 +) + +if "%HVR_PORT%"=="" set "HVR_PORT=8000" +echo Human Voice Rewrite Demo: http://127.0.0.1:%HVR_PORT% +".venv\Scripts\uvicorn.exe" main:app --host 127.0.0.1 --port %HVR_PORT% +pause diff --git a/test_demo.py b/test_demo.py new file mode 100644 index 0000000..917a951 --- /dev/null +++ b/test_demo.py @@ -0,0 +1,551 @@ +"""Hermetic tests for Human Voice Rewrite Demo (PRD v1.1). + +No real model calls — the LLM seam is stubbed. +""" + +import json + +import pytest +from fastapi.testclient import TestClient + +from evidence import sanitize_diagnosis, sanitize_patterns +from llm import LLMError, LlmClient, extract_json, message_text, reasoning_config +from main import app +from prompts import normalize_diagnose_payload +from schemas import Annotation, DiagnoseResponse, ParagraphBrief, Pattern + +SAMPLE_PARAGRAPHS = [ + "The pencil in my hand was still. I wanted to find the answer.", + "I met a problem that I could not solve right away.", + "I learned to be patient.", + "Now, I sit at my desk again.", +] + + +class FakeCompleter: + def __init__(self, payload): + self.payload = payload + + def complete_json(self, system, user, validate=None): + return self.payload + + +# ---------------------------------------------------------------- evidence +def test_evidence_keeps_verbatim_hits_drops_misses(): + pats = [ + Pattern( + pattern_id="P05", + name="Metaphor Stack", + affected_paragraphs=["p1"], + evidence=["the pencil in my hand", "not in the text"], + why_ai_like="w", + human_impact="h", + transformation_rule="Keep One Motif", + ) + ] + out = sanitize_patterns(pats, SAMPLE_PARAGRAPHS) + assert out[0].evidence == ["the pencil in my hand"] + assert out[0].category == "rhetoric" + + +def test_evidence_unmatched_cleared_but_pattern_kept(): + pats = [ + Pattern( + pattern_id="P02", + name="Explicit Lesson", + affected_paragraphs=["p3"], + evidence=["somewhere else entirely"], + why_ai_like="w", + human_impact="h", + transformation_rule="Lesson -> Change in Judgment", + ) + ] + out = sanitize_patterns(pats, SAMPLE_PARAGRAPHS) + assert len(out) == 1 and out[0].pattern_id == "P02" + assert out[0].evidence == [] + assert out[0].category == "growth" + + +def test_fictional_annotation_is_dropped(): + resp = DiagnoseResponse( + overall_diagnosis="x", + paragraph_briefs=[ + ParagraphBrief( + paragraph_id="p1", + annotations=[ + Annotation( + pattern_id="P05", + category="rhetoric", + title="虚构", + evidence=["this phrase is not in the paragraph"], + observation="o", + rewrite_action="a", + ), + Annotation( + pattern_id="P05", + category="rhetoric", + title="真实", + evidence=["the pencil in my hand"], + observation="o", + rewrite_action="a", + ), + ], + ) + ], + ) + cleaned = sanitize_diagnosis(resp, SAMPLE_PARAGRAPHS) + assert len(cleaned.paragraph_briefs[0].annotations) == 1 + assert cleaned.paragraph_briefs[0].annotations[0].title == "真实" + + +# ---------------------------------------------------------------- extract_json / retry +def test_extract_json_strips_fences_and_wraps(): + assert extract_json('```json\n{"a": 1}\n```') == {"a": 1} + assert extract_json('here you go: {"a": 1} thanks') == {"a": 1} + + +def test_message_text_joins_content_parts(): + assert message_text({"choices": [{"message": {"content": [{"type": "text", "text": '{"ok":1}'}]}}]}) == '{"ok":1}' + + +def test_reasoning_config_does_not_set_effort_and_max_tokens(): + first = reasoning_config(False) + retry = reasoning_config(True) + assert not ("effort" in first and "max_tokens" in first) + assert not ("effort" in retry and "max_tokens" in retry) + assert first == {"max_tokens": 2048} + assert retry == {"effort": "none", "exclude": True} + + +def test_message_text_empty_mentions_finish_reason(): + with pytest.raises(LLMError, match="finish_reason=length"): + message_text( + { + "choices": [ + { + "finish_reason": "length", + "message": {"content": "", "reasoning_content": "thinking..."}, + } + ] + } + ) + + +class SeqClient(LlmClient): + def __init__(self, responses): + super().__init__(api_key="sk-test", base_url="http://stub", model="stub") + self.responses = list(responses) + self.calls = 0 + + def _complete(self, system, user, json_reminder=False, **kwargs): + self.calls += 1 + kind, val = self.responses.pop(0) + if kind == "error": + raise LLMError(val) + return val + + +def test_parse_failure_retries_once(): + c = SeqClient([("content", "not json at all"), ("content", '{"ok": 1}')]) + assert c.complete_json("s", "u") == {"ok": 1} + assert c.calls == 2 + + +def test_transient_5xx_retries_once(): + c = SeqClient([("error", "HTTP 502 bad gateway"), ("content", '{"ok": 2}')]) + assert c.complete_json("s", "u") == {"ok": 2} + assert c.calls == 2 + + +def test_parse_failure_twice_raises(): + c = SeqClient([("content", "nope"), ("content", "still not json")]) + with pytest.raises(LLMError): + c.complete_json("s", "u") + assert c.calls == 2 + + +def test_http_402_error_shows_only_error_message(): + """HTTP 非 200(如 OpenRouter 402)→ LLMError 只带 error.message,不把完整 JSON body 甩给页面(PRD §11 失败兜底)。""" + import httpx + + resp = httpx.Response( + 402, + json={ + "error": { + "message": "Insufficient credits. Add more using https://openrouter.ai/settings/credits", + "code": 402, + "type": "insufficient_quota", + } + }, + request=httpx.Request("POST", "http://stub/chat/completions"), + ) + + class HttpErrorClient(LlmClient): + def __init__(self): + super().__init__(api_key="sk-test", base_url="http://stub", model="stub") + + def _post(self, payload): + return resp + + with pytest.raises(LLMError) as ei: + HttpErrorClient().complete_json("s", "u") + msg = str(ei.value) + assert "Insufficient credits" in msg # error.message 对用户可见 + assert '"error"' not in msg # 不是原始 JSON body + assert "insufficient_quota" not in msg # 不在 message 里的字段不外泄 + + +def test_no_key_fails_closed(monkeypatch): + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("PRODREAM_BACKEND_OPENROUTER_API_KEY", raising=False) + with pytest.raises(LLMError): + LlmClient(api_key="") + + +def test_normalize_diagnose_payload_maps_legacy_fields(): + data = normalize_diagnose_payload( + { + "paragraph_briefs": [ + { + "paragraph_id": "p1", + "primary_goal": "收修辞", + "rewrite_guidance": "不要连续比喻", + "context_before": "上一段已建立确定感", + "context_after": "这一段只需写挫败", + "scaffold": ["What I liked was ______."], + } + ], + "patterns": [{"pattern_id": "P05", "name": "Metaphor Stack"}], + } + ) + brief = data["paragraph_briefs"][0] + assert brief["rewrite_goal"] == "收修辞" + assert brief["ai_focus"] == "不要连续比喻" + assert "确定感" in brief["context_hint"] + assert brief["scaffold"] == "What I liked was ______." + assert data["patterns"][0]["category"] == "rhetoric" + + +def test_normalize_diagnose_payload_coerces_list_fields(): + """模型偶发把字符串字段返回成数组(2026-08-21 用户实测 ai_focus 4 段全中)—— + normalize 必须规整为字符串/数组,且规整后能通过 schema(不再 502)。""" + data = normalize_diagnose_payload( + { + "overall_diagnosis": ["开头不错", "结尾乏力"], + "paragraph_briefs": [ + { + "paragraph_id": "p1", + "confirmed_meaning": ["第一段写犹豫"], + "rewrite_goal": "收修辞", + "ai_focus": ["重复修辞", "抽象总结"], + "must_preserve": "secret code", + "annotations": [{"title": ["短标题"], "observation": "连续两个比喻", "evidence": "原文句"}], + } + ], + "patterns": [{"pattern_id": "P05", "name": ["Metaphor", "Stack"]}], + } + ) + brief = data["paragraph_briefs"][0] + assert brief["ai_focus"] == "重复修辞;抽象总结" + assert brief["confirmed_meaning"] == "第一段写犹豫" + assert brief["must_preserve"] == ["secret code"] + assert brief["annotations"][0]["title"] == "短标题" + assert brief["annotations"][0]["evidence"] == ["原文句"] + assert data["overall_diagnosis"] == "开头不错;结尾乏力" + assert data["patterns"][0]["name"] == "Metaphor;Stack" + # 规整后必须能过 schema——复现用户实测 502 场景不再发生 + resp = DiagnoseResponse.model_validate(data) + assert resp.paragraph_briefs[0].ai_focus == "重复修辞;抽象总结" + + +# ---------------------------------------------------------------- API shape +def api_client(payload): + import main as main_mod + + main_mod.get_client = lambda: FakeCompleter(payload) + return TestClient(app) + + +class CaptureCompleter: + def __init__(self, payload): + self.payload = payload + self.calls = [] + + def complete_json(self, system, user, validate=None): + self.calls.append({"system": system, "user": user}) + return self.payload + + +def test_api_diagnose_passes_constraints_to_llm(): + """补充改写要求/约束必须真实传给 LLM(诊断重生成链路)。""" + import main as main_mod + + cap = CaptureCompleter( + { + "overall_diagnosis": "d", + "patterns": [], + "paragraph_briefs": [ + { + "paragraph_id": "p1", + "confirmed_meaning": "m", + "rewrite_goal": "g", + "ai_focus": "a", + "annotations": [], + } + ], + "optional_suggestion": "", + } + ) + main_mod.get_client = lambda: cap + r = TestClient(app).post( + "/api/diagnose", + json={ + "paragraphs": SAMPLE_PARAGRAPHS, + "confirmed_anchors": ["a"], + "global_constraints": ["整体更直接、克制"], + "paragraph_constraints": {"p1": ["保留 secret code"]}, + }, + ) + assert r.status_code == 200 + assert "整体更直接、克制" in cap.calls[0]["user"] + assert "保留 secret code" in cap.calls[0]["user"] + + +def test_api_diagnose_coerces_ai_focus_list(): + """复现 2026-08-21 用户实测:模型把 ai_focus 返回成数组—— + 路由必须规整为字符串并返回 200,而不是 502。""" + import main as main_mod + + cap = CaptureCompleter( + { + "overall_diagnosis": ["开头不错", "结尾乏力"], + "patterns": [], + "paragraph_briefs": [ + { + "paragraph_id": f"p{i + 1}", + "confirmed_meaning": "m", + "rewrite_goal": "g", + "ai_focus": ["重复修辞", "抽象总结"], + "annotations": [], + } + for i in range(4) + ], + "optional_suggestion": "", + } + ) + main_mod.get_client = lambda: cap + r = TestClient(app).post( + "/api/diagnose", + json={"paragraphs": SAMPLE_PARAGRAPHS, "confirmed_anchors": [], "global_constraints": []}, + ) + assert r.status_code == 200 + briefs = r.json()["paragraph_briefs"] + assert len(briefs) == 4 + assert briefs[0]["ai_focus"] == "重复修辞;抽象总结" + + +def test_api_analyze_shape(): + payload = { + "essay_summary": "学生和数学关系的变化。", + "prompt_alignment": {"prompt_intent": "", "current_alignment": "", "optional_opportunity": ""}, + "paragraphs": [ + { + "id": "p1", + "original_text": SAMPLE_PARAGRAPHS[0], + "natural_meaning_zh": "喜欢数学带来的确定感。", + "semantic_anchor": "数学给我确定感。", + "optional_content_opportunity": "", + } + ], + } + r = api_client(payload).post( + "/api/analyze", json={"prompt": "P1", "word_limit": 650, "paragraphs": [SAMPLE_PARAGRAPHS[0]]} + ) + assert r.status_code == 200 + assert r.json()["paragraphs"][0]["id"] == "p1" + + +def test_api_analyze_empty_text_422(): + r = api_client({}).post("/api/analyze", json={"paragraphs": [" "]}) + assert r.status_code == 422 + + +def test_api_analyze_bad_payload_returns_one_line_detail(): + """LLM 输出不符合 schema → 502 detail 是一行用户可读摘要,不把 ValidationError 堆栈甩给页面(PRD §11 失败兜底)。""" + r = api_client({"essay_summary": "s", "paragraphs": [123]}).post( # 类型错误触发 ValidationError + "/api/analyze", json={"prompt": "P1", "word_limit": 650, "paragraphs": [SAMPLE_PARAGRAPHS[0]]} + ) + assert r.status_code == 502 + detail = r.json()["detail"] + assert "结果格式不符合预期" in detail + assert "validation errors for" not in detail # 没有完整校验堆栈 + assert detail.count("\n") == 0 # 单行 + assert len(detail) < 200 # 摘要级长度 + + +def test_api_diagnose_drops_fictional_evidence(): + payload = { + "overall_diagnosis": "修辞偏密。", + "patterns": [ + { + "pattern_id": "P05", + "name": "Metaphor Stack", + "category": "rhetoric", + "affected_paragraphs": ["p1"], + "evidence": ["不存在的证据"], + "why_ai_like": "w", + "human_impact": "h", + "transformation_rule": "Keep One Motif", + } + ], + "paragraph_briefs": [ + { + "paragraph_id": "p1", + "confirmed_meaning": "确定感", + "rewrite_goal": "收修辞", + "ai_focus": "连续比喻", + "annotations": [ + { + "pattern_id": "P05", + "category": "rhetoric", + "kind_label": "修辞包装", + "title": "虚构", + "evidence": ["不存在的证据"], + "observation": "o", + "rewrite_action": "a", + } + ], + "context_hint": "", + "scaffold": "What I liked was ______.", + "reference_snippet": "I liked the certainty.", + } + ], + "optional_suggestion": "", + } + r = api_client(payload).post( + "/api/diagnose", + json={"paragraphs": SAMPLE_PARAGRAPHS, "confirmed_anchors": ["a"], "global_constraints": [], "paragraph_constraints": {}}, + ) + assert r.status_code == 200 + body = r.json() + assert body["patterns"][0]["pattern_id"] == "P05" + assert body["patterns"][0]["evidence"] == [] + assert body["paragraph_briefs"][0]["annotations"] == [] + + +def test_api_recheck_revision_single_target(): + payload = { + "status": "revision_required", + "checked_rewrite_version": "rv_12", + "global_checks": { + k: "pass" + for k in [ + "semantic_preservation", + "voice_consistency", + "pattern_reduction", + "new_pattern", + "coherence", + "reference_copying", + "word_limit", + ] + }, + "revision_targets": [ + { + "paragraph_id": "p3", + "blocking_issue": "新版本仍是 Explicit Lesson", + "evidence": ["gradually came to realize"], + "single_revision_goal": "不要总结我学到了什么,写判断怎么变化。", + } + ], + } + r = api_client(payload).post( + "/api/recheck", + json={ + "original_paragraphs": SAMPLE_PARAGRAPHS, + "rewrite_paragraphs": ["a" * 60] * 4, + "rewrite_version": "rv_12", + }, + ) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "revision_required" + assert body["checked_rewrite_version"] == "rv_12" + assert len(body["revision_targets"]) == 1 + + +def test_api_recheck_empty_paragraph_422(): + r = api_client({}).post( + "/api/recheck", + json={"original_paragraphs": SAMPLE_PARAGRAPHS, "rewrite_paragraphs": ["ok", "", "ok", "ok"]}, + ) + assert r.status_code == 422 + + +def test_api_reference_shape(): + payload = {"paragraph_id": "p1", "starter": "What I liked about math was ______."} + r = api_client(payload).post( + "/api/reference", + json={"paragraph_id": "p1", "original_text": SAMPLE_PARAGRAPHS[0], "semantic_anchor": "a", "rewrite_goal": "g"}, + ) + assert r.status_code == 200 + assert r.json()["starter"].startswith("What I liked") + assert r.json()["reference_snippet"].startswith("What I liked") + + +def test_api_scaffold_shape(): + payload = {"paragraph_id": "p1", "scaffold": "What I liked about math was ______."} + r = api_client(payload).post( + "/api/scaffold", + json={"paragraph_id": "p1", "original_text": SAMPLE_PARAGRAPHS[0], "semantic_anchor": "a", "rewrite_goal": "g"}, + ) + assert r.status_code == 200 + assert "liked" in r.json()["scaffold"] + + +def test_complete_json_paragraph_count_mismatch_retries_once(): + """语义校验:段落数不匹配视为一次失败并自动重试,重试成功返回完整结果。 + 真实 LLM 偶发返回合法 JSON 但段落条目不全(AI 初审"4 段只显示 1 段"), + 此前无校验被静默接受;现在按 complete_json 既有重试约定自动再试一次。""" + one = {"essay_summary": "s", "paragraphs": [{"id": "p1", "natural_meaning_zh": "第一段"}]} + four = {"essay_summary": "s", "paragraphs": [{"id": f"p{i}", "natural_meaning_zh": f"第{i}段"} for i in range(1, 5)]} + c = SeqClient([("content", json.dumps(one)), ("content", json.dumps(four))]) + out = c.complete_json( + "s", "u", validate=lambda d: "结果段落数与输入不一致" if len(d.get("paragraphs") or []) != 4 else None + ) + assert len(out["paragraphs"]) == 4 + assert c.calls == 2 # 第一次因数量不匹配被重试 + + +def test_complete_json_paragraph_count_mismatch_twice_raises(): + """两次都数量不匹配 → LLMError 带一行可读说明(不把原始 JSON 甩给页面)。""" + one = {"essay_summary": "s", "paragraphs": [{"id": "p1", "natural_meaning_zh": "第一段"}]} + c = SeqClient([("content", json.dumps(one)), ("content", json.dumps(one))]) + with pytest.raises(LLMError) as ei: + c.complete_json( + "s", "u", + validate=lambda d: "结果段落数与输入不一致(输入 4 段,返回 1 段)" if len(d.get("paragraphs") or []) != 4 else None, + ) + assert "输入 4 段" in str(ei.value) and "返回 1 段" in str(ei.value) + assert c.calls == 2 + + +def test_api_analyze_wires_paragraph_count_check(): + """analyze 路由必须把段落数量校验传给 complete_json(否则 4 段输入只返回 1 段会被静默接受)。""" + import main as main_mod + + class SpyCompleter(CaptureCompleter): + def complete_json(self, system, user, validate=None): + self.validate = validate + return super().complete_json(system, user, validate) + + payload = {"essay_summary": "s", "paragraphs": [{"id": "p1", "natural_meaning_zh": "第一段"}]} + spy = SpyCompleter(payload) + main_mod.get_client = lambda: spy + r = TestClient(app).post( + "/api/analyze", json={"prompt": "P1", "word_limit": 650, "paragraphs": SAMPLE_PARAGRAPHS} + ) + assert r.status_code == 200 + assert spy.validate is not None + bad = spy.validate({"essay_summary": "s", "paragraphs": [{"id": "p1"}]}) + assert bad is not None and "段落数" in bad + assert spy.validate({"essay_summary": "s", "paragraphs": [{"id": f"p{i}"} for i in range(1, 5)]}) is None