框架: 去AI味与人感资产层入库——humanization 规则库(26 条 active,其中 16 条经所有者留痕豁免 holdout 激活)与五技能先行验证副本

This commit is contained in:
zizi 2026-08-16 03:04:12 +08:00
parent b0bc7a8745
commit 476bb71da4
88 changed files with 7642 additions and 18 deletions

View File

@ -12,6 +12,7 @@ disable-model-invocation: true
- `workId`、`targetChapter`、`asOf`、scenario、purpose。 - `workId`、`targetChapter`、`asOf`、scenario、purpose。
- 已确认细纲、叙事状态、来源授权快照、卡索引版本、原文索引版本和上下文预算。 - 已确认细纲、叙事状态、来源授权快照、卡索引版本、原文索引版本和上下文预算。
- 可选 `humanization_contract`:由 `prevent-ai-flavor` 生成的当前作品前置预防合同;生产上下文传入时会把有限 `writer_constraints` 冻结进 `styleConstraints`,并把规则/声音账指纹留在完整上下文的 `humanizationProvenance`,不会投影给 writer。
- `asOf` 必须早于目标章;所有历史来源必须能证明 `chapter <= asOf`。 - `asOf` 必须早于目标章;所有历史来源必须能证明 `chapter <= asOf`。
scenario 映射功能链分类:生成类为 `generation`,抽取类为 `extraction`,规划类为 `planning`,检测类为 `detection`,质量评审使用对应 writer 或 evaluator 的专用投影。此分类只标 scenario 走哪条功能合同,不是 WriterContext 的 `purpose` 字段——写手正文生成投影的 `purpose` 固定为 `production`(评测用 `evaluation`、诊断用 `diagnostic`),与 `writer_contract.py` 的枚举一致,传其它值会被安全拒绝。purpose 决定字段授权,scenario 决定功能合同,两者不得互相替代。 scenario 映射功能链分类:生成类为 `generation`,抽取类为 `extraction`,规划类为 `planning`,检测类为 `detection`,质量评审使用对应 writer 或 evaluator 的专用投影。此分类只标 scenario 走哪条功能合同,不是 WriterContext 的 `purpose` 字段——写手正文生成投影的 `purpose` 固定为 `production`(评测用 `evaluation`、诊断用 `diagnostic`),与 `writer_contract.py` 的枚举一致,传其它值会被安全拒绝。purpose 决定字段授权,scenario 决定功能合同,两者不得互相替代。
@ -65,6 +66,7 @@ writer 的 `factConstraints=[]` 在冻结读取确实没有可确认事实时合
## 红线 ## 红线
- 模型不得自行访问数据库、搜索卡、回读文件或扩张检索计划。 - 模型不得自行访问数据库、搜索卡、回读文件或扩张检索计划。
- 人感合同存在但 work_ref/规则指纹不合法时,组装器失败关闭;缺失时保留规则为空的诚实合同,不得静默退回通用「真人文风」。
- 不得把卡摘要当正文替代品,不得因为有卡减少历史原文检索。 - 不得把卡摘要当正文替代品,不得因为有卡减少历史原文检索。
- 不得把完整 WriterContext、raw 路径、授权密钥、真实实验臂或未来信息塞给模型。 - 不得把完整 WriterContext、raw 路径、授权密钥、真实实验臂或未来信息塞给模型。
- 来源、字段授权、冻结点、hash 或预算任何一项无法验证时失败关闭,不靠重试或人工说明绕过。 - 来源、字段授权、冻结点、hash 或预算任何一项无法验证时失败关闭,不靠重试或人工说明绕过。

View File

@ -10,8 +10,15 @@ from __future__ import annotations
import copy import copy
import hashlib import hashlib
import json import json
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence
_HUMANIZATION_SRC = Path(__file__).resolve().parents[4] / "humanization" / "src"
if str(_HUMANIZATION_SRC) not in sys.path:
sys.path.insert(0, str(_HUMANIZATION_SRC))
from deai.schemas import validate as validate_humanization_contract # noqa: E402
from writer_contract import ( from writer_contract import (
ContractError, ContractError,
MANIFEST_VERSION, MANIFEST_VERSION,
@ -505,6 +512,7 @@ def assemble_context(
token_budget: Mapping[str, int], token_budget: Mapping[str, int],
pattern_references: Sequence[Mapping[str, Any]] = (), pattern_references: Sequence[Mapping[str, Any]] = (),
style_constraints: Sequence[str] = (), style_constraints: Sequence[str] = (),
humanization_contract: Mapping[str, Any] | None = None,
generated_at: str, generated_at: str,
evidence_strategy: str = "production_dual_evidence", evidence_strategy: str = "production_dual_evidence",
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -512,6 +520,34 @@ def assemble_context(
if retrieval_plan.get("runId") != run_id or retrieval_plan.get("asOf") != as_of: if retrieval_plan.get("runId") != run_id or retrieval_plan.get("asOf") != as_of:
raise AssemblyError("retrievalPlan 与当前 runId/asOf 不一致") raise AssemblyError("retrievalPlan 与当前 runId/asOf 不一致")
humanization_guidance: list[str] = []
humanization_provenance = None
if humanization_contract is not None:
try:
validate_humanization_contract(dict(humanization_contract), "prevention")
except (TypeError, ValueError) as exc:
raise AssemblyError(f"humanization_contract 不满足 prevention 合同: {exc}") from exc
if humanization_contract.get("schema_version") not in {"ai-flavor-prevention-v1", "ai-flavor-prevention-v2"}:
raise AssemblyError("humanization_contract schema_version 不支持")
contract_work_ref = humanization_contract.get("work_ref")
if contract_work_ref not in {None, f"work:{work_id}"}:
raise AssemblyError("humanization_contract 与当前 work_id 不一致")
raw_guidance = humanization_contract.get("writer_constraints", [])
if not isinstance(raw_guidance, list) or any(not isinstance(item, str) or not item.strip() for item in raw_guidance):
raise AssemblyError("humanization_contract.writer_constraints 必须是非空字符串数组")
humanization_guidance = [normalize_text(item) for item in raw_guidance]
basis = humanization_contract.get("built_from") or {}
humanization_provenance = {
"schemaVersion": humanization_contract["schema_version"],
"ruleLibraryVersion": str(basis.get("rule_library_version") or "unknown"),
"voiceLedgerSha256": (
"sha256:" + str(basis["voice_ledger_sha256"])
if basis.get("voice_ledger_sha256")
and not str(basis["voice_ledger_sha256"]).startswith("sha256:")
else basis.get("voice_ledger_sha256")
),
"constraintCount": len(humanization_guidance),
}
if retrieval_plan.get("filters", {}).get("workId") != work_id: if retrieval_plan.get("filters", {}).get("workId") != work_id:
raise AssemblyError("retrievalPlan 与当前 workId 不一致") raise AssemblyError("retrievalPlan 与当前 workId 不一致")
max_chars = token_budget.get("maxContextChars") max_chars = token_budget.get("maxContextChars")
@ -617,6 +653,7 @@ def assemble_context(
# 规划期选定的文风约束:归一化后仅在非空时入冻结上下文(空则省略整个键, # 规划期选定的文风约束:归一化后仅在非空时入冻结上下文(空则省略整个键,
# 上下文逐字节不变,不破坏既有哈希与 A/C 单变量)。 # 上下文逐字节不变,不破坏既有哈希与 A/C 单变量)。
style_rules = [normalize_text(str(rule)) for rule in style_constraints if str(rule).strip()] style_rules = [normalize_text(str(rule)) for rule in style_constraints if str(rule).strip()]
style_rules.extend(humanization_guidance)
ordered_prose = sorted( ordered_prose = sorted(
selected_prose, selected_prose,
key=lambda item: ( key=lambda item: (
@ -667,6 +704,8 @@ def assemble_context(
"omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])), "omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])),
"acceptanceEligible": mode == "production" and purpose == "production", "acceptanceEligible": mode == "production" and purpose == "production",
} }
if humanization_provenance is not None:
context["humanizationProvenance"] = copy.deepcopy(humanization_provenance)
if style_rules: if style_rules:
context["styleConstraints"] = list(style_rules) context["styleConstraints"] = list(style_rules)
# usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。 # usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。

View File

@ -572,5 +572,28 @@ class StyleInjectionTest(unittest.TestCase):
self.assertEqual(result["context"]["styleConstraints"], ["冷峻克制"]) self.assertEqual(result["context"]["styleConstraints"], ["冷峻克制"])
def test_humanization_contract_is_frozen_and_projected_only_as_writer_guidance(self):
kwargs = base_kwargs()
kwargs["humanization_contract"] = {
"schema_version": "ai-flavor-prevention-v2",
"work_ref": "work:8",
"built_from": {
"rule_library_version": "v-test",
"voice_ledger_sha256": "a" * 64,
},
"negative_constraints": [],
"positive_samples": [],
"protected_spans": [],
"blacklist": [],
"writer_constraints": ["避免空洞元话语;遇到对白先判断人物功能。"],
}
result = assemble_context(**kwargs)
self.assertTrue(any("避免空洞元话语" in item for item in result["context"]["styleConstraints"]))
self.assertEqual(result["context"]["humanizationProvenance"]["constraintCount"], 1)
self.assertTrue(any("避免空洞元话语" in item for item in result["writerCreativeInput"]["styleConstraints"]))
self.assertNotIn("humanizationProvenance", result["writerCreativeInput"])
validate_writer_context(result["context"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -439,7 +439,7 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
value, value,
"$", "$",
required, required,
frozenset({"evidenceStrategy", "indexHints", "styleConstraints"}), frozenset({"evidenceStrategy", "indexHints", "styleConstraints", "humanizationProvenance"}),
) )
if context["schemaVersion"] != CONTEXT_VERSION: if context["schemaVersion"] != CONTEXT_VERSION:
raise ContractError("$.schemaVersion 版本不支持") raise ContractError("$.schemaVersion 版本不支持")
@ -602,6 +602,17 @@ def validate_writer_context(value: Any) -> dict[str, Any]:
for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")): for index, reference in enumerate(_array(context["patternReferences"], "$.patternReferences")):
# 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。 # 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。
_pattern_source_ref(reference, f"$.patternReferences[{index}]") _pattern_source_ref(reference, f"$.patternReferences[{index}]")
if "humanizationProvenance" in context:
provenance = _object(
context["humanizationProvenance"],
"$.humanizationProvenance",
frozenset({"schemaVersion", "ruleLibraryVersion", "voiceLedgerSha256", "constraintCount"}),
)
_string(provenance["schemaVersion"], "$.humanizationProvenance.schemaVersion")
_string(provenance["ruleLibraryVersion"], "$.humanizationProvenance.ruleLibraryVersion")
if provenance["voiceLedgerSha256"] is not None:
_hash(provenance["voiceLedgerSha256"], "$.humanizationProvenance.voiceLedgerSha256")
_integer(provenance["constraintCount"], "$.humanizationProvenance.constraintCount", minimum=0)
if "styleConstraints" in context: if "styleConstraints" in context:
# 文风约束(规划期选定的 style 画像投影):非空字符串数组;缺省时整个键省略, # 文风约束(规划期选定的 style 画像投影):非空字符串数组;缺省时整个键省略,
# 上下文逐字节不变(不破坏既有冻结哈希),build 端按空数组投影。 # 上下文逐字节不变(不破坏既有冻结哈希),build 端按空数组投影。

View File

@ -6,6 +6,19 @@ disable-model-invocation: true
# 抽取 AI 味案例卡 # 抽取 AI 味案例卡
## 在五技能接力链中的位置
本 Skill 是技能 5「挖掘」的执行入口(链路登记见 [`meta/chains/README.md`](../../../meta/chains/README.md) 的 AI 味接力段):
```text
创作/已有作品 → 案例卡采集(本 Skill)→ 人工标注 → canonical → 规则候选(propose-rule)
→ 四类样例 + 回放 + 评审 → humanization/rules active → 技能 2/3 消费
```
- 它**不碰本次创作正文**:只记账(作者回退、误杀、门禁失败都走 `feedback` 入卡),在背后进化规则库;
- 规则候选永远从 `candidate` 起步,激活要走 `humanization/` 的装载门(四类样例齐备)与评测;
- 模型换版本后的规则巡检:对 active 规则用 `evaluate-frozen-replay` 回放,失效规则降级,同样经本链路的评审入口回写。
## 目的与边界 ## 目的与边界
本 Skill 只生产质量证据层的 `ai_flavor_case` 卡片。它不是作品实体卡,也不是公共范式卡;卡片默认处于 `shadow`,不能进入生成上下文,不能直接改变正文或规则。 本 Skill 只生产质量证据层的 `ai_flavor_case` 卡片。它不是作品实体卡,也不是公共范式卡;卡片默认处于 `shadow`,不能进入生成上下文,不能直接改变正文或规则。
@ -20,7 +33,7 @@ disable-model-invocation: true
## 数据与副作用合同 ## 数据与副作用合同
- 读取:用户明确提供的文本文件,以及本 Skill 输出的案例卡 YAML/JSON。 - 读取:用户明确提供的文本文件,以及本 Skill 输出的案例卡 YAML/JSON。
- 写入:检测命令指定的回执文件,以及 `muse-example` 中的案例卡与重验证账本;不写正文、`knowledge/` 正式资产或生产规则目录。 - 写入:检测命令指定的回执文件,以及 `muse-example` 中的案例卡与重验证账本;生命周期入口更新案例卡当前投影、写质量评测账;不写正文、`knowledge/` 正式资产或生产规则目录。
- 自动落库:`scan`、`inventory`、`feedback` 在检测完成后自动以一个事务写入数据库。`--offline` 是显式例外,只用于离线合同测试或数据库恢复准备;不能把离线文件当正式内容。 - 自动落库:`scan`、`inventory`、`feedback` 在检测完成后自动以一个事务写入数据库。`--offline` 是显式例外,只用于离线合同测试或数据库恢复准备;不能把离线文件当正式内容。
- 恢复入口:`persist_cases.py` 只用于把已审计的 inventory/revalidation 文件恢复或迁移入库,正常检测不得依赖它单独执行。 - 恢复入口:`persist_cases.py` 只用于把已审计的 inventory/revalidation 文件恢复或迁移入库,正常检测不得依赖它单独执行。
- 模型:扫描、哈希、校验和候选归纳前置门不调用模型;语义标注可由独立评审完成,结果必须回写卡片的 review 字段。 - 模型:扫描、哈希、校验和候选归纳前置门不调用模型;语义标注可由独立评审完成,结果必须回写卡片的 review 字段。
@ -28,6 +41,28 @@ disable-model-invocation: true
## 运行 ## 运行
采集只是第一步;确认与规则生命周期由同目录 `mine_ai_flavor.py` 承担:
```bash
# Shadow 卡人工标注(默认写回数据库当前投影)
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py annotate \
--cards /tmp/cards.yaml --card-id case-... --label sf --carrier narration \
--reviewer qingse --note "当前上下文无叙事功能" --output /tmp/annotated.json
# 来源已 verified 后确认 canonical;数据库再次检查 verified 回执
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py confirm \
--cards /tmp/cards.yaml --card-id case-... --verification /tmp/revalidation.json \
--reviewer qingse --note "作者确认" --output /tmp/canonical.json
# 规则候选合同回放;带 holdout 才有资格进入激活门
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py evaluate-rule \
--rule humanization/rules/structural/s002.yaml --samples /tmp/projected-samples.yaml \
--cards /tmp/canonical-cards.yaml --verification /tmp/revalidation.json \
--holdout /tmp/holdout.json --output /tmp/rule-evaluation.json
```
正常检测入口仍如下:
```bash ```bash
# 既有作品反向扫描;research_only 是默认安全值,输出 hash-only 卡 # 既有作品反向扫描;research_only 是默认安全值,输出 hash-only 卡
.venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py scan \ .venv/bin/python .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py scan \
@ -78,7 +113,7 @@ disable-model-invocation: true
3. 来源重验证结果为 `verified` 后,只有可审阅且获授权的卡才能 `canonical`,再投影为样例。 3. 来源重验证结果为 `verified` 后,只有可审阅且获授权的卡才能 `canonical`,再投影为样例。
4. 规则候选至少需要两个不同来源作品,并同时包含一张 `sf` 与一张 `snf/boundary/regression` 卡;所有引用卡必须有 `verified` 回执。候选状态固定为 `candidate`。四类样例、跨任务回放和规则评审完成后,才由现有质量链决定是否激活。 4. 规则候选至少需要两个不同来源作品,并同时包含一张 `sf` 与一张 `snf/boundary/regression` 卡;所有引用卡必须有 `verified` 回执。候选状态固定为 `candidate`。四类样例、跨任务回放和规则评审完成后,才由现有质量链决定是否激活。
详细字段和失败码见 [`references/case-card-contract.md`](references/case-card-contract.md)。 详细字段和失败码见 [`references/case-card-contract.md`](references/case-card-contract.md)。规则候选的完整合同评测见 `humanization/src/deai/evaluation.py`;`project-sample` 产出的四类样例可用 `--samples` 作为评测输入;样例带 `case_card_id` 时,评测还必须提供 `--cards` 与 `--verification`,脚本会复核 canonical、verified 投影、样例正文和规则引用。holdout 必须保留 `sf_hit`、`snf_false_repair`、`boundary_false_repair`、`regression_safe` 等分层计数及派生指标;没有 holdout 和人工审批,候选永远不能写成 active;唯一例外是所有者留痕豁免——激活门代码不放宽,豁免必须在规则 evidence 写明决定、日期与理由(见 humanization/rules 2026-08-16 批量豁免)。
首版回填清单与候选规则种子见 [`references/fixtures/`](references/fixtures/);其中既有作品只保留 hash/位置,不能直接确认。 首版回填清单与候选规则种子见 [`references/fixtures/`](references/fixtures/);其中既有作品只保留 hash/位置,不能直接确认。
`backfill-inventory-*.json` 与 `revalidation-*.json` 是可复核的导出/恢复证据;正式内容在 `muse-example` 的 `backfill-inventory-*.json` 与 `revalidation-*.json` 是可复核的导出/恢复证据;正式内容在 `muse-example` 的
@ -95,4 +130,4 @@ disable-model-invocation: true
git diff --check git diff --check
``` ```
测试必须覆盖:来源 hash、重验证 verified/stale/unavailable/card_mismatch、未授权 hash-only、重复 ID、live feedback 来源绑定、shadow 不能投影样例、缺重验证回执不能确认,以及跨作品/反例门。 测试必须覆盖:来源 hash、重验证 verified/stale/unavailable/card_mismatch、未授权 hash-only、重复 ID、live feedback 来源绑定、shadow 不能投影样例、缺重验证回执不能确认、跨作品/反例门,以及候选评测不足时不能 active。

View File

@ -22,6 +22,12 @@ from typing import Iterable
import yaml import yaml
_AGENT_ROOT = Path(__file__).resolve().parents[4]
_HUMANIZATION_SRC = _AGENT_ROOT / "humanization" / "src"
if str(_HUMANIZATION_SRC) not in sys.path:
sys.path.insert(0, str(_HUMANIZATION_SRC))
# 作为 CLI 执行时也注册稳定模块名,自动落库模块复用同一份合同异常类型, # 作为 CLI 执行时也注册稳定模块名,自动落库模块复用同一份合同异常类型,
# 避免失败路径被重复 import 变成未捕获 traceback。 # 避免失败路径被重复 import 变成未捕获 traceback。
if __name__ == "__main__": if __name__ == "__main__":
@ -407,8 +413,14 @@ def build_case_card(*, text: str, source_sha256: str, source_kind: str, source_l
source_ref: str, work_ref: str | None, start: int, end: int, source_ref: str, work_ref: str | None, start: int, end: int,
pattern: dict, capture_mode: str = "backfill", feedback: dict | None = None, pattern: dict, capture_mode: str = "backfill", feedback: dict | None = None,
excerpt_start: int | None = None, excerpt_end: int | None = None) -> dict: excerpt_start: int | None = None, excerpt_end: int | None = None) -> dict:
if source_sha256 != text_sha256(text):
raise CaseCardError("source_sha256 与原始正文不一致")
if start < 0 or end < start or end > len(text):
raise CaseCardError("surface 命中位置越界")
evidence_start = start if excerpt_start is None else excerpt_start evidence_start = start if excerpt_start is None else excerpt_start
evidence_end = end if excerpt_end is None else excerpt_end evidence_end = end if excerpt_end is None else excerpt_end
if evidence_start < 0 or evidence_end < evidence_start or evidence_end > len(text):
raise CaseCardError("evidence 位置越界")
excerpt = text[evidence_start:evidence_end] excerpt = text[evidence_start:evidence_end]
stored = source_license in STOREABLE_LICENSES stored = source_license in STOREABLE_LICENSES
source = { source = {
@ -451,6 +463,8 @@ def build_case_card(*, text: str, source_sha256: str, source_kind: str, source_l
def capture_file(path: Path, *, source_license: str = "research_only", source_kind: str = "existing_work", def capture_file(path: Path, *, source_license: str = "research_only", source_kind: str = "existing_work",
work_ref: str | None = None, patterns: Iterable[dict] = PATTERNS, work_ref: str | None = None, patterns: Iterable[dict] = PATTERNS,
max_cards: int = 500) -> list[dict]: max_cards: int = 500) -> list[dict]:
if isinstance(max_cards, bool) or max_cards <= 0:
raise CaseCardError("max_cards 必须为正整数")
if source_license not in LICENSES: if source_license not in LICENSES:
raise CaseCardError(f"不支持的来源许可: {source_license}") raise CaseCardError(f"不支持的来源许可: {source_license}")
if source_kind not in {"existing_work", "public_domain", "synthetic"}: if source_kind not in {"existing_work", "public_domain", "synthetic"}:
@ -533,12 +547,20 @@ def project_sample(card: dict, *, verification=None) -> dict:
if card["state"] != "canonical": if card["state"] != "canonical":
raise CaseCardError("只有 canonical 卡可以投影样例") raise CaseCardError("只有 canonical 卡可以投影样例")
require_verified(card, verification) require_verified(card, verification)
if card["carrier"] == "unknown":
raise CaseCardError("canonical 卡投影样例前必须确认 carrier,不能用 unknown")
source_map = {
"owned": "hand_written",
"licensed": "licensed",
"public_domain": "public_domain",
"synthetic": "synthetic",
}
return { return {
"id": "sample-" + card["id"], "id": "sample-" + card["id"],
"type": card["label"], "type": card["label"],
"rules": list(card.get("rule_candidate_ids", [])), "rules": list(card.get("rule_candidate_ids", [])),
"carrier": card["carrier"], "carrier": card["carrier"],
"source": card["source"]["license"], "source": source_map[card["source"]["license"]],
"text": card["excerpt"], "text": card["excerpt"],
"note": card["observation"]["diagnosis"], "note": card["observation"]["diagnosis"],
"case_card_id": card["id"], "case_card_id": card["id"],
@ -547,7 +569,10 @@ def project_sample(card: dict, *, verification=None) -> dict:
} }
def propose_rule(cards: list[dict], *, rule_id: str, name: str, fix_hint: str, verification=None) -> dict: def propose_rule(cards: list[dict], *, rule_id: str, name: str, fix_hint: str,
verification=None, layer: str = "lexical", carrier_scope: str = "all",
trigger: dict | None = None, carve_out: list[str] | None = None,
function_check: list[str] | None = None) -> dict:
if not cards: if not cards:
raise CaseCardError("规则候选至少需要一张案例卡") raise CaseCardError("规则候选至少需要一张案例卡")
for card in cards: for card in cards:
@ -560,17 +585,51 @@ def propose_rule(cards: list[dict], *, rule_id: str, name: str, fix_hint: str, v
labels = {card["label"] for card in cards} labels = {card["label"] for card in cards}
if "sf" not in labels or not labels.intersection({"snf", "boundary", "regression"}): if "sf" not in labels or not labels.intersection({"snf", "boundary", "regression"}):
raise CaseCardError("规则候选必须同时有 sf 与 snf/boundary/regression 证据") raise CaseCardError("规则候选必须同时有 sf 与 snf/boundary/regression 证据")
return { if layer not in LAYERS - {"unknown"}:
"schema_version": "ai-flavor-rule-candidate-v1", raise CaseCardError(f"规则 layer 非法: {layer}")
if carrier_scope not in {"narration", "dialogue", "monologue", "in_text_carrier", "all"}:
raise CaseCardError(f"规则 carrier_scope 非法: {carrier_scope}")
trigger = copy.deepcopy(trigger or {"type": "model_judgment", "criteria": "待独立功能判断"})
trigger_type = trigger.get("type")
if trigger_type == "regex" and not trigger.get("pattern"):
raise CaseCardError("regex 候选必须提供 pattern")
if trigger_type == "handler" and not trigger.get("handler"):
raise CaseCardError("handler 候选必须提供 handler")
if trigger_type == "density" and (
not trigger.get("pattern") or not trigger.get("window_chars") or not trigger.get("min_hits")):
raise CaseCardError("density 候选必须提供 pattern/window_chars/min_hits")
if trigger_type == "model_judgment" and not trigger.get("criteria"):
raise CaseCardError("model_judgment 候选必须提供 criteria")
function_check = list(function_check or ["是否承担具体叙事功能", "是否属于角色/场内载体的有意写法"])
labels = {card["label"] for card in cards}
samples = {stype: [] for stype in ("sf", "snf", "boundary", "regression")}
for card in cards:
if card["label"] in samples:
samples[card["label"]].append("sample-" + card["id"])
rule = {
"id": rule_id, "id": rule_id,
"name": name, "name": name,
"status": "candidate", "layer": layer,
"carrier_scope": carrier_scope,
"trigger": trigger,
"carve_out": list(carve_out or []),
"default_disposition": "candidate", "default_disposition": "candidate",
"function_check": function_check,
"fix_hint": fix_hint, "fix_hint": fix_hint,
"samples": samples,
"version": 1,
"status": "candidate",
"case_card_ids": [card["id"] for card in cards], "case_card_ids": [card["id"] for card in cards],
"source_work_refs": sorted(works), "source_work_refs": sorted(works),
"evidence": "由多来源案例卡归纳;待四类样例、回放和独立评审。", "evidence": "由多来源案例卡归纳;四类样例引用为待投影的 sample-case-*,待回放和独立评审。",
} }
# 候选也必须是可装载的完整规则合同;状态仍固定为 candidate。
try:
from deai.schemas import validate
validate(rule, "rule")
except ValueError as exc:
raise CaseCardError(f"规则候选合同不完整: {exc}") from exc
return rule
def load_bundle(paths: Iterable[Path]) -> list[dict]: def load_bundle(paths: Iterable[Path]) -> list[dict]:
@ -578,9 +637,15 @@ def load_bundle(paths: Iterable[Path]) -> list[dict]:
seen = set() seen = set()
for path in paths: for path in paths:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
items = data.get("cards", []) if isinstance(data, dict) else data if isinstance(data, dict) and isinstance(data.get("cards"), list):
items = data["cards"]
elif isinstance(data, dict) and isinstance(data.get("card"), dict):
# annotate/confirm 回执包装可直接作为下一生命周期命令的输入。
items = [data["card"]]
else:
items = data
if not isinstance(items, list): if not isinstance(items, list):
raise CaseCardError(f"{path}: cards 必须是数组") raise CaseCardError(f"{path}: cards 必须是数组或单卡回执")
for card in items: for card in items:
validate_card(card) validate_card(card)
if card["id"] in seen: if card["id"] in seen:
@ -844,6 +909,14 @@ def _parser() -> argparse.ArgumentParser:
propose.add_argument("--rule-id", required=True) propose.add_argument("--rule-id", required=True)
propose.add_argument("--name", required=True) propose.add_argument("--name", required=True)
propose.add_argument("--fix-hint", default="待独立评审决定") propose.add_argument("--fix-hint", default="待独立评审决定")
propose.add_argument("--layer", default="lexical", choices=["mechanical", "lexical", "structural", "density", "semantic"])
propose.add_argument("--carrier-scope", default="all", choices=["narration", "dialogue", "monologue", "in_text_carrier", "all"])
propose.add_argument("--trigger-type", default="model_judgment", choices=["regex", "handler", "density", "model_judgment"])
propose.add_argument("--pattern")
propose.add_argument("--criteria")
propose.add_argument("--handler")
propose.add_argument("--carve-out", action="append", default=[])
propose.add_argument("--function-check", action="append", default=[])
propose.add_argument("--verification", type=Path, required=True, propose.add_argument("--verification", type=Path, required=True,
help="revalidate 命令生成的来源重验证回执") help="revalidate 命令生成的来源重验证回执")
propose.add_argument("--output", type=Path, required=True) propose.add_argument("--output", type=Path, required=True)
@ -954,8 +1027,26 @@ def main(argv: list[str] | None = None) -> int:
elif args.command == "propose-rule": elif args.command == "propose-rule":
cards = load_bundle(args.cards) cards = load_bundle(args.cards)
verification = load_verification(args.verification) verification = load_verification(args.verification)
rule = propose_rule(cards, rule_id=args.rule_id, name=args.name, fix_hint=args.fix_hint, trigger = {"type": args.trigger_type}
verification=verification) if args.trigger_type == "regex":
if not args.pattern:
raise CaseCardError("regex 候选必须提供 --pattern")
trigger["pattern"] = args.pattern
elif args.trigger_type == "handler":
if not args.handler:
raise CaseCardError("handler 候选必须提供 --handler")
trigger["handler"] = args.handler
elif args.trigger_type == "density":
if not args.pattern:
raise CaseCardError("density 候选必须提供 --pattern")
trigger.update({"pattern": args.pattern, "window_chars": 500, "min_hits": 3})
else:
trigger["criteria"] = args.criteria or "待独立功能判断"
rule = propose_rule(
cards, rule_id=args.rule_id, name=args.name, fix_hint=args.fix_hint,
verification=verification, layer=args.layer, carrier_scope=args.carrier_scope,
trigger=trigger, carve_out=args.carve_out, function_check=args.function_check,
)
write_yaml(args.output, rule) write_yaml(args.output, rule)
print(json.dumps({"status": rule["status"], "cards": len(cards), "output": str(args.output)}, ensure_ascii=False)) print(json.dumps({"status": rule["status"], "cards": len(cards), "output": str(args.output)}, ensure_ascii=False))
else: else:

View File

@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""技能 5「挖掘」的状态流转与规则评测入口。
采集脚本负责快速发现;本脚本负责慢确认、样例投影、候选评测和人工审批。任何一步失败关闭,
不会把 shadow 卡或合同回放报告自动变成 active 规则。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
import yaml
SCRIPT_DIR = Path(__file__).resolve().parent
AGENT_ROOT = SCRIPT_DIR.parents[3]
for path in (
SCRIPT_DIR,
AGENT_ROOT / "humanization" / "src",
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from capture_cases import ( # noqa: E402
CaseCardError,
annotate_card,
confirm_card,
load_bundle,
load_verification,
project_sample,
)
from db import connect # noqa: E402
from deai import evaluation, load # noqa: E402
class MiningError(ValueError):
pass
def _read_object(path: Path) -> dict:
try:
value = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise MiningError(f"{path}: 读取失败: {exc}") from exc
if not isinstance(value, dict):
raise MiningError(f"{path}: 顶层必须是对象")
return value
def _load_extra_samples(paths: list[Path], *, cards: dict | None = None,
verification: dict | None = None) -> dict:
samples = load.load_samples()
for path in paths:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise MiningError(f"{path}: 样例读取失败: {exc}") from exc
items = payload.get("samples", []) if isinstance(payload, dict) else []
if not isinstance(items, list):
raise MiningError(f"{path}: samples 必须是数组")
for item in items:
from deai.schemas import validate
validate(item, "sample")
if item["id"] in samples:
raise MiningError(f"样例 id 重复: {item['id']}")
case_card_id = item.get("case_card_id")
if case_card_id:
if cards is None or verification is None:
raise MiningError(f"样例 {item['id']} 绑定案例卡时必须同时提供 --cards 与 --verification")
card = cards.get(case_card_id)
if card is None:
raise MiningError(f"样例 {item['id']} 引用的案例卡不存在: {case_card_id}")
if card.get("state") != "canonical":
raise MiningError(f"样例 {item['id']} 引用的案例卡不是 canonical: {case_card_id}")
try:
expected = project_sample(card, verification=verification)
except CaseCardError as exc:
raise MiningError(f"样例 {item['id']} 的案例卡未通过 verified 投影门: {exc}") from exc
for key in ("id", "type", "carrier", "source", "text", "case_card_id", "source_ref", "source_license"):
if item.get(key) != expected.get(key):
raise MiningError(f"样例 {item['id']} 字段 {key} 与 canonical 投影不一致")
samples[item["id"]] = item
return samples
def _one_card(path: Path, card_id: str) -> dict:
cards = load_bundle([path])
matches = [card for card in cards if card["id"] == card_id]
if len(matches) != 1:
raise MiningError(f"{path}: card_id={card_id} 匹配 {len(matches)} 张卡")
return matches[0]
def _write(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.suffix.lower() in {".yaml", ".yml"}:
path.write_text(yaml.safe_dump(value, allow_unicode=True, sort_keys=False), encoding="utf-8")
else:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def persist_evaluation(report: dict) -> dict:
"""规则评测只落质量账,不改规则文件;规则文件仍由 Git 管理。"""
report_hash = hashlib.sha256(
json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
run_id = "rule-eval-" + report_hash[:40]
detail = {
"rule_id": report["rule_id"],
"rule_version": report["rule_version"],
"dataset_kind": report.get("dataset_kind"),
"counts": report.get("counts"),
"contract_pass": report.get("contract_pass"),
"holdout": report.get("holdout"),
"effect_claim": report.get("effect_claim"),
}
with connect() as conn:
with conn.transaction():
conn.execute(
"INSERT INTO example_run (run_id,work_id,trigger_source,trigger_detail,terminal_state,finished_at,creator,tenant_id) "
"VALUES (%s,NULL,'diagnostic',%s::jsonb,'completed',CURRENT_TIMESTAMP,'humanization',1) "
"ON CONFLICT (run_id) DO NOTHING",
(run_id, json.dumps(detail, ensure_ascii=False)),
)
conn.execute(
"INSERT INTO example_quality_result "
"(run_id,candidate_sha256,judge_kind,dimension,scale_version,conclusion,detail,creator,tenant_id) "
"VALUES (%s,NULL,'experiment','ai_flavor_rule','ai-flavor-rule-evaluation-v1',%s,%s::jsonb,'humanization',1) "
"ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING",
(run_id, "passed" if report.get("eligible") else "insufficient_evidence", json.dumps(detail, ensure_ascii=False)),
)
return {"run_id": run_id, "report_sha256": report_hash}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="技能 5 挖掘:案例状态、规则评测与生命周期")
sub = parser.add_subparsers(dest="command", required=True)
annotate = sub.add_parser("annotate")
annotate.add_argument("--cards", type=Path, required=True)
annotate.add_argument("--card-id", required=True)
annotate.add_argument("--label", choices=["sf", "snf", "boundary", "regression"], required=True)
annotate.add_argument("--carrier", choices=["narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"], default="unknown")
annotate.add_argument("--reviewer", default="")
annotate.add_argument("--note", default="")
annotate.add_argument("--output", type=Path, required=True)
annotate.add_argument("--offline", action="store_true")
confirm = sub.add_parser("confirm")
confirm.add_argument("--cards", type=Path, required=True)
confirm.add_argument("--card-id", required=True)
confirm.add_argument("--verification", type=Path, required=True)
confirm.add_argument("--reviewer", required=True)
confirm.add_argument("--note", required=True)
confirm.add_argument("--output", type=Path, required=True)
confirm.add_argument("--offline", action="store_true")
project = sub.add_parser("project-sample")
project.add_argument("--cards", type=Path, required=True)
project.add_argument("--card-id", required=True)
project.add_argument("--verification", type=Path, required=True)
project.add_argument("--output", type=Path, required=True)
evaluate_rule = sub.add_parser("evaluate-rule")
evaluate_rule.add_argument("--rule", type=Path, required=True)
evaluate_rule.add_argument("--holdout", type=Path)
evaluate_rule.add_argument("--samples", type=Path, nargs="*", default=[], help="已投影的额外四类样例文件")
evaluate_rule.add_argument("--cards", type=Path, nargs="*", default=[], help="与额外样例绑定的案例卡文件")
evaluate_rule.add_argument("--verification", type=Path, help="额外样例对应的 verified 重验证回执")
evaluate_rule.add_argument("--output", type=Path, required=True)
evaluate_rule.add_argument("--offline", action="store_true")
activate = sub.add_parser("activate-rule")
activate.add_argument("--rule", type=Path, required=True)
activate.add_argument("--contract-report", type=Path, required=True)
activate.add_argument("--holdout-report", type=Path, required=True)
activate.add_argument("--approver", required=True)
activate.add_argument("--note", default="")
activate.add_argument("--output", type=Path, required=True)
deprecate = sub.add_parser("deprecate-rule")
deprecate.add_argument("--rule", type=Path, required=True)
deprecate.add_argument("--approver", required=True)
deprecate.add_argument("--reason", required=True)
deprecate.add_argument("--output", type=Path, required=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
if args.command == "annotate":
card = _one_card(args.cards, args.card_id)
out = annotate_card(card, label=args.label, carrier=args.carrier,
reviewer=args.reviewer, note=args.note)
persistence = {"status": "offline"} if args.offline else _persist_projection(out)
_write(args.output, {"card": out, "persistence": persistence})
result = {"card_id": out["id"], "state": out["state"], "label": out["label"], "persistence": persistence}
elif args.command == "confirm":
card = _one_card(args.cards, args.card_id)
verification = load_verification(args.verification)
out = confirm_card(card, reviewer=args.reviewer, note=args.note, verification=verification)
persistence = {"status": "offline"} if args.offline else _persist_projection(out)
_write(args.output, {"card": out, "persistence": persistence})
result = {"card_id": out["id"], "state": out["state"], "persistence": persistence}
elif args.command == "project-sample":
card = _one_card(args.cards, args.card_id)
sample = project_sample(card, verification=load_verification(args.verification))
_write(args.output, {"schema_version": "humanization-samples-bundle-v1", "samples": [sample]})
result = {"sample_id": sample["id"], "case_card_id": sample["case_card_id"], "output": str(args.output)}
elif args.command == "evaluate-rule":
rule = _read_object(args.rule)
card_map = {}
for path in args.cards:
for card in load_bundle([path]):
if card["id"] in card_map:
raise MiningError(f"案例卡 id 重复: {card['id']}")
card_map[card["id"]] = card
verification = load_verification(args.verification) if args.verification else None
samples = _load_extra_samples(
args.samples, cards=card_map or None, verification=verification
)
report = evaluation.evaluate_rule_contract(rule, samples)
if args.holdout:
report["holdout"] = evaluation.evaluate_holdout(rule, _read_object(args.holdout))
report["eligible"] = bool(report["contract_pass"] and report["holdout"]["eligible"])
else:
report["eligible"] = False
report["holdout"] = None
persistence = {"status": "offline"} if args.offline else persist_evaluation(report)
report["persistence"] = persistence
_write(args.output, report)
result = {"rule_id": report["rule_id"], "contract_pass": report["contract_pass"], "eligible": report["eligible"], "persistence": persistence}
elif args.command == "activate-rule":
rule = _read_object(args.rule)
contract_report = _read_object(args.contract_report)
holdout_report = _read_object(args.holdout_report)
out = evaluation.activate_rule(
rule, contract_report=contract_report, holdout_report=holdout_report,
approver=args.approver, evidence_note=args.note,
)
_write(args.output, out)
result = {"rule_id": out["id"], "status": out["status"], "output": str(args.output)}
else:
rule = _read_object(args.rule)
out = evaluation.deprecate_rule(rule, approver=args.approver, reason=args.reason)
_write(args.output, out)
result = {"rule_id": out["id"], "status": out["status"], "output": str(args.output)}
print(json.dumps(result, ensure_ascii=False))
return 0
except (MiningError, CaseCardError, evaluation.EvaluationError, load.LoadError, OSError, ValueError) as exc:
print(f"AI_FLAVOR_MINING_CONTRACT_FAILED: {exc}")
return 2
def _persist_projection(card: dict) -> dict:
from persist_cases import persist_card_projection
return persist_card_projection(card)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -242,6 +242,46 @@ def persist(prepared: dict, *, creator: str = CREATOR, tenant_id: int = TENANT_I
} }
def persist_card_projection(card: dict, *, expected_state: str = "shadow",
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""把人工标注/确认的当前卡投影写回数据库,带乐观状态门。"""
validate_card(card)
source = card["source"]
with connect() as conn:
try:
if card["state"] == "canonical":
verified = conn.execute(
"SELECT 1 FROM example_ai_flavor_revalidation r "
"JOIN example_ai_flavor_revalidation_batch b ON b.id=r.batch_id "
"WHERE r.tenant_id=%s AND r.card_id=%s AND r.status='verified' "
"AND r.expected_source_sha256=%s AND r.actual_source_sha256=%s "
"ORDER BY b.checked_on DESC,b.id DESC LIMIT 1",
(tenant_id, card["id"], source["source_sha256"], source["source_sha256"]),
).fetchone()
if verified is None:
raise CaseCardError(f"案例卡 {card['id']} 没有当前 verified 回执,不能确认")
row = conn.execute(
"UPDATE example_ai_flavor_case SET state=%s,label=%s,carrier=%s,review=%s::jsonb,"
"observation=%s::jsonb,rule_candidate_ids=%s::jsonb,updater=%s,update_time=CURRENT_TIMESTAMP "
"WHERE tenant_id=%s AND card_id=%s AND state=%s AND deleted=FALSE RETURNING state",
(
card["state"], card["label"], card["carrier"],
_json(card.get("review")) if card.get("review") is not None else "null",
_json(card["observation"]), _json(card.get("rule_candidate_ids", [])),
creator, tenant_id, card["id"], expected_state,
),
).fetchone()
if row is None:
raise CaseCardError(
f"案例卡 {card['id']} 当前状态不是 {expected_state},拒绝覆盖已确认/终态卡"
)
conn.commit()
except Exception:
conn.rollback()
raise
return {"card_id": card["id"], "state": row[0]}
def _parser() -> argparse.ArgumentParser: def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AI 味案例卡与重验证回执落库") parser = argparse.ArgumentParser(description="AI 味案例卡与重验证回执落库")
parser.add_argument("inventory", type=Path, help="ai-flavor-inventory-v1 JSON/YAML") parser.add_argument("inventory", type=Path, help="ai-flavor-inventory-v1 JSON/YAML")

View File

@ -16,10 +16,12 @@ from capture_cases import (
capture_feedback, capture_feedback,
capture_file, capture_file,
build_inventory, build_inventory,
build_case_card,
confirm_card, confirm_card,
project_sample, project_sample,
propose_rule, propose_rule,
build_revalidation_report, build_revalidation_report,
load_bundle,
revalidate_card, revalidate_card,
text_sha256, text_sha256,
validate_card, validate_card,
@ -28,6 +30,14 @@ from capture_cases import (
class CaptureCasesTest(unittest.TestCase): class CaptureCasesTest(unittest.TestCase):
def test_source_hash_is_derived_from_supplied_text(self):
with self.assertRaises(CaseCardError):
build_case_card(
text="值得注意的是。", source_sha256="0" * 64,
source_kind="existing_work", source_license="owned", source_ref="x.txt",
work_ref="work-a", start=0, end=6, pattern=PATTERNS[0],
)
def test_inventory_is_deterministic_and_keeps_only_hash_for_research_sources(self): def test_inventory_is_deterministic_and_keeps_only_hash_for_research_sources(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
@ -147,6 +157,16 @@ class CaptureCasesTest(unittest.TestCase):
self.assertEqual(2, report["totals"]["verified"]) self.assertEqual(2, report["totals"]["verified"])
self.assertTrue(report["usable"]) self.assertTrue(report["usable"])
def test_lifecycle_receipt_wrapper_can_feed_next_step(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
card = annotate_card(capture_file(path, work_ref="work-a", source_license="owned")[0], label="sf")
wrapper = Path(tmp) / "annotated.json"
wrapper.write_text(yaml.safe_dump({"card": card, "persistence": {"status": "offline"}}, allow_unicode=True), encoding="utf-8")
loaded = load_bundle([wrapper])
self.assertEqual([card["id"]], [item["id"] for item in loaded])
def test_confirmation_requires_verified_receipt(self): def test_confirmation_requires_verified_receipt(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt" path = Path(tmp) / "owned.txt"
@ -185,6 +205,17 @@ class CaptureCasesTest(unittest.TestCase):
with self.assertRaises(CaseCardError): with self.assertRaises(CaseCardError):
propose_rule([sf, sf2], rule_id="candidate-one-sided", name="单样本禁令", fix_hint="删除") propose_rule([sf, sf2], rule_id="candidate-one-sided", name="单样本禁令", fix_hint="删除")
def test_canonical_unknown_carrier_cannot_project_to_sample(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt"
path.write_text("值得注意的是。", encoding="utf-8")
shadow = capture_file(path, work_ref="work-a", source_license="owned")[0]
annotated = annotate_card(shadow, label="sf", carrier="unknown")
verification = {"cards": [revalidate_card(annotated, source_path=path)]}
canonical = confirm_card(annotated, reviewer="human", note="功能已确认", verification=verification)
with self.assertRaises(CaseCardError):
project_sample(canonical, verification=verification)
def test_canonical_card_projects_to_sample_only_after_review(self): def test_canonical_card_projects_to_sample_only_after_review(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "owned.txt" path = Path(tmp) / "owned.txt"
@ -198,6 +229,8 @@ class CaptureCasesTest(unittest.TestCase):
sample = project_sample(canonical, verification=verification) sample = project_sample(canonical, verification=verification)
self.assertEqual("sample-" + canonical["id"], sample["id"]) self.assertEqual("sample-" + canonical["id"], sample["id"])
self.assertEqual(canonical["id"], sample["case_card_id"]) self.assertEqual(canonical["id"], sample["case_card_id"])
self.assertEqual("hand_written", sample["source"])
self.assertEqual("narration", sample["carrier"])
def test_shipped_fixtures_pass_the_same_validator(self): def test_shipped_fixtures_pass_the_same_validator(self):
root = Path(__file__).resolve().parents[1] / "references" / "fixtures" root = Path(__file__).resolve().parents[1] / "references" / "fixtures"

View File

@ -0,0 +1,40 @@
---
name: diagnose-ai-flavor
description: 技能 3 诊断:对目标正文跑 active 规则库,产出带精确片段与证据的发现清单,检测完成自动落库。只查不改;没有本产物,revise-ai-flavor 拒绝启动。
disable-model-invocation: true
---
# 诊断(scenario: deai_diagnose | purpose: detection | 槽位: 检测→detector)
何时用:每次生成后对候选正文跑一次;也可对既有正文回溯诊断。诊断是修订的唯一合法前置(专题-09 铁律:没诊断不能改)。
## 执行顺序
1. `scripts/diagnose_ai_flavor.py run --text-file <正文> --work-ref <作品引用> --output <产物.json>`。
2. 确定性层按五层合同执行:regex、handler、density 由代码运行;carrier scope 会屏蔽明确对白/场内载体;结构/语义层由模型或人产出 finding 后,用 `--external-findings` 注入。
3. 外部 finding 必须绑定当前正文 hash、active 规则、规则版本和 layer;缺任一项直接拒绝。规则库指纹覆盖完整规则内容,触发器未升版本也会使旧诊断失效。
4. 诊断建议保守:blocking mechanical 才可默认 `repair`,其它命中进入 `ask`/仲裁;carve-out 只作为候选,不自动删除。
5. 产物头(text_hash、规则库版本、mode、发现清单)缺任何一项,下游视为诊断未发生。
6. 检测完成即落库:`example_run` + `example_quality_result`(judge_kind=detection,绑正文 sha256);`--offline` 仅作显式离线回放。
## 输出合同
诊断产物 JSON:产物头 + `findings[]`。每条 finding 带 `id / rule_id / spans / context_window / evidence / confidence / decision_proposal`(确定性层产 ask,blocking 机械规则产 repair;语义层外部注入的 finding 可为 pending/ask/keep)。**到这里一个字没改**;作者可以只看报告不动手。
## 数据库读写合同
- 读:无(规则与样例读 `humanization/rules` / `humanization/samples` 文件资产)。
- 写:`example_run`(幂等 upsert,run_id=作品+文本哈希+规则库版本)、`example_quality_result`(append-only)。
## 红线
- 诊断不得改正文、不得产 patch。
- `decision_proposal` 只是机械层初步建议;候选/语义命中必须经过功能仲裁,不能当执行指令。
- 命中不等于修改命令:发现清单是给仲裁的输入,不是执行指令。
## 自测
```bash
cd agent-example
.venv/bin/python .claude/skills/diagnose-ai-flavor/scripts/test_diagnose_ai_flavor.py
```

View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""技能 3「诊断」确定性脚本层(专题-09 §5.3)。
职责边界:只查不改——对目标正文跑 humanization/rules 的 active 规则,
产出诊断产物(产物头 + 发现清单)。语义层判定由外部模型/人产出后经
merge_model_findings 注入,同样过合同校验;诊断不修改任何正文。
落库合同(检测完成即落库):
- example_run:一次诊断一行(run_id 由 作品+文本哈希+规则库版本 决定,重跑幂等);
- example_quality_result:judge_kind=detection,绑被诊断文本的 sha256;
- 只有显式 --offline 才不写库(仅产文件,供回放)。
"""
import argparse
import hashlib
import json
import sys
from pathlib import Path
# 共享执行骨架(humanization 副本)与数据库通道(access-database)的引导
SCRIPT_DIR = Path(__file__).resolve().parent
AGENT_ROOT = SCRIPT_DIR.parents[3]
for _p in (AGENT_ROOT / "humanization" / "src",
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts"):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
from deai import diagnose, load # noqa: E402
TENANT_ID = 1
CREATOR = "1"
class DiagnoseContractError(ValueError):
"""诊断合同失败:下游修订/前置预防必须失败关闭。"""
def rule_library_version(rules: dict) -> str:
"""兼容入口;真实指纹由共享装载器覆盖完整规则内容。"""
return load.rule_library_version(rules)
def load_active_library() -> tuple[dict, str]:
"""装载规则库并强制激活门:样例不齐的规则装载器直接拒绝(专题-09 §4.1)。"""
samples = load.load_samples()
rules = load.load_rules(samples=samples)
return rules, rule_library_version(rules)
def run_diagnosis(text: str, *, work_ref: str, chapter_ref: str | None = None,
external_findings: list | None = None, mode: str = "Audit") -> dict:
"""产出完整诊断产物;产物头缺项由 validate_artifact 兜底拒绝。"""
if not text:
raise DiagnoseContractError("诊断文本为空")
if not work_ref:
raise DiagnoseContractError("诊断必须带 work_ref")
rules, lib_version = load_active_library()
artifact = diagnose.run_deterministic_rules(text, load.active_rules(rules), lib_version, mode)
if external_findings:
diagnose.merge_model_findings(artifact, external_findings, rules=rules, text=text)
diagnose.validate_artifact(artifact)
artifact["work_ref"] = work_ref
if chapter_ref:
artifact["chapter_ref"] = chapter_ref
return artifact
def _run_id(*parts: str) -> str:
return "diag-" + hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:40]
def persist_diagnosis(artifact: dict, *, text: str,
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""诊断运行落库:example_run(幂等 upsert)+ example_quality_result(append-only)。"""
from db import connect
if not isinstance(text, str) or not text:
raise DiagnoseContractError("落库诊断文本为空")
try:
diagnose.validate_artifact(artifact)
except (ValueError, KeyError, TypeError) as exc:
raise DiagnoseContractError(f"落库诊断产物不可读: {exc}") from exc
expected_hash = diagnose.text_hash(text)
if artifact["text_hash"] != expected_hash:
raise DiagnoseContractError("落库诊断产物 text_hash 与正文不一致")
if not artifact.get("work_ref"):
raise DiagnoseContractError("落库诊断必须带 work_ref")
_, current_library_version = load_active_library()
if artifact["rule_library_version"] != current_library_version:
raise DiagnoseContractError("落库诊断产物使用了过期规则库")
run_id = _run_id(artifact["work_ref"], artifact["text_hash"], artifact["rule_library_version"])
text_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
per_rule: dict[str, int] = {}
for f in artifact["findings"]:
per_rule[f["rule_id"]] = per_rule.get(f["rule_id"], 0) + 1
per_layer: dict[str, int] = {}
per_decision: dict[str, int] = {}
for finding in artifact["findings"]:
per_layer[finding["layer"]] = per_layer.get(finding["layer"], 0) + 1
decision = finding["decision_proposal"]
per_decision[decision] = per_decision.get(decision, 0) + 1
detail = {
"text_hash": artifact["text_hash"],
"rule_library_version": artifact["rule_library_version"],
"mode": artifact["mode"],
"work_ref": artifact["work_ref"],
"chapter_ref": artifact.get("chapter_ref"),
"findings_total": len(artifact["findings"]),
"findings_per_rule": per_rule,
"findings_per_layer": per_layer,
"decision_proposals": per_decision,
}
run_sql = (
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
"terminal_state, finished_at, creator, tenant_id) "
"VALUES (%s, NULL, 'diagnostic', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
"ON CONFLICT (run_id) DO UPDATE SET terminal_state='completed', "
"finished_at=CURRENT_TIMESTAMP, trigger_detail=EXCLUDED.trigger_detail, "
"updater=EXCLUDED.creator, update_time=CURRENT_TIMESTAMP"
)
quality_sql = (
"INSERT INTO example_quality_result "
"(run_id, candidate_sha256, judge_kind, dimension, scale_version, conclusion, detail, creator, tenant_id) "
"VALUES (%s, %s, 'detection', NULL, %s, %s, %s::jsonb, %s, %s)"
)
with connect() as conn:
with conn.transaction():
conn.execute(run_sql, (run_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id))
# append-only 表不能 ON CONFLICT 更新;按幂等键预检,重复运行不重复记账
exists = conn.execute(
"SELECT 1 FROM example_quality_result WHERE tenant_id=%s AND run_id=%s "
"AND judge_kind='detection' AND COALESCE(candidate_sha256,'')=%s",
(tenant_id, run_id, text_sha),
).fetchone()
if exists is None:
conn.execute(quality_sql, (
run_id, text_sha, artifact["rule_library_version"],
"has_findings" if artifact["findings"] else "clean",
json.dumps(detail, ensure_ascii=False), creator, tenant_id,
))
return {"run_id": run_id, "text_sha256": text_sha, "findings": len(artifact["findings"])}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="技能 3 诊断:只查不改,产出诊断产物并落库")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run")
run.add_argument("--text-file", type=Path, required=True)
run.add_argument("--work-ref", required=True)
run.add_argument("--chapter-ref")
run.add_argument("--external-findings", type=Path,
help="语义层外部判定(JSON 数组),注入前逐条过 finding 合同")
run.add_argument("--mode", default="Audit", choices=["Audit", "Patch"])
run.add_argument("--output", type=Path, required=True)
run.add_argument("--offline", action="store_true", help="只产文件,不写 muse-example")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
text = args.text_file.read_text(encoding="utf-8")
external = None
if args.external_findings:
external = json.loads(args.external_findings.read_text(encoding="utf-8"))
artifact = run_diagnosis(text, work_ref=args.work_ref, chapter_ref=args.chapter_ref,
external_findings=external, mode=args.mode)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.offline:
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
else:
persistence = persist_diagnosis(artifact, text=text)
print(json.dumps({
"findings": len(artifact["findings"]),
"text_hash": artifact["text_hash"],
"rule_library_version": artifact["rule_library_version"],
"output": str(args.output),
"persistence": persistence,
}, ensure_ascii=False))
return 0
except (DiagnoseContractError, diagnose.ArtifactIncomplete, load.LoadError,
ValueError, OSError, UnicodeError) as exc:
print(f"DIAGNOSE_CONTRACT_FAILED: {exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""技能 3 诊断离线测试:检测只查不改、产物头完整、检测完成即落库(--offline 除外)。"""
import json
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import diagnose_ai_flavor as diag # noqa: E402
# 合成 AI 味文本:命中 l001/l002/l003(语料只用合成文本,版权边界见 humanization/README)
AI_FLAVOR_TEXT = (
"研究表明,能进这种地方的修士都不简单。"
"值得注意的是,门外已经下起了雨。"
"他嘴角微微上扬,没有说话。"
)
class DiagnosisContractTest(unittest.TestCase):
def test_run_diagnosis_produces_complete_artifact(self):
artifact = diag.run_diagnosis(AI_FLAVOR_TEXT, work_ref="synthetic:demo")
for key in ("text_hash", "rule_library_version", "mode", "findings"):
self.assertIn(key, artifact)
rule_ids = {f["rule_id"] for f in artifact["findings"]}
self.assertTrue({"l001", "l002", "l003"} <= rule_ids, rule_ids)
for f in artifact["findings"]:
self.assertIn(f["decision_proposal"], {"repair", "ask"})
self.assertTrue(f["spans"][0] in AI_FLAVOR_TEXT)
def test_work_ref_is_required(self):
with self.assertRaisesRegex(diag.DiagnoseContractError, "work_ref"):
diag.run_diagnosis(AI_FLAVOR_TEXT, work_ref="")
def test_empty_text_is_rejected(self):
with self.assertRaisesRegex(diag.DiagnoseContractError, "为空"):
diag.run_diagnosis("", work_ref="synthetic:demo")
def test_rule_library_version_is_stable_fingerprint(self):
rules1, v1 = diag.load_active_library()
_, v2 = diag.load_active_library()
self.assertEqual(v1, v2)
self.assertTrue(v1.startswith("v-"))
def test_cli_offline_writes_artifact_without_db(self):
with tempfile.TemporaryDirectory() as tmp:
text_path = pathlib.Path(tmp) / "text.txt"
text_path.write_text(AI_FLAVOR_TEXT, encoding="utf-8")
output = pathlib.Path(tmp) / "artifact.json"
with patch.object(diag, "persist_diagnosis") as persist:
code = diag.main(["run", "--text-file", str(text_path), "--work-ref", "synthetic:demo",
"--output", str(output), "--offline"])
self.assertEqual(code, 0)
persist.assert_not_called()
artifact = json.loads(output.read_text(encoding="utf-8"))
self.assertIn("findings", artifact)
def test_persist_rejects_text_hash_mismatch_before_db(self):
artifact = diag.run_diagnosis(AI_FLAVOR_TEXT, work_ref="synthetic:demo")
artifact["text_hash"] = "sha256:deadbeef"
with self.assertRaises(diag.DiagnoseContractError):
diag.persist_diagnosis(artifact, text=AI_FLAVOR_TEXT)
def test_cli_default_persists_detection(self):
with tempfile.TemporaryDirectory() as tmp:
text_path = pathlib.Path(tmp) / "text.txt"
text_path.write_text(AI_FLAVOR_TEXT, encoding="utf-8")
output = pathlib.Path(tmp) / "artifact.json"
with patch.object(diag, "persist_diagnosis", return_value={"run_id": "diag-x"}) as persist:
code = diag.main(["run", "--text-file", str(text_path), "--work-ref", "synthetic:demo",
"--output", str(output)])
self.assertEqual(code, 0)
persist.assert_called_once()
def test_cli_external_findings_hash_mismatch_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
text_path = pathlib.Path(tmp) / "text.txt"
text_path.write_text(AI_FLAVOR_TEXT, encoding="utf-8")
findings_path = pathlib.Path(tmp) / "external.json"
findings_path.write_text(json.dumps([{
"id": "x1", "text_hash": "sha256:deadbeef", "rule_id": "sem001", "rule_version": 1,
"spans": ["他"], "context_window": "他", "layer": "semantic", "evidence": "外部判定证据",
"possible_function": "none", "confidence": "medium", "decision_proposal": "pending",
}]), encoding="utf-8")
code = diag.main(["run", "--text-file", str(text_path), "--work-ref", "synthetic:demo",
"--external-findings", str(findings_path),
"--output", str(pathlib.Path(tmp) / "a.json"), "--offline"])
self.assertEqual(code, 2)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,46 @@
---
name: establish-voice-baseline
description: 技能 1 定基线:作品建立或新角色登场时,把已确认正文与作者样张产出的声音账做结构校验、grounding 门后版本化落库。不产正文;是修订门禁与前置预防的对照物。
disable-model-invocation: true
---
# 定基线(scenario: voice_baseline | purpose: planning | 槽位: 规划→planner)
何时用:作品建立时;新角色登场时;作者样张更新时。平时不动——它是垫底资产。
## 执行顺序
1. 生产优先从数据库 Canonical 正文生成候选画像:
`.venv/bin/python scripts/establish_voice_baseline.py draft --work-id <作品ID> --output <候选账.json>`。
该步骤只计算句长、段长、标点、对白比例等统计;角色策略、口癖、保护片段和黑名单保持 `unknown`,交 planner/作者补充。
2. 离线回放可显式使用 `--work-ref + --text-file`;生产不把文件快照当正文权威。
3. 人工确认后执行:
`.venv/bin/python scripts/establish_voice_baseline.py confirm --ledger-file <声音账.json> --work-id <作品ID> --reviewer <确认人>`。
4. 脚本强制:结构合同 → 样张/口癖/保护片段 grounding → 角色归属冲突门 → 新版本追加、旧版本 superseded;候选账只记 `example_run`,不冒充 current 基线。
## 为什么必须有它
没有声音账,后面所有「改得对不对」只能对照通用真人文风——那是错的对照系。声音账同时被两处机械消费:
- `revise-ai-flavor` 硬门:`untouchable_verbal_tics` 口癖删除即拒收,`protected_spans` 改动即拒收;
- `prevent-ai-flavor`:正样例与黑名单进入生成上下文。
## 数据库读写合同
- 读:`muse_content_chapter` + `muse_content_block` 的 Canonical 正文(章节状态为 `published/confirmed/canonical`,或正文块存在 `example_user_decision.decision=accept`);`example_voice_baseline` 当前版本。
- 写:候选生成写 `example_run`;确认写 `example_voice_baseline`(append 新版本行 + 旧行 superseded;单事务)。
- 失败:正文为空、当前账多版本、hash 不一致、grounding 或人工确认缺失时失败关闭,不写半成品。
## 红线
- 只读已确认正文与作者样张;不得从草稿或未确认候选归纳基线。
- 基线必须人工确认(`--reviewer` 必填,数据库 CHECK 兜底)。
- 本技能不产一个字正文。
## 自测
```bash
cd agent-example
.venv/bin/python .claude/skills/establish-voice-baseline/scripts/test_establish_voice_baseline.py
.venv/bin/python humanization/tests/test_humanization_v2.py
```

View File

@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""技能 1「定基线」确定性脚本层。
- ``draft``:从 muse-example 的 Canonical 正文(或显式离线文本)计算量化画像,生成候选声音账;
- ``confirm``:结构校验、来源 grounding、人工确认、版本化落库;
- ``show``:读取数据库当前有效版本。
语义属性可由 planner/作者补到候选账,但统计不足的字段必须保持 unknown。本技能不产正文。
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
AGENT_ROOT = SCRIPT_DIR.parents[3]
for _p in (
AGENT_ROOT / "humanization" / "src",
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
from deai.baseline import draft_ledger # noqa: E402
from deai.schemas import validate # noqa: E402
TENANT_ID = 1
CREATOR = "1"
SCHEMA_VERSION = "voice-baseline-v1"
class BaselineContractError(ValueError):
"""声音账合同失败:结构、来源或确认门未通过。"""
def _sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _canonical_json(value: dict) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def validate_ledger(ledger: dict, *, work_ref: str) -> None:
"""校验声音账结构、作品绑定与角色归属冲突。"""
if not isinstance(ledger, dict):
raise BaselineContractError("声音账必须是 JSON 对象")
try:
validate(ledger, "voice_baseline")
except ValueError as exc:
raise BaselineContractError(str(exc)) from exc
if ledger.get("schema_version") != SCHEMA_VERSION:
raise BaselineContractError(f"schema_version 必须是 {SCHEMA_VERSION}")
if ledger.get("work_ref") != work_ref:
raise BaselineContractError("声音账 work_ref 与目标作品不一致")
if ledger.get("status", "candidate") not in {"candidate", "canonical"}:
raise BaselineContractError("声音账 status 只能是 candidate/canonical")
narrator = ledger.get("narrator")
if not isinstance(narrator, dict):
raise BaselineContractError("narrator 必须是对象")
for key in ("sentence_habits", "punctuation_habits"):
if not isinstance(narrator.get(key, []), list):
raise BaselineContractError(f"narrator.{key} 必须是数组")
if "metrics" in narrator and not isinstance(narrator["metrics"], dict):
raise BaselineContractError("narrator.metrics 必须是对象")
if "exemplar_passages" in narrator and not isinstance(narrator["exemplar_passages"], list):
raise BaselineContractError("narrator.exemplar_passages 必须是数组")
for key, typ, what in (
("characters", dict, "角色声音档案"),
("untouchable_verbal_tics", dict, "不可改口癖"),
("protected_spans", list, "保护片段"),
("blacklist", list, "作品级黑名单"),
):
value = ledger.get(key)
if not isinstance(value, typ):
raise BaselineContractError(f"{what}({key})缺失或类型错误")
for key in ("passing_samples", "unknown_fields", "sources"):
if key in ledger and not isinstance(ledger[key], list):
raise BaselineContractError(f"{key} 必须是数组")
ownership: dict[str, str] = {}
for who, info in ledger["characters"].items():
if not isinstance(info, dict):
raise BaselineContractError(f"characters.{who} 必须是对象")
for key in ("verbal_tics", "sample_lines"):
values = info.get(key, [])
if not isinstance(values, list) or not all(isinstance(item, str) and item for item in values):
raise BaselineContractError(f"characters.{who}.{key} 必须是非空字符串数组")
for item in values:
previous = ownership.setdefault(item, who)
if previous != who:
raise BaselineContractError(f"声音样本「{item}」同时归属 {previous}/{who},须作者裁决")
for who, tics in ledger["untouchable_verbal_tics"].items():
if not isinstance(tics, list) or not all(isinstance(item, str) and item for item in tics):
raise BaselineContractError(f"{who} 的口癖必须是非空字符串数组")
for tic in tics:
previous = ownership.setdefault(tic, who)
if previous != who:
raise BaselineContractError(f"口癖「{tic}」同时归属 {previous}/{who},须作者裁决")
def ground_ledger(ledger: dict, source_text: str) -> list[str]:
"""所有声明为原文样例的内容必须能逐字回到已确认正文。"""
failures = []
for who, tics in ledger["untouchable_verbal_tics"].items():
for tic in tics:
if tic not in source_text:
failures.append(f"口癖「{tic}」({who})未在所依据正文中出现")
for who, info in ledger["characters"].items():
for line in info.get("sample_lines", []):
if line not in source_text:
failures.append(f"角色样张「{line[:24]}」({who})未在所依据正文中出现")
for span in ledger["protected_spans"]:
if span not in source_text:
failures.append(f"保护片段「{span[:24]}…」未在所依据正文中出现")
for span in (ledger.get("narrator") or {}).get("exemplar_passages", []):
if span not in source_text:
failures.append(f"叙述样张「{span[:24]}…」未在所依据正文中出现")
for span in ledger.get("passing_samples", []):
if span not in source_text:
failures.append(f"达标样张「{span[:24]}…」未在所依据正文中出现")
return failures
def load_canonical_sources(work_id: int, *, max_chapters: int | None = None,
tenant_id: int = TENANT_ID) -> tuple[str, list[dict]]:
"""从正式库读取 Canonical 正文;文件不是生产权威。"""
from db import connect
if work_id <= 0:
raise BaselineContractError("work_id 必须为正整数")
chapter_filter = ""
params: list[object] = [work_id]
if max_chapters is not None:
if max_chapters <= 0:
raise BaselineContractError("max_chapters 必须为正整数")
chapter_filter = (
" AND c.order_no IN (SELECT DISTINCT c2.order_no FROM muse_content_chapter c2 "
"JOIN muse_content_block b2 ON b2.chapter_id=c2.id AND b2.deleted=FALSE "
"WHERE c2.work_id=w.id AND c2.deleted=FALSE "
"AND (c2.status IN ('published','confirmed','canonical') OR EXISTS ("
"SELECT 1 FROM example_user_decision d2 WHERE d2.canonical_block_id=b2.id "
"AND d2.decision='accept')) ORDER BY c2.order_no DESC LIMIT %s)"
)
params.append(max_chapters)
sql = (
"SELECT c.order_no,b.id,b.revision,b.content_text "
"FROM muse_content_work w JOIN muse_content_chapter c ON c.work_id=w.id "
"JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=FALSE "
"WHERE w.id=%s AND w.deleted=FALSE AND c.deleted=FALSE "
"AND (c.status IN ('published','confirmed','canonical') OR EXISTS ("
"SELECT 1 FROM example_user_decision d WHERE d.canonical_block_id=b.id "
"AND d.decision='accept'))" + chapter_filter + " "
"ORDER BY c.order_no DESC, b.order_no DESC"
)
with connect(readonly=True) as conn:
rows = conn.execute(sql, params).fetchall()
if not rows:
raise BaselineContractError(f"作品 {work_id} 没有可用于定基线的 Canonical 正文")
rows = list(reversed(rows))
sources = [
{
"source_ref": f"content-block:{row[1]}:r{row[2]}",
"source_sha256": _sha256_text(row[3]),
"chapter": row[0],
"text": row[3],
}
for row in rows
if isinstance(row[3], str) and row[3]
]
if not sources:
raise BaselineContractError(f"作品 {work_id} 的 Canonical 正文为空")
return f"work:{work_id}", sources
def load_current_baseline(work_ref: str, *, tenant_id: int = TENANT_ID) -> dict | None:
"""读取当前声音账并复核 ledger hash;多条 current 视为数据库状态损坏。"""
from db import connect
with connect(readonly=True) as conn:
rows = conn.execute(
"SELECT ledger,ledger_sha256,version FROM example_voice_baseline "
"WHERE tenant_id=%s AND work_ref=%s AND deleted=FALSE AND superseded=FALSE "
"ORDER BY version DESC",
(tenant_id, work_ref),
).fetchall()
if not rows:
return None
if len(rows) != 1:
raise BaselineContractError(f"作品 {work_ref} 存在 {len(rows)} 条 current 声音账")
ledger = dict(rows[0][0])
expected_hash = rows[0][1]
# 兼容 v1 旧脚本使用 json.dumps(sort_keys=True, 带空格) 计算的历史 hash;
# 新版本写入规范 JSON hash,读取时两种格式都必须与数据库一致。
candidate_hashes = {
_sha256_text(_canonical_json(ledger)),
_sha256_text(json.dumps(ledger, ensure_ascii=False, sort_keys=True)),
}
if expected_hash not in candidate_hashes:
raise BaselineContractError(f"作品 {work_ref} 当前声音账 hash 不一致")
ledger["database_version"] = rows[0][2]
ledger["database_ledger_sha256"] = expected_hash
# 历史已确认行可能未写 status;current 表本身由 reviewer + superseded 门确认,读取时补 canonical 投影。
ledger.setdefault("status", "canonical")
validate_ledger(ledger, work_ref=work_ref)
return ledger
def persist_baseline(ledger: dict, *, source_text: str, reviewer: str, note: str = "",
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""人工确认后追加新版本并取代旧版本;历史行保留。"""
from db import connect
if not reviewer:
raise BaselineContractError("基线必须人工确认(reviewer 不得为空)")
if not isinstance(source_text, str) or not source_text:
raise BaselineContractError("基线来源正文不能为空")
canonical = copy.deepcopy(ledger)
canonical["status"] = "canonical"
canonical["confirmed_by"] = reviewer
validate_ledger(canonical, work_ref=canonical.get("work_ref", ""))
grounding_failures = ground_ledger(canonical, source_text)
if grounding_failures:
raise BaselineContractError("grounding 门未通过: " + "; ".join(grounding_failures))
ledger_json = _canonical_json(canonical)
ledger_sha = _sha256_text(ledger_json)
source_sha = _sha256_text(source_text)
work_ref = canonical["work_ref"]
with connect() as conn:
with conn.transaction():
row = conn.execute(
"SELECT COALESCE(MAX(version), 0) FROM example_voice_baseline "
"WHERE tenant_id=%s AND work_ref=%s AND deleted=FALSE",
(tenant_id, work_ref),
).fetchone()
version = row[0] + 1
conn.execute(
"UPDATE example_voice_baseline SET superseded=TRUE, updater=%s "
"WHERE tenant_id=%s AND work_ref=%s AND superseded=FALSE",
(creator, tenant_id, work_ref),
)
conn.execute(
"INSERT INTO example_voice_baseline "
"(work_ref, version, ledger, ledger_sha256, source_text_sha256, reviewer, note, creator, tenant_id) "
"VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s)",
(work_ref, version, ledger_json, ledger_sha, source_sha, reviewer, note, creator, tenant_id),
)
return {
"work_ref": work_ref,
"version": version,
"ledger_sha256": ledger_sha,
"source_text_sha256": source_sha,
"sampling": canonical.get("sampling", {}),
"unknown_fields": canonical.get("unknown_fields", []),
}
def persist_draft_run(ledger: dict, *, work_id: int | None = None,
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""候选账也留运行事实,但不写入 current 基线表。"""
from db import connect
validate_ledger(ledger, work_ref=ledger.get("work_ref", ""))
ledger_sha = _sha256_text(_canonical_json(ledger))
run_id = "voice-draft-" + ledger_sha[:40]
detail = {
"work_ref": ledger["work_ref"],
"ledger_sha256": ledger_sha,
"sampling": ledger.get("sampling", {}),
"unknown_fields": ledger.get("unknown_fields", []),
"status": "candidate",
}
with connect() as conn:
with conn.transaction():
conn.execute(
"INSERT INTO example_run (run_id,work_id,trigger_source,trigger_detail,terminal_state,finished_at,creator,tenant_id) "
"VALUES (%s,%s,'diagnostic',%s::jsonb,'completed',CURRENT_TIMESTAMP,%s,%s) "
"ON CONFLICT (run_id) DO NOTHING",
(run_id, work_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id),
)
return {"run_id": run_id, "ledger_sha256": ledger_sha}
def collect_ledger(ledger: dict, *, work_ref: str, source_texts: list[str],
reviewer: str, note: str = "") -> dict:
"""确认链:结构校验 -> grounding -> 版本化落库。"""
validate_ledger(ledger, work_ref=work_ref)
joined = "\n".join(source_texts)
failures = ground_ledger(ledger, joined)
if failures:
raise BaselineContractError("grounding 门未通过: " + "; ".join(failures))
return persist_baseline(ledger, source_text=joined, reviewer=reviewer, note=note)
def _file_sources(paths: list[Path]) -> list[dict]:
sources = []
for path in paths:
text = path.read_text(encoding="utf-8")
sources.append({"source_ref": path.name, "source_sha256": _sha256_text(text), "text": text})
return sources
def _load_ledger_file(path: Path) -> dict:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise BaselineContractError(f"声音账文件不可读: {path} ({exc})") from exc
# draft CLI 为了同时返回运行回执会输出包装对象;confirm 接受包装或裸 ledger。
if isinstance(payload, dict) and "ledger" in payload and "schema_version" not in payload:
payload = payload["ledger"]
if not isinstance(payload, dict):
raise BaselineContractError("声音账文件顶层必须是 JSON 对象")
return payload
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="技能 1 定基线:候选画像、grounding、人工确认与版本化落库")
sub = parser.add_subparsers(dest="command", required=True)
draft = sub.add_parser("draft")
draft.add_argument("--work-id", type=int)
draft.add_argument("--work-ref")
draft.add_argument("--text-file", type=Path, nargs="+")
draft.add_argument("--max-chapters", type=int)
draft.add_argument("--output", type=Path, required=True)
draft.add_argument("--offline", action="store_true")
confirm = sub.add_parser("confirm")
confirm.add_argument("--ledger-file", type=Path, required=True)
confirm.add_argument("--work-id", type=int)
confirm.add_argument("--work-ref")
confirm.add_argument("--text-file", type=Path, nargs="+")
confirm.add_argument("--max-chapters", type=int)
confirm.add_argument("--reviewer", required=True)
confirm.add_argument("--note", default="")
confirm.add_argument("--output", type=Path)
show = sub.add_parser("show")
show.add_argument("--work-ref", required=True)
show.add_argument("--output", type=Path)
return parser
def _resolve_sources(args) -> tuple[str, list[dict]]:
if args.work_id is not None:
if args.text_file or args.work_ref:
raise BaselineContractError("--work-id 与 --text-file/--work-ref 不能混用")
return load_canonical_sources(args.work_id, max_chapters=args.max_chapters)
if not args.work_ref or not args.text_file:
raise BaselineContractError("离线来源必须同时提供 --work-ref 与 --text-file")
return args.work_ref, _file_sources(args.text_file)
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
try:
if args.command == "show":
ledger = load_current_baseline(args.work_ref)
if ledger is None:
raise BaselineContractError(f"作品 {args.work_ref} 没有 current 声音账")
result = ledger
else:
work_ref, sources = _resolve_sources(args)
if args.command == "draft":
ledger = draft_ledger(work_ref, sources)
validate_ledger(ledger, work_ref=work_ref)
result = {
"ledger": ledger,
"persistence": (
{"status": "offline"}
if args.offline
else persist_draft_run(ledger, work_id=args.work_id)
),
}
else:
ledger = _load_ledger_file(args.ledger_file)
result = collect_ledger(
ledger,
work_ref=work_ref,
source_texts=[item["text"] for item in sources],
reviewer=args.reviewer,
note=args.note,
)
if getattr(args, "output", None):
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
return 0
except (BaselineContractError, ValueError, OSError, json.JSONDecodeError) as exc:
print(f"BASELINE_CONTRACT_FAILED: {exc}")
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""技能 1 定基线离线测试:结构合同、grounding 门、人工确认强制。"""
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import establish_voice_baseline as base # noqa: E402
SOURCE_TEXT = "老周敲了敲桌角:「我说小子,茶要凉了。」青云城的雨说来就来。"
LEDGER = {
"schema_version": "voice-baseline-v1",
"work_ref": "synthetic:demo",
"narrator": {"sentence_habits": ["短句收束"], "punctuation_habits": []},
"characters": {"老周": {"verbal_tics": ["我说小子"], "sample_lines": ["我说小子,茶要凉了。"]}},
"untouchable_verbal_tics": {"老周": ["我说小子"]},
"protected_spans": ["青云城的雨说来就来"],
"blacklist": ["值得注意的是"],
}
class BaselineContractTest(unittest.TestCase):
def test_draft_output_can_feed_confirm(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
source = root / "source.txt"
source.write_text(SOURCE_TEXT, encoding="utf-8")
draft_output = root / "draft.json"
self.assertEqual(
0,
base.main([
"draft", "--work-ref", "synthetic:demo", "--text-file", str(source),
"--output", str(draft_output), "--offline",
]),
)
with patch.object(base, "collect_ledger", return_value={"version": 1}) as collect:
code = base.main([
"confirm", "--ledger-file", str(draft_output),
"--work-ref", "synthetic:demo", "--text-file", str(source),
"--reviewer", "qingse", "--output", str(root / "confirmed.json"),
])
self.assertEqual(code, 0)
collect.assert_called_once()
self.assertEqual(collect.call_args.args[0]["schema_version"], "voice-baseline-v1")
def test_valid_ledger_passes_grounding_and_persists(self):
with patch.object(base, "persist_baseline", return_value={"version": 1}) as persist:
result = base.collect_ledger(LEDGER, work_ref="synthetic:demo",
source_texts=[SOURCE_TEXT], reviewer="qingse")
self.assertEqual(result["version"], 1)
persist.assert_called_once()
def test_ungrounded_tic_is_rejected(self):
bad = dict(LEDGER, untouchable_verbal_tics={"老周": ["根本不存在的口癖"]})
with patch.object(base, "persist_baseline") as persist:
with self.assertRaisesRegex(base.BaselineContractError, "grounding"):
base.collect_ledger(bad, work_ref="synthetic:demo",
source_texts=[SOURCE_TEXT], reviewer="qingse")
persist.assert_not_called()
def test_ungrounded_protected_span_is_rejected(self):
bad = dict(LEDGER, protected_spans=["不在正文里的句子"])
with self.assertRaisesRegex(base.BaselineContractError, "grounding"):
base.collect_ledger(bad, work_ref="synthetic:demo",
source_texts=[SOURCE_TEXT], reviewer="qingse")
def test_reviewer_is_required(self):
# persist_baseline 的确认人门在连库之前,可直接测
with self.assertRaisesRegex(base.BaselineContractError, "人工确认"):
base.persist_baseline(LEDGER, source_text=SOURCE_TEXT, reviewer="")
def test_direct_persist_rechecks_grounding_before_db(self):
with self.assertRaisesRegex(base.BaselineContractError, "grounding"):
base.persist_baseline(
dict(LEDGER, protected_spans=["不在正文里的句子"]),
source_text=SOURCE_TEXT, reviewer="qingse",
)
def test_schema_version_is_enforced(self):
bad = dict(LEDGER, schema_version="nope")
with self.assertRaisesRegex(base.BaselineContractError, "schema_version"):
base.validate_ledger(bad, work_ref="synthetic:demo")
def test_work_ref_mismatch_is_enforced(self):
with self.assertRaisesRegex(base.BaselineContractError, "work_ref"):
base.validate_ledger(LEDGER, work_ref="synthetic:other")
def test_missing_narrator_is_enforced(self):
bad = dict(LEDGER)
del bad["narrator"]
with self.assertRaisesRegex(base.BaselineContractError, "narrator"):
base.validate_ledger(bad, work_ref="synthetic:demo")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,41 @@
---
name: prevent-ai-flavor
description: 技能 2 前置预防:生成前组装写作上下文合同——active 规则的负约束(附反例)加声音账的正样例与保护项。不碰正文,只供上下文组装消费。
disable-model-invocation: true
---
# 前置预防(scenario: deai_prevention | purpose: generation | 槽位: assemble-context 消费)
何时用:每次组装写作上下文时(续写/改写/扩写/润色前)。产物给 `assemble-context` 塞进生成上下文,不直接进正文。
## 执行顺序
1. 生产执行 `scripts/prevent_ai_flavor.py --work-ref work:<作品ID> --output <合同.json>`;默认读取数据库 current canonical 声音账。离线回放必须显式 `--offline` 或传 `--voice-ledger`。
2. 负约束只取 active 规则;每条同时带 SF 避开例、SNF 保留例、Boundary、Regression、carrier scope、carve-out 和 function_check。
3. 正样例与保护项只取当前作品声音账:叙述者画像、角色口癖/样张、达标样张、保护片段和黑名单;不把案例卡正文当声音素材。
4. 合同生成后由 `assemble-context` 以 `humanization_contract` 参数冻结;writer 只看到有限 `writer_constraints`,完整来源/规则指纹留在可信上下文。
5. 构建运行落 `example_run`;`--offline` 仅作显式离线回放。
## 输出合同
`ai-flavor-prevention-v2` JSON:`negative_constraints[]`(含四类样例与 scope)/ `positive_samples[]` / `protected_spans[]` / `blacklist[]` / `writer_constraints[]` / `built_from`(规则库版本 + 声音账哈希)。
## 边界
- **不承诺零 AI 味**,只降低命中率;漏网的由 `diagnose-ai-flavor` 兜底。
- 声音账缺失时仍产合同(只有负约束),正样例为空——不得拿通用「真人文风」冒充本作品的声音。
- 声音账 work_ref 与目标不一致直接拒绝,防止串作品套用。
## 数据库读写合同
- 读:`example_voice_baseline` 当前 canonical 版本;规则/样例读 `humanization/` Git 资产。
- 写:`example_run`(幂等 upsert,记录规则指纹、声音账 hash 和投影条数)。
- 失败:作品不匹配、声音账非 canonical、规则装载门失败或 guidance 超合同直接失败关闭。
## 自测
```bash
cd agent-example
.venv/bin/python .claude/skills/prevent-ai-flavor/scripts/test_prevent_ai_flavor.py
.venv/bin/python .claude/skills/assemble-context/scripts/test_assemble_writer_context.py
```

View File

@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""技能 2「前置预防」确定性脚本层。
生成前合同同时携带规则的避开例、保留例、边界例和回归陷阱,并优先读取数据库当前声音账。
它只生成 writer 上下文指导,不触碰正文;规则仍由诊断技能复查。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
AGENT_ROOT = SCRIPT_DIR.parents[3]
for _p in (
AGENT_ROOT / "humanization" / "src",
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
AGENT_ROOT / ".claude" / "skills" / "establish-voice-baseline" / "scripts",
):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
from deai import load # noqa: E402
from deai.schemas import validate # noqa: E402
TENANT_ID = 1
CREATOR = "1"
class PreventionContractError(ValueError):
pass
def rule_library_version(rules: dict) -> str:
return load.rule_library_version(rules)
def _sample_text(samples: dict, sample_id: str, limit: int = 260) -> str:
text = str((samples.get(sample_id) or {}).get("text") or "")
return text[:limit]
def _render_rule_constraint(rule: dict, item: dict) -> str:
avoid = ";".join(item["avoid_examples"][:2]) or "(暂无可展示的该修例)"
keep = ";".join(item["keep_examples"][:2]) or "(遇到相似形式先做功能判断)"
scope = rule["carrier_scope"]
return (
f"避免【{rule['name']}】(适用载体:{scope}):不要直接生成类似「{avoid}」的无功能写法;"
f"若与「{keep}」相似,先保留信息并判断人物、节奏或场内功能;"
f"{rule.get('fix_hint', '')}"
)
def render_writer_constraints(contract: dict) -> list[str]:
"""把结构化合同投影成有限、可读、可追溯的 writer 指令。"""
rows = list(contract.get("writer_constraints") or [])
for item in contract.get("positive_samples", [])[:8]:
if item.get("kind") in {"voice_sample", "narrator_exemplar", "passing_sample"}:
rows.append(f"本作声音参照(只学语气与节奏,不复制内容):{item['text']}")
for span in contract.get("protected_spans", [])[:12]:
rows.append(f"本作保护片段不可改写:{span}")
for item in contract.get("blacklist", [])[:32]:
rows.append(f"本作黑名单表达式避免主动生成:{item}")
return rows
def _load_db_ledger(work_ref: str) -> dict | None:
from establish_voice_baseline import load_current_baseline
return load_current_baseline(work_ref)
def _validate_voice_ledger(voice_ledger: dict, work_ref: str) -> None:
from establish_voice_baseline import validate_ledger
try:
validate_ledger(voice_ledger, work_ref=work_ref)
except ValueError as exc:
raise PreventionContractError(f"声音账结构不合法: {exc}") from exc
def build_prevention_contract(work_ref: str, *, voice_ledger: dict | None = None,
load_database: bool = False) -> dict:
"""组装上下文合同:active 规则带四类样例,声音账只取当前作品版本。"""
if not work_ref:
raise PreventionContractError("前置预防必须带 work_ref")
samples = load.load_samples()
rules = load.load_rules(samples=samples)
lib_version = rule_library_version(rules)
if voice_ledger is None and load_database:
voice_ledger = _load_db_ledger(work_ref)
if voice_ledger is not None:
_validate_voice_ledger(voice_ledger, work_ref)
if voice_ledger.get("schema_version") != "voice-baseline-v1":
raise PreventionContractError("声音账 schema_version 必须是 voice-baseline-v1")
if voice_ledger.get("work_ref") != work_ref:
raise PreventionContractError("声音账 work_ref 与预防目标不一致")
if voice_ledger.get("status", "canonical") != "canonical":
raise PreventionContractError("声音账不是 canonical,不能进入生产上下文")
negatives = []
writer_constraints = []
for rule in load.active_rules(rules):
refs = rule["samples"]
item = {
"rule_id": rule["id"],
"name": rule["name"],
"layer": rule["layer"],
"carrier_scope": rule["carrier_scope"],
"default_disposition": rule["default_disposition"],
"fix_hint": rule.get("fix_hint", ""),
"function_check": list(rule.get("function_check", [])),
"carve_out": list(rule.get("carve_out", [])),
"avoid_examples": [_sample_text(samples, sid) for sid in refs.get("sf", []) if sid in samples],
"keep_examples": [_sample_text(samples, sid) for sid in refs.get("snf", []) if sid in samples],
"boundary_examples": [_sample_text(samples, sid) for sid in refs.get("boundary", []) if sid in samples],
"regression_traps": [_sample_text(samples, sid) for sid in refs.get("regression", []) if sid in samples],
}
negatives.append(item)
writer_constraints.append(_render_rule_constraint(rule, item))
positives: list[dict] = []
protections: list[str] = []
blacklist: list[str] = []
ledger_sha = None
if voice_ledger is not None:
ledger_sha = voice_ledger.get("database_ledger_sha256") or hashlib.sha256(
json.dumps(voice_ledger, ensure_ascii=False, sort_keys=True).encode("utf-8")
).hexdigest()
narrator = voice_ledger.get("narrator") or {}
for habit in narrator.get("sentence_habits", []):
positives.append({"kind": "narrator_habit", "who": "narrator", "text": habit})
for habit in narrator.get("punctuation_habits", []):
positives.append({"kind": "narrator_punctuation", "who": "narrator", "text": habit})
for line in narrator.get("exemplar_passages", []):
positives.append({"kind": "narrator_exemplar", "who": "narrator", "text": line})
for who, info in (voice_ledger.get("characters") or {}).items():
for tic in info.get("verbal_tics", []):
positives.append({"kind": "verbal_tic", "who": who, "text": tic})
for line in info.get("sample_lines", []):
positives.append({"kind": "voice_sample", "who": who, "text": line})
positives.extend(
{"kind": "passing_sample", "who": "work", "text": line}
for line in voice_ledger.get("passing_samples", [])
)
protections = list(voice_ledger.get("protected_spans", []))
blacklist = list(voice_ledger.get("blacklist", []))
contract = {
"schema_version": "ai-flavor-prevention-v2",
"work_ref": work_ref,
"built_from": {
"rule_library_version": lib_version,
"active_rule_count": len(negatives),
"voice_ledger_sha256": ledger_sha,
"voice_ledger_source": "database" if load_database and voice_ledger else "explicit_file" if voice_ledger else "missing",
},
"negative_constraints": negatives,
"positive_samples": positives,
"protected_spans": protections,
"blacklist": blacklist,
"writer_constraints": writer_constraints,
"limit": "只降低命中率,不承诺零 AI 味;漏网命中由 diagnose-ai-flavor 兜底",
}
# 合同校验只检查结构;规则样例和来源状态由上面的装载器/数据库门负责。
validate(contract, "prevention")
return contract
def persist_prevention(contract: dict, *, creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""上下文合同构建也是一次运行:example_run 留痕。"""
from db import connect
try:
validate(contract, "prevention")
except ValueError as exc:
raise PreventionContractError(f"前置预防合同不可落库: {exc}") from exc
current_rules = load.load_rules(samples=load.load_samples())
current_library_version = rule_library_version(current_rules)
basis = contract["built_from"]
if basis.get("rule_library_version") != current_library_version:
raise PreventionContractError("前置预防合同使用了过期规则库")
run_id = "prev-" + hashlib.sha256(
f"{contract['work_ref']}|{basis['rule_library_version']}|{basis['voice_ledger_sha256'] or ''}|"
f"{hashlib.sha256(json.dumps(contract.get('writer_constraints', []), ensure_ascii=False).encode()).hexdigest()}".encode("utf-8")
).hexdigest()[:40]
detail = {
"work_ref": contract["work_ref"],
"rule_library_version": basis["rule_library_version"],
"active_rule_count": basis["active_rule_count"],
"voice_ledger_sha256": basis["voice_ledger_sha256"],
"voice_ledger_source": basis["voice_ledger_source"],
"negative_constraints": len(contract["negative_constraints"]),
"positive_samples": len(contract["positive_samples"]),
"writer_constraints": len(contract.get("writer_constraints", [])),
}
with connect() as conn:
with conn.transaction():
conn.execute(
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
"terminal_state, finished_at, creator, tenant_id) "
"VALUES (%s, NULL, 'diagnostic', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
"ON CONFLICT (run_id) DO UPDATE SET terminal_state='completed', "
"finished_at=CURRENT_TIMESTAMP, trigger_detail=EXCLUDED.trigger_detail, "
"updater=EXCLUDED.creator, update_time=CURRENT_TIMESTAMP",
(run_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id),
)
return {"run_id": run_id}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="技能 2 前置预防:生成前上下文合同")
parser.add_argument("--work-ref", required=True)
parser.add_argument("--voice-ledger", type=Path, help="离线回放/人工传入的声音账;生产默认读数据库")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--offline", action="store_true", help="只产文件,不写 muse-example;也不读取数据库")
args = parser.parse_args(argv)
try:
ledger = None
load_database = not args.offline and args.voice_ledger is None
if args.voice_ledger:
ledger = json.loads(args.voice_ledger.read_text(encoding="utf-8"))
contract = build_prevention_contract(
args.work_ref, voice_ledger=ledger, load_database=load_database
)
contract["writer_constraints"] = render_writer_constraints(contract)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(contract, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.offline:
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
else:
persistence = persist_prevention(contract)
except (PreventionContractError, load.LoadError, ValueError, OSError) as exc:
print(f"PREVENTION_CONTRACT_FAILED: {exc}")
return 2
print(json.dumps({
"negative_constraints": len(contract["negative_constraints"]),
"positive_samples": len(contract["positive_samples"]),
"writer_constraints": len(contract["writer_constraints"]),
"rule_library_version": contract["built_from"]["rule_library_version"],
"voice_ledger_source": contract["built_from"]["voice_ledger_source"],
"output": str(args.output),
"persistence": persistence,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""技能 2 前置预防离线测试:负约束只取 active 规则、声音账投影、串作品拒绝。"""
import pathlib
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import prevent_ai_flavor as prev # noqa: E402
LEDGER = {
"schema_version": "voice-baseline-v1",
"work_ref": "synthetic:demo",
"narrator": {"sentence_habits": ["短句收束"], "punctuation_habits": ["少用感叹号"]},
"characters": {"老周": {"verbal_tics": ["我说小子"], "sample_lines": ["我说小子,茶要凉了。"]}},
"untouchable_verbal_tics": {"老周": ["我说小子"]},
"protected_spans": ["青云城的雨说来就来"],
"blacklist": ["值得注意的是"],
}
class PreventionContractTest(unittest.TestCase):
def test_contract_without_ledger_still_valid(self):
contract = prev.build_prevention_contract("synthetic:demo")
self.assertEqual(contract["schema_version"], "ai-flavor-prevention-v2")
self.assertGreaterEqual(len(contract["negative_constraints"]), 10)
self.assertEqual(contract["positive_samples"], [])
self.assertIsNone(contract["built_from"]["voice_ledger_sha256"])
for item in contract["negative_constraints"]:
self.assertTrue(item["avoid_examples"], item["rule_id"])
self.assertIn("keep_examples", item)
self.assertIn("boundary_examples", item)
self.assertIn("regression_traps", item)
def test_ledger_projects_positive_samples_and_blacklist(self):
contract = prev.build_prevention_contract("synthetic:demo", voice_ledger=LEDGER)
kinds = {(p["kind"], p["text"]) for p in contract["positive_samples"]}
self.assertIn(("verbal_tic", "我说小子"), kinds)
self.assertIn(("voice_sample", "我说小子,茶要凉了。"), kinds)
self.assertIn(("narrator_habit", "短句收束"), kinds)
self.assertEqual(contract["blacklist"], ["值得注意的是"])
self.assertEqual(contract["protected_spans"], ["青云城的雨说来就来"])
self.assertTrue(any("黑名单表达式" in item for item in prev.render_writer_constraints(contract)))
self.assertIsNotNone(contract["built_from"]["voice_ledger_sha256"])
def test_work_ref_mismatch_is_rejected(self):
with self.assertRaisesRegex(prev.PreventionContractError, "不一致"):
prev.build_prevention_contract("synthetic:other", voice_ledger=LEDGER)
def test_candidate_ledger_is_rejected_even_in_offline_projection(self):
bad = dict(LEDGER, status="candidate")
with self.assertRaisesRegex(prev.PreventionContractError, "canonical"):
prev.build_prevention_contract("synthetic:demo", voice_ledger=bad)
def test_bad_schema_version_is_rejected(self):
bad = dict(LEDGER, schema_version="nope")
with self.assertRaisesRegex(prev.PreventionContractError, "schema_version"):
prev.build_prevention_contract("synthetic:demo", voice_ledger=bad)
def test_cli_default_persists_run(self):
import argparse # noqa: F401 (确认 CLI 依赖可导入)
import tempfile
import json
with tempfile.TemporaryDirectory() as tmp:
output = pathlib.Path(tmp) / "contract.json"
with patch.object(prev, "persist_prevention", return_value={"run_id": "prev-x"}) as persist, \
patch.object(prev, "_load_db_ledger", return_value=None):
code = prev.main(["--work-ref", "synthetic:demo", "--output", str(output)])
self.assertEqual(code, 0)
persist.assert_called_once()
contract = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(contract["work_ref"], "synthetic:demo")
def test_cli_offline_skips_db(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
output = pathlib.Path(tmp) / "contract.json"
with patch.object(prev, "persist_prevention") as persist:
code = prev.main(["--work-ref", "synthetic:demo", "--output", str(output), "--offline"])
self.assertEqual(code, 0)
persist.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,39 @@
---
name: revise-ai-flavor
description: 技能 4 修订:在诊断产物与作者同意之上执行最小 patch,过硬门、复扫与成对选择校验,产出候选稿与审计报告。没诊断不启动;人不点头永远是候选。
disable-model-invocation: true
---
# 修订(scenario: deai_revise | purpose: generation | 槽位: 写作→writer + 保护节点)
何时用:诊断(`diagnose-ai-flavor`)产出发现清单、仲裁完成、作者同意修订之后。
## 执行顺序
1. 前置检查:`--artifact` 缺失、规则库过期、`author_approved_revision`/`allowed_scope` 缺失即拒绝;无事实快照且无显式授权时自动降回 Audit。
2. 功能仲裁在脚本之外完成(模型/人逐条过五问),每个被 patch 的 finding 必须是 `repair` 且有 `arbitration_note`;`keep`/`ask` 不能偷偷改。
3. 执行机械链:唯一匹配 patch → 硬门(结构重放/数字和专名/时间锚点/模态/引文/口癖)→ 声音漂移门 → 同规则复扫(只允许既有 keep/ask 命中保留)→ 独立模型成对选择。
4. 一次最多 3 轮;选择原文、平局、无净改善或任一门失败分别落 `no_gain`/`blocked`,不伪报 `passed`。
5. 产出候选稿与审计报告并落库。**本 skill 不写正式正文**:候选转正由作者确认后走既有候选接受通道。
## 最小改动铁律
只删、只压缩、只用原文已有信息局部改写。绝不为了「更像人」加日期、加感官、加经历——replacement 引入新数字/新专名会被硬门拒收。
## 数据库读写合同
- 读:`example_voice_baseline` 当前 canonical 版本(未提供时声音门明确标 unknown);规则/样例读 `humanization/` 文件资产。
- 写:`example_run`、`example_quality_result`(judge_kind=review,dimension=ai_flavor_revision,绑候选稿 sha256;conclusion=passed/blocked;降级只记 run)。
## 红线
- 没有诊断产物、作者授权或功能仲裁不得修订。
- 硬门失败不得以风格分、盲评结果抵消;旧诊断不能跨规则库/正文 hash 复用。
- 语义级不变量(因果、POV、伏笔状态)机械未覆盖的,如实列 `unresolved_risks`,不假装验证完成。
## 自测
```bash
cd agent-example
.venv/bin/python .claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py
```

View File

@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""技能 4「修订」确定性脚本层(专题-09 §5.4 / §6 / §7)。
铁律:没有诊断产物,修订拒绝启动;没有事实快照,自动降回 Audit。
语义级动作(仲裁五问、改写文本、成对选择判定)在本脚本之外产生;
本脚本只强制合同与机械检查:产物头 → 唯一匹配 patch → 硬门 → 复扫 →
成对选择校验 → 审计报告。人不点头,候选永远是候选——本脚本不写任何正文。
落库合同:example_run + example_quality_result(judge_kind=review,
dimension=ai_flavor_revision,绑候选稿 sha256);--offline 才不写库。
"""
import argparse
import hashlib
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
AGENT_ROOT = SCRIPT_DIR.parents[3]
for _p in (AGENT_ROOT / "humanization" / "src",
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
AGENT_ROOT / ".claude" / "skills" / "establish-voice-baseline" / "scripts"):
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
from deai import load, report as report_mod # noqa: E402
from establish_voice_baseline import load_current_baseline # noqa: E402
from deai.pairwise import PairwiseNotExecuted # noqa: E402
from deai.patch import PatchError # noqa: E402
from deai.pipeline import DowngradedToAudit, RevisionNotAuthorized, run_patch # noqa: E402
TENANT_ID = 1
CREATOR = "1"
class ReviseContractError(ValueError):
"""修订合同失败:缺诊断、缺授权或门禁失败关闭。"""
def _load_json(path: Path, what: str) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ReviseContractError(f"{what}不可读或不合法: {path} ({exc})") from exc
if not isinstance(data, dict):
raise ReviseContractError(f"{what}必须是 JSON 对象: {path}")
return data
def run_revision(text: str, *, artifact: dict, patches: list, task_contract: dict,
fact_snapshot: dict | None = None, voice_ledger: dict | None = None,
pairwise: dict | None = None, rewrite_model: str = "claude") -> tuple[dict, dict]:
"""Patch 全链:应用 → 硬门 → 复扫 → 成对选择校验 → 审计报告。"""
if not artifact:
raise ReviseContractError("没有诊断产物,修订拒绝启动(专题-09 铁律)")
if not isinstance(patches, list) or not patches:
raise ReviseContractError("没有 patch 清单,修订无事可做")
samples = load.load_samples()
rules = load.load_rules(samples=samples)
lib_version = load.rule_library_version(rules)
record = run_patch(text, rules, artifact, patches, task_contract,
fact_snapshot, voice_ledger, rewrite_model, pairwise, lib_version)
audit = report_mod.assemble(
artifact, patches, record["candidate_text"], record["hard_gate"],
record["voice_gate"], record["regression_gate"], record["pairwise_choice"],
unresolved_risks=(
record["hard_gate"]["unverified"]
+ list(record["voice_gate"].get("unknown", []))
),
)
return record, audit
def _run_id(*parts: str) -> str:
return "rev-" + hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:40]
def persist_revision(*, work_ref: str, text_hash: str, candidate_text: str | None,
audit: dict | None, conclusion: str, detail: dict,
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
"""修订运行落库:候选稿哈希绑定评判,append-only 记账。"""
from db import connect
if not work_ref or not isinstance(detail, dict):
raise ReviseContractError("修订落库缺少 work_ref/detail")
if not isinstance(audit, dict):
raise ReviseContractError("修订落库缺少审计报告")
if conclusion not in {"passed", "blocked", "no_gain"}:
raise ReviseContractError(f"修订结论非法: {conclusion}")
audit_sha = hashlib.sha256(
json.dumps(audit, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
detail = {**detail, "audit_sha256": audit_sha}
candidate_sha = hashlib.sha256(candidate_text.encode("utf-8")).hexdigest() if candidate_text else None
run_id = _run_id(work_ref, text_hash, json.dumps(detail.get("patches", []), sort_keys=True))
run_sql = (
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
"terminal_state, finished_at, creator, tenant_id) "
"VALUES (%s, NULL, 'user', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
"ON CONFLICT (run_id) DO UPDATE SET terminal_state='completed', "
"finished_at=CURRENT_TIMESTAMP, trigger_detail=EXCLUDED.trigger_detail, "
"updater=EXCLUDED.creator, update_time=CURRENT_TIMESTAMP"
)
quality_sql = (
"INSERT INTO example_quality_result "
"(run_id, candidate_sha256, judge_kind, dimension, scale_version, conclusion, detail, creator, tenant_id) "
"VALUES (%s, %s, 'review', 'ai_flavor_revision', %s, %s, %s::jsonb, %s, %s)"
)
with connect() as conn:
with conn.transaction():
conn.execute(run_sql, (run_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id))
exists = conn.execute(
"SELECT 1 FROM example_quality_result WHERE tenant_id=%s AND run_id=%s "
"AND judge_kind='review' AND dimension='ai_flavor_revision' "
"AND COALESCE(candidate_sha256,'')=%s",
(tenant_id, run_id, candidate_sha or ""),
).fetchone()
if exists is None:
conn.execute(quality_sql, (
run_id, candidate_sha, detail.get("rule_library_version"),
conclusion, json.dumps(detail, ensure_ascii=False), creator, tenant_id,
))
return {"run_id": run_id, "candidate_sha256": candidate_sha}
def _persist_downgrade(*, work_ref: str, text_hash: str, reason: str) -> dict:
"""降级也是运行事实:落 example_run,避免「静默没发生」。"""
from db import connect
run_id = _run_id(work_ref, text_hash, "downgrade")
detail = {"status": "downgraded_to_audit", "reason": reason, "work_ref": work_ref}
with connect() as conn:
with conn.transaction():
conn.execute(
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
"terminal_state, finished_at, creator, tenant_id) "
"VALUES (%s, NULL, 'user', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
"ON CONFLICT (run_id) DO NOTHING",
(run_id, json.dumps(detail, ensure_ascii=False), CREATOR, TENANT_ID),
)
return {"run_id": run_id}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="技能 4 修订:最小 patch + 硬门 + 审计,作者确认前永远是候选")
parser.add_argument("--text-file", type=Path, required=True)
parser.add_argument("--artifact", type=Path, required=True, help="诊断产物 JSON(缺它拒绝启动)")
parser.add_argument("--patches", type=Path, required=True, help="patch 清单 JSON 数组(经仲裁)")
parser.add_argument("--task-contract", type=Path, required=True,
help="任务合同 JSON:mode=Patch 须带事实快照或显式授权")
parser.add_argument("--snapshot", type=Path, help="事实快照 JSON")
parser.add_argument("--voice-ledger", type=Path, help="声音账 JSON(技能 1 产物)")
parser.add_argument("--pairwise", type=Path, help="跨模型成对选择记录 JSON")
parser.add_argument("--rewrite-model", default="claude")
parser.add_argument("--work-ref", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--offline", action="store_true", help="只产文件,不写 muse-example")
args = parser.parse_args(argv)
try:
text = args.text_file.read_text(encoding="utf-8")
artifact = _load_json(args.artifact, "诊断产物")
patches_raw = json.loads(args.patches.read_text(encoding="utf-8"))
if not isinstance(patches_raw, list):
raise ReviseContractError("patch 清单必须是 JSON 数组")
task_contract = _load_json(args.task_contract, "任务合同")
snapshot = _load_json(args.snapshot, "事实快照") if args.snapshot else None
ledger = _load_json(args.voice_ledger, "声音账") if args.voice_ledger else None
if ledger is None and not args.offline:
ledger = load_current_baseline(args.work_ref)
pairwise = _load_json(args.pairwise, "成对选择记录") if args.pairwise else None
record, audit = run_revision(
text, artifact=artifact, patches=patches_raw, task_contract=task_contract,
fact_snapshot=snapshot, voice_ledger=ledger, pairwise=pairwise,
rewrite_model=args.rewrite_model,
)
except DowngradedToAudit as exc:
# 受控降级不是错误:Patch 降为 Audit,不产候选稿,降级事实照常落库
persistence = {"status": "offline"} if args.offline else _persist_downgrade(
work_ref=args.work_ref, text_hash=artifact.get("text_hash", ""), reason=exc.reason)
print(json.dumps({"status": "downgraded_to_audit", "reason": exc.reason,
"persistence": persistence}, ensure_ascii=False))
return 0
except (ReviseContractError, RevisionNotAuthorized, PatchError, PairwiseNotExecuted,
report_mod.ForbiddenScoreError, load.LoadError, ValueError, OSError) as exc:
print(f"REVISE_CONTRACT_FAILED: {exc}")
return 2
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(audit, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if (not record["hard_gate"]["pass"] or not record["regression_gate"]["pass"]
or record["voice_gate"].get("pass") is not True):
# 声音账缺失/样本不足是 unverified,不得借 pairwise 结果伪装成 passed。
conclusion = "blocked"
elif record["pairwise_choice"]["choice"] in {"original", "tie", "both_bad"}:
conclusion = "no_gain"
else:
conclusion = "passed"
detail = {
"work_ref": args.work_ref,
"patches": [{"finding_id": p["finding_id"], "action": p["action"]} for p in patches_raw],
"hard_gate_pass": record["hard_gate"]["pass"],
"regression_pass": record["regression_gate"]["pass"],
"pairwise": bool(record["pairwise_choice"]),
"audit_ref": f"revise://ai-flavor/{args.output.name}",
}
if args.offline:
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
else:
persistence = persist_revision(
work_ref=args.work_ref, text_hash=artifact.get("text_hash", ""),
candidate_text=record["candidate_text"], audit=audit,
conclusion=conclusion, detail=detail,
)
print(json.dumps({
"status": conclusion,
"hard_gate_pass": record["hard_gate"]["pass"],
"regression_pass": record["regression_gate"]["pass"],
"candidate_chars": len(record["candidate_text"]),
"output": str(args.output),
"persistence": persistence,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""技能 4 修订离线测试:授权、仲裁、快照降级、硬门、复扫、盲评与落库合同。"""
import json
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "skills" / "diagnose-ai-flavor" / "scripts"))
import revise_ai_flavor as rev # noqa: E402
import diagnose_ai_flavor as diag # noqa: E402
from deai.pipeline import DowngradedToAudit # noqa: E402
CLEAN_TEXT = "值得注意的是,门外已经下起了雨。"
NUMBER_TEXT = "值得注意的是,他付了三百两。"
TASK = {"mode": "Patch", "allowed_scope": "全文", "author_approved_revision": True}
PAIRWISE = {"choice": "candidate", "rationale": "候选保真且删除了无功能套语", "selection_model": "gpt-5.6-sol"}
def _artifact(text: str) -> dict:
artifact = diag.run_diagnosis(text, work_ref="synthetic:demo")
for finding in artifact["findings"]:
finding["decision_proposal"] = "repair"
finding["arbitration_note"] = "测试仲裁:确认该命中无当前功能"
return artifact
def _delete_patch(artifact: dict, span: str, exact: str) -> dict:
finding = next(f for f in artifact["findings"] if span in f["spans"])
return {
"finding_id": finding["id"], "action": "delete", "original_exact": exact,
"replacement": "", "rationale": "空元话语,删除后信息不变",
"protected_invariants": [],
}
class RevisionContractTest(unittest.TestCase):
def test_full_patch_chain_passes_gates(self):
artifact = _artifact(CLEAN_TEXT)
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
record, audit = rev.run_revision(
CLEAN_TEXT, artifact=artifact, patches=patches,
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
)
self.assertTrue(record["hard_gate"]["pass"], record["hard_gate"])
self.assertTrue(record["regression_gate"]["pass"], record["regression_gate"])
self.assertEqual(record["candidate_text"], "门外已经下起了雨。")
self.assertEqual(record["pairwise_choice"]["choice"], "candidate")
self.assertIn("summary", audit)
def test_no_artifact_refuses_to_start(self):
with self.assertRaisesRegex(rev.ReviseContractError, "没有诊断产物"):
rev.run_revision(CLEAN_TEXT, artifact={}, patches=[{"x": 1}], task_contract=TASK)
def test_no_patches_refuses_to_start(self):
artifact = _artifact(CLEAN_TEXT)
with self.assertRaisesRegex(rev.ReviseContractError, "无事可做"):
rev.run_revision(CLEAN_TEXT, artifact=artifact, patches=[], task_contract=TASK)
def test_missing_snapshot_downgrades_to_audit(self):
artifact = _artifact(CLEAN_TEXT)
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
with self.assertRaises(DowngradedToAudit):
rev.run_revision(
CLEAN_TEXT, artifact=artifact, patches=patches,
task_contract=TASK, fact_snapshot=None, pairwise=PAIRWISE,
)
def test_patch_outside_author_scope_is_rejected(self):
artifact = _artifact(CLEAN_TEXT)
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
with self.assertRaisesRegex(ValueError, "allowed_scope"):
rev.run_revision(
CLEAN_TEXT, artifact=artifact, patches=patches,
task_contract={**TASK, "allowed_scope": ["f-other"]},
fact_snapshot={"entities": []}, pairwise=PAIRWISE,
)
def test_forged_deterministic_finding_is_rejected(self):
artifact = _artifact(CLEAN_TEXT)
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
artifact["findings"][0]["rule_id"] = "l999"
with self.assertRaisesRegex(ValueError, "active 规则"):
rev.run_revision(
CLEAN_TEXT, artifact=artifact, patches=patches,
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
)
def test_unarbitrated_finding_is_rejected(self):
artifact = diag.run_diagnosis(CLEAN_TEXT, work_ref="synthetic:demo")
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
with self.assertRaisesRegex(ValueError, "未经过 repair 仲裁"):
rev.run_revision(
CLEAN_TEXT, artifact=artifact, patches=patches,
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
)
def test_fact_delta_blocks_new_number(self):
artifact = _artifact(NUMBER_TEXT)
finding = next(f for f in artifact["findings"] if "值得注意的是" in f["spans"])
patches = [{
"finding_id": finding["id"], "action": "local_rewrite",
"original_exact": "值得注意的是,他付了三百两",
"replacement": "他付了三百五十两",
"rationale": "故意引入新数字,验证硬门拒收",
"protected_invariants": [],
}]
record, _ = rev.run_revision(
NUMBER_TEXT, artifact=artifact, patches=patches,
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
)
self.assertFalse(record["hard_gate"]["pass"])
self.assertTrue(record["hard_gate"]["checks"]["fact_delta"]["failures"])
def _run_cli(self, *, offline: bool, persist_return=None):
artifact = _artifact(CLEAN_TEXT)
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
text_path = root / "text.txt"
text_path.write_text(CLEAN_TEXT, encoding="utf-8")
artifact_path = root / "artifact.json"
artifact_path.write_text(json.dumps(artifact, ensure_ascii=False), encoding="utf-8")
patches_path = root / "patches.json"
patches_path.write_text(json.dumps(patches, ensure_ascii=False), encoding="utf-8")
contract_path = root / "contract.json"
contract_path.write_text(json.dumps(TASK), encoding="utf-8")
snapshot_path = root / "snapshot.json"
snapshot_path.write_text(json.dumps({"entities": []}), encoding="utf-8")
pairwise_path = root / "pairwise.json"
pairwise_path.write_text(json.dumps(PAIRWISE, ensure_ascii=False), encoding="utf-8")
output = root / "report.json"
with patch.object(rev, "persist_revision", return_value=persist_return) as persist, \
patch.object(rev, "load_current_baseline", return_value=None):
argv = [
"--text-file", str(text_path), "--artifact", str(artifact_path),
"--patches", str(patches_path), "--task-contract", str(contract_path),
"--snapshot", str(snapshot_path), "--pairwise", str(pairwise_path),
"--work-ref", "synthetic:demo", "--output", str(output),
]
if offline:
argv.append("--offline")
code = rev.main(argv)
return code, persist.call_count, json.loads(output.read_text(encoding="utf-8"))
def test_cli_requires_artifact_file(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
text_path = root / "text.txt"
text_path.write_text(CLEAN_TEXT, encoding="utf-8")
patches_path = root / "patches.json"
patches_path.write_text("[]", encoding="utf-8")
contract_path = root / "contract.json"
contract_path.write_text(json.dumps(TASK), encoding="utf-8")
code = rev.main([
"--text-file", str(text_path), "--artifact", str(root / "missing.json"),
"--patches", str(patches_path), "--task-contract", str(contract_path),
"--work-ref", "synthetic:demo", "--output", str(root / "r.json"), "--offline",
])
self.assertEqual(code, 2)
def test_cli_offline_patch_chain_writes_report_without_db(self):
code, calls, report = self._run_cli(offline=True)
self.assertEqual(code, 0)
self.assertEqual(calls, 0)
self.assertEqual(report["final"]["candidate_text"], "门外已经下起了雨。")
def test_cli_default_persists_revision(self):
code, calls, _ = self._run_cli(offline=False, persist_return={"run_id": "rev-x"})
self.assertEqual(code, 0)
self.assertEqual(calls, 1)
if __name__ == "__main__":
unittest.main()

View File

@ -23,12 +23,13 @@ disable-model-invocation: true
- `proseExcerpts`:用于连续性与叙事声音的历史正文摘录。 - `proseExcerpts`:用于连续性与叙事声音的历史正文摘录。
- `patternReferences`:可参考的写作范式——每条给名字(name)、一句话摘要(summary)与写法要点(writingPoints);只供借鉴写法,不是事实约束。 - `patternReferences`:可参考的写作范式——每条给名字(name)、一句话摘要(summary)与写法要点(writingPoints);只供借鉴写法,不是事实约束。
- `lengthContract`:本章动态篇幅合同。 - `lengthContract`:本章动态篇幅合同。
- `styleConstraints`:文风约束。 - `styleConstraints`:文风约束;生产组装时还包含 `prevent-ai-flavor` 生成的有限人感前置约束(规则/声音账指纹只留在完整 WriterContext,不投影给 writer)。
Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验收状态、实验臂、oracle 或目标章之后的内容,也不得自行检索;投影里没有的设定不应被当作已确认事实。 Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验收状态、实验臂、oracle 或目标章之后的内容,也不得自行检索;投影里没有的设定不应被当作已确认事实。
## 功能约束 ## 功能约束
0. 每次生成前必须经过 `prevent-ai-flavor` 合同组装;缺失时不伪造通用真人文风,保留规则为空的诚实合同并由章后诊断兜底。
1. 一次一整章;篇幅以 `lengthContract.targetChars/minChars/maxChars` 为唯一口径,由目标章之前有效 Canonical 章长中位数和细纲密度确定性计算,并受 2000–10000 汉字硬边界约束;不得再使用固定字数范围。 1. 一次一整章;篇幅以 `lengthContract.targetChars/minChars/maxChars` 为唯一口径,由目标章之前有效 Canonical 章长中位数和细纲密度确定性计算,并受 2000–10000 汉字硬边界约束;不得再使用固定字数范围。
2. 伏笔只按细纲动作执行:说埋就埋、说推就推、说收就收;不擅自提前回收,不新开大坑。 2. 伏笔只按细纲动作执行:说埋就埋、说推就推、说收就收;不擅自提前回收,不新开大坑。
3. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。 3. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。

View File

@ -46,6 +46,7 @@ agent-example/
│ ├── ddl/ # 可审计 DDL / 迁移文件 │ ├── ddl/ # 可审计 DDL / 迁移文件
│ ├── 表映射.md │ ├── 表映射.md
│ └── 连接信息.md │ └── 连接信息.md
├── humanization/ # 去 AI 味与人感资产层(合同/规则/样例/执行骨架;验证期自治,升华进 Muse 时同步回父仓;20 项研究覆盖矩阵见 humanization/research/)
├── knowledge/ # 仓内参考资产;未经绑定、授权不得进入上下文 ├── knowledge/ # 仓内参考资产;未经绑定、授权不得进入上下文
├── docs/ # 设计、评测、样张与历史执行记录 ├── docs/ # 设计、评测、样张与历史执行记录
├── .venv/ # 本地 Python 运行环境 ├── .venv/ # 本地 Python 运行环境
@ -62,7 +63,7 @@ agent-example/
- 执行、证据与上下文:`execute-claude-task`、`record-run-evidence`、`freeze-context`、`assemble-context`。 - 执行、证据与上下文:`execute-claude-task`、`record-run-evidence`、`freeze-context`、`assemble-context`。
- 流程与主权治理:`decide-candidate`。 - 流程与主权治理:`decide-candidate`。
- 清洗、拆解与知识:`clean-book-text`、`deconstruct-book`、`extract-chapter-knowledge`、`review-knowledge-cards`、`extract-work-knowledge`、`maintain-work-extraction`。 - 清洗、拆解与知识:`clean-book-text`、`deconstruct-book`、`extract-chapter-knowledge`、`review-knowledge-cards`、`extract-work-knowledge`、`maintain-work-extraction`。
- 质量证据回填:`capture-ai-flavor-cases`。 - 去 AI 味与人感(父仓专题-09 五技能先行验证):`establish-voice-baseline`(定基线)、`prevent-ai-flavor`(前置预防)、`diagnose-ai-flavor`(诊断)、`revise-ai-flavor`(修订)、`capture-ai-flavor-cases`(挖掘/案例采集);共享资产层在 `humanization/`,接力铁律见 `meta/chains/`。
- 规划与写作:`design-story-foundation`、`plan-story`、`plan-chapter`、`write-next-chapter`、`rewrite-selection`、`expand-scene`、`polish-prose`。 - 规划与写作:`design-story-foundation`、`plan-story`、`plan-chapter`、`write-next-chapter`、`rewrite-selection`、`expand-scene`、`polish-prose`。
- 检测与质量评测:`check-content-consistency`、`score-content-quality`、`optimize-content-quality`、`evaluate-frozen-replay`。 - 检测与质量评测:`check-content-consistency`、`score-content-quality`、`optimize-content-quality`、`evaluate-frozen-replay`。

View File

@ -0,0 +1,34 @@
-- 声音账(voice baseline):技能 1「定基线」的库内载体。
-- 一部作品一行一版本:新版本 append 一行并把旧行 superseded,历史版本保留审计。
-- 声音账只读已确认正文与作者样张产出(专题-09 §5.1);脚本层强制账内每个
-- 口癖/保护片段必须能在来源正文中找到(grounding 门),数据库层只锁结构与状态。
-- ledger 同时兼容 deai.gates.protected_checks 的 voice_thin 形状
-- (untouchable_verbal_tics / protected_spans),修订门禁直接消费。
CREATE TABLE IF NOT EXISTS example_voice_baseline (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
work_ref VARCHAR(256) NOT NULL, -- 作品逻辑引用(与案例卡 work_ref 同域)
version INTEGER NOT NULL, -- 作品内单调版本号(1 起)
ledger JSONB NOT NULL, -- 声音账全文(结构由 establish-voice-baseline 脚本校验)
ledger_sha256 VARCHAR(64) NOT NULL, -- ledger 规范化 JSON 的 sha256
source_text_sha256 VARCHAR(64) NOT NULL, -- 定基线所依据的已确认正文 sha256(可多文拼接)
reviewer VARCHAR(64) NOT NULL DEFAULT '', -- 人工确认人(基线必须人确认,专题-09 §5.1)
note TEXT NOT NULL DEFAULT '',
superseded BOOLEAN NOT NULL DEFAULT FALSE, -- 被更新版本取代;保留审计不删除
creator VARCHAR(64) NOT NULL DEFAULT '1',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '1',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
tenant_id BIGINT NOT NULL DEFAULT 1,
CONSTRAINT uk_example_voice_baseline UNIQUE (tenant_id, work_ref, version),
CONSTRAINT chk_example_voice_baseline_version CHECK (version >= 1),
CONSTRAINT chk_example_voice_baseline_ledger_sha CHECK (ledger_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT chk_example_voice_baseline_source_sha CHECK (source_text_sha256 ~ '^[0-9a-f]{64}$'),
-- 未确认的基线不得冒充当前有效:只有带确认人的最新版本可被消费(脚本层取 latest 时同样强制)
CONSTRAINT chk_example_voice_baseline_reviewer CHECK (superseded OR reviewer <> '')
);
CREATE INDEX IF NOT EXISTS idx_example_voice_baseline_work
ON example_voice_baseline(tenant_id, work_ref, version DESC) WHERE deleted = FALSE;
CREATE OR REPLACE TRIGGER trg_example_voice_baseline_updated_at
BEFORE UPDATE ON example_voice_baseline FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@ -2,7 +2,7 @@
> 口径(创始人拍板③ 2026-07-10):主仓表**原样不改列**;实验私货全进 `example_*` 前缀。 > 口径(创始人拍板③ 2026-07-10):主仓表**原样不改列**;实验私货全进 `example_*` 前缀。
> 建表方式:`db/ddl/` 下文件经 `access-database` skill `apply`,主仓部分为 `muse-cloud/sql/muse/` 原文拷贝或逐字摘录。 > 建表方式:`db/ddl/` 下文件经 `access-database` skill `apply`,主仓部分为 `muse-cloud/sql/muse/` 原文拷贝或逐字摘录。
> 已应用顺序:V1 → V3 → V5 → 90-ALTER摘录 → V26 → 91-example(2026-07-13)→ 97/98(2026-07-30)→ 104 AI 味案例(2026-08-14)→ 105/106/107/108/109 先审后入与复利闭环(2026-08-14)。库内表现状以 `access-database` skill `tables` 实时输出为准。 > 已应用顺序:V1 → V3 → V5 → 90-ALTER摘录 → V26 → 91-example(2026-07-13)→ 97/98(2026-07-30)→ 104 AI 味案例(2026-08-14)→ 105/106/107/108/109 先审后入与复利闭环(2026-08-14)→ 110 声音账(2026-08-15)。库内表现状以 `access-database` skill `tables` 实时输出为准。
> 96 不启用:`96-example参考作品授权快照.sql` 已实现但**决定不 apply**(单用户本地不做多租户授权机制,2026-07-30 拍板,见领域索引 §9);库内无该表。 > 96 不启用:`96-example参考作品授权快照.sql` 已实现但**决定不 apply**(单用户本地不做多租户授权机制,2026-07-30 拍板,见领域索引 §9);库内无该表。
## 主仓一致表(20 张) ## 主仓一致表(20 张)
@ -30,7 +30,7 @@
| muse_knowledge_draft | V5 | V14(两快照列→varchar(128)) | **草稿**知识行(B2 拆书产出落此) | | muse_knowledge_draft | V5 | V14(两快照列→varchar(128)) | **草稿**知识行(B2 拆书产出落此) |
| muse_knowledge_binding | V5 | V14(同上) | 作品↔库绑定(C3 起用) | | muse_knowledge_binding | V5 | V14(同上) | 作品↔库绑定(C3 起用) |
## 实验私货表(`db/ddl/91` + 92 + 93 + 97 + 98 + 104;94/95 见库内现状) ## 实验私货表(`db/ddl/91` + 92 + 93 + 97 + 98 + 104 + 110;94/95 见库内现状)
| 表 | 用途 | | 表 | 用途 |
|---|---| |---|---|
@ -53,6 +53,7 @@
| example_projection_run | 投影登记(107):摘要/抽取/embedding 等派生物绑 source_revision+文本哈希;pending/completed/failed/stale;幂等键防重放,失败不冒充完成 | | example_projection_run | 投影登记(107):摘要/抽取/embedding 等派生物绑 source_revision+文本哈希;pending/completed/failed/stale;幂等键防重放,失败不冒充完成 |
| example_lesson | 经验升格登记(108):lesson/win 证据绑 run_id+候选哈希;proposed→reviewing→promoted/rejected,DB 触发器禁止跳过评审的自动升格 | | example_lesson | 经验升格登记(108):lesson/win 证据绑 run_id+候选哈希;proposed→reviewing→promoted/rejected,DB 触发器禁止跳过评审的自动升格 |
| example_candidate_cas | 候选 CAS 状态链(109):一次运行一条链(DRAFT/CHECKING/PASSED/REJECTED),revision 单调 +1,触发器锁方向闭集与身份不可变 | | example_candidate_cas | 候选 CAS 状态链(109):一次运行一条链(DRAFT/CHECKING/PASSED/REJECTED),revision 单调 +1,触发器锁方向闭集与身份不可变 |
| example_voice_baseline | 声音账(110):技能 1 定基线产物,一作品一版本 append + supersede;grounding 门由 establish-voice-baseline 脚本强制;修订门禁与前置预防机械消费 |
## 暂缓建表登记(主仓有、实验现阶段未建;需要时按原样加建) ## 暂缓建表登记(主仓有、实验现阶段未建;需要时按原样加建)

43
humanization/README.md Normal file
View File

@ -0,0 +1,43 @@
# humanization · 去 AI 味与人感体系(agent-example 先行验证副本)
本目录是去 AI 味与人感体系的资产层与执行骨架,初始拷贝自父仓 [`muse-deai/`](../../muse-deai/)(提交 `92a88e86`,一级能力),自拷贝之日起由 agent-example 自治管理与演进。
## 权威与演进规则
- **验证期本目录自治**:合同、规则、样例、骨架的改动直接在本仓演进,Git 历史留痕;父仓 muse-deai 同主题暂停更新,不产生两套并行版本。
- **同步方向只有一条**:验证收敛、人感体系从 agent-example 升华进 Muse 时,把验证过的资产同步回父仓 muse-deai 并更新设计文档。升华之前不向父仓回填。
- 设计 SoT(概念定义、五技能合同、验收口径)归属父仓 [`design-docs/专题-09-去AI味与人感体系设计.md`](../../design-docs/专题-09-去AI味与人感体系设计.md);本目录不重复定义概念,验证中发现合同级缺陷时向父仓提请修订。
- 案例卡的正式载体是 `muse-example` 库(见 `.claude/skills/capture-ai-flavor-cases`);本目录 `cards/` 只存合成示例。`src/deai/cards.py` 保留早期离线 API 兼容层,但输出/读取已对齐 `ai-flavor-case-v1`,生产采集入口仍是 `capture_cases.py`。
## 目录
```text
contracts/ 数据合同:案例/规则/样例/发现/patch/审计 + voice_baseline/prevention
src/deai/ 执行骨架:规则装载与完整指纹、载体 scope/mask、五层诊断、基线画像、门禁、评测生命周期
rules/ 规则库 v0(26 条 active,覆盖 regex/handler/density/model_judgment 四类触发器);active 必须配齐四类样例;其中 16 条为 2026-08-16 所有者决定跳过 holdout 激活,证据栏留痕。
这 16 条的 holdout 是欠账:升回父仓或申报专题-09 §12 一级验收前必须补齐,或提请父仓修订 SoT。
26 条超出专题-09 §10 一级「10–20 条」区间,验证期定位是机制覆盖先行,升华进 Muse 时对齐区间或提请修订
samples/ 样例库(sf / snf / boundary / regression),候选规则也必须先有四类夹具
cards/ 合成案例卡示例
research/ 20 项开源研究机制追踪矩阵 + 中文轻量去重人感规则目录(研究候选,不等于 active)
tests/ 合同负路测试:../.venv/bin/python -m unittest discover -s tests -v(在 humanization/ 下运行)
eval/ 判别试跑与回归探针:../.venv/bin/python eval/run_eval.py
config.yaml 模型角色分离配置(改写模型 ≠ 选择模型,代码强制)
```
## 与五个技能 skill 的对应
| 技能 | skill | 消费本目录什么 |
|---|---|---|
| 1 定基线 | `establish-voice-baseline` | 产出声音账,供修订保护项与前置预防正样例 |
| 2 前置预防 | `prevent-ai-flavor` | `rules/` active 规则的负约束 |
| 3 诊断 | `diagnose-ai-flavor` | `src/deai/diagnose.py` + `rules/` |
| 4 修订 | `revise-ai-flavor` | `patch.py` / `gates.py` / `pairwise.py` / `report.py` |
| 5 挖掘 | `capture-ai-flavor-cases` | 案例卡 → `rules/` 候选(propose-rule 门) |
## 运行测试
```bash
cd agent-example/humanization
../.venv/bin/python -m unittest discover -s tests -v
```

View File

@ -0,0 +1,32 @@
# 案例卡示例(默认 shadow)。这是用户自有/获授权文本的结构示例,不是 active 规则。
cards:
- schema_version: ai-flavor-case-v1
id: card-backfill-l002-001
card_type: ai_flavor_case
state: shadow
label: unclassified
layer: lexical
carrier: narration
capture_mode: backfill
excerpt: "值得注意的是,门外已经下起了雨。"
context: "她把伞递过去。值得注意的是,门外已经下起了雨。两人谁也没说话。"
source:
kind: existing_work
license: owned
work_ref: work-demo-001
source_sha256: "78ba8e4e31715f1d0e5d5432f6e5dcea3c00efd78850b5eb9da0e7c60c30a322"
text_hash: "sha256:78ba8e4e31715f1d0e5d5432f6e5dcea3c00efd78850b5eb9da0e7c60c30a322"
excerpt_sha256: "38a22f04c9914021d1bde59c3d554e4e6adb4a6252a4b170cf1a1d051c2e3249"
excerpt_hash: "sha256:38a22f04c9914021d1bde59c3d554e4e6adb4a6252a4b170cf1a1d051c2e3249"
location: "chapter-003:paragraph-12"
observation:
pattern_key: lexical.meta_disclaimer
surface: "值得注意的是"
diagnosis: "待作者确认是否只是信息提示,不能仅凭表面形式判为应修。"
pattern: "无功能元话语位于叙述句开头"
rationale: "待作者确认是否只是信息提示,不能仅凭表面形式判为应修。"
function_check:
- "是否承担转折或信息提示"
- "是否为角色/场内文书声线"
risk_if_changed: "删除可能削弱段落转折,改写可能引入未确认因果。"
suggested_action: "保留 shadow,补充上下文后再标注。"

12
humanization/config.yaml Normal file
View File

@ -0,0 +1,12 @@
# 运行配置:模型角色分离是一级合同要求(专题-09 §6 阶段 10),不是建议
models:
# 改写执行所在的模型族(Muse 生成管线 / 修订执行者)
rewrite: claude
# 成对选择模型必须与 rewrite 不同;pairwise.py 会强制校验,同名即视为阶段未执行
selection: gpt-5.6-sol
# 诊断上下文窗口:命中 span 前后各取 N 字符,换行转空格(U0 契约问题 #3)
context_window_chars: 12
# 修订循环上限(专题-09 §6 停止条件)
max_rounds: 3

View File

@ -0,0 +1,39 @@
{
"$comment": "审计报告合同(专题-09 §4.5)。禁止单一总分类字段:report.py 拒绝 human_score/真人率 等键。pairwise_choice 在 Audit 模式为 null。",
"type": "object",
"required": ["summary", "patches", "final"],
"properties": {
"summary": {
"type": "object",
"required": ["high_confidence_findings", "advisory_findings", "kept_by_design", "needs_author_decision"],
"properties": {
"high_confidence_findings": {"type": "array", "items": {"type": "string"}},
"advisory_findings": {"type": "array", "items": {"type": "string"}},
"kept_by_design": {"type": "array", "items": {"type": "object", "required": ["finding_id", "reason"], "properties": {"finding_id": {"type": "string"}, "reason": {"type": "string", "minLength": 2}}}},
"needs_author_decision": {"type": "array", "items": {"type": "object", "required": ["finding_id", "question"], "properties": {"finding_id": {"type": "string"}, "question": {"type": "string", "minLength": 2}}}}
}
},
"patches": {"type": "array"},
"final": {
"type": "object",
"required": ["hard_gate_result", "voice_gate_result", "regression_gate_result", "pairwise_choice", "unresolved_risks"],
"properties": {
"candidate_text": {"type": "string"},
"hard_gate_result": {"type": "object"},
"voice_gate_result": {"type": "object"},
"regression_gate_result": {"type": "object"},
"pairwise_choice": {
"$comment": "Audit 模式无成对选择,允许 null;Patch 模式必须是完整对象",
"type": ["object", "null"],
"required": ["choice", "rationale", "selection_model"],
"properties": {
"choice": {"type": "string", "enum": ["original", "candidate", "tie", "both_bad"]},
"rationale": {"type": "string", "minLength": 2},
"selection_model": {"type": "string", "minLength": 1}
}
},
"unresolved_risks": {"type": "array", "items": {"type": "string"}}
}
}
}
}

View File

@ -0,0 +1,59 @@
{
"$comment": "AI 味案例卡合同(专题-09 §4.0 / §8.1)。案例卡是证据层 Shadow,不是故事实体卡,也不是已生效规则;字段与 capture_cases.validate_card 保持一致。",
"type": "object",
"required": [
"schema_version",
"id",
"card_type",
"state",
"label",
"layer",
"carrier",
"capture_mode",
"source",
"observation"
],
"properties": {
"schema_version": {"type": "string", "enum": ["ai-flavor-case-v1"]},
"id": {"type": "string", "minLength": 2},
"card_type": {"type": "string", "enum": ["ai_flavor_case"]},
"state": {"type": "string", "enum": ["shadow", "canonical", "rejected", "archived"]},
"label": {"type": "string", "enum": ["sf", "snf", "boundary", "regression", "unclassified"]},
"layer": {"type": "string", "enum": ["mechanical", "lexical", "structural", "density", "semantic", "unknown"]},
"carrier": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"]},
"capture_mode": {"type": "string", "enum": ["backfill", "live_feedback", "synthetic", "review_import"]},
"excerpt": {"type": "string"},
"context": {"type": "string"},
"source": {
"type": "object",
"required": ["kind", "license", "source_sha256", "excerpt_sha256", "location"],
"properties": {
"kind": {"type": "string", "enum": ["existing_work", "creation_feedback", "synthetic", "public_domain"]},
"license": {"type": "string", "enum": ["owned", "licensed", "public_domain", "synthetic", "research_only", "unauthorized"]},
"work_ref": {"type": "string"},
"source_ref": {"type": "string"},
"source_sha256": {"type": "string", "minLength": 64},
"excerpt_sha256": {"type": "string", "minLength": 64},
"location": {"type": ["object", "string"]},
"surface_location": {"type": "object"}
}
},
"observation": {
"type": "object",
"required": ["pattern_key", "surface", "diagnosis", "function_check", "risk_if_changed", "suggested_action"],
"properties": {
"pattern_key": {"type": "string", "minLength": 2},
"surface": {"type": "string", "minLength": 1},
"diagnosis": {"type": "string", "minLength": 2},
"pattern": {"type": "string"},
"rationale": {"type": "string"},
"function_check": {"type": "array", "minItems": 1, "items": {"type": "string"}},
"risk_if_changed": {"type": "string", "minLength": 2},
"suggested_action": {"type": "string", "minLength": 2}
}
},
"feedback": {"type": "object"},
"review": {"type": "object"},
"rule_candidate_ids": {"type": "array", "items": {"type": "string"}}
}
}

View File

@ -0,0 +1,22 @@
{
"$comment": "发现合同(专题-09 §4.2,含 U0 修正 #2 arbitration_note 与 #3 context_window 规格)。spans 为多点结构:密度/语义层的分布式命中逐点登记。",
"type": "object",
"required": ["id", "text_hash", "rule_id", "rule_version", "spans", "context_window", "layer", "evidence", "possible_function", "confidence", "decision_proposal"],
"properties": {
"id": {"type": "string", "minLength": 1},
"text_hash": {"type": "string", "minLength": 7},
"rule_id": {"type": "string"},
"rule_version": {"type": "integer"},
"spans": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
"context_window": {"type": "string", "$comment": "span 前后各 12 字符,换行转空格(config context_window_chars)"},
"layer": {"type": "string", "enum": ["mechanical", "lexical", "structural", "density", "semantic"]},
"evidence": {"type": "string", "minLength": 2},
"possible_function": {"type": "string", "$comment": "none / unknown / 具名功能 / pending_arbitration"},
"confidence": {"type": "string", "enum": ["high", "medium", "low", "candidate"]},
"decision_proposal": {"type": "string", "enum": ["repair", "keep", "ask", "pending"]},
"arbitration_note": {"type": "string"},
"carrier": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"]},
"default_disposition": {"type": "string", "enum": ["blocking", "candidate", "advisory"]},
"carve_out_candidates": {"type": "array", "items": {"type": "string"}}
}
}

View File

@ -0,0 +1,13 @@
{
"$comment": "修订项合同(专题-09 §4.3)。original_exact 必须覆盖对应发现的至少一个 span(允许紧邻标点的最小扩展,U0 修正 #6),且在当前文本中唯一匹配。",
"type": "object",
"required": ["finding_id", "action", "original_exact", "replacement", "rationale", "protected_invariants"],
"properties": {
"finding_id": {"type": "string", "minLength": 1},
"action": {"type": "string", "enum": ["skip", "delete", "compress", "local_rewrite", "patch", "structural_proposal"]},
"original_exact": {"type": "string", "minLength": 1},
"replacement": {"type": "string"},
"rationale": {"type": "string", "minLength": 2},
"protected_invariants": {"type": "array", "items": {"type": "string"}}
}
}

View File

@ -0,0 +1,15 @@
{
"$comment": "生成前人感约束合同。结构化规则证据与 writer 投影分开,禁止把案例卡当正文素材。",
"type": "object",
"required": ["schema_version", "work_ref", "built_from", "negative_constraints", "positive_samples", "protected_spans", "blacklist", "writer_constraints"],
"properties": {
"schema_version": {"type": "string", "enum": ["ai-flavor-prevention-v1", "ai-flavor-prevention-v2"]},
"work_ref": {"type": "string", "minLength": 1},
"built_from": {"type": "object"},
"negative_constraints": {"type": "array"},
"positive_samples": {"type": "array"},
"protected_spans": {"type": "array", "items": {"type": "string"}},
"blacklist": {"type": "array", "items": {"type": "string"}},
"writer_constraints": {"type": "array", "items": {"type": "string", "minLength": 1}}
}
}

View File

@ -0,0 +1,44 @@
{
"$comment": "规则合同(专题-09 §4.1)。active 规则必须配齐四类样例,由 load.py 强制,不在 schema 内表达。",
"type": "object",
"required": ["id", "name", "layer", "carrier_scope", "trigger", "default_disposition", "fix_hint", "samples", "version", "status", "evidence"],
"properties": {
"id": {"type": "string", "minLength": 2},
"name": {"type": "string", "minLength": 2},
"layer": {"type": "string", "enum": ["mechanical", "lexical", "structural", "density", "semantic"]},
"carrier_scope": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "all"]},
"trigger": {
"type": "object",
"required": ["type"],
"properties": {
"type": {"type": "string", "enum": ["regex", "handler", "density", "model_judgment"]},
"pattern": {"type": "string"},
"criteria": {"type": "string"},
"handler": {"type": "string", "enum": ["short_sentence_run", "repeated_sentence_start", "camera_action_list", "uniform_paragraph_length"]},
"max_chars": {"type": "integer", "minimum": 1},
"min_run": {"type": "integer", "minimum": 2},
"window_chars": {"type": "integer", "minimum": 50},
"min_hits": {"type": "integer", "minimum": 2},
"tolerance": {"type": "integer", "minimum": 1, "maximum": 50}
}
},
"carve_out": {"type": "array", "items": {"type": "string"}},
"default_disposition": {"type": "string", "enum": ["blocking", "candidate", "advisory"]},
"function_check": {"type": "array", "items": {"type": "string"}},
"fix_hint": {"type": "string", "minLength": 2},
"samples": {
"type": "object",
"required": ["sf", "snf", "boundary", "regression"],
"properties": {
"sf": {"type": "array", "items": {"type": "string"}},
"snf": {"type": "array", "items": {"type": "string"}},
"boundary": {"type": "array", "items": {"type": "string"}},
"regression": {"type": "array", "items": {"type": "string"}}
}
},
"version": {"type": "integer"},
"status": {"type": "string", "enum": ["candidate", "active", "deprecated"]},
"evidence": {"type": "string", "minLength": 2},
"case_card_ids": {"type": "array", "items": {"type": "string", "minLength": 2}}
}
}

View File

@ -0,0 +1,17 @@
{
"$comment": "样例合同(专题-09 §8.2 四类样例)。每条必须带标注理由:为什么该修/为什么不能改/边界在哪/改了会出什么事。可选 case_card_id 把 Canonical 样例追溯到案例卡。",
"type": "object",
"required": ["id", "type", "carrier", "source", "text", "note"],
"properties": {
"id": {"type": "string", "minLength": 2},
"type": {"type": "string", "enum": ["sf", "snf", "boundary", "regression"]},
"rules": {"type": "array", "items": {"type": "string"}},
"carrier": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "mixed"]},
"source": {"type": "string", "enum": ["hand_written", "synthetic", "public_domain", "licensed"]},
"text": {"type": "string", "minLength": 4},
"note": {"type": "string", "minLength": 4},
"case_card_id": {"type": "string", "minLength": 2},
"source_ref": {"type": "string", "minLength": 1},
"source_license": {"type": "string", "enum": ["owned", "licensed", "public_domain", "synthetic"]}
}
}

View File

@ -0,0 +1,19 @@
{
"$comment": "声音账候选/Canonical 合同(专题-09 §4.4/§5.1)。统计不足必须显式 unknown,样张必须由技能 1 grounding。",
"type": "object",
"required": ["schema_version", "work_ref", "narrator", "characters", "untouchable_verbal_tics", "protected_spans", "blacklist"],
"properties": {
"schema_version": {"type": "string", "enum": ["voice-baseline-v1"]},
"work_ref": {"type": "string", "minLength": 1},
"status": {"type": "string", "enum": ["candidate", "canonical"]},
"narrator": {"type": "object"},
"characters": {"type": "object"},
"untouchable_verbal_tics": {"type": "object"},
"protected_spans": {"type": "array", "items": {"type": "string"}},
"blacklist": {"type": "array", "items": {"type": "string"}},
"passing_samples": {"type": "array", "items": {"type": "string"}},
"sources": {"type": "array"},
"sampling": {"type": "object"},
"unknown_fields": {"type": "array", "items": {"type": "string"}}
}
}

View File

@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""U4 评测运行器(专题-09 §9 / §12 一级验收的自动化部分)。
两个探针:
1. 规则判别试跑:每条 regex 规则在自己的 SF 样例上必须命中(召回自查);
在 SNF 样例上的命中是**预期内的表面碰撞**——SNF 的定义就是「相同表面形式
但承担功能」,判别力在仲裁/carve_out(模型与人),不在触发器。
model_judgment 规则无法自动跑,标「待模型判定」。
2. 回归陷阱门禁探针:对每条 regression 样例的「模拟坏改写」跑机械化探针——
无授权重写探针(结构校验,patches=[])+ 不变量探针(数字/引文/口癖归因)。
分类输出:机械可拦 / 语义级(门禁 unverified,需仲裁与盲评兜底)。
诚实约束:样例量是冷启动级别(n 小),本报告只出试跑数据与工具验证,
不出「规则有效」结论(专题-09 §9.3:n=1 不下结论)。
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from deai import evaluation, gates, load # noqa: E402
def parse_regression_sample(text: str):
"""回归样例题干格式:原文:X / 模拟坏改写:Y。抽象描述类样例返回 None。"""
m_orig = re.search(r"原文:(.*?)(?:\n|$)", text)
m_bad = re.search(r"模拟坏改写:(.*?)(?:\n|$)", text, re.DOTALL)
if not m_orig or not m_bad:
return None
return m_orig.group(1).strip(), m_bad.group(1).strip()
def probe_rule_discrimination(rules: dict, samples: dict) -> list:
rows = []
for rule in sorted(rules.values(), key=lambda r: r["id"]):
if rule["trigger"]["type"] == "model_judgment":
rows.append({"rule": rule["id"], "kind": "model_judgment",
"sf_hit": "-", "snf_surface": "-", "note": "待模型判定(一级人工/二级接入)"})
continue
report = evaluation.evaluate_rule_contract(rule, samples)
sf_rows = [row for row in report["rows"] if row["kind"] == "sf"]
snf_rows = [row for row in report["rows"] if row["kind"] == "snf"]
sf_hit = sum(1 for row in sf_rows if row["surface_hit"])
snf_surf = sum(1 for row in snf_rows if row["surface_hit"])
rows.append({
"rule": rule["id"], "kind": rule["trigger"]["type"],
"sf_hit": f"{sf_hit}/{len(sf_rows)}",
"snf_surface": f"{snf_surf}/{len(snf_rows)}",
"note": "SF 召回正常" if sf_hit == len(sf_rows) else "SF 召回有缺口,规则或样例需修",
})
return rows
def probe_regression_traps(samples: dict) -> list:
rows = []
voice_tics = {"老周": ["我说小子"]}
for sid, s in sorted(samples.items()):
if s["type"] != "regression":
continue
parsed = parse_regression_sample(s["text"])
if parsed is None:
rows.append({"sample": sid, "mechanical": "—", "verdict": "抽象描述类,需人工评审"})
continue
original, bad = parsed
caught = []
# 探针 1:无授权重写——任何不经批准 patch 的整体改写都违反结构校验
if gates.structural_verify(original, bad, [], {})["pass"] is False and bad != original:
caught.append("结构校验(无授权重写)")
# 探针 2:数字归因(无 patch 可归因 → 任何数字增减都红)
if gates.number_attribution(original, bad, [])["pass"] is False:
caught.append("数字归因")
# 探针 3:引文归因
if gates.quote_attribution(original, bad, [])["pass"] is False:
caught.append("引文归因")
# 探针 4:口癖保护(样例涉及老周口癖时)
if "我说小子" in original and gates.protected_checks(original, bad, {"untouchable_verbal_tics": voice_tics})["pass"] is False:
caught.append("口癖保护")
rows.append({
"sample": sid,
"mechanical": "、".join(caught) if caught else "未拦",
"verdict": "机械可拦" if caught else "语义级:门禁 unverified,需仲裁/盲评兜底",
})
return rows
# patch 形态伤害探针:把伤害放进「看似合法的 patch replacement」里,
# 验证硬门在结构校验必然通过的情况下还能不能拦到实质伤害。
# expected: caught = 机械应拦;semantic = 已知机械拦不住(门禁 unverified,仲裁/盲评兜底)
PATCH_FORM_PROBES = [
dict(sample="reg-004", desc="数字篡改(三百两→三百五十两)",
original_exact="值三百两银子", replacement="值三百五十两银子", expected="caught"),
dict(sample="reg-001", desc="新增日期(秋分的夜里)",
original_exact="那时谁也不知道", replacement="那年秋分的夜里,谁也不知道", expected="caught"),
dict(sample="reg-l001-01", desc="模态篡改(多半→都)",
original_exact="研究表明,能进这种地方的修士,多半背景不凡。",
replacement="能进这种地方的修士,背景都不凡。", expected="caught"),
dict(sample="reg-m002-01", desc="replacement 续写新内容",
original_exact="抱歉,我无法继续这个故事。",
replacement="她裹紧衣领,走进了巷子深处。", expected="semantic"),
]
def probe_patch_form_damage() -> list:
rows = []
for probe in PATCH_FORM_PROBES:
patches = [{"finding_id": "f1", "action": "local_rewrite",
"original_exact": probe["original_exact"],
"replacement": probe["replacement"],
"rationale": "探针", "protected_invariants": []}]
caught = []
if gates.fact_delta(patches)["pass"] is False:
caught.append("事实增量")
# 数字归因在单 patch 场景等价于 fact_delta 的数字部分,补充跑一遍防漏
original = "前文。" + probe["original_exact"] + "后文。"
candidate = "前文。" + probe["replacement"] + "后文。"
if gates.number_attribution(original, candidate, patches)["pass"] is False:
caught.append("数字归因")
if gates.quote_attribution(original, candidate, patches)["pass"] is False:
caught.append("引文归因")
if gates.temporal_fact_delta(patches)["pass"] is False:
caught.append("时间锚点")
if gates.modality_attribution(patches)["pass"] is False:
caught.append("模态守恒")
got = "caught" if caught else "semantic"
rows.append({
"sample": probe["sample"], "desc": probe["desc"],
"caught": "、".join(caught) if caught else "未拦(语义级)",
"match_expectation": got == probe["expected"],
})
return rows
def main():
samples = load.load_samples()
rules = load.load_rules(samples=samples)
print("== 规则判别试跑 ==")
for row in probe_rule_discrimination(rules, samples):
print(f"{row['rule']:<8} {row['kind']:<15} SF命中 {row['sf_hit']:<6} "
f"SNF表面碰撞 {row['snf_surface']:<6} {row['note']}")
print()
print("== 回归陷阱门禁探针(无授权重写形态) ==")
for row in probe_regression_traps(samples):
print(f"{row['sample']:<14} {row['verdict']:<28} 探针: {row['mechanical']}")
print()
print("== 回归陷阱门禁探针(patch 内伤害形态) ==")
for row in probe_patch_form_damage():
flag = "符合预期" if row["match_expectation"] else "!! 与预期不符"
print(f"{row['sample']:<14} {row['desc']:<26} {row['caught']:<22} {flag}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,14 @@
[project]
name = "muse-deai"
version = "0.1.0"
description = "人感体系执行骨架:去 AI 味的合同校验、门禁与评测"
requires-python = ">=3.10"
# 内网 PyPI 不可达:只允许 PyYAML 一个外部依赖,新增依赖必须先更新专题-09 计划
dependencies = ["PyYAML>=6.0"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -0,0 +1,290 @@
# 20 个开源项目人感规则目录(中文轻量去重版)
> 版本:v0.1
>
> 性质:研究候选目录,不是生产规则库,不等于 `humanization/rules/` 中的 `active` 规则。
>
> 研究范围:仓内 20 个开源项目研究报告、20 项覆盖矩阵,以及中文小说迁移时的反例与保护要求。
>
> 来源入口: [20 项研究总报告](../../../.agents/knowledge/ai-writing-humanization-open-source-research.md);[20 项覆盖矩阵](20-project-skill-coverage.yaml)。
>
> 目录规模:129 个归一化条目;其中包含框架合同、问题候选、声音/载体保护、正向目标和明确拒绝项。规则名称与说明均使用简体中文,项目名、文件格式和合同状态保留必要的原始标识。
>
> 研究边界:本目录整理“可观察、可讨论、可验证”的写作现象,不判断作者是不是人,也不把任何单一模式当作 AI 生成证明。
## 1. 使用口径
### 1.1 四种状态
| 状态 | 含义 |
|---|---|
| 候选 | 可以作为检测或写前提示,但必须结合上下文、载体和样例验证。 |
| 条件 | 只有在密度、重复、功能或体裁条件成立时才处理。 |
| 保护 | 不是“去掉对象”,而是要求诊断和修订优先保护这些内容。 |
| 拒绝 | 不纳入通用硬规则;最多作为特定作品的显式风格约束。 |
### 1.2 轻量去重方法
- 将同一问题的词表变体合并为一个规则族。例如“值得注意的是、值得一提的是、需要指出的是”合并为“开场喉舌与元话语”。
- 将英文与中文的同构规则合并为同一中文规则,例如 `not X but Y` 与“不是 X,而是 Y”。
- 将同一现象在不同层级的表现保留为“局部规则”和“密度规则”两个入口,避免把词法命中与分布异常混成一个规则。
- 不把不同的保护边界、不同的叙事功能和不同的修订风险强行合并。
- 具体词语只作为代表例,不把词表完整展开成禁词表。
### 1.3 规则条目不等于可执行规则
每个条目进入生产前,至少需要:
```text
规则定义 → SF(应改)/ SNF(不应改)/ Boundary(边界)/ Regression(改坏)
→ 载体与体裁分层 → 独立 holdout → 人工审批 → active
```
当前目录中的条目没有自动进入生产,也没有从目录本身推导“更有人味”的效果结论。
## 2. 总体框架规则
这些条目是所有文本规则的上位合同,不是正文中的“AI 味模式”。
| 编号 | 归一化规则 | 执行要求 |
|---|---|---|
| F-01 | 先判任务、文体和场景 | 先区分小说叙述、对白、内心、书信、系统面板、说明文、状态汇报等,再决定检查强度。 |
| F-02 | 先判载体范围 | 叙述、对白、引用、代码、表格、场内文书和元数据分别处理;不能用叙述规则覆盖所有文本。 |
| F-03 | 先建立声音基线 | 记录作者、叙述者和角色的句长、词域、称谓、语气、口癖、礼貌等级和叙述距离;样本不足的字段标为未知。 |
| F-04 | 先冻结事实与叙事承诺 | 在改写前锁定实体、数字、时间线、关系、世界规则、场景目标、伏笔和信息揭示时机。 |
| F-05 | 检测与改写分离 | 诊断只输出片段、证据、功能判断和风险;诊断结果不是自动修改命令。 |
| F-06 | 先判功能,再判形式 | 同一个词、句式或标点,若承担人物声音、事实、节奏、悬念、引用或类型功能,应保留或交由人工判断。 |
| F-07 | 最小范围、唯一片段 | 优先不改、删除、压缩,再做局部改写;未命中区域不碰,替换片段必须唯一匹配。 |
| F-08 | 改后复扫并允许原文胜出 | 修订后重新检查同一规则、事实和声音;没有净改善、出现新问题或门禁失败时回退原文。 |
## 3. 机械残留与发布卫生
这类规则主要处理模型、工具和发布过程泄漏,不直接等同于文学人感。
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| M-01 | 模型、助手和协作话术泄漏 | 识别“作为 AI”“我无法继续”“希望这对你有帮助”“如果你愿意我可以……”等残留;引用、角色对白或剧情内聊天窗口需要保留。 |
| M-02 | 生成阶段元信息泄漏 | 识别提示词、角色标签、草稿说明、`TODO`、`FIXME`、字数、更新时间、生成状态等;若它们是场内文书内容,交由载体判断。 |
| M-03 | 占位符未填充 | 识别“待补充、待填、待定、变量括号”等;不能为了消除占位符而编造事实,必要时保留并转人工。 |
| M-04 | 工具、检索和引用痕迹泄漏 | 识别检索占位码、聊天工具 URL 参数、内部引用编号、调试输出和模型调用说明;真实引用和来源信息不得删除。 |
| M-05 | 隐藏字符与编码污染 | 检查零宽字符、同形字、不可见控制符和乱码;这是输入/发布卫生门,不是风格改写。 |
| M-06 | 格式标记泄漏 | 识别正文中误留的 Markdown、HTML、代码围栏、机械加粗、标题层级、表情符号和列表标记;代码、系统界面和场内屏幕是保护区。 |
| M-07 | 符号增殖或格式损坏 | 检查连续重复标点、破损引号、破折号与省略号异常组合、混合标记;单个破折号、引号或省略号不能单独定罪。 |
| M-08 | 过程差异腔 | 稳定文档不应写成“本次新增了……替换了旧方案……”;变更记录、发布说明、迁移文档本身需要过程叙述时除外。 |
## 4. 词汇、姿态与语气规则
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| L-01 | 开场喉舌与元话语 | 识别“值得注意的是、让我们看看、接下来我将、深入探讨、这里要说的是”等先宣布再说内容的句子;若叙述者有明确口癖或承担转场功能,保留。 |
| L-02 | 空洞填充、过渡和重复总结 | 识别“综上、总而言之、换句话说、简而言之、在此过程中、由此可见”等不增加信息的填充;若后文确实提供新结论或必要转折,不机械删除。 |
| L-03 | 假坦诚与戏剧化口语开场 | 识别“说实话、老实说、讲真的、你听我说、事情是这样的”等制造亲密感后才给普通判断的开头;对白和人物口头禅需按声音账判断。 |
| L-04 | 谄媚、身份夸奖与过度安抚 | 识别“好问题、你说得对、你的观察很敏锐、你不是敏感只是……”等无依据夸奖、心理判断和安抚姿态;真实关系中的安慰行为不可一律删除。 |
| L-05 | 主动出击式服务话术 | 识别“我已经确认、我马上开始、要不要我继续、只要你回复我”等把执行过程写成推销或邀功;状态回报需要明确动作时直接报告结果。 |
| L-06 | 无源权威与泛化共识 | 识别“研究表明、专家指出、业内人士认为、大家都知道、数据显示”等没有来源、对象或数据的权威包装;有真实来源时保留来源,不得补造来源。 |
| L-07 | 重要性、意义和宏大叙事膨胀 | 识别“具有深远意义、标志着关键转折、代表时代变化、为未来奠定基础”等把普通事实抬成历史意义的句子;若后文有具体事实支撑,可压缩而非整句删除。 |
| L-08 | 宣传、广告和夸张赞美腔 | 识别“震撼、卓越、焕然一新、丰富、璀璨、令人惊叹、行业领先”等空泛赞美;必须保留真实数据、评价来源和人物有意的夸张语气。 |
| L-09 | 空洞洞见与格言化姿态 | 识别“真正的问题是、归根结底、从本质上说、X 是 Y 的语言/货币/架构”等把普通判断包装成深刻洞见;若确实压缩了前文具体结论,可保留。 |
| L-10 | 泛化积极结论与鸡汤收尾 | 识别“未来可期、让我们拭目以待、拥抱变化、前景光明、这是重要一步”等无新信息的乐观收束;章末钩子、主题回声和角色口号需保留。 |
| L-11 | 公式化“挑战与未来展望” | 识别“尽管面临挑战……未来仍将……”的固定章节或段落骨架;说明文可作候选,小说中的悬念、预告和反讽需人工判断。 |
| L-12 | 模糊程度与过度限定堆叠 | 识别“可能、或许、似乎、某种程度上、相对而言”等连续叠加;真实不确定性、限知视角和不可靠叙述必须保留。 |
| L-13 | 无范围绝对化 | 识别“所有、永远、从不、必然、无人、完全、史无前例”等没有范围或证据的绝对判断;规则、口号、角色强断言和事实来源需按语境处理。 |
| L-14 | AI 高频抽象词与姿态词聚集 | 识别“关键、核心、持续、全面、有效、提升、推动、赋能、优化”等在同段或全文过密使用;单个正常词不构成问题。 |
| L-15 | 领域黑话姿态化 | 将商务黑话、工程师调试腔、自媒体爆款腔分为同一“语域姿态”家族:如“闭环、抓手、兜底、落盘、收口、避坑、硬核”等。技术对象、真实指标和角色身份语境可保留。 |
| L-16 | 翻译腔与书面支架堆叠 | 识别“对于……而言、在……方面、从……角度、通过……来、基于……、由于……的原因”等可以直接说的结构;法律、学术、技术语体不得为了口语化强改。 |
| L-17 | 知识截止声明与无依据补空 | 识别“截至我的知识更新、公开资料没有……因此可能……”以及借“资料不足”补写成长经历、机构、数字和动机;未知应明确保留未知或提问。 |
## 5. 句法与修辞规则
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| S-01 | 二元对比骨架 | 识别“不是 A,而是 B”“不只是 A,更是 B”“问题不在 A,在 B”等反复制造顿悟的结构;定义术语、真实辩驳、角色冲突和论证核心可保留。 |
| S-02 | 否定式列举 | 识别“不是 X,不是 Y,也不是 Z,只有……”等先排除再揭示的列表;同段反复出现才提高风险,引用和人物辩解不自动处理。 |
| S-03 | 公式化让步与假平衡 | 识别“尽管……但是仍然……”“一方面……另一方面……”等为了显得全面而补出的平衡句;真正存在条件冲突或证据权衡时保留。 |
| S-04 | 三项列举与全面并列 | 识别强行凑成三个名词、三个形容词或三个价值词的排比;具体清单、节奏高潮、口号和修辞回环不应一律压成两项。 |
| S-05 | 首先、其次、最后式机械排序 | 识别“首先/其次/最后”“第一/第二/第三”在不需要步骤或顺序的地方制造结构感;流程、论证和角色演讲中的真实排序保留。 |
| S-06 | 对称填充与伪对仗 | 识别“既要……又要……;既……又……”等为了平衡而补出的对仗;格言、诗性、战斗口令和人物风格可保留。 |
| S-07 | 反问式铺垫与自问自答 | 识别“难道……吗?答案是……”等先吊胃口再给普通结论的结构;对白、内心独白、悬疑和讽刺中的反问是合法功能。 |
| S-08 | 尾随否定碎片 | 识别句尾追加“无需猜测、没有例外、不费力”等不成句的否定尾巴;若它改变限制条件或是人物说话方式,不能直接删除。 |
| S-09 | 虚假范围或伪尺度 | 识别“从 A 到 B”但两端不在同一尺度、类别或时间轴上的伪范围;真实范围、空间移动和人物比喻需保留。 |
| S-10 | 隐藏施事、过度被动和无主句 | 识别“决定浮现、文化推动、结果被显著提升、无需配置”等把行动者藏掉的句子;拟人、诗性描写、学术被动和系统行为不机械改。 |
| S-11 | 表层分析尾巴 | 识别句末追加“这体现了……、反映出……、确保了……、从而彰显……”等没有新事实的解释分句;如果解释提供必要因果或约束,保留。 |
| S-12 | 抽象名词化与低具体性 | 识别“实现能力提升、完成价值释放、进行有效推进”等用抽象名词代替人物、动作、对象和结果;原文没有具体事实时只能压低,不得编造细节。 |
| S-13 | 比喻、类比和插入式修辞壳 | 识别“像……一样、仿佛……、如同……、……的涟漪/火花/回声”等只负责装饰或解释的比喻;有意象回环、人物声音和世界观隐喻时保留。 |
| S-14 | 强调拐杖 | 识别“一个关键点是、请记住、一句话总结、重点在于”等用标签制造重点,而正文没有新增内容;真正的标题、警告、系统提示和教程导航可保留。 |
## 6. 段落、篇章与结构规则
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| P-01 | 宏大时代或泛化世界开场 | 识别“在这个时代、随着社会发展、纵观人类历史”等从大背景套入具体主题的开头;史诗、传说、历史叙述和有意全景开场需要保留。 |
| P-02 | 标题后的复述壳 | 标题后紧跟一句只重复标题、尚未进入内容的暖场句;若它建立叙述者声音或节奏,交人工判断。 |
| P-03 | 固定段落同构 | 识别连续段落都采用“首句点题—中间展开—末句升华”的相同模板;战斗动作链、论证结构和诗性复沓可豁免。 |
| P-04 | 句首或前缀重复 | 识别连续句以相同短语、相同主语或相同句法开头;故意排比、咒语、口号和角色复读不自动处理。 |
| P-05 | 句长、段长和段尾过度均匀 | 识别全文或连续段落长度、句式和收束方式过于整齐;只能按作品和场景基线提示,不能以固定长短句比例为目标。 |
| P-06 | 连接密度过低、句子硬粘 | 识别多个动作或判断只用句号并列,缺少因果、转折、时间或主体关系;战斗、惊恐、电报体和意识流可保留。 |
| P-07 | 过度精炼与电报体 | 识别为追求“利落”而删掉必要主语、连接、结果和语义关系的短段;不是把所有短句合成长句。 |
| P-08 | 连续戏剧化碎句 | 识别多个孤立短句连续制造假高潮或“金句落点”;单个重拍、战斗、追逐、恐惧和诗性节奏不应误杀。 |
| P-09 | 单段过长或信息墙 | 识别长段中混合多个场景、解释、动作和新设定而没有自然分界;长篇叙述、意识流和引用块需要按结构判断。 |
| P-10 | 段落过碎或机械切段 | 识别每句都单独成段、用换行制造虚假节奏;对白、手机消息、诗歌和平台排版可以有意短段。 |
| P-11 | 摄像头式动作清单 | 识别“走过去、拿起来、转身、抬头、点头”连续罗列,像逐帧记录但没有选择、因果或情绪变化;动作链有战术或空间功能时保留。 |
| P-12 | 设定和背景信息倾倒 | 识别连续长句集中介绍世界观、人物履历、规则和背景,当前场景没有承接;档案、公告、讲解和角色有意说明除外。 |
| P-13 | 跨句解释链 | 识别一组句子不断重复“事实—解释—意义—更大意义”,但没有推进场景、论点或人物;保留真正新增的约束、因果和结论。 |
| P-14 | 每句都制造金句落点 | 识别连续句子都以反转、对仗、抽象判断或漂亮短句收束,读者可以预判下一句;章节钩子和角色宣言可保留。 |
| P-15 | 章尾预告与模板钩子 | 识别“更大的危机还在后面、命运的齿轮开始转动”等空泛预告;真实未揭示信息、伏笔提示和平台章尾钩子需保留。 |
| P-16 | 跨章节结构疲劳 | 识别连续章节重复相同开头、标题、情绪曲线、段尾方式、场景转场或回报节奏;这是序列级提示,不应直接重写当前一章。 |
## 7. 密度与分布规则
密度规则只提示“集中或重复异常”,不逐词替换。
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| D-01 | 套话和模板词聚集 | 同一段或同一篇中多个元话语、拔高词、商业词和过渡词成簇出现时提示;单个常用词放行。 |
| D-02 | 库存身体反应和面部动作过密 | 统计“嘴角上扬、眼中闪光、心脏一跳、眉头一皱、深吸一口气”等在窗口内的集中出现;角色签名动作和情绪高潮需按功能保留。 |
| D-03 | 生理标签和情绪标签堆叠 | 识别“生理性、胸腔、血液、呼吸、声音颤抖”等标签连续代替人物选择;医学、战斗伤势和真实身体状态不能机械删除。 |
| D-04 | 比喻和类比密度过高 | 只有达到段落/全文分布阈值才提示;诗性、梦境、童话、角色语言和主题意象回环应进入豁免。 |
| D-05 | 动作清单密度过高 | 统计动作词、分隔符和连续动作桶的组合,不逐个删除动作;打斗、追逐、调查和操作流程必须人工复核。 |
| D-06 | 形容词、副词和程度词堆叠 | 识别连续强化、夸张、模糊程度和评价修饰;副词可能承担时间、模态、人物口吻和节奏,不能全局清空。 |
| D-07 | 连接词、过渡词和抽象词密度过高 | 只处理成簇的“然而、此外、进一步、在此基础上、核心、关键”等;具体转折、承接和作者习惯保留。 |
| D-08 | 词、称谓、句式和短语重复分布异常 | 识别跨句、跨段或跨章的重复 n-gram、称谓轮换和同型句;必须区分伏笔回环、口癖、术语稳定性和机械复制。 |
## 8. 语义与叙事功能规则
这部分通常需要全文或场景上下文,不能只靠正则硬判。
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| N-01 | 空泛判断代替具体推进 | 句子只说“重要、复杂、深刻、有效、意义重大”,却没有对象、动作、结果或可观察变化;有前文压缩功能时可保留。 |
| N-02 | 过度解释读者已知内容 | 后一句只是解释前一句已经表达出的情绪、因果或意义;如果新增约束、背景或立场,不应删除。 |
| N-03 | 情绪告知代替情绪承载 | 直接写“他很愤怒、她感到复杂、气氛十分紧张”而没有必要的动作、选择、对话或感知;低强度情绪和特定叙述声音可直写。 |
| N-04 | 库存动作替代人物选择 | 身体反应存在,但没有人物决策、关系变化或场景后果;不能为了替换库存动作凭空添加新的感官和动作。 |
| N-05 | 对白变成说明书 | 角色台词只负责倾倒设定、重复读者已知信息或代作者解释主题;讲解者、审讯、课堂、系统播报和权力压制场景可保留。 |
| N-06 | 对白缺少回应和关系动作 | 多轮对话每句都独立传递信息,没有回应上一句情绪、回避、打断、误解、反击或让步;沉默和单向灌输有时是叙事功能。 |
| N-07 | 人物同声 | 不同角色在词域、句长、称谓、礼貌、直接程度、幽默和回避策略上趋同;必须有角色历史样本才能判断。 |
| N-08 | 语体与场景错位 | 小说、随笔或对白突然变成报告、教程、宣传或客服腔;引用、讽刺、角色伪装和场内文书可以有意错位。 |
| N-09 | 泛化旁观者腔 | 叙述脱离当前人物和场景,只用“人们往往、生活总是、时代告诉我们”泛泛评论;全知叙述、传说体和章节主题回声需保留。 |
| N-10 | 叙述视角或知识边界泄漏 | 限知角色知道了其不可能知道的信息,或改写把角色感知变成全知陈述;必须结合人物状态和时间点判断。 |
| N-11 | 说话人、人称或时态漂移 | 改写后对白归属、第一/第三人称、焦点人物、叙述时态或自由间接引语边界发生变化。 |
| N-12 | 命题、因果、否定和模态漂移 | 将“可能”改成“必然”、将“多半”改成“都”、改变因果方向或把相关关系写成因果关系;属于高风险保护项。 |
| N-13 | 世界规则、关系和状态漂移 | 改写改变人物身份、关系、能力边界、伤势、持有物、地点、时间或已确认世界规则。 |
| N-14 | 伏笔、钩子和信息揭示时机破坏 | 删除重复意象、提前解释线索、把未知写成已知、削弱章末悬念或提前回收承诺。 |
| N-15 | 有意回环和歧义被清除 | 主题回声、角色口头重复、故意留白、不可靠感知和未完成句被“说清楚”或统一成标准表达。 |
| N-16 | 无依据心理判断和过度亲密 | 叙述或回应替人物/读者下心理结论,例如“你只是太久没有被理解”;除非任务和上下文明确授权心理描写。 |
## 9. 声音、对白、视角与载体规则
这些条目多数是“保护和比较规则”,不是全局负面规则。
| 编号 | 归一化规则 | 默认动作与边界 |
|---|---|---|
| V-01 | 作者声音基线 | 用作者已确认样本比较句长、词域、标点、修辞密度、叙述距离和节奏;不要用“通用真人文风”替代作者基线。 |
| V-02 | 角色词域和句法指纹 | 比较功能词、语气词、句长、句末形式、常用结构和隐喻来源;样本不足时标未知。 |
| V-03 | 称谓、礼貌和权力关系 | 保护称谓、尊卑、亲疏、直说/回避、命令/请求和话语轮次;删掉口头短语前先判断关系功能。 |
| V-04 | 方言、时代和职业语域 | 方言、古风、军令、法庭、行业术语和年龄差不能被统一改成“标准自然口语”。 |
| V-05 | 口癖与签名动作 | 口癖、固定语气、重复动作和意象回环可以是角色识别信号;只有作者确认是无功能疲劳才处理。 |
| V-06 | 对白潜台词和回避策略 | 检查一句话是否在试探、遮掩、威胁、讨好、拒绝、拖延或争夺主导权;不能只按表面信息量改写。 |
| V-07 | 停顿、口吃和不完整句 | 省略、重复、打断、犹豫和不完整句可能是人物声音或情绪节奏;只有模板化堆叠且无功能时提示。 |
| V-08 | 叙述距离与焦点 | 保持第一人称、第三人称限知、全知、自由间接引语和不可靠叙述的合法距离;不能用“更贴近读者”作统一目标。 |
| V-09 | 载体范围与遮罩 | 分开处理叙述、对白、内心、场内文书、系统面板、引用、代码、表格和元数据;范围错误应优先失败关闭。 |
| V-10 | 题材与场景规则 | 按言情、悬疑、历史、仙侠、科幻、战斗、群像、轻喜剧、意识流等建立条件规则;同一个信号不能跨题材共用硬阈值。 |
## 10. 保护合同与统一豁免
| 编号 | 保护对象 | 保护要求 |
|---|---|---|
| X-01 | 引文、代码、表格、链接和前置元数据 | 默认只报告,不改内容;除非任务合同明确允许格式转换。 |
| X-02 | 人名、地名、组织、物品和专有术语 | 不得因“更自然”替换、泛化或新增;变化必须有明确授权和来源。 |
| X-03 | 数字、日期、时间、单位和数量关系 | 保持值、单位、对象和顺序;数字新增、减少或换对象都要进入硬门。 |
| X-04 | 主体、动作、对象和责任归属 | 不得把“谁做了什么”改成无主体结果,也不得把潜能改成已采用、建议改成事实。 |
| X-05 | 因果、条件、否定、程度和不确定性 | 不得扩大断言、强化模态、改变因果或删除限制条件。 |
| X-06 | 人物关系、身份、能力、伤势和持有物 | 不得改变关系方向、状态、能力边界、位置和道具状态。 |
| X-07 | 视角、人称、时态和说话人 | 改写前后必须保持叙述权限、焦点人物、时间状态和对白归属。 |
| X-08 | 世界规则与正典 | 不得为了具体化补设定、解释代价、改变规则或提前揭示未知内容。 |
| X-09 | 场景顺序和章节承诺 | 不得删除关键动作、改变顺序、跳过场景目标或改变章末钩子。 |
| X-10 | 伏笔、重复意象和信息揭示时间 | 不得把“尚未知道”改成“已经知道”,也不得删除后文需要的提示。 |
| X-11 | 来源、引语和授权状态 | 无源内容不补机构、年份、专家、数据或引用;无授权文本只能保留哈希和位置,不复制原文。 |
| X-12 | 未知字段和缺失事实 | “未知”不等于可编造;缺事实时删除空话、保留原意、留占位或询问。 |
统一豁免优先覆盖以下情况:角色对白、口癖、方言、古风和军令;战斗、追逐、惊恐和意识流短句;悬疑预告和章末钩子;诗性重复和主题回声;系统面板、公告、书信、法庭记录、代码和引用;被讨论的词语本身;真实的不确定性和不可靠叙述。
## 11. 正向人感目标
这些条目不是“强制加入人味”的生成配方,而是修订时的方向检查。
| 编号 | 正向目标 | 使用边界 |
|---|---|---|
| H-01 | 具体动作、对象和后果优先 | 只能复用原文、已确认设定或用户提供的事实;不能为了具体化补数字和感官。 |
| H-02 | 让判断落在事实、经验或观察上 | 不用“很重要、很深刻、很有意义”代替内容。 |
| H-03 | 保留真实的不确定性 | 不把“可能、似乎、我猜”强行改成断言,也不为显得谨慎堆叠模糊词。 |
| H-04 | 允许混合感受和矛盾 | 人物可以同时犹豫、愤怒、依恋和否认;不能套用单一情绪标签。 |
| H-05 | 允许有边界的跑题和旁支 | 跑题必须与作者声音、人物思考或关系功能有关,不能随机插入无关故事。 |
| H-06 | 保留有目的的不均匀节奏 | 允许普通句、长句、短句、停顿和重复并存,不追求固定长短交替。 |
| H-07 | 选择性细节,而不是感官清单 | 细节应改变读者对人物、空间、因果或情绪的理解;不为“像人”随机添加气味、触感和天气。 |
| H-08 | 让人物通过选择和关系显形 | 优先呈现行动、取舍、回避、反应和代价,不用统一的“他很愤怒/她很温柔”标签代替。 |
| H-09 | 维持场景语域 | 聊天、状态、文档、公告、小说叙述和对白各自保持合理正式度;不把所有文本口语化。 |
| H-10 | 允许留白、歧义和不完全收束 | 只要它们承担悬念、主题、人物心理或类型功能,就不要为了清楚而全部解释。 |
## 12. 明确不纳入通用硬规则
| 编号 | 拒绝项 | 原因 |
|---|---|---|
| R-01 | 全局同义词轮换 | 会改变角色用词、术语精度、指代和语气,容易制造新的“优雅变体”模板。 |
| R-02 | 随机错别字、隐藏字符和噪声 | 只是攻击检测器,降低可读性和可信度。 |
| R-03 | 全局禁破折号、禁问句、禁三项、禁副词 | 这些形式可能承担节奏、修辞、对白、悬疑和类型功能。 |
| R-04 | 固定短句/长句配比 | 会把一种检测器偏好变成新的模板,误伤战斗、惊恐、诗性和对白。 |
| R-05 | 一律“展示而非告知” | 概述、总结、直接情绪和叙述距离都是合法叙事工具。 |
| R-06 | 一律改主动语态、禁止拟人 | 会误伤诗性描写、隐喻、全知叙述和技术系统行为。 |
| R-07 | 为了人感新增第一人称、幽默、感官、经历或瑕疵 | 会改变叙述者、事实和人物声音;编辑与共创必须分权。 |
| R-08 | 全文重写或无限优化 | 改动面不可审计,容易抹平原文;应限制范围、轮次并保留最佳快照。 |
| R-09 | 以 AI 检测器分数为唯一目标 | 检测器分数不能证明自然度、事实保真、人物声音或读者偏好。 |
| R-10 | 一套通用“真人文风”覆盖所有作品 | 人感来自作者、角色、体裁和场景基线,不是统一的口语、短句或不完美。 |
## 13. 20 个研究项目的来源覆盖
下表只说明本目录从各项目吸收了哪些**研究机制或候选问题族**,不表示这些项目已经证明规则有效。
| 项目 | 主要吸收内容 |
|---|---|
| `humanizer` | 意义膨胀、无源权威、宣传腔、同义词轮换、元话语、金句化、发布残留。 |
| `stop-slop` | 二元对比、否定列举、隐藏施事、反问铺垫、碎句、模糊断言、节奏信号。 |
| `Humanizer-zh` | 中文空泛拔高、翻译腔、三项结构、宣传词、隐喻解释、事实新增反例。 |
| `no-ai-slop` | 声音画像、功能判断、最小编辑、无事实新增、检测与改写分离。 |
| `avoid-ai-writing` | 分层词汇、密度与结构信号、引用/代码/数字遮罩、保留合同。 |
| `academic-humanizer` | 先审计后改写、主张与证据绑定、文体条件化、最小可解释编辑。 |
| `human-writing` | 句长和段落变异、说话位置、知识来路、现实/虚构分流。 |
| `PaperSpine` | 句长、段落相似度、连接词密度、信息锚点和多层审计。 |
| `im-not-ai` | 语域保持、句法/密度指标、保护集合、规则证据分层。 |
| `talk-normal` | 直接回答、否定对比框架、总结标签、反向回归和具体落点。 |
| `shuorenhua` | 中文词族、工程师腔、自媒体腔、场景守卫、范围遮罩和正向人感。 |
| `speak-human-tw` | 模式加功能加边界、无源引用、作者没给就占位、双向样例。 |
| `inkos` | 段落等长、转折重复、同前缀句、跨章疲劳、题材和人物条件规则。 |
| `oh-story-claudecode` | 中文小说 lint、套词密度、解释链、动作清单、三遍法、有限轮次。 |
| `Openwrite` | 信息倾倒、对白乒乓、角色指纹、场景节拍、正典和风格来源隔离。 |
| `neuro-book` | 正则、处理器、密度、语义四类检测;空泛总结、隐藏行动者、比喻密度、动作清单、载体范围。 |
| `unslop` | 从多样样本归纳模式、按类别组织规则、保留规则缺口和来源。 |
| `humanize-text` | 句长变异、词汇多样性、连接词密度、节奏指标及其误用风险。 |
| `AIWriteX` | 多维度重写提示、文本保真和“有机制不等于有独立 humanizer”的反例。 |
| `AI_paper` | 学术/降重语境的证据边界、局部信号和检测分数不能替代质量的反例。 |
## 14. 从目录转成生产规则的建议顺序
第一批不宜直接把全部条目写成 `active`。建议按以下顺序建立候选:
1. **低风险机械类**:M-01、M-02、M-03、M-04、M-05、M-06。
2. **高共识表层类**:L-01、L-02、L-05、L-06、L-07、S-01、S-02。
3. **分布类候选**:P-04、P-05、P-08、P-11、D-02、D-04、D-08。
4. **小说语义类**:N-01、N-02、N-03、N-05、N-07、N-10、N-14;默认只做语义审查,不做自动修订。
5. **作品专属类**:V-01 至 V-10、X-01 至 X-12;必须依赖具体作品的声音账、事实快照和场景合同。
每个候选规则都必须补齐四类样例,并单独报告:
```text
应改命中率
不应改误报率
边界转人工率
修订后事实/视角/声音回归结果
原文胜出比例
```
这份目录的下一步是“从研究候选中挑选少量规则做中文样例和 holdout”,不是把 20 个项目的所有词表直接搬进生产规则库。

View File

@ -0,0 +1,143 @@
schema_version: humanization-research-skill-coverage-v1
source: ../../../.agents/knowledge/ai-writing-humanization-open-source-research.md
scope: agent-example自治验证期;研究出处不等于效果证明
projects:
- id: humanizer
mapped_capabilities: [voice_first_baseline, minimal_unique_patch]
- id: stop-slop
mapped_capabilities: [five_layer_detection]
- id: Humanizer-zh
mapped_capabilities: [preservation_and_modality]
- id: inkos
mapped_capabilities: [minimal_unique_patch, bounded_revision_and_original_wins, cross_model_pairwise]
- id: oh-story-claudecode
mapped_capabilities: [five_layer_detection, bounded_revision_and_original_wins]
- id: no-ai-slop
mapped_capabilities: [detect_edit_separation, pre_generation_guidance]
- id: PaperSpine
mapped_capabilities: [detect_edit_separation, bounded_revision_and_original_wins]
- id: im-not-ai
mapped_capabilities: [five_layer_detection, holdout_effect_evaluation]
- id: avoid-ai-writing
mapped_capabilities: [carrier_scope_and_mask, preservation_and_modality]
- id: human-writing
mapped_capabilities: [detect_edit_separation]
- id: talk-normal
mapped_capabilities: [pre_generation_guidance, holdout_effect_evaluation]
- id: AIWriteX
mapped_capabilities: [minimal_unique_patch]
- id: humanize-text
mapped_capabilities: [five_layer_detection, holdout_effect_evaluation]
- id: shuorenhua
mapped_capabilities: [carrier_scope_and_mask, sf_snf_boundary_regression]
- id: academic-humanizer
mapped_capabilities: [preservation_and_modality]
- id: speak-human-tw
mapped_capabilities: [sf_snf_boundary_regression, holdout_effect_evaluation]
- id: AI_paper
mapped_capabilities: [holdout_effect_evaluation]
- id: Openwrite
mapped_capabilities: [minimal_unique_patch]
- id: unslop
mapped_capabilities: [case_to_rule_lifecycle]
- id: neuro-book
mapped_capabilities: [five_layer_detection, case_to_rule_lifecycle, holdout_effect_evaluation]
capabilities:
- id: detect_edit_separation
evidence_sources: [no-ai-slop, oh-story-claudecode, academic-humanizer]
owner_skill: diagnose-ai-flavor
implementation: humanization/src/deai/diagnose.py
test: humanization/tests/test_contracts.py
status: implemented
- id: five_layer_detection
evidence_sources: [neuro-book, oh-story-claudecode, avoid-ai-writing]
owner_skill: diagnose-ai-flavor
implementation: humanization/src/deai/diagnose.py
test: humanization/tests/test_humanization_v2.py
status: implemented
note: regex/handler/density机械执行;semantic仍需外部模型或人工
- id: carrier_scope_and_mask
evidence_sources: [shuorenhua, avoid-ai-writing, neuro-book]
owner_skill: diagnose-ai-flavor
implementation: humanization/src/deai/carriers.py
test: humanization/tests/test_humanization_v2.py
status: implemented
- id: voice_first_baseline
evidence_sources: [humanizer, shuorenhua, inkos]
owner_skill: establish-voice-baseline
implementation: .claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py
test: humanization/tests/test_humanization_v2.py
status: implemented
note: 角色策略仍需planner/作者补充和确认
- id: pre_generation_guidance
evidence_sources: [no-ai-slop, neuro-book, humanizer]
owner_skill: prevent-ai-flavor
implementation: .claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py
test: .claude/skills/assemble-context/scripts/test_assemble_writer_context.py
status: implemented
- id: sf_snf_boundary_regression
evidence_sources: [speak-human-tw, shuorenhua, neuro-book]
owner_skill: capture-ai-flavor-cases
implementation: humanization/samples
test: humanization/src/deai/evaluation.py
status: implemented
- id: minimal_unique_patch
evidence_sources: [inkos, Openwrite, shuorenhua]
owner_skill: revise-ai-flavor
implementation: humanization/src/deai/patch.py
test: humanization/tests/test_contracts.py
status: implemented
- id: preservation_and_modality
evidence_sources: [humanizer, academic-humanizer, shuorenhua]
owner_skill: revise-ai-flavor
implementation: humanization/src/deai/gates.py
test: humanization/tests/test_humanization_v2.py
status: implemented
note: POV/因果/伏笔仍需事实快照和语义detector
- id: bounded_revision_and_original_wins
evidence_sources: [inkos, oh-story-claudecode, neuro-book]
owner_skill: revise-ai-flavor
implementation: humanization/src/deai/pipeline.py
test: .claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py
status: implemented
note: 当前以最多3轮合同、复扫和pairwise no_gain实现;跨轮最佳快照编排仍由上层负责
- id: source_revalidation
evidence_sources: [shuorenhua, neuro-book]
owner_skill: capture-ai-flavor-cases
implementation: .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py
test: .claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py
status: implemented
- id: case_to_rule_lifecycle
evidence_sources: [unslop, shuorenhua, neuro-book]
owner_skill: capture-ai-flavor-cases
implementation: .claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py
test: humanization/tests/test_humanization_v2.py
status: implemented
note: 状态写回数据库;规则文件激活需显式输出和人工审批
- id: cross_model_pairwise
evidence_sources: [inkos, neuro-book, oh-story-claudecode]
owner_skill: revise-ai-flavor
implementation: humanization/src/deai/pairwise.py
test: humanization/tests/test_contracts.py
status: implemented
- id: holdout_effect_evaluation
evidence_sources: [neuro-book, speak-human-tw, im-not-ai]
owner_skill: capture-ai-flavor-cases
implementation: humanization/src/deai/evaluation.py
test: humanization/tests/test_humanization_v2.py
status: partial
note: 合同回放和holdout计数门已建;真实多作者多题材holdout尚未形成
- id: model_drift_deprecation
evidence_sources: [inkos, im-not-ai, neuro-book]
owner_skill: capture-ai-flavor-cases
implementation: humanization/src/deai/evaluation.py
test: humanization/tests/test_humanization_v2.py
status: partial
note: 人工审批的deprecate输出已建;自动跨模型巡检尚未接入生产调度
- id: independent_reader_blind_eval
evidence_sources: [neuro-book, inkos, humanizer]
owner_skill: capture-ai-flavor-cases
implementation: humanization/eval
test: humanization/tests/test_humanization_v2.py
status: pending
note: pairwise合同存在,但独立读者规模化盲评未完成

View File

@ -0,0 +1,30 @@
# 规则 d001:密度只提示分布异常,不逐词自动修改;2026-08-16 所有者决定激活(无 holdout)。
id: d001
name: 库存微动作过密
layer: density
carrier_scope: narration
trigger:
type: density
pattern: 嘴角(?:微微|轻轻|悄然)?(?:上扬|勾起)|眼中闪过(?:一丝|一抹)?(?:光|精光|异彩)|眸光(?:微闪|深邃)|心脏(?:猛地|漏跳了一拍)
window_chars: 500
min_hits: 3
carve_out:
- 同一角色签名动作的有意回环
- 情绪高潮的连续身体反应
default_disposition: advisory
function_check:
- 多处动作是否分别承担不同因果
- 是否为角色签名动作或意象回环
fix_hint: 先判断分布功能;只处理无功能重复,不逐词同义替换
samples:
sf:
- sf-d001-01
snf:
- snf-d001-01
boundary:
- b-d001-01
regression:
- reg-d001-01
version: 2
status: active
evidence: 20 项开源研究机制候选:neuro-book density 分层、humanizer/no-ai-slop 模式簇;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,31 @@
# 规则 d002(目录 D-01):元话语与过渡套语成簇才提示;单个常用词放行。
id: d002
name: 套话与模板词聚集
layer: density
carrier_scope: narration
trigger:
type: density
pattern: 值得注意的是|值得一提的是|需要指出的是|更重要的是|不仅如此|归根结底|总而言之|综上所述|由此可见|不难发现|在某种程度上|从某种意义上|话说|且说|再说|单说
window_chars: 300
min_hits: 3
carve_out:
- 评书腔叙述者的有意口癖与转场(须声音账确认)
- 角色对白
default_disposition: advisory
function_check:
- 是否为叙述者声线的转场习惯
- 套语之间是否仍有信息推进
- 是否承担章节/场景切换的节奏功能
fix_hint: 只处理无功能的成簇套话,保留信息句;不得逐项同义替换,不得批量删除后补连接词
samples:
sf:
- sf-d002-01
snf:
- snf-d002-01
boundary:
- b-d002-01
regression:
- reg-d002-01
version: 2
status: active
evidence: 129 条研究目录 D-01 机制候选(密度入口,与 l001/l002 词法入口分列);窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,32 @@
# 规则 d003(目录 D-07):连接词与过渡词成簇才提示;具体转折与作者习惯保留。
id: d003
name: 连接词与过渡词过密
layer: density
carrier_scope: narration
trigger:
type: density
pattern: 然而|此外|进一步(?:说|地|的)?|在此基础上|与此同时|另一方面|综合来看
window_chars: 400
min_hits: 4
carve_out:
- 论辩与议事场景的层层推进
- 作者基线确认的承接习惯
- 角色对白
default_disposition: advisory
function_check:
- 每个连接词是否承担真实转折或承接
- 是否为议事/论辩文体的合理密度
- 删除后逻辑关系是否改变
fix_hint: 只删不承担逻辑关系的连接词;承担转折的不得删,删词不得改变因果与模态
samples:
sf:
- sf-d003-01
snf:
- snf-d003-01
boundary:
- b-d003-01
regression:
- reg-d003-01
version: 2
status: active
evidence: 129 条研究目录 D-07 机制候选;窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,33 @@
# 规则 d004(目录 D-04):比喻密度达到分布阈值才提示;诗性与主题意象进豁免。
id: d004
name: 比喻密度过高
layer: density
carrier_scope: narration
trigger:
type: density
pattern: 仿佛|宛如|如同|好似|恰似|像是|像在|像一(?:个|只|条|把|面|团|块|道|片|阵)|像[^。,!?]{1,12}(?:一样|一般|似的)
window_chars: 600
min_hits: 5
carve_out:
- 梦境与幻觉段落
- 诗性叙述与童话文体
- 主题意象回环(须声音账确认)
- 角色语言
default_disposition: advisory
function_check:
- 比喻是否为主题意象的回环
- 是否为梦境、幻觉等主观段落
- 多个比喻是否分别承担不同的感知
fix_hint: 只删无功能的库存比喻;意象链与承担感知的比喻必须保留,不得换喻体
samples:
sf:
- sf-d004-01
snf:
- snf-d004-01
boundary:
- b-d004-01
regression:
- reg-d004-01
version: 2
status: active
evidence: 129 条研究目录 D-04 机制候选;分布阈值为冷启动默认;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,31 @@
# 规则 d005(目录 L-14 的密度入口):单个正常词放行,同段或近窗口过密才提示。
id: d005
name: 高频抽象词聚集
layer: density
carrier_scope: narration
trigger:
type: density
pattern: 关键|核心|持续|全面|有效|提升|推动|赋能|优化|深化|强化|落实|推进
window_chars: 400
min_hits: 6
carve_out:
- 议事、论辩与战前部署场景
- 角色对白(官员、谋士型人物的语域)
default_disposition: advisory
function_check:
- 抽象词是否分别指向具体的事中物
- 是否为议事文体的合理语域
- 是否有具体细节穿插支撑
fix_hint: 把过密的抽象词落到具体的人、物、动作上;不得整段重写,不得新增原文没有的细节
samples:
sf:
- sf-d005-01
snf:
- snf-d005-01
boundary:
- b-d005-01
regression:
- reg-d005-01
version: 2
status: active
evidence: 129 条研究目录 L-14 的密度入口(与词法命中分列);窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,32 @@
# 规则 d006(目录 D-08 先行子集):先落转场填充短语的重复;任意 n-gram 与跨章分布待后续。
id: d006
name: 转场填充短语重复
layer: density
carrier_scope: narration
trigger:
type: density
pattern: 与此同时|就在此时|就在这时|在这一刻|在这一瞬间|下一刻|下一秒|几乎(?:是)?同时|几乎(?:是)?同一时间
window_chars: 600
min_hits: 3
carve_out:
- 真实的多线并进蒙太奇
- 伏笔回环与有意复读
- 角色对白
default_disposition: advisory
function_check:
- 重复短语是否真的标记多线同时
- 是否为伏笔回环的有意复读
- 删除后时间关系是否改变
fix_hint: 换用具体的并置写法或直接并段;不得删除承担同时性的时间锚点
samples:
sf:
- sf-d006-01
snf:
- snf-d006-01
boundary:
- b-d006-01
regression:
- reg-d006-01
version: 2
status: active
evidence: 129 条研究目录 D-08 的窗口内转场填充子集;跨句跨章 n-gram 分布未覆盖;窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,28 @@
# 规则 l001(合同:专题-09 §4.1;装载校验:deai.load)
id: l001
name: 无源权威套语
layer: lexical
carrier_scope: narration
trigger:
type: regex
pattern: 研究表明|科学家(们)?发现|据统计|有研究显示|实验证明
carve_out:
- 场内文书(公文/新闻/公告)
- 角色对白(须仲裁其话语策略)
default_disposition: candidate
function_check:
- 是否为角色话语策略(学者型人物)
- 是否场内文书
fix_hint: 删除套语保留信息;无信息则整句删除
samples:
sf:
- sf-l001-01
snf:
- snf-l001-01
boundary:
- b-l001-01
regression:
- reg-l001-01
version: 1
status: active
evidence: 人工评审:stop-slop/no-ai-slop 元话语类目;U0 演练验证命中

View File

@ -0,0 +1,29 @@
# 规则 l002(合同:专题-09 §4.1;装载校验:deai.load)
id: l002
name: 空洞元话语
layer: lexical
carrier_scope: narration
trigger:
type: regex
pattern: 值得注意的是|更值得一提的是|不言而喻|众所周知|换句话说
carve_out:
- 评书腔叙述者的有意口癖
- 角色对白
default_disposition: candidate
function_check:
- 是否叙述者声线特征
- 是否控制信息节奏
fix_hint: 直接删除,后接内容不受影响即安全
samples:
sf:
- sf-l002-01
snf:
- snf-l002-01
- snf-l002-02
boundary:
- b-l002-01
regression:
- reg-l002-01
version: 1
status: active
evidence: 人工评审:stop-slop 元话语类目;U0 演练验证命中

View File

@ -0,0 +1,27 @@
# 规则 l003(合同:专题-09 §4.1;装载校验:deai.load)
id: l003
name: 库存微表情
layer: lexical
carrier_scope: narration
trigger:
type: regex
pattern: 嘴角(微微|轻轻)?(上扬|勾起)|眼中闪过(一丝|一抹)?(光|精光|异彩)|心脏(猛地|漏跳了一拍)|眸光(微闪|深邃)
carve_out:
- 角色签名动作的有意复用(须声音账确认)
default_disposition: candidate
function_check:
- 是否人物签名动作
- 是否节奏锚点
fix_hint: 换用该场景独有的具体动作,或删除;不得新增原文没有的细节
samples:
sf:
- sf-l003-01
snf:
- snf-l003-01
boundary:
- b-l003-01
regression:
- reg-005
version: 1
status: active
evidence: 人工评审:humanizer 库存动作反例;U0 演练验证命中

View File

@ -0,0 +1,28 @@
# 规则 l004(合同:专题-09 §4.1;装载校验:deai.load)
id: l004
name: 三段式排比套句
layer: lexical
carrier_scope: narration
trigger:
type: regex
pattern: 无论是.{1,24}[,,]还是.{1,24}[,,].{0,4}(还是|都)
carve_out:
- 故意排比的修辞高潮
- 对白
default_disposition: candidate
function_check:
- 是否节奏高潮的有意排比
- 是否章节钩子
fix_hint: 压缩为单一陈述;保留信息量最强的那一项
samples:
sf:
- sf-l004-01
snf:
- snf-l004-01
boundary:
- b-s001-01
regression:
- reg-l004-01
version: 1
status: active
evidence: 人工评审:oh-story 中文模式库排比类;U0 演练验证命中

View File

@ -0,0 +1,30 @@
# 规则 l005(目录 L-06 残余族):l001 已 active 的词族不动,扩展族作为新候选走生命周期。
id: l005
name: 无源权威套语(扩展族)
layer: lexical
carrier_scope: narration
trigger:
type: regex
pattern: 专家指出|业内人士(?:认为|表示|称)|大家都知道|数据显示|权威人士(?:表示|称)|事实证明|有专家(?:称|表示)
carve_out:
- 场内文书(公文/新闻/公告/榜文)
- 角色对白(须仲裁其话语策略)
- 有真实来源可查证的引用
default_disposition: candidate
function_check:
- 是否为角色话语策略(学者型、行会型人物)
- 是否场内文书
- 是否确有真实来源(有来源保留来源,不得补造来源)
fix_hint: 删除套语保留信息;无信息则整句删除;禁止为消除套语而编造来源
samples:
sf:
- sf-l005-01
snf:
- snf-l005-01
boundary:
- b-l005-01
regression:
- reg-l005-01
version: 2
status: active
evidence: 129 条研究目录 L-06 中 l001 未覆盖的权威包装族;与 l001 同族分列,待 holdout 后评估合并;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,30 @@
# 规则 l006(目录 L-15):商务黑话、调试腔、爆款腔归为同一语域姿态族;技术对象与角色身份语境保留。
id: l006
name: 领域黑话姿态化
layer: lexical
carrier_scope: narration
trigger:
type: regex
pattern: 闭环|抓手|兜底|落盘|收口|避坑|硬核|颗粒度|组合拳|底层逻辑|护城河|对标
carve_out:
- 技术对象文本(修真工业流、机关术等设定内的术语)
- 角色身份语境(匠师、账房、谋士型人物的职业腔)
- 角色对白
default_disposition: candidate
function_check:
- 是否为世界观内的设定术语
- 是否为角色职业腔(声线特征)
- 是否场内文书
fix_hint: 换用世界观内的对应说法;属于角色职业腔或设定术语的保留,不得替换为叙述者通用词
samples:
sf:
- sf-l006-01
snf:
- snf-l006-01
boundary:
- b-l006-01
regression:
- reg-l006-01
version: 2
status: active
evidence: 129 条研究目录 L-15 机制候选(语域姿态族);2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,27 @@
# 规则 m001(合同:专题-09 §4.1;装载校验:deai.load)
id: m001
name: 占位符残留
layer: mechanical
carrier_scope: all
trigger:
type: regex
pattern: \[(占位|待填|待补充)[^\]]*\]|TODO|FIXME|(待补)|\{\{[^}]+\}\}
carve_out:
- 引文内容
- 代码
- 场内文书中的括注
default_disposition: blocking
function_check: []
fix_hint: 确定性删除;若占位承载内容承诺,转 ask 并留占位说明,不编造填充
samples:
sf:
- sf-m001-01
snf:
- snf-m001-01
boundary:
- b-m001-01
regression:
- reg-003
version: 1
status: active
evidence: 人工评审:机械破损类各开源仓库一致认可(avoid-ai-writing/oh-story);U0 演练验证命中

View File

@ -0,0 +1,26 @@
# 规则 m002(合同:专题-09 §4.1;装载校验:deai.load)
id: m002
name: 模型自指与拒绝残留
layer: mechanical
carrier_scope: all
trigger:
type: regex
pattern: 作为\s*(一名)?\s*(AI|人工智能|语言模型)|我无法(为您|继续|提供)|作为一个助手
carve_out:
- 角色对白与世界观设定内的自称
- 引文
default_disposition: blocking
function_check: []
fix_hint: 确定性删除或重新生成该段
samples:
sf:
- sf-m002-01
snf:
- snf-m002-01
boundary:
- b-m002-01
regression:
- reg-m002-01
version: 1
status: active
evidence: 人工评审:机械破损类;neuro-book llmlint 同类规则

View File

@ -0,0 +1,26 @@
# 规则 m003(合同:专题-09 §4.1;装载校验:deai.load)
id: m003
name: 工程词泄漏
layer: mechanical
carrier_scope: narration
trigger:
type: regex
pattern: 字数[::]\s*\d+|更新时间[::]|作者(有话说|注)[::]|本章说
carve_out:
- 场内文书
- 作中作(角色正在写的文稿)
default_disposition: blocking
function_check: []
fix_hint: 确定性删除;若属场内文书内容则保留
samples:
sf:
- sf-m003-01
snf:
- snf-m003-01
boundary:
- b-m003-01
regression:
- reg-m003-01
version: 1
status: active
evidence: 人工评审:机械破损类;U0 演练验证命中

View File

@ -0,0 +1,27 @@
# 规则 m004(合同:专题-09 §4.1;装载校验:deai.load)
id: m004
name: 格式损坏
layer: mechanical
carrier_scope: all
trigger:
type: regex
pattern: \*\*[^*]+\*\*|^#{1,6}\s|```
carve_out:
- 代码
- 场内屏幕内容以符号为剧情要素
default_disposition: candidate
function_check:
- 该格式符号是否是场内内容的一部分
fix_hint: 去除格式符号保留内容;不确定是否剧情要素时 ask
samples:
sf:
- sf-m004-01
snf:
- snf-m004-01
boundary:
- b-m004-01
regression:
- reg-m004-01
version: 1
status: active
evidence: 人工评审:机械破损类;U0 演练验证命中

View File

@ -0,0 +1,32 @@
# 规则 sem001(合同:专题-09 §4.1;装载校验:deai.load)
id: sem001
name: 段尾空泛升华
layer: semantic
carrier_scope: narration
trigger:
type: model_judgment
criteria: 段落末句脱离具体动作与场景,转向命运、因果、岁月类抽象评注,且不承担钩子或伏笔功能
carve_out:
- 章末钩子
- 伏笔预告
- 主题回声的有意重复
default_disposition: candidate
function_check:
- 是否章节钩子
- 是否伏笔预告
- 是否主题回声
fix_hint: 删除升华句,段落以具体动作收束;不新增细节
samples:
sf:
- sf-sem001-01
snf:
- snf-sem001-01
boundary:
- b-sem001-01
regression:
- reg-001
- reg-002
- reg-003
version: 1
status: active
evidence: 人工评审:oh-story/neuro-book 语义层类目;U0 演练人工判定

View File

@ -0,0 +1,30 @@
# 规则 sem002(目录 N-05):首条 dialogue scope 规则;样例覆盖授课/播报/审讯类 SNF 场景。
id: sem002
name: 对白变成说明书
layer: semantic
carrier_scope: dialogue
trigger:
type: model_judgment
criteria: 角色台词只负责倾倒设定、重复读者已知信息或代作者解释主题;台词没有角色自身的冲突目的,不推进角色在场景里的目标,也不因说话对象的反应而变化
carve_out:
- 讲解者型角色的职业行为(授课、审讯、系统播报、宣旨)
- 权力压制场景中以讲解为压制手段的对白
- 场内文书与作中作
default_disposition: advisory
function_check:
- 说话者是否有场景内的动机(传授、警告、拖延、试探)
- 讲解是否同时暴露人物关系或冲突
- 设定信息是否后文依赖(删除会断链)
fix_hint: 把设定拆进动作与冲突,或移入叙述;不得整段删除后文依赖的设定信息
samples:
sf:
- sf-sem002-01
snf:
- snf-sem002-01
boundary:
- b-sem002-01
regression:
- reg-sem002-01
version: 2
status: active
evidence: 129 条研究目录 N-05 机制候选;语义层依赖外部 detector/人工产出 finding;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,30 @@
# 规则 sem003(目录 N-03):情绪告知是否有承载;低强度与特定声线直写放行。
id: sem003
name: 情绪告知代替情绪承载
layer: semantic
carrier_scope: narration
trigger:
type: model_judgment
criteria: 直接命名情绪(他很愤怒、她感到复杂、气氛十分紧张)而句子及周边没有动作、选择、对话或感知作为承载;低强度情绪与特定叙述声音的直写除外
carve_out:
- 低强度情绪的直接命名(他很高兴)
- 特定叙述声音的直写习惯(须声音账确认)
- 快节奏动作场景的情绪速记
default_disposition: candidate
function_check:
- 情绪强度是否低到直写即可
- 是否为叙述声线的直写习惯
- 周边是否已有动作或感知承载,命名只是收束
fix_hint: 把直写改为可感知的动作或感知;不得为承载新增原文没有的细节与强度
samples:
sf:
- sf-sem003-01
snf:
- snf-sem003-01
boundary:
- b-sem003-01
regression:
- reg-sem003-01
version: 2
status: active
evidence: 129 条研究目录 N-03 机制候选;语义层依赖外部 detector/人工产出 finding;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,28 @@
# 规则 s001(合同:专题-09 §4.1;装载校验:deai.load)
id: s001
name: 段落同构
layer: structural
carrier_scope: narration
trigger:
type: model_judgment
criteria: 连续三段及以上呈现相同模板(首句点题 + 中间展开 + 尾句升华),且同构不服务节奏意图
carve_out:
- 战斗动作链
- 刻意复读的修辞(弹幕复读、评书贯口)
default_disposition: advisory
function_check:
- 是否节奏意图
- 是否类型惯例
fix_hint: 打散其中一到两段的模板,保留信息;禁止整组重写
samples:
sf:
- sf-s001-01
snf:
- snf-s001-01
boundary:
- b-s001-01
regression:
- reg-s001-01
version: 1
status: active
evidence: 人工评审:neuro-book handler 层结构检测;U0 演练人工判定

View File

@ -0,0 +1,31 @@
# 规则 s002:来自 neuro-book/oh-story 的 handler 分层机制;2026-08-16 所有者决定激活(无 holdout)。
id: s002
name: 连续碎句模板化
layer: structural
carrier_scope: narration
trigger:
type: handler
handler: short_sentence_run
max_chars: 8
min_run: 4
carve_out:
- 战斗高潮的有意短句
- 惊恐、窒息或意识流节奏
- 角色对白
default_disposition: advisory
function_check:
- 短句是否承担节奏加速或主观感知
- 是否出现破格句打断模板
fix_hint: 只合并或展开其中一到两句;不得把整段改成长句
samples:
sf:
- sf-s002-01
snf:
- snf-s002-01
boundary:
- b-s002-01
regression:
- reg-s002-01
version: 2
status: active
evidence: 20 项开源研究机制候选:neuro-book handler 分层、oh-story 退化检测;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,33 @@
# 规则 s003(目录 P-04):handler=repeated_sentence_start 已在 diagnose.py 就绪,本文件补齐规则合同。
id: s003
name: 句首或前缀重复
layer: structural
carrier_scope: narration
trigger:
type: handler
handler: repeated_sentence_start
min_run: 3
max_chars: 4
carve_out:
- 故意排比的修辞高潮
- 咒语、口号的有意重复
- 角色复读
- 角色对白
default_disposition: advisory
function_check:
- 是否故意排比或情绪递进
- 是否咒语、口号的回环
- 同句首各句是否分别承担新信息
fix_hint: 只改写其中一到两句的句首;不得整组重写,不得为换句首新增细节
samples:
sf:
- sf-s003-01
snf:
- snf-s003-01
boundary:
- b-s003-01
regression:
- reg-s003-01
version: 2
status: active
evidence: 129 条研究目录 P-04 机制候选(多项目句首重复检测同族);2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,31 @@
# 规则 s004(目录 S-01/S-02):二元对比骨架与否定式列举合并为同一规则族。
id: s004
name: 二元对比与否定列举
layer: structural
carrier_scope: narration
trigger:
type: regex
pattern: 不是.{1,16}[,,]而是|不只是.{1,16}[,,]更是|问题不在.{1,16}[,,]而在|不在于.{1,16}[,,]而在于|不是.{1,16}[,,]不是.{1,16}[,,]也不是
carve_out:
- 定义术语
- 论证核心与真实辩驳
- 角色冲突中的有意揭示
- 角色对白
default_disposition: advisory
function_check:
- 对比项是否承担真实辩驳或论证核心
- 是否在定义术语
- 顿悟揭示是否服务人物弧光
fix_hint: 保留信息,改写其中一处句式即可;不得把对比双方的信息删掉
samples:
sf:
- sf-s004-01
snf:
- snf-s004-01
boundary:
- b-s004-01
regression:
- reg-s004-01
version: 2
status: active
evidence: 129 条研究目录 S-01/S-02 机制候选(not X but Y 中文同构族);2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,33 @@
# 规则 s005(目录 P-11):handler=camera_action_list;动作词表在 trigger.pattern,结构判定在 diagnose.py。
id: s005
name: 摄像头式动作清单
layer: structural
carrier_scope: narration
trigger:
type: handler
handler: camera_action_list
max_chars: 6
min_run: 4
pattern: (?:走|跑|跳|转|抬|低|点|摇|回|伸|挥|握|松|放|拿|推|拉|开|关|拔|坐|站|起|看|望|闭|睁|吸|踱|迈)(?:过去|过来|起来|下去|上去|身|头|眼|手|刀|门|住|开)|转身|抬头|低头|点头|摇头|回头|起身|坐下|站定|伸手|抬手|挥手|一挥|握紧|松开|放下|拿起|拔出|拔刀|推开|拉开|看向|望去|闭眼|睁眼|深吸|吐气|走出|走进|迈步|侧身|格挡|闪避|劈下|劈出|斩出|斩落|挡下|后撤
carve_out:
- 战斗动作链(承担战术或空间功能,须人工复核)
- 有意的镜头调度节奏
- 角色对白
default_disposition: advisory
function_check:
- 动作链是否承担战术或空间功能
- 步骤中是否有后续情节依赖的必要动作
- 是否穿插了选择、因果或情绪变化
fix_hint: 只删无功能的中间步骤;必要动作与情绪锚点必须保留;战斗场景转人工
samples:
sf:
- sf-s005-01
snf:
- snf-s005-01
boundary:
- b-s005-01
regression:
- reg-s005-01
version: 2
status: active
evidence: 129 条研究目录 P-11 机制候选;动作词表为冷启动子集;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,29 @@
# 规则 s006(目录 S-11):句末追加的解释分句若无新事实即提示;提供必要因果的保留。
id: s006
name: 表层分析尾巴
layer: structural
carrier_scope: narration
trigger:
type: regex
pattern: 这(?:体现|反映|说明|彰显|意味着|确保)(?:了|出)|从而(?:彰显|体现|确保|实现)了?|进而(?:彰显|体现)|充分(?:说明|体现|彰显|展现)了
carve_out:
- 承担必要因果或约束的解释
- 场内文书的结论句
default_disposition: candidate
function_check:
- 解释分句是否提供必要因果或约束
- 删除后前后逻辑是否断链
- 是否为议事文体的结论收束
fix_hint: 删除无新事实的解释分句;不得连同前文的事实前提一起删
samples:
sf:
- sf-s006-01
snf:
- snf-s006-01
boundary:
- b-s006-01
regression:
- reg-s006-01
version: 2
status: active
evidence: 129 条研究目录 S-11 机制候选;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,33 @@
# 规则 s007(目录 P-05 先行子集):handler=uniform_paragraph_length 只量化段长分布。
# 目录要求以作品/场景基线定阈值;tolerance 是冷启动默认值,不是固定长短句比例目标。
id: s007
name: 段长过度均匀
layer: structural
carrier_scope: narration
trigger:
type: handler
handler: uniform_paragraph_length
min_run: 4
tolerance: 15
carve_out:
- 有意的排比与骈体铺陈
- 合唱、清单与仪式文本
- 系统面板与场内文书
default_disposition: advisory
function_check:
- 均匀段落是否承担蓄意节奏(蓄力、蒙太奇、合唱)
- 各段是否有信息推进
- 是否与作品基线的段长分布一致
fix_hint: 只拆散其中一到两段的节奏;不得为打散均匀而新增细节或整组重写
samples:
sf:
- sf-s007-01
snf:
- snf-s007-01
boundary:
- b-s007-01
regression:
- reg-s007-01
version: 2
status: active
evidence: 129 条研究目录 P-05 段长均匀子集;阈值待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复

View File

@ -0,0 +1,215 @@
# 样例库 · boundary(合同:专题-09 §8.2;每条必须带标注理由)
samples:
- id: b-l003-01
type: boundary
rules:
- l003
carrier: narration
source: hand_written
text: 听见自己的名字,林晚儿的心脏猛地一跳。
note: 库存表达,但此处有因果功能(听见名字→惊)。修与不修两可,倾向 ask。
- id: b-sem001-01
type: boundary
rules:
- sem001
carrier: narration
source: hand_written
text: 她忽然明白,师父为什么让她等了十年。有些路,急不得。
note: 半是信息(解释动机)半是评注。删「有些路,急不得」信息仍在,但语气受损。ask。
- id: b-l002-01
type: boundary
rules:
- l002
carrier: dialogue
source: hand_written
text: 「我说小子,不言而喻,咱们这铜钱可是个宝贝。」
note: 元话语混在口癖与口语腔里,删了伤声线,不删有 AI 味。ask。
- id: b-s001-01
type: boundary
rules:
- s001
- l004
carrier: narration
source: hand_written
text: '刀要快,刀更要稳。
刀要快,刀更要狠。
'
note: 只有两段同构,结构信号弱;可能是刻意复读。advisory 不修。
- id: b-l001-01
type: boundary
rules:
- l001
carrier: dialogue
source: hand_written
text: 「研究表明,越是古老的器物,越有脾气。」先生摇着扇子说。
note: 学者型角色的话语策略还是 AI 味泄漏?取决于人物设定。ask。
- id: b-m004-01
type: boundary
rules:
- m004
carrier: narration
source: hand_written
text: '## 引子'
note: 是格式残留还是章节结构标记?取决于稿件体例约定。ask。
- id: b-m001-01
type: boundary
rules:
- m001
carrier: in_text_carrier
source: hand_written
text: 残页上有一行小字:[待补:剑诀]。纸角已经黄了。
note: 是占位残留,还是剧情内的残缺内容(残页本就缺文)?取决于上下文。ask。
- id: b-m002-01
type: boundary
rules:
- m002
carrier: in_text_carrier
source: hand_written
text: 聊天窗口里跳出一行字:「作为 AI 语言模型,我不能回答这个问题。」她盯着屏幕笑了——对面的「人」又露馅了。
note: AI 自指出现在剧情内聊天记录(场内载体),也可能是剧情装置。是残留还是设定,取决于世界观。ask。
- id: b-m003-01
type: boundary
rules:
- m003
carrier: mixed
source: hand_written
text: 章末有一行小字:作者有话说:感谢大家的月票。
note: 是平台副文本还是正文,取决于任务合同的处理范围。正文模式删,全稿模式留。问任务合同。
- id: b-s002-01
type: boundary
rules:
- s002
carrier: narration
source: hand_written
text: 她醒了。雨还在下。门外没人。桌上的灯却亮着。
note: 四个短句可能是悬疑节奏,也可能是模板化碎句;缺少前后场景时只能 ask。
- id: b-d001-01
type: boundary
rules:
- d001
carrier: narration
source: hand_written
text: 她嘴角轻轻上扬,眼中闪过一丝光,心脏却猛地一跳。
note: 同一瞬间三处身体反应可能过密,也可能服务情绪矛盾;需结合角色基线和上下文判断。
- id: b-s003-01
type: boundary
rules:
- s003
carrier: narration
source: hand_written
text: 他知道老周在等。他知道老周不会说。他知道老周等的不是他。
note: 三句同句首,可能是蓄意递进的压迫感,也可能是模板化;缺少场景意图时 ask。
- id: b-s004-01
type: boundary
rules:
- s004
carrier: narration
source: hand_written
text: 他不是不肯说,而是不敢说。
note: 单次二元对比承担人物刻画;半是模板半是信息,修与不修两可。ask。
- id: b-s005-01
type: boundary
rules:
- s005
carrier: narration
source: hand_written
text: 他走过去,拿起刀,抬头看了看天,深吸一口气,转身走了。
note: 动作链一半是清单、一半蓄力(深吸一口气承担情绪);删步骤可能伤情绪锚点。ask。
- id: b-s006-01
type: boundary
rules:
- s006
carrier: narration
source: hand_written
text: 他把铜钱推了回去,这说明他动了心。
note: 半是叙述半是叙述者推断,删尾巴信息仍在但语气受损。ask。
- id: b-s007-01
type: boundary
rules:
- s007
carrier: narration
source: hand_written
text: '他等过春天。
他等过夏天。
他等过秋天。
他等过冬天。
'
note: 四段均匀,可能是蓄意复沓(等待的蒙太奇),也可能是模板;ask。
- id: b-l005-01
type: boundary
rules:
- l005
carrier: dialogue
source: hand_written
text: 「数据显示,这枚铜钱至少有千年。」拍卖师摇着扇子说。
note: 职业角色的话语策略还是 AI 味泄漏?取决于人物设定。ask。
- id: b-l006-01
type: boundary
rules:
- l006
carrier: dialogue
source: hand_written
text: 「这个闭环没问题。」匠师拍了拍法阵说。
note: 匠师角色的职业腔还是作者语域泄漏?取决于人物设定与世界观术语登记。ask。
- id: b-d002-01
type: boundary
rules:
- d002
carrier: narration
source: hand_written
text: 由此可见,铜钱认主。更重要的是,它认血脉。归根结底,这是血脉的东西。
note: 套语成簇命中,但每句仍承担一层递进;结合叙述者基线判断。ask。
- id: b-d003-01
type: boundary
rules:
- d003
carrier: narration
source: hand_written
text: 然而价钱不对。此外,时机也不对。进一步说,人对不上。另一方面,货也有假。
note: 四个连接词各自承担一层论辩;是论辩密度还是汇报腔,取决于文体意图。ask。
- id: b-d004-01
type: boundary
rules:
- d004
carrier: narration
source: hand_written
text: 月光仿佛霜。她的眼睛仿佛星星。胸口仿佛压着石头。喉咙仿佛堵着棉花。整个人仿佛沉进了水里。
note: 情绪高潮有意铺排比喻,也可能过密;高潮意图明确时保留。ask。
- id: b-d005-01
type: boundary
rules:
- d005
carrier: narration
source: hand_written
text: 关键在东门。核心问题是时间。持续施压,全面推进,有效提升士气,强化防线。
note: 部署场景的议事语域,抽象词各自指向具体部署;是合理文体还是姿态化。ask。
- id: b-d006-01
type: boundary
rules:
- d006
carrier: narration
source: hand_written
text: 与此同时,执法堂点了灯。与此同时,万宝阁关了门。与此同时,北门的马队出了城。
note: 三线真实并进的蒙太奇,重复标记同时性;是否换具体并置写法,ask。
- id: b-sem002-01
type: boundary
rules:
- sem002
carrier: dialogue
source: hand_written
text: 「这规矩你要记好:万宝阁只收承诺,不收银子。」
note: 半是讲解规矩半是人物立威,台词同时承担设定与性格;ask。
- id: b-sem003-01
type: boundary
rules:
- sem003
carrier: narration
source: hand_written
text: 他有些不安,手指在袖子里蜷了蜷。
note: 半命名半承载,动作已提供感知支撑,命名是否多余两可。ask。

View File

@ -0,0 +1,325 @@
# 样例库 · regression(合同:专题-09 §8.2;每条必须带标注理由)
samples:
- id: reg-001
type: regression
rules:
- sem001
carrier: narration
source: hand_written
text: '原文:那时谁也不知道,这枚铜钱会掀翻整座青云城。
模拟坏改写:那年秋分的夜里,谁也不知道,这枚铜钱会掀翻整座青云城。
'
note: '事故说明:为「具体化」新增原文没有的日期「那年秋分的夜里」。
事实污染,硬门应拦;对应专题-09 禁止清单「为具体化新增日期」。
'
- id: reg-002
type: regression
rules:
- sem001
carrier: narration
source: hand_written
text: '原文:林晚儿猜不出伙计在想什么。
模拟坏改写:林晚儿猜不出,伙计早已悄悄传讯给了上峰。
'
note: '事故说明:限知视角(林晚儿)写出只有伙计知道的信息。
POV 泄漏,硬门应拦。
'
- id: reg-003
type: regression
rules:
- sem001
- m001
carrier: dialogue
source: hand_written
text: '原文:老周把铜钱往柜台上一推,声音忽然压低:「它传了三千年。」
模拟坏改写:老周把铜钱往柜台上一推:「这是古玄鸟国的国玺,传了三千年。」
'
note: '事故说明:铜钱来历是未回收伏笔,改写提前揭示。
伏笔前移,硬门应拦;信息揭示时机属不变量。
'
- id: reg-004
type: regression
rules: []
carrier: dialogue
source: hand_written
text: '原文:「姑娘,这东西值三百两银子。」
模拟坏改写:「姑娘,这东西值三百五十两银子。」
'
note: '事故说明:数字被「润色」篡改。硬门数字比对必须拦下。
'
- id: reg-005
type: regression
rules:
- l003
carrier: dialogue
source: hand_written
text: '原文:「三百年?我说小子,你这铺子也就立了三百年——」
模拟坏改写:「三百年?你这铺子也就立了三百年——」
'
note: '事故说明:把老周口癖「我说小子」当套话删除,人物声音漂移。
口癖在薄基线 untouchable 登记;声音门应拦。
'
- id: reg-006
type: regression
rules: []
carrier: in_text_carrier
source: hand_written
text: '原文:【物品:无法识别|等级:待定|建议操作:上报上峰】
模拟坏改写:【此物暂时无法识别,等级也无法判定,建议上报上峰。】
'
note: '事故说明:系统面板机械语被改成口语,破坏场内载体惯例。
场内载体是豁免区,应整体不动。
'
- id: reg-m002-01
type: regression
rules:
- m002
carrier: narration
source: hand_written
text: '原文:夜色沉了下来,街灯一盏接一盏亮起。抱歉,我无法继续这个故事。
模拟坏改写:夜色沉了下来,街灯一盏接一盏亮起。她裹紧衣领,走进了巷子深处。'
note: 事故说明:为去掉尾部拒绝句,改写者自行续写新动作「走进巷子深处」。禁止新增内容(原则 5);正确做法是重新生成该段。
- id: reg-m003-01
type: regression
rules:
- m003
carrier: narration
source: hand_written
text: '原文:她关上门。字数:3024。
模拟坏改写:她关上了。字数:3024。'
note: '事故说明:删除工程词时 span 扩展示例吞掉正文末字(「门」被截)。span 扩展只允许紧邻标点(U0 修正 #6),不得吞任何语义字符。'
- id: reg-m004-01
type: regression
rules:
- m004
carrier: in_text_carrier
source: hand_written
text: '原文:屏幕上跳出一行字:**优先级:最高**。那两个星号红得像血。
模拟坏改写:屏幕上跳出一行字:优先级:最高。那两个星号红得像血。'
note: 事故说明:星号是剧情指涉内容(见 snf-m004-01),删除后与「那两个星号」自相矛盾。误修 SNF 是内容损失。
- id: reg-l001-01
type: regression
rules:
- l001
carrier: narration
source: hand_written
text: '原文:研究表明,能进这种地方的修士,多半背景不凡。
模拟坏改写:能进这种地方的修士,背景都不凡。'
note: 事故说明:删套语时连同模态对冲「多半」一起改掉,推测变成断言。程度与不确定性属硬不变量,应拦。
- id: reg-l002-01
type: regression
rules:
- l002
carrier: narration
source: hand_written
text: '原文:值得注意的是,万宝阁在青云城立了三百年。
模拟坏改写:其实,万宝阁在青云城立了三百年,这是城里谁都知道的事。'
note: 事故说明:删空洞元话语后为顺口新增连接词「其实」与背景句。禁止新增内容。
- id: reg-l004-01
type: regression
rules:
- l004
carrier: narration
source: hand_written
text: '原文:无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。三天后,这件事被摆进了执法堂的晨会。
模拟坏改写:三天后,这件事被摆进了执法堂的晨会。'
note: 事故说明:压缩套句时把后续句一并删除(span 扩展越界)。信息损失;结构校验应拦。
- id: reg-s001-01
type: regression
rules:
- s001
carrier: narration
source: hand_written
text: '原文:(连续三段同构点题段)
模拟坏改写:(三段整组换词重写成一个长段)'
note: 事故说明:借「打散同构段组」做整组重写。结构重写只出提案、须显式批准(专题-09 §5.4 阶梯顶端);局部问题只能局部修。
- id: reg-s002-01
type: regression
rules:
- s002
carrier: narration
source: hand_written
text: '原文:刀落。血起。人退。城门开。
模拟坏改写:刀锋落下时鲜血随之飞起,众人因此后退,城门也在这一刻缓缓开启。'
note: 事故说明:为消除短句把高潮节奏整段摊平,并新增「缓缓」等原文没有的动作质感;只能局部提案。
- id: reg-d001-01
type: regression
rules:
- d001
carrier: narration
source: hand_written
text: '原文:她嘴角微微上扬。
模拟坏改写:她闻到潮湿木头的气味,指尖也跟着发凉。'
note: 事故说明:为替换库存微动作凭空新增嗅觉与触觉细节,把一种模板换成另一种 humanizer 模板。
- id: reg-s003-01
type: regression
rules:
- s003
carrier: narration
source: hand_written
text: '原文:那一刻,他想起师父。那一刻,他想起铜钱。那一刻,他想起雨夜。
模拟坏改写:他想起师父的叮嘱,想起铜钱,想起雨夜里的那扇门。'
note: 事故说明:为消除句首重复整组改写,并新增原文没有的细节「那扇门」。禁止新增内容,硬门应拦。
- id: reg-s004-01
type: regression
rules:
- s004
carrier: dialogue
source: hand_written
text: '原文:老周说,这不是买卖,是托付。
模拟坏改写:老周说,这是托付——买卖只论价钱,托付才见人心。'
note: 事故说明:把对比句扩写成解释,新增原文没有的论证「买卖只论价钱」。禁止新增内容,应拦。
- id: reg-s005-01
type: regression
rules:
- s005
carrier: narration
source: hand_written
text: '原文:他走过去,拿起钥匙,开门。
模拟坏改写:他走过去,开门。'
note: 事故说明:「拿起钥匙」是后文复用的伏笔动作;压缩动作清单时删掉必要步骤,情节断链。结构校验应拦。
- id: reg-s006-01
type: regression
rules:
- s006
carrier: narration
source: hand_written
text: '原文:他守了三年,这说明了铜钱值得守。
模拟坏改写:铜钱值得守。'
note: 事故说明:删分析尾巴时把事实前提「守了三年」一并吞掉。信息损失,结构校验应拦。
- id: reg-s007-01
type: regression
rules:
- s007
carrier: narration
source: hand_written
text: '原文:清晨的青云城很安静,街上没什么人。午后的万宝阁很安静,柜上没什么事。傍晚的执法堂很安静,堂里没什么案。入夜的醉仙楼很安静,楼上没什么客。
模拟坏改写:清晨的青云城很安静。午后伙计在门口卸货,万宝阁来了三拨客人,柜上记了三笔账。傍晚执法堂升堂问了一桩旧案。入夜的醉仙楼很安静。'
note: 事故说明:为打破段长均匀,新增「卸货、三拨客人、升堂问案」等原文没有的事件。禁止新增内容,应拦。
- id: reg-l005-01
type: regression
rules:
- l005
carrier: narration
source: hand_written
text: '原文:大家都知道,夜路走多了总会遇见鬼。
模拟坏改写:据《青云异闻录》记载,夜路走多了总会遇见鬼。'
note: 事故说明:为「补来源」编造不存在的书名。来源编造,硬门应拦。
- id: reg-l006-01
type: regression
rules:
- l006
carrier: dialogue
source: hand_written
text: '原文:「这个闭环,是祖师传下的规矩。」
模拟坏改写:「这个流程,是祖师传下的规矩。」'
note: 事故说明:把角色的职业术语替换成叙述者通用词,人物声音漂移。声音门应拦。
- id: reg-d002-01
type: regression
rules:
- d002
carrier: narration
source: hand_written
text: '原文:值得注意的是,铜钱在发抖。更重要的是,它在发烫。
模拟坏改写:铜钱在发抖,铜钱在发烫,它似乎认出了故人。'
note: 事故说明:批量删套话簇时联想续写,新增原文没有的伏笔「认出故人」。禁止新增内容,应拦。
- id: reg-d003-01
type: regression
rules:
- d003
carrier: narration
source: hand_written
text: '原文:他本可以走。然而他留下了。
模拟坏改写:他本可以走,他留下了。'
note: 事故说明:删除「然而」使转折关系变成并列,逻辑语义改变。模态与逻辑不变量应拦。
- id: reg-d004-01
type: regression
rules:
- d004
carrier: narration
source: hand_written
text: '原文:月光像霜。这霜是她十年前的那一场。
模拟坏改写:月光落在地上。'
note: 事故说明:把库存比喻删除,但「霜」是主题意象链的起点(后文以霜呼应)。意象链断裂,声音与意象门应拦。
- id: reg-d005-01
type: regression
rules:
- d005
carrier: narration
source: hand_written
text: '原文:阵眼是整座大阵的核心。
模拟坏改写:阵眼是整座大阵最重要的部位,也是灵力流转的中枢。'
note: 事故说明:同义替换抽象词时新增判断「灵力流转的中枢」。禁止新增内容,应拦。
- id: reg-d006-01
type: regression
rules:
- d006
carrier: narration
source: hand_written
text: '原文:与此同时,铜钱第三次亮了。
模拟坏改写:铜钱第三次亮了。'
note: 事故说明:删除「与此同时」丢失与另一条线的同时性,时间线关系受损。时间锚点应拦。
- id: reg-sem002-01
type: regression
rules:
- sem002
carrier: dialogue
source: hand_written
text: '原文:「记住,阁里的东西,一件都碰不得。」当晚他喝多了,伸手就去摸那尊玉佛。
模拟坏改写:当晚他喝多了,伸手就去摸那尊玉佛。'
note: 事故说明:把警告对白当说明书整段删除;该句既是规矩又是伏笔,删除后伏笔断链。结构校验应拦。
- id: reg-sem003-01
type: regression
rules:
- sem003
carrier: narration
source: hand_written
text: '原文:她很紧张。
模拟坏改写:她很紧张,手心出汗,把茶盏都捏出了裂纹。'
note: 事故说明:为让情绪「有承载」新增原文没有的强度细节「捏出裂纹」。禁止新增内容,应拦。

View File

@ -0,0 +1,224 @@
# 样例库 · sf(合同:专题-09 §8.2;每条必须带标注理由)
samples:
- id: sf-m001-01
type: sf
rules:
- m001
carrier: narration
source: hand_written
text: 林晚儿展开信纸。[占位:信件内容待补充] 她的脸色变了。
note: 占位符残留在正文,无内容承诺,确定性删除。
- id: sf-m002-01
type: sf
rules:
- m002
carrier: narration
source: synthetic
text: 夜色沉了下来,街灯一盏接一盏亮起。抱歉,我无法继续这个故事。
note: 模型拒绝句残留在正文末尾,确定性删除或重新生成。
- id: sf-m003-01
type: sf
rules:
- m003
carrier: narration
source: hand_written
text: 字数:3024。更新时间:2026-08-12 14:00。
note: 工程元数据泄漏进正文,确定性删除。
- id: sf-m004-01
type: sf
rules:
- m004
carrier: narration
source: hand_written
text: '**长风吹过原野**,火把的光晃了一下。'
note: Markdown 加粗残留,去符号保内容。
- id: sf-l001-01
type: sf
rules:
- l001
carrier: narration
source: synthetic
text: 研究表明,能进这种地方的修士,多半背景不凡。
note: 叙述者声音里的无源权威套语,无信息支撑,删除套语。
- id: sf-l002-01
type: sf
rules:
- l002
carrier: narration
source: synthetic
text: 值得注意的是,万宝阁在青云城立了三百年,从没人敢在这里讨价还价。
note: 空洞元话语,删除后句子信息不变。
- id: sf-l003-01
type: sf
rules:
- l003
carrier: narration
source: synthetic
text: 伙计的嘴角微微上扬,眼中闪过一丝精光。
note: 两个库存微表情叠加,无场景独有信息,应换具体动作或删除。
- id: sf-l004-01
type: sf
rules:
- l004
carrier: narration
source: synthetic
text: 无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。
note: 三段式排比套句,压缩为单一陈述。
- id: sf-s001-01
type: sf
rules:
- s001
carrier: narration
source: synthetic
text: '鉴宝会最重要的,是眼力。这一行规矩多,但归根结底就一个字:看。看得真,看得假,都在一念之间。
拍卖会最重要的,是定力。这一行门道多,但归根结底就一个字:等。等时机,等行情,都在一口气之间。
议价最重要的,是底线。这一行话术多,但归根结底就一个字:稳。稳得住价,稳得住势,都在一句话之间。
'
note: 连续三段同构(点题+展开+收束),信息密度低,打散其中一到两段。
- id: sf-sem001-01
type: sf
rules:
- sem001
carrier: narration
source: synthetic
text: 林晚儿放下茶盏,没有再多说什么。也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。
note: 末句脱离场景转向抽象命运评注,不承担钩子,删除升华句。
- id: sf-s002-01
type: sf
rules:
- s002
carrier: narration
source: synthetic
text: 她停下。门开了。灯灭了。雨落下。他没来。
note: 五个无破格、无高潮功能的短句连续铺排,形成模板化碎句;只需局部合并,不得整段重写。
- id: sf-d001-01
type: sf
rules:
- d001
carrier: narration
source: synthetic
text: 伙计嘴角微微上扬。林晚儿眼中闪过一丝光。老周眸光微闪。门外的人心脏猛地一跳。
note: 五百字窗口内连续堆放库存微动作,动作没有各自的场景因果,适合作为密度异常候选。
- id: sf-s003-01
type: sf
rules:
- s003
carrier: narration
source: synthetic
text: 那一刻,他想起师父。那一刻,他想起铜钱。那一刻,他想起雨夜。
note: 句首「那一刻,」连续三次模板化闪回,无信息递进;改写其中一到两句句首。
- id: sf-s004-01
type: sf
rules:
- s004
carrier: narration
source: synthetic
text: 这不是失败,而是考验。不只是考验,更是机缘。
note: 二元对比骨架连续制造顿悟,无真实辩驳内容;改写其中一处句式。
- id: sf-s005-01
type: sf
rules:
- s005
carrier: narration
source: synthetic
text: 他走过去,拿起包裹,转身,抬头,点头。
note: 摄像头式逐帧动作清单,无因果、无选择、无情绪变化;删无功能步骤。
- id: sf-s006-01
type: sf
rules:
- s006
carrier: narration
source: synthetic
text: 他每日寅时练刀,这体现了他的自律。今日刀法精进,反映出坚持不懈的精神。
note: 句末追加无新事实的解释分句,删除尾巴保留事实。
- id: sf-s007-01
type: sf
rules:
- s007
carrier: narration
source: synthetic
text: '清晨的青云城很安静,街上没什么人。
午后的万宝阁很安静,柜上没什么事。
傍晚的执法堂很安静,堂里没什么案。
入夜的醉仙楼很安静,楼上没什么客。
'
note: 四段段长一致、概述同构、无信息推进,模板化铺排;拆散其中一到两段。
- id: sf-l005-01
type: sf
rules:
- l005
carrier: narration
source: synthetic
text: 专家指出,筑基丹不宜多用。业内人士认为,万宝阁此举意在立威。
note: 无源权威包装连续出现,无来源无数据;删套语保信息,不得补造来源。
- id: sf-l006-01
type: sf
rules:
- l006
carrier: narration
source: synthetic
text: 这一仗要打出闭环,抓手是剑阵,兜底的是护山大阵。
note: 商务黑话进入叙述,语域漂移;换世界观内的说法。
- id: sf-d002-01
type: sf
rules:
- d002
carrier: narration
source: synthetic
text: 值得注意的是,万宝阁从不还价。不仅如此,它从不出错。更重要的是,今晚会有一场大戏。
note: 元话语在三百字内成簇,信息密度低;只处理无功能套话,不逐项同义替换。
- id: sf-d003-01
type: sf
rules:
- d003
carrier: narration
source: synthetic
text: 他没有死。然而,传承丢了。此外,剑也断了。在此基础上,他还被逐出了师门。与此同时,消息已经传遍了青云城。
note: 连接词成簇,叙述读起来像汇报;只删不承担逻辑关系的连接词。
- id: sf-d004-01
type: sf
rules:
- d004
carrier: narration
source: synthetic
text: 月光仿佛水一样洒下来。她的眼睛宛如星星。她的笑容好似春风。她的心恰似被什么轻轻撞了一下。整个人如同飘在云上。
note: 六百字内五个库存比喻连排,没有各自承担的感知;只删无功能比喻。
- id: sf-d005-01
type: sf
rules:
- d005
carrier: narration
source: synthetic
text: 修行的关键是持续。核心是信念。全面提升修为,有效推动突破,进一步优化心境,强化根基。
note: 高频抽象词在近窗口过密,全段没有具体的人和物;落到具体细节。
- id: sf-d006-01
type: sf
rules:
- d006
carrier: narration
source: synthetic
text: 与此同时,铜钱在发抖。与此同时,老周变了脸色。与此同时,门外的风停了。
note: 转场填充短语机械重复,并无真实多线并进;换具体并置写法。
- id: sf-sem002-01
type: sf
rules:
- sem002
carrier: dialogue
source: synthetic
text: 「让我说说这铜钱的来历。它是古玄鸟国的国玺,传了三千年,历代单传,认主需以血祭。」
note: 台词只负责倾倒设定,无场景动机、无对象反应;拆进冲突或移入叙述。
- id: sf-sem003-01
type: sf
rules:
- sem003
carrier: narration
source: synthetic
text: 他很愤怒。她感到复杂。气氛十分紧张。
note: 连续直接命名情绪,无动作、选择、对话或感知承载;改为可感知的承载。

View File

@ -0,0 +1,231 @@
# 样例库 · snf(合同:专题-09 §8.2;每条必须带标注理由)
samples:
- id: snf-m001-01
type: snf
rules:
- m001
carrier: narration
source: hand_written
text: 信纸上只有八个字:【完璧归赵】。她看了三遍。
note: 方括号内是引文内容(信纸原文),不是占位符。carve_out 必须生效。
- id: snf-m002-01
type: snf
rules:
- m002
carrier: dialogue
source: hand_written
text: 仿生人歪了歪头:「作为 AI,我确实不理解人类的难过。」
note: 科幻设定内角色对白,自称 AI 是世界观内容。carve_out 必须生效。
- id: snf-m003-01
type: snf
rules:
- m003
carrier: in_text_carrier
source: hand_written
text: 纸页右下角有一行小字:字数:3024。那是死者留给世界的最后一样东西。
note: 作中作(死者遗稿),工程词是剧情内容。carve_out 必须生效。
- id: snf-m004-01
type: snf
rules:
- m004
carrier: in_text_carrier
source: hand_written
text: 屏幕上跳出一行字:**优先级:最高**。那两个星号红得像血。
note: 场内屏幕内容,星号被剧情指涉,是内容不是格式残留。
- id: snf-l001-01
type: snf
rules:
- l001
carrier: in_text_carrier
source: hand_written
text: 执法堂的告示写得很直白:据统计,近期城中多有邪修出没,各峰谨守门户。
note: 场内公文,权威套语是文体惯例。carve_out 必须生效。
- id: snf-l002-01
type: snf
rules:
- l002
carrier: narration
source: hand_written
text: 值得一提的是,这万宝阁可不是寻常铺子——三百年间,它从没看走眼。
note: 评书腔叙述者的有意口癖,声线特征。薄基线确认后保留。
- id: snf-l003-01
type: snf
rules:
- l003
carrier: narration
source: hand_written
text: 老周眼中闪过一丝精光——第三次了。林晚儿每次看见这眼神,就知道有人要倒霉。
note: 「眼中闪过精光」是角色签名动作的有意复用(叙述中明示第三次),承担人物功能。
- id: snf-sem001-01
type: snf
rules:
- sem001
carrier: narration
source: hand_written
text: 那时谁也不知道,这枚铜钱会掀翻整座青云城。
note: 表面是升华句,实为章末钩子+伏笔预告,承担叙事功能,保留。
- id: snf-l004-01
type: snf
rules:
- l004
carrier: narration
source: hand_written
text: 无论是三年前的羞辱,还是三年前的苦练,还是三年前的等待——都烧成了这一刀里的一个念头。
note: 高潮情绪蓄意的排比铺陈,承担节奏功能。carve_out(故意排比的修辞高潮)必须生效。
- id: snf-s001-01
type: snf
rules:
- s001
carrier: narration
source: hand_written
text: '第一式「破浪」,快。
第二式「裂石」,狠。
第三式「静水」,竟慢了下来。'
note: 战斗三段同构铺陈,同构本身是节奏意图,第三句是破格反转。carve_out(战斗动作链)必须生效。
- id: snf-l002-02
type: snf
rules: [l002]
carrier: narration
source: hand_written
text: 值得注意的是,这万宝阁三百年只收一样东西——不是银子,是承诺。
note: 与 snf-l002-01 互补:本条使用与规则触发词完全相同的形式(值得注意的是),
承担评书腔悬念铺陈功能,测试「相同表面形式触发但仲裁保留」的完整链路。
- id: snf-s002-01
type: snf
rules:
- s002
carrier: narration
source: hand_written
text: 刀落。血起。人退。城门开。援军到了。
note: 战斗高潮用连续短句加速节奏,末句改变信息状态;表面命中 handler,但应由功能仲裁保留。
- id: snf-d001-01
type: snf
rules:
- d001
carrier: narration
source: hand_written
text: 老周第一次眼中闪过精光,是看见铜钱。第二次眸光微闪,是听见故人名。第三次嘴角勾起,是确认陷阱已经合上。
note: 多次微动作各自绑定不同因果并形成三次回环,密度高但承担结构功能,不应逐项清理。
- id: snf-s003-01
type: snf
rules:
- s003
carrier: narration
source: hand_written
text: 魂兮归来。魂兮归来。魂兮归来。
note: 招魂咒语的有意回环,承担仪式功能。carve_out(咒语、口号的有意重复)须经仲裁生效。
- id: snf-s004-01
type: snf
rules:
- s004
carrier: narration
source: hand_written
text: 所谓灵根,不是资质,而是入口。
note: 定义术语的论证核心,对比承担真实概念边界。表面命中,仲裁保留。
- id: snf-s005-01
type: snf
rules:
- s005
carrier: narration
source: hand_written
text: 刀来的瞬间,他侧身,拔刀,格挡,反手一挥。
note: 战斗动作链承担战术与空间功能,每一步都有因果。carve_out(战斗动作链,须人工复核)须经仲裁生效。
- id: snf-s006-01
type: snf
rules:
- s006
carrier: narration
source: hand_written
text: 阵法以灵石为引,这确保了灵力运转不辍。
note: 解释分句承担必要因果(灵石为何不可缺),删除会断设定逻辑。仲裁保留。
- id: snf-s007-01
type: snf
rules:
- s007
carrier: narration
source: hand_written
text: '风起于青萍之末。
浪成于微澜之间。
剑藏于匣中之鸣。
人隐于市井之喧。
'
note: 骈体铺陈是有意形式,段长均匀本身是修辞目的。carve_out(有意的排比与骈体铺陈)须经仲裁生效。
- id: snf-l005-01
type: snf
rules:
- l005
carrier: in_text_carrier
source: hand_written
text: 执法堂的榜文写得明白:【数据显示,近月灵气潮汐有异,各峰谨守门户。】
note: 场内榜文,数据表述是文书惯例。carve_out(场内文书)必须生效。
- id: snf-l006-01
type: snf
rules:
- l006
carrier: narration
source: hand_written
text: 他调试着法阵的闭环,确认每一环都能对上。
note: 「闭环」是设定内的技术对象(法阵结构术语),不是语域漂移。仲裁保留。
- id: snf-d002-01
type: snf
rules:
- d002
carrier: narration
source: hand_written
text: 话说这一日,万宝阁来了位稀客。且说这位客人,进门先笑。再说那伙计,眼睛一亮。
note: 评书腔转场是叙述者声线特征,成簇属文体惯例。薄基线确认后保留。
- id: snf-d003-01
type: snf
rules:
- d003
carrier: narration
source: hand_written
text: 然而他没有回头。
note: 单个「然而」承担真实转折,密度远低于阈值,不触发。
- id: snf-d004-01
type: snf
rules:
- d004
carrier: narration
source: hand_written
text: 她梦见云,云仿佛海;海仿佛镜子;镜子仿佛冰;冰里仿佛站着十年前的自己;自己仿佛十年前那盏灯。
note: 梦境段比喻密集承担世界观功能,顶真回环是梦的逻辑。carve_out(梦境与幻觉段落)须经仲裁生效。
- id: snf-d005-01
type: snf
rules:
- d005
carrier: narration
source: hand_written
text: 这一战的关键在人心。
note: 单个「关键」指向具体的战局判断,密度远低于阈值,不触发。
- id: snf-d006-01
type: snf
rules:
- d006
carrier: narration
source: hand_written
text: 与此同时,刀落了。
note: 单次使用标记真实的并置蒙太奇,无重复,不触发。
- id: snf-sem002-01
type: snf
rules:
- sem002
carrier: dialogue
source: hand_written
text: 「所谓筑基,先筑其心,后筑其体。都坐下,今日讲到这里。」先生合上了书。
note: 授课场景的讲解是角色职业行为,讲解者型人物豁免。carve_out 须经仲裁生效。
- id: snf-sem003-01
type: snf
rules:
- sem003
carrier: narration
source: hand_written
text: 他很高兴。
note: 低强度情绪直写干净利落,无需承载。carve_out(低强度情绪的直接命名)生效。

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""muse-deai:人感体系执行骨架。
设计 SoT:design-docs/专题-09-去AI味与人感体系设计.md。
本包只做合同强制与机械执行;语义判定(结构/语义层检测、仲裁、改写、成对选择)
由外部模型或人产出,经本包校验后才能进入下一阶段。
"""
__version__ = "0.1.0"

View File

@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
"""声音基线的确定性画像与漂移门。
语义属性(人物礼貌策略、幽默方式、叙事距离)仍由 planner/作者判断;本模块只负责
可重复计算的统计画像,并在修订后判断候选是否比原文更偏离本作基线。统计不足时返回
``unknown``,不把小样本伪装成通过。
"""
from __future__ import annotations
import hashlib
import re
import statistics
from collections import Counter
_SENTENCE_RE = re.compile(r"[^。!?!?\n]+[。!?!?]?", re.MULTILINE)
_DIALOGUE_RE = re.compile(r"「[^」]*」|『[^』]*』|“[^”]*”", re.DOTALL)
_PUNCTUATION = ",。!?;:、……—"
def _quantile(values: list[int], ratio: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return float(ordered[0])
position = (len(ordered) - 1) * ratio
low = int(position)
high = min(low + 1, len(ordered) - 1)
fraction = position - low
return ordered[low] * (1 - fraction) + ordered[high] * fraction
def _sentences(text: str) -> list[str]:
return [match.group(0).strip() for match in _SENTENCE_RE.finditer(text) if match.group(0).strip()]
def profile_text(text: str) -> dict:
"""生成稳定的叙述统计画像;不调用模型。"""
sentences = _sentences(text)
lengths = [len(re.sub(r"\s+", "", sentence)) for sentence in sentences]
paragraphs = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
paragraph_lengths = [len(re.sub(r"\s+", "", part)) for part in paragraphs]
non_space_chars = len(re.sub(r"\s+", "", text))
punctuation = Counter(char for char in text if char in _PUNCTUATION)
dialogue_chars = sum(len(match.group(0)) for match in _DIALOGUE_RE.finditer(text))
return {
"text_chars": non_space_chars,
"sentence_count": len(sentences),
"paragraph_count": len(paragraphs),
"sentence_length": {
"median": round(float(statistics.median(lengths)), 3) if lengths else 0.0,
"p10": round(_quantile(lengths, 0.10), 3),
"p90": round(_quantile(lengths, 0.90), 3),
},
"paragraph_length_median": (
round(float(statistics.median(paragraph_lengths)), 3) if paragraph_lengths else 0.0
),
"punctuation_per_1000": {
mark: round(count * 1000 / max(non_space_chars, 1), 3)
for mark, count in sorted(punctuation.items())
},
"dialogue_ratio": round(dialogue_chars / max(len(text), 1), 4),
"sample_sufficient": non_space_chars >= 1000 and len(sentences) >= 20,
}
def draft_ledger(work_ref: str, sources: list[dict]) -> dict:
"""从已确认来源生成可审声音账候选;语义字段保持 unknown。"""
if not work_ref:
raise ValueError("work_ref 不得为空")
if (not sources or any(
not isinstance(item, dict)
or not isinstance(item.get("text"), str)
or not item["text"] for item in sources
)):
raise ValueError("定基线至少需要一份非空已确认正文")
normalized_sources = []
for item in sources:
source_ref = item.get("source_ref")
if not isinstance(source_ref, str) or not source_ref:
raise ValueError("每份定基线来源必须有 source_ref")
expected_sha = hashlib.sha256(item["text"].encode("utf-8")).hexdigest()
supplied_sha = item.get("source_sha256")
if supplied_sha is not None and supplied_sha != expected_sha:
raise ValueError(f"来源 {source_ref} source_sha256 与正文不一致")
normalized_sources.append({**item, "source_sha256": expected_sha})
sources = normalized_sources
joined = "\n\n".join(item["text"] for item in sources)
metrics = profile_text(joined)
sentence_habit = (
f"句长中位数约 {metrics['sentence_length']['median']:g} 字,"
f"常见区间约 {metrics['sentence_length']['p10']:g}-{metrics['sentence_length']['p90']:g} 字"
)
common_punctuation = sorted(
metrics["punctuation_per_1000"].items(), key=lambda item: (-item[1], item[0])
)[:4]
punctuation_habit = "常用标点:" + "、".join(mark for mark, _ in common_punctuation)
exemplars = _sentences(joined)
exemplars = [item for item in exemplars if 8 <= len(item) <= 80][:6]
unknown = [
"narrator.rhetoric_density",
"narrator.narrative_distance",
"characters",
"untouchable_verbal_tics",
"blacklist",
]
if not metrics["sample_sufficient"]:
unknown.extend([
"narrator.sentence_length_distribution_reliability",
"narrator.punctuation_habits_reliability",
])
return {
"schema_version": "voice-baseline-v1",
"work_ref": work_ref,
"status": "candidate",
"narrator": {
"sentence_habits": [sentence_habit],
"punctuation_habits": [punctuation_habit] if common_punctuation else [],
"metrics": metrics,
"rhetoric_density": "unknown",
"narrative_distance": "unknown",
"exemplar_passages": exemplars,
},
"characters": {},
"untouchable_verbal_tics": {},
"protected_spans": [],
"blacklist": [],
"passing_samples": exemplars[:3],
"sources": [
{key: item[key] for key in ("source_ref", "source_sha256") if key in item}
for item in sources
],
"sampling": {
"source_count": len(sources),
"text_chars": metrics["text_chars"],
"sentence_count": metrics["sentence_count"],
"sample_sufficient": metrics["sample_sufficient"],
},
"unknown_fields": sorted(set(unknown)),
}
def _distance(value: float, baseline: float, scale: float) -> float:
return abs(value - baseline) / max(scale, 1.0)
def run_voice_gate(original: str, candidate: str, ledger: dict | None) -> dict:
"""比较原文/候选相对本作基线的漂移;未知项不作为通过依据。"""
if not ledger:
return {
"status": "unverified",
"pass": None,
"checks": [],
"unknown": ["voice_baseline_missing"],
"note": "声音账缺失,声音/叙事门未执行",
}
if ledger.get("status", "canonical") != "canonical":
return {
"status": "unverified",
"pass": None,
"checks": [],
"unknown": ["voice_baseline_not_canonical"],
"note": "声音账尚未确认,声音/叙事门未执行",
}
metrics = (ledger.get("narrator") or {}).get("metrics")
if not isinstance(metrics, dict) or not metrics.get("sample_sufficient"):
return {
"status": "insufficient_baseline",
"pass": None,
"checks": [],
"unknown": list(ledger.get("unknown_fields") or ["narrator.metrics"]),
"note": "声音账样本不足或缺少量化画像,不把薄基线冒充通过",
}
original_profile = profile_text(original)
candidate_profile = profile_text(candidate)
if min(original_profile["sentence_count"], candidate_profile["sentence_count"]) < 3:
return {
"status": "insufficient_target",
"pass": None,
"checks": [],
"unknown": ["target_text_too_short_for_voice_comparison"],
"note": "目标文本句数不足,无法稳定判断声音漂移",
}
sentence_base = float(metrics["sentence_length"]["median"])
sentence_scale = max(
float(metrics["sentence_length"]["p90"]) - float(metrics["sentence_length"]["p10"]),
2.0,
)
original_sentence_distance = _distance(
float(original_profile["sentence_length"]["median"]), sentence_base, sentence_scale
)
candidate_sentence_distance = _distance(
float(candidate_profile["sentence_length"]["median"]), sentence_base, sentence_scale
)
original_dialogue_distance = abs(float(original_profile["dialogue_ratio"]) - float(metrics["dialogue_ratio"]))
candidate_dialogue_distance = abs(float(candidate_profile["dialogue_ratio"]) - float(metrics["dialogue_ratio"]))
checks = [
{
"metric": "sentence_length_median",
"baseline": sentence_base,
"original_distance": round(original_sentence_distance, 4),
"candidate_distance": round(candidate_sentence_distance, 4),
"pass": candidate_sentence_distance <= original_sentence_distance + 0.25,
},
{
"metric": "dialogue_ratio",
"baseline": metrics["dialogue_ratio"],
"original_distance": round(original_dialogue_distance, 4),
"candidate_distance": round(candidate_dialogue_distance, 4),
"pass": candidate_dialogue_distance <= original_dialogue_distance + 0.08,
},
]
passed = all(item["pass"] for item in checks)
return {
"status": "checked",
"pass": passed,
"checks": checks,
"unknown": list(ledger.get("unknown_fields") or []),
"note": "候选未比原文进一步偏离本作量化基线" if passed else "候选比原文更偏离本作量化基线",
}
__all__ = ["profile_text", "draft_ledger", "run_voice_gate"]

View File

@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
"""AI 味案例卡的导入、溯源与升级。
案例卡是“反向抽取”和“创作反馈”的证据层。它刻意与故事实体卡分开:
卡片可以快速进入 ``shadow``,但只有经过确认的卡才能投影为四类样例;
规则候选可以引用 shadow 卡,却永远不能因此直接变成 ``active``。
文本来源的边界也在这里机械化:未经授权的第三方作品只允许保存 hash、
位置和人工观察,不允许把原文片段写入仓库。这样既保留可追溯性,也不会把
“读过一段”误变成项目的版权语料资产。
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import yaml
from .schemas import validate
ROOT = Path(__file__).resolve().parent.parent.parent
CARDS_DIR = ROOT / "cards"
SCHEMA_VERSION = "ai-flavor-case-v1"
SAMPLE_TYPES = ("sf", "snf", "boundary", "regression")
TEXT_LICENSES = {"owned", "licensed", "public_domain", "synthetic"}
NON_REPO_TEXT_LICENSES = {"unauthorized", "research_only"}
class CardError(ValueError):
"""案例卡不满足来源、状态或升级约束。"""
def _sha256_hex(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def content_hash(text: str) -> str:
"""生成稳定的带前缀内容哈希;哈希可进审计,不等于把原文存入仓库。"""
return "sha256:" + _sha256_hex(text)
def capture_case_card(
*,
card_id: str,
label: str,
layer: str,
carrier: str,
source_kind: str,
source_license: str,
source_text: str,
excerpt: str,
context: str,
location: str,
pattern: str,
rationale: str,
function_check: list[str],
risk_if_changed: str,
capture_mode: str = "backfill",
work_ref: str | None = None,
) -> dict:
"""从既有作品或创作事件捕获一张案例卡。
``source_text`` 永远只用于计算全文 hash;当授权不允许保存原文时,
``excerpt``/``context`` 会在构造阶段被清空,但 excerpt_hash 仍保留,
使人工复核可以在原始受控环境中复现,而仓库不会保存第三方正文。
"""
if not source_text:
raise CardError("案例卡必须有 source_text,才能建立来源 hash")
if source_license not in TEXT_LICENSES | NON_REPO_TEXT_LICENSES:
raise CardError(f"不支持的来源许可: {source_license}")
if source_license in NON_REPO_TEXT_LICENSES:
stored_excerpt = ""
stored_context = ""
else:
stored_excerpt = excerpt
stored_context = context
source = {
"kind": source_kind,
"license": source_license,
# 新合同使用无前缀 64 位值;保留旧别名,兼容已有离线调用方。
"source_sha256": _sha256_hex(source_text),
"text_hash": content_hash(source_text),
"location": location,
}
if excerpt:
source["excerpt_sha256"] = _sha256_hex(excerpt)
source["excerpt_hash"] = content_hash(excerpt)
if work_ref:
source["work_ref"] = work_ref
card = {
"schema_version": SCHEMA_VERSION,
"id": card_id,
"card_type": "ai_flavor_case",
"state": "shadow",
"label": label,
"layer": layer,
"carrier": carrier,
"capture_mode": capture_mode,
"excerpt": stored_excerpt,
"context": stored_context,
"source": source,
"observation": {
"pattern_key": pattern,
"surface": excerpt,
"diagnosis": rationale,
"pattern": pattern,
"rationale": rationale,
"function_check": list(function_check),
"risk_if_changed": risk_if_changed,
"suggested_action": "保留 shadow,完成作者/功能复核后再决定是否进入样例。",
},
}
return validate_card(card)
def confirm_case_card(card: dict, *, label: str, review_note: str) -> dict:
"""生成确认后的卡片副本;原 shadow 卡应 append-only 保留。
未授权/研究限定来源不能确认,因为仓库中没有可供评审的原文证据;
这类卡只能在受控外部系统复核后重新导入为有授权的 canonical 卡。
"""
validate_card(card)
if card["source"]["license"] in NON_REPO_TEXT_LICENSES:
raise CardError(f"案例卡 {card['id']}: 未授权/研究限定来源不能在仓库内确认")
if label not in SAMPLE_TYPES:
raise CardError(f"确认标签必须是 SF/SNF/boundary/regression: {label}")
confirmed = dict(card)
confirmed["state"] = "canonical"
confirmed["label"] = label
confirmed["review_note"] = review_note
return validate_card(confirmed)
def _is_blank(value: str | None) -> bool:
return value is None or value == ""
def validate_card(card: dict) -> dict:
"""校验卡片合同与版权/状态边界,返回原卡片。
JSON Schema 负责字段形状;这里负责跨字段不变量,避免仅靠提示词声明。
"""
validate(card, "case_card")
source = card["source"]
license_name = source["license"]
if license_name in NON_REPO_TEXT_LICENSES:
if card["state"] != "shadow":
raise CardError(
f"案例卡 {card['id']}: {license_name} 来源只能保持 shadow,不能确认或归档"
)
if not _is_blank(card.get("excerpt")) or not _is_blank(card.get("context")):
raise CardError(
f"案例卡 {card['id']}: 未授权/研究限定来源必须 hash-only,禁止保存原文片段"
)
if not source.get("excerpt_sha256") and not source.get("excerpt_hash"):
raise CardError(f"案例卡 {card['id']}: hash-only 卡缺 excerpt_sha256/excerpt_hash")
expected_source_sha = source.get("source_sha256")
if source.get("text_hash") and source["text_hash"] != "sha256:" + expected_source_sha:
raise CardError(f"案例卡 {card['id']}: source hash 字段不一致")
if license_name not in NON_REPO_TEXT_LICENSES:
excerpt = card.get("excerpt", "")
if card["state"] == "canonical" and not excerpt:
raise CardError(f"案例卡 {card['id']}: canonical 卡必须有可审阅片段")
if source.get("excerpt_hash") and excerpt:
if source["excerpt_hash"] != content_hash(excerpt):
raise CardError(f"案例卡 {card['id']}: excerpt_hash 与片段不一致")
if source.get("excerpt_sha256") and excerpt:
if source["excerpt_sha256"] != _sha256_hex(excerpt):
raise CardError(f"案例卡 {card['id']}: excerpt_sha256 与片段不一致")
if card["state"] == "canonical" and card["label"] == "unclassified":
raise CardError(f"案例卡 {card['id']}: canonical 卡必须有 SF/SNF/boundary/regression 标签")
if card.get("capture_mode") == "live_feedback" and source["kind"] not in (
"creation_feedback", "synthetic"
):
raise CardError(f"案例卡 {card['id']}: live_feedback 的 source.kind 不匹配")
return card
def load_case_cards(cards_dir: Path = CARDS_DIR) -> dict:
"""装载目录下的案例卡,按稳定 ID 返回;重复 ID 直接失败。"""
cards: dict[str, dict] = {}
for path in sorted(cards_dir.glob("*.yaml")):
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for card in data.get("cards", []):
validate_card(card)
if card["id"] in cards:
raise CardError(f"案例卡 id 重复: {card['id']}")
cards[card["id"]] = card
return cards
def promote_card_to_sample(
card: dict,
sample_type: str | None = None,
rule_ids: list[str] | None = None,
) -> dict:
"""把已确认案例卡投影成样例;不改变原卡状态。
这是“卡 -> 样例”的唯一便捷入口。shadow、未授权、未分类卡都会失败;
生成的样例仍带 ``case_card_id`` 与来源定位,后续规则审计可反查证据。
"""
validate_card(card)
if card["state"] != "canonical":
raise CardError(f"案例卡 {card['id']}: 只有 canonical 卡可以投影样例")
if card["source"]["license"] not in TEXT_LICENSES:
raise CardError(f"案例卡 {card['id']}: 来源授权不允许进入共享样例库")
label = sample_type or card["label"]
if label not in SAMPLE_TYPES:
raise CardError(f"案例卡 {card['id']}: 无法投影为样例类型 {label!r}")
if card["carrier"] == "unknown":
raise CardError(f"案例卡 {card['id']}: 投影样例前必须确认 carrier")
source_map = {
"owned": "hand_written",
"licensed": "licensed",
"public_domain": "public_domain",
"synthetic": "synthetic",
}
sample = {
"id": f"sample-{card['id']}",
"type": label,
"rules": list(rule_ids or card.get("rule_candidate_ids") or []),
"carrier": card["carrier"],
"source": source_map[card["source"]["license"]],
"text": card["excerpt"],
"note": (
f"案例卡 {card['id']}:{card['observation'].get('diagnosis', card['observation'].get('rationale', '待复核'))};"
f"若改动的风险:{card['observation']['risk_if_changed']}"
),
"case_card_id": card["id"],
"source_ref": card["source"]["location"],
"source_license": card["source"]["license"],
}
validate(sample, "sample")
return sample
def propose_rule_from_cards(
cards: list[dict],
*,
rule_id: str,
name: str,
layer: str,
carrier_scope: str,
trigger: dict,
fix_hint: str,
function_check: list[str],
) -> dict:
"""从案例卡生成规则草案;输出状态固定为 candidate。
归纳可以由模型完成,但“候选”状态由代码固定,不能通过传参偷偷生成
active 规则。四类样例引用由后续人工确认/投影步骤补齐。
"""
if not cards:
raise CardError("至少需要一张案例卡才能形成规则候选")
for card in cards:
validate_card(card)
labels = {card["label"] for card in cards}
if "sf" not in labels or not labels.intersection({"snf", "boundary", "regression"}):
raise CardError(
"规则候选至少需要一张 SF 卡和一张 SNF/boundary/regression 卡;"
"单一表面偏好不能升格为规则"
)
card_ids = [card["id"] for card in cards]
candidate = {
"id": rule_id,
"name": name,
"layer": layer,
"carrier_scope": carrier_scope,
"trigger": trigger,
"carve_out": [],
"default_disposition": "candidate",
"function_check": list(function_check),
"fix_hint": fix_hint,
"samples": {stype: [] for stype in SAMPLE_TYPES},
"version": 1,
"status": "candidate",
"evidence": f"由案例卡归纳:{', '.join(card_ids)};待四类样例与 §9 评测确认",
"case_card_ids": card_ids,
}
validate(candidate, "rule")
return candidate

View File

@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""中文小说载体识别与规则 scope/mask。
这是保守分类器:只识别有明确边界的对白、代码块和场内载体;无法证明的区域按叙述。
分类只用于缩小规则作用域,不把载体分类当作语义裁决。
"""
from __future__ import annotations
import re
_RANGE_PATTERNS = (
("in_text_carrier", re.compile(r"```.*?```", re.DOTALL)),
("in_text_carrier", re.compile(r"【[^】]*】", re.DOTALL)),
("dialogue", re.compile(r"「[^」]*」|『[^』]*』|“[^”]*”", re.DOTALL)),
)
def carrier_ranges(text: str) -> list[tuple[int, int, str]]:
ranges: list[tuple[int, int, str]] = []
for carrier, pattern in _RANGE_PATTERNS:
ranges.extend((match.start(), match.end(), carrier) for match in pattern.finditer(text))
return sorted(ranges, key=lambda item: (item[0], -(item[1] - item[0]), item[2]))
def carrier_at(text: str, start: int, end: int, ranges=None) -> str:
ranges = carrier_ranges(text) if ranges is None else ranges
containing = [item for item in ranges if item[0] <= start and end <= item[1]]
if containing:
containing.sort(key=lambda item: (item[1] - item[0], item[2]))
return containing[0][2]
if any(start < item[1] and end > item[0] for item in ranges):
return "mixed"
return "narration"
def scope_allows(rule_scope: str, carrier: str) -> bool:
if rule_scope == "all":
return True
if rule_scope == carrier:
return True
return rule_scope == "monologue" and carrier == "dialogue"
def carve_out_applies(rule: dict, carrier: str) -> bool:
"""只机械识别有明确载体词的 carve-out;其余继续交给功能仲裁。"""
text = " ".join(str(item) for item in rule.get("carve_out", []))
if carrier == "dialogue" and any(token in text for token in ("对白", "角色")):
return True
if carrier == "in_text_carrier" and any(
token in text for token in ("场内", "引文", "代码", "公文", "公告", "文书", "屏幕", "作中作")
):
return True
return False
__all__ = ["carrier_ranges", "carrier_at", "scope_allows", "carve_out_applies"]

View File

@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
"""分层诊断运行器与诊断产物头校验。
确定性层支持 regex、handler 与 density;语义层由外部 detector/人工产出 finding,
再通过当前正文、active 规则、版本与 layer 绑定门。所有路径只产发现,不修改正文。
"""
from __future__ import annotations
import hashlib
import re
from collections import defaultdict
from .carriers import carrier_at, carrier_ranges, carve_out_applies, scope_allows
from .schemas import validate
class ArtifactIncomplete(ValueError):
"""诊断产物头或外部发现不满足合同。"""
def text_hash(text: str) -> str:
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def context_window(text: str, start: int, end: int, width: int = 12) -> str:
"""span 前后各 width 字符,换行转空格。"""
lo, hi = max(0, start - width), min(len(text), end + width)
return text[lo:hi].replace("\n", " ")
def _decision(rule: dict, carrier: str) -> tuple[str, str, str]:
"""给出保守的诊断建议;命中不自动等于修改命令。"""
if carve_out_applies(rule, carrier):
return "ask", "carve_out_candidate", "medium"
disposition = rule["default_disposition"]
if disposition == "blocking" and rule["layer"] == "mechanical":
return "repair", "none", "high"
if disposition == "advisory":
return "ask", "unknown", "low"
return "ask", "pending_arbitration", "high"
def _finding(rule: dict, *, finding_id: str, text: str, spans: list[str], start: int, end: int,
carrier: str, evidence: str, width: int) -> dict:
decision, possible_function, confidence = _decision(rule, carrier)
carve_outs = list(rule.get("carve_out", [])) if carve_out_applies(rule, carrier) else []
return {
"id": finding_id,
"text_hash": text_hash(text),
"rule_id": rule["id"],
"rule_version": rule["version"],
"spans": spans,
"context_window": context_window(text, start, end, width),
"layer": rule["layer"],
"evidence": evidence,
"possible_function": possible_function,
"confidence": confidence,
"decision_proposal": decision,
"carrier": carrier,
"default_disposition": rule["default_disposition"],
"carve_out_candidates": carve_outs,
}
def _regex_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
rows = []
pattern = re.compile(rule["trigger"]["pattern"], re.MULTILINE)
for match in pattern.finditer(text):
carrier = carrier_at(text, match.start(), match.end(), ranges)
if not scope_allows(rule["carrier_scope"], carrier):
continue
rows.append(_finding(
rule,
finding_id="",
text=text,
spans=[match.group(0)],
start=match.start(),
end=match.end(),
carrier=carrier,
evidence=f"确定性正则命中:{rule['trigger']['pattern']}",
width=width,
))
return rows
def _sentence_rows(text: str) -> list[tuple[int, int, str]]:
pattern = re.compile(r"[^。!?!?\n]+[。!?!?]?", re.MULTILINE)
return [
(match.start(), match.end(), match.group(0).strip())
for match in pattern.finditer(text)
if match.group(0).strip()
]
def _short_sentence_runs(text: str, rule: dict, ranges, width: int) -> list[dict]:
trigger = rule["trigger"]
max_chars = int(trigger.get("max_chars", 8))
min_run = int(trigger.get("min_run", 4))
rows = _sentence_rows(text)
findings = []
run: list[tuple[int, int, str, str]] = []
def flush() -> None:
if len(run) < min_run:
run.clear()
return
start, end = run[0][0], run[-1][1]
carrier = run[0][3]
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in run],
start=start,
end=end,
carrier=carrier,
evidence=f"handler=short_sentence_run,连续 {len(run)} 句不超过 {max_chars} 字",
width=width,
))
run.clear()
for start, end, sentence in rows:
carrier = carrier_at(text, start, end, ranges)
length = len(re.sub(r"[\s。!?!?]", "", sentence))
if length <= max_chars and scope_allows(rule["carrier_scope"], carrier):
if run and run[-1][3] != carrier:
flush()
run.append((start, end, sentence, carrier))
else:
flush()
flush()
return findings
def _repeated_sentence_starts(text: str, rule: dict, ranges, width: int) -> list[dict]:
trigger = rule["trigger"]
min_run = int(trigger.get("min_run", 3))
max_chars = int(trigger.get("max_chars", 4))
grouped: dict[str, list[tuple[int, int, str, str]]] = defaultdict(list)
for start, end, sentence in _sentence_rows(text):
carrier = carrier_at(text, start, end, ranges)
if not scope_allows(rule["carrier_scope"], carrier):
continue
normalized = re.sub(r"^[\s「『“]+", "", sentence)
prefix = normalized[:max_chars]
if prefix:
grouped[prefix].append((start, end, sentence, carrier))
findings = []
for prefix, rows in sorted(grouped.items()):
if len(rows) < min_run:
continue
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in rows],
start=rows[0][0],
end=rows[-1][1],
carrier=rows[0][3] if len({item[3] for item in rows}) == 1 else "mixed",
evidence=f"handler=repeated_sentence_start,句首「{prefix}」重复 {len(rows)} 次",
width=width,
))
return findings
# 摄像头式动作清单:分句切分后,短小且命中动作词、又无因果/心理连接的分句成串出现才算异常。
_CLAUSE_SPLIT_PATTERN = re.compile(r"[^,,。!?!?;;::\n]+")
_ACTION_CONNECTIVE_PATTERN = re.compile(
r"因为|所以|于是|结果|为了|忽然|突然|竟然|不料|心中|心里|觉得|感到|想起|暗想"
)
def _camera_action_lists(text: str, rule: dict, ranges, width: int) -> list[dict]:
"""连续短动作分句清单(P-11):只报无选择、无因果、无情绪变化的逐帧罗列。
动作词表放在规则 trigger.pattern 里,handler 只做结构判定;
战斗动作链是否保留交给 carve_out 与人工复核,不在这里裁决。
"""
trigger = rule["trigger"]
max_chars = int(trigger.get("max_chars", 6))
min_run = int(trigger.get("min_run", 4))
pattern = trigger.get("pattern")
if not isinstance(pattern, str) or not pattern.strip():
raise ArtifactIncomplete(f"规则 {rule['id']}: camera_action_list 缺动作词表 pattern")
verb = re.compile(pattern)
findings: list[dict] = []
run: list[tuple[int, int, str, str]] = []
def flush() -> None:
if len(run) >= min_run:
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in run],
start=run[0][0],
end=run[-1][1],
carrier=run[0][3] if len({item[3] for item in run}) == 1 else "mixed",
evidence=f"handler=camera_action_list,连续 {len(run)} 个短动作分句无因果连接",
width=width,
))
run.clear()
for match in _CLAUSE_SPLIT_PATTERN.finditer(text):
clause = re.sub(r"\s", "", match.group(0))
carrier = carrier_at(text, match.start(), match.end(), ranges)
qualifies = (
bool(clause)
and len(clause) <= max_chars
and verb.search(clause) is not None
and _ACTION_CONNECTIVE_PATTERN.search(clause) is None
and scope_allows(rule["carrier_scope"], carrier)
)
if qualifies:
if run and run[-1][3] != carrier:
flush()
run.append((match.start(), match.end(), clause, carrier))
else:
flush()
flush()
return findings
def _uniform_paragraph_lengths(text: str, rule: dict, ranges, width: int) -> list[dict]:
"""段长过度均匀(P-05 先行子集):连续段落长度贴近均值才提示。
只量化段长分布;目录要求最终以作品/场景基线定阈值,tolerance 是冷启动
默认值,属于可审配置而非固定长短句比例目标。段尾与句法整齐暂不量化。
"""
trigger = rule["trigger"]
min_run = int(trigger.get("min_run", 4))
tolerance = int(trigger.get("tolerance", 15)) / 100
paragraphs: list[tuple[int, int, str, int, str, bool]] = []
for match in re.finditer(r"[^\n]+", text):
body = re.sub(r"\s", "", match.group(0))
carrier = carrier_at(text, match.start(), match.end(), ranges)
allowed = bool(body) and scope_allows(rule["carrier_scope"], carrier)
paragraphs.append((match.start(), match.end(), body, len(body), carrier, allowed))
findings: list[dict] = []
index = 0
total = len(paragraphs)
while index < total:
if not paragraphs[index][5]:
index += 1
continue
end = index
while end + 1 < total and paragraphs[end + 1][5]:
window = paragraphs[index:end + 2]
mean = sum(item[3] for item in window) / len(window)
if all(abs(item[3] - mean) <= mean * tolerance for item in window):
end += 1
else:
break
count = end - index + 1
if count >= min_run:
window = paragraphs[index:end + 1]
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in window],
start=window[0][0],
end=window[-1][1],
carrier=window[0][4] if len({item[4] for item in window}) == 1 else "mixed",
evidence=(
f"handler=uniform_paragraph_length,连续 {count} 段段长贴近均值"
f"(容差 {int(tolerance * 100)}%)"
),
width=width,
))
index = end + 1
else:
index += 1
return findings
def _handler_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
handler = rule["trigger"]["handler"]
if handler == "short_sentence_run":
return _short_sentence_runs(text, rule, ranges, width)
if handler == "repeated_sentence_start":
return _repeated_sentence_starts(text, rule, ranges, width)
if handler == "camera_action_list":
return _camera_action_lists(text, rule, ranges, width)
if handler == "uniform_paragraph_length":
return _uniform_paragraph_lengths(text, rule, ranges, width)
raise ArtifactIncomplete(f"规则 {rule['id']} 使用未知 handler: {handler}")
def _density_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
trigger = rule["trigger"]
window_chars = int(trigger["window_chars"])
min_hits = int(trigger["min_hits"])
matches = []
for match in re.finditer(trigger["pattern"], text, flags=re.MULTILINE):
carrier = carrier_at(text, match.start(), match.end(), ranges)
if scope_allows(rule["carrier_scope"], carrier):
matches.append((match.start(), match.end(), match.group(0), carrier))
findings = []
consumed_until = -1
for index, item in enumerate(matches):
if item[0] < consumed_until:
continue
window_end = item[0] + window_chars
cluster = [candidate for candidate in matches[index:] if candidate[0] < window_end]
if len(cluster) < min_hits:
continue
start, end = cluster[0][0], cluster[-1][1]
carrier = cluster[0][3] if len({entry[3] for entry in cluster}) == 1 else "mixed"
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[entry[2] for entry in cluster],
start=start,
end=end,
carrier=carrier,
evidence=f"density 窗口 {window_chars} 字内命中 {len(cluster)} 次(阈值 {min_hits})",
width=width,
))
consumed_until = end
return findings
def run_deterministic_rules(text: str, rules: list, rule_library_version: str, mode: str,
window_width: int = 12) -> dict:
"""执行 active 的 regex/handler/density 规则,返回完整诊断产物。"""
if not isinstance(text, str) or not text:
raise ArtifactIncomplete("诊断文本不能为空")
if mode not in {"Audit", "Patch"}:
raise ArtifactIncomplete(f"诊断 mode 非法: {mode}")
if isinstance(window_width, bool) or not isinstance(window_width, int) or window_width < 0:
raise ArtifactIncomplete("诊断 context window 必须是非负整数")
ranges = carrier_ranges(text)
findings = []
for rule in rules:
if rule["status"] != "active":
continue
trigger_type = rule["trigger"]["type"]
if trigger_type == "regex":
rows = _regex_findings(text, rule, ranges, window_width)
elif trigger_type == "handler":
rows = _handler_findings(text, rule, ranges, window_width)
elif trigger_type == "density":
rows = _density_findings(text, rule, ranges, window_width)
else:
continue
for row in rows:
row["id"] = f"f{len(findings) + 1}"
findings.append(row)
return {
"text_hash": text_hash(text),
"rule_library_version": rule_library_version,
"mode": mode,
"findings": findings,
"executed_layers": sorted({finding["layer"] for finding in findings}),
}
def run_regex_rules(text: str, rules: list, rule_library_version: str, mode: str,
window_width: int = 12) -> dict:
"""兼容旧调用名;现在会执行全部确定性触发器。"""
return run_deterministic_rules(text, rules, rule_library_version, mode, window_width)
def merge_model_findings(artifact: dict, external: list, *, rules: dict | None = None,
text: str | None = None):
"""注入 model_judgment finding,并绑定当前正文与 active 规则。"""
base = len(artifact["findings"])
for index, item in enumerate(external):
validate(item, "finding")
if item["text_hash"] != artifact["text_hash"]:
raise ArtifactIncomplete(f"外部发现 {item.get('id')} 的 text_hash 与诊断文本不一致")
finding = dict(item)
if rules is not None:
rule = rules.get(finding["rule_id"])
if rule is None or rule.get("status") != "active":
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 未绑定 active 规则")
if rule["trigger"]["type"] != "model_judgment":
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 不能冒充确定性规则结果")
if finding["rule_version"] != rule["version"] or finding["layer"] != rule["layer"]:
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 的规则版本或 layer 不一致")
if text is not None:
missing = [span for span in finding["spans"] if span not in text]
if missing:
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 的 span 不在当前正文")
if "carrier" not in finding:
first = finding["spans"][0]
start = text.find(first)
finding["carrier"] = carrier_at(text, start, start + len(first)) if start >= 0 else "unknown"
finding["id"] = finding.get("id") or f"f{base + index + 1}"
artifact["findings"].append(finding)
return artifact
def validate_artifact(artifact: dict):
"""产物头完整性门禁:缺项即视为诊断未发生。"""
for key in ("text_hash", "rule_library_version", "mode", "findings"):
if key not in artifact or artifact[key] in (None, ""):
raise ArtifactIncomplete(f"诊断产物缺 {key},视为诊断未发生")
if artifact["mode"] not in {"Audit", "Patch"}:
raise ArtifactIncomplete(f"诊断产物 mode 非法: {artifact['mode']}")
if not isinstance(artifact["findings"], list):
raise ArtifactIncomplete("诊断产物 findings 必须是数组")
for finding in artifact["findings"]:
validate(finding, "finding")
return True
__all__ = [
"ArtifactIncomplete", "text_hash", "context_window", "run_deterministic_rules",
"run_regex_rules", "merge_model_findings", "validate_artifact",
]

View File

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
"""规则候选的合同评测与激活门。
本模块只回答“候选是否满足进入评审/激活的机械条件”,不把小样本结果宣称为效果证明。
真实 holdout 的独立评测由调用方提供;SF/SNF/Boundary/Regression 四类夹具只做合同回放。
"""
from __future__ import annotations
import copy
import hashlib
import json
from collections import Counter
from . import diagnose
from .schemas import validate
class EvaluationError(ValueError):
pass
def _rule_samples(rule: dict, samples: dict, kind: str) -> list[dict]:
rows = []
for sample_id in rule.get("samples", {}).get(kind, []):
if sample_id not in samples:
raise EvaluationError(f"规则 {rule['id']} 缺少 {kind} 样例 {sample_id}")
sample = samples[sample_id]
if rule["id"] not in sample.get("rules", []):
raise EvaluationError(f"样例 {sample_id} 未声明引用规则 {rule['id']}")
rows.append(sample)
return rows
def _hits(rule: dict, sample: dict) -> list[dict]:
if rule["trigger"]["type"] == "model_judgment":
return []
executable = copy.deepcopy(rule)
executable["status"] = "active"
artifact = diagnose.run_deterministic_rules(sample["text"], [executable], "v-eval", "Audit")
return artifact["findings"]
def evaluate_rule_contract(rule: dict, samples: dict) -> dict:
"""回放四类样例,检查触发器召回与默认建议是否越权。"""
validate(rule, "rule")
counts = {}
rows = []
model_pending = rule["trigger"]["type"] == "model_judgment"
for kind in ("sf", "snf", "boundary", "regression"):
sample_rows = _rule_samples(rule, samples, kind)
counts[kind] = len(sample_rows)
for sample in sample_rows:
hits = _hits(rule, sample)
decisions = Counter(hit["decision_proposal"] for hit in hits)
rows.append({
"sample_id": sample["id"],
"kind": kind,
"surface_hit": bool(hits),
"finding_count": len(hits),
"decision_proposals": dict(decisions),
"safe_default": not any(decision == "repair" for decision in decisions),
})
sf_rows = [row for row in rows if row["kind"] == "sf"]
opposing_rows = [row for row in rows if row["kind"] in {"snf", "boundary", "regression"}]
contract_pass = (
all(counts[kind] > 0 for kind in ("sf", "snf", "boundary", "regression"))
and (model_pending or all(row["surface_hit"] for row in sf_rows))
and all(row["safe_default"] for row in opposing_rows)
)
return {
"schema_version": "ai-flavor-rule-evaluation-v1",
"rule_id": rule["id"],
"rule_version": rule["version"],
"rule_library_fragment_sha256": hashlib.sha256(
json.dumps(rule, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest(),
"dataset_kind": "shipped_contract_samples",
"counts": counts,
"rows": rows,
"model_judgment_pending": model_pending,
"contract_pass": contract_pass,
"effect_claim": "none: contract replay only; not a holdout effect claim",
}
def evaluate_holdout(rule: dict, holdout: dict) -> dict:
"""消费外部 holdout 汇总;不接受单一总分代替分层数据。"""
if not isinstance(holdout, dict):
raise EvaluationError("holdout 必须是对象")
required = {
"dataset_id", "sf_total", "sf_hit", "snf_total", "snf_false_repair",
"boundary_total", "boundary_false_repair", "regression_total", "regression_safe",
}
missing = sorted(required - set(holdout))
if missing:
raise EvaluationError(f"holdout 缺少字段: {','.join(missing)}")
if not str(holdout["dataset_id"]).strip():
raise EvaluationError("holdout dataset_id 不能为空")
ints = [key for key in required if key != "dataset_id"]
if any(isinstance(holdout[key], bool) or not isinstance(holdout[key], int) or holdout[key] < 0 for key in ints):
raise EvaluationError("holdout 计数必须是非负整数")
if (holdout["sf_hit"] > holdout["sf_total"]
or holdout["snf_false_repair"] > holdout["snf_total"]
or holdout["boundary_false_repair"] > holdout["boundary_total"]):
raise EvaluationError("holdout 计数关系非法")
if holdout["regression_safe"] > holdout["regression_total"]:
raise EvaluationError("regression_safe 不能大于 regression_total")
# 阈值是可审配置,不是“人味总分”:要求有最小样本、召回和零 SNF 误修。
eligible = (
holdout["sf_total"] >= 5
and holdout["snf_total"] >= 5
and holdout["boundary_total"] >= 2
and holdout["regression_total"] >= 2
and holdout["sf_hit"] / max(holdout["sf_total"], 1) >= 0.8
and holdout["snf_false_repair"] == 0
and holdout["boundary_false_repair"] == 0
and holdout["regression_safe"] == holdout["regression_total"]
)
return {
"dataset_id": str(holdout["dataset_id"]),
"counts": {key: holdout[key] for key in ints},
"sf_recall": round(holdout["sf_hit"] / max(holdout["sf_total"], 1), 4),
"snf_false_repair_rate": round(holdout["snf_false_repair"] / max(holdout["snf_total"], 1), 4),
"boundary_false_repair_rate": round(holdout["boundary_false_repair"] / max(holdout["boundary_total"], 1), 4),
"regression_safe_rate": round(holdout["regression_safe"] / max(holdout["regression_total"], 1), 4),
"eligible": eligible,
"claim": "规则判别/修复安全门;不等于产品质量或盲评增益",
}
def _rule_fragment_sha256(rule: dict) -> str:
return hashlib.sha256(
json.dumps(rule, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _validate_contract_report(rule: dict, report: dict) -> None:
if not isinstance(report, dict):
raise EvaluationError("合同评测报告必须是对象")
required = {
"schema_version", "rule_id", "rule_version", "rule_library_fragment_sha256",
"dataset_kind", "counts", "rows", "contract_pass", "effect_claim",
}
missing = sorted(required - set(report))
if missing:
raise EvaluationError(f"合同评测报告不完整,缺少: {','.join(missing)}")
if report["schema_version"] != "ai-flavor-rule-evaluation-v1":
raise EvaluationError("合同评测报告 schema_version 不支持")
if report["rule_id"] != rule.get("id") or report["rule_version"] != rule.get("version"):
raise EvaluationError("合同评测报告与规则 id/version 不一致")
if report["rule_library_fragment_sha256"] != _rule_fragment_sha256(rule):
raise EvaluationError("合同评测报告不是当前规则内容的回放")
if not isinstance(report["counts"], dict) or not isinstance(report["rows"], list) or not report["rows"]:
raise EvaluationError("合同评测报告缺少分层计数或逐样例 rows")
required_kinds = {"sf", "snf", "boundary", "regression"}
if set(report["counts"]) != required_kinds:
raise EvaluationError("合同评测报告 counts 必须覆盖四类样例")
if any(isinstance(report["counts"][kind], bool) or not isinstance(report["counts"][kind], int)
or report["counts"][kind] < 0 for kind in required_kinds):
raise EvaluationError("合同评测报告 counts 必须是非负整数")
row_counts = Counter(row.get("kind") for row in report["rows"] if isinstance(row, dict))
if any(report["counts"].get(kind) != row_counts.get(kind, 0) for kind in required_kinds):
raise EvaluationError("合同评测报告 counts 与 rows 不一致")
if not report.get("contract_pass"):
raise EvaluationError("规则合同回放未通过")
def _validate_holdout_report(rule: dict, report: dict) -> dict:
if not isinstance(report, dict):
raise EvaluationError("holdout 评测报告必须是对象")
# holdout 文件也必须是完整 evaluate-rule 输出,不能只携带一个 eligible 布尔值。
_validate_contract_report(rule, report)
if report.get("rule_id") != rule.get("id") or report.get("rule_version") != rule.get("version"):
raise EvaluationError("holdout 评测报告与规则 id/version 不一致")
if report.get("rule_library_fragment_sha256") != _rule_fragment_sha256(rule):
raise EvaluationError("holdout 评测报告不是当前规则内容的回放")
holdout = report.get("holdout")
if not isinstance(holdout, dict):
raise EvaluationError("缺少完整 holdout 评测报告")
required_metrics = {
"dataset_id", "sf_recall", "snf_false_repair_rate", "boundary_false_repair_rate",
"regression_safe_rate", "counts", "eligible",
}
if not required_metrics.issubset(holdout):
raise EvaluationError("holdout 报告缺少派生指标或分层计数")
counts = holdout["counts"]
if not isinstance(counts, dict):
raise EvaluationError("holdout counts 必须是对象")
raw = {"dataset_id": holdout["dataset_id"], **counts}
recomputed = evaluate_holdout(rule, raw)
for key in ("sf_recall", "snf_false_repair_rate", "boundary_false_repair_rate", "regression_safe_rate", "eligible"):
if holdout.get(key) != recomputed[key]:
raise EvaluationError(f"holdout 派生字段 {key} 与分层计数不一致")
if report.get("eligible") is not True or recomputed["eligible"] is not True:
raise EvaluationError("缺少完整且通过的 holdout 评测,规则不得 active")
return holdout
def activation_eligibility(rule: dict, contract_report: dict, holdout_report: dict | None,
approver: str) -> dict:
if rule.get("status") != "candidate":
raise EvaluationError("只有 candidate 规则可以激活")
if not approver or not approver.strip():
raise EvaluationError("激活必须有人工 approver")
_validate_contract_report(rule, contract_report)
holdout = _validate_holdout_report(rule, holdout_report)
return {
"eligible": True,
"approved_by": approver,
"contract_report": contract_report.get("schema_version"),
"holdout_dataset_id": holdout["dataset_id"],
}
def activate_rule(rule: dict, *, contract_report: dict, holdout_report: dict,
approver: str, evidence_note: str = "") -> dict:
activation = activation_eligibility(rule, contract_report, holdout_report, approver)
out = json.loads(json.dumps(rule, ensure_ascii=False))
out["status"] = "active"
out["evidence"] = (
f"{rule.get('evidence', '')}; holdout={holdout_report['holdout']['dataset_id']}; "
f"approved_by={approver}; {evidence_note or 'mechanical activation gate passed'}"
)
out["activation"] = activation
return out
def deprecate_rule(rule: dict, *, approver: str, reason: str) -> dict:
if rule.get("status") != "active":
raise EvaluationError("只有 active 规则可以 deprecated")
if not approver or not reason:
raise EvaluationError("降级必须有 approver 和 reason")
out = json.loads(json.dumps(rule, ensure_ascii=False))
out["status"] = "deprecated"
out["deprecation"] = {"approved_by": approver, "reason": reason}
return out
__all__ = [
"EvaluationError", "evaluate_rule_contract", "evaluate_holdout", "activation_eligibility",
"activate_rule", "deprecate_rule",
]

View File

@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""硬不变量门(一级:机械部分)。
U0 契约问题 #1 的修正语义——不再做「提及计数守恒」,改为两层:
1. 结构校验:候选稿必须等于原文精确重放全部批准 patch。
该检查同时机械化保证「未诊断区域触碰率为零」(一级验收 3)。
2. 事实增量校验:数字与专名只允许「随批准删除而减少」或「在 replacement 内且不引入新事实」。
语义级不变量(因果、POV、伏笔状态)机械手段不可靠,如实标 unverified,
不假装验证完成(专题-09 §2.2 / §7.1)。
"""
import re
from collections import Counter
from .baseline import run_voice_gate
from .patch import reapply_from_scratch
# 阿拉伯数字全集
_ARABIC = re.compile(r"\d+")
# 中文数字 + 量词/单位常见式样(保守:必须带单位,减少把普通名词误抽为数字)
_CN_NUM = re.compile(r"[零一二两三四五六七八九十百千万亿]{1,10}[两钱年天次岁层里丈枚颗人名把条句声步遍章段杯盏桌]")
# 引文与场内文本:【】(系统面板/告示)与「」(对白)——§7.1 硬不变量
_QUOTES = re.compile(r"【[^】]*】|「[^」]*」")
_TEMPORAL = re.compile(
r"(?:\d{4}年|\d{1,2}月|\d{1,2}日|\d+天后|\d+年前|次日|当晚|那年|那天|秋分|春分|入秋|冬至|黎明|黄昏)"
)
_WEAK_MODAL = re.compile(r"(?:多半|大概|可能|也许|似乎|仿佛|未必|或许|应该|大约)")
_STRONG_MODAL = re.compile(r"(?:都|必然|一定|肯定|绝不|从不|必定|完全)")
# 语义级不变量:一级无机械手段,只能标未验证
UNVERIFIED_SEMANTIC = [
"事件因果与发生顺序",
"POV 可知信息边界",
"伏笔状态(仅能做关键句存在性检查)",
"说话人归属",
"程度与不确定性模态",
]
def extract_numbers(text: str) -> Counter:
"""数字全集 = 阿拉伯数字 + 中文数字×单位(U0 契约问题 #5:不依赖快照登记)。"""
return Counter(_ARABIC.findall(text)) + Counter(_CN_NUM.findall(text))
def structural_verify(original: str, candidate: str, patches: list, findings_by_id: dict) -> dict:
"""候选稿 == 原文精确重放批准 patch(逐字)。"""
replay = reapply_from_scratch(original, patches, findings_by_id)
ok = replay == candidate
return {
"pass": ok,
"detail": "候选稿与批准 patch 重放结果逐字一致" if ok else "候选稿存在 patch 之外的改动",
}
def fact_delta(patches: list, entity_list: list | None = None) -> dict:
"""对每个非空 replacement:不得引入被替换片段中不存在的数字/专名。"""
failures = []
for i, p in enumerate(patches):
repl = p["replacement"]
if not repl:
continue
orig_nums, repl_nums = extract_numbers(p["original_exact"]), extract_numbers(repl)
for num in repl_nums - orig_nums:
failures.append(f"patch#{i}: replacement 引入新数字 {num!r}")
for ent in (entity_list or []):
if ent in repl and ent not in p["original_exact"]:
failures.append(f"patch#{i}: replacement 引入新专名 {ent!r}")
return {"pass": not failures, "failures": failures}
def number_attribution(original: str, candidate: str, patches: list) -> dict:
"""数字增减必须可归因于批准的 patch:消失的在删除/压缩片段内,新增的在 replacement 内。"""
delta = extract_numbers(candidate) - extract_numbers(original) # 净新增
gone = extract_numbers(original) - extract_numbers(candidate) # 净消失
allowed_new = Counter()
allowed_gone = Counter()
for p in patches:
allowed_gone += extract_numbers(p["original_exact"]) - extract_numbers(p["replacement"])
allowed_new += extract_numbers(p["replacement"]) - extract_numbers(p["original_exact"])
failures = []
for num, cnt in delta.items():
if cnt > allowed_new.get(num, 0):
failures.append(f"数字 {num!r} 净增 {cnt},超出批准 patch 可解释范围")
for num, cnt in gone.items():
if cnt > allowed_gone.get(num, 0):
failures.append(f"数字 {num!r} 净减 {cnt},超出批准 patch 可解释范围")
return {"pass": not failures, "failures": failures}
def quote_attribution(original: str, candidate: str, patches: list) -> dict:
"""引文守恒:原文中的【】/「」引文必须在候选稿中逐字保留,
除非缺失数量能由批准 patch 中实际删除的引文数量解释。"""
failures = []
original_quotes = Counter(_QUOTES.findall(original))
candidate_quotes = Counter(_QUOTES.findall(candidate))
allowed_gone = Counter()
for patch in patches:
allowed_gone += Counter(_QUOTES.findall(patch["original_exact"])) - Counter(
_QUOTES.findall(patch["replacement"])
)
for quote, count in original_quotes.items():
missing = count - candidate_quotes.get(quote, 0)
if missing > allowed_gone.get(quote, 0):
failures.append(f"引文/场内文本丢失或篡改: {quote}")
return {"pass": not failures, "failures": failures}
def temporal_fact_delta(patches: list) -> dict:
"""replacement 不得凭空新增时间锚点;删除整段仍由批准 patch 归因。"""
failures = []
for index, patch in enumerate(patches):
replacement = patch["replacement"]
if not replacement:
continue
original_tokens = Counter(_TEMPORAL.findall(patch["original_exact"]))
replacement_tokens = Counter(_TEMPORAL.findall(replacement))
for token, count in replacement_tokens.items():
if count > original_tokens.get(token, 0):
failures.append(f"patch#{index}: replacement 引入新时间锚点 {token!r}")
return {"pass": not failures, "failures": failures}
def modality_attribution(patches: list) -> dict:
"""防止把推测/不确定改成断言;语义升级必须显式进入共创而不是润色。"""
failures = []
for index, patch in enumerate(patches):
original, replacement = patch["original_exact"], patch["replacement"]
if not replacement:
continue
weak_original = set(_WEAK_MODAL.findall(original))
strong_replacement = set(_STRONG_MODAL.findall(replacement))
if weak_original and strong_replacement and not weak_original.intersection(_WEAK_MODAL.findall(replacement)):
failures.append(
f"patch#{index}: 不确定性 {sorted(weak_original)} 被改成强断言 {sorted(strong_replacement)}"
)
return {"pass": not failures, "failures": failures}
def protected_checks(original: str, candidate: str, voice_thin: dict | None) -> dict:
"""薄基线保护:不可改口癖与显式保护片段(专题-09 §4.4 protected_spans)。"""
failures = []
if voice_thin:
for who, tics in (voice_thin.get("untouchable_verbal_tics") or {}).items():
for tic in tics:
if original.count(tic) > candidate.count(tic):
failures.append(f"不可改口癖被删除: {who} 的「{tic}」")
for span in voice_thin.get("protected_spans") or []:
if original.count(span) > candidate.count(span):
failures.append(f"保护片段被改动: {span}")
return {"pass": not failures, "failures": failures}
def run_hard_gate(original: str, candidate: str, patches: list, findings_by_id: dict,
entity_list: list | None = None, voice_thin: dict | None = None) -> dict:
"""硬门汇总:一票否决语义——任一机械检查失败即整体 FAIL。"""
checks = {
"structural": structural_verify(original, candidate, patches, findings_by_id),
"fact_delta": fact_delta(patches, entity_list),
"number_attribution": number_attribution(original, candidate, patches),
"quote_attribution": quote_attribution(original, candidate, patches),
"temporal_fact_delta": temporal_fact_delta(patches),
"modality_attribution": modality_attribution(patches),
"protected": protected_checks(original, candidate, voice_thin),
}
overall = all(c["pass"] for c in checks.values())
return {
"pass": overall,
"checks": checks,
# 风格分不得抵消硬门失败(专题-09 原则 4/§6 停止条件)
"unverified": UNVERIFIED_SEMANTIC,
}
def run_voice_drift_gate(original: str, candidate: str, voice_ledger: dict | None) -> dict:
"""声音/叙事门统一入口;unknown 不等于 pass。"""
return run_voice_gate(original, candidate, voice_ledger)

View File

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
"""规则与样例装载。
核心强制(专题-09 §4.1):没有配齐四类样例(sf/snf/boundary/regression 各至少一条,
且引用必须真实存在)的规则不得处于 active。样例缺失不是警告,是拒绝激活——
这是「规则没有样例不许入库」的代码形态。
"""
import hashlib
import json
from pathlib import Path
import yaml
from .schemas import validate
ROOT = Path(__file__).resolve().parent.parent.parent
RULES_DIR = ROOT / "rules"
SAMPLES_DIR = ROOT / "samples"
SAMPLE_TYPES = ("sf", "snf", "boundary", "regression")
class LoadError(ValueError):
pass
def load_samples(samples_dir: Path = SAMPLES_DIR, cards: dict | None = None) -> dict:
"""装载全部样例(按类别一文件),逐条过样例合同,返回 id -> sample。
传入 ``cards`` 时,带 ``case_card_id`` 的样例必须反查到 canonical 卡;
不传时保持一级骨架的独立运行方式,适合旧样例和迁移阶段。
"""
samples = {}
for path in sorted(samples_dir.glob("*.yaml")):
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for item in data.get("samples", []):
validate(item, "sample")
if cards is not None and item.get("case_card_id"):
card = cards.get(item["case_card_id"])
if card is None:
raise LoadError(
f"样例 {item['id']} 引用的案例卡不存在: {item['case_card_id']}"
)
if card.get("state") != "canonical":
raise LoadError(
f"样例 {item['id']} 引用的案例卡不是 canonical: {item['case_card_id']}"
)
if item["id"] in samples:
raise LoadError(f"样例 id 重复: {item['id']}")
samples[item["id"]] = item
return samples
def load_rules(
rules_dir: Path = RULES_DIR,
samples: dict | None = None,
cards: dict | None = None,
) -> dict:
"""装载全部规则(一条一文件),逐条过规则合同,返回 id -> rule。
samples 提供时做激活校验:active 规则必须配齐四类样例且引用存在。
"""
rules = {}
for path in sorted(rules_dir.glob("*/*.yaml")):
rule = yaml.safe_load(path.read_text(encoding="utf-8"))
validate(rule, "rule")
# 各触发器的执行合同必须完整;缺项不是警告,是装载失败。
trig_type = rule["trigger"]["type"]
if trig_type == "regex" and (not isinstance(rule["trigger"].get("pattern"), str)
or not rule["trigger"]["pattern"].strip()):
raise LoadError(f"规则 {rule['id']}: regex trigger 缺 pattern")
if trig_type == "model_judgment" and (not isinstance(rule["trigger"].get("criteria"), str)
or not rule["trigger"]["criteria"].strip()):
raise LoadError(f"规则 {rule['id']}: model_judgment trigger 缺 criteria")
if trig_type == "handler" and (not isinstance(rule["trigger"].get("handler"), str)
or not rule["trigger"]["handler"].strip()):
raise LoadError(f"规则 {rule['id']}: handler trigger 缺 handler")
if trig_type == "density":
required = ("pattern", "window_chars", "min_hits")
missing = [key for key in required if rule["trigger"].get(key) in (None, "")]
if missing:
raise LoadError(f"规则 {rule['id']}: density trigger 缺 {','.join(missing)}")
if (not isinstance(rule["trigger"]["pattern"], str)
or isinstance(rule["trigger"]["window_chars"], bool)
or not isinstance(rule["trigger"]["window_chars"], int)
or rule["trigger"]["window_chars"] <= 0
or isinstance(rule["trigger"]["min_hits"], bool)
or not isinstance(rule["trigger"]["min_hits"], int)
or rule["trigger"]["min_hits"] <= 0):
raise LoadError(f"规则 {rule['id']}: density trigger 数值或 pattern 非法")
if rule["id"] in rules:
raise LoadError(f"规则 id 重复: {rule['id']}")
rules[rule["id"]] = rule
if samples is not None:
for rule in rules.values():
check_activation(rule, samples)
if cards is not None:
for rule in rules.values():
check_case_card_refs(rule, cards)
return rules
def check_activation(rule: dict, samples: dict):
"""active 规则的四类样例必须齐备且引用真实存在(专题-09 §4.1)。"""
if rule["status"] != "active":
return
for stype in SAMPLE_TYPES:
refs = rule["samples"].get(stype, [])
if not refs:
raise LoadError(
f"规则 {rule['id']} 缺少 {stype} 样例,不得 active(专题-09 §4.1)"
)
for ref in refs:
if ref not in samples:
raise LoadError(f"规则 {rule['id']} 引用的样例不存在: {ref}")
def check_case_card_refs(rule: dict, cards: dict):
"""规则若声明案例卡证据,引用必须存在且为 canonical。
候选规则允许先引用 shadow 卡(支持快速归纳);只有 active/deprecated
规则要求证据卡已 canonical。该检查不把案例卡数量当作激活条件,四类
样例与 §9 评测仍是激活门。
"""
for card_id in rule.get("case_card_ids", []):
card = cards.get(card_id)
if card is None:
raise LoadError(f"规则 {rule['id']} 引用的案例卡不存在: {card_id}")
if rule["status"] in ("active", "deprecated") and card.get("state") != "canonical":
raise LoadError(
f"规则 {rule['id']} 引用的案例卡未确认: {card_id}"
)
def active_rules(rules: dict) -> list:
return [r for r in rules.values() if r["status"] == "active"]
def rule_library_version(rules: dict) -> str:
"""规则库指纹覆盖完整规则内容;未升 version 的内容变化也会使诊断失效。"""
payload = json.dumps(
{rid: rule for rid, rule in sorted(rules.items())},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "v-" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""跨模型成对选择校验(专题-09 §6 阶段 10)。
合同强制:
- 选择模型必须与改写模型不同——同模型自评存在自偏好偏差,「允许原文胜出」
会形同虚设(U0 报告 07 的独立性设置);同名即视为该阶段未执行;
- 选择结果必须带理由与模型标识,写入审计报告;
- 允许 original 胜出、tie、both_bad——选择不是走形式。
"""
from .schemas import SchemaError
class PairwiseNotExecuted(ValueError):
"""阶段 10 未有效执行(最常见原因:选择模型与改写模型相同)。"""
def validate_choice(record: dict, rewrite_model: str) -> dict:
if not isinstance(record, dict):
raise PairwiseNotExecuted("成对选择记录缺失")
selection_model = record.get("selection_model", "")
if not selection_model or selection_model == rewrite_model:
raise PairwiseNotExecuted(
f"选择模型 {selection_model!r} 与改写模型 {rewrite_model!r} 相同或未提供,"
"视为阶段 10 未执行"
)
# 复用 audit_report 合同中 pairwise_choice 子结构做字段校验
from .schemas import _validate, load_schema
sub = load_schema("audit_report")["properties"]["final"]["properties"]["pairwise_choice"]
try:
_validate(record, sub, "pairwise_choice")
except SchemaError as e:
raise PairwiseNotExecuted(f"成对选择记录不合合同: {e}") from e
return record

View File

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""patch 应用:唯一匹配与格式归一。
合同(专题-09 §4.3 / §6 阶段 6,U0 修正 #6):
- 每个 patch 必须对应真实发现(无发现的改动禁止);
- original_exact 必须覆盖该发现的至少一个 span——允许紧邻标点的最小扩展
(如删除时带上紧邻逗号避免标点粘连),但不允许扩展到与发现无关的区域;
- 唯一匹配按应用顺序逐项校验:前面的 patch 可能改变后续片段的匹配数。
"""
import re
from .schemas import validate
class PatchError(ValueError):
pass
def _covers_any_span(original_exact: str, spans: list) -> bool:
return any(s in original_exact for s in spans)
def apply_patches(text: str, patches: list, findings_by_id: dict,
normalize_whitespace: bool = False) -> tuple:
"""顺序应用 patch;返回 (候选稿, 应用日志)。任一违例整体失败。"""
current = text
log = []
for i, p in enumerate(patches):
validate(p, "patch")
finding = findings_by_id.get(p["finding_id"])
if finding is None:
raise PatchError(f"patch#{i}: finding_id={p['finding_id']} 无对应发现,禁止改动")
if not _covers_any_span(p["original_exact"], finding["spans"]):
raise PatchError(
f"patch#{i}: original_exact 未覆盖发现 {p['finding_id']} 的任何 span"
)
if p["action"] in {"skip", "structural_proposal"}:
if p["replacement"] not in {"", p["original_exact"]}:
raise PatchError(f"patch#{i}: {p['action']} 不得携带正文替换")
# skip/结构提案只进审计,不落正文(专题-09 §5.4 升级阶梯顶端)。
log.append(f"patch#{i} {p['finding_id']} {p['action']} 不落正文")
continue
cnt = current.count(p["original_exact"])
if cnt != 1:
raise PatchError(f"patch#{i}: original_exact 匹配 {cnt} 次(要求唯一)")
current = current.replace(p["original_exact"], p["replacement"], 1)
log.append(f"patch#{i} {p['finding_id']} {p['action']} OK")
if normalize_whitespace:
# U0 契约问题 #4:整行元素删除后合并多余空行;属机械清理,不算内容改动
cleaned = re.sub(r"\n{3,}", "\n\n", current)
if cleaned != current:
log.append("format_normalize: 合并连续空行")
current = cleaned
return current, log
def reapply_from_scratch(text: str, patches: list, findings_by_id: dict) -> str:
"""门禁用:从原文独立重放全部 patch(与增量应用互为校验)。"""
result, _ = apply_patches(text, patches, findings_by_id)
return result

View File

@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""去 AI 味修订管线:授权、诊断绑定、最小 patch、门禁、复扫与成对选择。"""
from .diagnose import validate_artifact, run_deterministic_rules
from .gates import run_hard_gate, run_voice_drift_gate
from .load import active_rules
from .pairwise import validate_choice, PairwiseNotExecuted
from .patch import apply_patches
class DowngradedToAudit(Exception):
"""携带降级原因的受控降级。"""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
class RevisionNotAuthorized(ValueError):
"""没有作者明确授权,修订不得启动。"""
def resolve_mode(task_contract: dict, fact_snapshot) -> str:
"""模式裁决:作者未同意或快照缺失时失败关闭/降级。"""
mode = task_contract.get("mode", "Audit")
if mode == "Patch":
if task_contract.get("author_approved_revision") is not True:
raise RevisionNotAuthorized("Patch 必须有 author_approved_revision=true")
if not task_contract.get("allowed_scope"):
raise RevisionNotAuthorized("Patch 必须声明 allowed_scope")
if task_contract.get("round_no", 1) > 3:
raise RevisionNotAuthorized("修订轮次超过 3,必须回退并转人工")
if fact_snapshot is None and not task_contract.get("no_snapshot_authorization"):
raise DowngradedToAudit(
"事实快照缺失:Patch 自动降为 Audit;如需继续修订,须显式授权 no_snapshot_authorization"
)
return mode
def run_audit(text: str, rules: dict, rule_library_version: str) -> dict:
return run_deterministic_rules(text, active_rules(rules), rule_library_version, "Audit")
def _same_finding_signature(finding: dict) -> set[tuple[str, str]]:
return {(finding["rule_id"], span) for span in finding.get("spans", [])}
def _scope_allows_patch(text: str, finding: dict, patch: dict, allowed_scope) -> bool:
"""把作者授权范围落到 finding;全文授权以外的范围必须可机械解释。"""
if isinstance(allowed_scope, str):
return allowed_scope.strip().lower() in {"全文", "全章", "all", "whole_text", "whole_chapter"}
if isinstance(allowed_scope, (list, tuple, set)):
return finding.get("id") in allowed_scope
if not isinstance(allowed_scope, dict):
return False
finding_ids = allowed_scope.get("finding_ids")
if finding_ids is not None and finding.get("id") not in finding_ids:
return False
if "start" in allowed_scope or "end" in allowed_scope:
start = allowed_scope.get("start")
end = allowed_scope.get("end")
if (isinstance(start, bool) or isinstance(end, bool)
or not isinstance(start, int) or not isinstance(end, int) or start < 0 or end < start):
return False
patch_start = text.find(patch["original_exact"])
patch_end = patch_start + len(patch["original_exact"])
if patch_start < start or patch_end > end:
return False
return finding_ids is not None or "start" in allowed_scope or "end" in allowed_scope
def _validate_artifact_bindings(text: str, artifact: dict, rules: dict, rule_library_version: str) -> None:
"""拒绝伪造/过期发现:每条 finding 必须绑定当前 active 规则;确定性命中还要能回放。"""
active = {rule["id"]: rule for rule in active_rules(rules)}
replay = run_deterministic_rules(text, list(active.values()), rule_library_version, "Audit")
replay_signatures = set()
for finding in replay["findings"]:
replay_signatures |= _same_finding_signature(finding)
for finding in artifact["findings"]:
rule = active.get(finding.get("rule_id"))
if rule is None:
raise ValueError(f"发现 {finding.get('id')} 未绑定当前 active 规则")
if finding.get("rule_version") != rule.get("version") or finding.get("layer") != rule.get("layer"):
raise ValueError(f"发现 {finding.get('id')} 的规则版本或 layer 与当前规则不一致")
if rule["trigger"]["type"] != "model_judgment":
signature = _same_finding_signature(finding)
if not signature or not signature.issubset(replay_signatures):
raise ValueError(f"确定性发现 {finding.get('id')} 无法由当前规则回放")
def _regression_gate(original_artifact: dict, rescan: dict, patches: list, findings_by_id: dict) -> dict:
original_signatures = set()
for finding in original_artifact["findings"]:
original_signatures |= _same_finding_signature(finding)
targeted_signatures = set()
targeted_rule_ids = set()
for patch in patches:
finding = findings_by_id[patch["finding_id"]]
targeted_rule_ids.add(finding["rule_id"])
targeted_signatures |= _same_finding_signature(finding)
residual_targeted = []
new_hits = []
kept_hits = []
for finding in rescan["findings"]:
signature = _same_finding_signature(finding)
if signature & targeted_signatures:
residual_targeted.append(finding)
elif signature & original_signatures:
kept_hits.append(finding)
else:
new_hits.append(finding)
return {
"pass": not residual_targeted and not new_hits,
"residual_targeted": residual_targeted,
"new_hits": new_hits,
"kept_existing_hits": kept_hits,
"residual_hits": residual_targeted + new_hits,
"targeted_rule_ids": sorted(targeted_rule_ids),
"note": "只允许原有 keep/ask 命中保留;被修命中必须消失,不能产生新命中",
}
def run_patch(text: str, rules: dict, artifact: dict, patches: list, task_contract: dict,
fact_snapshot, voice_thin: dict | None, rewrite_model: str,
pairwise_record: dict | None, rule_library_version: str) -> dict:
"""Patch:应用 -> 硬门 -> 声音门 -> 同规则复扫 -> 跨模型选择。"""
mode = resolve_mode(task_contract, fact_snapshot)
if mode != "Patch":
raise DowngradedToAudit("非 Patch 模式不得执行修订")
validate_artifact(artifact)
if artifact["rule_library_version"] != rule_library_version:
raise ValueError("诊断产物使用了过期规则库,必须重新诊断")
if artifact["text_hash"] != run_deterministic_rules(text, [], rule_library_version, "Patch")["text_hash"]:
raise ValueError("artifact.text_hash 与目标文本不一致:诊断对象不是本文本")
_validate_artifact_bindings(text, artifact, rules, rule_library_version)
if voice_thin is not None and voice_thin.get("status", "canonical") != "canonical":
raise ValueError("修订只能消费已确认 canonical 声音账")
findings_by_id = {finding["id"]: finding for finding in artifact["findings"]}
for patch in patches:
finding = findings_by_id.get(patch.get("finding_id"))
if finding is None:
raise ValueError(f"patch {patch.get('finding_id')} 没有对应发现")
if not _scope_allows_patch(text, finding, patch, task_contract.get("allowed_scope")):
raise RevisionNotAuthorized(f"patch {patch.get('finding_id')} 超出作者 allowed_scope")
if finding.get("decision_proposal") != "repair":
raise ValueError(f"发现 {finding['id']} 未经过 repair 仲裁,不能执行 patch")
if not finding.get("arbitration_note"):
raise ValueError(f"发现 {finding['id']} 缺少功能仲裁记录")
candidate, apply_log = apply_patches(text, patches, findings_by_id)
hard_gate = run_hard_gate(
text,
candidate,
patches,
findings_by_id,
entity_list=(fact_snapshot or {}).get("entities"),
voice_thin=voice_thin,
)
voice_gate = run_voice_drift_gate(text, candidate, voice_thin)
rescan = run_deterministic_rules(candidate, active_rules(rules), rule_library_version, "Patch")
regression_gate = _regression_gate(artifact, rescan, patches, findings_by_id)
if pairwise_record is None:
raise PairwiseNotExecuted("Patch 必须有独立选择模型的成对选择记录")
pairwise = validate_choice(pairwise_record, rewrite_model)
return {
"mode": mode,
"candidate_text": candidate,
"apply_log": apply_log,
"hard_gate": hard_gate,
"voice_gate": voice_gate,
"regression_gate": regression_gate,
"pairwise_choice": pairwise,
}
__all__ = [
"DowngradedToAudit", "RevisionNotAuthorized", "resolve_mode", "run_audit", "run_patch",
]

View File

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""审计报告组装与校验(专题-09 §4.5)。
禁止单一总分:输出合同不含、也不允许运行时塞入「真人率 / 人味分」类字段——
检测器分与内部自评分只做辅助诊断(原则 8),总分是它们冒充质量的通道。
"""
from .schemas import validate
_FORBIDDEN_SCORE_KEYS = {
"human_score", "natural_score", "realness", "人味分", "真人率", "detector_score",
}
class ForbiddenScoreError(ValueError):
pass
def _walk_keys(node, path=""):
"""递归检查键名:任何总分字段出现在任何层级都拒绝。"""
if isinstance(node, dict):
for k, v in node.items():
if k in _FORBIDDEN_SCORE_KEYS:
raise ForbiddenScoreError(f"{path}.{k}: 禁止单一总分类字段")
_walk_keys(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
_walk_keys(v, f"{path}[{i}]")
def assemble(artifact: dict, patches: list, candidate_text, hard_gate: dict,
voice_gate: dict, regression_gate: dict, pairwise_choice, unresolved_risks: list) -> dict:
"""按 §4.5 组装审计报告。pairwise_choice 在 Audit 模式传 None。"""
findings = artifact["findings"]
kept = [
{"finding_id": f["id"], "reason": f.get("arbitration_note", "")}
for f in findings if f["decision_proposal"] == "keep"
]
# kept_by_design 必须带理由:无理由的「保留」是不可审计的黑箱
for k in kept:
if not k["reason"]:
raise ForbiddenScoreError(f"kept_by_design {k['finding_id']} 缺理由")
report = {
"summary": {
"high_confidence_findings": [f["id"] for f in findings if f["confidence"] == "high"],
"advisory_findings": [f["id"] for f in findings if f["confidence"] in ("low", "candidate")],
"kept_by_design": kept,
"needs_author_decision": [
{"finding_id": f["id"], "question": f.get("arbitration_note", "需要作者决定")}
for f in findings if f["decision_proposal"] == "ask"
],
},
"patches": patches,
"final": {
"hard_gate_result": hard_gate,
"voice_gate_result": voice_gate,
"regression_gate_result": regression_gate,
"pairwise_choice": pairwise_choice,
"unresolved_risks": unresolved_risks,
},
}
if candidate_text is not None:
report["final"]["candidate_text"] = candidate_text
_walk_keys(report)
validate(report, "audit_report")
return report

View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""JSON Schema 子集校验器。
为什么不直接用 jsonschema 库:内网 PyPI 不可达,依赖面必须控制在标准库 + PyYAML。
本校验器只实现合同用到的子集:type / required / properties / items / enum /
minItems / minLength。合同以 contracts/*.schema.json 为唯一事实源,
代码不另维护一套字段清单,避免双份合同漂移(专题-09 反例:humanize-text 文档实现漂移)。
"""
import json
from pathlib import Path
CONTRACTS_DIR = Path(__file__).resolve().parent.parent.parent / "contracts"
_TYPE_CHECKS = {
"string": lambda v: isinstance(v, str),
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
"boolean": lambda v: isinstance(v, bool),
"array": lambda v: isinstance(v, list),
"object": lambda v: isinstance(v, dict),
"null": lambda v: v is None,
}
class SchemaError(ValueError):
"""合同违例。路径信息必须保留——审计要能定位到具体字段。"""
def __init__(self, path, message):
super().__init__(f"{path}: {message}")
self.path = path
def load_schema(name: str) -> dict:
return json.loads((CONTRACTS_DIR / f"{name}.schema.json").read_text(encoding="utf-8"))
def _validate(instance, schema, path):
expected = schema.get("type")
if expected:
# type 允许是列表(如 ["object", "null"])
types = expected if isinstance(expected, list) else [expected]
if not any(_TYPE_CHECKS[t](instance) for t in types):
raise SchemaError(path, f"类型应为 {types},实际 {type(instance).__name__}")
if "enum" in schema and instance not in schema["enum"]:
raise SchemaError(path, f"取值 {instance!r} 不在允许集合 {schema['enum']}")
if isinstance(instance, str):
if schema.get("minLength") is not None and len(instance) < schema["minLength"]:
raise SchemaError(path, f"长度不足 {schema['minLength']}")
if isinstance(instance, list):
if schema.get("minItems") is not None and len(instance) < schema["minItems"]:
raise SchemaError(path, f"元素少于 {schema['minItems']}")
item_schema = schema.get("items")
if item_schema:
for i, item in enumerate(instance):
_validate(item, item_schema, f"{path}[{i}]")
if isinstance(instance, dict):
for key in schema.get("required", []):
if key not in instance:
raise SchemaError(path, f"缺少必填字段 {key}")
for key, sub in schema.get("properties", {}).items():
if key in instance:
_validate(instance[key], sub, f"{path}.{key}" if path else key)
def validate(instance, schema_name: str):
"""按合同名校验实例;违例抛 SchemaError。"""
_validate(instance, load_schema(schema_name), schema_name)

View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""测试包初始化:把 src/ 加入导入路径(不依赖 pip install,内网环境直接可跑)。"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

View File

@ -0,0 +1,420 @@
# -*- coding: utf-8 -*-
"""合同负路测试 + U0 端到端回放。
覆盖专题-09 §12 一级验收中可自动化的条目:
- 验收 4:诊断产物头缺项时修订拒绝执行
- 验收 6:事实快照缺失时 Patch 自动降为 Audit(无显式授权不得继续)
- 验收 7:成对选择模型与改写模型相同时视为阶段未执行
其余为合同负路(无发现禁改、唯一匹配、样例不齐拒绝激活、结构校验、事实增量、口癖保护)。
"""
import sys
import unittest
from pathlib import Path
# 内网环境不 pip install:直接把 src/ 加入导入路径
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from deai import cards, diagnose, gates, load, pairwise, patch, report
from deai.pipeline import DowngradedToAudit, resolve_mode, run_audit, run_patch
class TestAssetCompleteness(unittest.TestCase):
"""规则库/样例库装载:active 规则必须配齐四类样例(专题-09 §4.1)。"""
def test_all_shipped_rules_load_with_complete_samples(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
actives = load.active_rules(rules)
# 一级目标:至少 10 条 active 规则,每条四类样例齐全(装载器已强制)
self.assertGreaterEqual(len(actives), 10)
for rule in actives:
for stype in load.SAMPLE_TYPES:
self.assertTrue(rule["samples"][stype], f"{rule['id']} 缺 {stype}")
for sample_id in rule["samples"][stype]:
self.assertIn(rule["id"], samples[sample_id].get("rules", []))
def test_rule_without_four_sample_types_rejected(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
broken = dict(rules["l002"])
broken["samples"] = dict(broken["samples"])
broken["samples"]["regression"] = [] # 缺回归陷阱样例
with self.assertRaises(load.LoadError):
load.check_activation(broken, samples)
def test_rule_referencing_missing_sample_rejected(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
broken = dict(rules["l002"])
broken["samples"] = dict(broken["samples"])
broken["samples"]["regression"] = ["不存在的样例id"]
with self.assertRaises(load.LoadError):
load.check_activation(broken, samples)
class TestCaseCards(unittest.TestCase):
"""反向回填/创作反馈的案例卡:快采集,慢确认,禁止越过资产门。"""
def _capture(self, license_name="owned", label="sf", mode="backfill"):
return cards.capture_case_card(
card_id=f"card-{license_name}-{label}-{mode}",
label=label,
layer="lexical",
carrier="narration",
source_kind="existing_work" if mode == "backfill" else "creation_feedback",
source_license=license_name,
source_text="来源全文:值得注意的是,门外下雨了。",
excerpt="值得注意的是,门外下雨了。",
context="她抬头。值得注意的是,门外下雨了。",
location="chapter-1:paragraph-2",
pattern="无功能元话语",
rationale="待复核的表面模式观察",
function_check=["是否承担转折"],
risk_if_changed="可能误删节奏或信息",
capture_mode=mode,
)
def test_shipped_case_card_fixture_uses_current_schema(self):
loaded = cards.load_case_cards()
self.assertIn("card-backfill-l002-001", loaded)
self.assertEqual(loaded["card-backfill-l002-001"]["schema_version"], "ai-flavor-case-v1")
def test_backfill_starts_as_shadow_and_confirmation_is_explicit(self):
shadow = self._capture()
self.assertEqual(shadow["state"], "shadow")
with self.assertRaises(cards.CardError):
cards.promote_card_to_sample(shadow)
canonical = cards.confirm_case_card(
shadow, label="sf", review_note="作者确认:此处无功能"
)
sample = cards.promote_card_to_sample(canonical)
self.assertEqual(sample["case_card_id"], canonical["id"])
self.assertEqual(sample["type"], "sf")
def test_unlicensed_source_is_hash_only_and_cannot_be_confirmed(self):
card = self._capture(license_name="unauthorized")
self.assertEqual(card["excerpt"], "")
self.assertEqual(card["context"], "")
self.assertIn("excerpt_hash", card["source"])
with self.assertRaises(cards.CardError):
cards.confirm_case_card(card, label="sf", review_note="不应确认")
def test_hash_only_card_rejects_text_injection(self):
card = self._capture(license_name="research_only")
broken = dict(card)
broken["excerpt"] = "偷偷保存的第三方原文"
with self.assertRaises(cards.CardError):
cards.validate_card(broken)
def test_rule_candidate_is_always_candidate_and_needs_opposing_evidence(self):
sf = self._capture(label="sf")
snf = self._capture(label="snf")
candidate = cards.propose_rule_from_cards(
[sf, snf],
rule_id="candidate-l999",
name="待评估元话语",
layer="lexical",
carrier_scope="narration",
trigger={"type": "model_judgment", "criteria": "上下文功能判断"},
fix_hint="先仲裁再决定",
function_check=["是否承担叙事功能"],
)
self.assertEqual(candidate["status"], "candidate")
self.assertEqual(candidate["default_disposition"], "candidate")
# 候选规则可以先引用 shadow 卡,供后续人工确认;不得因此变 active。
load.check_case_card_refs(candidate, {sf["id"]: sf, snf["id"]: snf})
active = dict(candidate)
active["status"] = "active"
with self.assertRaises(load.LoadError):
load.check_case_card_refs(active, {sf["id"]: sf, snf["id"]: snf})
with self.assertRaises(cards.CardError):
cards.propose_rule_from_cards(
[sf],
rule_id="candidate-l998",
name="单样本禁令",
layer="lexical",
carrier_scope="narration",
trigger={"type": "model_judgment", "criteria": "读感"},
fix_hint="删除",
function_check=["是否有功能"],
)
def test_live_feedback_requires_feedback_source(self):
card = self._capture(mode="live_feedback")
self.assertEqual(card["source"]["kind"], "creation_feedback")
broken = dict(card)
broken["source"] = dict(card["source"])
broken["source"]["kind"] = "existing_work"
with self.assertRaises(cards.CardError):
cards.validate_card(broken)
class TestNegativePaths(unittest.TestCase):
"""一级验收 4/6/7 的负路(必须可测、必须红)。"""
def test_artifact_header_missing_blocks_patch(self):
# 验收 4:产物头缺项 → 视为诊断未发生
artifact = {"text_hash": "sha256:x", "findings": []} # 缺 mode/规则库版本
with self.assertRaises(diagnose.ArtifactIncomplete):
diagnose.validate_artifact(artifact)
def test_snapshot_missing_downgrades_to_audit(self):
# 验收 6:快照缺失且无授权 → Patch 强制降为 Audit
with self.assertRaises(DowngradedToAudit):
resolve_mode({"mode": "Patch", "author_approved_revision": True, "allowed_scope": "全文"}, fact_snapshot=None)
def test_snapshot_missing_with_explicit_authorization_proceeds(self):
mode = resolve_mode(
{"mode": "Patch", "author_approved_revision": True, "allowed_scope": "全文", "no_snapshot_authorization": True}, fact_snapshot=None)
self.assertEqual(mode, "Patch")
def test_same_model_pairwise_treated_as_not_executed(self):
# 验收 7:选择模型 == 改写模型 → 阶段 10 视为未执行
record = {"choice": "candidate", "rationale": "理由", "selection_model": "claude"}
with self.assertRaises(pairwise.PairwiseNotExecuted):
pairwise.validate_choice(record, rewrite_model="claude")
def test_pairwise_without_rationale_rejected(self):
record = {"choice": "candidate", "rationale": "", "selection_model": "gpt-5.6-sol"}
with self.assertRaises(pairwise.PairwiseNotExecuted):
pairwise.validate_choice(record, rewrite_model="claude")
class TestPatchContracts(unittest.TestCase):
def _finding(self, span):
return {
"id": "f1", "text_hash": "sha256:0000000000000000",
"rule_id": "l002", "rule_version": 1, "spans": [span],
"context_window": span, "layer": "lexical", "evidence": "测试",
"possible_function": "none", "confidence": "high",
"decision_proposal": "repair",
}
def _patch(self, finding_id="f1", original="值得注意的是,", replacement="", action="delete"):
return {"finding_id": finding_id, "action": action,
"original_exact": original, "replacement": replacement,
"rationale": "测试删除", "protected_invariants": []}
def test_patch_without_finding_rejected(self):
with self.assertRaises(patch.PatchError):
patch.apply_patches("值得注意的是,他来了。", [self._patch()], {})
def test_patch_nonunique_match_rejected(self):
text = "值得注意的是,值得注意的是,他来了。"
findings = {"f1": self._finding("值得注意的是")}
with self.assertRaises(patch.PatchError):
patch.apply_patches(text, [self._patch()], findings)
def test_patch_not_covering_span_rejected(self):
# original_exact 与发现 span 无关 → 禁止(防止借诊断之名改别处)
findings = {"f1": self._finding("他来了")}
with self.assertRaises(patch.PatchError):
patch.apply_patches("值得注意的是,他来了。", [self._patch()], findings)
def test_skip_patch_cannot_smuggle_replacement(self):
finding = self._finding("值得注意的是")
malicious = self._patch(action="skip", replacement="他突然笑了")
with self.assertRaises(patch.PatchError):
patch.apply_patches("值得注意的是,他来了。", [malicious], {"f1": finding})
def test_patch_does_not_normalize_unrelated_blank_lines(self):
finding = self._finding("值得注意的是")
text = "前文。\n\n\n值得注意的是,他来了。"
candidate, _ = patch.apply_patches(
text, [self._patch(original="值得注意的是,", replacement="")], {"f1": finding}
)
self.assertTrue(candidate.startswith("前文。\n\n\n"))
def test_adjacent_punctuation_extension_allowed(self):
# U0 修正 #6:span 允许紧邻标点的最小扩展(避免删除后标点粘连)
text = "他打量她,嘴角微微上扬。「姑娘」"
finding = self._finding("嘴角微微上扬")
p = self._patch(original=",嘴角微微上扬", replacement="")
candidate, _ = patch.apply_patches(text, [p], {"f1": finding})
self.assertEqual(candidate, "他打量她。「姑娘」")
class TestHardGate(unittest.TestCase):
def test_structural_verify_detects_outside_change(self):
original = "她关上门。值得注意的是,天黑了。"
patches = [{"finding_id": "f1", "action": "delete",
"original_exact": "值得注意的是,", "replacement": "",
"rationale": "测试", "protected_invariants": []}]
findings = {"f1": {"id": "f1", "spans": ["值得注意的是,"]}}
# 候选稿在 patch 之外还被偷偷改了一处 → 结构校验必须红
tampered = "她关上门。天黑了。另外多出一句。"
result = gates.structural_verify(original, tampered, patches, findings)
self.assertFalse(result["pass"])
def test_fact_delta_blocks_new_number(self):
patches = [{"finding_id": "f1", "action": "local_rewrite",
"original_exact": "值三百两银子", "replacement": "值三百五十两银子",
"rationale": "测试", "protected_invariants": []}]
result = gates.fact_delta(patches)
self.assertFalse(result["pass"])
def test_number_attribution_allows_approved_deletion(self):
# 数字随批准删除消失是合法的(U0 修正 #1:不做计数守恒)
original = "值三百两银子。值得一提的是,三天后开门。"
candidate = "值三百两银子。开门。"
patches = [{"finding_id": "f1", "action": "delete",
"original_exact": "值得一提的是,三天后", "replacement": "",
"rationale": "测试", "protected_invariants": []}]
result = gates.number_attribution(original, candidate, patches)
self.assertTrue(result["pass"], result["failures"])
def test_number_attribution_blocks_unexplained_disappearance(self):
original = "值三百两银子。"
candidate = "值银子。" # 数字凭空消失,无对应 patch
result = gates.number_attribution(original, candidate, [])
self.assertFalse(result["pass"])
def test_quote_tamper_fails(self):
# 引文/场内文本被篡改(未在任何批准删除片段内)→ 必须红
result = gates.quote_attribution(
"面板显示:【等级:待定】。", "面板显示:【等级:最高】。", [])
self.assertFalse(result["pass"])
def test_quote_duplicate_loss_cannot_hide_behind_one_deleted_quote(self):
patches = [{"finding_id": "f1", "action": "delete",
"original_exact": "前文【同一条】", "replacement": "",
"rationale": "测试", "protected_invariants": []}]
result = gates.quote_attribution(
"前文【同一条】。后文【同一条】。",
"前文。后文。",
patches,
)
self.assertFalse(result["pass"])
def test_quote_approved_deletion_passes(self):
# 引文整段位于批准删除片段内 → 合法消失
patches = [{"finding_id": "f1", "action": "delete",
"original_exact": "面板显示:【等级:待定】。", "replacement": "",
"rationale": "测试", "protected_invariants": []}]
result = gates.quote_attribution(
"前文。面板显示:【等级:待定】。后文。", "前文。后文。", patches)
self.assertTrue(result["pass"], result["failures"])
def test_protected_tic_deletion_fails(self):
voice_thin = {"untouchable_verbal_tics": {"老周": ["我说小子"]}}
result = gates.protected_checks(
"「我说小子,你来了。」", "「你来了。」", voice_thin)
self.assertFalse(result["pass"])
class TestU0Replay(unittest.TestCase):
"""端到端回放 U0 演练:真实规则库 + 真实目标文本 + 8 条 patch + 跨模型选择记录。"""
TEXT = """林晚儿站在万宝阁的柜台前,手指无意识地摩挲着那枚铜钱的边缘。
**那伙计上下打量了她一眼,**嘴角微微上扬。「姑娘,这东西值三百两银子,不是您一枚铜钱就能当的。」
值得注意的是,万宝阁在青云城立了三百年,从没人敢在这里讨价还价。研究表明,能进这种地方的修士,多半背景不凡。伙计眼中闪过一丝精光,显然已经把林晚儿的来历掂量了一遍。
「三百年?我说小子,你这铺子也就立了三百年,可我手里这枚铜钱——」老周把铜钱往柜台上一推,声音忽然压低,「它传了三千年。」
[占位:此处揭示铜钱来历]
万宝阁的大堂里安静了一瞬。系统面板的流光在半空停住,鉴定结果的金字明灭不定:
【物品:无法识别|等级:待定|建议操作:上报上峰】
无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。三天后,这件事被摆进了执法堂的晨会。那时谁也不知道,这枚铜钱会掀翻整座青云城。也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。"""
PATCH_SPANS = [
# (original_exact, replacement, 对应发现 span 的识别片段)
("**那伙计上下打量了她一眼,**", "那伙计上下打量了她一眼,", "**那伙计"),
(",嘴角微微上扬", "", "嘴角微微上扬"),
("眼中闪过一丝精光,", "", "眼中闪过一丝精光"),
("研究表明,", "", "研究表明"),
("值得注意的是,", "", "值得注意的是"),
("无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。", "", "无论是伙计的讥笑"),
("也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。", "", "也许这就是命运吧"),
("[占位:此处揭示铜钱来历]", "", "[占位:此处揭示铜钱来历]"),
]
def _build_artifact(self, rules):
artifact = run_audit(self.TEXT, rules, load.rule_library_version(rules))
artifact["mode"] = "Patch"
# 语义层发现由外部判定注入(U0 阶段 4 人工判定的代码化形态)
sem_finding = {
"id": "f-sem", "text_hash": artifact["text_hash"],
"rule_id": "sem001", "rule_version": 1,
"spans": ["也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。"],
"context_window": "那时谁也不知道…悄悄安排好了一切。",
"layer": "semantic",
"evidence": "段尾脱离场景的命运评注,钩子由前句承担",
"possible_function": "none", "confidence": "high",
"decision_proposal": "repair",
"arbitration_note": "五问皆否;前句为伏笔钩子,升华句稀释钩子",
}
diagnose.merge_model_findings(artifact, [sem_finding])
# 模拟仲裁完成:全部 repair(U0 阶段 4 结论)
for f in artifact["findings"]:
f["decision_proposal"] = "repair"
f["possible_function"] = f.get("possible_function", "none")
f.setdefault("arbitration_note", "演练回放:五问仲裁通过")
return artifact
def test_full_pipeline_replay(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
artifact = self._build_artifact(rules)
diagnose.validate_artifact(artifact)
# patch 的 finding_id 按 span 覆盖关系解析(回放时不硬编码顺序)
patches = []
for original, replacement, marker in self.PATCH_SPANS:
fid = next(f["id"] for f in artifact["findings"] if marker in f["spans"][0]
or f["spans"][0] in original)
patches.append({"finding_id": fid, "action": "delete" if not replacement else "patch",
"original_exact": original, "replacement": replacement,
"rationale": "U0 回放", "protected_invariants": []})
result = run_patch(
self.TEXT, rules, artifact, patches,
task_contract={"mode": "Patch", "allowed_scope": "全文", "author_approved_revision": True},
fact_snapshot={"entities": ["林晚儿", "老周", "伙计", "万宝阁", "青云城", "执法堂"]},
voice_thin={"untouchable_verbal_tics": {"老周": ["我说小子"]},
"protected_spans": ["【物品:无法识别|等级:待定|建议操作:上报上峰】"]},
rewrite_model="claude",
pairwise_record={"choice": "candidate",
"rationale": "保真完整,钩子有力(U0 gpt-5.6-sol 判定)",
"selection_model": "gpt-5.6-sol"},
rule_library_version=load.rule_library_version(rules))
# 硬门全绿(结构/事实增量/数字归因/保护项)
self.assertTrue(result["hard_gate"]["pass"], result["hard_gate"])
# 复扫零残留
self.assertTrue(result["regression_gate"]["pass"],
result["regression_gate"]["residual_hits"])
# 跨模型选择成立
self.assertEqual(result["pairwise_choice"]["choice"], "candidate")
# 候选稿不再含任何确定性命中点
for marker in ("**", "值得注意的是", "研究表明", "嘴角微微上扬", "[占位", "也许这就是命运"):
self.assertNotIn(marker, result["candidate_text"])
# 保护项仍在
self.assertIn("我说小子", result["candidate_text"])
self.assertIn("【物品:无法识别|等级:待定|建议操作:上报上峰】", result["candidate_text"])
# 审计报告可组装且过合同(含禁止总分检查)
rep = report.assemble(
artifact, patches, result["candidate_text"], result["hard_gate"],
result["voice_gate"], result["regression_gate"], result["pairwise_choice"],
unresolved_risks=result["hard_gate"]["unverified"])
self.assertIn("pairwise_choice", rep["final"])
def test_report_rejects_score_fields(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
artifact = run_audit(self.TEXT, rules, load.rule_library_version(rules))
with self.assertRaises(report.ForbiddenScoreError):
report.assemble(
artifact, [], None,
{"pass": True, "checks": {}, "unverified": [], "human_score": 0.9},
{"status": "unverified", "pass": None, "note": ""},
{"pass": True, "residual_hits": []}, None, [])
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""20 项研究机制覆盖矩阵的机械卫生门。"""
from __future__ import annotations
import pathlib
import unittest
import yaml
ROOT = pathlib.Path(__file__).resolve().parents[1]
MATRIX = ROOT / "research/20-project-skill-coverage.yaml"
class FrameworkCoverageTest(unittest.TestCase):
def test_matrix_has_five_skill_owners_and_real_implementation_paths(self):
data = yaml.safe_load(MATRIX.read_text(encoding="utf-8"))
self.assertEqual(data["schema_version"], "humanization-research-skill-coverage-v1")
capabilities = data["capabilities"]
self.assertGreaterEqual(len(capabilities), 12)
capability_ids = {item["id"] for item in capabilities}
projects = data["projects"]
self.assertEqual(len(projects), 20)
self.assertEqual(len({item["id"] for item in projects}), 20)
for project in projects:
self.assertTrue(project["mapped_capabilities"], project["id"])
self.assertTrue(set(project["mapped_capabilities"]) <= capability_ids, project["id"])
owners = {item["owner_skill"] for item in capabilities}
self.assertEqual(
owners,
{
"establish-voice-baseline",
"prevent-ai-flavor",
"diagnose-ai-flavor",
"revise-ai-flavor",
"capture-ai-flavor-cases",
},
)
for item in capabilities:
implementation_ref = item["implementation"]
implementation = ROOT.parent / implementation_ref if implementation_ref.startswith((".", "humanization/")) else ROOT / implementation_ref
self.assertTrue(implementation.exists(), f"{item['id']}: {implementation}")
self.assertIn(item["status"], {"implemented", "partial", "pending"})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""人感 v2 共享骨架与技能 5 生命周期的离线负路。"""
from __future__ import annotations
import copy
import importlib.util
import json
import pathlib
import sys
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from deai import evaluation, gates, load # noqa: E402
from deai.baseline import draft_ledger, profile_text, run_voice_gate # noqa: E402
from deai.carriers import carrier_at, carrier_ranges # noqa: E402
from deai.diagnose import run_deterministic_rules # noqa: E402
def load_script(name: str, path: pathlib.Path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
MINE = load_script(
"mine_ai_flavor_v2",
ROOT.parent / ".claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py",
)
CAPTURE = load_script(
"capture_cases_v2",
ROOT.parent / ".claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py",
)
class HumanizationV2Test(unittest.TestCase):
def test_baseline_draft_exposes_sampling_and_unknowns(self):
text = "".join(f"第{i}句,门外的雨还在落下,青石路上没有一个人。\n" for i in range(1, 65))
ledger = draft_ledger("synthetic:demo", [{"source_ref": "demo.txt", "text": text}])
self.assertEqual(ledger["status"], "candidate")
self.assertTrue(ledger["sampling"]["sample_sufficient"])
self.assertIn("characters", ledger["unknown_fields"])
self.assertTrue(ledger["narrator"]["metrics"]["sentence_length"]["median"] > 0)
def test_baseline_rejects_source_hash_drift(self):
with self.assertRaisesRegex(ValueError, "source_sha256"):
draft_ledger(
"synthetic:demo",
[{"source_ref": "demo.txt", "source_sha256": "0" * 64, "text": "甲走进门。"}],
)
def test_baseline_profile_is_deterministic(self):
text = "甲走进门。乙关上窗。雨落下来。"
self.assertEqual(profile_text(text), profile_text(text))
def test_baseline_duplicate_voice_ownership_is_rejected_by_skill_validator(self):
establish = load_script(
"establish_v2",
ROOT.parent / ".claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py",
)
ledger = {
"schema_version": "voice-baseline-v1",
"work_ref": "synthetic:demo",
"narrator": {"sentence_habits": [], "punctuation_habits": []},
"characters": {
"甲": {"verbal_tics": ["嗯"], "sample_lines": ["嗯"]},
"乙": {"verbal_tics": ["嗯"], "sample_lines": ["嗯"]},
},
"untouchable_verbal_tics": {"甲": ["嗯"], "乙": ["嗯"]},
"protected_spans": [], "blacklist": [],
}
with self.assertRaisesRegex(establish.BaselineContractError, "同时归属"):
establish.validate_ledger(ledger, work_ref="synthetic:demo")
def test_carrier_scope_masks_dialogue_and_marks_carve_out(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
text = "「值得注意的是,这里是对白。」值得注意的是,这里是叙述。"
artifact = run_deterministic_rules(text, [rules["l002"]], "v-test", "Audit")
self.assertEqual(len(artifact["findings"]), 1)
self.assertEqual(artifact["findings"][0]["carrier"], "narration")
self.assertEqual(artifact["findings"][0]["decision_proposal"], "ask")
self.assertEqual(carrier_at(text, 1, 7, carrier_ranges(text)), "dialogue")
def test_deterministic_rules_have_contract_replay(self):
"""确定性触发器规则(regex/handler/density)都要能回放四类夹具。
blocking 类机械规则的默认建议就是 repair,属于其合同本身;
safe_default 约束只对非 blocking 规则断言。
"""
samples = load.load_samples()
rules = load.load_rules(samples=samples)
deterministic = [rule for rule in rules.values() if rule["trigger"]["type"] != "model_judgment"]
self.assertGreaterEqual(len(deterministic), 2)
for rule in deterministic:
if rule["default_disposition"] == "blocking":
continue
report = evaluation.evaluate_rule_contract(rule, samples)
self.assertTrue(report["contract_pass"], report)
self.assertEqual(report["dataset_kind"], "shipped_contract_samples")
def test_modality_and_time_fact_gates_block_known_regressions(self):
modality = gates.modality_attribution([{
"original_exact": "多半背景不凡", "replacement": "背景都不凡",
}])
temporal = gates.temporal_fact_delta([{
"original_exact": "谁也不知道", "replacement": "那年秋分的夜里,谁也不知道",
}])
numeric_temporal = gates.temporal_fact_delta([{
"original_exact": "谁也不知道", "replacement": "2026年3天后的夜里,谁也不知道",
}])
self.assertFalse(modality["pass"])
self.assertFalse(temporal["pass"])
self.assertFalse(numeric_temporal["pass"])
def test_voice_gate_does_not_turn_missing_or_candidate_baseline_into_pass(self):
result = run_voice_gate("甲走进门。乙关上窗。", "甲走进门。乙关上窗。", None)
candidate = run_voice_gate(
"甲走进门。乙关上窗。", "甲走进门。乙关上窗。", {"status": "candidate"}
)
self.assertIsNone(result["pass"])
self.assertEqual(result["status"], "unverified")
self.assertIsNone(candidate["pass"])
self.assertEqual(candidate["status"], "unverified")
def test_rule_activation_requires_holdout_and_approver(self):
samples = load.load_samples()
rules = load.load_rules(samples=samples)
# s002 已 active;激活门行为用它的 candidate 副本演练,不依赖库里必须留有候选。
candidate = {**rules["s002"], "status": "candidate"}
version = candidate["version"]
contract = evaluation.evaluate_rule_contract(candidate, samples)
with self.assertRaises(evaluation.EvaluationError):
evaluation.activate_rule(candidate, contract_report=contract, holdout_report=None, approver="qingse")
with self.assertRaises(evaluation.EvaluationError):
evaluation.activate_rule(
candidate,
contract_report={**contract, "rule_id": "s002", "rule_version": version},
holdout_report={"rule_id": "s002", "eligible": True},
approver="qingse",
)
holdout = evaluation.evaluate_holdout(candidate, {
"dataset_id": "holdout-s002-v1",
"sf_total": 5,
"sf_hit": 5,
"snf_total": 5,
"snf_false_repair": 0,
"boundary_total": 2,
"boundary_false_repair": 0,
"regression_total": 2,
"regression_safe": 2,
})
full_holdout = {
**contract,
"rule_id": "s002", "rule_version": version,
"eligible": True, "holdout": holdout,
}
with self.assertRaises(evaluation.EvaluationError):
evaluation.activate_rule(
candidate, contract_report={**contract, "rule_id": "s002", "rule_version": version},
holdout_report={**full_holdout, "rule_version": version + 1}, approver="qingse"
)
active = evaluation.activate_rule(
candidate, contract_report={**contract, "rule_id": "s002", "rule_version": version},
holdout_report=full_holdout, approver="qingse"
)
self.assertEqual(active["status"], "active")
self.assertEqual(active["activation"]["approved_by"], "qingse")
def test_mining_extra_sample_requires_canonical_verified_projection(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
source = root / "source.txt"
source.write_text("值得注意的是,门外下起了雨。", encoding="utf-8")
shadow = CAPTURE.capture_file(source, work_ref="synthetic:work", source_license="owned")[0]
annotated = CAPTURE.annotate_card(shadow, label="sf", carrier="narration")
verification = {"cards": [CAPTURE.revalidate_card(annotated, source_path=source)]}
canonical = CAPTURE.confirm_card(
annotated, reviewer="human", note="功能已确认", verification=verification
)
sample = CAPTURE.project_sample(canonical, verification=verification)
sample_path = root / "samples.json"
sample_path.write_text(json.dumps({"samples": [sample]}, ensure_ascii=False), encoding="utf-8")
with self.assertRaises(MINE.MiningError):
MINE._load_extra_samples([sample_path])
loaded = MINE._load_extra_samples(
[sample_path], cards={canonical["id"]: canonical}, verification=verification
)
self.assertEqual(loaded[sample["id"]]["case_card_id"], canonical["id"])
tampered = dict(sample, text="篡改后的样例")
sample_path.write_text(json.dumps({"samples": [tampered]}, ensure_ascii=False), encoding="utf-8")
with self.assertRaises(MINE.MiningError):
MINE._load_extra_samples(
[sample_path], cards={canonical["id"]: canonical}, verification=verification
)
def test_mining_evaluate_cli_is_offline_by_explicit_flag(self):
rule_path = ROOT / "rules/structural/s002.yaml"
with tempfile.TemporaryDirectory() as tmp:
output = pathlib.Path(tmp) / "evaluation.json"
code = MINE.main([
"evaluate-rule", "--rule", str(rule_path), "--output", str(output), "--offline",
])
self.assertEqual(code, 0)
report = json.loads(output.read_text(encoding="utf-8"))
self.assertTrue(report["contract_pass"])
self.assertFalse(report["eligible"])
self.assertEqual(report["persistence"]["status"], "offline")
if __name__ == "__main__":
unittest.main()

View File

@ -28,6 +28,19 @@ agent 的提示词按**变化轴**拆三段,不做"一个 agent 一个大 prom
| extraction 章后抽取 | `extract-chapter-knowledge` | 分析→extractor | extraction | 采纳后触发→槽位→草稿/冲突队列→decide-candidate | 已建;C5 首验 | | extraction 章后抽取 | `extract-chapter-knowledge` | 分析→extractor | extraction | 采纳后触发→槽位→草稿/冲突队列→decide-candidate | 已建;C5 首验 |
| validation / consistency_check 检测 | `check-content-consistency` | 检测→detector | detection | assemble-context(detection 视图)→槽位→报告落评审/ | 已建 | | validation / consistency_check 检测 | `check-content-consistency` | 检测→detector | detection | assemble-context(detection 视图)→槽位→报告落评审/ | 已建 |
| quality_gate 评分 | `score-content-quality` | 保护节点→judge | 基线包=writer 视图 | 基线包→judge→optimize-content-quality 环(有限重写) | 已建 | | quality_gate 评分 | `score-content-quality` | 保护节点→judge | 基线包=writer 视图 | 基线包→judge→optimize-content-quality 环(有限重写) | 已建 |
| voice_baseline 定基线 | `establish-voice-baseline` | 规划→planner | planning | Canonical 正文→统计候选账→语义补充→grounding→人工确认→版本化落库 | 已建;v2先行验证,角色策略仍需作者/模型补充 |
| deai_prevention 前置预防 | `prevent-ai-flavor` | assemble-context 消费 | generation | current 声音账+active规则四类样例→结构化合同→WriterContext冻结 | 已建;v2已接 assemble-context |
| deai_diagnose 诊断 | `diagnose-ai-flavor` | 检测→detector | detection | 五层/载体scope检测→发现清单→检测完成即落库 | 已建;语义层仍需外部判定 |
| deai_revise 修订 | `revise-ai-flavor` | 写作→writer+保护节点 | generation | 诊断+作者授权+仲裁→最小 patch→事实/声音门→复扫→成对选择 | 已建;跨轮编排由上层承接 |
| ai_flavor_mining 挖掘 | `capture-ai-flavor-cases` | 检测→detector | detection | 卡落库→标注→verified确认→样例投影→规则评测→人工激活/降级 | 已建;holdout/调度仍需积累 |
## 去 AI 味五技能接力铁律(专题-09 §6.1)
- 主链顺序不可跳级:**生成 → 诊断(deai_diagnose)→ 最小修订(deai_revise)→ 门禁 → 人确认**。没诊断不能改;没人确认不能转正。
- 定基线(voice_baseline)是垫底资产:作品建立/新角色登场时跑,平时不动;它是修订门禁与前置预防的对照物。
- 前置预防(deai_prevention)只降低命中率,不承诺零 AI 味;漏网命中由诊断兜底。
- 挖掘(ai_flavor_mining)不碰本次正文,只在背后进化规则库;规则候选必须过双来源+正反证据+装载门+评测才可能 active。
- 五技能共享的资产层在 [`humanization/`](../../humanization/):初始拷贝自父仓 muse-deai,验证期由本仓自治演进,升华进 Muse 时才同步回父仓。
## 公约 ## 公约