Import Human Voice Rewrite Demo (PRD v1.1) + Dockerfile for Coolify
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
+12
@@ -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`:命中 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(可重新提交复检)
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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 <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"
|
||||||
@@ -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
@@ -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"<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 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn>=0.30
|
||||||
|
httpx>=0.27
|
||||||
|
pydantic>=2.7
|
||||||
|
pytest>=8
|
||||||
+169
@@ -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 = ""
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user