Import Human Voice Rewrite Demo (PRD v1.1) + Dockerfile for Coolify

This commit is contained in:
Jeamee
2026-08-24 16:49:57 +08:00
commit d6f70b02f4
16 changed files with 3580 additions and 0 deletions
+12
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
.venv/
__pycache__/
*.pyc
.pytest_cache/
+12
View File
@@ -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"]
@@ -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`:命中 P01P12 + 每段 Rewrite Direction / Inline
Annotation / Context Hint / ScaffoldPRD §31.2);顾问在对话里补充的理解和约束优先
3. **全文复检** `POST /api/recheck`:六维度验收,通过即结束;需返工只返回一个段落 + 单一
返工目标,并绑定 `rewrite_version`PRD §31.3);`/api/scaffold``/api/reference`
只在顾问主动展开时使用,并写入 exposure 记录
前端流程:进入页(可粘贴自己的文章,自动分段)→ 开始分析 → 对话确认/纠偏 → 诊断 →
两栏逐段手写(左原文+批注,右安静写作区)→ 提交复检。段落数量按实际 `1N` 动态渲染。
## 四、可以调什么(按频率)
| 想调什么 | 改哪里 |
|---|---|
| 换模型 | `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(可重新提交复检)
+72
View File
@@ -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 DiagnosisPRD §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
```
+228
View File
@@ -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")
+121
View File
@@ -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
+1369
View File
File diff suppressed because it is too large Load Diff
+248
View File
@@ -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)。
# OpenRoutereffort 与 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
+56
View File
@@ -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 整个 .envdotenv 格式不是 bash 语法,直接执行会出错)
pick() { # pick KEY <file> -> 值(去首尾引号)
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"
+203
View File
@@ -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")
+392
View File
@@ -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):顾问确认理解 > 顾问补充约束 > 模型初始理解。
RecheckPRD §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"<p{idx}>{text}</p{idx}>" 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 DiagnosisPRD §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」502None 归空串(空串是合法默认值)。"""
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
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.115
uvicorn>=0.30
httpx>=0.27
pydantic>=2.7
pytest>=8
+169
View File
@@ -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 = ""
+34
View File
@@ -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
+551
View File
@@ -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"] == "MetaphorStack"
# 规整后必须能过 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