diff --git a/.claude/skills/assemble-context/SKILL.md b/.claude/skills/assemble-context/SKILL.md index 8692eb5..04ce5aa 100644 --- a/.claude/skills/assemble-context/SKILL.md +++ b/.claude/skills/assemble-context/SKILL.md @@ -12,6 +12,7 @@ disable-model-invocation: true - `workId`、`targetChapter`、`asOf`、scenario、purpose。 - 已确认细纲、叙事状态、来源授权快照、卡索引版本、原文索引版本和上下文预算。 +- 可选 `humanization_contract`:由 `prevent-ai-flavor` 生成的当前作品前置预防合同;生产上下文传入时会把有限 `writer_constraints` 冻结进 `styleConstraints`,并把规则/声音账指纹留在完整上下文的 `humanizationProvenance`,不会投影给 writer。 - `asOf` 必须早于目标章;所有历史来源必须能证明 `chapter <= asOf`。 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 路径、授权密钥、真实实验臂或未来信息塞给模型。 - 来源、字段授权、冻结点、hash 或预算任何一项无法验证时失败关闭,不靠重试或人工说明绕过。 diff --git a/.claude/skills/assemble-context/scripts/assemble_writer_context.py b/.claude/skills/assemble-context/scripts/assemble_writer_context.py index 4a6d0c3..ba3f3b8 100644 --- a/.claude/skills/assemble-context/scripts/assemble_writer_context.py +++ b/.claude/skills/assemble-context/scripts/assemble_writer_context.py @@ -10,8 +10,15 @@ from __future__ import annotations import copy import hashlib import json +import sys +from pathlib import Path 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 ( ContractError, MANIFEST_VERSION, @@ -505,6 +512,7 @@ def assemble_context( token_budget: Mapping[str, int], pattern_references: Sequence[Mapping[str, Any]] = (), style_constraints: Sequence[str] = (), + humanization_contract: Mapping[str, Any] | None = None, generated_at: str, evidence_strategy: str = "production_dual_evidence", ) -> dict[str, Any]: @@ -512,6 +520,34 @@ def assemble_context( if retrieval_plan.get("runId") != run_id or retrieval_plan.get("asOf") != as_of: 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: raise AssemblyError("retrievalPlan 与当前 workId 不一致") max_chars = token_budget.get("maxContextChars") @@ -617,6 +653,7 @@ def assemble_context( # 规划期选定的文风约束:归一化后仅在非空时入冻结上下文(空则省略整个键, # 上下文逐字节不变,不破坏既有哈希与 A/C 单变量)。 style_rules = [normalize_text(str(rule)) for rule in style_constraints if str(rule).strip()] + style_rules.extend(humanization_guidance) ordered_prose = sorted( selected_prose, key=lambda item: ( @@ -667,6 +704,8 @@ def assemble_context( "omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])), "acceptanceEligible": mode == "production" and purpose == "production", } + if humanization_provenance is not None: + context["humanizationProvenance"] = copy.deepcopy(humanization_provenance) if style_rules: context["styleConstraints"] = list(style_rules) # usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。 diff --git a/.claude/skills/assemble-context/scripts/test_assemble_writer_context.py b/.claude/skills/assemble-context/scripts/test_assemble_writer_context.py index 0be8d64..c227b4f 100644 --- a/.claude/skills/assemble-context/scripts/test_assemble_writer_context.py +++ b/.claude/skills/assemble-context/scripts/test_assemble_writer_context.py @@ -572,5 +572,28 @@ class StyleInjectionTest(unittest.TestCase): 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__": unittest.main() diff --git a/.claude/skills/assemble-context/scripts/writer_contract.py b/.claude/skills/assemble-context/scripts/writer_contract.py index e2a6a26..327b4fd 100644 --- a/.claude/skills/assemble-context/scripts/writer_contract.py +++ b/.claude/skills/assemble-context/scripts/writer_contract.py @@ -439,7 +439,7 @@ def validate_writer_context(value: Any) -> dict[str, Any]: value, "$", required, - frozenset({"evidenceStrategy", "indexHints", "styleConstraints"}), + frozenset({"evidenceStrategy", "indexHints", "styleConstraints", "humanizationProvenance"}), ) if context["schemaVersion"] != CONTEXT_VERSION: 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")): # 范式引用走放宽校验:来源指针仍严格,另允许 name/summary/writingPoints 内容字段。 _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: # 文风约束(规划期选定的 style 画像投影):非空字符串数组;缺省时整个键省略, # 上下文逐字节不变(不破坏既有冻结哈希),build 端按空数组投影。 diff --git a/.claude/skills/capture-ai-flavor-cases/SKILL.md b/.claude/skills/capture-ai-flavor-cases/SKILL.md index 72d999b..52ab29d 100644 --- a/.claude/skills/capture-ai-flavor-cases/SKILL.md +++ b/.claude/skills/capture-ai-flavor-cases/SKILL.md @@ -6,6 +6,19 @@ disable-model-invocation: true # 抽取 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`,不能进入生成上下文,不能直接改变正文或规则。 @@ -20,7 +33,7 @@ disable-model-invocation: true ## 数据与副作用合同 - 读取:用户明确提供的文本文件,以及本 Skill 输出的案例卡 YAML/JSON。 -- 写入:检测命令指定的回执文件,以及 `muse-example` 中的案例卡与重验证账本;不写正文、`knowledge/` 正式资产或生产规则目录。 +- 写入:检测命令指定的回执文件,以及 `muse-example` 中的案例卡与重验证账本;生命周期入口更新案例卡当前投影、写质量评测账;不写正文、`knowledge/` 正式资产或生产规则目录。 - 自动落库:`scan`、`inventory`、`feedback` 在检测完成后自动以一个事务写入数据库。`--offline` 是显式例外,只用于离线合同测试或数据库恢复准备;不能把离线文件当正式内容。 - 恢复入口:`persist_cases.py` 只用于把已审计的 inventory/revalidation 文件恢复或迁移入库,正常检测不得依赖它单独执行。 - 模型:扫描、哈希、校验和候选归纳前置门不调用模型;语义标注可由独立评审完成,结果必须回写卡片的 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 # 既有作品反向扫描;research_only 是默认安全值,输出 hash-only 卡 .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`,再投影为样例。 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/位置,不能直接确认。 `backfill-inventory-*.json` 与 `revalidation-*.json` 是可复核的导出/恢复证据;正式内容在 `muse-example` 的 @@ -95,4 +130,4 @@ disable-model-invocation: true 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。 diff --git a/.claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py b/.claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py index d35c301..b678cbc 100644 --- a/.claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py +++ b/.claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py @@ -22,6 +22,12 @@ from typing import Iterable 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 执行时也注册稳定模块名,自动落库模块复用同一份合同异常类型, # 避免失败路径被重复 import 变成未捕获 traceback。 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, pattern: dict, capture_mode: str = "backfill", feedback: dict | None = None, 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_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] stored = source_license in STOREABLE_LICENSES 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", work_ref: str | None = None, patterns: Iterable[dict] = PATTERNS, 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: raise CaseCardError(f"不支持的来源许可: {source_license}") 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": raise CaseCardError("只有 canonical 卡可以投影样例") 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 { "id": "sample-" + card["id"], "type": card["label"], "rules": list(card.get("rule_candidate_ids", [])), "carrier": card["carrier"], - "source": card["source"]["license"], + "source": source_map[card["source"]["license"]], "text": card["excerpt"], "note": card["observation"]["diagnosis"], "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: raise CaseCardError("规则候选至少需要一张案例卡") 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} if "sf" not in labels or not labels.intersection({"snf", "boundary", "regression"}): raise CaseCardError("规则候选必须同时有 sf 与 snf/boundary/regression 证据") - return { - "schema_version": "ai-flavor-rule-candidate-v1", + if layer not in LAYERS - {"unknown"}: + 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, "name": name, - "status": "candidate", + "layer": layer, + "carrier_scope": carrier_scope, + "trigger": trigger, + "carve_out": list(carve_out or []), "default_disposition": "candidate", + "function_check": function_check, "fix_hint": fix_hint, + "samples": samples, + "version": 1, + "status": "candidate", "case_card_ids": [card["id"] for card in cards], "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]: @@ -578,9 +637,15 @@ def load_bundle(paths: Iterable[Path]) -> list[dict]: seen = set() for path in paths: 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): - raise CaseCardError(f"{path}: cards 必须是数组") + raise CaseCardError(f"{path}: cards 必须是数组或单卡回执") for card in items: validate_card(card) if card["id"] in seen: @@ -844,6 +909,14 @@ def _parser() -> argparse.ArgumentParser: propose.add_argument("--rule-id", required=True) propose.add_argument("--name", required=True) 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, help="revalidate 命令生成的来源重验证回执") 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": cards = load_bundle(args.cards) verification = load_verification(args.verification) - rule = propose_rule(cards, rule_id=args.rule_id, name=args.name, fix_hint=args.fix_hint, - verification=verification) + trigger = {"type": args.trigger_type} + 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) print(json.dumps({"status": rule["status"], "cards": len(cards), "output": str(args.output)}, ensure_ascii=False)) else: diff --git a/.claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py b/.claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py new file mode 100644 index 0000000..c2690f8 --- /dev/null +++ b/.claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py @@ -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()) diff --git a/.claude/skills/capture-ai-flavor-cases/scripts/persist_cases.py b/.claude/skills/capture-ai-flavor-cases/scripts/persist_cases.py index 6a9666b..dc8d528 100644 --- a/.claude/skills/capture-ai-flavor-cases/scripts/persist_cases.py +++ b/.claude/skills/capture-ai-flavor-cases/scripts/persist_cases.py @@ -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: parser = argparse.ArgumentParser(description="AI 味案例卡与重验证回执落库") parser.add_argument("inventory", type=Path, help="ai-flavor-inventory-v1 JSON/YAML") diff --git a/.claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py b/.claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py index 35a14d6..595efa5 100644 --- a/.claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py +++ b/.claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py @@ -16,10 +16,12 @@ from capture_cases import ( capture_feedback, capture_file, build_inventory, + build_case_card, confirm_card, project_sample, propose_rule, build_revalidation_report, + load_bundle, revalidate_card, text_sha256, validate_card, @@ -28,6 +30,14 @@ from capture_cases import ( 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): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -147,6 +157,16 @@ class CaptureCasesTest(unittest.TestCase): self.assertEqual(2, report["totals"]["verified"]) 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): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "owned.txt" @@ -185,6 +205,17 @@ class CaptureCasesTest(unittest.TestCase): with self.assertRaises(CaseCardError): 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): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "owned.txt" @@ -198,6 +229,8 @@ class CaptureCasesTest(unittest.TestCase): sample = project_sample(canonical, verification=verification) self.assertEqual("sample-" + canonical["id"], sample["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): root = Path(__file__).resolve().parents[1] / "references" / "fixtures" diff --git a/.claude/skills/diagnose-ai-flavor/SKILL.md b/.claude/skills/diagnose-ai-flavor/SKILL.md new file mode 100644 index 0000000..64fde5b --- /dev/null +++ b/.claude/skills/diagnose-ai-flavor/SKILL.md @@ -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 +``` diff --git a/.claude/skills/diagnose-ai-flavor/scripts/diagnose_ai_flavor.py b/.claude/skills/diagnose-ai-flavor/scripts/diagnose_ai_flavor.py new file mode 100644 index 0000000..e3887e0 --- /dev/null +++ b/.claude/skills/diagnose-ai-flavor/scripts/diagnose_ai_flavor.py @@ -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()) diff --git a/.claude/skills/diagnose-ai-flavor/scripts/test_diagnose_ai_flavor.py b/.claude/skills/diagnose-ai-flavor/scripts/test_diagnose_ai_flavor.py new file mode 100644 index 0000000..979328b --- /dev/null +++ b/.claude/skills/diagnose-ai-flavor/scripts/test_diagnose_ai_flavor.py @@ -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() diff --git a/.claude/skills/establish-voice-baseline/SKILL.md b/.claude/skills/establish-voice-baseline/SKILL.md new file mode 100644 index 0000000..733b1e6 --- /dev/null +++ b/.claude/skills/establish-voice-baseline/SKILL.md @@ -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 +``` diff --git a/.claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py b/.claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py new file mode 100644 index 0000000..fba6225 --- /dev/null +++ b/.claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py @@ -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()) diff --git a/.claude/skills/establish-voice-baseline/scripts/test_establish_voice_baseline.py b/.claude/skills/establish-voice-baseline/scripts/test_establish_voice_baseline.py new file mode 100644 index 0000000..bcff26a --- /dev/null +++ b/.claude/skills/establish-voice-baseline/scripts/test_establish_voice_baseline.py @@ -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() diff --git a/.claude/skills/prevent-ai-flavor/SKILL.md b/.claude/skills/prevent-ai-flavor/SKILL.md new file mode 100644 index 0000000..a21eb90 --- /dev/null +++ b/.claude/skills/prevent-ai-flavor/SKILL.md @@ -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 +``` diff --git a/.claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py b/.claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py new file mode 100644 index 0000000..58e7615 --- /dev/null +++ b/.claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py @@ -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()) diff --git a/.claude/skills/prevent-ai-flavor/scripts/test_prevent_ai_flavor.py b/.claude/skills/prevent-ai-flavor/scripts/test_prevent_ai_flavor.py new file mode 100644 index 0000000..bf239e2 --- /dev/null +++ b/.claude/skills/prevent-ai-flavor/scripts/test_prevent_ai_flavor.py @@ -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() diff --git a/.claude/skills/revise-ai-flavor/SKILL.md b/.claude/skills/revise-ai-flavor/SKILL.md new file mode 100644 index 0000000..2ec4fef --- /dev/null +++ b/.claude/skills/revise-ai-flavor/SKILL.md @@ -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 +``` diff --git a/.claude/skills/revise-ai-flavor/scripts/revise_ai_flavor.py b/.claude/skills/revise-ai-flavor/scripts/revise_ai_flavor.py new file mode 100644 index 0000000..aed9785 --- /dev/null +++ b/.claude/skills/revise-ai-flavor/scripts/revise_ai_flavor.py @@ -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()) diff --git a/.claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py b/.claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py new file mode 100644 index 0000000..967cf5e --- /dev/null +++ b/.claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py @@ -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() diff --git a/.claude/skills/write-next-chapter/SKILL.md b/.claude/skills/write-next-chapter/SKILL.md index d1a7a83..afd63cb 100644 --- a/.claude/skills/write-next-chapter/SKILL.md +++ b/.claude/skills/write-next-chapter/SKILL.md @@ -23,12 +23,13 @@ disable-model-invocation: true - `proseExcerpts`:用于连续性与叙事声音的历史正文摘录。 - `patternReferences`:可参考的写作范式——每条给名字(name)、一句话摘要(summary)与写法要点(writingPoints);只供借鉴写法,不是事实约束。 - `lengthContract`:本章动态篇幅合同。 -- `styleConstraints`:文风约束。 +- `styleConstraints`:文风约束;生产组装时还包含 `prevent-ai-flavor` 生成的有限人感前置约束(规则/声音账指纹只留在完整 WriterContext,不投影给 writer)。 Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验收状态、实验臂、oracle 或目标章之后的内容,也不得自行检索;投影里没有的设定不应被当作已确认事实。 ## 功能约束 +0. 每次生成前必须经过 `prevent-ai-flavor` 合同组装;缺失时不伪造通用真人文风,保留规则为空的诚实合同并由章后诊断兜底。 1. 一次一整章;篇幅以 `lengthContract.targetChars/minChars/maxChars` 为唯一口径,由目标章之前有效 Canonical 章长中位数和细纲密度确定性计算,并受 2000–10000 汉字硬边界约束;不得再使用固定字数范围。 2. 伏笔只按细纲动作执行:说埋就埋、说推就推、说收就收;不擅自提前回收,不新开大坑。 3. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。 diff --git a/AGENTS.md b/AGENTS.md index aa5aa47..d76d8e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ agent-example/ │ ├── ddl/ # 可审计 DDL / 迁移文件 │ ├── 表映射.md │ └── 连接信息.md +├── humanization/ # 去 AI 味与人感资产层(合同/规则/样例/执行骨架;验证期自治,升华进 Muse 时同步回父仓;20 项研究覆盖矩阵见 humanization/research/) ├── knowledge/ # 仓内参考资产;未经绑定、授权不得进入上下文 ├── docs/ # 设计、评测、样张与历史执行记录 ├── .venv/ # 本地 Python 运行环境 @@ -62,7 +63,7 @@ agent-example/ - 执行、证据与上下文:`execute-claude-task`、`record-run-evidence`、`freeze-context`、`assemble-context`。 - 流程与主权治理:`decide-candidate`。 - 清洗、拆解与知识:`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`。 - 检测与质量评测:`check-content-consistency`、`score-content-quality`、`optimize-content-quality`、`evaluate-frozen-replay`。 diff --git a/db/ddl/110-example-voice-baseline.sql b/db/ddl/110-example-voice-baseline.sql new file mode 100644 index 0000000..faed849 --- /dev/null +++ b/db/ddl/110-example-voice-baseline.sql @@ -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(); diff --git a/db/表映射.md b/db/表映射.md index 3641672..8be7c31 100644 --- a/db/表映射.md +++ b/db/表映射.md @@ -2,7 +2,7 @@ > 口径(创始人拍板③ 2026-07-10):主仓表**原样不改列**;实验私货全进 `example_*` 前缀。 > 建表方式:`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);库内无该表。 ## 主仓一致表(20 张) @@ -30,7 +30,7 @@ | muse_knowledge_draft | V5 | V14(两快照列→varchar(128)) | **草稿**知识行(B2 拆书产出落此) | | 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_lesson | 经验升格登记(108):lesson/win 证据绑 run_id+候选哈希;proposed→reviewing→promoted/rejected,DB 触发器禁止跳过评审的自动升格 | | example_candidate_cas | 候选 CAS 状态链(109):一次运行一条链(DRAFT/CHECKING/PASSED/REJECTED),revision 单调 +1,触发器锁方向闭集与身份不可变 | +| example_voice_baseline | 声音账(110):技能 1 定基线产物,一作品一版本 append + supersede;grounding 门由 establish-voice-baseline 脚本强制;修订门禁与前置预防机械消费 | ## 暂缓建表登记(主仓有、实验现阶段未建;需要时按原样加建) diff --git a/humanization/README.md b/humanization/README.md new file mode 100644 index 0000000..60e27d2 --- /dev/null +++ b/humanization/README.md @@ -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 +``` diff --git a/humanization/cards/backfill-example.yaml b/humanization/cards/backfill-example.yaml new file mode 100644 index 0000000..5ce0620 --- /dev/null +++ b/humanization/cards/backfill-example.yaml @@ -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,补充上下文后再标注。" diff --git a/humanization/config.yaml b/humanization/config.yaml new file mode 100644 index 0000000..5e6fa7e --- /dev/null +++ b/humanization/config.yaml @@ -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 diff --git a/humanization/contracts/audit_report.schema.json b/humanization/contracts/audit_report.schema.json new file mode 100644 index 0000000..fdcc522 --- /dev/null +++ b/humanization/contracts/audit_report.schema.json @@ -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"}} + } + } + } +} diff --git a/humanization/contracts/case_card.schema.json b/humanization/contracts/case_card.schema.json new file mode 100644 index 0000000..1b8445a --- /dev/null +++ b/humanization/contracts/case_card.schema.json @@ -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"}} + } +} diff --git a/humanization/contracts/finding.schema.json b/humanization/contracts/finding.schema.json new file mode 100644 index 0000000..eed8fdf --- /dev/null +++ b/humanization/contracts/finding.schema.json @@ -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"}} + } +} diff --git a/humanization/contracts/patch.schema.json b/humanization/contracts/patch.schema.json new file mode 100644 index 0000000..2ab389b --- /dev/null +++ b/humanization/contracts/patch.schema.json @@ -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"}} + } +} diff --git a/humanization/contracts/prevention.schema.json b/humanization/contracts/prevention.schema.json new file mode 100644 index 0000000..655daf4 --- /dev/null +++ b/humanization/contracts/prevention.schema.json @@ -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}} + } +} diff --git a/humanization/contracts/rule.schema.json b/humanization/contracts/rule.schema.json new file mode 100644 index 0000000..85e0715 --- /dev/null +++ b/humanization/contracts/rule.schema.json @@ -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}} + } +} diff --git a/humanization/contracts/sample.schema.json b/humanization/contracts/sample.schema.json new file mode 100644 index 0000000..527d287 --- /dev/null +++ b/humanization/contracts/sample.schema.json @@ -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"]} + } +} diff --git a/humanization/contracts/voice_baseline.schema.json b/humanization/contracts/voice_baseline.schema.json new file mode 100644 index 0000000..f109521 --- /dev/null +++ b/humanization/contracts/voice_baseline.schema.json @@ -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"}} + } +} diff --git a/humanization/eval/run_eval.py b/humanization/eval/run_eval.py new file mode 100644 index 0000000..101fde7 --- /dev/null +++ b/humanization/eval/run_eval.py @@ -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() diff --git a/humanization/pyproject.toml b/humanization/pyproject.toml new file mode 100644 index 0000000..0e91949 --- /dev/null +++ b/humanization/pyproject.toml @@ -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"] diff --git a/humanization/research/20-project-humanization-rule-catalog.zh-CN.md b/humanization/research/20-project-humanization-rule-catalog.zh-CN.md new file mode 100644 index 0000000..ebd2277 --- /dev/null +++ b/humanization/research/20-project-humanization-rule-catalog.zh-CN.md @@ -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 个项目的所有词表直接搬进生产规则库。 diff --git a/humanization/research/20-project-skill-coverage.yaml b/humanization/research/20-project-skill-coverage.yaml new file mode 100644 index 0000000..f3b5a7f --- /dev/null +++ b/humanization/research/20-project-skill-coverage.yaml @@ -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合同存在,但独立读者规模化盲评未完成 diff --git a/humanization/rules/density/d001.yaml b/humanization/rules/density/d001.yaml new file mode 100644 index 0000000..4fac91c --- /dev/null +++ b/humanization/rules/density/d001.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/density/d002.yaml b/humanization/rules/density/d002.yaml new file mode 100644 index 0000000..c6b4980 --- /dev/null +++ b/humanization/rules/density/d002.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/density/d003.yaml b/humanization/rules/density/d003.yaml new file mode 100644 index 0000000..3de63d3 --- /dev/null +++ b/humanization/rules/density/d003.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/density/d004.yaml b/humanization/rules/density/d004.yaml new file mode 100644 index 0000000..45d708d --- /dev/null +++ b/humanization/rules/density/d004.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/density/d005.yaml b/humanization/rules/density/d005.yaml new file mode 100644 index 0000000..e80c068 --- /dev/null +++ b/humanization/rules/density/d005.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/density/d006.yaml b/humanization/rules/density/d006.yaml new file mode 100644 index 0000000..0ad5f04 --- /dev/null +++ b/humanization/rules/density/d006.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/lexical/l001.yaml b/humanization/rules/lexical/l001.yaml new file mode 100644 index 0000000..3fca097 --- /dev/null +++ b/humanization/rules/lexical/l001.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/lexical/l002.yaml b/humanization/rules/lexical/l002.yaml new file mode 100644 index 0000000..406190e --- /dev/null +++ b/humanization/rules/lexical/l002.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/lexical/l003.yaml b/humanization/rules/lexical/l003.yaml new file mode 100644 index 0000000..73a3526 --- /dev/null +++ b/humanization/rules/lexical/l003.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/lexical/l004.yaml b/humanization/rules/lexical/l004.yaml new file mode 100644 index 0000000..979316d --- /dev/null +++ b/humanization/rules/lexical/l004.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/lexical/l005.yaml b/humanization/rules/lexical/l005.yaml new file mode 100644 index 0000000..b6d7ba3 --- /dev/null +++ b/humanization/rules/lexical/l005.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/lexical/l006.yaml b/humanization/rules/lexical/l006.yaml new file mode 100644 index 0000000..3d7f4db --- /dev/null +++ b/humanization/rules/lexical/l006.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/mechanical/m001.yaml b/humanization/rules/mechanical/m001.yaml new file mode 100644 index 0000000..3959a02 --- /dev/null +++ b/humanization/rules/mechanical/m001.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/mechanical/m002.yaml b/humanization/rules/mechanical/m002.yaml new file mode 100644 index 0000000..3a41d3d --- /dev/null +++ b/humanization/rules/mechanical/m002.yaml @@ -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 同类规则 diff --git a/humanization/rules/mechanical/m003.yaml b/humanization/rules/mechanical/m003.yaml new file mode 100644 index 0000000..cafa543 --- /dev/null +++ b/humanization/rules/mechanical/m003.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/mechanical/m004.yaml b/humanization/rules/mechanical/m004.yaml new file mode 100644 index 0000000..355fbd7 --- /dev/null +++ b/humanization/rules/mechanical/m004.yaml @@ -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 演练验证命中 diff --git a/humanization/rules/semantic/sem001.yaml b/humanization/rules/semantic/sem001.yaml new file mode 100644 index 0000000..808a610 --- /dev/null +++ b/humanization/rules/semantic/sem001.yaml @@ -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 演练人工判定 diff --git a/humanization/rules/semantic/sem002.yaml b/humanization/rules/semantic/sem002.yaml new file mode 100644 index 0000000..ff59c69 --- /dev/null +++ b/humanization/rules/semantic/sem002.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/semantic/sem003.yaml b/humanization/rules/semantic/sem003.yaml new file mode 100644 index 0000000..c79deda --- /dev/null +++ b/humanization/rules/semantic/sem003.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/structural/s001.yaml b/humanization/rules/structural/s001.yaml new file mode 100644 index 0000000..4cd4293 --- /dev/null +++ b/humanization/rules/structural/s001.yaml @@ -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 演练人工判定 diff --git a/humanization/rules/structural/s002.yaml b/humanization/rules/structural/s002.yaml new file mode 100644 index 0000000..4ae296e --- /dev/null +++ b/humanization/rules/structural/s002.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/structural/s003.yaml b/humanization/rules/structural/s003.yaml new file mode 100644 index 0000000..b566463 --- /dev/null +++ b/humanization/rules/structural/s003.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/structural/s004.yaml b/humanization/rules/structural/s004.yaml new file mode 100644 index 0000000..6352cdb --- /dev/null +++ b/humanization/rules/structural/s004.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/structural/s005.yaml b/humanization/rules/structural/s005.yaml new file mode 100644 index 0000000..39d220b --- /dev/null +++ b/humanization/rules/structural/s005.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/structural/s006.yaml b/humanization/rules/structural/s006.yaml new file mode 100644 index 0000000..67087af --- /dev/null +++ b/humanization/rules/structural/s006.yaml @@ -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 建议,无自动修复 diff --git a/humanization/rules/structural/s007.yaml b/humanization/rules/structural/s007.yaml new file mode 100644 index 0000000..9aa2997 --- /dev/null +++ b/humanization/rules/structural/s007.yaml @@ -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 建议,无自动修复 diff --git a/humanization/samples/boundary.yaml b/humanization/samples/boundary.yaml new file mode 100644 index 0000000..d748002 --- /dev/null +++ b/humanization/samples/boundary.yaml @@ -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。 diff --git a/humanization/samples/regression.yaml b/humanization/samples/regression.yaml new file mode 100644 index 0000000..ce07495 --- /dev/null +++ b/humanization/samples/regression.yaml @@ -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: 事故说明:为让情绪「有承载」新增原文没有的强度细节「捏出裂纹」。禁止新增内容,应拦。 diff --git a/humanization/samples/sf.yaml b/humanization/samples/sf.yaml new file mode 100644 index 0000000..49c3a3a --- /dev/null +++ b/humanization/samples/sf.yaml @@ -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: 连续直接命名情绪,无动作、选择、对话或感知承载;改为可感知的承载。 diff --git a/humanization/samples/snf.yaml b/humanization/samples/snf.yaml new file mode 100644 index 0000000..a5f0e35 --- /dev/null +++ b/humanization/samples/snf.yaml @@ -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(低强度情绪的直接命名)生效。 diff --git a/humanization/src/deai/__init__.py b/humanization/src/deai/__init__.py new file mode 100644 index 0000000..a206c8a --- /dev/null +++ b/humanization/src/deai/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""muse-deai:人感体系执行骨架。 + +设计 SoT:design-docs/专题-09-去AI味与人感体系设计.md。 +本包只做合同强制与机械执行;语义判定(结构/语义层检测、仲裁、改写、成对选择) +由外部模型或人产出,经本包校验后才能进入下一阶段。 +""" + +__version__ = "0.1.0" diff --git a/humanization/src/deai/baseline.py b/humanization/src/deai/baseline.py new file mode 100644 index 0000000..9cc81c4 --- /dev/null +++ b/humanization/src/deai/baseline.py @@ -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"] diff --git a/humanization/src/deai/cards.py b/humanization/src/deai/cards.py new file mode 100644 index 0000000..574322b --- /dev/null +++ b/humanization/src/deai/cards.py @@ -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 diff --git a/humanization/src/deai/carriers.py b/humanization/src/deai/carriers.py new file mode 100644 index 0000000..d6cc1db --- /dev/null +++ b/humanization/src/deai/carriers.py @@ -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"] diff --git a/humanization/src/deai/diagnose.py b/humanization/src/deai/diagnose.py new file mode 100644 index 0000000..c501ce0 --- /dev/null +++ b/humanization/src/deai/diagnose.py @@ -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", +] diff --git a/humanization/src/deai/evaluation.py b/humanization/src/deai/evaluation.py new file mode 100644 index 0000000..93be57e --- /dev/null +++ b/humanization/src/deai/evaluation.py @@ -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", +] diff --git a/humanization/src/deai/gates.py b/humanization/src/deai/gates.py new file mode 100644 index 0000000..29c2dce --- /dev/null +++ b/humanization/src/deai/gates.py @@ -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) diff --git a/humanization/src/deai/load.py b/humanization/src/deai/load.py new file mode 100644 index 0000000..728a8ea --- /dev/null +++ b/humanization/src/deai/load.py @@ -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] diff --git a/humanization/src/deai/pairwise.py b/humanization/src/deai/pairwise.py new file mode 100644 index 0000000..d204b5a --- /dev/null +++ b/humanization/src/deai/pairwise.py @@ -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 diff --git a/humanization/src/deai/patch.py b/humanization/src/deai/patch.py new file mode 100644 index 0000000..acb53e2 --- /dev/null +++ b/humanization/src/deai/patch.py @@ -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 diff --git a/humanization/src/deai/pipeline.py b/humanization/src/deai/pipeline.py new file mode 100644 index 0000000..a223072 --- /dev/null +++ b/humanization/src/deai/pipeline.py @@ -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", +] diff --git a/humanization/src/deai/report.py b/humanization/src/deai/report.py new file mode 100644 index 0000000..90814e8 --- /dev/null +++ b/humanization/src/deai/report.py @@ -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 diff --git a/humanization/src/deai/schemas.py b/humanization/src/deai/schemas.py new file mode 100644 index 0000000..14a2f1b --- /dev/null +++ b/humanization/src/deai/schemas.py @@ -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) diff --git a/humanization/tests/__init__.py b/humanization/tests/__init__.py new file mode 100644 index 0000000..153585e --- /dev/null +++ b/humanization/tests/__init__.py @@ -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")) diff --git a/humanization/tests/test_contracts.py b/humanization/tests/test_contracts.py new file mode 100644 index 0000000..880eefe --- /dev/null +++ b/humanization/tests/test_contracts.py @@ -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() diff --git a/humanization/tests/test_framework_coverage.py b/humanization/tests/test_framework_coverage.py new file mode 100644 index 0000000..3fad3d2 --- /dev/null +++ b/humanization/tests/test_framework_coverage.py @@ -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() diff --git a/humanization/tests/test_humanization_v2.py b/humanization/tests/test_humanization_v2.py new file mode 100644 index 0000000..ad42d80 --- /dev/null +++ b/humanization/tests/test_humanization_v2.py @@ -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() diff --git a/meta/chains/README.md b/meta/chains/README.md index 9758a25..a2fcbc5 100644 --- a/meta/chains/README.md +++ b/meta/chains/README.md @@ -28,6 +28,19 @@ agent 的提示词按**变化轴**拆三段,不做"一个 agent 一个大 prom | extraction 章后抽取 | `extract-chapter-knowledge` | 分析→extractor | extraction | 采纳后触发→槽位→草稿/冲突队列→decide-candidate | 已建;C5 首验 | | validation / consistency_check 检测 | `check-content-consistency` | 检测→detector | detection | assemble-context(detection 视图)→槽位→报告落评审/ | 已建 | | 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 时才同步回父仓。 ## 公约