diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 87131233..8682de68 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -120,6 +120,7 @@ jobs:
--hidden-import=delector.data.core_dict_ext \
--hidden-import=delector.data.prep_dict \
--hidden-import=delector.data.a1_dict \
+ --hidden-import=delector.data.a1_verb_case \
--hidden-import=delector.data.a1_writing_dict \
--hidden-import=delector.data.a1_hoeren_dict \
--hidden-import=delector.data.a1_lesen_dict \
@@ -218,6 +219,7 @@ jobs:
--hidden-import=delector.data.core_dict_ext \
--hidden-import=delector.data.prep_dict \
--hidden-import=delector.data.a1_dict \
+ --hidden-import=delector.data.a1_verb_case \
--hidden-import=delector.data.a1_writing_dict \
--hidden-import=delector.data.a1_hoeren_dict \
--hidden-import=delector.data.a1_lesen_dict \
diff --git a/WORKMEMORY/work.log b/WORKMEMORY/work.log
index 9679058d..cee1f60f 100644
--- a/WORKMEMORY/work.log
+++ b/WORKMEMORY/work.log
@@ -519,3 +519,37 @@ Caps: filesystem-read, filesystem-write, shell-exec
- **Why 现在不做**:三态递进需先有「第二个实现」才有内容(现仅一个工具面、一个裁判)——提前定接口正是 §2 反模式;且与本子计划(抬 §4.3 门槛)正交。
- **已登记**:写进新计划 `docs/plans/2026-10-15-a1-judge-expansion.md` 的 `Out of Scope`(本计划一切改动 MUST NOT 依赖该重构)+ `Fog of War [Unknown E]`(含四个子目标与将来启动的前置顺序)。
- **不阻塞**:三张 RED(接线缝 / fail-closed / 同步调用超时)**均可走"端点注入缝"修复,不需要这次重构**(这正是两个席位给出的"不改内核结构、允许经注入缝改变裁判语义"路径)。
+
+### 2026-10-15 15:10 | codebuddy | NOTE
+Caps: filesystem-read, filesystem-write, shell-exec
+事件:ADR-0023 §4.3 续作(A1 裁判集扩充,计划 `docs/plans/2026-10-15-a1-judge-expansion.md`,8 任务)在分支 `feat/a1-judge-expansion` 上推进到 **3/8**(Task 1+2 合并交付 `88856ab`;Task 8 `deede44`)。
+- **开工前的双席位评审**(用户指示,按 `01-Rules/LAYERED-ARCHITECTURE` + `AGENT-SDK-PATTERNS`):两席位均判「局部偏离」,核心是 **RED#1 接线缝缺失** —— 计划 7 个任务没写"谁把 `nlp` 交给裁判",而 `_run_polish` 从不传 `judge=`、Out-of-Scope 又禁改内核 ⇒ **语法裁判在生产恒不生效而 7 任务测试可全绿**(dead-green),§4.3 判据表还能被打勾解除。据此把 Task 1/2 合并为**原子变更**(能力与接缝同批落地)。
+- **我对评审的两处反裁决(都有实测依据,已写进计划)**:① RED#2 的"降级即禁止收敛"**不采用** —— 实测 `processor.py:192-198` Android 显式 `nlp=None`,若禁止收敛则 `argmin` 回退返回**原文** = ADR §10.3 的功能报废回归;改为**运行期 fail-open + 显式标记 / 声明门禁层 fail-closed**。② maker 一度把 `_a1_violations_if_letter` 提前改成按规则族分流 —— 那是 **Task 7 的 HITL 行为变更**(语法违规开始计数后,模型修不动的作文会回退原文),已退回并加一条**非真空钉子**钉住「本批不改该行为」。
+- **Task 8(接缝加固)**:dispatch 加 `min(per-call, 剩余 deadline)` 超时(调用前重算剩余)、同步 `judge` 移 `asyncio.to_thread`(否则长裁判阻塞 `/api/health` 且 `task.cancel()` 掐不断)、修 `_polish_event_stream` 那个**永不命中**的 `except llm_client.LLMError`(`_run_polish` 早已把它转成 HTTPException)⇒ 改为捕 HTTPException 发 error 帧,`e.detail` 人话才到得了用户。
+- **基础设施坑(两次)**:两次 subagent 派单被掐(10003 / 10101),第一次留了半成品 + 一条取消用例跑不完(备份 `/tmp/task8-partial.diff` 后丢弃、重派成功)。**教训:给 maker 的反挂死纪律必须写进 prompt**(所有 sleep 要有 wait_for/固定次数兜底、禁 join 线程、禁无界 while、每条用例贴实测耗时)。Task 8 的 flaky 黄卡(心跳窗口余量趋零)由我按 CRV 开方改 1 行(跨调用取 max)并 20 连跑自证 —— 偏离 maker-checker,已如实记录在提交信息。
+- **门禁**:5 套件 411 passed;`ruff check .` 全绿;`mypy --strict delector` 66 files 0;契约闸 PASS;CRLF。
+- **未做**:Task 3/4/5(三个新维度规则)、Task 6(FP 标定,HITL)、Task 7(闸门收窄 + §4.3 判据表,HITL,含前端降级提示)。**Pi 式分层重构已由用户拍板暂缓**(`docs/agents/architecture.md` 已登记)。
+
+### 2026-10-15 16:40 | codebuddy | DECISION
+Caps: filesystem-read, filesystem-write, shell-exec
+事件:**Task 6 的 HITL 裁决落地**(用户拍板):A 维**不做**、B 维**接线为 warning**。
+- **A 维(情态动词框架语序)不做**:实测负样本 6/7(「实词夹在情态动词与末位动词之间」整族不可见 = 结构性 FN),且样本外追补的 3 句**正确**句(逗号并列主句)全部误报 ⇒ FP 与 FN 同源、两侧都破。标定产物(`tools/bench_judge_fp.py`)保留作记录、不接线。**Why 这条要记**:本计划第一个"测了之后选择不做"的维度 —— 防止后来者看到 bench 脚本以为它只是"还没接线"。
+- **B 维(时间副词 ↔ 时态一致性)接线为 warning**:正样本 FP 0/35、负样本 7/7、对照 0/6,两档阈值均成立;样本偏小(7 错 + 6 对照)⇒ 先 advisory 不阻断收敛,积累真实用法后再议升 error。可见性靠响应体新增 `judge_advisories`(只增不改)+ Task 7 的前端渲染。
+- 更正一条**未实测口径**:提交 `39e6d79` 的消息里写「COMMON_CORRECT_SENTENCES 47 句」是我未实测的转述,**实测为 51 句** —— 以实测为准(本事件与 PR 正文为准)。
+
+### 2026-10-15 18:20 | codebuddy | NOTE
+Caps: filesystem-read, filesystem-write, shell-exec
+事件:**A1 裁判集扩充计划(`docs/plans/2026-10-15-a1-judge-expansion.md`,8 任务)技术面全部落地,台账 8/8**(分支 `feat/a1-judge-expansion`,待开 PR)。
+- **交付面**:`88856ab` Task 1+2(违规形状统一 + 接线既有两件算力 + 端点注入缝 + 官方范文零违规门)· `39e6d79` Task 3/4/5(`numerus` / `rektion`+29 条人工词表 / `wortstellung` + 加宽后的 51 句永久硬门)· `209cc24` Task 6(FP 标定工具,冻结记录)· `cf9ea76` Task 6 收尾 + Task 7(B 维 warning/advisory + 闸门按规则族分流 + 提示词参数化 + 前端非阻断提示)· `deede44` Task 8(dispatch 超时 / 同步裁判移出事件循环 / 修不可达 except)。
+- **HITL 裁决(用户)**:A 维(情态框架语序)**不做** —— 实测负样本 6/7 + 样本外 3 句正确句全误报 ⇒ FP/FN 同源;B 维(时态一致性)**接线为 warning** —— 正样本 FP 0/35、负样本 7/7、对照 0/6。
+- **本计划最有价值的产出不是规则,而是三次"误报类"返工**:① `öffnen/bezahlen/schmecken` 被当严格 Acc ⇒ 合法自由第三格被报;② V2 把**主语 NP 内修饰**(`Meine sehr große Familie…`)当独立前置成分;③ V2 漏豁免**焦点小词**(`Auch…`/`Nur…`)+ rektion 把自由第三格当违规。三次都**先写成用例跑红再修**,并把硬门从"官方 10 篇"扩到 **51 句 + 类覆盖清单**(编排者另做 30 句广度扫雷 FP=0 后定稿)。**Why 要记**:官方范文门放过了这三类 —— "正样本不够宽"本身就是一类系统性风险。
+- **判据面**:§4.3 解除判据表已入计划(8 条判据:维度覆盖**部分满足**、官方范文零误报 ✅、51 句硬门 ✅、30 句扫雷 ✅、负样本召回 ✅、非书信可判 ✅、降级可见性 ✅、**"浅裁判是否被模型针对"的对照实验未做**(阻塞于 `DEEPSEEK_API_KEY`)⇒ 建议**只允许限定表述**,不建议解禁 §4.3 原文所禁的无条件"已实现")。
+- **基础设施坑(本会话共 5 次子代理中断:10003×2 / 10006×3 / 10101)**:Task 8 首次留半成品 + 挂死用例(备份后丢弃重派);Task 6 前两次零落盘;最后一次死在报告前但改动已落盘 ⇒ 由我逐条核到行号并复跑门禁后代提交。**两条实操教训**:⑴ 给 maker 的 prompt MUST 写反挂死纪律(所有 sleep 要有 wait_for/固定次数兜底、禁 join 线程、禁无界 while、每条用例贴实测耗时);⑵ 提交信息**禁用 `python -c "...'''...'''"` 形式**(反引号会被 bash 当命令替换吞掉正文 —— Task 6 那次就丢了正文,已用 heredoc 重建并在消息尾部注明)。
+- **未做**:Android 真机点检(含新 API 的 WebView 表现)、有 Key 后的真实轮次分布与真 token 计数回测、Pi 式分层重构(用户已拍板暂缓,登记在 `docs/agents/architecture.md`)。
+
+### 2026-10-15 19:05 | codebuddy | DECISION
+Caps: filesystem-read, filesystem-write, shell-exec
+事件:**§4.3 门槛的宣称方式与回退语义由用户签批**(计划 `docs/plans/2026-10-15-a1-judge-expansion.md` 的 §4.3 判据表尾已记)。
+- **宣称方式 = 限定表述**:允许对外写「裁判已扩到 A1 语法维度:动词位置 + 格的使用三规则 error 级、时态一致性 advisory;语序经实测评审判定不做」;**无条件「已实现验证驱动 agent 循环」继续禁用** —— 判据表第 8 条("浅裁判是否真被模型针对"的对照实验)因缺 `DEEPSEEK_API_KEY` 未做,故 §4.3 的门槛只算**部分抬升**。
+- **回退语义 = 保持**:非书信文本模型修不动 ⇒ `no_improvement` ⇒ argmin 回退**原文**(与循环语义一致,断言 + 三处 docstring + 前端人话原因码已钉住)。
+- **Why 记**:这是"测出来之后**不做**"与"允许**限定**宣称"两个反直觉结论 —— 防止后来者把 §4.3 读成"已经解禁"或把 bench 脚本里的 A 维当成"待接线"。
diff --git a/delector/data/a1_verb_case.py b/delector/data/a1_verb_case.py
new file mode 100644
index 00000000..83c6f425
--- /dev/null
+++ b/delector/data/a1_verb_case.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+"""A1 高频动词 → 支配格(Rektion)**人工登记表**。
+
+**这是事实数据,不是生成物**:每条都按「歌德 A1 词表(`delector/data/a1_dict.py::GOETHE_A1_VOCAB`)
++ 教科书级支配格常识」逐条确认,禁止用 LLM 批量生成。收录标准 = **少而准**(FP 是敌人):
+
+- 只收**严格单格**支配的动词(`Dat` 或 `Acc`)——检测器仅在「有冠词的宾语 NP + 单数」时才报;
+- 标 `Dat+Acc` 的动词(人三格物四格 / 自由第三格 freier Dativ / 格支配两可)**登记但不判**
+ —— 这些动词的宾语格位依句式而定,硬判必误报;
+- 可分前缀动词(an|rufen)/ 反身动词(sich freuen)/ 名词化动词一律**不入表**
+ (检测器另有守卫,见 `delector/services/writing.py::detect_verb_case_government` 的 docstring);
+- 动词同时可接介词搭配(如 `helfen bei`、`danken für`)不影响本表 —— 那是
+ `detect_preposition_case` / 固定搭配表的领域。
+
+来源注释(按组给出,每组均为教科书级支配格常识 + 歌德 A1 词表在册动词):
+- `Dat` 组:`jemandem helfen / danken / gratulieren`(人三格)、`Das gefällt / gehört /
+ passt / fehlt mir`(A1 教材固定搭配)。
+- `Acc` 组:A1 教科书典型句式中只接四格宾语的及物动词(`etwas sehen / brauchen / suchen /
+ finden / lieben / grüßen / kennen / lesen / trinken / essen / hören / verstehen /
+ wiederholen / unterschreiben`)。**凡 A1 教科书级出现自由第三格(人三格附加宾)或双宾
+ 用法的动词一律不进本组**:`jdm. die Tür öffnen` / `jdm. etw. bezahlen` 是 A1 教科书级
+ 搭配 ⇒ `öffnen` / `bezahlen` 移入 `Dat+Acc`;`schmecken` 两可(`Das schmeckt mir.`
+ 三格 / `Ich schmecke den Wein.` 四格)⇒ 同样移入 `Dat+Acc`。
+- `Dat+Acc` 组(不判):双宾「人三物四」(`jemandem etwas geben / bringen / zeigen /
+ schreiben / kaufen`)+ 自由第三格(`jdm. die Tür öffnen` / `jdm. etw. bezahlen`)+
+ 两可支配(`schmecken`)。
+"""
+
+A1_VERB_CASES: dict[str, str] = {
+ # ── Dat(严格三格;`helfen / danken / gratulieren` = 人三格,A1 教材固定搭配)────────
+ "helfen": "Dat",
+ "danken": "Dat",
+ "gratulieren": "Dat",
+ # Dat(`Das gefällt / gehört / passt / fehlt mir.` 句式)─────────────────────────────
+ "gefallen": "Dat",
+ "gehören": "Dat",
+ "passen": "Dat",
+ "fehlen": "Dat",
+ # ── Acc(严格四格;A1 教材典型句式只接四格宾语的及物动词)──────────────────────────
+ "sehen": "Acc",
+ "brauchen": "Acc",
+ "suchen": "Acc",
+ "finden": "Acc",
+ "lieben": "Acc",
+ "grüßen": "Acc",
+ "kennen": "Acc",
+ "lesen": "Acc",
+ "trinken": "Acc",
+ "essen": "Acc",
+ "hören": "Acc",
+ "verstehen": "Acc",
+ "wiederholen": "Acc",
+ "unterschreiben": "Acc",
+ # ── Dat+Acc(双宾 / 自由第三格 / 两可支配 ⇒ 登记但**不判**)────────────────────────
+ "geben": "Dat+Acc",
+ "bringen": "Dat+Acc",
+ "zeigen": "Dat+Acc",
+ "schreiben": "Dat+Acc",
+ "kaufen": "Dat+Acc",
+ "öffnen": "Dat+Acc",
+ "bezahlen": "Dat+Acc",
+ "schmecken": "Dat+Acc",
+}
diff --git a/delector/nlp_engine/processor.py b/delector/nlp_engine/processor.py
index 80fdbbea..76d11e86 100644
--- a/delector/nlp_engine/processor.py
+++ b/delector/nlp_engine/processor.py
@@ -167,6 +167,55 @@ def _resolve_nlp_model() -> Any:
return _nlp_model
+# ── 只读 / 无下载加载助手(Task 1:给产品层「拿一个可用的 nlp」但不付下载成本)────────
+# 说明:`_resolve_nlp_model()` 是「首用即加载」且**带 `spacy.cli.download` 兜底**(出网 + 起 pip
+# 子进程、无时限)。产品层的润色循环不能付这个成本,于是拆成两件更窄的只读/无下载算力。
+def get_loaded_nlp() -> Any:
+ """只读**已加载**的 spaCy 模型:仅当此前成功解析过(`_nlp_resolved` 且 `_nlp_model` 非 None)才返回。
+
+ 绝不触发加载、绝不 `spacy.cli.download` —— 这是给产品层「先看有没有现成模型」的纯读探针。
+ 与 `_resolve_nlp_model()` 的分工:后者是首用即加载(可能起下载),本函数是**零副作用读取**。
+ """
+ if _nlp_resolved and _nlp_model is not None:
+ return _nlp_model
+ return None
+
+
+def try_load_nlp_no_download() -> Any:
+ """按 `SPACY_MODEL_CANDIDATES` **只做本地加载**:能装就装(写回既有缓存),装不上就返回 None。
+
+ **为什么需要这个中间态**(设计理由,勿删):
+ - 只用 `get_loaded_nlp()` 会让**桌面首次润色**也落在 `rules_only`(模型是惰性加载的,别处
+ 还没触发过)⇒ 生产上引擎标记长期假降级,与真实能力不符;
+ - 直接调 `_resolve_nlp_model()` 会走 `spacy.cli.download`(出网 + 起 pip 子进程,无时限);
+ - 本函数取中间态:**本地能加载就加载、加载不上就诚实返回 None,绝不下载**。
+
+ 成功 ⇒ 写入既有缓存(`_nlp_model` / `_nlp_resolved=True`)并刷新 `NLP_ENGINE` / `NLP_ENGINE_DETAIL`;
+ 失败 ⇒ **不写任何全局**(不改 `_nlp_resolved` / `_nlp_error`),返回 None。
+
+ 同步函数(内部不 await),可在 `asyncio.to_thread` 里跑,避免阻塞事件循环。并发安全:
+ 沿用 `_model_lock` + 双检,保证与 `_resolve_nlp_model()` 共用同一份缓存、不重复加载。
+ """
+ global _nlp_model, _nlp_resolved, NLP_ENGINE, NLP_ENGINE_DETAIL
+ if _nlp_resolved and _nlp_model is not None:
+ return _nlp_model
+ with _model_lock:
+ if _nlp_resolved and _nlp_model is not None:
+ return _nlp_model
+ for candidate in SPACY_MODEL_CANDIDATES:
+ try:
+ model, how = _load_spacy_model(candidate)
+ except Exception:
+ # 该候选本地加载失败 ⇒ 试下一个;**绝不**退到 spacy.cli.download(见 docstring)。
+ continue
+ _nlp_model = model
+ _nlp_resolved = True
+ NLP_ENGINE = "spacy"
+ NLP_ENGINE_DETAIL = f"spaCy {getattr(spacy, '__version__', 'unknown')} + {how}"
+ return _nlp_model
+ return None
+
+
class _LazyNlp:
"""惰性 spaCy 模型代理:首次**调用**时才真正加载(ADR-0018 §7.6 O0)。
diff --git a/delector/routes/main.py b/delector/routes/main.py
index aa1fa8d2..9c3ade2a 100644
--- a/delector/routes/main.py
+++ b/delector/routes/main.py
@@ -36,7 +36,7 @@
import threading
import time
from datetime import datetime, timedelta
-from typing import Any, AsyncIterator, Dict, List, NamedTuple, Optional, Tuple
+from typing import Any, AsyncIterator, Callable, Dict, List, NamedTuple, Optional, Tuple
import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
@@ -134,6 +134,9 @@
from delector.services import llm_client, polish_loop
from delector.services.polish_tools import POLISH_TOOL_ALLOWLIST, call_tool
+# 统一违规抽取器(Task 1):端点层与裁判层共用同一形状口径。取具名符号(纯函数,无循环依赖)。
+from delector.services.writing import analyze_a1_email, collect_violations
+
router = APIRouter()
@@ -2200,23 +2203,91 @@ def api_syntax_analyze(req: SyntaxAnalyzeReq) -> Any:
_SSE_POLL_INTERVAL_S = 0.02
-def _a1_violations_if_letter(verdict: Dict[str, Any]) -> List[Dict[str, Any]]:
- """适用性闸门:**A1 书信规则只对书信体文本计数**(Unknown A 的边界)。
+# 格式/字数族 = 书信题材专属规则的 rule 前缀(`a1_*`:称呼语 / 结语 / 字数)。
+# 语法族 = 不以 `a1_` 开头的 rule(artikel / kasus / praeposition / numerus / rektion /
+# wortstellung)—— **与题材无关** ⇒ 对任意德语文本生效(Task 7 收窄;`tempus` 是 advisory、
+# 不进 `collect_violations` 的输出,天然不参与计数)。
+_LETTER_FORMAT_RULE_PREFIX = "a1_"
+
+
+def _text_kind(verdict: Dict[str, Any]) -> str:
+ """文本族判定:**沿用既有 `greeting.valid` 口径**(书信体 ⇔ 称呼语合法)。
- 今天唯一的确定性裁判是 `writing.analyze_a1_email`(歌德 A1 书信规则);裁判集
- 扩充属 Unknown A,本计划不做。它对**非书信体**(普通作文)必然报
- `a1_greeting_missing`(level=error)—— 那是**题材差异**,不是错误。若照单全收:
- `v_0 = 1`,而模型润色后仍然没有称呼语 ⇒ `v_1 >= v_0` ⇒ 循环判 `no_improvement`
- 并回退 argmin = 原文 ⇒ 用户点了润色却拿回原文,功能报废。
+ ⚠️ 以 `greeting.valid` 为唯一判据的三条边界后果(**如实登记,勿删**):
- 故:称呼语不合法 ⇒ 认定「这套规则对该文本不适用」⇒ 零违规。
- 注意这**不等于**「裁判判定无错」:手上的裁判对该文本**无话可说**,语义等价于
- 今天的一次性润色(首轮即接受),而不是「零违规」的证明。
+ ① **书信缺称呼语** ⇒ 归 `prose` ⇒ 格式族(含 `a1_greeting_missing` 这条 error)被
+ `a1_` 前缀过滤 ⇒ 该类邮件的格式错**永远不被计数**;且提示词按初始文本族装配
+ (`pick_system_prompt`)⇒ 走散文版。这是**继承自旧闸门**的边界,不是本批回归。
+ ② **散文含称呼语** ⇒ 归 `letter` ⇒ 格式族照用(对散文生效)+ 书信版提示词。
+ ③ **候选侧翻转(未收口欠账)**:循环的 kind **逐候选**按候选自身裁决体判定
+ (`_polish_violations_of` 单参契约)⇒ 模型"删掉称呼语"的那个候选会翻转口径 ——
+ 该候选自身的格式族违规被过滤后 v 可能掉到 0。这是 2026-10-14 计划里已登记的
+ "闸门对候选单向生效"欠账,本批**未收口**,如实登记。
"""
greeting = verdict.get("greeting") if isinstance(verdict, dict) else None
- if not isinstance(greeting, dict) or not greeting.get("valid"):
- return []
- return polish_loop.default_violations_of(verdict)
+ if isinstance(greeting, dict) and greeting.get("valid"):
+ return "letter"
+ return "prose"
+
+
+def violations_for(kind: str, verdict: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """适用性闸门(**Task 7 收窄版**:按**规则族**分流,而不是按整份裁判开关)。
+
+ - `kind == "letter"` ⇒ 格式/字数族(`a1_*`)+ 语法族 **全计**;
+ - `kind == "prose"` ⇒ **只计语法族**(语法与题材无关)—— 这就是「循环的价值面扩到
+ 任意德语文本」的落点。
+
+ 统一抽取器:走 `collect_violations`(新键 `violations` 优先、legacy `suggestions` 回退,
+ 且只取 `level == "error"`),与裁判层同口径;advisory(warning 级)不在其输出里。
+
+ ⚠️ **产品语义(ADR §10.3 失败模式的实际落点,MUST NOT 静默改)**:非书信文本的语法违规
+ 开始计数 ⇒ 若模型修不动(v 不降),循环按 Task 4 钉死的语义走 `no_improvement` ⇒ argmin
+ 回退**原文**(**不是**"首轮即接受")。这是**明知而接受**的行为变更(2026-10-15 用户拍板):
+ 价值面从"仅 A1 书信"扩到"任意德语文本",代价是修不动的文本拿到原文 + 人话原因码
+ (前端 `POLISH_REASON_COPY.no_improvement`)。
+
+ 历史注记(为什么不再"非书信体一律零违规"):旧闸门是针对"裁判只有书信格式规则"的权宜
+ —— 当时 `a1_greeting_missing` 对每篇非书信文本恒报 error ⇒ v 恒 > 0 ⇒ 恒回退原文。
+ 语法维度接线后前提变了:上面的失败模式只在**模型真修不动**时出现,而不是对非书信文本恒真。
+ """
+ violations = collect_violations(verdict)
+ if kind == "letter":
+ return violations
+ return [v for v in violations if not str(v.get("rule", "")).startswith(_LETTER_FORMAT_RULE_PREFIX)]
+
+
+def _polish_violations_of(verdict: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """循环入口(单参,`violations_of` 契约):文本族由**裁决体自己**判(沿用 `greeting.valid`)。"""
+ return violations_for(_text_kind(verdict), verdict)
+
+
+# 散文向系统提示词(Task 7 的立论冲突解法 b:提示词按**文本族**参数化,业务知识住产品层)。
+# 与书信版(内核 `polish_loop.POLISH_LOOP_SYSTEM_PROMPT`)的差异——**只删题材指令,不动协议**:
+# - 去掉逐字的 A1 书信自查指令("称呼语存在且以逗号结尾 / 逗号后正文首词小写 / 结语后不得加
+# 逗号"):评审黄卡 #2 —— 对非书信文本它会**诱导模型加一个称呼语**,而格式族规则此时已被
+# 闸门关掉 ⇒ 污染无守卫;
+# - **保留** JSON 契约(循环按它解析回复)与"逐句润色、保持句子结构与出现顺序"(来自
+# `essay_diff.diff_sentences` 的按句对齐,diff 逐 hunk 采纳 UI 的**关键约束**)。
+POLISH_PROSE_SYSTEM_PROMPT = (
+ "你是德语写作润色助手。每一轮**只**回复一个 JSON 对象,不要输出任何其他文字。\n"
+ "回复有且只有两种形态:\n"
+ '1) 还需要证据:{"tool_call": {"name": "<工具名>", "payload": {...}}}\n'
+ '2) 可以收尾:{"corrected_text": "<润色后的全文>", "notes_zh": ["<中文说明>", ...]}\n'
+ "工具名必须在可用工具列表里,否则会被拒绝并把错误原样回传给你。\n"
+ "请尽量逐句润色,保持句子的结构与出现顺序,不要合并或拆分句子,除非确实必要。"
+)
+
+
+def pick_system_prompt(kind: str) -> str:
+ """文本族 → 系统提示词(Task 7;**业务知识住在产品层**,内核只收参数)。
+
+ - `"letter"` ⇒ 既有 A1 书信提示词(`polish_loop.POLISH_LOOP_SYSTEM_PROMPT`,逐字);
+ - 其余(`"prose"` 及未知值)⇒ 散文向提示词 —— 未知 kind 取散文向是**保守默认**:
+ 宁少一条题材指令(漏掉书信自查),也不冒"对非书信文本诱导加称呼语"的污染风险。
+ """
+ if kind == "letter":
+ return polish_loop.POLISH_LOOP_SYSTEM_PROMPT
+ return POLISH_PROSE_SYSTEM_PROMPT
def _make_tool_audit_hook(seen: List[str]) -> Any:
@@ -2292,14 +2363,92 @@ def _capture(self, reply: Any) -> None:
self.error_count = count
+def _make_judge(leitpunkte: Optional[List[str]], nlp: Any) -> Callable[[str], Dict[str, Any]]:
+ """装配生产链路用的 **A1 裁判**(带 nlp):内部调 `analyze_a1_email(text, leitpunkte, nlp)`。
+
+ 为什么必须由**产品层**装配、而不能用内核默认裁判:内核默认裁判是
+ `polish_loop._default_judge` = `analyze_a1_email(text, leitpunkte)`(**不带 nlp**)⇒ 语法族
+ 恒不生效。能力必须在产品层装配,内核只保留「谁都能被注入」的缝(评审 RED#1 的闭环点)。
+
+ 降级语义边界(如实登记,勿删):`nlp is None` 时本裁判只跑格式族、`judge_engine="rules_only"`;
+ 此时循环**照常收敛**(运行期 fail-open —— 禁止收敛会让 argmin 回退返回原文 = 功能报废)。
+ “没检查语法”不等于“语法零违规”:声明层的诚实性由裁决体的 `judge_engine` 透出
+ (`_JudgeRecorder` 捕获 → 响应体 `judge_degraded`),门禁层 fail-closed 由调用方承担。
+ **禁止**靠塞一条人造 violation 来实现降级提示。
+
+ ⚠️ **非书信文本的产品语义(Task 7 收窄后的落点;ADR §10.3 失败模式的实际发生点,勿删)**:
+ 本裁判对**任意德语文本**都会给出语法族裁决(语法与题材无关),闸门(`violations_for`)只把
+ 格式/字数族(`a1_*`)限给书信体。后果:非书信文本 + 语法错若**模型修不动**(候选的 v 不降),
+ 循环按 Task 4 钉死的语义走 `no_improvement` ⇒ argmin 回退**原文** ——
+ **不是**"首轮即接受",用户拿到原文 + 前端人话原因码(`no_improvement`)。
+ 这是 2026-10-15 用户拍板的**明知而接受**的行为(价值面从"仅 A1 书信"扩到"任意德语文本")。
+ """
+
+ def _judge(text: str) -> Dict[str, Any]:
+ return analyze_a1_email(text, leitpunkte, nlp)
+
+ return _judge
+
+
+class _JudgeRecorder:
+ """裁判的**录制包装**(与 `_PolishReplyRecorder` 同形):记下每次裁决的 `judge_engine`
+ 与 `judge_advisories`,再原样返回裁决。
+
+ 为什么端点要自己录:`judge_engine` / `advisories` 住在裁决体里(`analyze_a1_email` 的
+ 并列字段),而循环出口的 `LoopResult` 不带它们 ⇒ 端点必须从裁判这一侧捕获,才能把
+ 降级状态与 advisory **如实透出**到响应体(红线 1:不得让“没检查”看起来像“检查通过”;
+ Task 6 HITL:warning 必须真能被看到,否则是 no-op)。
+
+ `judge_advisories` 取裁决体 `advisories`(统一形状、`level=="warning"`)的 `message`
+ 文本;它**不进** `violations`(`violations_of`/`collect_violations` 只认 `level=="error"`)
+ ⇒ 不改变 v、不阻断收敛。每次裁决**覆盖式**记录:记录的是**最后一次裁决**。
+
+ ⚠️ 收窄说明(如实登记,勿删):**argmin 回退路径下 `judge_advisories` 可能与出口文本
+ 不一致** —— 未收敛时循环出口走 `min(history, key=v)` 回退,出口文本可能是**更早**的
+ 候选或原文(`min` 并列取最早 ⇒ 此时原文胜出),而本包装记的是**最后一次裁判调用**的
+ 裁决 ⇒ 响应体 advisory 描述的是"最后一次被裁的文本",不保证是返回给用户的那份。
+ 非阻断:advisory **不进任何计数**(v / 停止策略 / 收敛归属全不受它影响)⇒ 仅呈现层
+ 的对应性欠账。(`judge_engine` 是引擎装配态、与文本无关,不受此影响。)
+
+ `__call__` 的第二参数兼容循环的 `judge_fn(text, leitpunkte)` 调用约定(leitpunkte 已由
+ `_make_judge` 闭包绑定,这里忽略即可)。
+ """
+
+ def __init__(self, judge_fn: Callable[[str], Dict[str, Any]]) -> None:
+ self._judge_fn = judge_fn
+ # 未被裁过时的保守默认:宁报降级(rules_only)不报可用,避免“没跑却标成已跑”。
+ self.judge_engine: str = "rules_only"
+ self.judge_advisories: List[str] = []
+
+ def __call__(self, text: str, leitpunkte: Optional[List[str]] = None) -> Dict[str, Any]:
+ verdict = self._judge_fn(text)
+ if isinstance(verdict, dict):
+ engine = verdict.get("judge_engine")
+ if isinstance(engine, str) and engine:
+ self.judge_engine = engine
+ advisories = verdict.get("advisories")
+ if isinstance(advisories, list):
+ self.judge_advisories = [str(a.get("message", "")) for a in advisories if isinstance(a, dict)]
+ return verdict
+
+
class _PolishOutcome(NamedTuple):
- """一次润色的出口(端点据此组装响应体,流式与非流式共用一份)。"""
+ """一次润色的出口(端点据此组装响应体,流式与非流式共用一份)。
+
+ `judge_engine`(追加,Task 1):本次运行实际装配的裁判引擎(`"rules+spacy"` / `"rules_only"`),
+ 端据此算出响应体 `judge_degraded`。
+
+ `judge_advisories`(追加,Task 6 HITL 收尾):最后一次裁决的 advisory 文本(warning 级,
+ 当前 = B 维 `tempus`)——响应体 `judge_advisories` 的承载,**不参与任何计数**。
+ """
text: str
notes_zh: List[str]
error_count: int
converged: bool
reason: Optional[str]
+ judge_engine: str
+ judge_advisories: List[str]
def _no_key_outcome(text: str) -> _PolishOutcome:
@@ -2313,6 +2462,9 @@ def _no_key_outcome(text: str) -> _PolishOutcome:
error_count=0,
converged=False,
reason=REASON_NO_API_KEY,
+ # 循环没跑 ⇒ 裁判也未装配 ⇒ 保守记为降级(宁报降级不报可用)。
+ judge_engine="rules_only",
+ judge_advisories=[],
)
@@ -2323,17 +2475,44 @@ async def _run_polish(
on_progress: Optional[Any] = None,
should_stop: Optional[Any] = None,
) -> _PolishOutcome:
- """跑一遍润色循环:Task 4 内核 + Task 5 白名单/钩子 + 适用性闸门。"""
+ """跑一遍润色循环:Task 4 内核 + Task 5 白名单/钩子 + **规则族闸门** + 语法裁判装配 +
+ **文本族感知的提示词装配**。
+
+ 引擎来源(评审 RED#1 的闭环点):先**只读**已加载模型(`get_loaded_nlp`,不触发加载/下载);
+ 没有则在线程里尝试**仅本地**加载(`try_load_nlp_no_download` 绝不 `spacy.cli.download`,
+ 且同步函数放 `asyncio.to_thread` 里跑,不阻塞事件循环)。`nlp=None` ⇒ 裁判降级为
+ `rules_only`、循环照常收敛,降级状态由 `judge_engine` 如实透出(响应体 `judge_degraded`)。
+
+ 文本族(Task 7):`kind` 由**纯规则**裁决体判一次(见下方注释),决定
+ ①系统提示词(`pick_system_prompt`:内核的 A1 书信指令对非书信体不适用);
+ ②闸门口径(`violations_for`:格式族只对书信体、语法族对任意德语文本)。
+
+ 产品语义(ADR §10.3 的落点,同款登记在 `violations_for` / `_make_judge` docstring):
+ 非书信文本的语法违规**会计数** ⇒ 模型修不动时 `no_improvement` ⇒ argmin 回退**原文**
+ (**不是**"首轮即接受")。
+ """
import logging
seen: List[str] = []
recorder = _PolishReplyRecorder(api_key, get_effective_api_base_url().rstrip("/"))
+ # 引擎解析:先只读 → 再线程内本地加载(无下载)。绝不阻塞事件循环、绝不触发出网。
+ nlp = _nlp_processor.get_loaded_nlp()
+ if nlp is None:
+ nlp = await asyncio.to_thread(_nlp_processor.try_load_nlp_no_download)
+ # 文本族(选提示词用):纯规则口径(不传 nlp ⇒ 不加载、不跑 spaCy);`greeting.valid`
+ # 本身是纯规则,与带 nlp 的裁决逐字一致。只用于装配提示词,不当作裁决(裁决仍由
+ # judge_recorder 全量记录并透出)。
+ kind = _text_kind(analyze_a1_email(text))
+ judge_recorder = _JudgeRecorder(_make_judge(None, nlp))
try:
loop = await polish_loop.run_polish_loop(
text=text,
tools=POLISH_TOOL_ALLOWLIST,
dispatch=functools.partial(call_tool, hooks=(_make_tool_audit_hook(seen),)),
- violations_of=_a1_violations_if_letter,
+ violations_of=_polish_violations_of,
+ system_prompt=pick_system_prompt(kind),
+ # 必须真的注入(不能退回内核默认裁判)—— 否则生产上语法裁判恒不生效 = dead-green。
+ judge=judge_recorder,
chat=recorder,
budget=polish_loop.Budget(),
on_progress=on_progress,
@@ -2358,22 +2537,32 @@ async def _run_polish(
error_count=error_count,
converged=loop.converged,
reason=loop.reason,
+ judge_engine=judge_recorder.judge_engine,
+ judge_advisories=judge_recorder.judge_advisories,
)
+def _judge_degraded(outcome: _PolishOutcome) -> bool:
+ """引擎降级标记(只增不改的响应字段):`judge_engine != "rules+spacy"` ⇒ 语法族未启用。"""
+ return outcome.judge_engine != "rules+spacy"
+
+
def _polish_payload(outcome: _PolishOutcome) -> Dict[str, Any]:
- """`/api/writing/ai-polish` 的 `result`(**只增不改**:新增收敛两字段)。"""
+ """`/api/writing/ai-polish` 的 `result`(**只增不改**:追加 `judge_degraded` / `judge_advisories`)。"""
return {
"corrected_text": outcome.text,
"notes_zh": outcome.notes_zh,
"error_count": outcome.error_count,
"converged": outcome.converged,
"unconverged_reason": None if outcome.converged else outcome.reason,
+ "judge_degraded": _judge_degraded(outcome),
+ "judge_advisories": outcome.judge_advisories,
}
def _polish_diff_payload(text: str, outcome: _PolishOutcome) -> Dict[str, Any]:
- """`/api/writing/ai-polish/diff` 的 `result`(既有字段与顺序一律不动)。"""
+ """`/api/writing/ai-polish/diff` 的 `result`(既有字段与顺序一律不动,末尾追加 `judge_degraded` /
+ `judge_advisories`)。"""
from delector.services.essay_diff import diff_sentences
return {
@@ -2384,6 +2573,8 @@ def _polish_diff_payload(text: str, outcome: _PolishOutcome) -> Dict[str, Any]:
"error_count": outcome.error_count,
"converged": outcome.converged,
"unconverged_reason": None if outcome.converged else outcome.reason,
+ "judge_degraded": _judge_degraded(outcome),
+ "judge_advisories": outcome.judge_advisories,
}
@@ -2436,8 +2627,20 @@ async def _runner() -> _PolishOutcome:
try:
outcome = await task
- except llm_client.LLMError as e:
+ except HTTPException as e:
# 流已经开了头(状态行已发出),改不了状态码 ⇒ 用一条 error 帧交代人话。
+ #
+ # 为什么捕 `HTTPException` 而不是 `llm_client.LLMError`(接缝③):
+ # `_run_polish` 早就把 `LLMError` 转成 `HTTPException(502, detail=e.detail)`,
+ # 这里写 `except llm_client.LLMError` **永不命中** ⇒ 异常裸抛到
+ # StreamingResponse 外层 ⇒ starlette 抛
+ # "Caught handled exception, but response already started" ⇒ 用户既拿不到
+ # 状态码也拿不到 error 帧,只剩前端那句通用文案。
+ # 选 (b) 而不是 (a)(让 `_run_polish` 在流式路径原样抛 LLMError)的理由:
+ # `_run_polish` 的失败契约对两种调用方 MUST 同一形状(都是 502 + 人话
+ # detail),不该随「谁调它」变形;且流已开头、状态码改不了,error 帧是
+ # 唯一能把 `e.detail` 送到用户面前的通道(前端 writer.js 已按
+ # `ev.event === 'error'` / `ev.detail` 消费)。
yield _sse_frame({"event": "error", "detail": e.detail})
return
payload = _polish_diff_payload(text, outcome) if with_diff else _polish_payload(outcome)
diff --git a/delector/services/polish_loop.py b/delector/services/polish_loop.py
index ebfb3e67..a57e8abe 100644
--- a/delector/services/polish_loop.py
+++ b/delector/services/polish_loop.py
@@ -58,7 +58,14 @@
- `converged=True ⇒ reason == "ok"`;未收敛 MUST 带原因码(零静默失败);
- 收尾候选 MUST 有**本轮新鲜裁判裁决**(D3),否则驳回并强制补跑一次裁判;
-- 每轮开始**之前**先查 `max_tokens_total` 与 `deadline_s`,超支即带原因码退出。
+- 每轮开始**之前**先查 `max_tokens_total` 与 `deadline_s`,超支即带原因码退出;
+- **每次 `dispatch` 都受超时约束**(Task 8 接缝①):`min(DEFAULT_PER_CALL_TIMEOUT_S, 剩余 deadline)`,
+ 剩余 deadline 在调用前**重算**;`asyncio.TimeoutError` ⇒ errors-as-messages(喂回模型),
+ **不炸循环、不冒泡** —— 收口仍由轮首的预算前置检查给原因码;
+- **同步裁判 MUST 移出事件循环**(Task 8 接缝②):经 `asyncio.to_thread` 跑
+ ⇒ 长裁判不阻塞同进程其它路由,且 `task.cancel()` 掐得断在途裁判
+ (线程本身掐不断,但循环不再等待它);注入 async 裁判时直接 `await`
+ (否则 `to_thread` 会把 coroutine 对象当返回值 ⇒ 静默假收敛)。
## 刻意不做
@@ -68,6 +75,8 @@
- `on_progress` 回调抛异常同样**原样冒泡**(UI 回调不该被静默吞掉)。
"""
+import asyncio
+import inspect
import json
import math
import time
@@ -265,6 +274,31 @@ def _default_judge(text: str, leitpunkte: list[str] | None) -> dict[str, Any]:
return analyze_a1_email(text, leitpunkte)
+async def _call_judge(
+ fn: Callable[[str, list[str] | None], Any],
+ text: str,
+ leitpunkte: list[str] | None,
+) -> Any:
+ """调一次裁判:**同步裁判移出事件循环**(Task 8 接缝②)。
+
+ 为什么必须绕这一层(两处调用点都走它):
+
+ 1. **长裁判不阻塞同进程其它路由**:`analyze_a1_email` 是同步 CPU/IO 活,直接跑在
+ 事件循环上会把 `/api/health` 与其它路由一起按在地上(实测单次可达数百 ms);
+ 2. **取消可掐断**:跑在事件循环上时 `task.cancel()` 递不进去(取消要等协程让出
+ 控制权,而同步函数不让)⇒ Task 6 的取消按钮对「在途裁判」无效。
+ `asyncio.to_thread` 的等待点可取消:任务立刻收到 `CancelledError`,
+ 循环**不再等待**那个线程(线程本身掐不断,这是 asyncio 的既有语义)。
+
+ `iscoroutinefunction` 那一支**必须有**:注入方给了 async 裁判时,`to_thread`
+ 只会把 **coroutine 对象**当返回值交回来 ⇒ 上层拿到「没有裁决」⇒ 违规数恒 0
+ ⇒ 循环静默假收敛(最危险的失败形态)。
+ """
+ if inspect.iscoroutinefunction(fn):
+ return await fn(text, leitpunkte)
+ return await asyncio.to_thread(fn, text, leitpunkte)
+
+
def _make_default_dispatch(
tools_map: Mapping[str, Callable[..., Awaitable[dict[str, Any]]]],
) -> Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]:
@@ -295,6 +329,7 @@ async def run_polish_loop(
judge: Callable[[str, list[str] | None], dict[str, Any]] | None = None,
violations_of: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = None,
dispatch: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None = None,
+ system_prompt: str | None = None,
clock: Callable[[], float] = time.monotonic,
on_progress: Callable[[dict[str, Any]], None] | None = None,
should_stop: Callable[[], bool] | None = None,
@@ -302,6 +337,11 @@ async def run_polish_loop(
"""跑一遍有界润色循环。**全部依赖可注入**(测试一律 stub,严禁真 Key)。
返回 `LoopResult`:未收敛 MUST 带原因码(零静默失败),`rounds` 是真实跑过的轮数。
+
+ `system_prompt`(Task 7,**唯一新增参数,向后兼容**):`None` ⇒ 用内核默认的 A1 书信
+ 提示词(`POLISH_LOOP_SYSTEM_PROMPT`);产品层可按文本族传入(书信 ⇒ 默认;散文 ⇒
+ 不含称呼语/结语指令的变体,见 `routes.main.pick_system_prompt`)。**只换提示词文本**:
+ 停止策略 / 预算 / 收敛归属(Task 4 定稿)一律不动。
"""
tools_map: Mapping[str, Callable[..., Awaitable[dict[str, Any]]]] = tools or {}
judge_fn: Callable[[str, list[str] | None], dict[str, Any]] = judge if judge is not None else _default_judge
@@ -312,7 +352,8 @@ async def run_polish_loop(
model = "" if chat is not None else get_effective_api_model()
messages: list[dict[str, Any]] = [
- {"role": "system", "content": POLISH_LOOP_SYSTEM_PROMPT},
+ # `system_prompt=None` ⇒ 内核默认 A1 书信提示词(向后兼容;Task 7 的注入缝)。
+ {"role": "system", "content": POLISH_LOOP_SYSTEM_PROMPT if system_prompt is None else system_prompt},
{"role": "user", "content": f"待润色的德语文本:\n{text}"},
]
if tools_map:
@@ -327,7 +368,7 @@ async def run_polish_loop(
# v_0:先裁一次原文,给停止策略一个可比较的基线(裁判异常 ⇒ 单轮降级,不冒泡)。
try:
- baseline_violations = violations_fn(judge_fn(text, leitpunkte))
+ baseline_violations = violations_fn(await _call_judge(judge_fn, text, leitpunkte))
prev_v = len(baseline_violations)
history.append((text, prev_v, baseline_violations))
except Exception as exc: # 宽捕是刻意的:errors-as-messages,任何裁判异常都不得冒泡
@@ -408,8 +449,17 @@ async def run_polish_loop(
)
continue
# 工具自身抛异常同样 errors-as-messages(不炸循环)
+ # 接缝①:dispatch 也 MUST 有超时。剩余 deadline 在**调用前重算**(不能复用
+ # 轮首那个:本轮的 chat 已经花掉时间),口径与 chat 完全一致 ——
+ # min(每轮默认超时, 剩余 deadline)。剩余 ≤0 ⇒ timeout=0 ⇒ `wait_for` 立刻
+ # 超时,同样喂回模型;下一轮轮首的预算前置检查会以 `deadline` 原因码收口。
+ remaining_now = budget.deadline_s - (clock() - t0)
+ dispatch_timeout = min(DEFAULT_PER_CALL_TIMEOUT_S, max(remaining_now, 0.0))
try:
- result = await dispatch_fn(name, payload)
+ result = await asyncio.wait_for(dispatch_fn(name, payload), timeout=dispatch_timeout)
+ except asyncio.TimeoutError:
+ # 超时是"证据"不是异常:喂回模型让它换招,循环不炸、不冒泡。
+ result = {"error": f"工具 '{name}' 执行超时(>{dispatch_timeout:.1f}s)"}
except Exception as exc: # 宽捕是刻意的:工具失败是"证据",不是 500
result = {"error": f"工具 '{name}' 执行失败: {_brief(exc)}"}
evidence = truncate_evidence(_render_evidence(result))
@@ -436,7 +486,7 @@ async def run_polish_loop(
# 删掉这个守卫 ⇒ 裁判被跳过 ⇒ v_n 恒 0 ⇒ 循环**静默假收敛**(最危险的失败形态)。
if verdict_this_round is None:
try:
- verdict_this_round = judge_fn(candidate, leitpunkte)
+ verdict_this_round = await _call_judge(judge_fn, candidate, leitpunkte)
except Exception as exc: # 宽捕是刻意的:Unknown D 按"会抛"设计 ⇒ 单轮降级
degraded = REASON_JUDGE_ERROR
messages.append({"role": "user", "content": f"[裁判异常] {_brief(exc)}"})
diff --git a/delector/services/writing.py b/delector/services/writing.py
index f2bb3a8d..f0d55d5a 100644
--- a/delector/services/writing.py
+++ b/delector/services/writing.py
@@ -9,6 +9,7 @@
from typing import Any, Dict, List, Optional, Tuple
from delector.core.lexicon import lookup_core_vocab
+from delector.data.a1_verb_case import A1_VERB_CASES
# 双格介词整组跳过(静动态依语境决定,纯规则极易误报)
_TWO_WAY_PREPS = {"in", "an", "auf", "über", "unter", "vor", "hinter", "neben", "zwischen"}
@@ -438,6 +439,568 @@ def detect_preposition_case(tokens: List[Any], base: int) -> List[Dict[str, Any]
return spans
+# ── 规则 C:主谓一致(Number 一致)────────────────────────────────────────────
+# FP 守卫③清单:Number 语义不可靠的形式/不定/指示代词作主语(spaCy 实测:
+# 「Das sind meine Eltern.」里 das=Sing 而 sind=Plur —— 这是合法句 ⇒ 一律不判)。
+_NUMBER_UNRELIABLE_SUBJECTS = {
+ "man",
+ "es",
+ "das",
+ "dies",
+ "jemand",
+ "niemand",
+ "irgendwer",
+ "wer",
+ "was",
+ "etwas",
+ "nichts",
+ "alles",
+ "alle",
+ "jeder",
+ "manche",
+ "mehrere",
+ "viele",
+ "einige",
+}
+
+# FP 守卫②清单:集合名词(语法格为单数,但常见误标/语义漂移风险)⇒ 不判。
+_COLLECTIVE_NOUNS = {
+ "familie",
+ "gruppe",
+ "team",
+ "klasse",
+ "mannschaft",
+ "gesellschaft",
+ "publikum",
+ "polizei",
+ "verein",
+ "firma",
+}
+
+# 现在时第三人称复数与不定式同形(通则),唯一例外:
+_IRREG_PLURAL_VERB_FORMS = {"sein": "sind"}
+
+
+def _is_plural(num: Optional[str]) -> bool:
+ return (num or "").lower() in ("plur", "plural")
+
+
+def _subject_is_coordinated(subj: Any, verb: Any) -> bool:
+ """FP 守卫①:主语含并列(sb 只代表第一个并列项,Number 属整个并列短语 ⇒ 不判)。
+
+ 实测结构:`Anna und Tom spielen` ⇒ sb=Anna(Sing),`und`(CCONJ) 是 Anna 的孩子。
+ """
+ if subj.dep_ in ("cj", "cd"):
+ return True
+ if any(c.pos_ == "CCONJ" for c in subj.children):
+ return True
+ return any(c.dep_ in ("cj", "cd") and c.pos_ in ("NOUN", "PROPN", "PRON") for c in verb.children)
+
+
+def _plural_verb_form(tok: Any) -> Optional[str]:
+ """生成现在时第三人称复数形式;不确定 ⇒ None(调用方跳过 —— 宁漏不误)。"""
+ if _first_morph(tok.morph.get("Tense")) != "Pres":
+ return None
+ lemma = (tok.lemma_ or "").lower().strip()
+ if not lemma:
+ return None
+ return _IRREG_PLURAL_VERB_FORMS.get(lemma, lemma)
+
+
+def detect_subject_verb_agreement(tokens: List[Any], base: int) -> List[Dict[str, Any]]:
+ """规则 C:主语(`dep=="sb"`)的 Number 与定式动词的 Number 不一致。
+
+ 只报「复数主语 + 单数定式动词」这一个方向(实测锚点:`Die Kinder spielt im Garten.`);
+ 反向(单数主语 + 复数动词)不判 —— 单数第三人称形式不规则面大(hilft/liest/isst…),
+ 生成 `corrected_form` 不可靠 ⇒ 宁漏不误。
+
+ FP 守卫(逐条实测标定;②—⑦ 缺一条就会在真实文本上大批误报,唯 ① 例外 —— 当前单向
+ 判据下它不可达/防御性、只会造成漏报、勿当有效拦截器):
+ ① 并列主语(`Anna und Tom spielen` —— sb 只挂第一个并列项;「复数主语+单数动词」单向
+ 判定下此类句子的动词天然是复数、根本不进判定 ⇒ 该守卫当前不可达);
+ ② 集合名词(`die Familie`);
+ ③ `man` / 不定代词 / 形式主语(`Das sind meine Eltern.` 实测是合法句);
+ ④ 复合时态(`hat ... gemacht`:句含过去分词 ⇒ 整句不判,Number 落在助动词上);
+ ⑤ 数量词主语(`Zwei Stunden ist genug.` 实测是合法句 ⇒ 不判);
+ ⑥ 不定式/动词短语主语(`Bücher lesen ist schön.` ⇒ Number 不适用);
+ ⑦ 主语缺失(祈使句 / `es gibt` / 无 `sb` 的句子 ⇒ 天然零输出)。
+ """
+ spans: List[Dict[str, Any]] = []
+ # FP 守卫④:句内存在过去分词(复合时态/被动)⇒ 整句不判。
+ if any(_first_morph(t.morph.get("VerbForm")) == "Part" for t in tokens):
+ return spans
+
+ for subj in tokens:
+ if subj.dep_ != "sb":
+ continue
+ verb = subj.head
+ if verb is None or verb.pos_ not in ("VERB", "AUX"):
+ continue
+ if _first_morph(verb.morph.get("VerbForm")) != "Fin":
+ continue
+ # FP 守卫③:形式/不定代词主语
+ if subj.text.lower() in _NUMBER_UNRELIABLE_SUBJECTS:
+ continue
+ if subj.lemma_ and subj.lemma_.lower() in _NUMBER_UNRELIABLE_SUBJECTS:
+ continue
+ # FP 守卫②:集合名词主语
+ if subj.pos_ in ("NOUN", "PROPN") and subj.lemma_.lower() in _COLLECTIVE_NOUNS:
+ continue
+ # FP 守卫⑤:主语是数量词短语(实测 `Zwei Stunden ist genug.` 是合法句 ——
+ # sb=Stunden(Plur) + ist(Sing),德语里量词主语接单数动词是常见合法用法)。
+ if any(c.pos_ == "NUM" for c in subj.children):
+ continue
+ # FP 守卫⑥:主语为不定式/动词短语(`Bücher lesen ist schön.`)⇒ Number 不适用。
+ if subj.pos_ in ("VERB", "AUX") or _first_morph(subj.morph.get("VerbForm")) == "Inf":
+ continue
+ # FP 守卫①:并列主语
+ if _subject_is_coordinated(subj, verb):
+ continue
+
+ s_num = _first_morph(subj.morph.get("Number"))
+ if not s_num and subj.pos_ == "NOUN":
+ det = _np_det(subj)
+ s_num = _first_morph(det.morph.get("Number")) if det is not None else None
+ v_num = _first_morph(verb.morph.get("Number"))
+ if not s_num or not v_num:
+ continue
+ # 只报「复数主语 + 单数动词」方向(宁漏不误,见 docstring)。
+ if not _is_plural(s_num) or _is_plural(v_num):
+ continue
+
+ correct = _plural_verb_form(verb)
+ if not correct or correct.lower() == verb.text.lower():
+ continue
+ start, end = _tok_off(verb, base)
+ spans.append(
+ {
+ "severity": "error",
+ "error_type": "numerus",
+ "corrected_form": correct,
+ "explanation_zh": (
+ f"主语「{subj.text}」是复数,定式动词「{verb.text}」却是单数形式,应为「{correct}」。"
+ ),
+ "start": start,
+ "end": end,
+ }
+ )
+ return spans
+
+
+# ── 规则 D:动词支配格(Rektion)──────────────────────────────────────────────
+# 词表是人工登记的事实数据(逐条来源注释见 delector/data/a1_verb_case.py)。
+_CASE_LABEL_ZH = {"Dat": "三格(Dativ)", "Acc": "四格(Akkusativ)"}
+
+
+def detect_verb_case_government(tokens: List[Any], base: int) -> List[Dict[str, Any]]:
+ """规则 D:词表动词的支配格 vs 其宾语 NP 的实际格。
+
+ 实测锚点:`Ich helfe meinen Bruder.`(helfen 支配三格)⇒ 报 `meinem Bruder`;
+ `Ich helfe meinem Bruder.` ⇒ 0 条。
+
+ 实现要点(FP 是敌人 ⇒ 宁漏不误):
+ - 只报**有冠词且单数**的宾语 NP:零冠词(`Ich danke dir.`)与复数宾语(Dat Plur 名词
+ 还需 -n 词尾,只换冠词会给出半对纠正)一律跳过;
+ - 比对**表面形式 vs 目标格形式**(`decline_determiner`),不直判 `morph.Case` —— 实测
+ `Ich helfe meiner Schwester.` 里 spaCy 把 Fem Dat 的 `meiner` 标成 `Gen`(同形歧义),
+ 直判 morph 会误报合法句;
+ - 词表内标 `Dat+Acc` 的双宾动词(geben 等)不判;
+ - **只比对「占用被支配格位」的宾语**(如实跑标定,见守卫⑧):目标格 = `Acc` 时,动词四格位
+ (`oa` 孩子)**已被占用**的情况下,`da` 宾语是**自由第三格**(`jdm. etw.` 构造,与
+ `Ich öffne jdm. die Tür.` 同类)⇒ MUST 不判。
+
+ FP 守卫:① 动词不在词表;② 反身(宾语是反身代词);③ 可分前缀(`svp` 分裂前缀);
+ ④ 名词化(动词 token 带限定词孩子 / pos 非 VERB);⑤ `Dat+Acc`;⑥ 代词/零冠词宾语;
+ ⑦ 复数宾语;⑧ **自由第三格**(严格 `Acc` 动词 + 已有 `oa` 宾语时的 `da` 宾语)。
+
+ 守卫⑧ 的实跑锚点与取舍:`Ich lese meinem Bruder das Buch.` / `Ich suche meinem Bruder
+ die Schuhe.` 里 spaCy 给 `Bruder` 打 `da`、给 `Buch`/`Schuhe` 打 `oa` —— 旧实现把所有
+ `oa`/`da` 宾语都拿去比对 Acc 目标格 ⇒ 合法句被报 `meinen Bruder`。自由第三格是 A1 教材级
+ 普遍构造(`Ich öffne / wasche / lese / suche jdm. etw.`),故判据取「四格位已被 `oa` 占用时
+ `da` 不判」;**代价(明知而接受)**:漏掉「本该 Dat 却写成 Acc 的双宾句」(人三物四里把三格
+ 写成 `meinen Bruder`)—— 宁漏不误。反证据 `Ich verstehe der Frage.`(`da` 宾语、无 `oa`
+ 兄弟 = 真实格错)MUST 仍报,用例见 tests/test_writing_rules.py。
+ """
+ spans: List[Dict[str, Any]] = []
+ for tok in tokens:
+ if tok.pos_ not in ("VERB", "AUX"):
+ continue
+ target = A1_VERB_CASES.get(tok.lemma_.lower())
+ if target is None or target == "Dat+Acc":
+ continue
+ # 守卫③:可分前缀动词(`Ich rufe meine Mutter an.` —— an 的 dep=svp 且 head 是动词)
+ if any(c.dep_ == "svp" for c in tok.children):
+ continue
+ # 守卫②:反身(`Ich freue mich ...` —— mich 的 pos=PRF / Reflex=Yes)
+ if any(c.pos_ == "PRF" or _first_morph(c.morph.get("Reflex")) == "Yes" for c in tok.children):
+ continue
+ # 守卫④:名词化(`Das Lesen ...` —— 动词 token 带限定词孩子)
+ if any(c.pos_ == "DET" for c in tok.children):
+ continue
+
+ # 守卫⑧(结构性 FP,实跑标定):`oa` 孩子 = 该动词四格位已被占用的信号 ——
+ # 严格 Acc 动词只有在四格位**空着**时,`da` 宾语才可能是「四格被写成三格」的错句
+ # (`Ich verstehe der Frage.` ⇒ 实测 `da`、无 `oa` ⇒ 仍报);四格位被占用时,`da` 宾语
+ # 是自由第三格(`Ich lese/suche jdm. etw.`)⇒ MUST 不判。取舍见 docstring(宁漏不误)。
+ has_accusative_object = any(c.dep_ == "oa" for c in tok.children)
+ for obj in tok.children:
+ if obj.pos_ != "NOUN" or obj.dep_ not in ("oa", "da"):
+ continue
+ if target == "Acc" and obj.dep_ == "da" and has_accusative_object:
+ continue
+ det = _np_det(obj)
+ if det is None:
+ continue # 守卫⑥:零冠词 / 代词宾语
+ number = _first_morph(obj.morph.get("Number")) or _first_morph(det.morph.get("Number"))
+ if not number or _is_plural(number):
+ continue # 守卫⑦:复数宾语
+ dict_info = lookup_core_vocab(obj.lemma_) or lookup_core_vocab(obj.text)
+ real_gender = (
+ (dict_info.get("gender") if dict_info else None)
+ or _first_morph(obj.morph.get("Gender"))
+ or _first_morph(det.morph.get("Gender"))
+ )
+ form = decline_determiner(det.lemma_, real_gender, number, target)
+ if form is None or form.lower() == det.text.lower():
+ continue # 表面形式已是目标格 ⇒ 判定通过
+ start = min(det.idx, obj.idx) - base
+ end = max(det.idx + len(det.text), obj.idx + len(obj.text)) - base
+ spans.append(
+ {
+ "severity": "error",
+ "error_type": "rektion",
+ "corrected_form": f"{form} {obj.text}",
+ "explanation_zh": (
+ f"动词「{tok.lemma_}」支配{_CASE_LABEL_ZH.get(target, target)},"
+ f"「{obj.text}」前应为「{form}」而非「{det.text}」。"
+ ),
+ "start": start,
+ "end": end,
+ }
+ )
+ return spans
+
+
+# ── 规则 E:主句动词位置(V2)────────────────────────────────────────────────
+_FINITE_MOODS = {"Ind", "Imp", "Sub"}
+# 非主句分句标记(德语 TIGER 依存标签,全部实测):mo=从句谓词(`weil ich krank bin` 的 bin)
+# · oc=宾语从句/引语内(`"Ich komme morgen."` 的 komme)· cp=从属连词提升 · cs=从属连词
+# · cj/cd/cc=并列分句(`... und mein Bruder isst.` 的 isst)· rc/re=关系从句 · mnr/par=附加语。
+_SUBORDINATE_DEPS = {"mo", "oc", "cp", "cs", "rc", "re", "mnr", "par", "cj", "cd", "cc"}
+# 主语同一 NP 内部的依存标记(不算"额外前置成分")。
+_NP_INTERNAL_DEPS = {"det", "nk", "pnc", "ag", "app", "pn", "cc", "cd", "cj"}
+_QUOTE_CHARS = {'"', "„", "“", "”", "«", "»", "‚", "‘", "’"}
+
+
+def _is_finite_verb(tok: Any) -> bool:
+ if tok.pos_ not in ("VERB", "AUX"):
+ return False
+ if _first_morph(tok.morph.get("VerbForm")) == "Fin":
+ return True
+ return _first_morph(tok.morph.get("Mood")) in _FINITE_MOODS
+
+
+def _np_family_text(subj: Any, tokens: List[Any]) -> str:
+ """主语 NP 的完整文本(含限定词/定语/并列项;用于生成 corrected_form)。"""
+ members: Dict[int, Any] = {subj.i: subj}
+ for _ in range(4):
+ grew = False
+ for t in tokens:
+ if t.i in members or t.dep_ not in _NP_INTERNAL_DEPS:
+ continue
+ if t.head is not None and t.head.i in members:
+ members[t.i] = t
+ grew = True
+ if not grew:
+ break
+ return " ".join(members[k].text for k in sorted(members))
+
+
+# 焦点小词(Fokuspartikeln)—— **封闭词表**,只收「焦点标记」用法:语义上依附其后的成分
+# (`Auch meine Familie ...` / `Nur mein Bruder ...`),A1 教材级普遍构造。
+# MUST NOT 收:时间/频率/程度副词(`gestern / heute / morgen / jetzt / dann / sehr / oft` …)——
+# 实测两者在 spaCy 里**同形**(dep=`mo`、head 直挂主语),一旦混入就会让错误语序负样本
+# `Gestern ich habe gearbeitet.` 被豁免(V2 规则对 A1 最高频语序错永久失明,见用例
+# `test_v2_focus_particles_closed_list_excludes_time_adverbs`)。
+# 同理 MUST NOT 收 `erst / schon`:其主导用法是时间性状语(Vorfeld 位 ⇒ 正常 V2),
+# 与焦点用法不可区分 ⇒ 宁漏不误。
+_FOCUS_PARTICLES = frozenset(
+ {
+ "auch",
+ "nur",
+ "sogar",
+ "eben",
+ "genau",
+ "gerade",
+ "bloß",
+ "allein",
+ "insbesondere",
+ "wenigstens",
+ "zumindest",
+ "ausgerechnet",
+ "besonders",
+ "lediglich",
+ "speziell",
+ "mindestens",
+ "hauptsächlich",
+ }
+)
+
+
+def _is_focus_particle_in_subject_np(tok: Any, subj: Any) -> bool:
+ """tok 是否为**落在主语 NP 内部**的焦点小词(1~2 步进入主语名词或其限定词)。
+
+ 实测锚点(第三类 FP):`Auch meine Familie wohnt in Berlin.` 的链 `Auch → Familie(sb)`(1 步)、
+ `Nur mein Bruder kommt heute.` 的链 `Nur → mein(DET/`nk`) → Bruder(sb)`(2 步)—— 两条链上
+ **都没有 ADJ**,故只靠「经过 ADJ」的判据整类漏掉 ⇒ 合法句被报 `Auch wohnt meine Familie`。
+ 中间节点 MUST 是主语 NP 内部成分(`_NP_INTERNAL_DEPS`:det/nk/…),否则不算 NP 内部。
+ """
+ if tok.text.lower() not in _FOCUS_PARTICLES:
+ return False
+ node = tok
+ for _ in range(2):
+ parent = node.head
+ if parent is None or parent.i == node.i:
+ return False
+ if parent.i == subj.i:
+ return True
+ node = parent
+ if node.dep_ not in _NP_INTERNAL_DEPS:
+ return False
+ return False
+
+
+def _is_inside_subject_np(tok: Any, subj: Any) -> bool:
+ """沿 head 链判定 tok 是否落在主语 NP 内部(主语 NP 的修饰链 / 焦点小词)。
+
+ 判据(逐条实测标定):
+ ① **焦点小词**(封闭词表 `_FOCUS_PARTICLES`):head 链 1~2 步进入主语 NP ⇒ 参与构成主语 NP
+ (`Auch meine Familie ...` / `Nur mein Bruder ...`,实测链上没有 ADJ ⇒ ② 覆盖不到);
+ ② 其余词:head 链回溯可达主语,**且路径上经过形容词(ADJ)**。
+ - 实测锚点(B 类 FP):`Meine sehr große Familie wohnt in Berlin.` 里 `sehr` 的链
+ `sehr(ADV, mo) → große(ADJ, nk) → Familie(sb)` 经过 ADJ ⇒ 是主语 NP 内部修饰语、豁免;
+ - 反例(MUST 继续报):错误语序 `Gestern ich habe gearbeitet.` 里 spaCy 把状语
+ `Gestern` 直接挂到主语 `ich` 上(`Gestern(ADV, mo) → ich(sb)`,实测),链上无 ADJ
+ ⇒ 不算 NP 内部成分 —— 「链可达主语」单独使用会漏报该负样本(实测踩坑后收紧);该反例也是
+ ① 只能用**封闭词表**而不能用「形态相同即豁免」的原因(`Gestern` 与 `Auch` 的解析形态一致)。
+ 限步回溯,防解析异常成环。
+ """
+ if _is_focus_particle_in_subject_np(tok, subj):
+ return True
+ saw_adj = False
+ node = tok
+ for _ in range(8):
+ parent = node.head
+ if parent is None or parent.i == node.i:
+ return False # 抵达 ROOT(自环)仍未经过主语 ⇒ 不在主语 NP 内
+ if node.pos_ == "ADJ":
+ saw_adj = True
+ if parent.i == subj.i:
+ return saw_adj
+ node = parent
+ return False
+
+
+def detect_verb_second_position(tokens: List[Any], base: int) -> List[Dict[str, Any]]:
+ """规则 E:主句定式动词 MUST 落在第二位置(V2)。
+
+ 实测锚点:`Gestern habe ich gearbeitet.` ⇒ 0 条;`Gestern ich habe gearbeitet.` ⇒ 报 1 条。
+
+ 主句判定**优先用 dep 而不是标点**:候选定式动词的 head 链上出现从句/并列标记
+ (`mo`/`oc`/`cp`/`cj`/`cd`…)即非主句 ⇒ 不判 —— 实测 `weil ich krank bin` 里 `bin` 的
+ dep=`mo`、引语内 `komme` 的 dep=`oc`、并列分句 `isst` 的 dep=`cj`。
+
+ 豁免面(逐条用例见 tests/test_writing_rules.py):
+ ① 从句 / ② 疑问句(含 `?` 或句首动词 V1)/ ③ 祈使句(Mood=Imp 或句首动词)/
+ ④ 并列句第二分句 / ⑤ 直接引语(引号奇偶) / ⑥ 标题式片段(无主语 / 多主语 ⇒ 天然零输出)/
+ ⑦ 主语与动词之间有逗号(称呼语 `Sehr geehrter Herr ...`、插入语)⇒ 宁漏不误;
+ ⑧ 动词前出现换行(称呼语/行边界被并进同一句)⇒ Vorfeld 不跨行 ⇒ 宁漏不误。
+
+ 判据本体:主语在定式动词**之后**(Vorfeld 是别的成分)⇒ 天然合法,不判;主语在动词之前时,
+ 仅当主语左侧还有**独立于该主语 NP 的成分**才报 —— 故「Vorfeld = 多 token 短语」
+ (`Gestern Abend habe ich gearbeitet.` / `Am Montag habe ich Zeit.`)不会误报。
+
+ 「独立于主语 NP」MUST 沿 head 链判定(判据本体见 `_is_inside_subject_np`,实测锚点
+ `Meine sehr große Familie wohnt in Berlin.`:`sehr` 的 dep=`mo`、head 链
+ `sehr → große(ADJ) → Familie(主语)` —— 只按 dep 白名单会把 `sehr` 当独立 Vorfeld 成分
+ ⇒ 误报;豁免仅当链可达主语**且经过 ADJ** —— 否则错误语序句 `Gestern ich habe gearbeitet.`
+ 会被漏报,此为收紧后的实测行为)。
+
+ 第三类实跑 FP 与补丁:**焦点小词**(`Auch meine Familie wohnt in Berlin.` / `Nur mein Bruder
+ kommt heute.`)的 head 链同样直挂主语 NP,但链上**没有 ADJ** ⇒ 上面那条判据整类漏掉、合法句被
+ 报。故豁免面增补:词 ∈ **封闭**焦点小词表 `_FOCUS_PARTICLES` 且 head 链 1~2 步进入主语 NP。
+ 时间/频率副词 MUST NOT 进表(`Gestern` 与 `Auch` 的解析形态实测一致,只能靠封闭词表区分)。
+ """
+ spans: List[Dict[str, Any]] = []
+ if any(t.text == "?" for t in tokens):
+ return spans # 豁免②:疑问句(标点信号)
+ for v in tokens:
+ if not _is_finite_verb(v):
+ continue
+ if _first_morph(v.morph.get("Mood")) == "Imp":
+ continue # 豁免③:祈使句(形态信号)
+ if v.i == 0:
+ continue # 豁免②③:句首动词(V1 疑问 / 祈使 / 片段)
+ # 主句判定:head 链上出现从句/并列标记 ⇒ 该候选是子句谓词(豁免①④)。
+ in_main = True
+ node = v
+ for _ in range(6):
+ if node.dep_ in _SUBORDINATE_DEPS or node.pos_ == "SCONJ":
+ in_main = False
+ break
+ parent = node.head
+ if parent is None or parent.i == node.i:
+ break # 到达 ROOT 且链上无从句标记
+ node = parent
+ if not in_main:
+ continue
+ # 豁免⑤:直接引语(动词落在奇数个引号之后 ⇒ 处于引号对内)。
+ if sum(1 for t in tokens if t.i < v.i and t.text in _QUOTE_CHARS) % 2 == 1:
+ continue
+ # 豁免⑥:无主语 / 多主语(含无人称被动、标题式片段)⇒ 天然零输出。
+ # 多 sb 是解析不确定的信号:实测书信称呼语 `Sehr geehrter Herr Becker, ich interessiere ...`
+ # 会给动词挂上两个 sb(`Herr` 与 `ich`)⇒ 必须跳过,否则官方范文被误报。
+ subj_children = [c for c in v.children if c.dep_ == "sb"]
+ if len(subj_children) != 1:
+ continue
+ subj = subj_children[0]
+ # 豁免⑦:主语与动词之间有逗号(称呼语 / 插入语 `Ich, ehrlich gesagt, habe ...`)⇒ 宁漏不误。
+ if any(t.pos_ == "PUNCT" and subj.i < t.i < v.i for t in tokens):
+ continue
+ # 主语在动词之后 ⇒ Vorfeld 已被别的成分占据,语序合法。
+ if subj.i > v.i:
+ continue
+ # 豁免⑧:Vorfeld 不跨行 —— 定式动词之前出现换行(称呼语/行边界吸收进同一句)⇒ 该
+ # "前置成分"只是上一行残留(实测官方范文 `Liebe Sarah,\n\nes tut mir leid ...` 被并句)。
+ if any("\n" in t.text for t in tokens if t.i < v.i):
+ continue
+ # 主语左侧的 token(排除标点与空白):只有“独立于主语 NP 的成分”才算额外前置成分。
+ left = [t for t in tokens if t.i < v.i and t.i < subj.i and t.pos_ not in ("PUNCT", "SPACE")]
+ if any(t.pos_ == "CCONJ" for t in left):
+ continue # 并列结构(解析复杂)⇒ 宁漏不误
+ indep = [t for t in left if t.dep_ not in _NP_INTERNAL_DEPS and not _is_inside_subject_np(t, subj)]
+ if not indep:
+ continue # 主语左侧只有它自己的 NP 内部成分(`Meine Eltern ...`)⇒ 合法 V2
+ vorfeld = " ".join(t.text for t in sorted(indep, key=lambda x: x.i))
+ corrected = f"{vorfeld} {v.text} {_np_family_text(subj, tokens)}"
+ start, end = _tok_off(v, base)
+ spans.append(
+ {
+ "severity": "error",
+ "error_type": "wortstellung",
+ "corrected_form": corrected,
+ "explanation_zh": (
+ f"德语主句定式动词必须占第二位(V2):动词「{v.text}」之前已有前置成分「{vorfeld}」,"
+ f"主语「{subj.text}」不应再插在动词前(应为「{corrected}」)。"
+ ),
+ "start": start,
+ "end": end,
+ }
+ )
+ return spans
+
+
+# ── 规则 F:过去时间锚 × 时态一致性(advisory / warning,Task 6 HITL 裁决)────────
+# 移植自 `tools/bench_judge_fp.py` 的 B 维探测(**该脚本是冻结的标定记录,勿改**)。
+# HITL 裁决(用户 2026-10-15):接线为 `level="warning"` —— 正样本 FP 0/35、负样本 7/7、
+# 对照 0/6,两档阈值均成立;但样本偏小(7 错 + 6 对照)⇒ **先 advisory,不阻断收敛**
+# (`violations_of`/`collect_violations` 只认 `level=="error"`),积累真实用法后再议升 error。
+# 探测器刻意不依赖 dep 边(实测 spaCy 对德语框架解析不可靠,见 bench 模块头),只用
+# 「时间锚词表 + morph 时态 + 完成时豁免」。
+_PAST_ANCHOR_ADVERBS = frozenset(
+ {
+ "gestern",
+ "vorgestern",
+ "damals",
+ "früher",
+ "frueher",
+ "neulich",
+ "kürzlich",
+ "kuerzlich",
+ "ehemals",
+ }
+)
+# letzte/letzten/... + 时间名词 才是锚(单独 "letzte" 可能是"末班车")。
+_LETZTE_FORMS = frozenset({"letzte", "letzten", "letzter", "letztes"})
+_TIME_NOUNS = frozenset(
+ {
+ "woche",
+ "wochen",
+ "monat",
+ "monaten",
+ "jahr",
+ "jahren",
+ "wochenende",
+ "mal",
+ "sommer",
+ "winter",
+ "tag",
+ "tagen",
+ }
+)
+# 现在时形式的完成时助动词(haben/sein)—— 句中存在 Partizip II 时豁免。
+# 例:`Gestern habe ich eine Suppe gekocht.` 的 habe 是现在时形态但整体是过去语义。
+_PERFECT_AUX_PRES = frozenset({"habe", "hast", "hat", "haben", "habt", "bin", "bist", "ist", "sind", "seid"})
+
+
+def _past_anchor_token(tokens: List[Any]) -> Optional[Any]:
+ """返回句中第一个过去时间锚 token(无 ⇒ None)。"""
+ for i, token in enumerate(tokens):
+ if token.lower_ in _PAST_ANCHOR_ADVERBS:
+ return token
+ if token.lower_ in _LETZTE_FORMS and i + 1 < len(tokens) and tokens[i + 1].lower_ in _TIME_NOUNS:
+ return token
+ return None
+
+
+def detect_past_anchor_tense_conflict(tokens: List[Any], base: int) -> List[Dict[str, Any]]:
+ """规则 F(**advisory / warning**):过去时间锚 + 现在时限定动词(非完成时结构)。
+
+ 实测锚点:`Gestern gehe ich zur Schule.` ⇒ 报 span 指向 `gehe`(span=(8, 12));
+ `Gestern ging ich zur Schule.` / `Gestern habe ich eine Suppe gekocht.` ⇒ 0 条。
+
+ 判据(词表 + morph;与 bench 同源):
+ 1. 句中含过去时间锚(gestern / letzte Woche / damals ...);
+ 2. 存在 morph `VerbForm=Fin` 且 `Tense=Pres` 的动词;
+ 3. 豁免完成时结构:句中存在 Partizip II(`VerbForm=Part` 的 VERB)时,
+ haben/sein 的现在时形式不触发(`Gestern habe ich ... gekocht.` 是正确句)。
+
+ 每句至多报 1 条(首个命中的限定动词;与 bench 的句级布尔口径一致,避免 advisory 刷屏)。
+
+ 已知边界(未覆盖,如实登记,与 bench 同源):`Gestern muss ich müde gewesen sein.` 这类
+ "情态词 + 完成时不定式"的正确推测句会被报(advisory 语义下可接受);`Morgen`/`am Samstag`
+ 等中性/未来时间表达不在锚表内 ⇒ 不报。
+
+ `corrected_form` 留空串:确定性的过去时形式需要完整变位表,编一个具体形式就是误导
+ (宁缺勿假)——方向性建议在 `explanation_zh` 里给出。
+ """
+ spans: List[Dict[str, Any]] = []
+ anchor = _past_anchor_token(tokens)
+ if anchor is None:
+ return spans
+ has_partizip = any(t.pos_ == "VERB" and "Part" in t.morph.get("VerbForm") for t in tokens)
+ for token in tokens:
+ if "Fin" in token.morph.get("VerbForm") and "Pres" in token.morph.get("Tense"):
+ if has_partizip and token.lower_ in _PERFECT_AUX_PRES:
+ continue
+ start, end = _tok_off(token, base)
+ spans.append(
+ {
+ "severity": "warning",
+ "error_type": "tempus",
+ "corrected_form": "",
+ "explanation_zh": (
+ f"句中时间锚「{anchor.text}」指向过去,但限定动词「{token.text}」是现在时。"
+ f"A1 建议改用过去时(Präteritum)或完成时(Perfekt)。"
+ ),
+ "start": start,
+ "end": end,
+ }
+ )
+ break
+ return spans
+
+
def _collect_prep_hints(tokens: List[Any], base: int) -> List[Dict[str, Any]]:
"""收集介词支配格内联提示。"""
hints = []
@@ -604,7 +1167,49 @@ def _normalize(s: str) -> str:
return {"correct": False, "expected": expected_val, "user_answer": user_val}
-def analyze_a1_email(text: str, leitpunkte: Optional[List[str]] = None) -> Dict[str, Any]:
+def collect_violations(analysis: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """把一次裁判输出规整为**统一违规形状**,且只保留 `level == "error"`(阻止收敛的那一类)。
+
+ 统一形状:`{"rule": str, "level": "error"|"warning", "message": str, "span": tuple[int, int] | None}`。
+
+ 抽取顺序(Task 1 新增,`只增不改` 的落地):
+ - **新键 `violations` 优先** —— `analyze_a1_email` 扩后的并列字段(格式族与语法族已按统一
+ 形状并列其中);
+ - 缺新键时回退 **legacy `suggestions`**(Task 4 起的旧形状),回退路径同样只取
+ `level == "error"`。
+
+ 只取 error 的理由与 `polish_loop.default_violations_of` 一致:warning 是建议(字数偏短/
+ 偏多、称呼语缺逗号…),不构成"不达标";把 warning 也算违规 ⇒ 几乎所有 A1 短文永远
+ `v > 0` ⇒ 循环永不收敛,那才是真正的静默失败。
+ """
+ if not isinstance(analysis, dict):
+ return []
+ raw = analysis.get("violations")
+ if isinstance(raw, list):
+ source: List[Any] = raw
+ else:
+ legacy = analysis.get("suggestions")
+ source = legacy if isinstance(legacy, list) else []
+ out: List[Dict[str, Any]] = []
+ for item in source:
+ if not isinstance(item, dict) or item.get("level") != "error":
+ continue
+ span = item.get("span")
+ span_t: Optional[Tuple[int, int]] = None
+ if isinstance(span, (list, tuple)) and len(span) == 2:
+ span_t = (int(span[0]), int(span[1]))
+ out.append(
+ {
+ "rule": str(item.get("rule", "")),
+ "level": "error",
+ "message": str(item.get("message", "")),
+ "span": span_t,
+ }
+ )
+ return out
+
+
+def analyze_a1_email(text: str, leitpunkte: Optional[List[str]] = None, nlp: Optional[Any] = None) -> Dict[str, Any]:
"""
Automated evaluation and diagnostics for Goethe A1 Teil 2 (ca. 30 Woerter E-Mail/Brief).
Checks:
@@ -613,6 +1218,21 @@ def analyze_a1_email(text: str, leitpunkte: Optional[List[str]] = None) -> Dict[
3. Grussformel (Valediction) - presence, comma prohibition (NO comma after Viele Gruesse!).
4. Word count recommendation (25-35 words).
5. Leitpunkte coverage hints.
+
+ 追加并列字段(Task 1/2,只增不改;既有键逐字不动):
+ - `violations`:统一形状 `{rule, level, message, span}`(格式族 + 语法族并列);
+ - `advisories`:**warning 级**语法族的并列键(Task 6 HITL 裁决的 B 维 `tempus`)——
+ 与 `violations` 同形状、同 span 口径,但 `collect_violations` 不读它 ⇒ **不阻断收敛**;
+ - `judge_engine`:`"rules+spacy"`(nlp 可用且语法族至少跑过一个检测器)/ `"rules_only"`(否则)。
+
+ ⚠️ **warning 级条目按族分居两键**(如实登记):格式族 warning(如 `a1_word_count`)
+ 留在 `violations`(既有 pin 所钉),语法族 warning(`tempus`)入 `advisories`;
+ 取"全部 warning"需并集两键。
+
+ **降级运行时 `converged` 的语义边界**(评审 RED#2 的诚实性,勿删):`judge_engine == "rules_only"`
+ 时语法族**根本未检查**,此时「零违规 ⇒ 收敛」只代表**格式族**无阻塞性违规,**不等于**语法正确;
+ 任何人(判据表/发行说明/宣传文案)MUST NOT 把该次 `converged` 当作「全引擎零违规」的证据
+ (运行期 fail-open,声明 / 门禁层 fail-closed)。
"""
lines = [ln.strip() for ln in text.strip().split("\n") if ln.strip()]
words = [w for w in re.findall(r"[a-zA-ZäöüÄÖÜß]+", text)]
@@ -743,6 +1363,47 @@ def analyze_a1_email(text: str, leitpunkte: Optional[List[str]] = None) -> Dict[
leitpunkte_matches = sum(1 for r in leitpunkte_results if r["matched"])
+ # ── 统一违规形状(Task 1 并列字段,只增不改):格式族 + 语法族 ─────────────
+ # 既有 `suggestions` 逐字不动;这里只把它与语法族的 span 一并搬进统一的
+ # {rule, level, message, span} 形状,供 `collect_violations` 与端点层同口径消费。
+ violations: List[Dict[str, Any]] = [
+ {"rule": s["rule"], "level": s["level"], "message": s["message"], "span": None} for s in suggestions
+ ]
+ # advisory 并列键(Task 6 HITL 收尾):**warning 级**语法族(当前 = B 维 `tempus`)住这里。
+ # `collect_violations` 只读 `violations` 且只认 `level=="error"` ⇒ advisory 不阻断收敛;
+ # 端点经 `_JudgeRecorder` 取它的 `message` 进响应体 `judge_advisories`(可见性)。
+ advisories: List[Dict[str, Any]] = []
+ judge_engine = "rules_only"
+ if nlp is not None and text and text.strip():
+ # 沿用 `analyze_essay_text(text, nlp)` 的「显式传 nlp」惯例:不在模块内自读全局 nlp。
+ doc = nlp(text)
+ for sent in doc.sents:
+ toks = list(sent)
+ if not toks:
+ continue
+ base = toks[0].idx
+ spans = (
+ detect_determiner_noun_agreement(toks, base)
+ + detect_preposition_case(toks, base)
+ + detect_subject_verb_agreement(toks, base)
+ + detect_verb_case_government(toks, base)
+ + detect_verb_second_position(toks, base)
+ + detect_past_anchor_tense_conflict(toks, base)
+ )
+ # 语法族至少跑过一个检测器(哪怕零违规)⇒ 引擎标记为 rules+spacy。
+ judge_engine = "rules+spacy"
+ for sp in spans:
+ entry = {
+ "rule": str(sp.get("error_type", "")),
+ "level": "error" if sp.get("severity") == "error" else "warning",
+ "message": str(sp.get("explanation_zh", "")),
+ "span": (int(sp["start"]), int(sp["end"])) if ("start" in sp and "end" in sp) else None,
+ }
+ # 路由以 `level` 为准(单一判据点):error ⇒ `violations`(进违规计数);
+ # warning ⇒ `advisories`(advisory,不阻断收敛 —— Task 6 HITL 裁决的落地)。
+ # 变异自证①:把 tempus 的 severity 改成 error ⇒ 立刻翻进 `violations`。
+ (violations if entry["level"] == "error" else advisories).append(entry)
+
return {
"word_count": word_count,
"word_count_status": word_count_status,
@@ -757,4 +1418,7 @@ def analyze_a1_email(text: str, leitpunkte: Optional[List[str]] = None) -> Dict[
"leitpunkte_matches": leitpunkte_matches,
"leitpunkte_results": leitpunkte_results,
"suggestions": suggestions,
+ "violations": violations,
+ "advisories": advisories,
+ "judge_engine": judge_engine,
}
diff --git a/docs/plans/2026-10-15-a1-judge-expansion.md b/docs/plans/2026-10-15-a1-judge-expansion.md
index b11a2018..3602b816 100644
--- a/docs/plans/2026-10-15-a1-judge-expansion.md
+++ b/docs/plans/2026-10-15-a1-judge-expansion.md
@@ -7,6 +7,12 @@
> - **禁止 LLM 裁判**:vault `01-Rules/AGENT-CONDUCT.md:249` 的三层校验明确 LLM-as-a-Judge **只用于无法形式化定义的开放语言**、**严禁参与物理真值判断**。语法维度是可形式化的 ⇒ MUST 确定性规则。LLM 辅助若将来要加,只能落在 advisory(`level=warning`)且不得阻止收敛(见 Fog of War)。
> - **误报(FP)是一等公民**:每条新规则 MUST 同时具备①正样本零误报 ②负样本能报 ③边界不报 三条用例;**官方 `A1_SCHREIBEN_TEIL2` 的 10 篇 `sample_email` 在扩后裁判下 `violations == 0` 是硬门**(连官方范文都报错 ⇒ 规则过严 ⇒ 循环永不可能收敛)。
> - **静默降级 = 静默假收敛(红线 1)**:`analyze_a1_email` 现为**纯规则、不依赖 spaCy**。接入语法维度后,若运行时无 spaCy ⇒ 语法违规会**静默消失**,裁判会判出「零违规」。故裁判输出 MUST 带引擎标记(如 `judge_engine` / `degraded`),且**端点 MISS 把降级状态带到返回体**(不得让"没检查"看起来像"检查通过")。
+> - **接线缝 MUST 显式登记(评审 RED#1)**:任何"裁判能力"的改动 MUST **同时指名"谁把它送进循环"**。本次固化 = 端点 `_run_polish` 注入 `judge=`(含 nlp 来源)。**禁止**只改 `writing.py` 就让 7 个任务的测试全绿 —— 那是 dead-green(生产路径上语法裁判恒不生效,而 §4.3 判据表还能被打勾解除)。
+> - **降级态的诚实性(评审 RED#2 + 编排者修正)**:目标不变 —— **"没检查" MUST NOT 被当成"检查通过"**。但实现**分两层**,因为**运行期 fail-closed 会在 Android 上把功能打回原文**(实测依据:`delector/nlp_engine/processor.py:192-198` —— Android 分支**显式** `nlp=None`、不加载不下载 ⇒ 引擎恒 `rules_only`;此时若禁止收敛,循环的 `argmin` 回退会返回**原文** ⇒ 即 ADR §10.3 记过的「用户点了润色拿回原文」回归):
+> 1. **运行期 fail-open + 显式标记**:降级时循环照常收敛,但响应体 MUST 带 `judge_engine` / `judge_degraded`,前端 MUST 给出**可见提示**(不是 error toast,而是"语法检查未启用"一类)。
+> 2. **声明 / 门禁层 fail-closed**:**任何人(含 §4.3 解除判据表、发行说明、宣传文案)在 `judge_degraded == true` 时 MUST NOT 把该次运行当"全引擎零违规"的证据**;判据表 MUST 显式列出「降级运行时 `converged` 的语义边界」这一行。
+> ⚠️ 禁止用"降级时塞一条人造 violation"实现 —— 那是把"没检查"伪装成"检测到错误",同样违反不撒谎纪律。
+> - **既有断言冲突的裁决规则(本次新增)**:当"只增不改"与某条既有测试的 **pin 对象本身错误**(钉住的是默认值而非生产装配)冲突时,允许把该 pin **从默认值改为装配点** —— 属**强化**不是放宽,但 MUST 在提交信息里逐条写明"改的是哪条断言、为什么这是强化"。
> - **只增不改**:`analyze_a1_email` 既有的 `suggestions` 字段与 `detect_*` 既有返回形状逐字不动,新增统一形状的并列字段;`tests/test_writing_rules.py`(34 条)与 `tests/test_goethe_a1_writing.py` 既有断言不许放宽。
> - 测试**一律 stub**(真 `DEEPSEEK_API_KEY` 在门禁上恒 skip = 没有测试);spaCy 相关用例沿用既有的 `nlp` fixture 惯例。
> - 行尾 CRLF;提交信息中文 + 行号/函数锚点。
@@ -52,26 +58,31 @@
**Files:**
- Modify: `delector/services/writing.py`(新增统一抽取器与形状常量)
-- Modify: `delector/routes/main.py`(`_a1_violations_if_letter` 改走统一抽取器;返回体透出引擎标记)
-- Test: `tests/test_writing_rules.py`(追加)+ `tests/test_ai_polish_loop.py`(不动既有)
+- Modify: `delector/nlp_engine/processor.py`(**新增只读** `get_loaded_nlp()`:只读 `_nlp_resolved` / `_nlp_model`,**不触发加载、绝不触发 `spacy.cli.download`**)
+- Modify: `delector/routes/main.py`(`_a1_violations_if_letter` 改走统一抽取器;**`_run_polish` 注入 `judge=` 并接住引擎标记**;返回体透出 `judge_degraded`)
+- Test: `tests/test_writing_rules.py`(追加)+ `tests/test_server.py`(**端点级接线回归,MUST**)+ `tests/test_ai_polish_loop.py`(按下面裁决规则处理那一条 pin)
**Interfaces:**
- Consumes: `analyze_a1_email(text, leitpunkte) -> dict`(既有);`detect_determiner_noun_agreement` / `detect_preposition_case` 的 span 形状 `{severity, error_type, corrected_form, explanation_zh, start, end}`
- Produces: `def collect_violations(analysis: dict[str, Any]) -> list[dict[str, Any]]` —— 统一形状 `{"rule": str, "level": "error"|"warning", "message": str, "span": tuple[int, int] | None}`;**新键优先、旧键回退**(`violations` → legacy `suggestions` 的 `level=="error"` 过滤)
- Produces: `analyze_a1_email` 追加并列字段 `violations: list[dict]` 与 `judge_engine: str`(`"rules+spacy"` / `"rules_only"`),**既有键逐字不动**
- Produces(端点侧): `_PolishOutcome` 追加 `judge_engine: str`;响应体 `result` 追加 `judge_degraded: bool`(**只增不改**)
+- Produces(**接线缝,评审 RED#1 的闭环点**): `def _make_judge(leitpunkte: list[str] | None, nlp: Any | None) -> Callable[[str], dict]`(端点侧装配器;**内部接住 verdict 的 `judge_engine` 交给 `_JudgeRecorder`**)+ `class _JudgeRecorder`(与既有 `_PolishReplyRecorder` 同形,`main.py:2238-2292`)
+- Produces(**降级诚实性,评审 RED#2 的闭环点,按 Global Constraints 的分层方案**): `judge_engine` 由裁决**如实透出**(引擎可用 ⇒ `"rules+spacy"`;不可用 ⇒ `"rules_only"`)并经 `_JudgeRecorder` 进 `_PolishOutcome` ⇒ 响应体 `judge_degraded` 忠实;**运行期不禁止收敛**(见 Global Constraints 的 Android 证据),门禁层的 fail-closed 由 Task 7 的判据表承担
-> ⚠️ 本任务只立形状与降级标记,**不加任何新规则**(接线在 Task 2+)。
+> ⚠️ 本任务只立**形状 + 接缝 + fail-closed**,**不加任何新规则**(新规则在 Task 2+)。
+> ⚠️ **不允许**"只改 `writing.py` 就算完":MUST 有一条**走端点**的用例证明语法违规真被计数且 `judge_degraded is False`(否则就是 dead-green)。
+> ⚠️ **既有 pin 的处置**:`tests/test_ai_polish_loop.py::test_real_judge_is_wired_by_default` 逐字钉住「内核默认裁判 = `writing.analyze_a1_email`」。本任务按 Global Constraints 的裁决规则把它**从默认值改为装配点**(断言生产链路 `_run_polish` 装配的裁判是 A1 裁判 + nlp;不是放宽 —— 旧 pin 钉的是"内核自带业务",新 pin 钉的是"业务在产品层装配"),并在提交信息里逐条写明。
**Subagent Prompt Scaffold (for /dfs-exec):**
-> "统一 A1 裁判的违规形状并加引擎降级标记。现状:`analyze_a1_email` 出 `{suggestions:[{rule,level,message}]}`(纯规则),而 `detect_*` 出 `{severity,error_type,corrected_form,explanation_zh,start,end}`(spaCy span)—— 两套形状让端点层 `_a1_violations_if_letter` 只能认一套。① 在 `delector/services/writing.py` 加 `collect_violations(analysis)`:统一形状 `{rule,level,message,span}`,**新键 `violations` 优先、legacy `suggestions` 回退**,`level` 只认 `error`(warning 不阻止收敛 —— 这是 Task 4/5/6 已钉死的语义);② `analyze_a1_email` **追加**并列字段 `violations` 与 `judge_engine`(`rules+spacy` / `rules_only`),既有键与既有断言逐字不动;③ 端点 `_a1_violations_if_letter` 改用 `collect_violations`,`_PolishOutcome`/响应体加 `judge_engine`/`judge_degraded`(只增不改,前端文案表不动)。TDD:先在 `tests/test_writing_rules.py` 写 RED(统一形状 + 回退 + level 过滤),再实现。RUN:`python -m pytest tests/test_writing_rules.py tests/test_goethe_a1_writing.py tests/test_ai_polish_loop.py tests/test_server.py -q`(既有断言只许新增不许放宽)。"
+> "统一 A1 裁判的违规形状并加引擎降级标记。现状:`analyze_a1_email` 出 `{suggestions:[{rule,level,message}]}`(纯规则),而 `detect_*` 出 `{severity,error_type,corrected_form,explanation_zh,start,end}`(spaCy span)—— 两套形状让端点层 `_a1_violations_if_letter` 只能认一套。① 在 `delector/services/writing.py` 加 `collect_violations(analysis)`:统一形状 `{rule,level,message,span}`,**新键 `violations` 优先、legacy `suggestions` 回退**,`level` 只认 `error`(warning 不阻止收敛 —— 这是 Task 4/5/6 已钉死的语义);② `analyze_a1_email` **追加**并列字段 `violations` 与 `judge_engine`(`rules+spacy` / `rules_only`),既有键与既有断言逐字不动;③ 端点 `_a1_violations_if_letter` 改用 `collect_violations`,`_PolishOutcome`/响应体加 `judge_engine`/`judge_degraded`(只增不改,前端文案表不动)。TDD:先在 `tests/test_writing_rules.py` 写 RED(统一形状 + 回退 + level 过滤),再实现。**④(评审 RED#1,MUST)堵接缝**:`processor.py` 加只读 `get_loaded_nlp()`(不触发加载/下载);`_run_polish` 改为**注入** `judge=_make_judge(leitpunkte, get_loaded_nlp())`;新增 `_JudgeRecorder` 接住 verdict 里的 `judge_engine`;在 `tests/test_server.py` 加**走端点**的用例(注入可用 nlp + 喂带语法错的非书信体文本 ⇒ 断言违规真被计数 且 `judge_degraded is False`)。**⑤(评审 RED#2,MUST)降级诚实性**:`judge_engine` 如实透出(可用 ⇒ `rules+spacy`;不可用 ⇒ `rules_only`)并把 `judge_degraded` 送进响应体与前端提示;**运行期 fail-open**(Android `nlp=None`,fail-closed 会让 argmin 回退返回原文 = 功能报废)、**声明/门禁层 fail-closed**(Task 7 的判据表在 `judge_degraded==true` 时不得被用作"全引擎通过"的证据)。两条用例:引擎可用 ⇒ `judge_degraded is False`;引擎不可用 ⇒ `judge_degraded is True` 且 `converged` 的语义边界写进 docstring。禁止靠塞人造 violation 实现。RUN:`python -m pytest tests/test_writing_rules.py tests/test_server.py tests/test_goethe_a1_writing.py tests/test_ai_polish_loop.py -q`(既有断言只许新增不许放宽;那条 pin 按裁决规则改造并在提交信息写明)。"
**Step Breakdown:**
-- [ ] Step 1: 写失败测试(RED:统一形状 / legacy 回退 / `level=warning` 不入违规)
-- [ ] Step 2: 跑并记录预期失败原文
-- [ ] Step 3: 实现 `collect_violations` + `violations`/`judge_engine` 并列字段 + 端点透出(GREEN)
-- [ ] Step 4: 变异自证(把回退分支删掉 ⇒ 相关用例红;把 `level` 过滤删掉 ⇒ warning 入违规 ⇒ 红)
-- [ ] Step 5: 关联套件 + `ruff` + mypy 四档 + 原子提交
+- [x] Step 1: 写失败测试(RED:统一形状 / legacy 回退 / `level=warning` 不入违规 / **端点接线** / **降级态不返回 converged:true**)
+- [x] Step 2: 跑并记录预期失败原文(**注意 RED 的形态是"报 0"或"字段缺失",不是报错**)
+- [x] Step 3: 实现 `collect_violations` + `violations`/`judge_engine` 并列字段 + `get_loaded_nlp()` + `_make_judge`/`_JudgeRecorder` + 端点注入与 `judge_degraded` 透出(GREEN)
+- [x] Step 4: 变异自证(① 删回退分支 ⇒ 红;② 删 `level` 过滤 ⇒ warning 入违规 ⇒ 红;③ **端点不注入 judge(退回内核默认)⇒ 端点接线用例 MUST 红**;④ **`_make_judge` 去掉 fail-closed ⇒ 降级态用例 MUST 红**)
+- [x] Step 5: 关联套件 + `ruff` + mypy 四档 + 原子提交(提交信息逐条写明那条 pin 的改造理由)
---
@@ -91,11 +102,12 @@
> "把仓内**已有**的两件确定性语法算力接进 A1 裁判(照抄 `analyze_essay_text(text, nlp)` 的「显式传 nlp」惯例,不要在模块内自读全局 nlp)。① `analyze_a1_email(text, leitpunkte=None, nlp=None)`:`nlp` 可用时把 `detect_determiner_noun_agreement` + `detect_preposition_case` 的 span 折进 `violations`(映射 `error_type`→`rule`、`explanation_zh`→`message`、`start/end`→`span`、severity→level),`nlp is None` 时不产语法违规并把 `judge_engine` 置 `rules_only`。② **硬门(D3)**:新增用例遍历 `delector/data/a1_writing_dict.A1_SCHREIBEN_TEIL2` 的 10 篇 `sample_email`,断言在这两件算力下 **`violations == 0`**(官方范文被报违规 ⇒ 规则过严 ⇒ 必须修规则或收窄触发条件,不许改断言)。③ 负样本用例:`Ich habe ein Hund.` 与 `Ich fahre mit den Bus.` MUST 各报 1 条;边界用例:无冠词 / 双格介词(`in/auf`)/ 固定搭配 MUST **不报**(既有 `test_two_case_preposition_skipped` 等已覆盖,别删)。RUN:`python -m pytest tests/test_writing_rules.py tests/test_goethe_a1_writing.py -q`。"
**Step Breakdown:**
-- [ ] Step 1: 写失败测试(RED:负样本两例报违规 + 官方 10 篇零违规门)
-- [ ] Step 2: 跑并记录预期失败原文(**红色是"报 0",不是"报错"**)
-- [ ] Step 3: 实现接线(GREEN)
-- [ ] Step 4: 变异自证(关掉一致性检查 ⇒ 负样本用例红;把双格介词豁免删掉 ⇒ 官方范文门红)
-- [ ] Step 5: 关联套件 + ruff + mypy 四档 + 原子提交
+- [x] **Step 0(✅ 2026-10-15 已跑,评审黄卡已消)**:官方 `A1_SCHREIBEN_TEIL2` 10 篇 `sample_email` 在两件既有算力下 **误报 0/10**(逐篇 `det=0 prep=0`;评审点名的高危句 `für Ihre 2-Zimmer-Wohnung` / `vom 15. bis 18. August` / `im Preis enthalten` 均不误报)⇒ **本任务是"接线"而非"修规则"**,硬门基线绿。执行时把这条基线**落成用例**(不是再跑一次人眼)。
+- [x] Step 1: 写失败测试(RED:负样本两例报违规 + 官方 10 篇零违规门)
+- [x] Step 2: 跑并记录预期失败原文(**红色是"报 0",不是"报错"**)
+- [x] Step 3: 实现接线(GREEN)
+- [x] Step 4: 变异自证(关掉一致性检查 ⇒ 负样本用例红;把双格介词豁免删掉 ⇒ 官方范文门红)
+- [x] Step 5: 关联套件 + ruff + mypy 四档 + 原子提交
---
@@ -113,11 +125,11 @@
> "新增主谓一致规则 `detect_subject_verb_agreement(tokens, base)`,返回与 `detect_determiner_noun_agreement` **完全同形状**的 span 列表。判据:主语 `dep=="sb"` 的 `Number` 与定式动词的 `Number` 不一致即报错(实测锚点:`Die Kinder spielt im Garten.` ⇒ `Kinder`=Plur / `spielt`=Sing ⇒ MUST 报 1 条;`Die Kinder spielen im Garten.` ⇒ MUST 报 0 条)。**FP 守卫必须先写**:① 主语含并列(`Anna und Tom`)/② 主语是集合名词(`die Familie`)/③ 主语为 `man`、不定代词 /④ 复合时态(`hat ... gemacht`,`Number` 落在助动词上)⇒ 一律**不报**(宁漏不误)。每条 FP 守卫都要有用例。RUN:`python -m pytest tests/test_writing_rules.py -q`。⚠️ 若实测发现 spaCy 的 `Number` 在常见结构上不可靠(如 `es gibt`),**不要硬做** —— 停手报告,把该维度降级登记。"
**Step Breakdown:**
-- [ ] Step 1: 写失败测试(RED:负样本报 1 条 + 4 条 FP 守卫各不报)
-- [ ] Step 2: 跑并记录预期失败原文
-- [ ] Step 3: 实现(GREEN)
-- [ ] Step 4: 变异自证(抽掉 FP 守卫 ⇒ 对应用例红)
-- [ ] Step 5: 官方范文零违规门仍绿 + 原子提交
+- [x] Step 1: 写失败测试(RED:负样本报 1 条 + 4 条 FP 守卫各不报)
+- [x] Step 2: 跑并记录预期失败原文
+- [x] Step 3: 实现(GREEN)
+- [x] Step 4: 变异自证(抽掉 FP 守卫 ⇒ 对应用例红)
+- [x] Step 5: 官方范文零违规门仍绿 + 原子提交
---
@@ -140,11 +152,11 @@
> "新增动词支配格规则。① 建 `delector/data/a1_verb_case.py`,内容 = **人工登记**的 A1 高频动词→支配格映射(如 `helfen: Dat`、`danken: Dat`、`sehen: Acc`、`es gibt: Acc`…),条目数**先只收你能逐条确认的**,每个条目带一行来源注释;**禁止**用 LLM 批量生成(这是事实数据,不是文本生成)。② `detect_verb_case_government(tokens, base)`:找动词(`VERB`/`AUX`)→ 查表 → 取其宾语 NP 的 `morph.Case` 与之比对,不一致则用 `decline_determiner` 生成 `corrected_form` 并报 error(实测锚点:`Ich helfe meinen Bruder.` MUST 报 1 条;`Ich helfe meinem Bruder.` MUST 报 0)。③ FP 守卫:动词不在表内 / 反身动词 / 可分前缀动词 / 名词化动词 ⇒ 不报。④ 数据自检测试(键 ⊆ A1 lemma、值集合合法)。RUN:`python -m pytest tests/test_writing_rules.py tests/test_a1_verb_case_data.py -q`。**官方 10 篇范文零违规门(Task 2 起)必须仍绿。**"
**Step Breakdown:**
-- [ ] Step 1: 写失败测试(RED:负样本报 1 条 + 正样本 0 条 + 数据自检 + FP 守卫)
-- [ ] Step 2: 跑并记录预期失败原文
-- [ ] Step 3: 数据 + 规则实现(GREEN)
-- [ ] Step 4: 变异自证 + 官方范文零违规门复核
-- [ ] Step 5: 原子提交
+- [x] Step 1: 写失败测试(RED:负样本报 1 条 + 正样本 0 条 + 数据自检 + FP 守卫)
+- [x] Step 2: 跑并记录预期失败原文
+- [x] Step 3: 数据 + 规则实现(GREEN)
+- [x] Step 4: 变异自证 + 官方范文零违规门复核
+- [x] Step 5: 原子提交
---
@@ -164,11 +176,11 @@
> "新增主句动词位置规则(德语 V2)。实测信号:主句定式动词 MUST 落在第二位置;`Gestern habe ich gearbeitet.`(`habe` index 1)正确,`Gestern ich habe gearbeitet.`(`habe` index 2)错误。实现 `detect_verb_second_position(tokens, base)`,返回既有 span 形状。**豁免面逐条写用例**(从句 / 疑问句 / 祈使句 / 并列句 / 直接引语 / 标题式片段)—— 这些结构里 V2 不成立,漏一条豁免就会在真实文本上大批误报。区分主句/从句优先用 `dep`(`ROOT` vs `cs`/`mo` 等)而不是标点启发。RUN:`python -m pytest tests/test_writing_rules.py -q`。"
**Step Breakdown:**
-- [ ] Step 1: 写失败测试(RED:错句报 1 条 + 正句 0 条 + 6 条豁免各 0 条)
-- [ ] Step 2: 跑并记录预期失败原文
-- [ ] Step 3: 实现(GREEN)
-- [ ] Step 4: 变异自证(抽掉任一条豁免 ⇒ 对应用例红)+ 官方范文零违规门复核
-- [ ] Step 5: 原子提交
+- [x] Step 1: 写失败测试(RED:错句报 1 条 + 正句 0 条 + 6 条豁免各 0 条)
+- [x] Step 2: 跑并记录预期失败原文
+- [x] Step 3: 实现(GREEN)
+- [x] Step 4: 变异自证(抽掉任一条豁免 ⇒ 对应用例红)+ 官方范文零违规门复核
+- [x] Step 5: 原子提交
---
@@ -180,44 +192,113 @@
**Interfaces:**
- Consumes: `A1_SCHREIBEN_TEIL2`(10 篇官方范文,正样本)· 自建负样本集(每维 ≥5 句手工构造的定向错误句)· 候选规则(本任务**只写探测实现,不接线**)
-- Produces: `python tools/bench_judge_fp.py` 的**原始输出**(每维 FP 率 / FN 率 / 误报样例逐条列出)+ 一份结论:每维 ∈ {**接线为 error** | **接线为 warning(advisory)** | **不做**},附判据与阈值
+- Produces(**可证伪签名,评审黄卡已补**): `def measure_candidates(candidates: Sequence[Candidate], positives: Sequence[str], negatives: Sequence[str]) -> Report`(`Report = {per_candidate: [{name, fp, fn, fp_samples: list[str], fn_samples: list[str]}], verdict: dict[str, str]}`)+ CLI `python tools/bench_judge_fp.py --json`(**退出码 0 = 标定完成;非 0 = 样本集自身无效**);每维结论 ∈ {**接线为 error** | **接线为 warning(advisory)** | **不做**},附判据与阈值
> **为什么这维度必须是 HITL + Tier 3**:探针已实测 spaCy 对 `Ich muss gehen heute nach Hause.` 的**框架解析失败**(`muss` 判 `nk`、`gehen` 判 `ROOT`)⇒ 直接接线会**误报**,而误报比漏报危险(模型会去"修"一个不存在的错误 = 奖励黑客的反面)。做/不做是**产品语义决定**,必须人拍板。
**Subagent Prompt Scaffold (for /dfs-exec):**
> "做一次可复跑的 FP 标定实验,**不接线**。① 建 `tools/bench_judge_fp.py`:对两条候选维度(情态动词框架语序 / 时间副词与时态一致性)各写一版**探测实现**(可住在脚本里,不必进 `writing.py`),在 a) 官方 10 篇 `sample_email`(正样本,期望 FP=0)与 b) 每维 ≥5 句手工定向错误句(负样本,期望能报)上逐句跑,输出原始计数表 + 误报样例全文。② 建 `tests/test_judge_fp_bench.py` 钉脚本自身行为(给固定样本 ⇒ 固定计数;不许"跑得过但数不清")。③ 产出**带阈值的结论建议**(例如:FP 率 ≤ 某值 ⇒ 可做 error;否则 warning 或不做的理由),并**把误报样例原文带回**(不许只报比例 —— 比例无法证伪判据)。RUN:`python tools/bench_judge_fp.py` 与 `python -m pytest tests/test_judge_fp_bench.py -q`,原始输出全带回。⚠️ 若某维在正样本上就误报 ⇒ **如实结论是"不做"**,不许调样本让它好看。"
+**HITL 裁决(用户 2026-10-15 拍板,Step 4 已结)**:
+- **A 维(情态动词框架语序)= 不做**。依据:实测负样本 6/7(「实词夹在情态动词与末位动词之间」整族不可见 ⇒ FN),且样本外追补的 3 句逗号并列**正确**句全部误报(⇒ 若计入对照组,warning 档条件亦不成立)——**FP 与 FN 同源**,两侧都破。标定产物与数据保留在 `tools/bench_judge_fp.py` 作记录,**不接线**(后来者勿把它当"只是还没接线")。
+- **B 维(时间副词 ↔ 时态一致性)= 接线为 `level="warning"`(advisory)**。依据:正样本 FP 0/35、负样本 7/7、对照 0/6,两档阈值均成立(样本偏小:7 错 + 6 对照 ⇒ 先 advisory,积累真实用法后再议升 error)。**不阻断收敛**:`violations_of` 只认 `level=="error"`(Task 4 已钉死的语义)。
+- **advisory 的可见性**(编排者定的最小实现):warning 必须真能被看到,否则是 no-op ⇒ 响应体 `result` 追加 `judge_advisories: list[str]`(**只增不改**),前端渲染与本计划的 Task 7 同批。
+
**Step Breakdown:**
-- [ ] Step 1: 建正/负样本集(负样本每维 ≥5 句,逐句标注期望)
-- [ ] Step 2: 写探测实现 + 脚本(含脚本自身测试)
-- [ ] Step 3: 跑出原始计数表与误报样例
-- [ ] Step 4: **HITL**:把结论(做 error / warning / 不做)交用户拍板
-- [ ] Step 5: 按拍板结论决定是否接线(若接线 ⇒ 走 Task 2~5 同款门禁:正样本零违规 + FP 守卫 + 变异自证)+ 原子提交
+- [x] Step 1: 建正/负样本集(负样本每维 ≥5 句,逐句标注期望)
+- [x] Step 2: 写探测实现 + 脚本(含脚本自身测试)
+- [x] Step 3: 跑出原始计数表与误报样例
+- [x] Step 4: **HITL**:把结论(做 error / warning / 不做)交用户拍板
+- [x] Step 5: 按拍板结论决定是否接线(若接线 ⇒ 走 Task 2~5 同款门禁:正样本零违规 + FP 守卫 + 变异自证)+ 原子提交
---
### Task 7: 适用性闸门收窄(循环价值面扩到任意德语文本)+ §4.3 门槛解除判据 [Tier: 2-Standard] [Mode: HITL] [Role: TDD Builder]
**Files:**
-- Modify: `delector/routes/main.py`(`_a1_violations_if_letter` 的闸门范围)
+- Modify: `delector/routes/main.py`(`_a1_violations_if_letter` 的闸门范围 + **文本族感知的系统提示词装配**)
+- Modify: `static/js/writer.js`(**降级可见提示的生产者** —— 评审黄卡:Global Constraints 要求"前端 MUST 给出可见提示",但此前**没有任何任务的 Files 覆盖 `static/`** ⇒ 该 MUST 无生产者。实现:`judge_degraded === true` 时给一句**非阻断**提示(如"语法检查未启用:本机未加载德语模型,本次仅做格式/字数检查"),不是 error toast)
+- Modify: `delector/services/polish_loop.py`(**只加一个可选参数** `system_prompt: str | None = None`(None ⇒ 用现有 A1 默认值,向后兼容);业务提示词由产品层传入 —— 见下方"立论冲突的解法")
- Modify: `docs/plans/2026-10-15-a1-judge-expansion.md`(本文件:记录解除判据与实测值)
-- Test: `tests/test_goethe_a1_writing.py`
+- Test: `tests/test_goethe_a1_writing.py` + **`tests/test_server.py`(评审黄卡:该文件此前未列入 ⇒ 会让一条既有回归闸变成 dead-green,见 scaffold ③)**
**要解决的现状(实测)**:`_a1_violations_if_letter` 目前「称呼语不合法 ⇒ 零违规」,因为当时的裁判**只有书信格式规则**。扩了语法维度后,前提变了:**语法违规与题材无关** ⇒ 语法维度对任何德语文本都该生效,只有书信格式/字数规则保留"仅书信体"闸门。这一步把循环的真实迭代价值面从「仅 A1 书信体」扩到「任意德语文本」。
**Interfaces:**
-- Consumes: `collect_violations(...)`(Task 1)· 各维度规则
-- Produces: 闸门按**规则族**分流(格式族:仅书信体;语法族:任何文本)+ `docs/plans` 里的**§4.3 解除判据表**(每条判据 + 实测值 + 是否满足)
+- Consumes: `collect_violations(...)`(Task 1)· 各维度规则 · `polish_loop.run_polish_loop(system_prompt=...)`(本任务新增的可选参数)
+- Produces(**可证伪签名,评审黄卡已补**): `def violations_for(kind: str, verdict: dict[str, Any]) -> list[dict[str, Any]]`(`kind ∈ {"letter", "prose"}`:`letter` ⇒ 格式族 + 语法族全计;`prose` ⇒ **只计语法族**)+ `def pick_system_prompt(kind: str) -> str`(文本族 → 提示词;**业务知识住在产品层**)+ `docs/plans` 里的 **§4.3 解除判据表**(每条判据 + 实测值 + 是否满足)
+
+> **立论冲突的解法(评审黄卡 #2,选 b 而非 a)**:原立论"把循环的真实迭代价值面扩到任意德语文本"与内核里**逐字 A1 书信**的 `POLISH_LOOP_SYSTEM_PROMPT`(`polish_loop.py:144-152`)直接冲突 —— 对非书信体,它会**诱导模型加一个称呼语**,而格式族规则此时已被关掉 ⇒ 污染无守卫。故选 b:**提示词按文本族参数化**,由产品层(`main.py`)装配。这同时朝 `architecture.md` 登记的"业务知识上提"方向挪了一步(与日后 Pi 式重构同向、不冲突)。⚠️ 若执行中发现该参数化会破坏 `tests/test_ai_polish_loop.py` 里钉住提示词/契约的用例,按 Global Constraints 的裁决规则处理并逐条写明。
**Subagent Prompt Scaffold (for /dfs-exec):**
> "收窄适用性闸门的范围。现状:`_a1_violations_if_letter` 因当时裁判只有书信格式规则而做成「称呼语不合法 ⇒ 零违规」;语法维度接线后**前提变了** —— 语法与题材无关。改法:闸门改按**规则族**分流 —— ① 格式/字数族(`a1_greeting_*` / `a1_valediction_*` / `a1_word_count`)仍仅对书信体生效;② 语法族(冠词-格一致 / 介词支配格 / 动词支配格 / 主谓一致 / V2)对**任意德语文本**生效。测试 MUST 覆盖:非书信体文本带语法错误 ⇒ **真被计数**(与今日"零违规"对比,这是行为变更);书信体文本的格式规则语义不变。③ 同时在本计划文件里落一张 **§4.3 解除判据表**(维度数 / 官方正样本零误报 / 负样本 recall / 非书信文本可判 / 降级标记可见性 各条 + 实测值),**满足与否由用户拍板**,agent 不自行解禁。RUN:`python -m pytest tests/test_goethe_a1_writing.py tests/test_server.py tests/test_ai_polish_loop.py -q` + 全量。⚠️ 本任务会改变产品行为(非书信文本从"首轮即接受"变为"真迭代"),执行前 MUST 先跑一遍 `tests/test_server.py::test_ai_polish_diff_mocked` 系用例确认既有断言仍成立。"
**Step Breakdown:**
-- [ ] Step 1: 写失败测试(RED:非书信体带语法错误 ⇒ 被计数)
-- [ ] Step 2: 跑并记录预期失败原文
-- [ ] Step 3: 闸门按规则族分流(GREEN)
-- [ ] Step 4: 全量套件 + 契约闸 + §4.3 解除判据表落盘
-- [ ] Step 5: **HITL**:判据表交用户拍板(是否允许宣称"已实现")+ 原子提交
+- [x] Step 1: 写失败测试(RED:非书信体带语法错误 ⇒ 被计数)
+- [x] Step 2: 跑并记录预期失败原文
+- [x] Step 3: 闸门按规则族分流(GREEN)
+- [x] Step 4: 全量套件 + 契约闸 + §4.3 解除判据表落盘
+- [x] Step 5: **改写 `tests/test_server.py::test_ai_polish_non_letter_text_is_not_rolled_back`**(评审黄卡③:它在"现状 / 仅接 nlp / 收窄闸门"**三态下都绿** ⇒ 已不守卫任何东西)为能区分行为的两条:① 非书信体 + 语法错(且桩模型不修)⇒ 断言语法违规**真被计数**;② 明确并钉住收窄后的产品语义(该修不动时是回退原文还是首轮即接受)—— 后者正是 ADR §10.3「功能忘了判就是报废」失败模式的重现,MUST 有断言,不许只留在 Fog of War。
+- [x] Step 6: **HITL**:判据表交用户拍板(是否允许宣称"已实现")+ 原子提交
+
+### §4.3 解除判据表(2026-10-15 实测值;签批归用户)
+
+| # | 判据 | 口径 | 实测 | 满足 |
+| --- | --- | --- | --- | --- |
+| 1 | **维度覆盖**(ADR §4.3 点名四项:动词位置 / 时态一致性 / 语序 / 格的使用) | 每项 MUST 有确定性规则且接线 | **动词位置** = `wortstellung`(主句 V2 + 8 豁免)✅ · **格的使用** = `artikel` + `kasus` + `rektion` 三条 ✅ · **时态一致性** = `tempus`(**warning/advisory**,按用户裁决)🟡 · **语序(主谓宾/情态框架)** = **经实测评审判定不做**(负样本 6/7 + 样本外正确句 3/3 误报 ⇒ FP/FN 同源)❌ | **部分**(3 error + 1 advisory + 1 明确不做) |
+| 2 | **官方正样本零误报** | 10 篇 `A1_SCHREIBEN_TEIL2.sample_email` 逐句 error 级违规 == 0 | `FP_TOTAL 0`(逐篇全 0) | ✅ |
+| 3 | **更宽的合法句硬门** | `COMMON_CORRECT_SENTENCES`(≥51 句,含类覆盖清单)零语法族违规 **且** `judge_engine == "rules+spacy"`(防降级假绿) | **51 句全绿** | ✅ |
+| 4 | **编排者独立广度扫雷** | 另取 30 句常见正确德语(焦点小词/自由第三格/前置状语/从句前置/疑问祈使/反身/可分动词…) | **FP = 0** | ✅ |
+| 5 | **负样本召回** | 每个已接线维度都有定向错误句用例(且变异自证:抽掉守卫即红) | 三新维度 + 两个既有算力各有负样本用例;变异自证 5 组以上 | ✅ |
+| 6 | **非书信文本可判** | 语法族对**任意德语文本**生效(不再要求书信体) | 已落地(闸门按规则族分流:格式族仅书信体、语法族任意文本) | ✅ |
+| 7 | **降级可见性**(不许"没检查"伪装成"检查通过") | `judge_engine`/`judge_degraded` 忠实透出 + 前端非阻断提示 | 已落地(含 no-key 路径抑制不实文案) | ✅ |
+| 8 | **"浅裁判失效"对照实验**(奖励黑客的正面证据:模型学会通过检查 vs 真变好) | 需真实模型与 Key | **未做**(阻塞于 `DEEPSEEK_API_KEY` 缺失) | ❌ |
+
+**判据结论(供签批)**:第 1 项**部分**满足、第 8 项**未做** ⇒ 建议**只允许限定表述**,例如「裁判已扩到 A1 语法维度(动词位置 + 格的使用三规则 error 级、时态一致性 advisory;语序经实测评审判定不做)」,**不建议**解禁 §4.3 原文所禁的那句无条件"已实现"—— 因为第 8 项(浅裁判是否真被模型针对)尚无证据。
+
+**✅ 用户签批(2026-10-15,Task 7 Step 6 结)**:
+1. **采用限定表述** —— 允许对外写「裁判已扩到 A1 语法维度:动词位置 + 格的使用三规则 error 级、时态一致性 advisory;语序经实测评审判定不做」;**无条件「已实现验证驱动 agent 循环」继续禁用**(第 8 条对照实验未做之前不解禁)。
+2. **非书信文本「模型修不动 ⇒ 回退原文」保持**(与循环 argmin 语义一致,已有断言 + 三处 docstring + 前端人话原因码钉住)。
+
+**已知边界(随判据表一并登记)**:① 书信缺称呼语 ⇒ 判据 `greeting.valid` 归 prose ⇒ 格式族(含 `a1_greeting_missing`)**永远不被计数**(继承自旧闸门);② 候选侧 kind 逐候选翻转(旧欠账,未收口);③ 非书信文本模型修不动 ⇒ `no_improvement` ⇒ **回退原文**(有断言 + 三处 docstring,前端给人话原因码);④ advisory 在 argmin 回退路径下可能与出口文本不对应(不进任何计数);⑤ warning 级条目按族分居 `violations`(格式族)/ `advisories`(语法族),取全集需并集两键。
+
+---
+
+### Task 8: 内核接缝加固(超时 / 线程 / 不可达分支) [Tier: 2-Standard] [Mode: AFK] [Role: TDD Builder]
+
+> **由来**:评审 RED#3 与模式 3 要点 4。**不是** Pi 式重构(那已暂缓)—— 本任务只在既有结构上补三处缝,不重画层。
+
+**Files:**
+- Modify: `delector/services/polish_loop.py`(`dispatch` 包超时;同步 `judge` 移出事件循环)
+- Modify: `delector/routes/main.py`(删/修那个**不可达**的 `except LLMError` 分支;SSE 侧错误帧语义)
+- Test: `tests/test_ai_polish_loop.py`、`tests/test_server.py`
+
+**Interfaces:**
+- Consumes: `asyncio.wait_for(...)` · `asyncio.to_thread(...)` · 既有 `DEFAULT_PER_CALL_TIMEOUT_S` / 剩余 deadline 口径
+- Produces: ① `dispatch` 调用 MUST 受 `asyncio.wait_for(timeout=min(剩余 deadline, DEFAULT_PER_CALL_TIMEOUT_S))` 约束(超时 ⇒ errors-as-messages,不炸循环);② 同步 `judge` MUST 经 `asyncio.to_thread`(否则 `task.cancel()` 掐不断它、且会阻塞同进程的 `/api/health` 与其他路由);③ SSE 的 LLMError 语义:要么让 `_run_polish` 原样抛出、要么在流层捕获 `HTTPException` —— **不允许存在"永不命中的 except"**
+
+**Subagent Prompt Scaffold (for /dfs-exec):**
+> "补三处接缝。① `polish_loop.py` 的 `dispatch`(:412)目前**无超时**:包 `asyncio.wait_for`,超时按 errors-as-messages 返回工具失败消息(不炸循环);② 同步裁判 `judge`(:330/:439)直接跑在事件循环上 ⇒ 移 `asyncio.to_thread`,使 (a) `task.cancel()` 真能掐断、(b) 长裁判不阻塞 `/api/health` 与其他路由;用例钉住"取消时裁判线程不再被等待"/"裁判期间 health 仍可应答"(后者可用事件循环探针)。③ 修 `main.py` 的**不可达分支**:`_run_polish`(:2342-2344)已把 `LLMError` 转成 `HTTPException(502)`,而 `_polish_event_stream`(:2439-2442)写的是 `except llm_client.LLMError` ⇒ 该分支永不命中、流内 error 帧永不发出、用户拿到通用文案而非 `e.detail` 人话。断言:注入的 chat 在流中途抛 LLMError ⇒ 用户**看得到** `e.detail`(走 error 帧或明确的状态码,二选一并在注释写明为什么)。RUN:`python -m pytest tests/test_ai_polish_loop.py tests/test_server.py -q` + 全量。"
+
+**Step Breakdown:**
+- [x] Step 1: 写失败测试(RED:dispatch 无超时 / 同步裁判阻塞事件循环 / error 帧不可达)
+- [x] Step 2: 跑并记录预期失败原文
+- [x] Step 3: 三处实现(GREEN)
+- [x] Step 4: 变异自证(抽掉 `wait_for` ⇒ 超时用例红;抽掉 `to_thread` ⇒ 阻塞探针红;把 except 改回 LLMError ⇒ error 帧用例红)
+- [x] Step 5: 全量 + ruff + mypy 四档 + 原子提交
+
+---
+
+## 📌 登记(进 backlog,**不**塞进本计划)
+
+> 评审发现、但属既有行为或与"抬 §4.3 门槛"无关 ⇒ 只登记,不在本计划动手。
+
+| # | 事项 | 证据 | 为什么不现在做 |
+| --- | --- | --- | --- |
+| B1 | 润色入口对**用户输入**静默截断到 2000 字符,无提示、无标记 | `main.py:2465/:2481` `req.text[:2000]`;响应体无字段 | 既有行为,与裁判集无关;应配 UI 提示或 `input_truncated` 字段 |
+| B2 | `truncate_evidence` 截断**不可见**、无逃生通道 | `polish_loop.py:222-237`(纯前缀裁剪、无标记、不落盘) | 与"零违规"叠加会放大假收敛;最小修法是尾部加确定性标记("无标记 ⇒ 必未截断"),另立任务 |
+| B3 | 循环级 token 闸**只累计 completion**,注入证据带来的 prompt 增长不在任何闸内 | `polish_loop.py:49-55`(自认)、`:357` | 今天有界性靠 `max_turns=3`;**若将来抬轮数 MUST 先把证据计入闸**(Fog Unknown C 的前置条件) |
+| B4 | `services ⇄ tools` 双向包依赖;且 `polish_tools` 一被 import 就间接拉起白名单**明确排除**的 `tts`/`ingest`/`export` 三个模块 | `polish_tools.py:60` + `tools/__init__.py:11-27` | 属 Pi 式重构域(已暂缓);差集守卫只挡"白名单加项",挡不住"import 期拉起" |
---
@@ -227,12 +308,13 @@
- **[Unknown B] 官方评分维度的口径**:vault 内**没有**歌德 A1 Schreiben 官方评分表(已搜,零命中)⇒ 本计划以 ADR §4.3 点名的四项 + 实测可判性为准。若日后拿到官方评分表,维度优先级需重排(可能新增"语用/语域得体")。
- **[Unknown C] 裁判变严对收敛率的影响**:error 级违规变多后,**官方范文仍必须 `v == 0`**(Task 2 起的硬门);但真实用户文本的 `v` 分布会整体上移 ⇒ `max_turns=3` 是否够、`no_improvement` 是否会变成常态,须在有 Key 后回测(与 ADR §6 Unknown 2 同一笔账)。
- **[Unknown D] B1/B2 维度**:本计划只做 A1。跨等级复用(`syntax_score` 的 7 维难度 / `detect_*` 是否可参数化到 B1)不在此评估。
+- **[Unknown F] Android 上 `try_load_nlp_no_download()` 到底能不能加载成功(真机未验证)**:`processor.py:192-198` 的**导入期** Android 分支显式 `nlp=None`,但新助手走的是 `_load_spacy_model()` 的**路径回退**(glob `{name}-*`,`processor.py:61-72`)—— 而 Android 的模型正是"裸源码目录拷进 Chaquopy"。⇒ **若回退命中,Android 也能拿到 `rules+spacy`(比预期更好);若失败,则 `rules_only` + 可见提示**。② 已知副作用(评审黄卡):该助手**不读** `_android_runtime`,失败时也不写全局 ⇒ Android 上模型缺失会**每次润色都重试本地加载**(无记忆、重复耗时)。**验证归属**:Android 真机点检(用户 2026-10-15 指示暂缓);若届时确认恒失败,再决定是"加 `if _android_runtime: return None`"还是"加进程内失败记忆"。
- **[Unknown E] Pi 式分层重构(已拍板:日后单独做,本计划不做)**:目标形态 = ① **层间类型三态递进**(`Tool` 名片 → `AgentTool extends Tool` 工人 → `ToolDefinition` 产品组件,**只加不改**);② **依赖单向**(消掉实测到的两组双向耦合:`core ⇄ nlp_engine`、`services ⇄ tools`);③ **层序声明**(当前仓内**零处**声明,导致 `LAYERED-ARCHITECTURE §1` 连"违规"都无法判定);④ 把业务知识(A1 提示词 / 默认裁判)从机制内核提到产品层。**判据来源**:`01-Rules/LAYERED-ARCHITECTURE.md`(实证对象 = Pi-Agent 的 `pi-ai`/`pi-agent-core`/`pi-coding-agent`)+ 2026-10-15 双席位评审(code-reviewer/layering + sre-resilience)。**为什么现在不做**:三态递进需先有「第二个实现」才有内容(现只有一个工具面、一个裁判)——提前定接口正是 §2 反模式;且本子计划的目标是抬 §4.3 门槛,与分层重构正交。**将来启动时的前置**:先落层序声明,再做 ①②③④(顺序见评审报告)。
## 🚫 Out of Scope
- **不引入 LLM 裁判**(D1 / vault 规则);Fog of War Unknown A 若将来要加,也只能是 advisory。
-- **不改循环内核**(`polish_loop.py` 的停止策略/预算/哨兵已定稿并有 25 条测试钉住)。
+- **不改循环内核结构**(`polish_loop.py` 的停止策略/预算/哨兵已定稿并有 25 条测试钉住);**允许**经**注入缝 / 可选参数**改变裁判与提示词语义(Task 1 的 `judge=`、Task 7 的 `system_prompt=`)—— 这是评审三张 RED 的修复路径,也朝 `docs/agents/architecture.md` 登记的"业务知识上提"同向。
- **不做 B1/B2 评分维度**;不扩到口语/写作 Teil 1。
- **不改前端契约形状**(只可能新增一个 `judge_degraded` 布尔;不做新 UI)。
- **不动 `TOOL_REGISTRY` 与工具白名单**(Task 5 已定稿为恰好 3 项只读)。
diff --git a/package_windows.py b/package_windows.py
index 8eb61d4e..da132440 100644
--- a/package_windows.py
+++ b/package_windows.py
@@ -340,6 +340,7 @@ def build_windows() -> None:
"--hidden-import=delector.data.core_dict_ext",
"--hidden-import=delector.data.prep_dict",
"--hidden-import=delector.data.a1_dict",
+ "--hidden-import=delector.data.a1_verb_case",
"--hidden-import=delector.data.a1_writing_dict",
"--hidden-import=delector.data.a1_hoeren_dict",
"--hidden-import=delector.data.a1_lesen_dict",
diff --git a/static/js/writer.js b/static/js/writer.js
index 316868d4..3f7c1a86 100644
--- a/static/js/writer.js
+++ b/static/js/writer.js
@@ -42,7 +42,13 @@ let polishState = {
corrected: '',
hunks: [],
notes_zh: [],
- error_count: 0
+ error_count: 0,
+ // Task 6/7 的响应契约(只增不改):advisory 文本 + 引擎降级标记。
+ // 两者都只做**非阻断**呈现(overlay 的 notes 区),绝不弹 error toast。
+ judge_advisories: [],
+ judge_degraded: false,
+ // Y3:no-key 抑制分支的输入(no-key 路径循环根本没跑,见 renderPolishReview 的守卫)。
+ unconverged_reason: null
};
// ── Error Type Labels & Badges ──────────────────────────────────────────────
@@ -1199,14 +1205,40 @@ export function renderPolishReview() {
const notesEl = document.getElementById('polish-notes');
if (notesEl) {
- if (polishState.notes_zh && polishState.notes_zh.length > 0) {
- notesEl.classList.remove('hidden');
- notesEl.innerHTML = `
-
+ const notes = polishState.notes_zh || [];
+ // Task 6/7:advisory(warning 级,如时态口径不一致)与引擎降级提示都**复用**这块
+ // notes 区呈现 —— 非阻断、不是 error toast(降级提示的文案口径见
+ // `judge_degraded`;advisory 不参与任何计数,见后端 `advisories` / `judge_advisories`)。
+ const advisories = polishState.judge_advisories || [];
+ let html = '';
+ if (notes.length > 0) {
+ html += `
+
+
+ ${notes.map(n => `
• ${esc(n)}
`).join('')}
+
+ `;
+ }
+ if (advisories.length > 0) {
+ html += `
+
- ${polishState.notes_zh.map(n => `
• ${esc(n)}
`).join('')}
+ ${advisories.map(n => `
• ${esc(n)}
`).join('')}
`;
+ }
+ // Y3 小修(二选一取①"抑制"):no-key 路径循环**根本没跑**(后端 `_no_key_outcome`
+ // 什么都没检查)⇒"本次仅做格式/字数检查"不实 ⇒ `unconverged_reason === 'no_api_key'`
+ // 时抑制本提示(no-key 已有自己的 toast / notes 文案,抑制不丢信息)。不选②改措辞:
+ // 那会连"有 key 但无 nlp"这条提示真实成立(格式/字数确实做了)的路径一起说糊。
+ if (polishState.judge_degraded && polishState.unconverged_reason !== 'no_api_key') {
+ html += `
+ ℹ️ 语法检查未启用:本机未加载德语模型,本次仅做格式/字数检查。
+ `;
+ }
+ if (html) {
+ notesEl.classList.remove('hidden');
+ notesEl.innerHTML = html;
} else {
notesEl.classList.add('hidden');
notesEl.innerHTML = '';
@@ -1324,13 +1356,17 @@ export async function aiPolishEssay() {
}
if (payload) {
- const { original, corrected, hunks, notes_zh, error_count } = payload;
+ const { original, corrected, hunks, notes_zh, error_count, judge_advisories, judge_degraded, unconverged_reason } = payload;
polishState = {
original: original || text,
corrected: corrected || text,
hunks: (hunks || []).map(h => ({ ...h, accepted: true })),
notes_zh: notes_zh || [],
- error_count: error_count || 0
+ error_count: error_count || 0,
+ judge_advisories: judge_advisories || [],
+ judge_degraded: judge_degraded === true,
+ // Y3:抑制分支的输入(收敛时为 null;no-key 路径为 'no_api_key')。
+ unconverged_reason: unconverged_reason || null
};
openPolishOverlay();
renderPolishReview();
diff --git a/tests/test_a1_verb_case_data.py b/tests/test_a1_verb_case_data.py
new file mode 100644
index 00000000..a1d8edc5
--- /dev/null
+++ b/tests/test_a1_verb_case_data.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+"""`A1_VERB_CASES` 数据自检(Task 4)。
+
+词表是**人工登记的事实数据**(歌德 A1 词表 + 教科书级支配格常识),不是生成物 ——
+这些用例是它的可证伪边界:键越界 / 值非法 / 空键 / 条目过少都会红。
+"""
+
+from delector.data.a1_dict import GOETHE_A1_VOCAB
+from delector.data.a1_verb_case import A1_VERB_CASES
+
+
+def _a1_lemma_set():
+ lemmas = set()
+ for key, entry in GOETHE_A1_VOCAB.items():
+ lemma = entry.get("lemma") if isinstance(entry, dict) else None
+ lemmas.add(str(lemma or key).strip().lower())
+ return lemmas
+
+
+def test_verb_cases_keys_are_a1_lemmas():
+ """键 MUST ⊆ 歌德 A1 词表的 lemma 集(不许登记课外动词)。"""
+ lemmas = _a1_lemma_set()
+ unknown = sorted(k for k in A1_VERB_CASES if k.strip().lower() not in lemmas)
+ assert unknown == [], f"词表键必须在歌德 A1 词表内,未知键={unknown}"
+
+
+def test_verb_cases_values_are_valid():
+ """值 MUST ∈ {Dat, Acc, Dat+Acc}(第三种 = 两可支配 ⇒ 检测器不判)。"""
+ assert set(A1_VERB_CASES.values()) <= {"Dat", "Acc", "Dat+Acc"}
+
+
+def test_verb_cases_min_size_and_no_empty_keys():
+ """条目数 ≥ 5,且无空键(少而准是设计目标,但空数据不算数据)。"""
+ assert len(A1_VERB_CASES) >= 5
+ assert all(k.strip() for k in A1_VERB_CASES)
diff --git a/tests/test_ai_polish_loop.py b/tests/test_ai_polish_loop.py
index abea4076..3ca28d77 100644
--- a/tests/test_ai_polish_loop.py
+++ b/tests/test_ai_polish_loop.py
@@ -17,6 +17,7 @@
import math
import os
import re
+import time
from typing import Any, Callable
import pytest
@@ -535,18 +536,79 @@ def test_token_accounting_falls_back_to_estimate() -> None:
assert completion_tokens_of({"x": sample}) == estimate_tokens(dump({"x": sample}))
-def test_real_judge_is_wired_by_default() -> None:
- """默认裁判 = `writing.analyze_a1_email`,默认抽取器只取 `level == 'error'`。"""
- bad = "Liebe Maria,\nIch lade dich ein.\nViele Grüße,\nAnna"
- good = "Liebe Maria,\nich lade dich ein.\nViele Grüße\nAnna"
- rec = Recorder()
+@pytest.fixture(scope="module")
+def nlp() -> Any:
+ """真 spaCy 德语模型(沿用 `tests/test_writing_rules.py` 的 nlp fixture 惯例);缺失 ⇒ skip。"""
+ import spacy
- result = run(text=bad, chat=rec.chat([{"corrected_text": good}]))
+ try:
+ return spacy.load("de_core_news_sm")
+ except OSError:
+ pytest.skip("de_core_news_sm not installed")
- assert result.converged is True, f"真实裁判下修好的文本 MUST 收敛:{result}"
- assert result.reason == "ok"
- assert result.text == good
- # warning 级(如字数偏短)不构成违规 ⇒ 不得阻止收敛
+
+def test_real_judge_is_wired_at_the_assembly_point(nlp: Any, monkeypatch: Any) -> None:
+ """**装配点**(旧 pin `test_real_judge_is_wired_by_default` 的裁决改造)。
+
+ 旧 pin 逐字钉住「内核默认裁判 == `writing.analyze_a1_email`」——那钉的是「业务住在机制
+ 内核」。Task 1 的接缝方向相反:内核只留**可注入的缝**,业务由**产品层装配**。按计划
+ Global Constraints 的裁决规则,把它从「默认值」改为「装配点」:断言生产链路 `_run_polish`
+ 传给 `run_polish_loop` 的 `judge=` 真是一个经 `analyze_a1_email(text, leitpunkte, nlp)`
+ 装配、且**带 nlp** 的裁判(证据 = 裁决体 `judge_engine == "rules+spacy"` 且语法违规出现)。
+
+ **这不是放宽**:旧 pin 钉「内核自带业务」,新 pin 钉「业务在产品层装配**且真送进循环**」
+ —— 后者更强,能拦住「只改 `writing.py` 却没接到生产链路」的 dead-green。
+ """
+ from delector.nlp_engine import processor
+ from delector.routes.main import _run_polish
+
+ captured: dict[str, Any] = {}
+
+ async def _fake_loop(**kwargs: Any) -> Any:
+ captured.update(kwargs)
+ # 模拟循环至少裁一次原文 ⇒ 触发 `_JudgeRecorder` 记下 judge_engine。
+ kwargs["judge"](kwargs["text"])
+ return LoopResult(text=kwargs["text"], converged=True, reason="ok", rounds=0, violations=[])
+
+ monkeypatch.setattr("delector.services.polish_loop.run_polish_loop", _fake_loop)
+ monkeypatch.setattr("delector.routes.main.get_effective_api_base_url", lambda: "http://stub")
+ monkeypatch.setattr(processor, "get_loaded_nlp", lambda: nlp)
+
+ outcome = asyncio.run(_run_polish("Ich habe ein Hund.", "sk-stub"))
+
+ judge = captured.get("judge")
+ assert judge is not None, "端点 MUST 真的把 judge= 注入循环(否则生产上语法裁判恒不生效)"
+ verdict = judge("Ich habe ein Hund.")
+ assert verdict["judge_engine"] == "rules+spacy", "装配出的裁判 MUST 带 nlp"
+ assert any(v["rule"] == "artikel" for v in verdict["violations"]), "语法族 MUST 在装配出的裁判里生效"
+ assert outcome.judge_engine == "rules+spacy"
+
+
+def test_endpoint_assembly_degrades_to_rules_only_when_no_nlp(monkeypatch: Any) -> None:
+ """装配点对照:nlp 来源不可用 ⇒ 装配出的裁判 `judge_engine == "rules_only"`(如实降级)。"""
+ from delector.nlp_engine import processor
+ from delector.routes.main import _run_polish
+
+ captured: dict[str, Any] = {}
+
+ async def _fake_loop(**kwargs: Any) -> Any:
+ captured.update(kwargs)
+ kwargs["judge"](kwargs["text"])
+ return LoopResult(text=kwargs["text"], converged=True, reason="ok", rounds=0, violations=[])
+
+ monkeypatch.setattr("delector.services.polish_loop.run_polish_loop", _fake_loop)
+ monkeypatch.setattr("delector.routes.main.get_effective_api_base_url", lambda: "http://stub")
+ monkeypatch.setattr(processor, "get_loaded_nlp", lambda: None)
+ monkeypatch.setattr(processor, "try_load_nlp_no_download", lambda: None)
+
+ outcome = asyncio.run(_run_polish("Ich habe ein Hund.", "sk-stub"))
+
+ assert captured.get("judge") is not None
+ assert outcome.judge_engine == "rules_only"
+
+
+def test_default_violations_extractor_ignores_warnings() -> None:
+ """默认抽取器只取 `level == 'error'`(warning 是建议,不阻止收敛)。"""
assert default_violations_of({"suggestions": [{"rule": "a1_word_count", "level": "warning"}]}) == []
@@ -663,3 +725,188 @@ def test_truncate_evidence_is_pure_and_both_limits_are_independent() -> None:
cut_bytes = truncate_evidence(one_huge_line)
assert len(cut_bytes.splitlines()) == 1
assert len(cut_bytes.encode("utf-8")) <= EVIDENCE_MAX_BYTES
+
+
+# ── Task 8 接缝加固:dispatch 超时 / 裁判移出事件循环 ──────────────────────────
+# 反挂死红线:本区块每条用例都同时满足
+# 1) 所有 sleep 都有 `asyncio.wait_for` / 固定次数上限兜底(绝无无界 while);
+# 2) 取消语义只用 `task.cancel()` + `wait_for(..., timeout=...)`,**禁止 join 线程**
+# (线程掐不断,join 会让整轮门禁挂死)。
+def test_tool_dispatch_timeout_is_fed_back_not_raised() -> None:
+ """接缝①:`dispatch` MUST 受 `min(剩余 deadline, DEFAULT_PER_CALL_TIMEOUT_S)` 约束。
+
+ 超时是**证据**(errors-as-messages),不是异常、不是 500:循环照常走到下一轮,
+ 由轮首的预算前置检查以 `deadline` 原因码收口(零静默失败)。
+ `remaining` 在调用前**重算**(不能复用轮首那个:本轮 chat 已花掉时间)。
+
+ 时钟用**可推进的桩**(沿用本文件 `test_deadline_breaks_loop_...` 的惯例):每次工具
+ 超时只记 0.5s 墙钟,而真实等待是 1.0s ⇒ 第一次超时后仍有剩余 ⇒ 循环能多跑一轮,
+ 「超时作为证据喂回模型」这件事才**观测得到**(否则第 2 轮轮首就 deadline 退出,
+ 证据消息根本没机会进任何一次 chat 的 messages)。
+ """
+ rec = Recorder()
+ state = {"t": 0.0}
+
+ def _clock() -> float:
+ return state["t"]
+
+ def _advance() -> None:
+ state["t"] += 0.5
+
+ async def _slow_hang(name: str, payload: dict[str, Any]) -> dict[str, Any]:
+ _advance() # 假装这次工具调用烧掉了 0.5s 墙钟(真实等待是 1.0s)
+ await asyncio.sleep(30)
+ return {"never": True}
+
+ async def _drive() -> Any:
+ # 外层 5s 兜底:实现侧 wait_for 被抽掉时本用例必须红(dispatch 会睡 30s)。
+ return await asyncio.wait_for(
+ run_polish_loop(
+ text="T0",
+ tools={"known": echo_tool({"ok": True})},
+ dispatch=_slow_hang,
+ chat=rec.chat([TOOL_CALL]),
+ judge=make_judge({"T0": 1}),
+ budget=Budget(deadline_s=1.0),
+ clock=_clock,
+ ),
+ timeout=5.0,
+ )
+
+ result = asyncio.run(_drive())
+
+ assert result.converged is False
+ assert result.reason == "deadline", f"超时只是证据,收口 MUST 是预算原因码:{result.reason}"
+ assert result.rounds == 2, f"第 3 轮轮首剩余 ≤0 ⇒ 直接退出:{result.rounds}"
+ assert any("执行超时" in c for c in rec.all_contents()), "工具超时 MUST 作为证据喂回模型(不是 500、不冒泡)"
+ assert len(rec.calls) == 2, f"两轮各调一次 chat(第 2 轮才带着证据上线):{len(rec.calls)}"
+
+
+def test_sync_judge_does_not_block_the_event_loop() -> None:
+ """接缝②:同步裁判 MUST 经 `asyncio.to_thread` —— 长裁判不阻塞同进程其它协程。
+
+ 探针是**有界**心跳协程(固定 20 次 × 0.02s 后自停,`finally` 里收尸),断言的是
+ **裁判执行期间**心跳被推进的次数:裁判若直接同步跑在事件循环上,这 0.3s 内心跳
+ 一次都跑不了(探针只观察行为,不耦合「用了线程」这个实现细节)。
+
+ 窗口取**跨调用最大值**(评审黄卡的修正):循环会调两次裁判(v_0 基线 + 收尾候选),
+ 而心跳寿命只有 ≈0.40s —— 若每次调用覆写 `window`,被测值就落在**第二次**窗口上
+ (只剩约 5 tick,余量趋近零 ⇒ 精确计时平台会误红)。取 max 后基线那次窗口
+ (≈15 tick)成为被测值,阈值 5 的余量 ≈10 tick。
+ """
+ ticks: list[int] = []
+ window = {"during": 0}
+
+ def _slow_judge(text: str, leitpunkte: list[str] | None = None) -> dict:
+ before = len(ticks)
+ time.sleep(0.3) # 长裁判(0.3s 有界;下方 wait_for 兜底,绝不挂死门禁)
+ window["during"] = max(window["during"], len(ticks) - before)
+ return {"suggestions": [{"rule": "r", "level": "error", "message": "m"}]}
+
+ async def _drive() -> int:
+ async def _heartbeat() -> None:
+ # 有界:固定 20 次 × 0.02s = 0.4s 后自停(**禁止**无界 while 轮询)。
+ for _ in range(20):
+ await asyncio.sleep(0.02)
+ ticks.append(1)
+
+ hb = asyncio.ensure_future(_heartbeat())
+ try:
+ await asyncio.wait_for(
+ run_polish_loop(
+ text="T0",
+ chat=Recorder().chat([{"corrected_text": "T1"}]),
+ judge=_slow_judge,
+ ),
+ timeout=5.0,
+ )
+ finally:
+ # 心跳本身有界(0.4s);这里再兜底 2s,本用例最坏耗时因此有上界。
+ try:
+ await asyncio.wait_for(hb, timeout=2.0)
+ except asyncio.TimeoutError:
+ pass
+ return window["during"]
+
+ during = asyncio.run(_drive())
+
+ assert during >= 5, f"裁判执行期间事件循环 MUST 仍能推进(实测 tick {during} 次)"
+
+
+def test_cancel_interrupts_the_in_flight_sync_judge() -> None:
+ """接缝②:`task.cancel()` MUST 掐断在途同步裁判(修好前它跑在事件循环上,掐不断)。
+
+ 线程掐不断是事实,但循环**不再等待**它 ⇒ 取消在毫秒级返回。禁止 `join` 线程
+ (那会把门禁挂死且语义也不对:我们断言的是「不再被等待」)。
+
+ 为什么必须同时断言**耗时**:裁判同步跑在事件循环上时,`await asyncio.sleep(0.1)`
+ 本身就被推迟到裁判跑完之后 ⇒ `task.cancel()` 也随之推迟 ⇒ `wait_for` 的 0.3s
+ 超时根本来不及触发(循环被占着)⇒ 只看「有没有超时」会**假绿**。故补一条
+ 「取消 MUST 立刻返回」的耗时断言(修好后是毫秒级,没修好是 0.5s 级)。
+ """
+ def _slow_judge(text: str, leitpunkte: list[str] | None = None) -> dict:
+ time.sleep(0.5) # 有界:0.5s,由下方 cancel + wait_for(0.3) 兜底
+ return {"suggestions": []}
+
+ async def _drive() -> dict[str, Any]:
+ task = asyncio.ensure_future(
+ run_polish_loop(
+ text="T0",
+ chat=Recorder().chat([{"corrected_text": "T1"}]),
+ judge=_slow_judge,
+ )
+ )
+ # 等它进裁判:0.5s 的裁判此刻正在途(基线裁判在循环最前面,无需等太久)。
+ await asyncio.sleep(0.1)
+ task.cancel()
+ started = time.perf_counter()
+ outcome = "returned"
+ try:
+ await asyncio.wait_for(task, timeout=0.3)
+ except asyncio.TimeoutError:
+ outcome = "timeout"
+ except asyncio.CancelledError:
+ outcome = "cancelled"
+ return {"outcome": outcome, "took": time.perf_counter() - started}
+
+ snap = asyncio.run(_drive())
+
+ assert snap["outcome"] != "timeout", "取消未在 0.3s 内完成 ⇒ 在途裁判仍在阻塞等待"
+ assert snap["outcome"] == "cancelled", f"取消 MUST 真正传导到循环任务(实测 {snap['outcome']})"
+ assert snap["took"] < 0.25, (
+ f"取消 MUST 在裁判在途时立刻返回(实测 {snap['took']:.2f}s)"
+ " ⇒ 裁判仍在阻塞事件循环(连 cancel() 本身都被推迟到它跑完)"
+ )
+
+
+def test_async_judge_verdict_is_awaited_not_a_coroutine_object() -> None:
+ """接缝②:注入 `async def` 裁判 ⇒ 循环 MUST await 它,而不是把 coroutine 当裁决。
+
+ 判别力(删掉 `iscoroutinefunction` 那支 ⇒ 本用例红):`to_thread` 会把 coroutine
+ **对象**当返回值交给 `violations_of` ⇒ 那不是 dict ⇒ 违规数恒 0 ⇒ 循环**静默假收敛**。
+ 这里把 v_0(原文)= 0、v_1(候选)= 1 摆成「不降即停」:
+ - 正确:`no_improvement` + 回退原文;
+ - 退化:假收敛 + 把候选当答案返回。
+ """
+ seen: list[Any] = []
+
+ async def _async_judge(text: str, leitpunkte: list[str] | None = None) -> dict:
+ n = 0 if text == "T0" else 1
+ return {"suggestions": [{"rule": f"r{i}", "level": "error", "message": f"v{i}"} for i in range(n)]}
+
+ def _spy(verdict: Any) -> list[dict[str, Any]]:
+ seen.append(verdict)
+ return default_violations_of(verdict)
+
+ result = run(
+ text="T0",
+ chat=Recorder().chat([{"corrected_text": "T1"}]),
+ judge=_async_judge,
+ violations_of=_spy,
+ )
+
+ assert len(seen) == 2, f"原文与收尾候选都要过裁判:{len(seen)}"
+ assert all(isinstance(v, dict) for v in seen), f"裁判交给循环的 MUST 是真裁决对象:{seen}"
+ assert result.converged is False, "async 裁判给候选判了 1 条违规,不得静默判成收敛"
+ assert result.reason == "no_improvement"
+ assert result.text == "T0"
diff --git a/tests/test_goethe_a1_writing.py b/tests/test_goethe_a1_writing.py
index 6ec0f30b..37e41458 100644
--- a/tests/test_goethe_a1_writing.py
+++ b/tests/test_goethe_a1_writing.py
@@ -8,15 +8,25 @@
from pathlib import Path
import pytest
+import spacy
os.environ.setdefault("DATABASE_PATH", "test_delector_goethe_a1_writing.db")
from fastapi.testclient import TestClient
from delector.data import a1_writing_dict
-from delector.routes.main import _a1_violations_if_letter
+from delector.routes.main import violations_for
from delector.server import app
from delector.services.polish_loop import default_violations_of
-from delector.services.writing import analyze_a1_email, check_a1_formular_answer
+from delector.services.writing import analyze_a1_email, check_a1_formular_answer, collect_violations
+
+
+@pytest.fixture(scope="module")
+def nlp():
+ """真 spaCy 模型(沿用 `tests/test_writing_rules.py` 的 nlp fixture 惯例);缺失 ⇒ skip。"""
+ try:
+ return spacy.load("de_core_news_sm")
+ except OSError:
+ pytest.skip("de_core_news_sm not installed")
def test_a1_writing_dict_dataset_integrity():
@@ -210,15 +220,13 @@ def test_a1_writing_frontend_html_and_css():
_LETTER_TEXT = "Liebe Maria,\nich lade dich ein.\nViele Grüße,\nAnna"
-def test_a1_violations_gate_only_counts_when_text_is_a_letter():
- """适用性闸门(**Unknown A 的边界**):A1 书信规则只对书信体计数。
+def test_a1_violations_gate_format_family_still_letter_only():
+ """闸门(Task 7 收窄后的口径):格式/字数族只对书信体生效;无语法违规的非书信文本 ⇒ 零计数。
- 裁判集今天**只有** A1 书信规则(`analyze_a1_email`,裁判集扩充属 Unknown A,
- 本计划不做)。对非书信文本它必然报 `a1_greeting_missing`(error) —— 那是**题材
- 差异**,不是错误 ⇒ 闸门让它「无话可说」(返回 [])。
-
- 注意这**不等于**「裁判判定无错」:它只是说明手上这套规则不适用于该文本,
- 语义等价于今天的一次性润色(首轮即接受),而不是「零违规」的证明。
+ - 非书信体 `_ESSAY_TEXT` 的裁决体只有格式族违规(无 nlp ⇒ 语法族未跑)⇒
+ `violations_for("prose", ·)` 为 [] —— 但这**不等于**「裁判判定无错」:语义是
+ 「格式族对本文本不适用 + 语法族没有可计的东西」,与旧文档的措辞不同、结论按本次输入相同。
+ - 书信体照常计数(闸门必须**有选择性**,一律返回 [] 是关掉裁判,不是闸门)。
"""
verdict_letter = analyze_a1_email(_LETTER_TEXT)
verdict_essay = analyze_a1_email(_ESSAY_TEXT)
@@ -228,10 +236,47 @@ def test_a1_violations_gate_only_counts_when_text_is_a_letter():
# 无闸门时:非书信体被判「缺少称呼语」= error ⇒ v_0 = 1 ⇒ 循环会误判「改不动」
assert len(default_violations_of(verdict_essay)) >= 1
- # 有闸门:规则不适用 ⇒ 零违规
- assert _a1_violations_if_letter(verdict_essay) == []
- # 书信体照常计数(闸门必须**有选择性**,一律返回 [] 是关掉裁判,不是闸门)
- assert len(_a1_violations_if_letter(verdict_letter)) >= 1
+ # 收窄后的闸门:格式族对非书信体不计数(此处裁决体无语法违规 ⇒ 结果为空)
+ assert violations_for("prose", verdict_essay) == []
+ # 书信体照常计数
+ assert len(violations_for("letter", verdict_letter)) >= 1
+
+
+def test_a1_violations_gate_counts_grammar_for_non_letter_text(nlp):
+ """★ Task 7 闸门收窄(**旧钉子的显式改写**):非书信体 + 语法错 ⇒ 语法违规**真被计数**。
+
+ 这枚钉子此前钉住"非书信体 MUST 仍不计数",目的就是让「闸门被收窄」不可能被静默做掉;
+ 2026-10-15 用户拍板收窄后,本用例**翻转口径**成为新行为的守卫(谁把闸门改回
+ "非书信体一律零违规",这条立刻红 —— 变异自证②)。
+
+ 判别力来源:先证语法错**真实存在**于裁决体("ein Hund" ⇒ `artikel` 违规),
+ 再证闸门**现在**对它计数(不是「没有违规可挡」的空转),且格式族不被顺带带进来。
+ """
+ verdict_essay = analyze_a1_email(_ESSAY_TEXT, nlp=nlp)
+
+ # 前提①:非书信体(称呼语不合法 ⇒ 文本族 = prose)。
+ assert verdict_essay["greeting"]["valid"] is False
+
+ # 前提②:语法族**确实**报出了违规(否则这条用例就是空转 —— 验了个不存在的东西)。
+ essay_violations = collect_violations(verdict_essay)
+ assert any(v["rule"] == "artikel" for v in essay_violations), (
+ f"冠词格错必须被语法族捕获(夹具失效?实际={essay_violations})"
+ )
+
+ counted = violations_for("prose", verdict_essay)
+ assert any(v["rule"] == "artikel" for v in counted), "非书信体的语法违规 MUST 被计数(闸门收窄)"
+ assert all(not v["rule"].startswith("a1_") for v in counted), "格式/字数族 MUST 只对书信体生效"
+
+
+def test_a1_violations_gate_letter_counts_format_and_grammar(nlp):
+ """闸门对照:书信体 ⇒ 格式/字数族 + 语法族**全计**(收窄不误伤书信体)。"""
+ letter = "Liebe Maria,\nich habe ein Hund.\nViele Grüße,\nAnna"
+ verdict = analyze_a1_email(letter, nlp=nlp)
+ assert verdict["greeting"]["valid"] is True
+
+ rules = [v["rule"] for v in violations_for("letter", verdict)]
+ assert "a1_valediction_comma" in rules, "书信体的格式错 MUST 照旧计数"
+ assert "artikel" in rules, "书信体的语法错 MUST 照旧计数"
def test_ai_polish_endpoint_keeps_non_letter_polish(monkeypatch):
diff --git a/tests/test_judge_fp_bench.py b/tests/test_judge_fp_bench.py
new file mode 100644
index 00000000..682d28c9
--- /dev/null
+++ b/tests/test_judge_fp_bench.py
@@ -0,0 +1,288 @@
+# -*- coding: utf-8 -*-
+"""`tools/bench_judge_fp.py` 的行为契约测试(Task 6 的 AFK 半)。
+
+钉住什么(为什么这些断言不是恒真)
+--------------------------------
+1. **样本集有效性判据**:标注缺失 / 空样本 ⇒ ``validate_samples`` 非空、
+ ``measure_candidates`` 抛 ValueError、CLI 退出码 1("样本集自身无效"的语义锚)。
+2. **计数的可证伪性**:用两个方向相反的桩探测(永不报 / 永远报)喂同一批样本,
+ 断言 FP/FN 计数在两端都正确 —— 只断言"永不报 ⇒ FP=0"是恒真死断言
+ (实现写坏时 0 == 0 照样成立),必须配"永远报 ⇒ FP=全部期望不报句"的反向断言。
+3. **Report 形状**:``fp_samples`` / ``fn_samples`` 键必须存在,且装载误报/漏报句
+ 全文(带来源前缀、顺序确定)—— "只报比例无法证伪判据"是本任务的红线。
+4. **确定性**:同输入两次测量输出逐字节一致("可复跑"的硬条件)。
+5. **内置样本集自洽**:10 篇官方范文 + 每维 ≥5 定向错误句 + ≥5 正确对照组、
+ 标注齐全(防后续有人删样本让标定失真)。
+6. **环境不可用 = 退出码 2**:spaCy 缺失(ImportError 原始形态)/ 模型加载失败
+ (RuntimeError)都归口 ``probe_environment`` 映射为 2,不与"样本集无效 = 1"
+ 撞语义(注入桩 loader,不真删包、不真坏环境)。
+7. **样本外追补必须出现在产物**:A 维 3 句样本外误报的原文 + 实测结果必须在
+ verdict(text 与 ``--json`` 同源)与 CLI 区块可寻,且**不得**混入冻结样本集
+ —— 防退化成"只住 docstring"的系统性乐观(评审 RED)。
+
+反挂死纪律:本文件无 sleep / 无线程 / 无循环等待;唯一的子进程调用带固定
+timeout(防挂死防线,实测 ~1.5s)。
+"""
+
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import types
+from pathlib import Path
+from typing import Any
+
+_ROOT = Path(__file__).resolve().parent.parent
+_SCRIPT = _ROOT / "tools" / "bench_judge_fp.py"
+
+# 桩探测的触发标记(样本句里含它就被"报")。
+_MARKER = "FALSCH"
+
+
+def _load_bench_module() -> types.ModuleType:
+ """按路径加载 ``tools/bench_judge_fp.py``(tools/ 不是 package,同 tests/test_audit_official_vocab.py 模式)。
+
+ 注意 MUST 先注册 ``sys.modules``:被测脚本用 ``from __future__ import annotations``,
+ dataclasses 在 exec 期会查 ``sys.modules[cls.__module__]`` 解析字符串注解 ——
+ 不注册时动态加载直接 AttributeError(2026-10-15 实测踩坑,别再省这一步)。
+ """
+ spec = importlib.util.spec_from_file_location("bench_judge_fp_under_test", _SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+bench = _load_bench_module()
+
+
+def _stub(name: str, reports: bool) -> Any:
+ """构造桩候选:``reports=True`` ⇒ 永远报;False ⇒ 永不报。"""
+ return bench.Candidate(name=name, description=f"stub(always={reports})", detect=lambda _s: reports)
+
+
+def _stub_by_marker() -> Any:
+ """构造桩候选:句含 ``FALSCH`` 才报(用来钉"定向错误句被报")。"""
+ return bench.Candidate(name="stub", description="含 FALSCH 才报", detect=lambda s: _MARKER in s)
+
+
+def test_missing_label_invalidates_samples() -> None:
+ """标注缺失(expected=None)⇒ 样本集无效:validate 报错 + measure 抛 ValueError。"""
+ candidates = [_stub("stub", reports=False)]
+ negatives = {"stub": [bench.LabeledSentence("Ein Satz ohne Label.", None, "nolabel")]}
+ errors = bench.validate_samples(candidates, ["Ein korrekter Satz."], negatives)
+ assert errors, "缺标注必须被 validate_samples 判为无效"
+ assert any("缺标注" in e for e in errors)
+ try:
+ bench.measure_candidates(candidates, ["Ein korrekter Satz."], negatives)
+ except ValueError as exc:
+ assert "样本集无效" in str(exc)
+ else: # pragma: no cover - 防御:不抛就是契约破坏
+ raise AssertionError("measure_candidates 对缺标注样本集必须抛 ValueError")
+
+
+def test_empty_samples_invalidate() -> None:
+ """空正样本 / 空负样本维度 ⇒ validate 非空("空样本"一类的退出码 1 锚点)。"""
+ candidates = [_stub("stub", reports=False)]
+ ok_neg = {"stub": [bench.LabeledSentence("Ein Satz.", False, "ctrl")]}
+ assert bench.validate_samples(candidates, [], ok_neg), "空正样本必须被判无效"
+ assert bench.validate_samples(candidates, ["Ein Satz."], {}), "空负样本集必须被判无效"
+ assert bench.validate_samples(candidates, ["Ein Satz."], {"stub": []}), "空的负样本维度必须被判无效"
+
+
+def test_cli_missing_label_exits_nonzero(tmp_path: Path) -> None:
+ """CLI 层:--negatives 指向缺 expected 的 JSON ⇒ 退出码 1(非零 = 标定未完成)。"""
+ bad = tmp_path / "bad_negatives.json"
+ bad.write_text(
+ json.dumps(
+ {
+ "a_modal_frame_order": [{"text": "Ich muss gehen heute nach Hause.", "note": "nolabel"}],
+ "b_past_anchor_tense_conflict": [{"text": "Gestern gehe ich zur Schule.", "expected": True}],
+ },
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+ env = dict(os.environ)
+ env["PYTHONIOENCODING"] = "utf-8"
+ proc = subprocess.run(
+ [sys.executable, str(_SCRIPT), "--negatives", str(bad)],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=15,
+ env=env,
+ cwd=str(_ROOT),
+ )
+ assert proc.returncode == 1, f"缺标注必须退出码 1(实际 {proc.returncode})\nstderr={proc.stderr}"
+ assert "样本集无效" in proc.stderr
+
+
+def test_all_correct_samples_zero_fp() -> None:
+ """全部正确样本(期望不报)⇒ FP 计数 0 —— 并配"永远报"的反向断言防恒真。"""
+ positives = ["Ein voellig korrekter Satz."]
+ negatives = {"stub": [bench.LabeledSentence("Noch ein korrekter Satz.", False, "ctrl1")]}
+
+ never = bench.measure_candidates([_stub("stub", reports=False)], positives, negatives)
+ entry = never["per_candidate"][0]
+ assert entry["fp"] == 0
+ assert entry["fn"] == 0
+ assert entry["fp_samples"] == []
+ assert entry["fn_samples"] == []
+
+ # 反向对照(防恒真):同一批全正确样本,"永远报"必须把每句都记成 FP。
+ always = bench.measure_candidates([_stub("stub", reports=True)], positives, negatives)
+ entry_always = always["per_candidate"][0]
+ assert entry_always["fp"] == 2, "1 句正样本 + 1 句对照组,全部应为 FP"
+ assert len(entry_always["fp_samples"]) == 2
+
+
+def test_targeted_wrong_samples_zero_fn() -> None:
+ """定向错误样本(期望报)⇒ 被正确报出时 FN 计数 0;配"永不报"的反向断言。"""
+ positives = ["Ein korrekter Satz."]
+ negatives = {
+ "stub": [
+ bench.LabeledSentence("FALSCH Satz eins.", True, "err1"),
+ bench.LabeledSentence("FALSCH Satz zwei.", True, "err2"),
+ ]
+ }
+ always = bench.measure_candidates([_stub("stub", reports=True)], positives, negatives)
+ entry = always["per_candidate"][0]
+ assert entry["fn"] == 0
+ assert entry["fn_samples"] == []
+
+ never = bench.measure_candidates([_stub("stub", reports=False)], positives, negatives)
+ entry_never = never["per_candidate"][0]
+ assert entry_never["fn"] == 2, "两句定向错误句全部漏报"
+ assert len(entry_never["fn_samples"]) == 2
+
+
+def test_report_contains_sample_keys_with_full_text() -> None:
+ """Report 形状契约:per_candidate 键集合 + 样例行 = 带来源前缀的句全文(顺序确定)。"""
+ positives = ["Ein Satz."]
+ negatives = {
+ "stub": [
+ bench.LabeledSentence("Zwei Satz.", False, "ctrl"),
+ bench.LabeledSentence("Drei Satz.", True, "err"),
+ ]
+ }
+ always = bench.measure_candidates([_stub("stub", reports=True)], positives, negatives)
+ entry = always["per_candidate"][0]
+ assert set(entry.keys()) == {"name", "fp", "fn", "fp_samples", "fn_samples"}
+ assert entry["name"] == "stub"
+ # 误报样例 = 正样本句 + 对照组句(各带来源前缀;顺序 = 正样本在前、负样本按输入序)。
+ assert entry["fp_samples"] == ["[positive#1] Ein Satz.", "[ctrl] Zwei Satz."]
+ # 定向错误句被报 ⇒ 不进 fn_samples。
+ assert entry["fn_samples"] == []
+ # verdict 是 dict[str, str](只含量与阈值对照)。
+ assert isinstance(always["verdict"], dict)
+ assert isinstance(always["verdict"]["stub"], str)
+ assert "阈值" in always["verdict"]["stub"]
+
+ # 反向:永"不漏"的另一端 —— 永不报时 fn_samples 装载的是漏报句全文。
+ never = bench.measure_candidates([_stub("stub", reports=False)], positives, negatives)
+ entry_never = never["per_candidate"][0]
+ assert entry_never["fn_samples"] == ["[err] Drei Satz."]
+ assert entry_never["fp_samples"] == []
+
+
+def test_determinism_same_input_same_output() -> None:
+ """同输入两次测量,输出必须逐字节一致(可复跑纪律)。"""
+ candidate = _stub_by_marker()
+ positives = ["Ein korrekter Satz. Und FALSCH ein zweiter."]
+ negatives = {
+ "stub": [
+ bench.LabeledSentence("FALSCH hier.", True, "err1"),
+ bench.LabeledSentence("Alles gut.", False, "ctrl1"),
+ ]
+ }
+ first = bench.measure_candidates([candidate], positives, negatives)
+ second = bench.measure_candidates([candidate], positives, negatives)
+ assert json.dumps(first, ensure_ascii=False) == json.dumps(second, ensure_ascii=False)
+
+
+def test_default_dataset_is_valid_and_meets_min_size() -> None:
+ """内置样本集自洽:10 篇范文 + 每维 ≥5 定向错误 + ≥5 对照组、标注齐全。"""
+ pairs = bench.load_positives()
+ assert len(pairs) == 10, "正样本 = A1_SCHREIBEN_TEIL2 全部 10 篇"
+ assert all(entry_id and text for entry_id, text in pairs)
+ texts = [text for _, text in pairs]
+ assert bench.validate_samples(bench.CANDIDATES, texts, bench.NEGATIVES) == []
+ for name in ("a_modal_frame_order", "b_past_anchor_tense_conflict"):
+ items = bench.NEGATIVES[name]
+ errs = [it for it in items if it.expected is True]
+ ctrls = [it for it in items if it.expected is False]
+ assert len(errs) >= 5, f"{name} 的定向错误句不足 5 条"
+ assert len(ctrls) >= 5, f"{name} 的正确对照组不足 5 条"
+
+
+def _raiser(exc: BaseException) -> Any:
+ """构造"调用即抛指定异常"的桩 loader(注入环境探测,不真删包、不真坏环境)。"""
+
+ def _raise() -> Any:
+ raise exc
+
+ return _raise
+
+
+def test_environment_unavailable_maps_to_exit_code_2(monkeypatch: Any, capsys: Any) -> None:
+ """环境不可用 ⇒ 退出码 2(与"样本集无效 = 1"分语义);注入桩,不真删包/不真坏环境。"""
+ # 1) 探测函数是"rc=2"的单一归口:可用 ⇒ None;两类环境故障都返回原因文本。
+ assert bench.probe_environment(lambda: object()) is None, "环境可用时探测必须返回 None"
+ import_err = bench.probe_environment(_raiser(ImportError("No module named 'spacy'")))
+ assert import_err is not None and "spacy" in import_err
+ runtime_err = bench.probe_environment(_raiser(RuntimeError("德语模型加载失败")))
+ assert runtime_err is not None and "德语模型加载失败" in runtime_err
+
+ # 2) CLI 主路径:spaCy 缺失的原始形态(ImportError 直接外泄)必须被转成 2,
+ # 而不是未捕获异常(rc=1,与"样本集无效"撞语义)。
+ monkeypatch.setattr(bench, "_load_nlp", _raiser(ImportError("No module named 'spacy'")))
+ rc = bench.main([])
+ assert rc == 2, f"环境不可用必须退出码 2(实际 {rc})"
+ captured = capsys.readouterr()
+ assert "环境不可用" in captured.err
+
+
+def test_out_of_sample_supplement_is_in_product(monkeypatch: Any, capsys: Any) -> None:
+ """RED 修复钉:A 维样本外追补的原文+结果必须进产物,且**不得**混入冻结样本集。
+
+ 无模型依赖:verdict 走合成输入、CLI 区块注入桩结果(不真跑 spaCy)。
+ """
+ # 1) 追补集形状:3 句、第 1 句 = 评审记录句、逐句非空、与冻结样本集**零重叠**
+ # (混入会改动 fp/fn 计数,破坏"计数冻结"纪律)。
+ assert len(bench.OUT_OF_SAMPLE_A) == 3
+ assert bench.OUT_OF_SAMPLE_A[0] == "Ich kann nicht kommen, ich muss arbeiten."
+ assert all(text.strip() for text in bench.OUT_OF_SAMPLE_A)
+ frozen = {item.text for items in bench.NEGATIVES.values() for item in items}
+ assert not (set(bench.OUT_OF_SAMPLE_A) & frozen), "追补句不得混入冻结样本集"
+
+ # 2) verdict(text 与 --json 同源):A 维必须带追补句与翻档结论;B 维不得带。
+ outcomes = [bench.SentenceOutcome("TN", "positive", "p1", False, False, "Ein Satz.")]
+ oos = [(text, True) for text in bench.OUT_OF_SAMPLE_A]
+ a_verdict = bench._verdict_text(outcomes, oos)
+ assert "样本外追补" in a_verdict
+ assert "未入本冻结集" in a_verdict
+ assert "3 个变体中 3 个报 True" in a_verdict
+ assert "warning 档条件亦不成立" in a_verdict, "3 句报 True 计入对照组时必须翻掉 warning 档条件"
+ b_verdict = bench._verdict_text(outcomes, None)
+ assert "样本外追补" not in b_verdict
+
+ # 3) CLI 独立区块:注入桩结果 ⇒ 必须打印 3 句原文与结果(含 err7 族结构性边界)。
+ monkeypatch.setattr(bench, "_out_of_sample_a_results", lambda: [(text, True) for text in bench.OUT_OF_SAMPLE_A])
+ report = {
+ "per_candidate": [
+ {"name": "a_modal_frame_order", "fp": 0, "fn": 0, "fp_samples": [], "fn_samples": []},
+ {"name": "b_past_anchor_tense_conflict", "fp": 0, "fn": 0, "fp_samples": [], "fn_samples": []},
+ ],
+ "verdict": {},
+ }
+ details: dict[str, list[Any]] = {"a_modal_frame_order": [], "b_past_anchor_tense_conflict": []}
+ bench._print_text_report(report, details, bench.CANDIDATES, 1, 0)
+ out = capsys.readouterr().out
+ assert "样本外追补(不入计数)" in out
+ for text in bench.OUT_OF_SAMPLE_A:
+ assert text in out, f"区块缺原文:{text}"
+ assert "[报=True]" in out
+ assert "err7" in out
diff --git a/tests/test_server.py b/tests/test_server.py
index ebacb895..4316e37e 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -4606,6 +4606,8 @@ def test_all_backend_modules_registered_in_all_packaging_targets():
"official_vocab_rich",
"a1_fragments",
"a1_sidecar",
+ # Task 4(rektion):a1_verb_case 被 services/writing.py 静态导入 —— 漏登记 = 打包后 ModuleNotFoundError。
+ "a1_verb_case",
}
# Phase 1 Task 4:8 个 routes_*.py 收进 delector.routes/ 子包,`routes_` 前缀由
# 包路径取代(delector.routes_a1 → delector.routes.a1)。漏改打包清单 =
@@ -6638,23 +6640,48 @@ def test_ai_polish_no_key_reports_endpoint_level_reason(client, monkeypatch):
)
-def test_ai_polish_non_letter_text_is_not_rolled_back(client, monkeypatch):
- """★ 产品回归闸门:非书信体不得被回退成原文。
+def test_ai_polish_non_letter_grammar_error_is_counted_by_the_loop(client, monkeypatch):
+ """★ Task 7 闸门收窄(**三态死绿守卫的改写其一**):非书信体 + 语法错 ⇒ 语法违规**真被计数**。
- 裁判集只有 A1 书信规则(`analyze_a1_email`);对普通作文它必然报
- `a1_greeting_missing` ⇒ 不挂适用性闸门的话 v_0=1、v_1>=v_0 ⇒ 回退原文
- ⇒ 用户点了润色却拿回原文,功能报废。
+ 场景:桩模型改了措辞但**不修**冠词错(`ein Hund` 原样保留)⇒ v_0 == v_1 == 1 ⇒
+ `no_improvement`(语法违规进了 v 才会发生)。
+
+ 判别力(变异自证②):把闸门改回"非书信体一律零违规" ⇒ v_0 == v_1 == 0 ⇒ 首轮即收敛
+ ⇒ 本用例 MUST 红 —— 旧闸门下的行为就是"零违规",本用例的存在即"真被计数"与它的对比。
"""
+ nlp = _real_nlp_or_skip()
_stub_api_key(monkeypatch)
- polished = "Ich habe einen Hund. Er ist gut und sehr lieb."
- chat = _ScriptedChat([{"corrected_text": polished, "notes_zh": ["用词更地道"]}])
+ chat = _ScriptedChat([{"corrected_text": "Ich habe ein Hund. Er ist sehr lieb.", "notes_zh": ["x"]}])
monkeypatch.setattr("delector.routes.main.llm_client.chat", chat)
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: nlp)
result = client.post("/api/writing/ai-polish/diff", json={"text": _ESSAY_TEXT}).json()["result"]
- assert result["corrected"] == polished, "裁判对非书信体无话可说 ⇒ 首轮即接受,不得回退原文"
- assert result["converged"] is True
- assert result["unconverged_reason"] is None
+ assert result["converged"] is False, "非书信体的语法违规 MUST 被计数(否则修不动也会被判收敛)"
+ assert result["unconverged_reason"] == "no_improvement"
+ assert result["judge_degraded"] is False
+
+
+def test_ai_polish_non_letter_unfixable_falls_back_to_original(client, monkeypatch):
+ """★ 产品语义钉子(**三态死绿守卫的改写其二**;ADR §10.3 的落点,MUST 有断言):
+ 非书信体语法错**修不动** ⇒ 回退**原文**(**不是**"首轮即接受")。
+
+ 这是闸门收窄后的**明知而接受**的行为(2026-10-15 用户拍板;同款登记在
+ `violations_for` 与 `_make_judge` 的 docstring):循环按 Task 4 钉死的
+ `no_improvement` 语义走 argmin ⇒ 原文;用户拿到的人话是前端
+ `POLISH_REASON_COPY.no_improvement`("AI 改了几轮仍没能减少硬性错误,已保留更接近原文的一版。")。
+ """
+ nlp = _real_nlp_or_skip()
+ _stub_api_key(monkeypatch)
+ chat = _ScriptedChat([{"corrected_text": "Ich habe ein Hund. Er ist sehr lieb.", "notes_zh": ["x"]}])
+ monkeypatch.setattr("delector.routes.main.llm_client.chat", chat)
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: nlp)
+
+ result = client.post("/api/writing/ai-polish", json={"text": _ESSAY_TEXT}).json()["result"]
+
+ assert result["corrected_text"] == _ESSAY_TEXT, "修不动 ⇒ 按循环语义回退原文(不是首轮即接受)"
+ assert result["converged"] is False
+ assert result["unconverged_reason"] == "no_improvement"
def test_ai_polish_letter_text_still_judged_by_a1_rules(client, monkeypatch):
@@ -6674,6 +6701,264 @@ def test_ai_polish_letter_text_still_judged_by_a1_rules(client, monkeypatch):
assert result["unconverged_reason"] == "no_improvement"
+# ── Task 1/2 评审 RED#1 闭环:端点级接线回归 + 引擎降级诚实性 ──────────────────
+# 书信体 + 冠词格错("ein Hund" → "einen Hund")的文本夹具。
+# 用**书信体**而非非书信体:RED#1 只要求"证明接缝是活的",书信体已足够 ——
+# 且能把本用例与 Task 7(HITL)的闸门收窄彻底解耦(非书信体的语法计数属 Task 7,此处不碰)。
+# 该文本无任何**格式/字数族 error**(称呼语合法、正文首词小写、结语无逗号)
+# ⇒ 不接 nlp 时 v_0 == 0(这是本用例能判死"没接线"的前提)。
+_GRAMMAR_LETTER_TEXT = "Liebe Maria,\nich habe ein Hund.\nViele Grüße\nAnna"
+
+
+def _real_nlp_or_skip() -> Any:
+ """加载真模型(沿用 `tests/test_writing_rules.py` 的 nlp fixture 惯例);缺失 ⇒ skip。"""
+ import spacy
+
+ try:
+ return spacy.load("de_core_news_sm")
+ except OSError:
+ pytest.skip("de_core_news_sm not installed")
+
+
+def test_ai_polish_endpoint_counts_grammar_violations_on_letter_text(client, monkeypatch):
+ """★ 评审 RED#1 闭环点:端点级接线回归 —— 生产链路 MUST 把语法裁判真的送进循环。
+
+ 用**书信体 + 冠词格错**文本(书信体 ⇒ 闸门不豁免 ⇒ 语法违规照常计数):
+ 桩模型**不修**该冠词错 ⇒ v_0 == v_1 == 1 ⇒ `no_improvement`(断言形态①:语法违规真被计数)。
+ 若端点**不注入** `judge=`(退回内核默认裁判,不带 nlp)⇒ 语法违规静默消失,而该书信体文本
+ 又无格式 error ⇒ v_0 == v_1 == 0 ⇒ **首轮即收敛** ⇒ 本用例 MUST 红(这就是 dead-green 的判别力)。
+ """
+ nlp = _real_nlp_or_skip()
+ _stub_api_key(monkeypatch)
+ # 桩模型只改结尾措辞、**不修**冠词错 ⇒ 裁判应判「改不动」
+ chat = _ScriptedChat(
+ [{"corrected_text": "Liebe Maria,\nich habe ein Hund und freue mich.\nViele Grüße\nAnna", "notes_zh": ["x"]}]
+ )
+ monkeypatch.setattr("delector.routes.main.llm_client.chat", chat)
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: nlp)
+
+ result = client.post("/api/writing/ai-polish", json={"text": _GRAMMAR_LETTER_TEXT}).json()["result"]
+
+ assert result["converged"] is False, "带冠词格错的书信体文本不该被判为已收敛"
+ assert result["unconverged_reason"] == "no_improvement", (
+ "语法违规 MUST 被计数(否则端点没把语法裁判送进循环 = dead-green)"
+ )
+ assert result["judge_degraded"] is False, "引擎可用 ⇒ judge_degraded 必须为 False"
+
+
+def test_ai_polish_endpoint_reports_degraded_when_no_nlp(client, monkeypatch):
+ """引擎不可用 ⇒ 循环**照常收敛**(运行期 fail-open),但 `judge_degraded` MUST 如实为 True。
+
+ 红线 1:不得让「没检查」看起来像「检查通过」⇒ 声明层 fail-closed 靠这个布尔透出。
+ """
+ _stub_api_key(monkeypatch)
+ chat = _ScriptedChat([{"corrected_text": "Ich habe einen Hund. Er ist gut.", "notes_zh": ["x"]}])
+ monkeypatch.setattr("delector.routes.main.llm_client.chat", chat)
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: None)
+ monkeypatch.setattr("delector.nlp_engine.processor.try_load_nlp_no_download", lambda: None)
+
+ result = client.post("/api/writing/ai-polish", json={"text": _ESSAY_TEXT}).json()["result"]
+
+ assert result["judge_degraded"] is True
+ # 降级不阻止收敛(fail-open):非书信体 + 无语法裁判 ⇒ 首轮即接受
+ assert result["converged"] is True
+
+
+def test_ai_polish_endpoint_surfaces_advisories_without_blocking_convergence(client, monkeypatch):
+ """★ Task 6 HITL 收尾(B 维 advisory 可见性):`judge_advisories` MUST 到达响应体,
+ 且 advisory **不阻断收敛** —— 判定与「去掉该句」逐字段相同。
+
+ 判别力(变异自证①):把 advisory 的 level 改成 error ⇒ 它翻进 error 计数 ⇒
+ v_0 == v_1 == 1 ⇒ no_improvement ⇒ 本用例的 `converged is True` 与
+ `unconverged_reason is None` 断言 MUST 红。
+ """
+ nlp = _real_nlp_or_skip()
+ _stub_api_key(monkeypatch)
+ text_with = "Gestern gehe ich zur Schule."
+ text_without = "Ich gehe zur Schule."
+
+ # 桩模型「不修任何东西」:候选 = 原文 ⇒ 本用例只观察判定不受 advisory 影响。
+ chat_with = _ScriptedChat([{"corrected_text": text_with, "notes_zh": []}])
+ monkeypatch.setattr("delector.routes.main.llm_client.chat", chat_with)
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: nlp)
+ res_with = client.post("/api/writing/ai-polish", json={"text": text_with}).json()["result"]
+
+ chat_without = _ScriptedChat([{"corrected_text": text_without, "notes_zh": []}])
+ monkeypatch.setattr("delector.routes.main.llm_client.chat", chat_without)
+ res_without = client.post("/api/writing/ai-polish", json={"text": text_without}).json()["result"]
+
+ assert res_with["judge_advisories"], "B 维 advisory MUST 到达响应体(否则 warning 是 no-op)"
+ assert all(isinstance(a, str) for a in res_with["judge_advisories"])
+ assert any("过去" in a for a in res_with["judge_advisories"]), (
+ f"advisory 文案应解释时间锚与时态冲突: {res_with['judge_advisories']}"
+ )
+ assert res_without["judge_advisories"] == [], "对照组不得有 advisory(防断言恒真)"
+ assert res_with["converged"] is True and res_with["unconverged_reason"] is None
+ assert res_with["judge_degraded"] is False
+ # 「与去掉该句逐字相同」:advisory 不得进 error 计数、不得改变停止策略。
+ assert (res_with["error_count"], res_with["converged"], res_with["unconverged_reason"]) == (
+ res_without["error_count"],
+ res_without["converged"],
+ res_without["unconverged_reason"],
+ ), "advisory 影响了判定(警告被当成错误计数?)"
+
+
+def test_ai_polish_response_carries_judge_advisories_contract(client, monkeypatch):
+ """响应契约(只增不改):无 Key 路径也要带 `judge_advisories: []`(前端消费不炸)。"""
+ monkeypatch.setattr("delector.routes.main.get_effective_api_key", lambda *a, **k: "")
+
+ result = client.post("/api/writing/ai-polish", json={"text": "Hallo."}).json()["result"]
+
+ assert result["judge_advisories"] == []
+ assert result["judge_degraded"] is True
+
+
+# ── Task 7:文本族感知的提示词参数化(立论冲突解法 b;业务知识住产品层)──────────
+
+
+def test_pick_system_prompt_letter_vs_prose():
+ """提示词选择:书信体 ⇒ 既有 A1 提示词**逐字**;散文 ⇒ 不含称呼语/结语指令。
+
+ 为什么散文版必须去书信指令(评审黄卡 #2):内核里逐字 A1 书信指令对非书信体会
+ **诱导模型加一个称呼语**,而格式族规则此时已被闸门关掉 ⇒ 污染无守卫。
+ """
+ from delector.routes.main import POLISH_PROSE_SYSTEM_PROMPT, pick_system_prompt
+
+ letter_prompt = pick_system_prompt("letter")
+ prose_prompt = pick_system_prompt("prose")
+
+ assert letter_prompt == polish_loop.POLISH_LOOP_SYSTEM_PROMPT, "书信体 MUST 用既有 A1 提示词(逐字)"
+ assert prose_prompt == POLISH_PROSE_SYSTEM_PROMPT, "散文体 MUST 用散文向提示词(产品层常量)"
+ assert prose_prompt != letter_prompt
+ assert "称呼语" not in prose_prompt and "结语" not in prose_prompt, "散文提示词不得含称呼语/结语指令"
+ assert "逐句润色" in prose_prompt and "保持句子的结构与出现顺序" in prose_prompt, "diff 关键约束 MUST 保留"
+ assert "corrected_text" in prose_prompt, "JSON 契约 MUST 保留(循环按它解析)"
+
+
+def test_run_polish_passes_text_family_aware_system_prompt(monkeypatch):
+ """装配点:`_run_polish` MUST 按文本族把系统提示词**真的送进循环**(否则参数化是死代码)。
+
+ 判别力(变异自证③):`pick_system_prompt` 恒返回 A1 提示词 ⇒ 本用例第一条断言(散文
+ 提示词)MUST 红。
+ """
+ from delector.routes.main import POLISH_PROSE_SYSTEM_PROMPT, _run_polish
+
+ captured: List[Dict[str, Any]] = []
+
+ async def _fake_loop(**kwargs: Any) -> Any:
+ captured.append(kwargs)
+ return polish_loop.LoopResult(text=kwargs["text"], converged=True, reason="ok", rounds=0, violations=[])
+
+ monkeypatch.setattr("delector.services.polish_loop.run_polish_loop", _fake_loop)
+ monkeypatch.setattr("delector.routes.main.get_effective_api_base_url", lambda: "http://stub")
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: None)
+ monkeypatch.setattr("delector.nlp_engine.processor.try_load_nlp_no_download", lambda: None)
+
+ essay = "Ich habe ein Hund. Er ist gut."
+ letter = "Liebe Maria,\nich lade dich ein.\nViele Grüße\nAnna"
+ asyncio.run(_run_polish(essay, "sk-stub"))
+ asyncio.run(_run_polish(letter, "sk-stub"))
+
+ assert captured[0]["system_prompt"] == POLISH_PROSE_SYSTEM_PROMPT, (
+ "非书信体 ⇒ 散文提示词 MUST 真送进循环(参数化不许是死代码)"
+ )
+ assert captured[1]["system_prompt"] == polish_loop.POLISH_LOOP_SYSTEM_PROMPT, "书信体 ⇒ 既有 A1 提示词"
+
+
+def test_writer_js_surfaces_degraded_hint_and_advisories_without_toast():
+ """前端生产者(评审黄卡的闭环):`judge_degraded` / `judge_advisories` MUST 被
+ writer.js 消费,且走 **overlay notes 区的非阻断呈现**(Global Constraints 明说
+ "不是 error toast")。
+
+ 与 `test_frontend_polish_reason_copy_covers_every_reason_code` 同为源码级守卫:
+ 后端加了字段而前端不渲染 ⇒ 字段等于没加(这正是评审黄卡点名的"无生产者")。
+
+ no-key 抑制(评审黄卡 Y3 的闭环):no-key 路径**循环根本没跑**(`_no_key_outcome`
+ 什么都没检查)⇒ 降级提示里"本次仅做格式/字数检查"不实 ⇒ `unconverged_reason ==
+ "no_api_key"` 时 MUST 抑制该提示(no-key 已有自己的 toast / notes 文案,抑制不丢信息)。
+ """
+ src = open(os.path.join(ROOT, "static", "js", "writer.js"), encoding="utf-8").read()
+ assert "judge_advisories" in src, "writer.js MUST 消费 judge_advisories(advisory 可见性)"
+ assert "judge_degraded" in src, "writer.js MUST 消费 judge_degraded(降级可见性)"
+
+ render_block = src[src.index("export function renderPolishReview") : src.index("// ── AI 润色:未收敛原因码")]
+ assert "polishState.judge_advisories" in render_block, "advisory MUST 在 renderPolishReview 里逐条呈现"
+ assert "polishState.judge_degraded" in render_block, "降级提示 MUST 在 renderPolishReview 里呈现"
+ assert "语法检查未启用" in render_block, "降级提示 MUST 是一句可见的人话(非阻断,不是 toast)"
+ assert (
+ "polishState.judge_degraded && polishState.unconverged_reason !== 'no_api_key'" in render_block
+ ), "no-key 路径循环根本没跑 ⇒ 提示 MUST 受 unconverged_reason 守卫(否则'仅做格式/字数检查'不实)"
+
+ # 生产侧(抑制条件的输入):响应装配 MUST 把 unconverged_reason 存进 polishState
+ assign = src[src.index("const { original, corrected, hunks, notes_zh"):]
+ assert "unconverged_reason" in assign[: assign.index("};")], (
+ "polishState MUST 携带 unconverged_reason(no-key 抑制分支的输入)"
+ )
+
+
+def test_get_loaded_nlp_is_read_only(monkeypatch):
+ """`get_loaded_nlp()` 只读已加载模型:未解析/加载失败 ⇒ None,且**绝不触发加载**。"""
+ from delector.nlp_engine import processor
+
+ monkeypatch.setattr(processor, "_nlp_resolved", False)
+ monkeypatch.setattr(processor, "_nlp_model", None)
+ calls = {"n": 0}
+
+ def _spy(name: str) -> Any:
+ calls["n"] += 1
+ raise RuntimeError("不应被调用")
+
+ monkeypatch.setattr(processor, "_load_spacy_model", _spy)
+
+ assert processor.get_loaded_nlp() is None
+ assert calls["n"] == 0, "get_loaded_nlp 不得触发任何加载"
+
+ sentinel = object()
+ monkeypatch.setattr(processor, "_nlp_resolved", True)
+ monkeypatch.setattr(processor, "_nlp_model", sentinel)
+ assert processor.get_loaded_nlp() is sentinel
+
+
+def test_try_load_nlp_no_download_loads_locally(monkeypatch):
+ """本地能加载 ⇒ 写入既有缓存(`_nlp_model` / `_nlp_resolved=True`)并返回模型。"""
+ from delector.nlp_engine import processor
+
+ sentinel = object()
+ monkeypatch.setattr(processor, "_nlp_resolved", False)
+ monkeypatch.setattr(processor, "_nlp_model", None)
+ monkeypatch.setattr(processor, "_load_spacy_model", lambda name: (sentinel, f"{name}(stub)"))
+
+ assert processor.try_load_nlp_no_download() is sentinel
+ assert processor.get_loaded_nlp() is sentinel
+
+
+def test_try_load_nlp_no_download_never_downloads(monkeypatch):
+ """★ 变异守卫:本地加载全失败 ⇒ MUST 返回 None,且**绝不**触发 `spacy.cli.download`。
+
+ 把 `try_load_nlp_no_download` 改成会走 download 的版本 ⇒ 本地加载既已失败,爆炸桩
+ `spacy.cli.download` 会被命中 ⇒ 本用例红线。
+ """
+ from spacy import cli as spacy_cli
+
+ from delector.nlp_engine import processor
+
+ monkeypatch.setattr(processor, "_nlp_resolved", False)
+ monkeypatch.setattr(processor, "_nlp_model", None)
+
+ def _boom(name: str) -> Any:
+ raise RuntimeError("no local model")
+
+ monkeypatch.setattr(processor, "_load_spacy_model", _boom)
+
+ def _bomb(*args: Any, **kwargs: Any) -> Any:
+ raise AssertionError("try_load_nlp_no_download 绝不能触发 spacy.cli.download(出网 + pip)")
+
+ monkeypatch.setattr(spacy_cli, "download", _bomb)
+
+ assert processor.try_load_nlp_no_download() is None, "本地失败 ⇒ 诚实返回 None(不抛、不下载)"
+ assert processor.get_loaded_nlp() is None, "失败 ⇒ 不写任何全局"
+
+
def test_ai_polish_runs_tool_hooks_on_the_real_path(client, monkeypatch, caplog):
"""前置钩子链 MUST 在**真链路**上被跑过(不是只在单测里):审计留痕 + 证据回喂。"""
import logging
@@ -6929,6 +7214,39 @@ async def send(message: Dict[str, Any]) -> None:
assert all(e.get("event") != "result" for e in snap["frames"]), "已断开就不该再产出完整 result"
+def test_ai_polish_stream_error_frame_carries_llm_detail(client, monkeypatch):
+ """接缝③:流已开头 ⇒ 上游 LLMError MUST 以 **error 帧**抵达用户(`e.detail` 人话)。
+
+ 事实链:`_run_polish` 捕获 `llm_client.LLMError` 后 `raise HTTPException(502, detail=e.detail)`
+ ⇒ 流层原本写的 `except llm_client.LLMError` **永不命中** ⇒ error 帧永不发出 ⇒ 用户只拿到
+ 前端那句通用「润色流结束但没有返回结果」。修法选 **(b) 流层捕获 `HTTPException`**:
+ `_run_polish` 的契约对两种调用方保持同一形状(失败 ⇒ HTTPException(502)),流已经开了头
+ 改不了状态码,只能在流内用 error 帧把人话送出去;前端 `writer.js` 也早已按
+ `ev.event === 'error'` / `ev.detail` 消费。
+ """
+ from delector.services import llm_client
+
+ _stub_api_key(monkeypatch)
+ # 裁判引擎 deliberately 置为不可用:本条只关心「错误怎么抵达用户」,
+ # 让 chat 第一轮就抛,别把时间花在加载真 spaCy 模型上。
+ monkeypatch.setattr("delector.nlp_engine.processor.get_loaded_nlp", lambda: None)
+ monkeypatch.setattr("delector.nlp_engine.processor.try_load_nlp_no_download", lambda: None)
+
+ async def _chat(**kwargs: Any) -> Dict[str, Any]:
+ raise llm_client.LLMError("DeepSeek 服务暂时不可用 (503)")
+
+ monkeypatch.setattr("delector.routes.main.llm_client.chat", _chat)
+
+ res = client.post("/api/writing/ai-polish/diff", json={"text": _ESSAY_TEXT, "stream": True})
+
+ assert res.status_code == 200
+ events = _parse_sse(res.text)
+ errors = [e for e in events if e.get("event") == "error"]
+ assert errors, f"上游失败 MUST 以 error 帧抵达用户,实际帧:{events}"
+ assert "DeepSeek 服务暂时不可用 (503)" in errors[0]["detail"], f"error 帧 MUST 带 e.detail 人话:{errors[0]}"
+ assert all(e.get("event") != "result" for e in events), "失败路径不该再产出 result 帧"
+
+
def test_frontend_polish_reason_copy_covers_every_reason_code():
"""writer.js 的中文文案 MUST 覆盖 `polish_loop.REASONS` 全部成员 + 端点层 no_api_key。
diff --git a/tests/test_writing_rules.py b/tests/test_writing_rules.py
index 79ccf3c5..8653e71b 100644
--- a/tests/test_writing_rules.py
+++ b/tests/test_writing_rules.py
@@ -351,3 +351,697 @@ def test_analyze_a1_email_capitalized_noun_exemption_and_expanded_valedictions()
diag = analyze_a1_email(email)
rules = [s["rule"] for s in diag["suggestions"]]
assert "a1_valediction_missing" not in rules, f"{v} 应被识别为有效结语"
+
+
+# ── Task 1/2:统一违规形状 + 引擎降级标记 + 接线既有语法算力 ──────────────────
+# 统一违规形状:{"rule": str, "level": "error"|"warning", "message": str, "span": tuple|None}
+
+
+def test_collect_violations_unified_shape_and_error_only():
+ """`collect_violations` 只保留 `level=="error"` 并给出统一形状(warning 不阻止收敛)。"""
+ from delector.services.writing import collect_violations
+
+ analysis = {
+ "violations": [
+ {"rule": "a1_greeting_missing", "level": "error", "message": "缺称呼语", "span": None},
+ {"rule": "a1_word_count", "level": "warning", "message": "字数偏短", "span": None},
+ {"rule": "artikel", "level": "error", "message": "应为 einen", "span": (4, 13)},
+ ]
+ }
+ out = collect_violations(analysis)
+ assert [v["rule"] for v in out] == ["a1_greeting_missing", "artikel"], "warning 不得入违规"
+ assert set(out[0].keys()) == {"rule", "level", "message", "span"}
+ assert out[1]["span"] == (4, 13)
+
+
+def test_collect_violations_legacy_suggestions_fallback_error_only():
+ """无新键 `violations` ⇒ 回退 legacy `suggestions`,且同样只取 `level=="error"`。"""
+
+ from delector.services.writing import collect_violations
+
+ analysis = {
+ "suggestions": [
+ {"rule": "a1_greeting_missing", "level": "error", "message": "缺称呼语"},
+ {"rule": "a1_word_count", "level": "warning", "message": "字数偏短"},
+ ]
+ }
+ out = collect_violations(analysis)
+ assert [v["rule"] for v in out] == ["a1_greeting_missing"]
+ assert out[0]["span"] is None
+
+
+def test_collect_violations_prefers_new_key_over_legacy():
+ """新键 `violations` 存在时**优先**新键(legacy 不再被读)。"""
+ from delector.services.writing import collect_violations
+
+ analysis = {
+ "violations": [{"rule": "kasus", "level": "error", "message": "m", "span": (0, 3)}],
+ "suggestions": [{"rule": "a1_greeting_missing", "level": "error", "message": "legacy"}],
+ }
+ assert [v["rule"] for v in collect_violations(analysis)] == ["kasus"]
+
+
+def test_collect_violations_empty_analysis():
+ from delector.services.writing import collect_violations
+
+ assert collect_violations({}) == []
+ assert collect_violations({"violations": []}) == []
+
+
+def test_analyze_a1_email_judge_engine_rules_only_without_nlp():
+ """无 nlp ⇒ 只跑格式族,`judge_engine="rules_only"`(语法族不产违规)。"""
+ from delector.services.writing import analyze_a1_email
+
+ diag = analyze_a1_email("Liebe Maria,\nich lade dich ein.\nViele Grüße,\nAnna")
+ assert diag["judge_engine"] == "rules_only"
+ assert all(v["rule"].startswith("a1_") for v in diag["violations"]), "无 nlp 时不应产语法违规"
+
+
+def test_analyze_a1_email_judge_engine_rules_plus_spacy(nlp):
+ """nlp 可用且语法族跑过检测器 ⇒ `judge_engine="rules+spacy"`,语法违规并入 `violations`。"""
+ from delector.services.writing import analyze_a1_email
+
+ diag = analyze_a1_email("Ich habe ein Hund.", nlp=nlp)
+ assert diag["judge_engine"] == "rules+spacy"
+ gram = [v for v in diag["violations"] if v["rule"] == "artikel"]
+ assert len(gram) == 1
+ assert gram[0]["level"] == "error"
+ assert gram[0]["span"] == (9, 17)
+ assert "einen" in gram[0]["message"]
+
+
+def test_analyze_a1_email_existing_keys_unchanged(nlp):
+ """只增不改:既有的 `suggestions` / `greeting` / `valediction` 等内容逐字不动。"""
+ from delector.services.writing import analyze_a1_email
+
+ text = "Liebe Maria,\nich lade dich ein.\nViele Grüße,\nAnna"
+ diag = analyze_a1_email(text)
+ assert [(s["rule"], s["level"]) for s in diag["suggestions"]] == [
+ ("a1_word_count", "warning"),
+ ("a1_valediction_comma", "error"),
+ ]
+ assert set(diag["suggestions"][0].keys()) == {"rule", "level", "message"}
+ assert diag["greeting"]["valid"] is True and diag["greeting"]["has_comma"] is True
+ assert diag["valediction"]["valid"] is True
+ assert diag["word_count"] == 9 and diag["word_count_status"] == "too_short"
+ # 并列新键:格式族进入统一形状(无 nlp ⇒ 只格式族;键集与建议一致地多一个 span)
+ assert [v["rule"] for v in diag["violations"]] == ["a1_word_count", "a1_valediction_comma"]
+ assert all(set(v.keys()) == {"rule", "level", "message", "span"} for v in diag["violations"])
+
+
+def test_analyze_a1_email_wires_grammar_and_preposition(nlp):
+ """负样本接线:冠词-名词一致 / 介词支配格 各报 1 条(经统一抽取器可见)。"""
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ letter_bad_artikel = "Liebe Maria,\nich habe ein Hund.\nViele Grüße\nAnna"
+ v1 = collect_violations(analyze_a1_email(letter_bad_artikel, nlp=nlp))
+ assert [v["rule"] for v in v1] == ["artikel"] and v1[0]["level"] == "error"
+
+ letter_bad_kasus = "Liebe Maria,\nich fahre mit den Bus.\nViele Grüße\nAnna"
+ v2 = collect_violations(analyze_a1_email(letter_bad_kasus, nlp=nlp))
+ assert [v["rule"] for v in v2] == ["kasus"] and v2[0]["level"] == "error"
+
+
+def test_official_a1_samples_have_no_errors_under_wired_judge(nlp):
+ """硬门(D3):官方 10 篇范文在接线后的裁判下 MUST 零**error** 级违规。
+
+ 连官方范文都报错 ⇒ 规则过严 ⇒ `v` 恒 > 0 ⇒ 循环永不收敛。故这是每加一条规则都要过的门。
+ """
+ from delector.data import a1_writing_dict
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ samples = a1_writing_dict.A1_SCHREIBEN_TEIL2
+ assert len(samples) >= 10
+ for ex in samples:
+ diag = analyze_a1_email(ex["sample_email"], nlp=nlp)
+ got = collect_violations(diag)
+ assert got == [], f"官方范文被报违规(规则过严): {ex['id']} -> {got}"
+ assert diag["judge_engine"] == "rules+spacy"
+
+
+# ── Task 3:主谓一致(Number 一致)───────────────────────────────────────────
+
+
+def _sent_spans(text, nlp, fn):
+ """按句切分、逐句调用检测器(与 analyze_essay_text / analyze_a1_email 的 base 口径一致)。"""
+ doc = nlp(text)
+ spans = []
+ for sent in doc.sents:
+ toks = list(sent)
+ if not toks:
+ continue
+ spans.extend(fn(toks, toks[0].idx))
+ return spans
+
+
+def _numerus(text, nlp):
+ from delector.services.writing import detect_subject_verb_agreement
+
+ return _sent_spans(text, nlp, detect_subject_verb_agreement)
+
+
+def test_numerus_wrong_number_flagged(nlp):
+ """负样本:复数主语 + 单数定式动词 MUST 报 1 条(error_type=numerus)。"""
+ spans = _numerus("Die Kinder spielt im Garten.", nlp)
+ assert len(spans) == 1
+ sp = spans[0]
+ assert set(sp.keys()) == {"severity", "error_type", "corrected_form", "explanation_zh", "start", "end"}
+ assert sp["severity"] == "error"
+ assert sp["error_type"] == "numerus"
+ assert "spielen" in sp["corrected_form"]
+ assert (sp["start"], sp["end"]) == (11, 17), "span 应指向出错的定式动词 spielt"
+ assert "Kinder" in sp["explanation_zh"]
+
+
+def test_numerus_correct_number_clean(nlp):
+ """正样本:数一致 MUST 零误报。"""
+ assert _numerus("Die Kinder spielen im Garten.", nlp) == []
+
+
+def test_numerus_coordinated_subject_guard(nlp):
+ """FP 守卫①(防御性):并列主语(sb 只挂第一个并列项,Number 是整个并列短语的属性)⇒ 不报。
+
+ 注意:当前「只报复数主语+单数动词」判据下**该守卫不可达/防御性**(`Anna und Tom spielen`
+ 的动词天然是复数,本就不会进判定)—— 勿当有效拦截器;此用例仅锁行为不退化,将来放开
+ 反向判定(单数主语+复数动词)时该守卫才可能成为真实拦截。
+ """
+ assert _numerus("Anna und Tom spielen im Garten.", nlp) == []
+ assert _numerus("Ich und du sind Freunde.", nlp) == []
+
+
+def test_numerus_collective_noun_guard(nlp):
+ """FP 守卫②:集合名词(die Familie)在语法上是单数 ⇒ 不报。"""
+ assert _numerus("Meine Familie ist groß.", nlp) == []
+ assert _numerus("Die Familie meiner Frau wohnt in Berlin.", nlp) == []
+
+
+def test_numerus_indefinite_pronoun_guard(nlp):
+ """FP 守卫③:man / 不定代词 / 形式主语 das 的 Number 语义不可靠 ⇒ 不报(宁漏不误)。"""
+ assert _numerus("Man spielt im Garten.", nlp) == []
+ assert _numerus("Das sind meine Eltern.", nlp) == []
+ assert _numerus("Niemand kennt den Mann.", nlp) == []
+
+
+def test_numerus_perfect_tense_guard(nlp):
+ """FP 守卫④:复合时态(hat ... gemacht,Number 落在助动词上)⇒ 不报。"""
+ assert _numerus("Die Kinder haben im Garten gespielt.", nlp) == []
+ assert _numerus("Meine Eltern sind gestern angekommen.", nlp) == []
+
+
+def test_numerus_quantity_phrase_guard(nlp):
+ """FP 守卫⑤:数量词短语作主语(`Zwei Stunden ist genug.` 实测是合法句)⇒ 不报。"""
+ assert _numerus("Zwei Stunden ist genug.", nlp) == []
+ assert _numerus("Zehn Euro ist zu viel.", nlp) == []
+
+
+def test_numerus_infinitive_subject_guard(nlp):
+ """FP 守卫⑥:不定式/动词短语作主语(`Bücher lesen ist schön.`)⇒ 不报。"""
+ assert _numerus("Bücher lesen ist schön.", nlp) == []
+ assert _numerus("Sport machen ist wichtig.", nlp) == []
+
+
+def test_numerus_missing_subject_guard(nlp):
+ """FP 守卫⑦:主语缺失(祈使句 / es gibt / 无 sb 的句子)⇒ 不报。"""
+ assert _numerus("Hilf mir bitte!", nlp) == []
+ assert _numerus("Es gibt viele Leute in der Stadt.", nlp) == []
+ assert _numerus("Heute ist es warm.", nlp) == []
+
+
+def test_numerus_wired_into_a1_judge(nlp):
+ """接线缝(RED#1 教训):numerus 违规 MUST 经 analyze_a1_email 进统一违规形状。"""
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ got = collect_violations(analyze_a1_email("Die Kinder spielt im Garten.", nlp=nlp))
+ numerus_spans = [v for v in got if v["rule"] == "numerus"]
+ assert len(numerus_spans) == 1
+ assert numerus_spans[0]["level"] == "error"
+ assert numerus_spans[0]["span"] is not None
+
+
+# ── Task 4:动词支配格(Rektion)+ 人工词表 A1_VERB_CASES ─────────────────────
+
+
+def _rektion(text, nlp):
+ from delector.services.writing import detect_verb_case_government
+
+ return _sent_spans(text, nlp, detect_verb_case_government)
+
+
+def test_rektion_dative_verb_with_accusative_object_flagged(nlp):
+ """负样本:helfen 支配三格,宾语却是四格形态 ⇒ 报 1 条(实测锚点)。"""
+ spans = _rektion("Ich helfe meinen Bruder.", nlp)
+ assert len(spans) == 1
+ sp = spans[0]
+ assert set(sp.keys()) == {"severity", "error_type", "corrected_form", "explanation_zh", "start", "end"}
+ assert sp["severity"] == "error"
+ assert sp["error_type"] == "rektion"
+ assert sp["corrected_form"] == "meinem Bruder"
+ assert "helfen" in sp["explanation_zh"]
+
+
+def test_rektion_dative_verb_with_dative_object_clean(nlp):
+ """正样本:helfen + 三格 ⇒ 0 条。
+
+ 含 `meiner Schwester` 这个关键 FP 样本:spaCy 把 Fem Dat 的 `meiner` 标成 Gen
+ (同形歧义)—— 该守卫按**表面形式 vs 目标格形式**比对,不依赖 morph.Case 直判。
+ """
+ assert _rektion("Ich helfe meinem Bruder.", nlp) == []
+ assert _rektion("Ich helfe meiner Schwester.", nlp) == []
+
+
+def test_rektion_accusative_verb_flags_dative_object(nlp):
+ """负样本:verstehen 支配四格,宾语却是三格 ⇒ 报。"""
+ spans = _rektion("Ich verstehe der Frage.", nlp)
+ assert len(spans) == 1
+ assert spans[0]["error_type"] == "rektion"
+ assert spans[0]["corrected_form"] == "die Frage"
+
+
+def test_rektion_accusative_verb_with_accusative_object_clean(nlp):
+ """正样本:四格动词 + 四格宾语 ⇒ 0 条。"""
+ assert _rektion("Ich sehe den Mann.", nlp) == []
+ assert _rektion("Ich lese den Brief.", nlp) == []
+
+
+def test_rektion_unknown_verb_guard(nlp):
+ """FP 守卫①:动词不在词表(besuchen)⇒ 不判。"""
+ assert _rektion("Ich besuche meinen Bruder.", nlp) == []
+
+
+def test_rektion_reflexive_guard(nlp):
+ """FP 守卫②:反身结构(sich freuen)⇒ 不判。"""
+ assert _rektion("Ich freue mich auf das Wochenende.", nlp) == []
+
+
+def test_rektion_separable_prefix_guard(nlp):
+ """FP 守卫③:可分前缀动词(an|rufen,分裂前缀 dep=svp)⇒ 不判。"""
+ assert _rektion("Ich rufe meine Mutter an.", nlp) == []
+
+
+def test_rektion_nominalized_verb_guard(nlp):
+ """FP 守卫④:名词化动词(das Lesen)⇒ 不判。"""
+ assert _rektion("Das Lesen ist wichtig.", nlp) == []
+
+
+def test_rektion_ambiguous_two_case_verbs_skipped(nlp):
+ """FP 守卫⑤:词表内标 `Dat+Acc` 的动词(geben/kaufen 等两可支配)⇒ 不判。"""
+ assert _rektion("Ich gebe dem Kind ein Buch.", nlp) == []
+ assert _rektion("Ich kaufe den Mann ein Buch.", nlp) == []
+
+
+def test_rektion_pronoun_object_skipped(nlp):
+ """FP 守卫⑥:代词宾语(dir/ihm,无冠词)⇒ 不判。"""
+ assert _rektion("Ich danke dir.", nlp) == []
+ assert _rektion("Ich helfe ihm.", nlp) == []
+
+
+def test_rektion_plural_object_skipped(nlp):
+ """宁漏不误:复数宾语跳过(Dat Plur 名词还需 -n 词尾,只换冠词给出半对纠正)。"""
+ assert _rektion("Ich helfe den Kindern.", nlp) == []
+ assert _rektion("Ich helfe die Kinder.", nlp) == []
+
+
+def test_rektion_free_dative_and_ambiguous_verbs_skipped(nlp):
+ """A 类 FP(评审实跑锚点):登记进 `Dat+Acc` 的动词 MUST 不判 ——
+
+ 自由第三格(`jdm. die Tür öffnen` / `jdm. etw. bezahlen`)与两可支配(`schmecken`:
+ `Das schmeckt mir.` 三格 与 `Ich schmecke den Wein.` 四格都合法)硬判必误报。
+ """
+ assert _rektion("Ich öffne meinem Bruder die Tür.", nlp) == []
+ assert _rektion("Ich bezahle meinem Vater das Essen.", nlp) == []
+ assert _rektion("Ich schmecke den Wein.", nlp) == []
+
+
+def test_rektion_free_dative_with_strict_accusative_verb_skipped(nlp):
+ """第三类 FP(评审探针实跑锚点):**严格 Acc 动词 + 自由第三格**(`jdm. etw.`)MUST 不报。
+
+ 实测:`Ich lese meinem Bruder das Buch.` / `Ich suche meinem Bruder die Schuhe.` 里 spaCy 给
+ `Bruder` 打 `da`、给四格宾语 `Buch` / `Schuhe` 打 `oa` —— 旧实现把所有 `oa`/`da` 宾语都拿去
+ 和 Acc 目标格比对 ⇒ 合法句被报 `corrected_form='meinen Bruder'`。自由第三格是 A1 教材级
+ 普遍构造(`Ich öffne / lese / suche jdm. etw.`),故:四格位已被 `oa` 宾语占用的 `da` 宾语
+ MUST 不判(判据本体与取舍见 `detect_verb_case_government` docstring)。
+ """
+ for text in [
+ "Ich lese meinem Bruder das Buch.",
+ "Ich suche meinem Bruder die Schuhe.",
+ "Ich lese meinem Bruder die Zeitung.",
+ "Ich suche meiner Schwester den Regenschirm.",
+ ]:
+ assert _rektion(text, nlp) == [], f"自由第三格被误报: {text} -> {_rektion(text, nlp)}"
+
+
+def test_rektion_accusative_verb_single_dative_shaped_object_still_flagged(nlp):
+ """反证据(防上一豁免过宽):四格位**无人占用**时,`da` 宾语 MUST 仍按「格写错」报 1 条。
+
+ 实测(探针):`Ich verstehe der Frage.` 里 spaCy 把宾语打成 `da`(无 `oa` 兄弟)—— 该句是
+ **真实格错**(应为 `die Frage`,既有用例 `test_rektion_accusative_verb_flags_dative_object`
+ 已钉死);`Ich brauche dem Buch.` 同理(应为 `das Buch`)。豁免若按「da 一律不判」实现就会把
+ 这两句一起漏掉,本条锁死「只有四格位被 `oa` 占用时,`da` 才是自由第三格」这条判据。
+ """
+ sp = _rektion("Ich verstehe der Frage.", nlp)
+ assert len(sp) == 1 and sp[0]["corrected_form"] == "die Frage"
+ sp2 = _rektion("Ich brauche dem Buch.", nlp)
+ assert len(sp2) == 1 and sp2[0]["corrected_form"] == "das Buch"
+
+
+def test_rektion_wired_into_a1_judge(nlp):
+ """接线缝(RED#1 教训):rektion 违规 MUST 经 analyze_a1_email 进统一违规形状。"""
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ got = collect_violations(analyze_a1_email("Ich helfe meinen Bruder.", nlp=nlp))
+ rektion_spans = [v for v in got if v["rule"] == "rektion"]
+ assert len(rektion_spans) == 1
+ assert rektion_spans[0]["level"] == "error"
+ assert rektion_spans[0]["span"] is not None
+
+
+# ── Task 5:主句动词位置(V2)────────────────────────────────────────────────
+
+
+def _v2(text, nlp):
+ from delector.services.writing import detect_verb_second_position
+
+ return _sent_spans(text, nlp, detect_verb_second_position)
+
+
+def test_v2_wrong_position_flagged(nlp):
+ """负样本(实测锚点):主语插在 Vorfeld 与定式动词之间 ⇒ 报 1 条。"""
+ spans = _v2("Gestern ich habe gearbeitet.", nlp)
+ assert len(spans) == 1
+ sp = spans[0]
+ assert set(sp.keys()) == {"severity", "error_type", "corrected_form", "explanation_zh", "start", "end"}
+ assert sp["severity"] == "error"
+ assert sp["error_type"] == "wortstellung"
+ assert sp["corrected_form"] == "Gestern habe ich"
+ assert "habe" in sp["explanation_zh"] or "V2" in sp["explanation_zh"]
+
+
+def test_v2_correct_position_clean(nlp):
+ """正样本(实测锚点):单 token / 多 token Vorfeld(PP、ADV+名词)⇒ 0 条。
+
+ `Gestern Abend habe ich gearbeitet.` 与 `Am Montag habe ich Zeit.` 是多 token 前置短语 ——
+ 按 token index 硬判会误报,本实现按「主语是否在动词前 + 其左侧有无独立成分」判定。
+ """
+ assert _v2("Gestern habe ich gearbeitet.", nlp) == []
+ assert _v2("Am Montag habe ich Zeit.", nlp) == []
+ assert _v2("Gestern Abend habe ich gearbeitet.", nlp) == []
+ assert _v2("Die Kinder spielen im Garten.", nlp) == []
+ assert _v2("Ich habe Hunger.", nlp) == []
+ assert _v2("Meine Eltern haben mir ein Buch gekauft.", nlp) == []
+
+
+def test_v2_subject_np_internal_modifier_exempt(nlp):
+ """B 类 FP(评审补充实跑锚点):主语 NP **内部**的副词修饰不算独立 Vorfeld 成分 ⇒ 0 条。
+
+ 实测链:`Meine sehr große Familie wohnt in Berlin.` 里 `sehr`(dep=`mo`)→ `große`(ADJ)
+ → `Familie`(主语)—— 只按 dep 白名单会把 `sehr` 当独立前置成分 ⇒ 误报。
+ """
+ for text in [
+ "Meine sehr große Familie wohnt in Berlin.",
+ "Ein wirklich guter Film läuft heute.",
+ "Meine sehr nette Nachbarin hilft mir oft.",
+ "Zwei sehr schöne Blumen blühen.",
+ "Sehr guter Kaffee kommt aus Brasilien.",
+ ]:
+ assert _v2(text, nlp) == [], f"NP 内部修饰误报: {text} -> {_v2(text, nlp)}"
+
+
+def test_v2_focus_particle_inside_subject_np_exempt(nlp):
+ """第三类 FP(评审探针实跑锚点):**焦点小词**挂在主语 NP 上 ⇒ 不算独立 Vorfeld 成分 ⇒ 0 条。
+
+ 实测链:`Auch → Familie`(1 步到主语)、`Nur → mein(DET) → Bruder`(2 步)—— 两条链上**都没有
+ ADJ**,故旧判据(「必须经过 ADJ」)整类漏掉,把 `Auch` / `Nur` 当独立前置成分 ⇒ 合法句被报
+ `Auch wohnt meine Familie`。豁免判据 = 该词是**封闭焦点小词表**(`_FOCUS_PARTICLES`)成员且
+ head 链 1~2 步进入主语 NP;时间副词 MUST NOT 进表(见下一条用例)。
+ """
+ for text in [
+ "Auch meine Familie wohnt in Berlin.",
+ "Nur mein Bruder kommt heute.",
+ "Auch mein Bruder arbeitet in Berlin.",
+ "Nur meine Schwester wohnt hier.",
+ "Sogar mein Vater kocht heute.",
+ "Auch meine Eltern arbeiten hier.",
+ "Gerade mein Bruder hilft mir oft.",
+ ]:
+ assert _v2(text, nlp) == [], f"焦点小词误报: {text} -> {_v2(text, nlp)}"
+
+
+def test_v2_focus_particles_closed_list_excludes_time_adverbs(nlp):
+ """防退化(焦点小词豁免的边界,MUST NOT 松):时间副词**不得**进 `_FOCUS_PARTICLES`。
+
+ 理由(实测):错误语序 `Gestern ich habe gearbeitet.` 里 `Gestern` 的 dep=`mo`、head 直挂主语
+ —— 与 `Auch meine Familie ...` 的 `Auch` 在 spaCy 里**形态相同**,两者只能靠封闭词表区分。
+ 词表一旦混入时间副词,V2 规则对「状语 + 主语 + 定式动词」这类错误(A1 最高频语序错)永久
+ 失明。本条钉死词表成员口径;行为侧由下方真前置状语负样本锁住。
+ """
+ from delector.services.writing import _FOCUS_PARTICLES
+
+ for adv in ("gestern", "heute", "morgen", "jetzt", "dann", "später", "abends", "früher", "oft"):
+ assert adv not in _FOCUS_PARTICLES, f"时间/频率副词「{adv}」不得进焦点小词表"
+
+
+def test_v2_fronted_adverbial_still_flagged_after_np_guard(nlp):
+ """反证据(防豁免过宽):错误语序里真前置状语(head 链到主语/动词、链上无 ADJ)⇒ MUST 仍报。
+
+ 实测坑:`Gestern ich habe gearbeitet.` 里 spaCy 把 `Gestern` 直接挂到主语 `ich` 上
+ (`Gestern → ich`)—— 「head 链可达主语」单独使用会把它误豁免 ⇒ 豁免因此额外要求
+ 链上经过 ADJ(`sehr → große(ADJ) → Familie`),本条锁死该收紧不回退。
+ """
+ for text in [
+ "Gestern ich habe gearbeitet.",
+ "Heute meine Familie wohnt in Berlin.",
+ "Jetzt ich gehe nach Hause.",
+ "Am Montag ich habe Zeit.",
+ "Sehr ich habe mich gefreut.",
+ "In Berlin meine Familie wohnt.",
+ # 焦点小词豁免(F2)落地后追加:时间副词前置 + 主语插在动词前 = A1 最高频语序错,
+ # 与焦点小词在 spaCy 里同形(head 直挂主语)⇒ MUST 仍全部报(防豁免过宽)。
+ "Morgen ich fahre nach Berlin.",
+ "Heute ich trinke Kaffee.",
+ ]:
+ assert len(_v2(text, nlp)) == 1, f"真前置成分被漏报: {text} -> {_v2(text, nlp)}"
+
+
+def test_v2_subordinate_clause_exempt(nlp):
+ """豁免①从句:从属连词引导的分句里 V2 不成立(实测 `bin`/`regnet` 的 dep=mo)⇒ 0 条。"""
+ assert _v2("Ich bleibe zu Hause, weil ich krank bin.", nlp) == []
+ assert _v2("Wenn es regnet, bleibe ich zu Hause.", nlp) == []
+
+
+def test_v2_question_exempt(nlp):
+ """豁免②疑问句:V1/疑问语序 ⇒ 0 条。"""
+ assert _v2("Hast du Zeit?", nlp) == []
+ assert _v2("Warum hast du keine Zeit?", nlp) == []
+
+
+def test_v2_imperative_exempt(nlp):
+ """豁免③祈使句 ⇒ 0 条。"""
+ assert _v2("Komm bitte her!", nlp) == []
+ assert _v2("Machen Sie bitte das Fenster zu!", nlp) == []
+
+
+def test_v2_coordinated_clause_exempt(nlp):
+ """豁免④并列句:第二分句(`isst` 的 dep=cj)与并列主语 ⇒ 0 条。"""
+ assert _v2("Ich koche Kaffee, und mein Bruder isst Kuchen.", nlp) == []
+ assert _v2("Mein Bruder und ich gehen ins Kino.", nlp) == []
+
+
+def test_v2_quoted_speech_exempt(nlp):
+ """豁免⑤直接引语:引号内的句子(实测 `komme` 的 dep=oc + 引号奇偶双保险)⇒ 0 条。"""
+ assert _v2('Er sagt: "Ich komme morgen."', nlp) == []
+
+
+def test_v2_fragment_exempt(nlp):
+ """豁免⑥标题式片段 / 无主语句(含无人称被动)⇒ 0 条。"""
+ assert _v2("Viele Grüße aus Berlin.", nlp) == []
+ assert _v2("Heute wird viel gearbeitet.", nlp) == []
+
+
+def test_v2_salutation_line_break_not_flagged(nlp):
+ """实测 FP 源(官方范文 email_03/email_06):称呼语被 spaCy 并进正文句 ——
+ `Herr` 会成为第二个 sb(豁免⑥多主语)、或跨行 Vorfeld(豁免⑧换行)⇒ 不报。"""
+ assert _v2("Sehr geehrter Herr Becker,\n\nich interessiere mich sehr für Ihre Wohnung.", nlp) == []
+ assert _v2("Liebe Sarah,\n\nes tut mir leid, aber ich muss am Freitag arbeiten.", nlp) == []
+
+
+def test_v2_wired_into_a1_judge(nlp):
+ """接线缝(RED#1 教训):wortstellung 违规 MUST 经 analyze_a1_email 进统一违规形状。"""
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ got = collect_violations(analyze_a1_email("Gestern ich habe gearbeitet.", nlp=nlp))
+ v2_spans = [v for v in got if v["rule"] == "wortstellung"]
+ assert len(v2_spans) == 1
+ assert v2_spans[0]["level"] == "error"
+ assert v2_spans[0]["span"] is not None
+
+
+# ── 永久硬门(D):≥20 句合法德语 MUST 零(语法族)违规 ────────────────────────
+# 背景:官方 10 篇范文门(test_official_a1_samples_have_no_errors_under_wired_judge)放过
+# 三类实跑误报 —— ① 主语 NP 内副词修饰(`Meine sehr große Familie...` 报 wortstellung)
+# ② 自由第三格/两可动词(`Ich öffne meinem Bruder die Tür.` 报 rektion)
+# ③ 焦点小词(`Auch meine Familie wohnt in Berlin.` 报 wortstellung)—— 范文里没有这些句式
+# ⇒ 单靠范文门不足以防 FP。本清单为**永久硬门**:每句都实跑确认零语法族违规后才登记。
+#
+# **类覆盖清单(加句子前 MUST 逐类过一遍 —— 只加句子不检类,门会再次变窄,本轮就是这么漏的)**:
+# ① 主语 NP 内部修饰:副词(`sehr`)/ 形容词(`guter`)/ **分词**(`von mir gekaufte`)/
+# **介词短语**(`im blauen Hemd`)
+# ② 焦点小词(Fokuspartikeln,封闭词表 `_FOCUS_PARTICLES` / `writing.py::_is_inside_subject_np`)
+# ③ 自由第三格 `jdm. etw.`(严格 Acc 动词 + 三格宾语)+ 人三物四(`Dat+Acc` 登记不判)
+# ④ 及物 / 不及物两可(`schmecken`:`Das schmeckt mir.` 与 `Ich schmecke den Wein.`)
+# ⑤ 前置状语 / 介词短语后的正常 V2
+# ⑥ 从句前置的主从复合
+# ⑦ 疑问句 / 祈使句(V1、`?`、Mood=Imp)
+# 新增句子时逐类核对:任一类句式若无在册句子 ⇒ 该类对 FP 失明(本门的真实失败模式)。
+COMMON_CORRECT_SENTENCES: list[str] = [
+ # ① 主语 NP 内修饰 —— 副词/形容词(B 类 FP 覆盖)
+ "Meine sehr große Familie wohnt in Berlin.",
+ "Ein wirklich guter Film läuft heute.",
+ "Meine sehr nette Nachbarin hilft mir oft.",
+ "Zwei sehr schöne Blumen blühen.",
+ "Sehr guter Kaffee kommt aus Brasilien.",
+ "Ganz besonders gute Ideen sind wichtig.",
+ # ① 主语 NP 内修饰 —— 分词 / 介词短语(CRV 指出此前无任何用例的句式)
+ "Die von mir gekaufte Uhr funktioniert.",
+ "Der Mann im blauen Hemd arbeitet hier.",
+ # ② 焦点小词(Fokuspartikeln)—— `Auch` / `Nur` 的 head 直挂主语 NP,链上无 ADJ
+ "Auch meine Familie wohnt in Berlin.",
+ "Nur mein Bruder kommt heute.",
+ "Auch mein Bruder arbeitet in Berlin.",
+ "Nur meine Schwester wohnt hier.",
+ "Sogar mein Vater kocht heute.",
+ "Auch meine Eltern arbeiten hier.",
+ "Gerade mein Bruder hilft mir oft.",
+ # ③ 自由第三格 `jdm. etw.`(严格 Acc 动词 lesen/suchen + 三格宾语)/ 人三物四
+ "Ich öffne meinem Bruder die Tür.",
+ "Ich bezahle meinem Vater das Essen.",
+ "Ich gebe dem Kind ein Buch.",
+ "Ich zeige meiner Freundin die Fotos.",
+ "Ich kaufe meinem Mann ein Buch.",
+ "Ich lese meinem Bruder das Buch.",
+ "Ich suche meinem Bruder die Schuhe.",
+ "Ich lese meinem Bruder die Zeitung.",
+ "Ich suche meiner Schwester den Regenschirm.",
+ # ④ 及物/不及物两可的动词
+ "Ich schmecke den Wein.",
+ "Das schmeckt mir gut.",
+ "Die Suppe schmeckt sehr gut.",
+ "Ich öffne die Tür.",
+ # ⑤ 前置介词短语 / 时间状语后 V2 正常语序
+ "Am Montag habe ich Zeit.",
+ "Gestern Abend habe ich gearbeitet.",
+ "Am Wochenende fahre ich nach Berlin.",
+ "Heute Morgen trinke ich Kaffee.",
+ # ⑥ 从句在前的主从复合
+ "Weil ich müde bin, gehe ich schlafen.",
+ "Wenn es regnet, bleibe ich zu Hause.",
+ "Wenn ich Zeit habe, komme ich zu dir.",
+ "Obwohl es kalt ist, gehe ich spazieren.",
+ # ⑦ 疑问句 / 祈使句
+ "Kannst du mir die Tür öffnen?",
+ "Kochst du heute Kaffee?",
+ "Öffne bitte die Tür!",
+ # 常见 A1 基础句(回归护栏)
+ "Ich lerne jeden Tag Deutsch.",
+ "Meine Eltern wohnen in Hamburg.",
+ "Der Mann liest ein Buch.",
+ "Die Kinder spielen im Garten.",
+ "Ich trinke morgens Kaffee.",
+ "Sie liest die Zeitung.",
+ "Wir sehen einen Film.",
+ "Er braucht eine Pause.",
+ "Der Film ist wirklich gut.",
+ "Mein Bruder arbeitet in einer Firma.",
+ "Heute ist das Wetter schön.",
+ "Im Sommer fahren wir ans Meer.",
+]
+
+
+def test_common_correct_sentences_grammar_gate(nlp):
+ """永久硬门:`COMMON_CORRECT_SENTENCES` 每句 MUST 零**语法族**违规(nlp fixture 下)。
+
+ 判据口径(对任务原话的客观修正,非放水):清单是裸句(非邮件),格式族
+ `a1_greeting_missing` 对任何无称呼语输入**必然**报 error —— 与语法误报无关 ⇒ 门只断言
+ 语法族(rule 不以 `a1_` 开头),并以 `judge_engine == "rules+spacy"` 证明语法族真跑过
+ (防门空转:将来若接线断开、语法族不跑,本门 MUST 红)。
+ """
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ # 防静默缩水(变异自证③):清单**只增不减** —— 抽掉任一句(含本轮新增 16 句)即红。需要删句时
+ # MUST 在 diff 里写明「该句本身不合法」的理由,并同步下调此下限(删句唯一正当理由)。
+ assert len(COMMON_CORRECT_SENTENCES) >= 51, "硬门清单只增不减(上一轮 35 句 → 本轮 51 句,覆盖七类句式)"
+ for s in COMMON_CORRECT_SENTENCES:
+ diag = analyze_a1_email(s, nlp=nlp)
+ assert diag["judge_engine"] == "rules+spacy", f"语法族未跑(门空转): {s}"
+ got = [v for v in collect_violations(diag) if not v["rule"].startswith("a1_")]
+ assert got == [], f"合法句被语法族误报: {s} -> {got}"
+
+
+# ── Task 6 HITL 收尾(B 维):过去时间锚 × 时态一致性 = advisory(warning)──────────
+# 移植自 `tools/bench_judge_fp.py` 的 B 维探测(该脚本是冻结的标定记录,勿改)。
+# HITL 裁决(用户 2026-10-15):接线为 `level="warning"`(正样本 FP 0/35、负样本 7/7、
+# 对照 0/6 两档阈值均成立,但样本偏小 ⇒ 先 advisory,不阻断收敛)。
+
+
+def _tempus(text, nlp):
+ from delector.services.writing import detect_past_anchor_tense_conflict
+
+ return _sent_spans(text, nlp, detect_past_anchor_tense_conflict)
+
+
+def test_tempus_past_anchor_conflict_is_warning_span(nlp):
+ """负样本:过去时间锚 + 现在时限定动词 MUST 报 1 条,且是 **warning** 级(advisory)。"""
+ spans = _tempus("Gestern gehe ich zur Schule.", nlp)
+ assert len(spans) == 1
+ sp = spans[0]
+ assert set(sp.keys()) == {"severity", "error_type", "corrected_form", "explanation_zh", "start", "end"}
+ assert sp["severity"] == "warning", "B 维是 advisory:severity 必须是 warning(HITL 裁决)"
+ assert sp["error_type"] == "tempus"
+ assert (sp["start"], sp["end"]) == (8, 12), "span 应指向出错的定式动词 gehe"
+ assert "Gestern" in sp["explanation_zh"]
+ assert "过去" in sp["explanation_zh"]
+
+
+def test_tempus_correct_past_sentences_clean(nlp):
+ """正/边界样本:Präteritum / Perfekt / 未来现在时 MUST 零误报(与 bench 对照 0/6 同口径)。"""
+ for text in [
+ "Gestern ging ich zur Schule.",
+ "Gestern habe ich eine Suppe gekocht.",
+ "Letzte Woche bin ich nach Berlin gefahren.",
+ "Gestern kaufte ich ein Buch im Supermarkt.",
+ "Damals wohnte ich in Hamburg.",
+ "Morgen fahre ich nach Berlin.",
+ ]:
+ assert _tempus(text, nlp) == [], f"正确句被误报: {text}"
+
+
+def test_tempus_no_false_advisory_on_known_good_texts(nlp):
+ """FP 守卫(正样本零误报,D3 硬门同口径):官方 10 篇 + 51 句硬门清单 MUST 零 advisory。"""
+ from delector.data import a1_writing_dict
+ from delector.services.writing import analyze_a1_email
+
+ for ex in a1_writing_dict.A1_SCHREIBEN_TEIL2:
+ advs = analyze_a1_email(ex["sample_email"], nlp=nlp)["advisories"]
+ assert advs == [], f"官方范文被报 advisory(误报): {ex['id']} -> {advs}"
+ for s in COMMON_CORRECT_SENTENCES:
+ advs = analyze_a1_email(s, nlp=nlp)["advisories"]
+ assert advs == [], f"合法句被报 advisory(误报): {s} -> {advs}"
+
+
+def test_tempus_advisory_wired_but_not_counted_as_error(nlp):
+ """接线缝:advisory 进并列键 `advisories`(统一形状、level=warning),**不进 error 计数**。
+
+ 判别力(变异自证①):把 tempus 的 severity 改成 error ⇒ 路由翻进 `violations` ⇒
+ `collect_violations` 非空 ⇒ 本用例 `== []` 断言 MUST 红。
+ """
+ from delector.services.writing import analyze_a1_email, collect_violations
+
+ letter = "Liebe Maria,\ngestern gehe ich zur Schule.\nViele Grüße\nAnna"
+ diag = analyze_a1_email(letter, nlp=nlp)
+ assert diag["judge_engine"] == "rules+spacy"
+ tempus = [a for a in diag["advisories"] if a["rule"] == "tempus"]
+ assert len(tempus) == 1
+ assert tempus[0]["level"] == "warning"
+ assert set(tempus[0].keys()) == {"rule", "level", "message", "span"}
+ assert tempus[0]["span"] == (21, 25), "advisory 的 span 与 detect_* 同口径(指向 gehe)"
+ assert collect_violations(diag) == [], "advisory 不得进 error 计数(否则阻断收敛)"
+ assert all(v["rule"] != "tempus" for v in diag["violations"]), "advisory 只住 advisories 键"
diff --git a/tools/bench_judge_fp.py b/tools/bench_judge_fp.py
new file mode 100644
index 00000000..f9ac91b7
--- /dev/null
+++ b/tools/bench_judge_fp.py
@@ -0,0 +1,765 @@
+# -*- coding: utf-8 -*-
+"""A1 裁判候选维度的 FP 标定实验(Task 6 的 AFK 半:**只标定,不接线**)。
+
+为什么存在
+----------
+计划 docs/plans/2026-10-15-a1-judge-expansion.md 的 Task 6 要在两条候选维度
+(情态动词框架语序 / 过去时间锚与时态一致性)上回答一个问题:**把它们接进
+A1 裁判的误报代价有多大**。误报比漏报危险(模型会去"修"一个不存在的错误),
+所以裁决(接线为 error / 降级 warning / 不做)是 HITL,由人拿本脚本产出的
+原始数据拍板 —— **本脚本不下这个结论**。
+
+探测实现只住在本脚本里(计划原文:可不进 writing.py),**不得**接进
+`analyze_a1_email`;它们是"探针"而不是"规则"。
+
+为什么两条探测都刻意绕开 spaCy 的依存连接(实测,2026-10-15,de_core_news_sm)
+------------------------------------------------------------------------------
+错误句 `Ich muss gehen heute nach Hause.`:
+ spaCy: muss=AUX/dep=nk、gehen=VERB/ROOT 且被误标 VerbForm=Fin(本应为 Inf);
+ 正确句 `Ich muss heute nach Hause gehen.` 下同一对词是 muss=ROOT、gehen=oc/Inf。
+错误句 `Sie muss machen die Hausaufgaben heute.`:
+ spaCy: muss=ADV/dep=mo(连动词词性都错了)、machen=VERB/ROOT。
+错误句 `Du kannst helfen mir bei der Hausaufgabe.`:
+ spaCy: kannst=VERB/dep=sb(荒谬)、helfen=VERB/ROOT/Inf。
+范文 email_05 里 `kannst` 的 lemma 是垃圾值 "kannstn";范文 email_06 里
+`muss` 被标 Tense=Past。且整篇解析与单句解析不一致("Kannst du ... mitbringen?"
+整篇=VERB/ROOT、单句=PROPN/ROOT)。
+
+⇒ A 维探测用「情态动词表面形式表 + 位置 + 实词 POS 检查」、B 维用「时间锚词表 +
+morph 时态」——都不依赖 dep 边的正确性。这不改变一个事实:探测在真实文本上的
+FP 率由本脚本的样本集来量化。
+
+样本集
+------
+- 正样本:`delector/data/a1_writing_dict.py` 的 `A1_SCHREIBEN_TEIL2` 全部 10 篇
+ 官方 sample_email(期望误报 = 0)。
+- 负样本:每维 ≥5 句手工定向错误句 + 正确对照组(逐句标注期望;对照组期望
+ "不报" —— 没有对照组,FP/FN 无法分开)。
+- 切句为确定性标点规则 `(?<=\\D[.!?])\\s+`(数字缩写如 "15." 不切):与 spaCy
+ senter 的实测差异仅 1 处(email_06 落款 "Liebe Grüße\\nJulia" 多切一段
+ 无动词碎片,不影响这两维的判定),换来"切句零模型依赖 + 口径固定为单句解析"。
+
+计数口径与裁决
+--------------
+per_candidate[i] = ``{name, fp, fn, fp_samples, fn_samples}``:
+- ``fp`` = 正样本句误报数 + 对照组句误报数(两类都进 fp_samples,带来源前缀);
+- ``fn`` = 定向错误句漏报数(进 fn_samples,带来源前缀)。
+``verdict`` 只给"实测值与建议阈值的对照"(error 档 / warning 档的条件是否
+成立),**不写"建议做/不做"** —— 裁决归 HITL。
+另:A 维附**样本外追补**(OUT_OF_SAMPLE_A,3 句逗号并列正确句;2026-10-15 评审
+追补实测 3/3 被 A 维误报)——**不入任何计数**:原文与 live 结果打印在 CLI
+「样本外追补(不入计数)」区块,结论(含 warning 档翻档影响)并入 A 维
+``verdict``。防止产物读者以冻结表的 0 误报外推出"A 维真实文本 FP≈0"。
+
+退出码
+------
+0 = 标定完成;1 = 样本集自身无效(空样本 / 标注缺失 / JSON 结构错误);
+2 = 环境不可用(spaCy 未安装 / 德语模型加载失败;**绝不**触发 ``spacy.cli.download`` ——
+与 ``processor.try_load_nlp_no_download`` 同纪律)。
+
+可复跑:同输入同输出(无随机、无时间、无网络;切句为确定性正则)。
+"""
+
+from __future__ import annotations
+
+import argparse
+import io
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, TypedDict
+
+# 允许从任意 CWD 直接 `python tools/bench_judge_fp.py` 运行(同 tools/ 其余脚本约定)。
+# 注意:这里**只**注入路径、不 import 仓库模块 —— 仓库 import 全部延迟到函数内
+# (import 期零重依赖、零副作用;E402 也无从触发)。
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _REPO_ROOT not in sys.path:
+ sys.path.insert(0, _REPO_ROOT)
+
+# --------------------------------------------------------------------------- #
+# 类型与常量
+# --------------------------------------------------------------------------- #
+
+#: 四分类标签:TP=期望报且报;FN=期望报却漏;FP=期望不报却报;TN=期望不报且没报。
+OutcomeLabel = Literal["TP", "TN", "FP", "FN"]
+
+#: 模型候选顺序**复制自** processor.SPACY_MODEL_CANDIDATES(不 import:本脚本
+#: 不应拉起 delector 的依赖链;顺序不一致会让标定口径和产品漂移,改这里时必须
+#: 同步 `delector/nlp_engine/processor.py:23`)。
+MODEL_CANDIDATES: Tuple[str, ...] = ("de_core_news_md", "de_core_news_sm")
+
+#: verdict 的建议阈值(**脚本算"条件是否成立",不替人裁决**):
+#: error 档 = 零误报(正样本句 + 对照组句)且零漏报;warning 档 = 各容忍少量。
+ERROR_POS_FP_MAX = 0
+ERROR_CTRL_FP_MAX = 0
+ERROR_FN_MAX = 0
+WARN_POS_FP_MAX = 1
+WARN_CTRL_FP_MAX = 1
+WARN_FN_MAX = 2
+
+#: 确定性切句:句末标点后跟空白才切;`\\D` 防 "vom 15. bis 18. August" 这类
+#: 数字缩写被切碎(实测:不加它会切出 "vom 15." / "bis 18." 碎片)。
+_SENT_BOUNDARY = re.compile(r"(?<=\D[.!?])\s+")
+
+
+@dataclass(frozen=True)
+class LabeledSentence:
+ """负样本条目:逐句标注期望(``expected=None`` = 标注缺失 ⇒ 样本集无效)。"""
+
+ text: str
+ expected: Optional[bool]
+ note: str = ""
+
+
+@dataclass(frozen=True)
+class Candidate:
+ """一条候选维度:名字 + 描述 + 句级探测(True = 报违规)。"""
+
+ name: str
+ description: str
+ detect: Callable[[str], bool]
+
+
+@dataclass(frozen=True)
+class SentenceOutcome:
+ """一个句子的评估结果(明细,供 CLI 打印与 fold 折叠)。"""
+
+ label: OutcomeLabel
+ kind: Literal["positive", "negative"]
+ source: str
+ expected: bool
+ reported: bool
+ text: str
+
+
+class CandidateResult(TypedDict):
+ """单条候选的标定计数(计划 Task 6 固定的 Report 形状)。"""
+
+ name: str
+ fp: int
+ fn: int
+ fp_samples: List[str]
+ fn_samples: List[str]
+
+
+class Report(TypedDict):
+ """标定报告:per_candidate 计数 + verdict(只含量与阈值对照,不含裁决)。"""
+
+ per_candidate: List[CandidateResult]
+ verdict: Dict[str, str]
+
+
+# --------------------------------------------------------------------------- #
+# 探测实现(候选维度 A/B —— 只住脚本里,不接线)
+# --------------------------------------------------------------------------- #
+
+# 维度 A:情态动词表面形式表。为什么用"表面形式"而不是 lemma:
+# spaCy 的德语 lemma 实测会产出垃圾(kannst→kannstn、muss→Muss),不可依赖。
+# 覆盖六大情态动词的现在时/过去时/第二虚拟式全部常见变位(60+ 形式)。
+_MODAL_SURFACE_FORMS = frozenset({
+ # können
+ "kann", "kannst", "könnt", "können", "konnte", "konntest", "konnten", "konntet",
+ "könnte", "könntest", "könnten", "könntet",
+ # müssen
+ "muss", "musst", "müsst", "müssen", "musste", "musstest", "mussten", "musstet",
+ "müsste", "müsstest", "müssten", "müsstet",
+ # wollen
+ "will", "willst", "wollt", "wollen", "wollte", "wolltest", "wollten", "wolltet",
+ # dürfen
+ "darf", "darfst", "dürft", "dürfen", "durfte", "durftest", "durften", "durftet",
+ "dürfte", "dürftest", "dürften", "dürftet",
+ # sollen
+ "soll", "sollst", "sollt", "sollen", "sollte", "solltest", "sollten", "solltet",
+ # mögen(含 möchte 全系列;mochte 系列兼收无变音符号的 möchte 误拼)
+ "mag", "magst", "mögt", "mögen", "mochte", "mochtest", "mochten", "mochtet",
+ "möchte", "möchtest", "möchten", "möchtet",
+})
+
+# 维度 A:触发"框架被拆开"的实词 POS(功能词/标点/动词本身都不算)。
+_CONTENT_POS = frozenset({"NOUN", "PROPN", "PRON", "ADJ", "ADV", "NUM"})
+
+# 维度 B:过去时间锚(决定性锚定过去的副词;含无变音符号转写)。
+_PAST_ANCHOR_ADVERBS = frozenset({
+ "gestern", "vorgestern", "damals", "früher", "frueher",
+ "neulich", "kürzlich", "kuerzlich", "ehemals",
+})
+# 维度 B:letzte/letzten/... + 时间名词 才是锚(单独 "letzte" 可能是"末班车")。
+_LETZTE_FORMS = frozenset({"letzte", "letzten", "letzter", "letztes"})
+_TIME_NOUNS = frozenset({
+ "woche", "wochen", "monat", "monaten", "jahr", "jahren",
+ "wochenende", "mal", "sommer", "winter", "tag", "tagen",
+})
+# 维度 B:现在时形式的完成时助动词(haben/sein)——句中存在 Partizip II 时豁免。
+# 例:`Gestern habe ich eine Suppe gekocht.` 的 habe 是现在时形态但整体是过去语义。
+_PERFECT_AUX_PRES = frozenset({"habe", "hast", "hat", "haben", "habt", "bin", "bist", "ist", "sind", "seid"})
+
+# spaCy 惰性加载的进程内缓存(import 期不加载、不联网;加载失败不写缓存)。
+_NLP: Any = None
+_NLP_MODEL_NAME: str = ""
+
+
+def _load_nlp() -> Any:
+ """惰性加载德语模型(缓存)。spaCy 缺失 / 全部候选失败一律抛 RuntimeError;**绝不下载**。"""
+ global _NLP, _NLP_MODEL_NAME
+ if _NLP is not None:
+ return _NLP
+ try:
+ import spacy # 惰性:import 期不引入 spacy(本脚本直接运行时的启动成本也在意)
+ except ImportError as exc: # 裸外泄会拿到未捕获异常的 rc=1,与"样本集无效"撞语义 ⇒ 归一为 RuntimeError
+ raise RuntimeError(f"spaCy 未安装(本脚本不自动安装、不触发下载):{exc}") from exc
+
+ errors: List[str] = []
+ for name in MODEL_CANDIDATES:
+ try:
+ _NLP = spacy.load(name)
+ _NLP_MODEL_NAME = name
+ return _NLP
+ except Exception as exc: # 该候选加载失败 ⇒ 继续试下一个;全部失败才抛 RuntimeError
+ errors.append(f"{name}: {exc}")
+ raise RuntimeError("德语模型加载失败(不自动下载;请先安装 de_core_news_sm):" + " | ".join(errors))
+
+
+def get_loaded_model_name() -> str:
+ """返回实际生效的模型名(未加载时为空串)。"""
+ return _NLP_MODEL_NAME
+
+
+def probe_environment(load_nlp: Optional[Callable[[], Any]] = None) -> Optional[str]:
+ """环境探测("退出码 2 = 环境不可用"的单一归口):可用返回 None,不可用返回原因文本。
+
+ ``load_nlp`` 仅供测试注入(默认 ``_load_nlp``);同时兜住两类环境故障:
+ spaCy 未安装(ImportError,原始形态)与德语模型加载失败(RuntimeError,
+ 见 ``_load_nlp`` 的归一化)。**绝不**触发 ``spacy.cli.download`` —— 与
+ ``processor.try_load_nlp_no_download`` 同纪律。
+ """
+ loader = _load_nlp if load_nlp is None else load_nlp
+ try:
+ loader()
+ except (RuntimeError, ImportError) as exc:
+ return str(exc)
+ return None
+
+
+def _is_zu_infinitive(tokens: Sequence[Any], index: int) -> bool:
+ """判断 index 处的不定式是否属于 zu-Infinitiv(如 `etwas zu trinken`)。
+
+ zu-Inf 不是情态框架的一部分:`Kannst du ... etwas zu trinken mitbringen?`
+ 里的 trinken 若被误当框架动词,正确范文会被误报 —— 排除它是保 FP=0 的关键。
+ """
+ return index > 0 and tokens[index - 1].pos_ == "PART" and tokens[index - 1].lower_ == "zu"
+
+
+def _detect_modal_frame_order(sentence: str) -> bool:
+ """候选维度 A:主句情态框架(情态动词 + 句末不定式)疑似被拆开 ⇒ True。
+
+ 判据(表面形式 + 位置 + 实词 POS 检查,**刻意不用 dep 连接与 morph**,理由见模块 docstring):
+ 1. 找句中第一个情态动词表面形式 W;
+ 2. 找 W 之后第一个动词 V(跳过 zu-不定式);
+ 3. 若 V 之后(在遇到下一个从句/并列引导词 SCONJ/CCONJ 之前)还有实词
+ (名词/代词/形容词/副词/数词)⇒ 框架动词不在末尾 ⇒ 报。
+
+ 已知边界(未覆盖,如实登记;2026-10-15 实测):
+ - 机制:探测**仅按 SCONJ/CCONJ 断句,不识别从句/并列结构** —— 从句内框架被不被
+ 报,只看实词落在末位动词之后(`..., dass ich muss gehen heute.` 会报)还是夹在
+ 情态动词与末位动词之间(`..., dass ich muss heute arbeiten.` 不报;负样本 err7
+ 即此族,**整族不可见**);
+ - 逗号并列主句(`Ich kann nicht kommen, ich muss arbeiten.`)**会误报**(FP 方向;
+ 第二分句主语 PRON 触发实词检查)。样本外追补原文见 ``OUT_OF_SAMPLE_A``(不入计数)。
+ """
+ doc = _load_nlp()(sentence)
+ tokens = [t for t in doc if not t.is_space]
+ w_index: Optional[int] = None
+ for i, token in enumerate(tokens):
+ if token.lower_ in _MODAL_SURFACE_FORMS:
+ w_index = i
+ break
+ if w_index is None:
+ return False
+ v_index: Optional[int] = None
+ for i in range(w_index + 1, len(tokens)):
+ if tokens[i].pos_ == "VERB" and not _is_zu_infinitive(tokens, i):
+ v_index = i
+ break
+ if v_index is None:
+ return False
+ for token in tokens[v_index + 1:]:
+ if token.pos_ in ("SCONJ", "CCONJ"):
+ break # 从句/并列分句从引导词开始 ≠ 主句 Mittelfeld(`..., weil ...` / `und ...`)
+ if token.pos_ in _CONTENT_POS:
+ return True
+ return False
+
+
+def _has_past_anchor(tokens: Sequence[Any]) -> bool:
+ """句中是否含过去时间锚(副词表 或 letzte-系 + 时间名词)。"""
+ for i, token in enumerate(tokens):
+ if token.lower_ in _PAST_ANCHOR_ADVERBS:
+ return True
+ if token.lower_ in _LETZTE_FORMS and i + 1 < len(tokens) and tokens[i + 1].lower_ in _TIME_NOUNS:
+ return True
+ return False
+
+
+def _detect_past_anchor_tense_conflict(sentence: str) -> bool:
+ """候选维度 B:过去时间锚 + 现在时限定动词(非完成时结构)⇒ True。
+
+ 判据(词表 + morph):
+ 1. 句中含过去时间锚(gestern / letzte Woche / damals ...);
+ 2. 存在 morph VerbForm=Fin 且 Tense=Pres 的动词;
+ 3. 豁免完成时结构:句中存在 Partizip II(VerbForm=Part 的 VERB)时,
+ haben/sein 的现在时形式不触发(`Gestern habe ich ... gekocht.` 是正确句)。
+
+ 已知边界(未覆盖,如实登记):`Gestern muss ich müde gewesen sein.` 这类
+ "情态词 + 完成时不定式"的正确推测句会被误报;`Morgen`/`am Samstag` 等
+ 中性/未来时间表达不在锚表内(`Morgen fahre ich ...` 正确不报)。
+ """
+ doc = _load_nlp()(sentence)
+ tokens = [t for t in doc if not t.is_space]
+ if not _has_past_anchor(tokens):
+ return False
+ has_partizip = any(t.pos_ == "VERB" and "Part" in t.morph.get("VerbForm") for t in tokens)
+ for token in tokens:
+ if "Fin" in token.morph.get("VerbForm") and "Pres" in token.morph.get("Tense"):
+ if has_partizip and token.lower_ in _PERFECT_AUX_PRES:
+ continue
+ return True
+ return False
+
+
+# --------------------------------------------------------------------------- #
+# 样本集:正样本装载 + 负样本(逐句标注期望;错句与正确对照组配对)
+# --------------------------------------------------------------------------- #
+
+
+def load_positives() -> List[Tuple[str, str]]:
+ """装载正样本:A1_SCHREIBEN_TEIL2 全部 10 篇官方 sample_email。
+
+ 返回 ``[(email_id, sample_email), ...]``(顺序 = 数据文件顺序,deterministic)。
+ """
+ from delector.data.a1_writing_dict import A1_SCHREIBEN_TEIL2 # 延迟 import(见模块头)
+
+ return [(str(entry["id"]), str(entry["sample_email"])) for entry in A1_SCHREIBEN_TEIL2]
+
+
+# 负样本集(内置;`--negatives` 可覆盖)。每维 = 定向错误句(expected=True)
+# + 正确对照组(expected=False)。构造依据如下:错误句取"情态框架拆裂"
+# 与"过去锚+现在时"两个高频学生错误族;对照组是修正版 + 边界守卫
+# (从句框架 / 完成时 / 未来现在时)。
+NEGATIVES: Dict[str, Tuple[LabeledSentence, ...]] = {
+ "a_modal_frame_order": (
+ LabeledSentence("Ich muss gehen heute nach Hause.", True, "err1_verb_vorgezogen"),
+ LabeledSentence("Ich kann besuchen dich morgen.", True, "err2_verb_vorgezogen"),
+ LabeledSentence("Wir wollen fahren am Wochenende nach Berlin.", True, "err3_verb_vorgezogen"),
+ LabeledSentence("Er will kaufen ein neues Auto.", True, "err4_verb_vorgezogen"),
+ LabeledSentence("Sie muss machen die Hausaufgaben heute.", True, "err5_verb_vorgezogen"),
+ LabeledSentence("Du kannst helfen mir bei der Hausaufgabe.", True, "err6_verb_vorgezogen"),
+ LabeledSentence("Ich weiß, dass ich muss heute arbeiten.", True, "err7_nebensatz_klammer"),
+ LabeledSentence("Ich muss heute nach Hause gehen.", False, "ctrl1_klammer_korrekt"),
+ LabeledSentence("Ich kann dich morgen besuchen.", False, "ctrl2_klammer_korrekt"),
+ LabeledSentence("Wir wollen am Wochenende nach Berlin fahren.", False, "ctrl3_klammer_korrekt"),
+ LabeledSentence("Er will ein neues Auto kaufen.", False, "ctrl4_klammer_korrekt"),
+ LabeledSentence("Sie muss die Hausaufgaben heute machen.", False, "ctrl5_klammer_korrekt"),
+ LabeledSentence("Du kannst mir bei der Hausaufgabe helfen.", False, "ctrl6_klammer_korrekt"),
+ LabeledSentence("Ich weiß, dass ich heute arbeiten muss.", False, "ctrl7_nebensatz_korrekt"),
+ ),
+ "b_past_anchor_tense_conflict": (
+ LabeledSentence("Gestern gehe ich zur Schule.", True, "err1_praesens_statt_praeteritum"),
+ LabeledSentence("Vorgestern koche ich eine Suppe.", True, "err2_praesens_statt_praeteritum"),
+ LabeledSentence("Letzte Woche fahre ich nach Berlin.", True, "err3_praesens_statt_praeteritum"),
+ LabeledSentence("Gestern kaufe ich ein Buch im Supermarkt.", True, "err4_praesens_statt_praeteritum"),
+ LabeledSentence("Damals wohne ich in Hamburg.", True, "err5_praesens_statt_praeteritum"),
+ LabeledSentence("Letztes Jahr arbeite ich in einer Firma.", True, "err6_praesens_statt_praeteritum"),
+ LabeledSentence("Gestern habe ich eine Suppe koche.", True, "err7_kein_partizip"),
+ LabeledSentence("Gestern ging ich zur Schule.", False, "ctrl1_praeteritum"),
+ LabeledSentence("Gestern habe ich eine Suppe gekocht.", False, "ctrl2_perfekt"),
+ LabeledSentence("Letzte Woche bin ich nach Berlin gefahren.", False, "ctrl3_perfekt"),
+ LabeledSentence("Gestern kaufte ich ein Buch im Supermarkt.", False, "ctrl4_praeteritum"),
+ LabeledSentence("Damals wohnte ich in Hamburg.", False, "ctrl5_praeteritum"),
+ LabeledSentence("Morgen fahre ich nach Berlin.", False, "ctrl6_futur_praesens"),
+ ),
+}
+
+#: A 维候选名(verdict 的样本外追补句与 CLI 追补区块按此名挂钩;同名键出现在
+#: NEGATIVES / CANDIDATES 里,改名时三处必须同步)。
+_A_MODAL_FRAME_NAME = "a_modal_frame_order"
+
+#: A 维**样本外追补集**(**不入冻结集、不入任何计数**)。2026-10-15 评审追补实测:
+#: 逗号并列的两个正确主句被误报 —— 第一分句情态框架的末位动词之后,"第二分句主语
+#: PRON"命中实词检查(机制边界见 _detect_modal_frame_order docstring)。第 1 句为
+#: 评审原记录句;本机 de_core_news_sm 复测 3 句均报 True —— 结果不硬编码,由
+#: _out_of_sample_a_results() 现场实测(CLI 区块与 verdict 追补句同源)。
+#: 此前这些实测量只住 docstring ⇒ 产物无痕迹、读者会误得"A 维真实文本 FP≈0":
+#: 产物必须自带反例(评审 RED 修复)。
+OUT_OF_SAMPLE_A: Tuple[str, ...] = (
+ "Ich kann nicht kommen, ich muss arbeiten.",
+ "Er kann nicht kommen, er muss noch arbeiten.",
+ "Wir können nicht kommen, wir müssen am Wochenende arbeiten.",
+)
+
+
+def _out_of_sample_a_results() -> List[Tuple[str, bool]]:
+ """对 ``OUT_OF_SAMPLE_A`` 跑 A 维探测(live 实测;verdict 追补句与 CLI 区块共用)。"""
+ return [(text, bool(_detect_modal_frame_order(text))) for text in OUT_OF_SAMPLE_A]
+
+
+#: 内置候选(探测实现见上方;接线与否由 HITL 拍板,这里只定义"候选")。
+CANDIDATES: Tuple[Candidate, ...] = (
+ Candidate(
+ name="a_modal_frame_order",
+ description="维度A:主句情态动词框架(情态动词 + 不定式末位)被拆开",
+ detect=_detect_modal_frame_order,
+ ),
+ Candidate(
+ name="b_past_anchor_tense_conflict",
+ description="维度B:过去时间锚 + 现在时限定动词(非完成时结构)冲突",
+ detect=_detect_past_anchor_tense_conflict,
+ ),
+)
+
+
+def load_negatives_from_json(path: str) -> Dict[str, List[LabeledSentence]]:
+ """从 JSON 装载负样本覆盖集(格式:``{维度名: [{text, expected, note?}, ...]}``)。
+
+ 结构错误抛 ValueError(CLI 转退出码 1)。条目缺 expected / 非 bool 一律
+ 记 expected=None ⇒ 由 validate_samples 报"标注缺失"(这正是退出码 1 的
+ 可测路径,见 tests/test_judge_fp_bench.py)。
+ """
+ with open(path, encoding="utf-8") as handle:
+ raw = json.load(handle)
+ if not isinstance(raw, dict):
+ raise ValueError("负样本 JSON 顶层必须是对象({维度名: [条目, ...]})")
+ result: Dict[str, List[LabeledSentence]] = {}
+ for dim, items in raw.items():
+ if not isinstance(items, list):
+ raise ValueError(f"负样本维度 {dim} 的值必须是列表")
+ loaded: List[LabeledSentence] = []
+ for j, item in enumerate(items):
+ if not isinstance(item, dict):
+ raise ValueError(f"负样本维度 {dim} 第 {j + 1} 条必须是对象")
+ text = item.get("text")
+ if not isinstance(text, str):
+ raise ValueError(f"负样本维度 {dim} 第 {j + 1} 条缺 text(或非字符串)")
+ expected_raw = item.get("expected")
+ expected: Optional[bool] = expected_raw if isinstance(expected_raw, bool) else None
+ note = item.get("note")
+ loaded.append(LabeledSentence(text, expected, note if isinstance(note, str) else ""))
+ result[dim] = loaded
+ return result
+
+
+# --------------------------------------------------------------------------- #
+# 校验与评估引擎
+# --------------------------------------------------------------------------- #
+
+
+def _split_sentences(text: str) -> List[str]:
+ """确定性标点切句(不依赖模型;实测与 spaCy senter 的差异见模块 docstring)。"""
+ flat = " ".join(text.split())
+ return [part.strip() for part in _SENT_BOUNDARY.split(flat) if part.strip()]
+
+
+def validate_samples(
+ candidates: Sequence[Candidate],
+ positives: Sequence[str],
+ negatives: Mapping[str, Sequence[LabeledSentence]],
+) -> List[str]:
+ """校验样本集;返回问题列表(空列表 = 有效)。
+
+ 退出码语义:本函数非空 ⇔ CLI 退出码 1("样本集自身无效"),覆盖两类
+ 致命情况:空样本(正样本空 / 某维负样本空)与标注缺失(expected=None)。
+ """
+ errors: List[str] = []
+ if not candidates:
+ errors.append("候选集为空:没有可标定的维度")
+ if not positives:
+ errors.append("正样本为空:无法标定误报(FP)")
+ else:
+ for i, text in enumerate(positives):
+ if not text.strip():
+ errors.append(f"正样本第 {i + 1} 篇为空文本")
+ if not negatives:
+ errors.append("负样本集为空:无法标定漏报(FN)")
+ for candidate in candidates:
+ items = negatives.get(candidate.name)
+ if items is None:
+ errors.append(f"候选 {candidate.name} 缺负样本集(键缺失)")
+ continue
+ if not items:
+ errors.append(f"候选 {candidate.name} 的负样本集为空")
+ continue
+ for j, item in enumerate(items):
+ if not item.text.strip():
+ errors.append(f"{candidate.name} 第 {j + 1} 条负样本为空文本")
+ if item.expected is None:
+ errors.append(f"{candidate.name} 第 {j + 1} 条缺标注(expected 缺失)")
+ return errors
+
+
+def _classify(kind: Literal["positive", "negative"], expected: bool, reported: bool) -> OutcomeLabel:
+ """四分类。正样本句 = 期望不报的 negative 同口径(expected=False)。"""
+ if expected and reported:
+ return "TP"
+ if expected and not reported:
+ return "FN"
+ if (not expected) and reported:
+ return "FP"
+ return "TN"
+
+
+def evaluate_candidate(
+ candidate: Candidate,
+ positives: Sequence[str],
+ negatives: Sequence[LabeledSentence],
+ positive_labels: Optional[Sequence[str]] = None,
+) -> List[SentenceOutcome]:
+ """逐句评估一条候选维度,返回明细(顺序确定:正样本在前、负样本在后)。
+
+ ``positive_labels`` 仅影响明细/样例里的来源标识(CLI 传 email id;
+ 库调用默认 ``positive#N``)。
+ """
+ outcomes: List[SentenceOutcome] = []
+ for i, text in enumerate(positives):
+ source = positive_labels[i] if positive_labels is not None and i < len(positive_labels) else f"positive#{i + 1}"
+ for sentence in _split_sentences(text):
+ reported = bool(candidate.detect(sentence))
+ outcomes.append(
+ SentenceOutcome(_classify("positive", False, reported), "positive", source, False, reported, sentence)
+ )
+ for j, item in enumerate(negatives):
+ if item.expected is None:
+ raise ValueError(f"负样本第 {j + 1} 条缺标注(expected=None):{item.text!r}(先跑 validate_samples)")
+ source = item.note or f"negative#{j + 1}"
+ reported = bool(candidate.detect(item.text))
+ outcomes.append(
+ SentenceOutcome(
+ _classify("negative", item.expected, reported), "negative", source, item.expected, reported, item.text
+ )
+ )
+ return outcomes
+
+
+def _fold(name: str, outcomes: Sequence[SentenceOutcome]) -> CandidateResult:
+ """把明细折叠成 per_candidate 计数(fp/fn + 样例全文)。"""
+ fp_samples = [f"[{o.source}] {o.text}" for o in outcomes if o.label == "FP"]
+ fn_samples = [f"[{o.source}] {o.text}" for o in outcomes if o.label == "FN"]
+ return CandidateResult(
+ name=name, fp=len(fp_samples), fn=len(fn_samples), fp_samples=fp_samples, fn_samples=fn_samples
+ )
+
+
+def _verdict_text(
+ outcomes: Sequence[SentenceOutcome],
+ out_of_sample: Optional[Sequence[Tuple[str, bool]]] = None,
+) -> str:
+ """生成 verdict 文本:只报"实测值 + 建议阈值对照",裁决归 HITL。
+
+ ``out_of_sample``(仅 A 维传入,见 ``OUT_OF_SAMPLE_A``):**不入任何计数**的样本外
+ live 实测;追补句必须同时出现在 text 与 ``--json`` 的 verdict(同一份 Report ⇒ 同源),
+ 防止产物读者以冻结表的 0 误报外推"A 维真实文本 FP≈0"。
+ """
+ pos = [o for o in outcomes if o.kind == "positive"]
+ neg = [o for o in outcomes if o.kind == "negative"]
+ pos_fp = [o for o in pos if o.label == "FP"]
+ neg_err = [o for o in neg if o.expected]
+ neg_ctrl = [o for o in neg if not o.expected]
+ fn = [o for o in neg_err if o.label == "FN"]
+ ctrl_fp = [o for o in neg_ctrl if o.label == "FP"]
+ error_ok = len(pos_fp) <= ERROR_POS_FP_MAX and len(ctrl_fp) <= ERROR_CTRL_FP_MAX and len(fn) <= ERROR_FN_MAX
+ warn_ok = len(pos_fp) <= WARN_POS_FP_MAX and len(ctrl_fp) <= WARN_CTRL_FP_MAX and len(fn) <= WARN_FN_MAX
+ text = (
+ f"实测:正样本误报 {len(pos_fp)}/{len(pos)} 句;负样本定向错误句漏报 {len(fn)}/{len(neg_err)} 句;"
+ f"正确对照组误报 {len(ctrl_fp)}/{len(neg_ctrl)} 句。"
+ f"建议阈值对照:error 档「正样本 FP<={ERROR_POS_FP_MAX} 且 对照组 FP<={ERROR_CTRL_FP_MAX} "
+ f"且 FN<={ERROR_FN_MAX}」⇒ 条件{'成立' if error_ok else '不成立'};"
+ f"warning 档「正样本 FP<={WARN_POS_FP_MAX} 且 对照组 FP<={WARN_CTRL_FP_MAX} 且 FN<={WARN_FN_MAX}」"
+ f"⇒ 条件{'成立' if warn_ok else '不成立'}。"
+ f"裁决(接线为 error / 降级 warning / 不做)归 HITL,本脚本只报量与阈值对照,不下结论。"
+ )
+ if out_of_sample:
+ hits = sum(1 for _text, reported in out_of_sample if reported)
+ table_fp = len(pos_fp) + len(ctrl_fp)
+ text += (
+ f"样本外追补实测(**未入本冻结集**,不计入上面任何计数):逗号并列正确句"
+ f"「{out_of_sample[0][0]}」等 {len(out_of_sample)} 个变体中 {hits} 个报 True"
+ f" ⇒ **真实文本 FP 不得以本表 {table_fp} 外推**"
+ )
+ if len(ctrl_fp) + hits > WARN_CTRL_FP_MAX:
+ text += (
+ f";若将其计入对照组(对照组 FP={len(ctrl_fp)}+{hits}={len(ctrl_fp) + hits}"
+ f" > WARN_CTRL_FP_MAX={WARN_CTRL_FP_MAX})则 warning 档条件亦不成立"
+ )
+ text += "。"
+ return text
+
+
+def _evaluate_all(
+ candidates: Sequence[Candidate],
+ positives: Sequence[str],
+ negatives: Mapping[str, Sequence[LabeledSentence]],
+ positive_labels: Optional[Sequence[str]] = None,
+) -> Tuple[Report, Dict[str, List[SentenceOutcome]]]:
+ """评估全部候选:返回 (Report, 逐句明细)。CLI 用同一份明细打印原始结果(不二次计算)。
+
+ A 维额外跑一次样本外追补 live 实测(``_out_of_sample_a_results``,**不入计数**)
+ 并把它并入该维 verdict(text 与 --json 同源)。
+ """
+ per_candidate: List[CandidateResult] = []
+ details: Dict[str, List[SentenceOutcome]] = {}
+ for candidate in candidates:
+ outcomes = evaluate_candidate(candidate, positives, negatives[candidate.name], positive_labels)
+ details[candidate.name] = outcomes
+ per_candidate.append(_fold(candidate.name, outcomes))
+ verdict: Dict[str, str] = {}
+ for entry in per_candidate:
+ out_of_sample = _out_of_sample_a_results() if entry["name"] == _A_MODAL_FRAME_NAME else None
+ verdict[entry["name"]] = _verdict_text(details[entry["name"]], out_of_sample)
+ report = Report(per_candidate=per_candidate, verdict=verdict)
+ return report, details
+
+
+def measure_candidates(
+ candidates: Sequence[Candidate],
+ positives: Sequence[str],
+ negatives: Mapping[str, Sequence[LabeledSentence]],
+) -> Report:
+ """标定入口(可证伪签名,计划 Task 6):样本集无效时抛 ValueError。
+
+ 返回 ``{per_candidate: [{name, fp, fn, fp_samples, fn_samples}, ...], verdict: {name: str}}``。
+ 环境不可用(spaCy 缺失 / 模型加载失败,见 ``_load_nlp``)由 detect 抛 RuntimeError;
+ CLI 层用 ``probe_environment`` 预加载并把任何环境故障转成退出码 2。
+ """
+ errors = validate_samples(candidates, positives, negatives)
+ if errors:
+ raise ValueError("样本集无效:" + ";".join(errors))
+ report, _details = _evaluate_all(candidates, positives, negatives)
+ return report
+
+
+# --------------------------------------------------------------------------- #
+# CLI
+# --------------------------------------------------------------------------- #
+
+
+def _print_out_of_sample_block() -> None:
+ """CLI 独立区块:A 维样本外追补(**不入计数**)——3 句原文 + live 结果 + err7 族边界。
+
+ 评审 RED 修复:这些实测误报此前只住 docstring ⇒ 以产物为据的读者会误得
+ "A 维真实文本 FP≈0"。结果与 verdict 追补句同源(``_out_of_sample_a_results``)。
+ """
+ results = _out_of_sample_a_results()
+ print()
+ print("--- 样本外追补(不入计数)---")
+ print("逗号并列正确句(**不在**冻结样本集内、**不计入**任何 fp/fn;原文见模块常量 OUT_OF_SAMPLE_A):")
+ for text, reported in results:
+ print(f" [报={'True' if reported else 'False'}] {text}")
+ print("结构性边界(漏报方向,2026-10-15 实测):err7 族(实词夹在情态动词与末位动词之间,")
+ print("如 `Ich weiß, dass ich muss heute arbeiten.`)**整族不可见** —— 探测只扫描末位动词")
+ print("之后的实词;该边界与上列误报同为 HITL 判据的一部分,不允许只住 docstring。")
+
+
+def _print_text_report(
+ report: Report,
+ details: Dict[str, List[SentenceOutcome]],
+ candidates: Sequence[Candidate],
+ positive_docs: int,
+ positive_sentences: int,
+) -> None:
+ """文本模式:逐句原始结果 + 计数 + 样例全文(只报比例无法证伪判据,故必须打全文)。"""
+ descriptions = {candidate.name: candidate.description for candidate in candidates}
+ print("=== bench_judge_fp(A1 裁判候选维度 FP 标定实验;只标定不接线)===")
+ print(f"model={get_loaded_model_name()}")
+ print("positive_source=A1_SCHREIBEN_TEIL2(官方 sample_email,逐句评估)")
+ print(f"positive_docs={positive_docs} positive_sentences={positive_sentences}")
+ for entry in report["per_candidate"]:
+ name = entry["name"]
+ print()
+ print(f"--- candidate: {name} ---")
+ print(f"描述:{descriptions.get(name, '')}")
+ print("逐句结果(TP=期望报且报;FN=期望报却漏;FP=期望不报却报;TN=期望不报且没报):")
+ for outcome in details[name]:
+ print(f" [{outcome.label}] [{outcome.source}] {outcome.text}")
+ print(f"fp={entry['fp']} fn={entry['fn']}")
+ print("误报样例(全文):")
+ if entry["fp_samples"]:
+ for sample in entry["fp_samples"]:
+ print(f" - {sample}")
+ else:
+ print(" (无)")
+ print("漏报样例(全文):")
+ if entry["fn_samples"]:
+ for sample in entry["fn_samples"]:
+ print(f" - {sample}")
+ else:
+ print(" (无)")
+ if name == _A_MODAL_FRAME_NAME:
+ _print_out_of_sample_block()
+ print()
+ print("--- verdict(量与建议阈值对照;裁决归 HITL)---")
+ for name, text in report["verdict"].items():
+ print(f"verdict[{name}]={text}")
+ print()
+ print("退出码:0=标定完成;1=样本集自身无效(空样本/标注缺失);2=环境不可用(spaCy 缺失/模型加载失败)")
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+ """CLI 入口。退出码:0=标定完成;1=样本集无效;2=环境不可用。"""
+ stdout = sys.stdout
+ if isinstance(stdout, io.TextIOWrapper):
+ try:
+ stdout.reconfigure(encoding="utf-8") # Windows 控制台默认 GBK:德语字符需 UTF-8
+ except Exception: # 已关闭/不可重配置的流(如 pytest capture)⇒ 保持原状
+ pass
+
+ parser = argparse.ArgumentParser(description="A1 裁判候选维度 FP 标定实验(Task 6 AFK 半:只标定、不接线)。")
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="以 JSON 输出 Report(stdout;含 fp_samples/fn_samples 全文)",
+ )
+ parser.add_argument(
+ "--negatives",
+ metavar="PATH",
+ default=None,
+ help="覆盖内置负样本集(JSON:{维度名: [{text, expected, note?}, ...]};缺标注 ⇒ 退出码 1)",
+ )
+ args = parser.parse_args(argv)
+
+ try:
+ if args.negatives is not None:
+ negatives: Dict[str, List[LabeledSentence]] = load_negatives_from_json(args.negatives)
+ else:
+ negatives = {dim: list(items) for dim, items in NEGATIVES.items()}
+ except (ValueError, OSError) as exc:
+ print(f"[样本集无效] 负样本装载失败:{exc}", file=sys.stderr)
+ return 1
+
+ pairs = load_positives()
+ positive_labels = [entry_id for entry_id, _ in pairs]
+ positive_texts = [text for _, text in pairs]
+
+ errors = validate_samples(CANDIDATES, positive_texts, negatives)
+ if errors:
+ print(f"[样本集无效] 共 {len(errors)} 个问题(退出码 1):", file=sys.stderr)
+ for error in errors:
+ print(f" - {error}", file=sys.stderr)
+ return 1
+
+ env_error = probe_environment()
+ if env_error is not None:
+ print(f"[环境不可用] {env_error}", file=sys.stderr)
+ return 2
+
+ report, details = _evaluate_all(CANDIDATES, positive_texts, negatives, positive_labels)
+
+ if args.json:
+ print(json.dumps(report, ensure_ascii=False, indent=2))
+ else:
+ positive_sentences = sum(len(_split_sentences(text)) for text in positive_texts)
+ _print_text_report(report, details, CANDIDATES, len(positive_texts), positive_sentences)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())