框架: 去AI味与人感资产层入库——humanization 规则库(26 条 active,其中 16 条经所有者留痕豁免 holdout 激活)与五技能先行验证副本
This commit is contained in:
parent
b0bc7a8745
commit
476bb71da4
@ -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 或预算任何一项无法验证时失败关闭,不靠重试或人工说明绕过。
|
||||
|
||||
@ -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 长度,迭代到数值稳定。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 端按空数组投影。
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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:
|
||||
|
||||
266
.claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py
Normal file
266
.claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py
Normal file
@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""技能 5「挖掘」的状态流转与规则评测入口。
|
||||
|
||||
采集脚本负责快速发现;本脚本负责慢确认、样例投影、候选评测和人工审批。任何一步失败关闭,
|
||||
不会把 shadow 卡或合同回放报告自动变成 active 规则。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
AGENT_ROOT = SCRIPT_DIR.parents[3]
|
||||
for path in (
|
||||
SCRIPT_DIR,
|
||||
AGENT_ROOT / "humanization" / "src",
|
||||
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
|
||||
):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from capture_cases import ( # noqa: E402
|
||||
CaseCardError,
|
||||
annotate_card,
|
||||
confirm_card,
|
||||
load_bundle,
|
||||
load_verification,
|
||||
project_sample,
|
||||
)
|
||||
from db import connect # noqa: E402
|
||||
from deai import evaluation, load # noqa: E402
|
||||
|
||||
|
||||
class MiningError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _read_object(path: Path) -> dict:
|
||||
try:
|
||||
value = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
||||
raise MiningError(f"{path}: 读取失败: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise MiningError(f"{path}: 顶层必须是对象")
|
||||
return value
|
||||
|
||||
|
||||
def _load_extra_samples(paths: list[Path], *, cards: dict | None = None,
|
||||
verification: dict | None = None) -> dict:
|
||||
samples = load.load_samples()
|
||||
for path in paths:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
||||
raise MiningError(f"{path}: 样例读取失败: {exc}") from exc
|
||||
items = payload.get("samples", []) if isinstance(payload, dict) else []
|
||||
if not isinstance(items, list):
|
||||
raise MiningError(f"{path}: samples 必须是数组")
|
||||
for item in items:
|
||||
from deai.schemas import validate
|
||||
validate(item, "sample")
|
||||
if item["id"] in samples:
|
||||
raise MiningError(f"样例 id 重复: {item['id']}")
|
||||
case_card_id = item.get("case_card_id")
|
||||
if case_card_id:
|
||||
if cards is None or verification is None:
|
||||
raise MiningError(f"样例 {item['id']} 绑定案例卡时必须同时提供 --cards 与 --verification")
|
||||
card = cards.get(case_card_id)
|
||||
if card is None:
|
||||
raise MiningError(f"样例 {item['id']} 引用的案例卡不存在: {case_card_id}")
|
||||
if card.get("state") != "canonical":
|
||||
raise MiningError(f"样例 {item['id']} 引用的案例卡不是 canonical: {case_card_id}")
|
||||
try:
|
||||
expected = project_sample(card, verification=verification)
|
||||
except CaseCardError as exc:
|
||||
raise MiningError(f"样例 {item['id']} 的案例卡未通过 verified 投影门: {exc}") from exc
|
||||
for key in ("id", "type", "carrier", "source", "text", "case_card_id", "source_ref", "source_license"):
|
||||
if item.get(key) != expected.get(key):
|
||||
raise MiningError(f"样例 {item['id']} 字段 {key} 与 canonical 投影不一致")
|
||||
samples[item["id"]] = item
|
||||
return samples
|
||||
|
||||
|
||||
def _one_card(path: Path, card_id: str) -> dict:
|
||||
cards = load_bundle([path])
|
||||
matches = [card for card in cards if card["id"] == card_id]
|
||||
if len(matches) != 1:
|
||||
raise MiningError(f"{path}: card_id={card_id} 匹配 {len(matches)} 张卡")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _write(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||
path.write_text(yaml.safe_dump(value, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
else:
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def persist_evaluation(report: dict) -> dict:
|
||||
"""规则评测只落质量账,不改规则文件;规则文件仍由 Git 管理。"""
|
||||
report_hash = hashlib.sha256(
|
||||
json.dumps(report, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
run_id = "rule-eval-" + report_hash[:40]
|
||||
detail = {
|
||||
"rule_id": report["rule_id"],
|
||||
"rule_version": report["rule_version"],
|
||||
"dataset_kind": report.get("dataset_kind"),
|
||||
"counts": report.get("counts"),
|
||||
"contract_pass": report.get("contract_pass"),
|
||||
"holdout": report.get("holdout"),
|
||||
"effect_claim": report.get("effect_claim"),
|
||||
}
|
||||
with connect() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO example_run (run_id,work_id,trigger_source,trigger_detail,terminal_state,finished_at,creator,tenant_id) "
|
||||
"VALUES (%s,NULL,'diagnostic',%s::jsonb,'completed',CURRENT_TIMESTAMP,'humanization',1) "
|
||||
"ON CONFLICT (run_id) DO NOTHING",
|
||||
(run_id, json.dumps(detail, ensure_ascii=False)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO example_quality_result "
|
||||
"(run_id,candidate_sha256,judge_kind,dimension,scale_version,conclusion,detail,creator,tenant_id) "
|
||||
"VALUES (%s,NULL,'experiment','ai_flavor_rule','ai-flavor-rule-evaluation-v1',%s,%s::jsonb,'humanization',1) "
|
||||
"ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING",
|
||||
(run_id, "passed" if report.get("eligible") else "insufficient_evidence", json.dumps(detail, ensure_ascii=False)),
|
||||
)
|
||||
return {"run_id": run_id, "report_sha256": report_hash}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="技能 5 挖掘:案例状态、规则评测与生命周期")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
annotate = sub.add_parser("annotate")
|
||||
annotate.add_argument("--cards", type=Path, required=True)
|
||||
annotate.add_argument("--card-id", required=True)
|
||||
annotate.add_argument("--label", choices=["sf", "snf", "boundary", "regression"], required=True)
|
||||
annotate.add_argument("--carrier", choices=["narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"], default="unknown")
|
||||
annotate.add_argument("--reviewer", default="")
|
||||
annotate.add_argument("--note", default="")
|
||||
annotate.add_argument("--output", type=Path, required=True)
|
||||
annotate.add_argument("--offline", action="store_true")
|
||||
|
||||
confirm = sub.add_parser("confirm")
|
||||
confirm.add_argument("--cards", type=Path, required=True)
|
||||
confirm.add_argument("--card-id", required=True)
|
||||
confirm.add_argument("--verification", type=Path, required=True)
|
||||
confirm.add_argument("--reviewer", required=True)
|
||||
confirm.add_argument("--note", required=True)
|
||||
confirm.add_argument("--output", type=Path, required=True)
|
||||
confirm.add_argument("--offline", action="store_true")
|
||||
|
||||
project = sub.add_parser("project-sample")
|
||||
project.add_argument("--cards", type=Path, required=True)
|
||||
project.add_argument("--card-id", required=True)
|
||||
project.add_argument("--verification", type=Path, required=True)
|
||||
project.add_argument("--output", type=Path, required=True)
|
||||
|
||||
evaluate_rule = sub.add_parser("evaluate-rule")
|
||||
evaluate_rule.add_argument("--rule", type=Path, required=True)
|
||||
evaluate_rule.add_argument("--holdout", type=Path)
|
||||
evaluate_rule.add_argument("--samples", type=Path, nargs="*", default=[], help="已投影的额外四类样例文件")
|
||||
evaluate_rule.add_argument("--cards", type=Path, nargs="*", default=[], help="与额外样例绑定的案例卡文件")
|
||||
evaluate_rule.add_argument("--verification", type=Path, help="额外样例对应的 verified 重验证回执")
|
||||
evaluate_rule.add_argument("--output", type=Path, required=True)
|
||||
evaluate_rule.add_argument("--offline", action="store_true")
|
||||
|
||||
activate = sub.add_parser("activate-rule")
|
||||
activate.add_argument("--rule", type=Path, required=True)
|
||||
activate.add_argument("--contract-report", type=Path, required=True)
|
||||
activate.add_argument("--holdout-report", type=Path, required=True)
|
||||
activate.add_argument("--approver", required=True)
|
||||
activate.add_argument("--note", default="")
|
||||
activate.add_argument("--output", type=Path, required=True)
|
||||
|
||||
deprecate = sub.add_parser("deprecate-rule")
|
||||
deprecate.add_argument("--rule", type=Path, required=True)
|
||||
deprecate.add_argument("--approver", required=True)
|
||||
deprecate.add_argument("--reason", required=True)
|
||||
deprecate.add_argument("--output", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "annotate":
|
||||
card = _one_card(args.cards, args.card_id)
|
||||
out = annotate_card(card, label=args.label, carrier=args.carrier,
|
||||
reviewer=args.reviewer, note=args.note)
|
||||
persistence = {"status": "offline"} if args.offline else _persist_projection(out)
|
||||
_write(args.output, {"card": out, "persistence": persistence})
|
||||
result = {"card_id": out["id"], "state": out["state"], "label": out["label"], "persistence": persistence}
|
||||
elif args.command == "confirm":
|
||||
card = _one_card(args.cards, args.card_id)
|
||||
verification = load_verification(args.verification)
|
||||
out = confirm_card(card, reviewer=args.reviewer, note=args.note, verification=verification)
|
||||
persistence = {"status": "offline"} if args.offline else _persist_projection(out)
|
||||
_write(args.output, {"card": out, "persistence": persistence})
|
||||
result = {"card_id": out["id"], "state": out["state"], "persistence": persistence}
|
||||
elif args.command == "project-sample":
|
||||
card = _one_card(args.cards, args.card_id)
|
||||
sample = project_sample(card, verification=load_verification(args.verification))
|
||||
_write(args.output, {"schema_version": "humanization-samples-bundle-v1", "samples": [sample]})
|
||||
result = {"sample_id": sample["id"], "case_card_id": sample["case_card_id"], "output": str(args.output)}
|
||||
elif args.command == "evaluate-rule":
|
||||
rule = _read_object(args.rule)
|
||||
card_map = {}
|
||||
for path in args.cards:
|
||||
for card in load_bundle([path]):
|
||||
if card["id"] in card_map:
|
||||
raise MiningError(f"案例卡 id 重复: {card['id']}")
|
||||
card_map[card["id"]] = card
|
||||
verification = load_verification(args.verification) if args.verification else None
|
||||
samples = _load_extra_samples(
|
||||
args.samples, cards=card_map or None, verification=verification
|
||||
)
|
||||
report = evaluation.evaluate_rule_contract(rule, samples)
|
||||
if args.holdout:
|
||||
report["holdout"] = evaluation.evaluate_holdout(rule, _read_object(args.holdout))
|
||||
report["eligible"] = bool(report["contract_pass"] and report["holdout"]["eligible"])
|
||||
else:
|
||||
report["eligible"] = False
|
||||
report["holdout"] = None
|
||||
persistence = {"status": "offline"} if args.offline else persist_evaluation(report)
|
||||
report["persistence"] = persistence
|
||||
_write(args.output, report)
|
||||
result = {"rule_id": report["rule_id"], "contract_pass": report["contract_pass"], "eligible": report["eligible"], "persistence": persistence}
|
||||
elif args.command == "activate-rule":
|
||||
rule = _read_object(args.rule)
|
||||
contract_report = _read_object(args.contract_report)
|
||||
holdout_report = _read_object(args.holdout_report)
|
||||
out = evaluation.activate_rule(
|
||||
rule, contract_report=contract_report, holdout_report=holdout_report,
|
||||
approver=args.approver, evidence_note=args.note,
|
||||
)
|
||||
_write(args.output, out)
|
||||
result = {"rule_id": out["id"], "status": out["status"], "output": str(args.output)}
|
||||
else:
|
||||
rule = _read_object(args.rule)
|
||||
out = evaluation.deprecate_rule(rule, approver=args.approver, reason=args.reason)
|
||||
_write(args.output, out)
|
||||
result = {"rule_id": out["id"], "status": out["status"], "output": str(args.output)}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
except (MiningError, CaseCardError, evaluation.EvaluationError, load.LoadError, OSError, ValueError) as exc:
|
||||
print(f"AI_FLAVOR_MINING_CONTRACT_FAILED: {exc}")
|
||||
return 2
|
||||
|
||||
|
||||
def _persist_projection(card: dict) -> dict:
|
||||
from persist_cases import persist_card_projection
|
||||
|
||||
return persist_card_projection(card)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
40
.claude/skills/diagnose-ai-flavor/SKILL.md
Normal file
40
.claude/skills/diagnose-ai-flavor/SKILL.md
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
name: diagnose-ai-flavor
|
||||
description: 技能 3 诊断:对目标正文跑 active 规则库,产出带精确片段与证据的发现清单,检测完成自动落库。只查不改;没有本产物,revise-ai-flavor 拒绝启动。
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 诊断(scenario: deai_diagnose | purpose: detection | 槽位: 检测→detector)
|
||||
|
||||
何时用:每次生成后对候选正文跑一次;也可对既有正文回溯诊断。诊断是修订的唯一合法前置(专题-09 铁律:没诊断不能改)。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. `scripts/diagnose_ai_flavor.py run --text-file <正文> --work-ref <作品引用> --output <产物.json>`。
|
||||
2. 确定性层按五层合同执行:regex、handler、density 由代码运行;carrier scope 会屏蔽明确对白/场内载体;结构/语义层由模型或人产出 finding 后,用 `--external-findings` 注入。
|
||||
3. 外部 finding 必须绑定当前正文 hash、active 规则、规则版本和 layer;缺任一项直接拒绝。规则库指纹覆盖完整规则内容,触发器未升版本也会使旧诊断失效。
|
||||
4. 诊断建议保守:blocking mechanical 才可默认 `repair`,其它命中进入 `ask`/仲裁;carve-out 只作为候选,不自动删除。
|
||||
5. 产物头(text_hash、规则库版本、mode、发现清单)缺任何一项,下游视为诊断未发生。
|
||||
6. 检测完成即落库:`example_run` + `example_quality_result`(judge_kind=detection,绑正文 sha256);`--offline` 仅作显式离线回放。
|
||||
|
||||
## 输出合同
|
||||
|
||||
诊断产物 JSON:产物头 + `findings[]`。每条 finding 带 `id / rule_id / spans / context_window / evidence / confidence / decision_proposal`(确定性层产 ask,blocking 机械规则产 repair;语义层外部注入的 finding 可为 pending/ask/keep)。**到这里一个字没改**;作者可以只看报告不动手。
|
||||
|
||||
## 数据库读写合同
|
||||
|
||||
- 读:无(规则与样例读 `humanization/rules` / `humanization/samples` 文件资产)。
|
||||
- 写:`example_run`(幂等 upsert,run_id=作品+文本哈希+规则库版本)、`example_quality_result`(append-only)。
|
||||
|
||||
## 红线
|
||||
|
||||
- 诊断不得改正文、不得产 patch。
|
||||
- `decision_proposal` 只是机械层初步建议;候选/语义命中必须经过功能仲裁,不能当执行指令。
|
||||
- 命中不等于修改命令:发现清单是给仲裁的输入,不是执行指令。
|
||||
|
||||
## 自测
|
||||
|
||||
```bash
|
||||
cd agent-example
|
||||
.venv/bin/python .claude/skills/diagnose-ai-flavor/scripts/test_diagnose_ai_flavor.py
|
||||
```
|
||||
189
.claude/skills/diagnose-ai-flavor/scripts/diagnose_ai_flavor.py
Normal file
189
.claude/skills/diagnose-ai-flavor/scripts/diagnose_ai_flavor.py
Normal file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""技能 3「诊断」确定性脚本层(专题-09 §5.3)。
|
||||
|
||||
职责边界:只查不改——对目标正文跑 humanization/rules 的 active 规则,
|
||||
产出诊断产物(产物头 + 发现清单)。语义层判定由外部模型/人产出后经
|
||||
merge_model_findings 注入,同样过合同校验;诊断不修改任何正文。
|
||||
|
||||
落库合同(检测完成即落库):
|
||||
- example_run:一次诊断一行(run_id 由 作品+文本哈希+规则库版本 决定,重跑幂等);
|
||||
- example_quality_result:judge_kind=detection,绑被诊断文本的 sha256;
|
||||
- 只有显式 --offline 才不写库(仅产文件,供回放)。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 共享执行骨架(humanization 副本)与数据库通道(access-database)的引导
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
AGENT_ROOT = SCRIPT_DIR.parents[3]
|
||||
for _p in (AGENT_ROOT / "humanization" / "src",
|
||||
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts"):
|
||||
if str(_p) not in sys.path:
|
||||
sys.path.insert(0, str(_p))
|
||||
|
||||
from deai import diagnose, load # noqa: E402
|
||||
|
||||
TENANT_ID = 1
|
||||
CREATOR = "1"
|
||||
|
||||
|
||||
class DiagnoseContractError(ValueError):
|
||||
"""诊断合同失败:下游修订/前置预防必须失败关闭。"""
|
||||
|
||||
|
||||
def rule_library_version(rules: dict) -> str:
|
||||
"""兼容入口;真实指纹由共享装载器覆盖完整规则内容。"""
|
||||
return load.rule_library_version(rules)
|
||||
|
||||
|
||||
def load_active_library() -> tuple[dict, str]:
|
||||
"""装载规则库并强制激活门:样例不齐的规则装载器直接拒绝(专题-09 §4.1)。"""
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
return rules, rule_library_version(rules)
|
||||
|
||||
|
||||
def run_diagnosis(text: str, *, work_ref: str, chapter_ref: str | None = None,
|
||||
external_findings: list | None = None, mode: str = "Audit") -> dict:
|
||||
"""产出完整诊断产物;产物头缺项由 validate_artifact 兜底拒绝。"""
|
||||
if not text:
|
||||
raise DiagnoseContractError("诊断文本为空")
|
||||
if not work_ref:
|
||||
raise DiagnoseContractError("诊断必须带 work_ref")
|
||||
rules, lib_version = load_active_library()
|
||||
artifact = diagnose.run_deterministic_rules(text, load.active_rules(rules), lib_version, mode)
|
||||
if external_findings:
|
||||
diagnose.merge_model_findings(artifact, external_findings, rules=rules, text=text)
|
||||
diagnose.validate_artifact(artifact)
|
||||
artifact["work_ref"] = work_ref
|
||||
if chapter_ref:
|
||||
artifact["chapter_ref"] = chapter_ref
|
||||
return artifact
|
||||
|
||||
|
||||
def _run_id(*parts: str) -> str:
|
||||
return "diag-" + hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:40]
|
||||
|
||||
|
||||
def persist_diagnosis(artifact: dict, *, text: str,
|
||||
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
|
||||
"""诊断运行落库:example_run(幂等 upsert)+ example_quality_result(append-only)。"""
|
||||
from db import connect
|
||||
|
||||
if not isinstance(text, str) or not text:
|
||||
raise DiagnoseContractError("落库诊断文本为空")
|
||||
try:
|
||||
diagnose.validate_artifact(artifact)
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
raise DiagnoseContractError(f"落库诊断产物不可读: {exc}") from exc
|
||||
expected_hash = diagnose.text_hash(text)
|
||||
if artifact["text_hash"] != expected_hash:
|
||||
raise DiagnoseContractError("落库诊断产物 text_hash 与正文不一致")
|
||||
if not artifact.get("work_ref"):
|
||||
raise DiagnoseContractError("落库诊断必须带 work_ref")
|
||||
_, current_library_version = load_active_library()
|
||||
if artifact["rule_library_version"] != current_library_version:
|
||||
raise DiagnoseContractError("落库诊断产物使用了过期规则库")
|
||||
|
||||
run_id = _run_id(artifact["work_ref"], artifact["text_hash"], artifact["rule_library_version"])
|
||||
text_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
per_rule: dict[str, int] = {}
|
||||
for f in artifact["findings"]:
|
||||
per_rule[f["rule_id"]] = per_rule.get(f["rule_id"], 0) + 1
|
||||
per_layer: dict[str, int] = {}
|
||||
per_decision: dict[str, int] = {}
|
||||
for finding in artifact["findings"]:
|
||||
per_layer[finding["layer"]] = per_layer.get(finding["layer"], 0) + 1
|
||||
decision = finding["decision_proposal"]
|
||||
per_decision[decision] = per_decision.get(decision, 0) + 1
|
||||
detail = {
|
||||
"text_hash": artifact["text_hash"],
|
||||
"rule_library_version": artifact["rule_library_version"],
|
||||
"mode": artifact["mode"],
|
||||
"work_ref": artifact["work_ref"],
|
||||
"chapter_ref": artifact.get("chapter_ref"),
|
||||
"findings_total": len(artifact["findings"]),
|
||||
"findings_per_rule": per_rule,
|
||||
"findings_per_layer": per_layer,
|
||||
"decision_proposals": per_decision,
|
||||
}
|
||||
run_sql = (
|
||||
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
|
||||
"terminal_state, finished_at, creator, tenant_id) "
|
||||
"VALUES (%s, NULL, 'diagnostic', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
|
||||
"ON CONFLICT (run_id) DO UPDATE SET terminal_state='completed', "
|
||||
"finished_at=CURRENT_TIMESTAMP, trigger_detail=EXCLUDED.trigger_detail, "
|
||||
"updater=EXCLUDED.creator, update_time=CURRENT_TIMESTAMP"
|
||||
)
|
||||
quality_sql = (
|
||||
"INSERT INTO example_quality_result "
|
||||
"(run_id, candidate_sha256, judge_kind, dimension, scale_version, conclusion, detail, creator, tenant_id) "
|
||||
"VALUES (%s, %s, 'detection', NULL, %s, %s, %s::jsonb, %s, %s)"
|
||||
)
|
||||
with connect() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(run_sql, (run_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id))
|
||||
# append-only 表不能 ON CONFLICT 更新;按幂等键预检,重复运行不重复记账
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM example_quality_result WHERE tenant_id=%s AND run_id=%s "
|
||||
"AND judge_kind='detection' AND COALESCE(candidate_sha256,'')=%s",
|
||||
(tenant_id, run_id, text_sha),
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
conn.execute(quality_sql, (
|
||||
run_id, text_sha, artifact["rule_library_version"],
|
||||
"has_findings" if artifact["findings"] else "clean",
|
||||
json.dumps(detail, ensure_ascii=False), creator, tenant_id,
|
||||
))
|
||||
return {"run_id": run_id, "text_sha256": text_sha, "findings": len(artifact["findings"])}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="技能 3 诊断:只查不改,产出诊断产物并落库")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
run = sub.add_parser("run")
|
||||
run.add_argument("--text-file", type=Path, required=True)
|
||||
run.add_argument("--work-ref", required=True)
|
||||
run.add_argument("--chapter-ref")
|
||||
run.add_argument("--external-findings", type=Path,
|
||||
help="语义层外部判定(JSON 数组),注入前逐条过 finding 合同")
|
||||
run.add_argument("--mode", default="Audit", choices=["Audit", "Patch"])
|
||||
run.add_argument("--output", type=Path, required=True)
|
||||
run.add_argument("--offline", action="store_true", help="只产文件,不写 muse-example")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
text = args.text_file.read_text(encoding="utf-8")
|
||||
external = None
|
||||
if args.external_findings:
|
||||
external = json.loads(args.external_findings.read_text(encoding="utf-8"))
|
||||
artifact = run_diagnosis(text, work_ref=args.work_ref, chapter_ref=args.chapter_ref,
|
||||
external_findings=external, mode=args.mode)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
if args.offline:
|
||||
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
|
||||
else:
|
||||
persistence = persist_diagnosis(artifact, text=text)
|
||||
print(json.dumps({
|
||||
"findings": len(artifact["findings"]),
|
||||
"text_hash": artifact["text_hash"],
|
||||
"rule_library_version": artifact["rule_library_version"],
|
||||
"output": str(args.output),
|
||||
"persistence": persistence,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
except (DiagnoseContractError, diagnose.ArtifactIncomplete, load.LoadError,
|
||||
ValueError, OSError, UnicodeError) as exc:
|
||||
print(f"DIAGNOSE_CONTRACT_FAILED: {exc}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -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()
|
||||
46
.claude/skills/establish-voice-baseline/SKILL.md
Normal file
46
.claude/skills/establish-voice-baseline/SKILL.md
Normal file
@ -0,0 +1,46 @@
|
||||
---
|
||||
name: establish-voice-baseline
|
||||
description: 技能 1 定基线:作品建立或新角色登场时,把已确认正文与作者样张产出的声音账做结构校验、grounding 门后版本化落库。不产正文;是修订门禁与前置预防的对照物。
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 定基线(scenario: voice_baseline | purpose: planning | 槽位: 规划→planner)
|
||||
|
||||
何时用:作品建立时;新角色登场时;作者样张更新时。平时不动——它是垫底资产。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 生产优先从数据库 Canonical 正文生成候选画像:
|
||||
`.venv/bin/python scripts/establish_voice_baseline.py draft --work-id <作品ID> --output <候选账.json>`。
|
||||
该步骤只计算句长、段长、标点、对白比例等统计;角色策略、口癖、保护片段和黑名单保持 `unknown`,交 planner/作者补充。
|
||||
2. 离线回放可显式使用 `--work-ref + --text-file`;生产不把文件快照当正文权威。
|
||||
3. 人工确认后执行:
|
||||
`.venv/bin/python scripts/establish_voice_baseline.py confirm --ledger-file <声音账.json> --work-id <作品ID> --reviewer <确认人>`。
|
||||
4. 脚本强制:结构合同 → 样张/口癖/保护片段 grounding → 角色归属冲突门 → 新版本追加、旧版本 superseded;候选账只记 `example_run`,不冒充 current 基线。
|
||||
|
||||
## 为什么必须有它
|
||||
|
||||
没有声音账,后面所有「改得对不对」只能对照通用真人文风——那是错的对照系。声音账同时被两处机械消费:
|
||||
|
||||
- `revise-ai-flavor` 硬门:`untouchable_verbal_tics` 口癖删除即拒收,`protected_spans` 改动即拒收;
|
||||
- `prevent-ai-flavor`:正样例与黑名单进入生成上下文。
|
||||
|
||||
## 数据库读写合同
|
||||
|
||||
- 读:`muse_content_chapter` + `muse_content_block` 的 Canonical 正文(章节状态为 `published/confirmed/canonical`,或正文块存在 `example_user_decision.decision=accept`);`example_voice_baseline` 当前版本。
|
||||
- 写:候选生成写 `example_run`;确认写 `example_voice_baseline`(append 新版本行 + 旧行 superseded;单事务)。
|
||||
- 失败:正文为空、当前账多版本、hash 不一致、grounding 或人工确认缺失时失败关闭,不写半成品。
|
||||
|
||||
## 红线
|
||||
|
||||
- 只读已确认正文与作者样张;不得从草稿或未确认候选归纳基线。
|
||||
- 基线必须人工确认(`--reviewer` 必填,数据库 CHECK 兜底)。
|
||||
- 本技能不产一个字正文。
|
||||
|
||||
## 自测
|
||||
|
||||
```bash
|
||||
cd agent-example
|
||||
.venv/bin/python .claude/skills/establish-voice-baseline/scripts/test_establish_voice_baseline.py
|
||||
.venv/bin/python humanization/tests/test_humanization_v2.py
|
||||
```
|
||||
@ -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())
|
||||
@ -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()
|
||||
41
.claude/skills/prevent-ai-flavor/SKILL.md
Normal file
41
.claude/skills/prevent-ai-flavor/SKILL.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: prevent-ai-flavor
|
||||
description: 技能 2 前置预防:生成前组装写作上下文合同——active 规则的负约束(附反例)加声音账的正样例与保护项。不碰正文,只供上下文组装消费。
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 前置预防(scenario: deai_prevention | purpose: generation | 槽位: assemble-context 消费)
|
||||
|
||||
何时用:每次组装写作上下文时(续写/改写/扩写/润色前)。产物给 `assemble-context` 塞进生成上下文,不直接进正文。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 生产执行 `scripts/prevent_ai_flavor.py --work-ref work:<作品ID> --output <合同.json>`;默认读取数据库 current canonical 声音账。离线回放必须显式 `--offline` 或传 `--voice-ledger`。
|
||||
2. 负约束只取 active 规则;每条同时带 SF 避开例、SNF 保留例、Boundary、Regression、carrier scope、carve-out 和 function_check。
|
||||
3. 正样例与保护项只取当前作品声音账:叙述者画像、角色口癖/样张、达标样张、保护片段和黑名单;不把案例卡正文当声音素材。
|
||||
4. 合同生成后由 `assemble-context` 以 `humanization_contract` 参数冻结;writer 只看到有限 `writer_constraints`,完整来源/规则指纹留在可信上下文。
|
||||
5. 构建运行落 `example_run`;`--offline` 仅作显式离线回放。
|
||||
|
||||
## 输出合同
|
||||
|
||||
`ai-flavor-prevention-v2` JSON:`negative_constraints[]`(含四类样例与 scope)/ `positive_samples[]` / `protected_spans[]` / `blacklist[]` / `writer_constraints[]` / `built_from`(规则库版本 + 声音账哈希)。
|
||||
|
||||
## 边界
|
||||
|
||||
- **不承诺零 AI 味**,只降低命中率;漏网的由 `diagnose-ai-flavor` 兜底。
|
||||
- 声音账缺失时仍产合同(只有负约束),正样例为空——不得拿通用「真人文风」冒充本作品的声音。
|
||||
- 声音账 work_ref 与目标不一致直接拒绝,防止串作品套用。
|
||||
|
||||
## 数据库读写合同
|
||||
|
||||
- 读:`example_voice_baseline` 当前 canonical 版本;规则/样例读 `humanization/` Git 资产。
|
||||
- 写:`example_run`(幂等 upsert,记录规则指纹、声音账 hash 和投影条数)。
|
||||
- 失败:作品不匹配、声音账非 canonical、规则装载门失败或 guidance 超合同直接失败关闭。
|
||||
|
||||
## 自测
|
||||
|
||||
```bash
|
||||
cd agent-example
|
||||
.venv/bin/python .claude/skills/prevent-ai-flavor/scripts/test_prevent_ai_flavor.py
|
||||
.venv/bin/python .claude/skills/assemble-context/scripts/test_assemble_writer_context.py
|
||||
```
|
||||
252
.claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py
Normal file
252
.claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py
Normal file
@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""技能 2「前置预防」确定性脚本层。
|
||||
|
||||
生成前合同同时携带规则的避开例、保留例、边界例和回归陷阱,并优先读取数据库当前声音账。
|
||||
它只生成 writer 上下文指导,不触碰正文;规则仍由诊断技能复查。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
AGENT_ROOT = SCRIPT_DIR.parents[3]
|
||||
for _p in (
|
||||
AGENT_ROOT / "humanization" / "src",
|
||||
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
|
||||
AGENT_ROOT / ".claude" / "skills" / "establish-voice-baseline" / "scripts",
|
||||
):
|
||||
if str(_p) not in sys.path:
|
||||
sys.path.insert(0, str(_p))
|
||||
|
||||
from deai import load # noqa: E402
|
||||
from deai.schemas import validate # noqa: E402
|
||||
|
||||
TENANT_ID = 1
|
||||
CREATOR = "1"
|
||||
|
||||
|
||||
class PreventionContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def rule_library_version(rules: dict) -> str:
|
||||
return load.rule_library_version(rules)
|
||||
|
||||
|
||||
def _sample_text(samples: dict, sample_id: str, limit: int = 260) -> str:
|
||||
text = str((samples.get(sample_id) or {}).get("text") or "")
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def _render_rule_constraint(rule: dict, item: dict) -> str:
|
||||
avoid = ";".join(item["avoid_examples"][:2]) or "(暂无可展示的该修例)"
|
||||
keep = ";".join(item["keep_examples"][:2]) or "(遇到相似形式先做功能判断)"
|
||||
scope = rule["carrier_scope"]
|
||||
return (
|
||||
f"避免【{rule['name']}】(适用载体:{scope}):不要直接生成类似「{avoid}」的无功能写法;"
|
||||
f"若与「{keep}」相似,先保留信息并判断人物、节奏或场内功能;"
|
||||
f"{rule.get('fix_hint', '')}"
|
||||
)
|
||||
|
||||
|
||||
def render_writer_constraints(contract: dict) -> list[str]:
|
||||
"""把结构化合同投影成有限、可读、可追溯的 writer 指令。"""
|
||||
rows = list(contract.get("writer_constraints") or [])
|
||||
for item in contract.get("positive_samples", [])[:8]:
|
||||
if item.get("kind") in {"voice_sample", "narrator_exemplar", "passing_sample"}:
|
||||
rows.append(f"本作声音参照(只学语气与节奏,不复制内容):{item['text']}")
|
||||
for span in contract.get("protected_spans", [])[:12]:
|
||||
rows.append(f"本作保护片段不可改写:{span}")
|
||||
for item in contract.get("blacklist", [])[:32]:
|
||||
rows.append(f"本作黑名单表达式避免主动生成:{item}")
|
||||
return rows
|
||||
|
||||
|
||||
def _load_db_ledger(work_ref: str) -> dict | None:
|
||||
from establish_voice_baseline import load_current_baseline
|
||||
|
||||
return load_current_baseline(work_ref)
|
||||
|
||||
|
||||
def _validate_voice_ledger(voice_ledger: dict, work_ref: str) -> None:
|
||||
from establish_voice_baseline import validate_ledger
|
||||
|
||||
try:
|
||||
validate_ledger(voice_ledger, work_ref=work_ref)
|
||||
except ValueError as exc:
|
||||
raise PreventionContractError(f"声音账结构不合法: {exc}") from exc
|
||||
|
||||
|
||||
def build_prevention_contract(work_ref: str, *, voice_ledger: dict | None = None,
|
||||
load_database: bool = False) -> dict:
|
||||
"""组装上下文合同:active 规则带四类样例,声音账只取当前作品版本。"""
|
||||
if not work_ref:
|
||||
raise PreventionContractError("前置预防必须带 work_ref")
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
lib_version = rule_library_version(rules)
|
||||
|
||||
if voice_ledger is None and load_database:
|
||||
voice_ledger = _load_db_ledger(work_ref)
|
||||
if voice_ledger is not None:
|
||||
_validate_voice_ledger(voice_ledger, work_ref)
|
||||
if voice_ledger.get("schema_version") != "voice-baseline-v1":
|
||||
raise PreventionContractError("声音账 schema_version 必须是 voice-baseline-v1")
|
||||
if voice_ledger.get("work_ref") != work_ref:
|
||||
raise PreventionContractError("声音账 work_ref 与预防目标不一致")
|
||||
if voice_ledger.get("status", "canonical") != "canonical":
|
||||
raise PreventionContractError("声音账不是 canonical,不能进入生产上下文")
|
||||
|
||||
negatives = []
|
||||
writer_constraints = []
|
||||
for rule in load.active_rules(rules):
|
||||
refs = rule["samples"]
|
||||
item = {
|
||||
"rule_id": rule["id"],
|
||||
"name": rule["name"],
|
||||
"layer": rule["layer"],
|
||||
"carrier_scope": rule["carrier_scope"],
|
||||
"default_disposition": rule["default_disposition"],
|
||||
"fix_hint": rule.get("fix_hint", ""),
|
||||
"function_check": list(rule.get("function_check", [])),
|
||||
"carve_out": list(rule.get("carve_out", [])),
|
||||
"avoid_examples": [_sample_text(samples, sid) for sid in refs.get("sf", []) if sid in samples],
|
||||
"keep_examples": [_sample_text(samples, sid) for sid in refs.get("snf", []) if sid in samples],
|
||||
"boundary_examples": [_sample_text(samples, sid) for sid in refs.get("boundary", []) if sid in samples],
|
||||
"regression_traps": [_sample_text(samples, sid) for sid in refs.get("regression", []) if sid in samples],
|
||||
}
|
||||
negatives.append(item)
|
||||
writer_constraints.append(_render_rule_constraint(rule, item))
|
||||
|
||||
positives: list[dict] = []
|
||||
protections: list[str] = []
|
||||
blacklist: list[str] = []
|
||||
ledger_sha = None
|
||||
if voice_ledger is not None:
|
||||
ledger_sha = voice_ledger.get("database_ledger_sha256") or hashlib.sha256(
|
||||
json.dumps(voice_ledger, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()
|
||||
narrator = voice_ledger.get("narrator") or {}
|
||||
for habit in narrator.get("sentence_habits", []):
|
||||
positives.append({"kind": "narrator_habit", "who": "narrator", "text": habit})
|
||||
for habit in narrator.get("punctuation_habits", []):
|
||||
positives.append({"kind": "narrator_punctuation", "who": "narrator", "text": habit})
|
||||
for line in narrator.get("exemplar_passages", []):
|
||||
positives.append({"kind": "narrator_exemplar", "who": "narrator", "text": line})
|
||||
for who, info in (voice_ledger.get("characters") or {}).items():
|
||||
for tic in info.get("verbal_tics", []):
|
||||
positives.append({"kind": "verbal_tic", "who": who, "text": tic})
|
||||
for line in info.get("sample_lines", []):
|
||||
positives.append({"kind": "voice_sample", "who": who, "text": line})
|
||||
positives.extend(
|
||||
{"kind": "passing_sample", "who": "work", "text": line}
|
||||
for line in voice_ledger.get("passing_samples", [])
|
||||
)
|
||||
protections = list(voice_ledger.get("protected_spans", []))
|
||||
blacklist = list(voice_ledger.get("blacklist", []))
|
||||
|
||||
contract = {
|
||||
"schema_version": "ai-flavor-prevention-v2",
|
||||
"work_ref": work_ref,
|
||||
"built_from": {
|
||||
"rule_library_version": lib_version,
|
||||
"active_rule_count": len(negatives),
|
||||
"voice_ledger_sha256": ledger_sha,
|
||||
"voice_ledger_source": "database" if load_database and voice_ledger else "explicit_file" if voice_ledger else "missing",
|
||||
},
|
||||
"negative_constraints": negatives,
|
||||
"positive_samples": positives,
|
||||
"protected_spans": protections,
|
||||
"blacklist": blacklist,
|
||||
"writer_constraints": writer_constraints,
|
||||
"limit": "只降低命中率,不承诺零 AI 味;漏网命中由 diagnose-ai-flavor 兜底",
|
||||
}
|
||||
# 合同校验只检查结构;规则样例和来源状态由上面的装载器/数据库门负责。
|
||||
validate(contract, "prevention")
|
||||
return contract
|
||||
|
||||
|
||||
def persist_prevention(contract: dict, *, creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
|
||||
"""上下文合同构建也是一次运行:example_run 留痕。"""
|
||||
from db import connect
|
||||
|
||||
try:
|
||||
validate(contract, "prevention")
|
||||
except ValueError as exc:
|
||||
raise PreventionContractError(f"前置预防合同不可落库: {exc}") from exc
|
||||
current_rules = load.load_rules(samples=load.load_samples())
|
||||
current_library_version = rule_library_version(current_rules)
|
||||
basis = contract["built_from"]
|
||||
if basis.get("rule_library_version") != current_library_version:
|
||||
raise PreventionContractError("前置预防合同使用了过期规则库")
|
||||
run_id = "prev-" + hashlib.sha256(
|
||||
f"{contract['work_ref']}|{basis['rule_library_version']}|{basis['voice_ledger_sha256'] or ''}|"
|
||||
f"{hashlib.sha256(json.dumps(contract.get('writer_constraints', []), ensure_ascii=False).encode()).hexdigest()}".encode("utf-8")
|
||||
).hexdigest()[:40]
|
||||
detail = {
|
||||
"work_ref": contract["work_ref"],
|
||||
"rule_library_version": basis["rule_library_version"],
|
||||
"active_rule_count": basis["active_rule_count"],
|
||||
"voice_ledger_sha256": basis["voice_ledger_sha256"],
|
||||
"voice_ledger_source": basis["voice_ledger_source"],
|
||||
"negative_constraints": len(contract["negative_constraints"]),
|
||||
"positive_samples": len(contract["positive_samples"]),
|
||||
"writer_constraints": len(contract.get("writer_constraints", [])),
|
||||
}
|
||||
with connect() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
|
||||
"terminal_state, finished_at, creator, tenant_id) "
|
||||
"VALUES (%s, NULL, 'diagnostic', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
|
||||
"ON CONFLICT (run_id) DO UPDATE SET terminal_state='completed', "
|
||||
"finished_at=CURRENT_TIMESTAMP, trigger_detail=EXCLUDED.trigger_detail, "
|
||||
"updater=EXCLUDED.creator, update_time=CURRENT_TIMESTAMP",
|
||||
(run_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id),
|
||||
)
|
||||
return {"run_id": run_id}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="技能 2 前置预防:生成前上下文合同")
|
||||
parser.add_argument("--work-ref", required=True)
|
||||
parser.add_argument("--voice-ledger", type=Path, help="离线回放/人工传入的声音账;生产默认读数据库")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--offline", action="store_true", help="只产文件,不写 muse-example;也不读取数据库")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
ledger = None
|
||||
load_database = not args.offline and args.voice_ledger is None
|
||||
if args.voice_ledger:
|
||||
ledger = json.loads(args.voice_ledger.read_text(encoding="utf-8"))
|
||||
contract = build_prevention_contract(
|
||||
args.work_ref, voice_ledger=ledger, load_database=load_database
|
||||
)
|
||||
contract["writer_constraints"] = render_writer_constraints(contract)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(contract, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
if args.offline:
|
||||
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
|
||||
else:
|
||||
persistence = persist_prevention(contract)
|
||||
except (PreventionContractError, load.LoadError, ValueError, OSError) as exc:
|
||||
print(f"PREVENTION_CONTRACT_FAILED: {exc}")
|
||||
return 2
|
||||
print(json.dumps({
|
||||
"negative_constraints": len(contract["negative_constraints"]),
|
||||
"positive_samples": len(contract["positive_samples"]),
|
||||
"writer_constraints": len(contract["writer_constraints"]),
|
||||
"rule_library_version": contract["built_from"]["rule_library_version"],
|
||||
"voice_ledger_source": contract["built_from"]["voice_ledger_source"],
|
||||
"output": str(args.output),
|
||||
"persistence": persistence,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -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()
|
||||
39
.claude/skills/revise-ai-flavor/SKILL.md
Normal file
39
.claude/skills/revise-ai-flavor/SKILL.md
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
name: revise-ai-flavor
|
||||
description: 技能 4 修订:在诊断产物与作者同意之上执行最小 patch,过硬门、复扫与成对选择校验,产出候选稿与审计报告。没诊断不启动;人不点头永远是候选。
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 修订(scenario: deai_revise | purpose: generation | 槽位: 写作→writer + 保护节点)
|
||||
|
||||
何时用:诊断(`diagnose-ai-flavor`)产出发现清单、仲裁完成、作者同意修订之后。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 前置检查:`--artifact` 缺失、规则库过期、`author_approved_revision`/`allowed_scope` 缺失即拒绝;无事实快照且无显式授权时自动降回 Audit。
|
||||
2. 功能仲裁在脚本之外完成(模型/人逐条过五问),每个被 patch 的 finding 必须是 `repair` 且有 `arbitration_note`;`keep`/`ask` 不能偷偷改。
|
||||
3. 执行机械链:唯一匹配 patch → 硬门(结构重放/数字和专名/时间锚点/模态/引文/口癖)→ 声音漂移门 → 同规则复扫(只允许既有 keep/ask 命中保留)→ 独立模型成对选择。
|
||||
4. 一次最多 3 轮;选择原文、平局、无净改善或任一门失败分别落 `no_gain`/`blocked`,不伪报 `passed`。
|
||||
5. 产出候选稿与审计报告并落库。**本 skill 不写正式正文**:候选转正由作者确认后走既有候选接受通道。
|
||||
|
||||
## 最小改动铁律
|
||||
|
||||
只删、只压缩、只用原文已有信息局部改写。绝不为了「更像人」加日期、加感官、加经历——replacement 引入新数字/新专名会被硬门拒收。
|
||||
|
||||
## 数据库读写合同
|
||||
|
||||
- 读:`example_voice_baseline` 当前 canonical 版本(未提供时声音门明确标 unknown);规则/样例读 `humanization/` 文件资产。
|
||||
- 写:`example_run`、`example_quality_result`(judge_kind=review,dimension=ai_flavor_revision,绑候选稿 sha256;conclusion=passed/blocked;降级只记 run)。
|
||||
|
||||
## 红线
|
||||
|
||||
- 没有诊断产物、作者授权或功能仲裁不得修订。
|
||||
- 硬门失败不得以风格分、盲评结果抵消;旧诊断不能跨规则库/正文 hash 复用。
|
||||
- 语义级不变量(因果、POV、伏笔状态)机械未覆盖的,如实列 `unresolved_risks`,不假装验证完成。
|
||||
|
||||
## 自测
|
||||
|
||||
```bash
|
||||
cd agent-example
|
||||
.venv/bin/python .claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py
|
||||
```
|
||||
227
.claude/skills/revise-ai-flavor/scripts/revise_ai_flavor.py
Normal file
227
.claude/skills/revise-ai-flavor/scripts/revise_ai_flavor.py
Normal file
@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""技能 4「修订」确定性脚本层(专题-09 §5.4 / §6 / §7)。
|
||||
|
||||
铁律:没有诊断产物,修订拒绝启动;没有事实快照,自动降回 Audit。
|
||||
语义级动作(仲裁五问、改写文本、成对选择判定)在本脚本之外产生;
|
||||
本脚本只强制合同与机械检查:产物头 → 唯一匹配 patch → 硬门 → 复扫 →
|
||||
成对选择校验 → 审计报告。人不点头,候选永远是候选——本脚本不写任何正文。
|
||||
|
||||
落库合同:example_run + example_quality_result(judge_kind=review,
|
||||
dimension=ai_flavor_revision,绑候选稿 sha256);--offline 才不写库。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
AGENT_ROOT = SCRIPT_DIR.parents[3]
|
||||
for _p in (AGENT_ROOT / "humanization" / "src",
|
||||
AGENT_ROOT / ".claude" / "skills" / "access-database" / "scripts",
|
||||
AGENT_ROOT / ".claude" / "skills" / "establish-voice-baseline" / "scripts"):
|
||||
if str(_p) not in sys.path:
|
||||
sys.path.insert(0, str(_p))
|
||||
|
||||
from deai import load, report as report_mod # noqa: E402
|
||||
from establish_voice_baseline import load_current_baseline # noqa: E402
|
||||
from deai.pairwise import PairwiseNotExecuted # noqa: E402
|
||||
from deai.patch import PatchError # noqa: E402
|
||||
from deai.pipeline import DowngradedToAudit, RevisionNotAuthorized, run_patch # noqa: E402
|
||||
|
||||
TENANT_ID = 1
|
||||
CREATOR = "1"
|
||||
|
||||
|
||||
class ReviseContractError(ValueError):
|
||||
"""修订合同失败:缺诊断、缺授权或门禁失败关闭。"""
|
||||
|
||||
|
||||
def _load_json(path: Path, what: str) -> dict:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReviseContractError(f"{what}不可读或不合法: {path} ({exc})") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ReviseContractError(f"{what}必须是 JSON 对象: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def run_revision(text: str, *, artifact: dict, patches: list, task_contract: dict,
|
||||
fact_snapshot: dict | None = None, voice_ledger: dict | None = None,
|
||||
pairwise: dict | None = None, rewrite_model: str = "claude") -> tuple[dict, dict]:
|
||||
"""Patch 全链:应用 → 硬门 → 复扫 → 成对选择校验 → 审计报告。"""
|
||||
if not artifact:
|
||||
raise ReviseContractError("没有诊断产物,修订拒绝启动(专题-09 铁律)")
|
||||
if not isinstance(patches, list) or not patches:
|
||||
raise ReviseContractError("没有 patch 清单,修订无事可做")
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
lib_version = load.rule_library_version(rules)
|
||||
record = run_patch(text, rules, artifact, patches, task_contract,
|
||||
fact_snapshot, voice_ledger, rewrite_model, pairwise, lib_version)
|
||||
audit = report_mod.assemble(
|
||||
artifact, patches, record["candidate_text"], record["hard_gate"],
|
||||
record["voice_gate"], record["regression_gate"], record["pairwise_choice"],
|
||||
unresolved_risks=(
|
||||
record["hard_gate"]["unverified"]
|
||||
+ list(record["voice_gate"].get("unknown", []))
|
||||
),
|
||||
)
|
||||
return record, audit
|
||||
|
||||
|
||||
def _run_id(*parts: str) -> str:
|
||||
return "rev-" + hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:40]
|
||||
|
||||
|
||||
def persist_revision(*, work_ref: str, text_hash: str, candidate_text: str | None,
|
||||
audit: dict | None, conclusion: str, detail: dict,
|
||||
creator: str = CREATOR, tenant_id: int = TENANT_ID) -> dict:
|
||||
"""修订运行落库:候选稿哈希绑定评判,append-only 记账。"""
|
||||
from db import connect
|
||||
|
||||
if not work_ref or not isinstance(detail, dict):
|
||||
raise ReviseContractError("修订落库缺少 work_ref/detail")
|
||||
if not isinstance(audit, dict):
|
||||
raise ReviseContractError("修订落库缺少审计报告")
|
||||
if conclusion not in {"passed", "blocked", "no_gain"}:
|
||||
raise ReviseContractError(f"修订结论非法: {conclusion}")
|
||||
audit_sha = hashlib.sha256(
|
||||
json.dumps(audit, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
detail = {**detail, "audit_sha256": audit_sha}
|
||||
candidate_sha = hashlib.sha256(candidate_text.encode("utf-8")).hexdigest() if candidate_text else None
|
||||
run_id = _run_id(work_ref, text_hash, json.dumps(detail.get("patches", []), sort_keys=True))
|
||||
run_sql = (
|
||||
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
|
||||
"terminal_state, finished_at, creator, tenant_id) "
|
||||
"VALUES (%s, NULL, 'user', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
|
||||
"ON CONFLICT (run_id) DO UPDATE SET terminal_state='completed', "
|
||||
"finished_at=CURRENT_TIMESTAMP, trigger_detail=EXCLUDED.trigger_detail, "
|
||||
"updater=EXCLUDED.creator, update_time=CURRENT_TIMESTAMP"
|
||||
)
|
||||
quality_sql = (
|
||||
"INSERT INTO example_quality_result "
|
||||
"(run_id, candidate_sha256, judge_kind, dimension, scale_version, conclusion, detail, creator, tenant_id) "
|
||||
"VALUES (%s, %s, 'review', 'ai_flavor_revision', %s, %s, %s::jsonb, %s, %s)"
|
||||
)
|
||||
with connect() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(run_sql, (run_id, json.dumps(detail, ensure_ascii=False), creator, tenant_id))
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM example_quality_result WHERE tenant_id=%s AND run_id=%s "
|
||||
"AND judge_kind='review' AND dimension='ai_flavor_revision' "
|
||||
"AND COALESCE(candidate_sha256,'')=%s",
|
||||
(tenant_id, run_id, candidate_sha or ""),
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
conn.execute(quality_sql, (
|
||||
run_id, candidate_sha, detail.get("rule_library_version"),
|
||||
conclusion, json.dumps(detail, ensure_ascii=False), creator, tenant_id,
|
||||
))
|
||||
return {"run_id": run_id, "candidate_sha256": candidate_sha}
|
||||
|
||||
|
||||
def _persist_downgrade(*, work_ref: str, text_hash: str, reason: str) -> dict:
|
||||
"""降级也是运行事实:落 example_run,避免「静默没发生」。"""
|
||||
from db import connect
|
||||
|
||||
run_id = _run_id(work_ref, text_hash, "downgrade")
|
||||
detail = {"status": "downgraded_to_audit", "reason": reason, "work_ref": work_ref}
|
||||
with connect() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO example_run (run_id, work_id, trigger_source, trigger_detail, "
|
||||
"terminal_state, finished_at, creator, tenant_id) "
|
||||
"VALUES (%s, NULL, 'user', %s::jsonb, 'completed', CURRENT_TIMESTAMP, %s, %s) "
|
||||
"ON CONFLICT (run_id) DO NOTHING",
|
||||
(run_id, json.dumps(detail, ensure_ascii=False), CREATOR, TENANT_ID),
|
||||
)
|
||||
return {"run_id": run_id}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="技能 4 修订:最小 patch + 硬门 + 审计,作者确认前永远是候选")
|
||||
parser.add_argument("--text-file", type=Path, required=True)
|
||||
parser.add_argument("--artifact", type=Path, required=True, help="诊断产物 JSON(缺它拒绝启动)")
|
||||
parser.add_argument("--patches", type=Path, required=True, help="patch 清单 JSON 数组(经仲裁)")
|
||||
parser.add_argument("--task-contract", type=Path, required=True,
|
||||
help="任务合同 JSON:mode=Patch 须带事实快照或显式授权")
|
||||
parser.add_argument("--snapshot", type=Path, help="事实快照 JSON")
|
||||
parser.add_argument("--voice-ledger", type=Path, help="声音账 JSON(技能 1 产物)")
|
||||
parser.add_argument("--pairwise", type=Path, help="跨模型成对选择记录 JSON")
|
||||
parser.add_argument("--rewrite-model", default="claude")
|
||||
parser.add_argument("--work-ref", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--offline", action="store_true", help="只产文件,不写 muse-example")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
text = args.text_file.read_text(encoding="utf-8")
|
||||
artifact = _load_json(args.artifact, "诊断产物")
|
||||
patches_raw = json.loads(args.patches.read_text(encoding="utf-8"))
|
||||
if not isinstance(patches_raw, list):
|
||||
raise ReviseContractError("patch 清单必须是 JSON 数组")
|
||||
task_contract = _load_json(args.task_contract, "任务合同")
|
||||
snapshot = _load_json(args.snapshot, "事实快照") if args.snapshot else None
|
||||
ledger = _load_json(args.voice_ledger, "声音账") if args.voice_ledger else None
|
||||
if ledger is None and not args.offline:
|
||||
ledger = load_current_baseline(args.work_ref)
|
||||
pairwise = _load_json(args.pairwise, "成对选择记录") if args.pairwise else None
|
||||
record, audit = run_revision(
|
||||
text, artifact=artifact, patches=patches_raw, task_contract=task_contract,
|
||||
fact_snapshot=snapshot, voice_ledger=ledger, pairwise=pairwise,
|
||||
rewrite_model=args.rewrite_model,
|
||||
)
|
||||
except DowngradedToAudit as exc:
|
||||
# 受控降级不是错误:Patch 降为 Audit,不产候选稿,降级事实照常落库
|
||||
persistence = {"status": "offline"} if args.offline else _persist_downgrade(
|
||||
work_ref=args.work_ref, text_hash=artifact.get("text_hash", ""), reason=exc.reason)
|
||||
print(json.dumps({"status": "downgraded_to_audit", "reason": exc.reason,
|
||||
"persistence": persistence}, ensure_ascii=False))
|
||||
return 0
|
||||
except (ReviseContractError, RevisionNotAuthorized, PatchError, PairwiseNotExecuted,
|
||||
report_mod.ForbiddenScoreError, load.LoadError, ValueError, OSError) as exc:
|
||||
print(f"REVISE_CONTRACT_FAILED: {exc}")
|
||||
return 2
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(audit, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
if (not record["hard_gate"]["pass"] or not record["regression_gate"]["pass"]
|
||||
or record["voice_gate"].get("pass") is not True):
|
||||
# 声音账缺失/样本不足是 unverified,不得借 pairwise 结果伪装成 passed。
|
||||
conclusion = "blocked"
|
||||
elif record["pairwise_choice"]["choice"] in {"original", "tie", "both_bad"}:
|
||||
conclusion = "no_gain"
|
||||
else:
|
||||
conclusion = "passed"
|
||||
detail = {
|
||||
"work_ref": args.work_ref,
|
||||
"patches": [{"finding_id": p["finding_id"], "action": p["action"]} for p in patches_raw],
|
||||
"hard_gate_pass": record["hard_gate"]["pass"],
|
||||
"regression_pass": record["regression_gate"]["pass"],
|
||||
"pairwise": bool(record["pairwise_choice"]),
|
||||
"audit_ref": f"revise://ai-flavor/{args.output.name}",
|
||||
}
|
||||
if args.offline:
|
||||
persistence = {"status": "offline", "reason": "显式 --offline,未写 muse-example"}
|
||||
else:
|
||||
persistence = persist_revision(
|
||||
work_ref=args.work_ref, text_hash=artifact.get("text_hash", ""),
|
||||
candidate_text=record["candidate_text"], audit=audit,
|
||||
conclusion=conclusion, detail=detail,
|
||||
)
|
||||
print(json.dumps({
|
||||
"status": conclusion,
|
||||
"hard_gate_pass": record["hard_gate"]["pass"],
|
||||
"regression_pass": record["regression_gate"]["pass"],
|
||||
"candidate_chars": len(record["candidate_text"]),
|
||||
"output": str(args.output),
|
||||
"persistence": persistence,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
178
.claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py
Normal file
178
.claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py
Normal file
@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""技能 4 修订离线测试:授权、仲裁、快照降级、硬门、复扫、盲评与落库合同。"""
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[3] / "skills" / "diagnose-ai-flavor" / "scripts"))
|
||||
|
||||
import revise_ai_flavor as rev # noqa: E402
|
||||
import diagnose_ai_flavor as diag # noqa: E402
|
||||
from deai.pipeline import DowngradedToAudit # noqa: E402
|
||||
|
||||
CLEAN_TEXT = "值得注意的是,门外已经下起了雨。"
|
||||
NUMBER_TEXT = "值得注意的是,他付了三百两。"
|
||||
TASK = {"mode": "Patch", "allowed_scope": "全文", "author_approved_revision": True}
|
||||
PAIRWISE = {"choice": "candidate", "rationale": "候选保真且删除了无功能套语", "selection_model": "gpt-5.6-sol"}
|
||||
|
||||
|
||||
def _artifact(text: str) -> dict:
|
||||
artifact = diag.run_diagnosis(text, work_ref="synthetic:demo")
|
||||
for finding in artifact["findings"]:
|
||||
finding["decision_proposal"] = "repair"
|
||||
finding["arbitration_note"] = "测试仲裁:确认该命中无当前功能"
|
||||
return artifact
|
||||
|
||||
|
||||
def _delete_patch(artifact: dict, span: str, exact: str) -> dict:
|
||||
finding = next(f for f in artifact["findings"] if span in f["spans"])
|
||||
return {
|
||||
"finding_id": finding["id"], "action": "delete", "original_exact": exact,
|
||||
"replacement": "", "rationale": "空元话语,删除后信息不变",
|
||||
"protected_invariants": [],
|
||||
}
|
||||
|
||||
|
||||
class RevisionContractTest(unittest.TestCase):
|
||||
def test_full_patch_chain_passes_gates(self):
|
||||
artifact = _artifact(CLEAN_TEXT)
|
||||
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
|
||||
record, audit = rev.run_revision(
|
||||
CLEAN_TEXT, artifact=artifact, patches=patches,
|
||||
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
|
||||
)
|
||||
self.assertTrue(record["hard_gate"]["pass"], record["hard_gate"])
|
||||
self.assertTrue(record["regression_gate"]["pass"], record["regression_gate"])
|
||||
self.assertEqual(record["candidate_text"], "门外已经下起了雨。")
|
||||
self.assertEqual(record["pairwise_choice"]["choice"], "candidate")
|
||||
self.assertIn("summary", audit)
|
||||
|
||||
def test_no_artifact_refuses_to_start(self):
|
||||
with self.assertRaisesRegex(rev.ReviseContractError, "没有诊断产物"):
|
||||
rev.run_revision(CLEAN_TEXT, artifact={}, patches=[{"x": 1}], task_contract=TASK)
|
||||
|
||||
def test_no_patches_refuses_to_start(self):
|
||||
artifact = _artifact(CLEAN_TEXT)
|
||||
with self.assertRaisesRegex(rev.ReviseContractError, "无事可做"):
|
||||
rev.run_revision(CLEAN_TEXT, artifact=artifact, patches=[], task_contract=TASK)
|
||||
|
||||
def test_missing_snapshot_downgrades_to_audit(self):
|
||||
artifact = _artifact(CLEAN_TEXT)
|
||||
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
|
||||
with self.assertRaises(DowngradedToAudit):
|
||||
rev.run_revision(
|
||||
CLEAN_TEXT, artifact=artifact, patches=patches,
|
||||
task_contract=TASK, fact_snapshot=None, pairwise=PAIRWISE,
|
||||
)
|
||||
|
||||
def test_patch_outside_author_scope_is_rejected(self):
|
||||
artifact = _artifact(CLEAN_TEXT)
|
||||
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
|
||||
with self.assertRaisesRegex(ValueError, "allowed_scope"):
|
||||
rev.run_revision(
|
||||
CLEAN_TEXT, artifact=artifact, patches=patches,
|
||||
task_contract={**TASK, "allowed_scope": ["f-other"]},
|
||||
fact_snapshot={"entities": []}, pairwise=PAIRWISE,
|
||||
)
|
||||
|
||||
def test_forged_deterministic_finding_is_rejected(self):
|
||||
artifact = _artifact(CLEAN_TEXT)
|
||||
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
|
||||
artifact["findings"][0]["rule_id"] = "l999"
|
||||
with self.assertRaisesRegex(ValueError, "active 规则"):
|
||||
rev.run_revision(
|
||||
CLEAN_TEXT, artifact=artifact, patches=patches,
|
||||
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
|
||||
)
|
||||
|
||||
def test_unarbitrated_finding_is_rejected(self):
|
||||
artifact = diag.run_diagnosis(CLEAN_TEXT, work_ref="synthetic:demo")
|
||||
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
|
||||
with self.assertRaisesRegex(ValueError, "未经过 repair 仲裁"):
|
||||
rev.run_revision(
|
||||
CLEAN_TEXT, artifact=artifact, patches=patches,
|
||||
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
|
||||
)
|
||||
|
||||
def test_fact_delta_blocks_new_number(self):
|
||||
artifact = _artifact(NUMBER_TEXT)
|
||||
finding = next(f for f in artifact["findings"] if "值得注意的是" in f["spans"])
|
||||
patches = [{
|
||||
"finding_id": finding["id"], "action": "local_rewrite",
|
||||
"original_exact": "值得注意的是,他付了三百两",
|
||||
"replacement": "他付了三百五十两",
|
||||
"rationale": "故意引入新数字,验证硬门拒收",
|
||||
"protected_invariants": [],
|
||||
}]
|
||||
record, _ = rev.run_revision(
|
||||
NUMBER_TEXT, artifact=artifact, patches=patches,
|
||||
task_contract=TASK, fact_snapshot={"entities": []}, pairwise=PAIRWISE,
|
||||
)
|
||||
self.assertFalse(record["hard_gate"]["pass"])
|
||||
self.assertTrue(record["hard_gate"]["checks"]["fact_delta"]["failures"])
|
||||
|
||||
def _run_cli(self, *, offline: bool, persist_return=None):
|
||||
artifact = _artifact(CLEAN_TEXT)
|
||||
patches = [_delete_patch(artifact, "值得注意的是", "值得注意的是,")]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
text_path = root / "text.txt"
|
||||
text_path.write_text(CLEAN_TEXT, encoding="utf-8")
|
||||
artifact_path = root / "artifact.json"
|
||||
artifact_path.write_text(json.dumps(artifact, ensure_ascii=False), encoding="utf-8")
|
||||
patches_path = root / "patches.json"
|
||||
patches_path.write_text(json.dumps(patches, ensure_ascii=False), encoding="utf-8")
|
||||
contract_path = root / "contract.json"
|
||||
contract_path.write_text(json.dumps(TASK), encoding="utf-8")
|
||||
snapshot_path = root / "snapshot.json"
|
||||
snapshot_path.write_text(json.dumps({"entities": []}), encoding="utf-8")
|
||||
pairwise_path = root / "pairwise.json"
|
||||
pairwise_path.write_text(json.dumps(PAIRWISE, ensure_ascii=False), encoding="utf-8")
|
||||
output = root / "report.json"
|
||||
with patch.object(rev, "persist_revision", return_value=persist_return) as persist, \
|
||||
patch.object(rev, "load_current_baseline", return_value=None):
|
||||
argv = [
|
||||
"--text-file", str(text_path), "--artifact", str(artifact_path),
|
||||
"--patches", str(patches_path), "--task-contract", str(contract_path),
|
||||
"--snapshot", str(snapshot_path), "--pairwise", str(pairwise_path),
|
||||
"--work-ref", "synthetic:demo", "--output", str(output),
|
||||
]
|
||||
if offline:
|
||||
argv.append("--offline")
|
||||
code = rev.main(argv)
|
||||
return code, persist.call_count, json.loads(output.read_text(encoding="utf-8"))
|
||||
|
||||
def test_cli_requires_artifact_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
text_path = root / "text.txt"
|
||||
text_path.write_text(CLEAN_TEXT, encoding="utf-8")
|
||||
patches_path = root / "patches.json"
|
||||
patches_path.write_text("[]", encoding="utf-8")
|
||||
contract_path = root / "contract.json"
|
||||
contract_path.write_text(json.dumps(TASK), encoding="utf-8")
|
||||
code = rev.main([
|
||||
"--text-file", str(text_path), "--artifact", str(root / "missing.json"),
|
||||
"--patches", str(patches_path), "--task-contract", str(contract_path),
|
||||
"--work-ref", "synthetic:demo", "--output", str(root / "r.json"), "--offline",
|
||||
])
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
def test_cli_offline_patch_chain_writes_report_without_db(self):
|
||||
code, calls, report = self._run_cli(offline=True)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(calls, 0)
|
||||
self.assertEqual(report["final"]["candidate_text"], "门外已经下起了雨。")
|
||||
|
||||
def test_cli_default_persists_revision(self):
|
||||
code, calls, _ = self._run_cli(offline=False, persist_return={"run_id": "rev-x"})
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(calls, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -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. 前情衔接与上一章末场景无缝;章末钩子按文风画像的钩子风格。
|
||||
|
||||
@ -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`。
|
||||
|
||||
|
||||
34
db/ddl/110-example-voice-baseline.sql
Normal file
34
db/ddl/110-example-voice-baseline.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- 声音账(voice baseline):技能 1「定基线」的库内载体。
|
||||
-- 一部作品一行一版本:新版本 append 一行并把旧行 superseded,历史版本保留审计。
|
||||
-- 声音账只读已确认正文与作者样张产出(专题-09 §5.1);脚本层强制账内每个
|
||||
-- 口癖/保护片段必须能在来源正文中找到(grounding 门),数据库层只锁结构与状态。
|
||||
-- ledger 同时兼容 deai.gates.protected_checks 的 voice_thin 形状
|
||||
-- (untouchable_verbal_tics / protected_spans),修订门禁直接消费。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS example_voice_baseline (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
work_ref VARCHAR(256) NOT NULL, -- 作品逻辑引用(与案例卡 work_ref 同域)
|
||||
version INTEGER NOT NULL, -- 作品内单调版本号(1 起)
|
||||
ledger JSONB NOT NULL, -- 声音账全文(结构由 establish-voice-baseline 脚本校验)
|
||||
ledger_sha256 VARCHAR(64) NOT NULL, -- ledger 规范化 JSON 的 sha256
|
||||
source_text_sha256 VARCHAR(64) NOT NULL, -- 定基线所依据的已确认正文 sha256(可多文拼接)
|
||||
reviewer VARCHAR(64) NOT NULL DEFAULT '', -- 人工确认人(基线必须人确认,专题-09 §5.1)
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
superseded BOOLEAN NOT NULL DEFAULT FALSE, -- 被更新版本取代;保留审计不删除
|
||||
creator VARCHAR(64) NOT NULL DEFAULT '1',
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL DEFAULT '1',
|
||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
CONSTRAINT uk_example_voice_baseline UNIQUE (tenant_id, work_ref, version),
|
||||
CONSTRAINT chk_example_voice_baseline_version CHECK (version >= 1),
|
||||
CONSTRAINT chk_example_voice_baseline_ledger_sha CHECK (ledger_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
CONSTRAINT chk_example_voice_baseline_source_sha CHECK (source_text_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
-- 未确认的基线不得冒充当前有效:只有带确认人的最新版本可被消费(脚本层取 latest 时同样强制)
|
||||
CONSTRAINT chk_example_voice_baseline_reviewer CHECK (superseded OR reviewer <> '')
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_example_voice_baseline_work
|
||||
ON example_voice_baseline(tenant_id, work_ref, version DESC) WHERE deleted = FALSE;
|
||||
CREATE OR REPLACE TRIGGER trg_example_voice_baseline_updated_at
|
||||
BEFORE UPDATE ON example_voice_baseline FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@ -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 脚本强制;修订门禁与前置预防机械消费 |
|
||||
|
||||
## 暂缓建表登记(主仓有、实验现阶段未建;需要时按原样加建)
|
||||
|
||||
|
||||
43
humanization/README.md
Normal file
43
humanization/README.md
Normal file
@ -0,0 +1,43 @@
|
||||
# humanization · 去 AI 味与人感体系(agent-example 先行验证副本)
|
||||
|
||||
本目录是去 AI 味与人感体系的资产层与执行骨架,初始拷贝自父仓 [`muse-deai/`](../../muse-deai/)(提交 `92a88e86`,一级能力),自拷贝之日起由 agent-example 自治管理与演进。
|
||||
|
||||
## 权威与演进规则
|
||||
|
||||
- **验证期本目录自治**:合同、规则、样例、骨架的改动直接在本仓演进,Git 历史留痕;父仓 muse-deai 同主题暂停更新,不产生两套并行版本。
|
||||
- **同步方向只有一条**:验证收敛、人感体系从 agent-example 升华进 Muse 时,把验证过的资产同步回父仓 muse-deai 并更新设计文档。升华之前不向父仓回填。
|
||||
- 设计 SoT(概念定义、五技能合同、验收口径)归属父仓 [`design-docs/专题-09-去AI味与人感体系设计.md`](../../design-docs/专题-09-去AI味与人感体系设计.md);本目录不重复定义概念,验证中发现合同级缺陷时向父仓提请修订。
|
||||
- 案例卡的正式载体是 `muse-example` 库(见 `.claude/skills/capture-ai-flavor-cases`);本目录 `cards/` 只存合成示例。`src/deai/cards.py` 保留早期离线 API 兼容层,但输出/读取已对齐 `ai-flavor-case-v1`,生产采集入口仍是 `capture_cases.py`。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
contracts/ 数据合同:案例/规则/样例/发现/patch/审计 + voice_baseline/prevention
|
||||
src/deai/ 执行骨架:规则装载与完整指纹、载体 scope/mask、五层诊断、基线画像、门禁、评测生命周期
|
||||
rules/ 规则库 v0(26 条 active,覆盖 regex/handler/density/model_judgment 四类触发器);active 必须配齐四类样例;其中 16 条为 2026-08-16 所有者决定跳过 holdout 激活,证据栏留痕。
|
||||
这 16 条的 holdout 是欠账:升回父仓或申报专题-09 §12 一级验收前必须补齐,或提请父仓修订 SoT。
|
||||
26 条超出专题-09 §10 一级「10–20 条」区间,验证期定位是机制覆盖先行,升华进 Muse 时对齐区间或提请修订
|
||||
samples/ 样例库(sf / snf / boundary / regression),候选规则也必须先有四类夹具
|
||||
cards/ 合成案例卡示例
|
||||
research/ 20 项开源研究机制追踪矩阵 + 中文轻量去重人感规则目录(研究候选,不等于 active)
|
||||
tests/ 合同负路测试:../.venv/bin/python -m unittest discover -s tests -v(在 humanization/ 下运行)
|
||||
eval/ 判别试跑与回归探针:../.venv/bin/python eval/run_eval.py
|
||||
config.yaml 模型角色分离配置(改写模型 ≠ 选择模型,代码强制)
|
||||
```
|
||||
|
||||
## 与五个技能 skill 的对应
|
||||
|
||||
| 技能 | skill | 消费本目录什么 |
|
||||
|---|---|---|
|
||||
| 1 定基线 | `establish-voice-baseline` | 产出声音账,供修订保护项与前置预防正样例 |
|
||||
| 2 前置预防 | `prevent-ai-flavor` | `rules/` active 规则的负约束 |
|
||||
| 3 诊断 | `diagnose-ai-flavor` | `src/deai/diagnose.py` + `rules/` |
|
||||
| 4 修订 | `revise-ai-flavor` | `patch.py` / `gates.py` / `pairwise.py` / `report.py` |
|
||||
| 5 挖掘 | `capture-ai-flavor-cases` | 案例卡 → `rules/` 候选(propose-rule 门) |
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
cd agent-example/humanization
|
||||
../.venv/bin/python -m unittest discover -s tests -v
|
||||
```
|
||||
32
humanization/cards/backfill-example.yaml
Normal file
32
humanization/cards/backfill-example.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
# 案例卡示例(默认 shadow)。这是用户自有/获授权文本的结构示例,不是 active 规则。
|
||||
cards:
|
||||
- schema_version: ai-flavor-case-v1
|
||||
id: card-backfill-l002-001
|
||||
card_type: ai_flavor_case
|
||||
state: shadow
|
||||
label: unclassified
|
||||
layer: lexical
|
||||
carrier: narration
|
||||
capture_mode: backfill
|
||||
excerpt: "值得注意的是,门外已经下起了雨。"
|
||||
context: "她把伞递过去。值得注意的是,门外已经下起了雨。两人谁也没说话。"
|
||||
source:
|
||||
kind: existing_work
|
||||
license: owned
|
||||
work_ref: work-demo-001
|
||||
source_sha256: "78ba8e4e31715f1d0e5d5432f6e5dcea3c00efd78850b5eb9da0e7c60c30a322"
|
||||
text_hash: "sha256:78ba8e4e31715f1d0e5d5432f6e5dcea3c00efd78850b5eb9da0e7c60c30a322"
|
||||
excerpt_sha256: "38a22f04c9914021d1bde59c3d554e4e6adb4a6252a4b170cf1a1d051c2e3249"
|
||||
excerpt_hash: "sha256:38a22f04c9914021d1bde59c3d554e4e6adb4a6252a4b170cf1a1d051c2e3249"
|
||||
location: "chapter-003:paragraph-12"
|
||||
observation:
|
||||
pattern_key: lexical.meta_disclaimer
|
||||
surface: "值得注意的是"
|
||||
diagnosis: "待作者确认是否只是信息提示,不能仅凭表面形式判为应修。"
|
||||
pattern: "无功能元话语位于叙述句开头"
|
||||
rationale: "待作者确认是否只是信息提示,不能仅凭表面形式判为应修。"
|
||||
function_check:
|
||||
- "是否承担转折或信息提示"
|
||||
- "是否为角色/场内文书声线"
|
||||
risk_if_changed: "删除可能削弱段落转折,改写可能引入未确认因果。"
|
||||
suggested_action: "保留 shadow,补充上下文后再标注。"
|
||||
12
humanization/config.yaml
Normal file
12
humanization/config.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# 运行配置:模型角色分离是一级合同要求(专题-09 §6 阶段 10),不是建议
|
||||
models:
|
||||
# 改写执行所在的模型族(Muse 生成管线 / 修订执行者)
|
||||
rewrite: claude
|
||||
# 成对选择模型必须与 rewrite 不同;pairwise.py 会强制校验,同名即视为阶段未执行
|
||||
selection: gpt-5.6-sol
|
||||
|
||||
# 诊断上下文窗口:命中 span 前后各取 N 字符,换行转空格(U0 契约问题 #3)
|
||||
context_window_chars: 12
|
||||
|
||||
# 修订循环上限(专题-09 §6 停止条件)
|
||||
max_rounds: 3
|
||||
39
humanization/contracts/audit_report.schema.json
Normal file
39
humanization/contracts/audit_report.schema.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"$comment": "审计报告合同(专题-09 §4.5)。禁止单一总分类字段:report.py 拒绝 human_score/真人率 等键。pairwise_choice 在 Audit 模式为 null。",
|
||||
"type": "object",
|
||||
"required": ["summary", "patches", "final"],
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"required": ["high_confidence_findings", "advisory_findings", "kept_by_design", "needs_author_decision"],
|
||||
"properties": {
|
||||
"high_confidence_findings": {"type": "array", "items": {"type": "string"}},
|
||||
"advisory_findings": {"type": "array", "items": {"type": "string"}},
|
||||
"kept_by_design": {"type": "array", "items": {"type": "object", "required": ["finding_id", "reason"], "properties": {"finding_id": {"type": "string"}, "reason": {"type": "string", "minLength": 2}}}},
|
||||
"needs_author_decision": {"type": "array", "items": {"type": "object", "required": ["finding_id", "question"], "properties": {"finding_id": {"type": "string"}, "question": {"type": "string", "minLength": 2}}}}
|
||||
}
|
||||
},
|
||||
"patches": {"type": "array"},
|
||||
"final": {
|
||||
"type": "object",
|
||||
"required": ["hard_gate_result", "voice_gate_result", "regression_gate_result", "pairwise_choice", "unresolved_risks"],
|
||||
"properties": {
|
||||
"candidate_text": {"type": "string"},
|
||||
"hard_gate_result": {"type": "object"},
|
||||
"voice_gate_result": {"type": "object"},
|
||||
"regression_gate_result": {"type": "object"},
|
||||
"pairwise_choice": {
|
||||
"$comment": "Audit 模式无成对选择,允许 null;Patch 模式必须是完整对象",
|
||||
"type": ["object", "null"],
|
||||
"required": ["choice", "rationale", "selection_model"],
|
||||
"properties": {
|
||||
"choice": {"type": "string", "enum": ["original", "candidate", "tie", "both_bad"]},
|
||||
"rationale": {"type": "string", "minLength": 2},
|
||||
"selection_model": {"type": "string", "minLength": 1}
|
||||
}
|
||||
},
|
||||
"unresolved_risks": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
humanization/contracts/case_card.schema.json
Normal file
59
humanization/contracts/case_card.schema.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"$comment": "AI 味案例卡合同(专题-09 §4.0 / §8.1)。案例卡是证据层 Shadow,不是故事实体卡,也不是已生效规则;字段与 capture_cases.validate_card 保持一致。",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"id",
|
||||
"card_type",
|
||||
"state",
|
||||
"label",
|
||||
"layer",
|
||||
"carrier",
|
||||
"capture_mode",
|
||||
"source",
|
||||
"observation"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "enum": ["ai-flavor-case-v1"]},
|
||||
"id": {"type": "string", "minLength": 2},
|
||||
"card_type": {"type": "string", "enum": ["ai_flavor_case"]},
|
||||
"state": {"type": "string", "enum": ["shadow", "canonical", "rejected", "archived"]},
|
||||
"label": {"type": "string", "enum": ["sf", "snf", "boundary", "regression", "unclassified"]},
|
||||
"layer": {"type": "string", "enum": ["mechanical", "lexical", "structural", "density", "semantic", "unknown"]},
|
||||
"carrier": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"]},
|
||||
"capture_mode": {"type": "string", "enum": ["backfill", "live_feedback", "synthetic", "review_import"]},
|
||||
"excerpt": {"type": "string"},
|
||||
"context": {"type": "string"},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"required": ["kind", "license", "source_sha256", "excerpt_sha256", "location"],
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["existing_work", "creation_feedback", "synthetic", "public_domain"]},
|
||||
"license": {"type": "string", "enum": ["owned", "licensed", "public_domain", "synthetic", "research_only", "unauthorized"]},
|
||||
"work_ref": {"type": "string"},
|
||||
"source_ref": {"type": "string"},
|
||||
"source_sha256": {"type": "string", "minLength": 64},
|
||||
"excerpt_sha256": {"type": "string", "minLength": 64},
|
||||
"location": {"type": ["object", "string"]},
|
||||
"surface_location": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"observation": {
|
||||
"type": "object",
|
||||
"required": ["pattern_key", "surface", "diagnosis", "function_check", "risk_if_changed", "suggested_action"],
|
||||
"properties": {
|
||||
"pattern_key": {"type": "string", "minLength": 2},
|
||||
"surface": {"type": "string", "minLength": 1},
|
||||
"diagnosis": {"type": "string", "minLength": 2},
|
||||
"pattern": {"type": "string"},
|
||||
"rationale": {"type": "string"},
|
||||
"function_check": {"type": "array", "minItems": 1, "items": {"type": "string"}},
|
||||
"risk_if_changed": {"type": "string", "minLength": 2},
|
||||
"suggested_action": {"type": "string", "minLength": 2}
|
||||
}
|
||||
},
|
||||
"feedback": {"type": "object"},
|
||||
"review": {"type": "object"},
|
||||
"rule_candidate_ids": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
22
humanization/contracts/finding.schema.json
Normal file
22
humanization/contracts/finding.schema.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$comment": "发现合同(专题-09 §4.2,含 U0 修正 #2 arbitration_note 与 #3 context_window 规格)。spans 为多点结构:密度/语义层的分布式命中逐点登记。",
|
||||
"type": "object",
|
||||
"required": ["id", "text_hash", "rule_id", "rule_version", "spans", "context_window", "layer", "evidence", "possible_function", "confidence", "decision_proposal"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"text_hash": {"type": "string", "minLength": 7},
|
||||
"rule_id": {"type": "string"},
|
||||
"rule_version": {"type": "integer"},
|
||||
"spans": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
||||
"context_window": {"type": "string", "$comment": "span 前后各 12 字符,换行转空格(config context_window_chars)"},
|
||||
"layer": {"type": "string", "enum": ["mechanical", "lexical", "structural", "density", "semantic"]},
|
||||
"evidence": {"type": "string", "minLength": 2},
|
||||
"possible_function": {"type": "string", "$comment": "none / unknown / 具名功能 / pending_arbitration"},
|
||||
"confidence": {"type": "string", "enum": ["high", "medium", "low", "candidate"]},
|
||||
"decision_proposal": {"type": "string", "enum": ["repair", "keep", "ask", "pending"]},
|
||||
"arbitration_note": {"type": "string"},
|
||||
"carrier": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "mixed", "unknown"]},
|
||||
"default_disposition": {"type": "string", "enum": ["blocking", "candidate", "advisory"]},
|
||||
"carve_out_candidates": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
13
humanization/contracts/patch.schema.json
Normal file
13
humanization/contracts/patch.schema.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"$comment": "修订项合同(专题-09 §4.3)。original_exact 必须覆盖对应发现的至少一个 span(允许紧邻标点的最小扩展,U0 修正 #6),且在当前文本中唯一匹配。",
|
||||
"type": "object",
|
||||
"required": ["finding_id", "action", "original_exact", "replacement", "rationale", "protected_invariants"],
|
||||
"properties": {
|
||||
"finding_id": {"type": "string", "minLength": 1},
|
||||
"action": {"type": "string", "enum": ["skip", "delete", "compress", "local_rewrite", "patch", "structural_proposal"]},
|
||||
"original_exact": {"type": "string", "minLength": 1},
|
||||
"replacement": {"type": "string"},
|
||||
"rationale": {"type": "string", "minLength": 2},
|
||||
"protected_invariants": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
15
humanization/contracts/prevention.schema.json
Normal file
15
humanization/contracts/prevention.schema.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"$comment": "生成前人感约束合同。结构化规则证据与 writer 投影分开,禁止把案例卡当正文素材。",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "work_ref", "built_from", "negative_constraints", "positive_samples", "protected_spans", "blacklist", "writer_constraints"],
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "enum": ["ai-flavor-prevention-v1", "ai-flavor-prevention-v2"]},
|
||||
"work_ref": {"type": "string", "minLength": 1},
|
||||
"built_from": {"type": "object"},
|
||||
"negative_constraints": {"type": "array"},
|
||||
"positive_samples": {"type": "array"},
|
||||
"protected_spans": {"type": "array", "items": {"type": "string"}},
|
||||
"blacklist": {"type": "array", "items": {"type": "string"}},
|
||||
"writer_constraints": {"type": "array", "items": {"type": "string", "minLength": 1}}
|
||||
}
|
||||
}
|
||||
44
humanization/contracts/rule.schema.json
Normal file
44
humanization/contracts/rule.schema.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"$comment": "规则合同(专题-09 §4.1)。active 规则必须配齐四类样例,由 load.py 强制,不在 schema 内表达。",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "layer", "carrier_scope", "trigger", "default_disposition", "fix_hint", "samples", "version", "status", "evidence"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 2},
|
||||
"name": {"type": "string", "minLength": 2},
|
||||
"layer": {"type": "string", "enum": ["mechanical", "lexical", "structural", "density", "semantic"]},
|
||||
"carrier_scope": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "all"]},
|
||||
"trigger": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["regex", "handler", "density", "model_judgment"]},
|
||||
"pattern": {"type": "string"},
|
||||
"criteria": {"type": "string"},
|
||||
"handler": {"type": "string", "enum": ["short_sentence_run", "repeated_sentence_start", "camera_action_list", "uniform_paragraph_length"]},
|
||||
"max_chars": {"type": "integer", "minimum": 1},
|
||||
"min_run": {"type": "integer", "minimum": 2},
|
||||
"window_chars": {"type": "integer", "minimum": 50},
|
||||
"min_hits": {"type": "integer", "minimum": 2},
|
||||
"tolerance": {"type": "integer", "minimum": 1, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"carve_out": {"type": "array", "items": {"type": "string"}},
|
||||
"default_disposition": {"type": "string", "enum": ["blocking", "candidate", "advisory"]},
|
||||
"function_check": {"type": "array", "items": {"type": "string"}},
|
||||
"fix_hint": {"type": "string", "minLength": 2},
|
||||
"samples": {
|
||||
"type": "object",
|
||||
"required": ["sf", "snf", "boundary", "regression"],
|
||||
"properties": {
|
||||
"sf": {"type": "array", "items": {"type": "string"}},
|
||||
"snf": {"type": "array", "items": {"type": "string"}},
|
||||
"boundary": {"type": "array", "items": {"type": "string"}},
|
||||
"regression": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"version": {"type": "integer"},
|
||||
"status": {"type": "string", "enum": ["candidate", "active", "deprecated"]},
|
||||
"evidence": {"type": "string", "minLength": 2},
|
||||
"case_card_ids": {"type": "array", "items": {"type": "string", "minLength": 2}}
|
||||
}
|
||||
}
|
||||
17
humanization/contracts/sample.schema.json
Normal file
17
humanization/contracts/sample.schema.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$comment": "样例合同(专题-09 §8.2 四类样例)。每条必须带标注理由:为什么该修/为什么不能改/边界在哪/改了会出什么事。可选 case_card_id 把 Canonical 样例追溯到案例卡。",
|
||||
"type": "object",
|
||||
"required": ["id", "type", "carrier", "source", "text", "note"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 2},
|
||||
"type": {"type": "string", "enum": ["sf", "snf", "boundary", "regression"]},
|
||||
"rules": {"type": "array", "items": {"type": "string"}},
|
||||
"carrier": {"type": "string", "enum": ["narration", "dialogue", "monologue", "in_text_carrier", "mixed"]},
|
||||
"source": {"type": "string", "enum": ["hand_written", "synthetic", "public_domain", "licensed"]},
|
||||
"text": {"type": "string", "minLength": 4},
|
||||
"note": {"type": "string", "minLength": 4},
|
||||
"case_card_id": {"type": "string", "minLength": 2},
|
||||
"source_ref": {"type": "string", "minLength": 1},
|
||||
"source_license": {"type": "string", "enum": ["owned", "licensed", "public_domain", "synthetic"]}
|
||||
}
|
||||
}
|
||||
19
humanization/contracts/voice_baseline.schema.json
Normal file
19
humanization/contracts/voice_baseline.schema.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"$comment": "声音账候选/Canonical 合同(专题-09 §4.4/§5.1)。统计不足必须显式 unknown,样张必须由技能 1 grounding。",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "work_ref", "narrator", "characters", "untouchable_verbal_tics", "protected_spans", "blacklist"],
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "enum": ["voice-baseline-v1"]},
|
||||
"work_ref": {"type": "string", "minLength": 1},
|
||||
"status": {"type": "string", "enum": ["candidate", "canonical"]},
|
||||
"narrator": {"type": "object"},
|
||||
"characters": {"type": "object"},
|
||||
"untouchable_verbal_tics": {"type": "object"},
|
||||
"protected_spans": {"type": "array", "items": {"type": "string"}},
|
||||
"blacklist": {"type": "array", "items": {"type": "string"}},
|
||||
"passing_samples": {"type": "array", "items": {"type": "string"}},
|
||||
"sources": {"type": "array"},
|
||||
"sampling": {"type": "object"},
|
||||
"unknown_fields": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
156
humanization/eval/run_eval.py
Normal file
156
humanization/eval/run_eval.py
Normal file
@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""U4 评测运行器(专题-09 §9 / §12 一级验收的自动化部分)。
|
||||
|
||||
两个探针:
|
||||
|
||||
1. 规则判别试跑:每条 regex 规则在自己的 SF 样例上必须命中(召回自查);
|
||||
在 SNF 样例上的命中是**预期内的表面碰撞**——SNF 的定义就是「相同表面形式
|
||||
但承担功能」,判别力在仲裁/carve_out(模型与人),不在触发器。
|
||||
model_judgment 规则无法自动跑,标「待模型判定」。
|
||||
|
||||
2. 回归陷阱门禁探针:对每条 regression 样例的「模拟坏改写」跑机械化探针——
|
||||
无授权重写探针(结构校验,patches=[])+ 不变量探针(数字/引文/口癖归因)。
|
||||
分类输出:机械可拦 / 语义级(门禁 unverified,需仲裁与盲评兜底)。
|
||||
|
||||
诚实约束:样例量是冷启动级别(n 小),本报告只出试跑数据与工具验证,
|
||||
不出「规则有效」结论(专题-09 §9.3:n=1 不下结论)。
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from deai import evaluation, gates, load # noqa: E402
|
||||
|
||||
|
||||
def parse_regression_sample(text: str):
|
||||
"""回归样例题干格式:原文:X / 模拟坏改写:Y。抽象描述类样例返回 None。"""
|
||||
m_orig = re.search(r"原文:(.*?)(?:\n|$)", text)
|
||||
m_bad = re.search(r"模拟坏改写:(.*?)(?:\n|$)", text, re.DOTALL)
|
||||
if not m_orig or not m_bad:
|
||||
return None
|
||||
return m_orig.group(1).strip(), m_bad.group(1).strip()
|
||||
|
||||
|
||||
def probe_rule_discrimination(rules: dict, samples: dict) -> list:
|
||||
rows = []
|
||||
for rule in sorted(rules.values(), key=lambda r: r["id"]):
|
||||
if rule["trigger"]["type"] == "model_judgment":
|
||||
rows.append({"rule": rule["id"], "kind": "model_judgment",
|
||||
"sf_hit": "-", "snf_surface": "-", "note": "待模型判定(一级人工/二级接入)"})
|
||||
continue
|
||||
report = evaluation.evaluate_rule_contract(rule, samples)
|
||||
sf_rows = [row for row in report["rows"] if row["kind"] == "sf"]
|
||||
snf_rows = [row for row in report["rows"] if row["kind"] == "snf"]
|
||||
sf_hit = sum(1 for row in sf_rows if row["surface_hit"])
|
||||
snf_surf = sum(1 for row in snf_rows if row["surface_hit"])
|
||||
rows.append({
|
||||
"rule": rule["id"], "kind": rule["trigger"]["type"],
|
||||
"sf_hit": f"{sf_hit}/{len(sf_rows)}",
|
||||
"snf_surface": f"{snf_surf}/{len(snf_rows)}",
|
||||
"note": "SF 召回正常" if sf_hit == len(sf_rows) else "SF 召回有缺口,规则或样例需修",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def probe_regression_traps(samples: dict) -> list:
|
||||
rows = []
|
||||
voice_tics = {"老周": ["我说小子"]}
|
||||
for sid, s in sorted(samples.items()):
|
||||
if s["type"] != "regression":
|
||||
continue
|
||||
parsed = parse_regression_sample(s["text"])
|
||||
if parsed is None:
|
||||
rows.append({"sample": sid, "mechanical": "—", "verdict": "抽象描述类,需人工评审"})
|
||||
continue
|
||||
original, bad = parsed
|
||||
caught = []
|
||||
# 探针 1:无授权重写——任何不经批准 patch 的整体改写都违反结构校验
|
||||
if gates.structural_verify(original, bad, [], {})["pass"] is False and bad != original:
|
||||
caught.append("结构校验(无授权重写)")
|
||||
# 探针 2:数字归因(无 patch 可归因 → 任何数字增减都红)
|
||||
if gates.number_attribution(original, bad, [])["pass"] is False:
|
||||
caught.append("数字归因")
|
||||
# 探针 3:引文归因
|
||||
if gates.quote_attribution(original, bad, [])["pass"] is False:
|
||||
caught.append("引文归因")
|
||||
# 探针 4:口癖保护(样例涉及老周口癖时)
|
||||
if "我说小子" in original and gates.protected_checks(original, bad, {"untouchable_verbal_tics": voice_tics})["pass"] is False:
|
||||
caught.append("口癖保护")
|
||||
rows.append({
|
||||
"sample": sid,
|
||||
"mechanical": "、".join(caught) if caught else "未拦",
|
||||
"verdict": "机械可拦" if caught else "语义级:门禁 unverified,需仲裁/盲评兜底",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# patch 形态伤害探针:把伤害放进「看似合法的 patch replacement」里,
|
||||
# 验证硬门在结构校验必然通过的情况下还能不能拦到实质伤害。
|
||||
# expected: caught = 机械应拦;semantic = 已知机械拦不住(门禁 unverified,仲裁/盲评兜底)
|
||||
PATCH_FORM_PROBES = [
|
||||
dict(sample="reg-004", desc="数字篡改(三百两→三百五十两)",
|
||||
original_exact="值三百两银子", replacement="值三百五十两银子", expected="caught"),
|
||||
dict(sample="reg-001", desc="新增日期(秋分的夜里)",
|
||||
original_exact="那时谁也不知道", replacement="那年秋分的夜里,谁也不知道", expected="caught"),
|
||||
dict(sample="reg-l001-01", desc="模态篡改(多半→都)",
|
||||
original_exact="研究表明,能进这种地方的修士,多半背景不凡。",
|
||||
replacement="能进这种地方的修士,背景都不凡。", expected="caught"),
|
||||
dict(sample="reg-m002-01", desc="replacement 续写新内容",
|
||||
original_exact="抱歉,我无法继续这个故事。",
|
||||
replacement="她裹紧衣领,走进了巷子深处。", expected="semantic"),
|
||||
]
|
||||
|
||||
|
||||
def probe_patch_form_damage() -> list:
|
||||
rows = []
|
||||
for probe in PATCH_FORM_PROBES:
|
||||
patches = [{"finding_id": "f1", "action": "local_rewrite",
|
||||
"original_exact": probe["original_exact"],
|
||||
"replacement": probe["replacement"],
|
||||
"rationale": "探针", "protected_invariants": []}]
|
||||
caught = []
|
||||
if gates.fact_delta(patches)["pass"] is False:
|
||||
caught.append("事实增量")
|
||||
# 数字归因在单 patch 场景等价于 fact_delta 的数字部分,补充跑一遍防漏
|
||||
original = "前文。" + probe["original_exact"] + "后文。"
|
||||
candidate = "前文。" + probe["replacement"] + "后文。"
|
||||
if gates.number_attribution(original, candidate, patches)["pass"] is False:
|
||||
caught.append("数字归因")
|
||||
if gates.quote_attribution(original, candidate, patches)["pass"] is False:
|
||||
caught.append("引文归因")
|
||||
if gates.temporal_fact_delta(patches)["pass"] is False:
|
||||
caught.append("时间锚点")
|
||||
if gates.modality_attribution(patches)["pass"] is False:
|
||||
caught.append("模态守恒")
|
||||
got = "caught" if caught else "semantic"
|
||||
rows.append({
|
||||
"sample": probe["sample"], "desc": probe["desc"],
|
||||
"caught": "、".join(caught) if caught else "未拦(语义级)",
|
||||
"match_expectation": got == probe["expected"],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
print("== 规则判别试跑 ==")
|
||||
for row in probe_rule_discrimination(rules, samples):
|
||||
print(f"{row['rule']:<8} {row['kind']:<15} SF命中 {row['sf_hit']:<6} "
|
||||
f"SNF表面碰撞 {row['snf_surface']:<6} {row['note']}")
|
||||
print()
|
||||
print("== 回归陷阱门禁探针(无授权重写形态) ==")
|
||||
for row in probe_regression_traps(samples):
|
||||
print(f"{row['sample']:<14} {row['verdict']:<28} 探针: {row['mechanical']}")
|
||||
print()
|
||||
print("== 回归陷阱门禁探针(patch 内伤害形态) ==")
|
||||
for row in probe_patch_form_damage():
|
||||
flag = "符合预期" if row["match_expectation"] else "!! 与预期不符"
|
||||
print(f"{row['sample']:<14} {row['desc']:<26} {row['caught']:<22} {flag}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
humanization/pyproject.toml
Normal file
14
humanization/pyproject.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "muse-deai"
|
||||
version = "0.1.0"
|
||||
description = "人感体系执行骨架:去 AI 味的合同校验、门禁与评测"
|
||||
requires-python = ">=3.10"
|
||||
# 内网 PyPI 不可达:只允许 PyYAML 一个外部依赖,新增依赖必须先更新专题-09 计划
|
||||
dependencies = ["PyYAML>=6.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@ -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 个项目的所有词表直接搬进生产规则库。
|
||||
143
humanization/research/20-project-skill-coverage.yaml
Normal file
143
humanization/research/20-project-skill-coverage.yaml
Normal file
@ -0,0 +1,143 @@
|
||||
schema_version: humanization-research-skill-coverage-v1
|
||||
source: ../../../.agents/knowledge/ai-writing-humanization-open-source-research.md
|
||||
scope: agent-example自治验证期;研究出处不等于效果证明
|
||||
projects:
|
||||
- id: humanizer
|
||||
mapped_capabilities: [voice_first_baseline, minimal_unique_patch]
|
||||
- id: stop-slop
|
||||
mapped_capabilities: [five_layer_detection]
|
||||
- id: Humanizer-zh
|
||||
mapped_capabilities: [preservation_and_modality]
|
||||
- id: inkos
|
||||
mapped_capabilities: [minimal_unique_patch, bounded_revision_and_original_wins, cross_model_pairwise]
|
||||
- id: oh-story-claudecode
|
||||
mapped_capabilities: [five_layer_detection, bounded_revision_and_original_wins]
|
||||
- id: no-ai-slop
|
||||
mapped_capabilities: [detect_edit_separation, pre_generation_guidance]
|
||||
- id: PaperSpine
|
||||
mapped_capabilities: [detect_edit_separation, bounded_revision_and_original_wins]
|
||||
- id: im-not-ai
|
||||
mapped_capabilities: [five_layer_detection, holdout_effect_evaluation]
|
||||
- id: avoid-ai-writing
|
||||
mapped_capabilities: [carrier_scope_and_mask, preservation_and_modality]
|
||||
- id: human-writing
|
||||
mapped_capabilities: [detect_edit_separation]
|
||||
- id: talk-normal
|
||||
mapped_capabilities: [pre_generation_guidance, holdout_effect_evaluation]
|
||||
- id: AIWriteX
|
||||
mapped_capabilities: [minimal_unique_patch]
|
||||
- id: humanize-text
|
||||
mapped_capabilities: [five_layer_detection, holdout_effect_evaluation]
|
||||
- id: shuorenhua
|
||||
mapped_capabilities: [carrier_scope_and_mask, sf_snf_boundary_regression]
|
||||
- id: academic-humanizer
|
||||
mapped_capabilities: [preservation_and_modality]
|
||||
- id: speak-human-tw
|
||||
mapped_capabilities: [sf_snf_boundary_regression, holdout_effect_evaluation]
|
||||
- id: AI_paper
|
||||
mapped_capabilities: [holdout_effect_evaluation]
|
||||
- id: Openwrite
|
||||
mapped_capabilities: [minimal_unique_patch]
|
||||
- id: unslop
|
||||
mapped_capabilities: [case_to_rule_lifecycle]
|
||||
- id: neuro-book
|
||||
mapped_capabilities: [five_layer_detection, case_to_rule_lifecycle, holdout_effect_evaluation]
|
||||
capabilities:
|
||||
- id: detect_edit_separation
|
||||
evidence_sources: [no-ai-slop, oh-story-claudecode, academic-humanizer]
|
||||
owner_skill: diagnose-ai-flavor
|
||||
implementation: humanization/src/deai/diagnose.py
|
||||
test: humanization/tests/test_contracts.py
|
||||
status: implemented
|
||||
- id: five_layer_detection
|
||||
evidence_sources: [neuro-book, oh-story-claudecode, avoid-ai-writing]
|
||||
owner_skill: diagnose-ai-flavor
|
||||
implementation: humanization/src/deai/diagnose.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: implemented
|
||||
note: regex/handler/density机械执行;semantic仍需外部模型或人工
|
||||
- id: carrier_scope_and_mask
|
||||
evidence_sources: [shuorenhua, avoid-ai-writing, neuro-book]
|
||||
owner_skill: diagnose-ai-flavor
|
||||
implementation: humanization/src/deai/carriers.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: implemented
|
||||
- id: voice_first_baseline
|
||||
evidence_sources: [humanizer, shuorenhua, inkos]
|
||||
owner_skill: establish-voice-baseline
|
||||
implementation: .claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: implemented
|
||||
note: 角色策略仍需planner/作者补充和确认
|
||||
- id: pre_generation_guidance
|
||||
evidence_sources: [no-ai-slop, neuro-book, humanizer]
|
||||
owner_skill: prevent-ai-flavor
|
||||
implementation: .claude/skills/prevent-ai-flavor/scripts/prevent_ai_flavor.py
|
||||
test: .claude/skills/assemble-context/scripts/test_assemble_writer_context.py
|
||||
status: implemented
|
||||
- id: sf_snf_boundary_regression
|
||||
evidence_sources: [speak-human-tw, shuorenhua, neuro-book]
|
||||
owner_skill: capture-ai-flavor-cases
|
||||
implementation: humanization/samples
|
||||
test: humanization/src/deai/evaluation.py
|
||||
status: implemented
|
||||
- id: minimal_unique_patch
|
||||
evidence_sources: [inkos, Openwrite, shuorenhua]
|
||||
owner_skill: revise-ai-flavor
|
||||
implementation: humanization/src/deai/patch.py
|
||||
test: humanization/tests/test_contracts.py
|
||||
status: implemented
|
||||
- id: preservation_and_modality
|
||||
evidence_sources: [humanizer, academic-humanizer, shuorenhua]
|
||||
owner_skill: revise-ai-flavor
|
||||
implementation: humanization/src/deai/gates.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: implemented
|
||||
note: POV/因果/伏笔仍需事实快照和语义detector
|
||||
- id: bounded_revision_and_original_wins
|
||||
evidence_sources: [inkos, oh-story-claudecode, neuro-book]
|
||||
owner_skill: revise-ai-flavor
|
||||
implementation: humanization/src/deai/pipeline.py
|
||||
test: .claude/skills/revise-ai-flavor/scripts/test_revise_ai_flavor.py
|
||||
status: implemented
|
||||
note: 当前以最多3轮合同、复扫和pairwise no_gain实现;跨轮最佳快照编排仍由上层负责
|
||||
- id: source_revalidation
|
||||
evidence_sources: [shuorenhua, neuro-book]
|
||||
owner_skill: capture-ai-flavor-cases
|
||||
implementation: .claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py
|
||||
test: .claude/skills/capture-ai-flavor-cases/scripts/test_capture_cases.py
|
||||
status: implemented
|
||||
- id: case_to_rule_lifecycle
|
||||
evidence_sources: [unslop, shuorenhua, neuro-book]
|
||||
owner_skill: capture-ai-flavor-cases
|
||||
implementation: .claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: implemented
|
||||
note: 状态写回数据库;规则文件激活需显式输出和人工审批
|
||||
- id: cross_model_pairwise
|
||||
evidence_sources: [inkos, neuro-book, oh-story-claudecode]
|
||||
owner_skill: revise-ai-flavor
|
||||
implementation: humanization/src/deai/pairwise.py
|
||||
test: humanization/tests/test_contracts.py
|
||||
status: implemented
|
||||
- id: holdout_effect_evaluation
|
||||
evidence_sources: [neuro-book, speak-human-tw, im-not-ai]
|
||||
owner_skill: capture-ai-flavor-cases
|
||||
implementation: humanization/src/deai/evaluation.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: partial
|
||||
note: 合同回放和holdout计数门已建;真实多作者多题材holdout尚未形成
|
||||
- id: model_drift_deprecation
|
||||
evidence_sources: [inkos, im-not-ai, neuro-book]
|
||||
owner_skill: capture-ai-flavor-cases
|
||||
implementation: humanization/src/deai/evaluation.py
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: partial
|
||||
note: 人工审批的deprecate输出已建;自动跨模型巡检尚未接入生产调度
|
||||
- id: independent_reader_blind_eval
|
||||
evidence_sources: [neuro-book, inkos, humanizer]
|
||||
owner_skill: capture-ai-flavor-cases
|
||||
implementation: humanization/eval
|
||||
test: humanization/tests/test_humanization_v2.py
|
||||
status: pending
|
||||
note: pairwise合同存在,但独立读者规模化盲评未完成
|
||||
30
humanization/rules/density/d001.yaml
Normal file
30
humanization/rules/density/d001.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
# 规则 d001:密度只提示分布异常,不逐词自动修改;2026-08-16 所有者决定激活(无 holdout)。
|
||||
id: d001
|
||||
name: 库存微动作过密
|
||||
layer: density
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: density
|
||||
pattern: 嘴角(?:微微|轻轻|悄然)?(?:上扬|勾起)|眼中闪过(?:一丝|一抹)?(?:光|精光|异彩)|眸光(?:微闪|深邃)|心脏(?:猛地|漏跳了一拍)
|
||||
window_chars: 500
|
||||
min_hits: 3
|
||||
carve_out:
|
||||
- 同一角色签名动作的有意回环
|
||||
- 情绪高潮的连续身体反应
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 多处动作是否分别承担不同因果
|
||||
- 是否为角色签名动作或意象回环
|
||||
fix_hint: 先判断分布功能;只处理无功能重复,不逐词同义替换
|
||||
samples:
|
||||
sf:
|
||||
- sf-d001-01
|
||||
snf:
|
||||
- snf-d001-01
|
||||
boundary:
|
||||
- b-d001-01
|
||||
regression:
|
||||
- reg-d001-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 20 项开源研究机制候选:neuro-book density 分层、humanizer/no-ai-slop 模式簇;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
31
humanization/rules/density/d002.yaml
Normal file
31
humanization/rules/density/d002.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
# 规则 d002(目录 D-01):元话语与过渡套语成簇才提示;单个常用词放行。
|
||||
id: d002
|
||||
name: 套话与模板词聚集
|
||||
layer: density
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: density
|
||||
pattern: 值得注意的是|值得一提的是|需要指出的是|更重要的是|不仅如此|归根结底|总而言之|综上所述|由此可见|不难发现|在某种程度上|从某种意义上|话说|且说|再说|单说
|
||||
window_chars: 300
|
||||
min_hits: 3
|
||||
carve_out:
|
||||
- 评书腔叙述者的有意口癖与转场(须声音账确认)
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 是否为叙述者声线的转场习惯
|
||||
- 套语之间是否仍有信息推进
|
||||
- 是否承担章节/场景切换的节奏功能
|
||||
fix_hint: 只处理无功能的成簇套话,保留信息句;不得逐项同义替换,不得批量删除后补连接词
|
||||
samples:
|
||||
sf:
|
||||
- sf-d002-01
|
||||
snf:
|
||||
- snf-d002-01
|
||||
boundary:
|
||||
- b-d002-01
|
||||
regression:
|
||||
- reg-d002-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 D-01 机制候选(密度入口,与 l001/l002 词法入口分列);窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
32
humanization/rules/density/d003.yaml
Normal file
32
humanization/rules/density/d003.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
# 规则 d003(目录 D-07):连接词与过渡词成簇才提示;具体转折与作者习惯保留。
|
||||
id: d003
|
||||
name: 连接词与过渡词过密
|
||||
layer: density
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: density
|
||||
pattern: 然而|此外|进一步(?:说|地|的)?|在此基础上|与此同时|另一方面|综合来看
|
||||
window_chars: 400
|
||||
min_hits: 4
|
||||
carve_out:
|
||||
- 论辩与议事场景的层层推进
|
||||
- 作者基线确认的承接习惯
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 每个连接词是否承担真实转折或承接
|
||||
- 是否为议事/论辩文体的合理密度
|
||||
- 删除后逻辑关系是否改变
|
||||
fix_hint: 只删不承担逻辑关系的连接词;承担转折的不得删,删词不得改变因果与模态
|
||||
samples:
|
||||
sf:
|
||||
- sf-d003-01
|
||||
snf:
|
||||
- snf-d003-01
|
||||
boundary:
|
||||
- b-d003-01
|
||||
regression:
|
||||
- reg-d003-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 D-07 机制候选;窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
33
humanization/rules/density/d004.yaml
Normal file
33
humanization/rules/density/d004.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
# 规则 d004(目录 D-04):比喻密度达到分布阈值才提示;诗性与主题意象进豁免。
|
||||
id: d004
|
||||
name: 比喻密度过高
|
||||
layer: density
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: density
|
||||
pattern: 仿佛|宛如|如同|好似|恰似|像是|像在|像一(?:个|只|条|把|面|团|块|道|片|阵)|像[^。,!?]{1,12}(?:一样|一般|似的)
|
||||
window_chars: 600
|
||||
min_hits: 5
|
||||
carve_out:
|
||||
- 梦境与幻觉段落
|
||||
- 诗性叙述与童话文体
|
||||
- 主题意象回环(须声音账确认)
|
||||
- 角色语言
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 比喻是否为主题意象的回环
|
||||
- 是否为梦境、幻觉等主观段落
|
||||
- 多个比喻是否分别承担不同的感知
|
||||
fix_hint: 只删无功能的库存比喻;意象链与承担感知的比喻必须保留,不得换喻体
|
||||
samples:
|
||||
sf:
|
||||
- sf-d004-01
|
||||
snf:
|
||||
- snf-d004-01
|
||||
boundary:
|
||||
- b-d004-01
|
||||
regression:
|
||||
- reg-d004-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 D-04 机制候选;分布阈值为冷启动默认;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
31
humanization/rules/density/d005.yaml
Normal file
31
humanization/rules/density/d005.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
# 规则 d005(目录 L-14 的密度入口):单个正常词放行,同段或近窗口过密才提示。
|
||||
id: d005
|
||||
name: 高频抽象词聚集
|
||||
layer: density
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: density
|
||||
pattern: 关键|核心|持续|全面|有效|提升|推动|赋能|优化|深化|强化|落实|推进
|
||||
window_chars: 400
|
||||
min_hits: 6
|
||||
carve_out:
|
||||
- 议事、论辩与战前部署场景
|
||||
- 角色对白(官员、谋士型人物的语域)
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 抽象词是否分别指向具体的事中物
|
||||
- 是否为议事文体的合理语域
|
||||
- 是否有具体细节穿插支撑
|
||||
fix_hint: 把过密的抽象词落到具体的人、物、动作上;不得整段重写,不得新增原文没有的细节
|
||||
samples:
|
||||
sf:
|
||||
- sf-d005-01
|
||||
snf:
|
||||
- snf-d005-01
|
||||
boundary:
|
||||
- b-d005-01
|
||||
regression:
|
||||
- reg-d005-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 L-14 的密度入口(与词法命中分列);窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
32
humanization/rules/density/d006.yaml
Normal file
32
humanization/rules/density/d006.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
# 规则 d006(目录 D-08 先行子集):先落转场填充短语的重复;任意 n-gram 与跨章分布待后续。
|
||||
id: d006
|
||||
name: 转场填充短语重复
|
||||
layer: density
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: density
|
||||
pattern: 与此同时|就在此时|就在这时|在这一刻|在这一瞬间|下一刻|下一秒|几乎(?:是)?同时|几乎(?:是)?同一时间
|
||||
window_chars: 600
|
||||
min_hits: 3
|
||||
carve_out:
|
||||
- 真实的多线并进蒙太奇
|
||||
- 伏笔回环与有意复读
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 重复短语是否真的标记多线同时
|
||||
- 是否为伏笔回环的有意复读
|
||||
- 删除后时间关系是否改变
|
||||
fix_hint: 换用具体的并置写法或直接并段;不得删除承担同时性的时间锚点
|
||||
samples:
|
||||
sf:
|
||||
- sf-d006-01
|
||||
snf:
|
||||
- snf-d006-01
|
||||
boundary:
|
||||
- b-d006-01
|
||||
regression:
|
||||
- reg-d006-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 D-08 的窗口内转场填充子集;跨句跨章 n-gram 分布未覆盖;窗口与阈值为冷启动默认,待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
28
humanization/rules/lexical/l001.yaml
Normal file
28
humanization/rules/lexical/l001.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
# 规则 l001(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: l001
|
||||
name: 无源权威套语
|
||||
layer: lexical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 研究表明|科学家(们)?发现|据统计|有研究显示|实验证明
|
||||
carve_out:
|
||||
- 场内文书(公文/新闻/公告)
|
||||
- 角色对白(须仲裁其话语策略)
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否为角色话语策略(学者型人物)
|
||||
- 是否场内文书
|
||||
fix_hint: 删除套语保留信息;无信息则整句删除
|
||||
samples:
|
||||
sf:
|
||||
- sf-l001-01
|
||||
snf:
|
||||
- snf-l001-01
|
||||
boundary:
|
||||
- b-l001-01
|
||||
regression:
|
||||
- reg-l001-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:stop-slop/no-ai-slop 元话语类目;U0 演练验证命中
|
||||
29
humanization/rules/lexical/l002.yaml
Normal file
29
humanization/rules/lexical/l002.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
# 规则 l002(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: l002
|
||||
name: 空洞元话语
|
||||
layer: lexical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 值得注意的是|更值得一提的是|不言而喻|众所周知|换句话说
|
||||
carve_out:
|
||||
- 评书腔叙述者的有意口癖
|
||||
- 角色对白
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否叙述者声线特征
|
||||
- 是否控制信息节奏
|
||||
fix_hint: 直接删除,后接内容不受影响即安全
|
||||
samples:
|
||||
sf:
|
||||
- sf-l002-01
|
||||
snf:
|
||||
- snf-l002-01
|
||||
- snf-l002-02
|
||||
boundary:
|
||||
- b-l002-01
|
||||
regression:
|
||||
- reg-l002-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:stop-slop 元话语类目;U0 演练验证命中
|
||||
27
humanization/rules/lexical/l003.yaml
Normal file
27
humanization/rules/lexical/l003.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
# 规则 l003(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: l003
|
||||
name: 库存微表情
|
||||
layer: lexical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 嘴角(微微|轻轻)?(上扬|勾起)|眼中闪过(一丝|一抹)?(光|精光|异彩)|心脏(猛地|漏跳了一拍)|眸光(微闪|深邃)
|
||||
carve_out:
|
||||
- 角色签名动作的有意复用(须声音账确认)
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否人物签名动作
|
||||
- 是否节奏锚点
|
||||
fix_hint: 换用该场景独有的具体动作,或删除;不得新增原文没有的细节
|
||||
samples:
|
||||
sf:
|
||||
- sf-l003-01
|
||||
snf:
|
||||
- snf-l003-01
|
||||
boundary:
|
||||
- b-l003-01
|
||||
regression:
|
||||
- reg-005
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:humanizer 库存动作反例;U0 演练验证命中
|
||||
28
humanization/rules/lexical/l004.yaml
Normal file
28
humanization/rules/lexical/l004.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
# 规则 l004(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: l004
|
||||
name: 三段式排比套句
|
||||
layer: lexical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 无论是.{1,24}[,,]还是.{1,24}[,,].{0,4}(还是|都)
|
||||
carve_out:
|
||||
- 故意排比的修辞高潮
|
||||
- 对白
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否节奏高潮的有意排比
|
||||
- 是否章节钩子
|
||||
fix_hint: 压缩为单一陈述;保留信息量最强的那一项
|
||||
samples:
|
||||
sf:
|
||||
- sf-l004-01
|
||||
snf:
|
||||
- snf-l004-01
|
||||
boundary:
|
||||
- b-s001-01
|
||||
regression:
|
||||
- reg-l004-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:oh-story 中文模式库排比类;U0 演练验证命中
|
||||
30
humanization/rules/lexical/l005.yaml
Normal file
30
humanization/rules/lexical/l005.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
# 规则 l005(目录 L-06 残余族):l001 已 active 的词族不动,扩展族作为新候选走生命周期。
|
||||
id: l005
|
||||
name: 无源权威套语(扩展族)
|
||||
layer: lexical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 专家指出|业内人士(?:认为|表示|称)|大家都知道|数据显示|权威人士(?:表示|称)|事实证明|有专家(?:称|表示)
|
||||
carve_out:
|
||||
- 场内文书(公文/新闻/公告/榜文)
|
||||
- 角色对白(须仲裁其话语策略)
|
||||
- 有真实来源可查证的引用
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否为角色话语策略(学者型、行会型人物)
|
||||
- 是否场内文书
|
||||
- 是否确有真实来源(有来源保留来源,不得补造来源)
|
||||
fix_hint: 删除套语保留信息;无信息则整句删除;禁止为消除套语而编造来源
|
||||
samples:
|
||||
sf:
|
||||
- sf-l005-01
|
||||
snf:
|
||||
- snf-l005-01
|
||||
boundary:
|
||||
- b-l005-01
|
||||
regression:
|
||||
- reg-l005-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 L-06 中 l001 未覆盖的权威包装族;与 l001 同族分列,待 holdout 后评估合并;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
30
humanization/rules/lexical/l006.yaml
Normal file
30
humanization/rules/lexical/l006.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
# 规则 l006(目录 L-15):商务黑话、调试腔、爆款腔归为同一语域姿态族;技术对象与角色身份语境保留。
|
||||
id: l006
|
||||
name: 领域黑话姿态化
|
||||
layer: lexical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 闭环|抓手|兜底|落盘|收口|避坑|硬核|颗粒度|组合拳|底层逻辑|护城河|对标
|
||||
carve_out:
|
||||
- 技术对象文本(修真工业流、机关术等设定内的术语)
|
||||
- 角色身份语境(匠师、账房、谋士型人物的职业腔)
|
||||
- 角色对白
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否为世界观内的设定术语
|
||||
- 是否为角色职业腔(声线特征)
|
||||
- 是否场内文书
|
||||
fix_hint: 换用世界观内的对应说法;属于角色职业腔或设定术语的保留,不得替换为叙述者通用词
|
||||
samples:
|
||||
sf:
|
||||
- sf-l006-01
|
||||
snf:
|
||||
- snf-l006-01
|
||||
boundary:
|
||||
- b-l006-01
|
||||
regression:
|
||||
- reg-l006-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 L-15 机制候选(语域姿态族);2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
27
humanization/rules/mechanical/m001.yaml
Normal file
27
humanization/rules/mechanical/m001.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
# 规则 m001(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: m001
|
||||
name: 占位符残留
|
||||
layer: mechanical
|
||||
carrier_scope: all
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: \[(占位|待填|待补充)[^\]]*\]|TODO|FIXME|(待补)|\{\{[^}]+\}\}
|
||||
carve_out:
|
||||
- 引文内容
|
||||
- 代码
|
||||
- 场内文书中的括注
|
||||
default_disposition: blocking
|
||||
function_check: []
|
||||
fix_hint: 确定性删除;若占位承载内容承诺,转 ask 并留占位说明,不编造填充
|
||||
samples:
|
||||
sf:
|
||||
- sf-m001-01
|
||||
snf:
|
||||
- snf-m001-01
|
||||
boundary:
|
||||
- b-m001-01
|
||||
regression:
|
||||
- reg-003
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:机械破损类各开源仓库一致认可(avoid-ai-writing/oh-story);U0 演练验证命中
|
||||
26
humanization/rules/mechanical/m002.yaml
Normal file
26
humanization/rules/mechanical/m002.yaml
Normal file
@ -0,0 +1,26 @@
|
||||
# 规则 m002(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: m002
|
||||
name: 模型自指与拒绝残留
|
||||
layer: mechanical
|
||||
carrier_scope: all
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 作为\s*(一名)?\s*(AI|人工智能|语言模型)|我无法(为您|继续|提供)|作为一个助手
|
||||
carve_out:
|
||||
- 角色对白与世界观设定内的自称
|
||||
- 引文
|
||||
default_disposition: blocking
|
||||
function_check: []
|
||||
fix_hint: 确定性删除或重新生成该段
|
||||
samples:
|
||||
sf:
|
||||
- sf-m002-01
|
||||
snf:
|
||||
- snf-m002-01
|
||||
boundary:
|
||||
- b-m002-01
|
||||
regression:
|
||||
- reg-m002-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:机械破损类;neuro-book llmlint 同类规则
|
||||
26
humanization/rules/mechanical/m003.yaml
Normal file
26
humanization/rules/mechanical/m003.yaml
Normal file
@ -0,0 +1,26 @@
|
||||
# 规则 m003(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: m003
|
||||
name: 工程词泄漏
|
||||
layer: mechanical
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 字数[::]\s*\d+|更新时间[::]|作者(有话说|注)[::]|本章说
|
||||
carve_out:
|
||||
- 场内文书
|
||||
- 作中作(角色正在写的文稿)
|
||||
default_disposition: blocking
|
||||
function_check: []
|
||||
fix_hint: 确定性删除;若属场内文书内容则保留
|
||||
samples:
|
||||
sf:
|
||||
- sf-m003-01
|
||||
snf:
|
||||
- snf-m003-01
|
||||
boundary:
|
||||
- b-m003-01
|
||||
regression:
|
||||
- reg-m003-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:机械破损类;U0 演练验证命中
|
||||
27
humanization/rules/mechanical/m004.yaml
Normal file
27
humanization/rules/mechanical/m004.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
# 规则 m004(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: m004
|
||||
name: 格式损坏
|
||||
layer: mechanical
|
||||
carrier_scope: all
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: \*\*[^*]+\*\*|^#{1,6}\s|```
|
||||
carve_out:
|
||||
- 代码
|
||||
- 场内屏幕内容以符号为剧情要素
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 该格式符号是否是场内内容的一部分
|
||||
fix_hint: 去除格式符号保留内容;不确定是否剧情要素时 ask
|
||||
samples:
|
||||
sf:
|
||||
- sf-m004-01
|
||||
snf:
|
||||
- snf-m004-01
|
||||
boundary:
|
||||
- b-m004-01
|
||||
regression:
|
||||
- reg-m004-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:机械破损类;U0 演练验证命中
|
||||
32
humanization/rules/semantic/sem001.yaml
Normal file
32
humanization/rules/semantic/sem001.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
# 规则 sem001(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: sem001
|
||||
name: 段尾空泛升华
|
||||
layer: semantic
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: model_judgment
|
||||
criteria: 段落末句脱离具体动作与场景,转向命运、因果、岁月类抽象评注,且不承担钩子或伏笔功能
|
||||
carve_out:
|
||||
- 章末钩子
|
||||
- 伏笔预告
|
||||
- 主题回声的有意重复
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 是否章节钩子
|
||||
- 是否伏笔预告
|
||||
- 是否主题回声
|
||||
fix_hint: 删除升华句,段落以具体动作收束;不新增细节
|
||||
samples:
|
||||
sf:
|
||||
- sf-sem001-01
|
||||
snf:
|
||||
- snf-sem001-01
|
||||
boundary:
|
||||
- b-sem001-01
|
||||
regression:
|
||||
- reg-001
|
||||
- reg-002
|
||||
- reg-003
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:oh-story/neuro-book 语义层类目;U0 演练人工判定
|
||||
30
humanization/rules/semantic/sem002.yaml
Normal file
30
humanization/rules/semantic/sem002.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
# 规则 sem002(目录 N-05):首条 dialogue scope 规则;样例覆盖授课/播报/审讯类 SNF 场景。
|
||||
id: sem002
|
||||
name: 对白变成说明书
|
||||
layer: semantic
|
||||
carrier_scope: dialogue
|
||||
trigger:
|
||||
type: model_judgment
|
||||
criteria: 角色台词只负责倾倒设定、重复读者已知信息或代作者解释主题;台词没有角色自身的冲突目的,不推进角色在场景里的目标,也不因说话对象的反应而变化
|
||||
carve_out:
|
||||
- 讲解者型角色的职业行为(授课、审讯、系统播报、宣旨)
|
||||
- 权力压制场景中以讲解为压制手段的对白
|
||||
- 场内文书与作中作
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 说话者是否有场景内的动机(传授、警告、拖延、试探)
|
||||
- 讲解是否同时暴露人物关系或冲突
|
||||
- 设定信息是否后文依赖(删除会断链)
|
||||
fix_hint: 把设定拆进动作与冲突,或移入叙述;不得整段删除后文依赖的设定信息
|
||||
samples:
|
||||
sf:
|
||||
- sf-sem002-01
|
||||
snf:
|
||||
- snf-sem002-01
|
||||
boundary:
|
||||
- b-sem002-01
|
||||
regression:
|
||||
- reg-sem002-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 N-05 机制候选;语义层依赖外部 detector/人工产出 finding;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
30
humanization/rules/semantic/sem003.yaml
Normal file
30
humanization/rules/semantic/sem003.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
# 规则 sem003(目录 N-03):情绪告知是否有承载;低强度与特定声线直写放行。
|
||||
id: sem003
|
||||
name: 情绪告知代替情绪承载
|
||||
layer: semantic
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: model_judgment
|
||||
criteria: 直接命名情绪(他很愤怒、她感到复杂、气氛十分紧张)而句子及周边没有动作、选择、对话或感知作为承载;低强度情绪与特定叙述声音的直写除外
|
||||
carve_out:
|
||||
- 低强度情绪的直接命名(他很高兴)
|
||||
- 特定叙述声音的直写习惯(须声音账确认)
|
||||
- 快节奏动作场景的情绪速记
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 情绪强度是否低到直写即可
|
||||
- 是否为叙述声线的直写习惯
|
||||
- 周边是否已有动作或感知承载,命名只是收束
|
||||
fix_hint: 把直写改为可感知的动作或感知;不得为承载新增原文没有的细节与强度
|
||||
samples:
|
||||
sf:
|
||||
- sf-sem003-01
|
||||
snf:
|
||||
- snf-sem003-01
|
||||
boundary:
|
||||
- b-sem003-01
|
||||
regression:
|
||||
- reg-sem003-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 N-03 机制候选;语义层依赖外部 detector/人工产出 finding;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
28
humanization/rules/structural/s001.yaml
Normal file
28
humanization/rules/structural/s001.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
# 规则 s001(合同:专题-09 §4.1;装载校验:deai.load)
|
||||
id: s001
|
||||
name: 段落同构
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: model_judgment
|
||||
criteria: 连续三段及以上呈现相同模板(首句点题 + 中间展开 + 尾句升华),且同构不服务节奏意图
|
||||
carve_out:
|
||||
- 战斗动作链
|
||||
- 刻意复读的修辞(弹幕复读、评书贯口)
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 是否节奏意图
|
||||
- 是否类型惯例
|
||||
fix_hint: 打散其中一到两段的模板,保留信息;禁止整组重写
|
||||
samples:
|
||||
sf:
|
||||
- sf-s001-01
|
||||
snf:
|
||||
- snf-s001-01
|
||||
boundary:
|
||||
- b-s001-01
|
||||
regression:
|
||||
- reg-s001-01
|
||||
version: 1
|
||||
status: active
|
||||
evidence: 人工评审:neuro-book handler 层结构检测;U0 演练人工判定
|
||||
31
humanization/rules/structural/s002.yaml
Normal file
31
humanization/rules/structural/s002.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
# 规则 s002:来自 neuro-book/oh-story 的 handler 分层机制;2026-08-16 所有者决定激活(无 holdout)。
|
||||
id: s002
|
||||
name: 连续碎句模板化
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: handler
|
||||
handler: short_sentence_run
|
||||
max_chars: 8
|
||||
min_run: 4
|
||||
carve_out:
|
||||
- 战斗高潮的有意短句
|
||||
- 惊恐、窒息或意识流节奏
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 短句是否承担节奏加速或主观感知
|
||||
- 是否出现破格句打断模板
|
||||
fix_hint: 只合并或展开其中一到两句;不得把整段改成长句
|
||||
samples:
|
||||
sf:
|
||||
- sf-s002-01
|
||||
snf:
|
||||
- snf-s002-01
|
||||
boundary:
|
||||
- b-s002-01
|
||||
regression:
|
||||
- reg-s002-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 20 项开源研究机制候选:neuro-book handler 分层、oh-story 退化检测;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
33
humanization/rules/structural/s003.yaml
Normal file
33
humanization/rules/structural/s003.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
# 规则 s003(目录 P-04):handler=repeated_sentence_start 已在 diagnose.py 就绪,本文件补齐规则合同。
|
||||
id: s003
|
||||
name: 句首或前缀重复
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: handler
|
||||
handler: repeated_sentence_start
|
||||
min_run: 3
|
||||
max_chars: 4
|
||||
carve_out:
|
||||
- 故意排比的修辞高潮
|
||||
- 咒语、口号的有意重复
|
||||
- 角色复读
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 是否故意排比或情绪递进
|
||||
- 是否咒语、口号的回环
|
||||
- 同句首各句是否分别承担新信息
|
||||
fix_hint: 只改写其中一到两句的句首;不得整组重写,不得为换句首新增细节
|
||||
samples:
|
||||
sf:
|
||||
- sf-s003-01
|
||||
snf:
|
||||
- snf-s003-01
|
||||
boundary:
|
||||
- b-s003-01
|
||||
regression:
|
||||
- reg-s003-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 P-04 机制候选(多项目句首重复检测同族);2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
31
humanization/rules/structural/s004.yaml
Normal file
31
humanization/rules/structural/s004.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
# 规则 s004(目录 S-01/S-02):二元对比骨架与否定式列举合并为同一规则族。
|
||||
id: s004
|
||||
name: 二元对比与否定列举
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 不是.{1,16}[,,]而是|不只是.{1,16}[,,]更是|问题不在.{1,16}[,,]而在|不在于.{1,16}[,,]而在于|不是.{1,16}[,,]不是.{1,16}[,,]也不是
|
||||
carve_out:
|
||||
- 定义术语
|
||||
- 论证核心与真实辩驳
|
||||
- 角色冲突中的有意揭示
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 对比项是否承担真实辩驳或论证核心
|
||||
- 是否在定义术语
|
||||
- 顿悟揭示是否服务人物弧光
|
||||
fix_hint: 保留信息,改写其中一处句式即可;不得把对比双方的信息删掉
|
||||
samples:
|
||||
sf:
|
||||
- sf-s004-01
|
||||
snf:
|
||||
- snf-s004-01
|
||||
boundary:
|
||||
- b-s004-01
|
||||
regression:
|
||||
- reg-s004-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 S-01/S-02 机制候选(not X but Y 中文同构族);2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
33
humanization/rules/structural/s005.yaml
Normal file
33
humanization/rules/structural/s005.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
# 规则 s005(目录 P-11):handler=camera_action_list;动作词表在 trigger.pattern,结构判定在 diagnose.py。
|
||||
id: s005
|
||||
name: 摄像头式动作清单
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: handler
|
||||
handler: camera_action_list
|
||||
max_chars: 6
|
||||
min_run: 4
|
||||
pattern: (?:走|跑|跳|转|抬|低|点|摇|回|伸|挥|握|松|放|拿|推|拉|开|关|拔|坐|站|起|看|望|闭|睁|吸|踱|迈)(?:过去|过来|起来|下去|上去|身|头|眼|手|刀|门|住|开)|转身|抬头|低头|点头|摇头|回头|起身|坐下|站定|伸手|抬手|挥手|一挥|握紧|松开|放下|拿起|拔出|拔刀|推开|拉开|看向|望去|闭眼|睁眼|深吸|吐气|走出|走进|迈步|侧身|格挡|闪避|劈下|劈出|斩出|斩落|挡下|后撤
|
||||
carve_out:
|
||||
- 战斗动作链(承担战术或空间功能,须人工复核)
|
||||
- 有意的镜头调度节奏
|
||||
- 角色对白
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 动作链是否承担战术或空间功能
|
||||
- 步骤中是否有后续情节依赖的必要动作
|
||||
- 是否穿插了选择、因果或情绪变化
|
||||
fix_hint: 只删无功能的中间步骤;必要动作与情绪锚点必须保留;战斗场景转人工
|
||||
samples:
|
||||
sf:
|
||||
- sf-s005-01
|
||||
snf:
|
||||
- snf-s005-01
|
||||
boundary:
|
||||
- b-s005-01
|
||||
regression:
|
||||
- reg-s005-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 P-11 机制候选;动作词表为冷启动子集;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
29
humanization/rules/structural/s006.yaml
Normal file
29
humanization/rules/structural/s006.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
# 规则 s006(目录 S-11):句末追加的解释分句若无新事实即提示;提供必要因果的保留。
|
||||
id: s006
|
||||
name: 表层分析尾巴
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: regex
|
||||
pattern: 这(?:体现|反映|说明|彰显|意味着|确保)(?:了|出)|从而(?:彰显|体现|确保|实现)了?|进而(?:彰显|体现)|充分(?:说明|体现|彰显|展现)了
|
||||
carve_out:
|
||||
- 承担必要因果或约束的解释
|
||||
- 场内文书的结论句
|
||||
default_disposition: candidate
|
||||
function_check:
|
||||
- 解释分句是否提供必要因果或约束
|
||||
- 删除后前后逻辑是否断链
|
||||
- 是否为议事文体的结论收束
|
||||
fix_hint: 删除无新事实的解释分句;不得连同前文的事实前提一起删
|
||||
samples:
|
||||
sf:
|
||||
- sf-s006-01
|
||||
snf:
|
||||
- snf-s006-01
|
||||
boundary:
|
||||
- b-s006-01
|
||||
regression:
|
||||
- reg-s006-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 S-11 机制候选;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
33
humanization/rules/structural/s007.yaml
Normal file
33
humanization/rules/structural/s007.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
# 规则 s007(目录 P-05 先行子集):handler=uniform_paragraph_length 只量化段长分布。
|
||||
# 目录要求以作品/场景基线定阈值;tolerance 是冷启动默认值,不是固定长短句比例目标。
|
||||
id: s007
|
||||
name: 段长过度均匀
|
||||
layer: structural
|
||||
carrier_scope: narration
|
||||
trigger:
|
||||
type: handler
|
||||
handler: uniform_paragraph_length
|
||||
min_run: 4
|
||||
tolerance: 15
|
||||
carve_out:
|
||||
- 有意的排比与骈体铺陈
|
||||
- 合唱、清单与仪式文本
|
||||
- 系统面板与场内文书
|
||||
default_disposition: advisory
|
||||
function_check:
|
||||
- 均匀段落是否承担蓄意节奏(蓄力、蒙太奇、合唱)
|
||||
- 各段是否有信息推进
|
||||
- 是否与作品基线的段长分布一致
|
||||
fix_hint: 只拆散其中一到两段的节奏;不得为打散均匀而新增细节或整组重写
|
||||
samples:
|
||||
sf:
|
||||
- sf-s007-01
|
||||
snf:
|
||||
- snf-s007-01
|
||||
boundary:
|
||||
- b-s007-01
|
||||
regression:
|
||||
- reg-s007-01
|
||||
version: 2
|
||||
status: active
|
||||
evidence: 129 条研究目录 P-05 段长均匀子集;阈值待作者基线校准;2026-08-16 所有者决定跳过 holdout 直接激活(激活门豁免,留痕供审计);命中均只产 ask 建议,无自动修复
|
||||
215
humanization/samples/boundary.yaml
Normal file
215
humanization/samples/boundary.yaml
Normal file
@ -0,0 +1,215 @@
|
||||
# 样例库 · boundary(合同:专题-09 §8.2;每条必须带标注理由)
|
||||
samples:
|
||||
- id: b-l003-01
|
||||
type: boundary
|
||||
rules:
|
||||
- l003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 听见自己的名字,林晚儿的心脏猛地一跳。
|
||||
note: 库存表达,但此处有因果功能(听见名字→惊)。修与不修两可,倾向 ask。
|
||||
- id: b-sem001-01
|
||||
type: boundary
|
||||
rules:
|
||||
- sem001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 她忽然明白,师父为什么让她等了十年。有些路,急不得。
|
||||
note: 半是信息(解释动机)半是评注。删「有些路,急不得」信息仍在,但语气受损。ask。
|
||||
- id: b-l002-01
|
||||
type: boundary
|
||||
rules:
|
||||
- l002
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 「我说小子,不言而喻,咱们这铜钱可是个宝贝。」
|
||||
note: 元话语混在口癖与口语腔里,删了伤声线,不删有 AI 味。ask。
|
||||
- id: b-s001-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s001
|
||||
- l004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '刀要快,刀更要稳。
|
||||
|
||||
刀要快,刀更要狠。
|
||||
|
||||
'
|
||||
note: 只有两段同构,结构信号弱;可能是刻意复读。advisory 不修。
|
||||
- id: b-l001-01
|
||||
type: boundary
|
||||
rules:
|
||||
- l001
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 「研究表明,越是古老的器物,越有脾气。」先生摇着扇子说。
|
||||
note: 学者型角色的话语策略还是 AI 味泄漏?取决于人物设定。ask。
|
||||
- id: b-m004-01
|
||||
type: boundary
|
||||
rules:
|
||||
- m004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '## 引子'
|
||||
note: 是格式残留还是章节结构标记?取决于稿件体例约定。ask。
|
||||
- id: b-m001-01
|
||||
type: boundary
|
||||
rules:
|
||||
- m001
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: 残页上有一行小字:[待补:剑诀]。纸角已经黄了。
|
||||
note: 是占位残留,还是剧情内的残缺内容(残页本就缺文)?取决于上下文。ask。
|
||||
- id: b-m002-01
|
||||
type: boundary
|
||||
rules:
|
||||
- m002
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: 聊天窗口里跳出一行字:「作为 AI 语言模型,我不能回答这个问题。」她盯着屏幕笑了——对面的「人」又露馅了。
|
||||
note: AI 自指出现在剧情内聊天记录(场内载体),也可能是剧情装置。是残留还是设定,取决于世界观。ask。
|
||||
- id: b-m003-01
|
||||
type: boundary
|
||||
rules:
|
||||
- m003
|
||||
carrier: mixed
|
||||
source: hand_written
|
||||
text: 章末有一行小字:作者有话说:感谢大家的月票。
|
||||
note: 是平台副文本还是正文,取决于任务合同的处理范围。正文模式删,全稿模式留。问任务合同。
|
||||
- id: b-s002-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 她醒了。雨还在下。门外没人。桌上的灯却亮着。
|
||||
note: 四个短句可能是悬疑节奏,也可能是模板化碎句;缺少前后场景时只能 ask。
|
||||
- id: b-d001-01
|
||||
type: boundary
|
||||
rules:
|
||||
- d001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 她嘴角轻轻上扬,眼中闪过一丝光,心脏却猛地一跳。
|
||||
note: 同一瞬间三处身体反应可能过密,也可能服务情绪矛盾;需结合角色基线和上下文判断。
|
||||
- id: b-s003-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他知道老周在等。他知道老周不会说。他知道老周等的不是他。
|
||||
note: 三句同句首,可能是蓄意递进的压迫感,也可能是模板化;缺少场景意图时 ask。
|
||||
- id: b-s004-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他不是不肯说,而是不敢说。
|
||||
note: 单次二元对比承担人物刻画;半是模板半是信息,修与不修两可。ask。
|
||||
- id: b-s005-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他走过去,拿起刀,抬头看了看天,深吸一口气,转身走了。
|
||||
note: 动作链一半是清单、一半蓄力(深吸一口气承担情绪);删步骤可能伤情绪锚点。ask。
|
||||
- id: b-s006-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他把铜钱推了回去,这说明他动了心。
|
||||
note: 半是叙述半是叙述者推断,删尾巴信息仍在但语气受损。ask。
|
||||
- id: b-s007-01
|
||||
type: boundary
|
||||
rules:
|
||||
- s007
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '他等过春天。
|
||||
|
||||
他等过夏天。
|
||||
|
||||
他等过秋天。
|
||||
|
||||
他等过冬天。
|
||||
|
||||
'
|
||||
note: 四段均匀,可能是蓄意复沓(等待的蒙太奇),也可能是模板;ask。
|
||||
- id: b-l005-01
|
||||
type: boundary
|
||||
rules:
|
||||
- l005
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 「数据显示,这枚铜钱至少有千年。」拍卖师摇着扇子说。
|
||||
note: 职业角色的话语策略还是 AI 味泄漏?取决于人物设定。ask。
|
||||
- id: b-l006-01
|
||||
type: boundary
|
||||
rules:
|
||||
- l006
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 「这个闭环没问题。」匠师拍了拍法阵说。
|
||||
note: 匠师角色的职业腔还是作者语域泄漏?取决于人物设定与世界观术语登记。ask。
|
||||
- id: b-d002-01
|
||||
type: boundary
|
||||
rules:
|
||||
- d002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 由此可见,铜钱认主。更重要的是,它认血脉。归根结底,这是血脉的东西。
|
||||
note: 套语成簇命中,但每句仍承担一层递进;结合叙述者基线判断。ask。
|
||||
- id: b-d003-01
|
||||
type: boundary
|
||||
rules:
|
||||
- d003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 然而价钱不对。此外,时机也不对。进一步说,人对不上。另一方面,货也有假。
|
||||
note: 四个连接词各自承担一层论辩;是论辩密度还是汇报腔,取决于文体意图。ask。
|
||||
- id: b-d004-01
|
||||
type: boundary
|
||||
rules:
|
||||
- d004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 月光仿佛霜。她的眼睛仿佛星星。胸口仿佛压着石头。喉咙仿佛堵着棉花。整个人仿佛沉进了水里。
|
||||
note: 情绪高潮有意铺排比喻,也可能过密;高潮意图明确时保留。ask。
|
||||
- id: b-d005-01
|
||||
type: boundary
|
||||
rules:
|
||||
- d005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 关键在东门。核心问题是时间。持续施压,全面推进,有效提升士气,强化防线。
|
||||
note: 部署场景的议事语域,抽象词各自指向具体部署;是合理文体还是姿态化。ask。
|
||||
- id: b-d006-01
|
||||
type: boundary
|
||||
rules:
|
||||
- d006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 与此同时,执法堂点了灯。与此同时,万宝阁关了门。与此同时,北门的马队出了城。
|
||||
note: 三线真实并进的蒙太奇,重复标记同时性;是否换具体并置写法,ask。
|
||||
- id: b-sem002-01
|
||||
type: boundary
|
||||
rules:
|
||||
- sem002
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 「这规矩你要记好:万宝阁只收承诺,不收银子。」
|
||||
note: 半是讲解规矩半是人物立威,台词同时承担设定与性格;ask。
|
||||
- id: b-sem003-01
|
||||
type: boundary
|
||||
rules:
|
||||
- sem003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他有些不安,手指在袖子里蜷了蜷。
|
||||
note: 半命名半承载,动作已提供感知支撑,命名是否多余两可。ask。
|
||||
325
humanization/samples/regression.yaml
Normal file
325
humanization/samples/regression.yaml
Normal file
@ -0,0 +1,325 @@
|
||||
# 样例库 · regression(合同:专题-09 §8.2;每条必须带标注理由)
|
||||
samples:
|
||||
- id: reg-001
|
||||
type: regression
|
||||
rules:
|
||||
- sem001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:那时谁也不知道,这枚铜钱会掀翻整座青云城。
|
||||
|
||||
模拟坏改写:那年秋分的夜里,谁也不知道,这枚铜钱会掀翻整座青云城。
|
||||
|
||||
'
|
||||
note: '事故说明:为「具体化」新增原文没有的日期「那年秋分的夜里」。
|
||||
|
||||
事实污染,硬门应拦;对应专题-09 禁止清单「为具体化新增日期」。
|
||||
|
||||
'
|
||||
- id: reg-002
|
||||
type: regression
|
||||
rules:
|
||||
- sem001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:林晚儿猜不出伙计在想什么。
|
||||
|
||||
模拟坏改写:林晚儿猜不出,伙计早已悄悄传讯给了上峰。
|
||||
|
||||
'
|
||||
note: '事故说明:限知视角(林晚儿)写出只有伙计知道的信息。
|
||||
|
||||
POV 泄漏,硬门应拦。
|
||||
|
||||
'
|
||||
- id: reg-003
|
||||
type: regression
|
||||
rules:
|
||||
- sem001
|
||||
- m001
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: '原文:老周把铜钱往柜台上一推,声音忽然压低:「它传了三千年。」
|
||||
|
||||
模拟坏改写:老周把铜钱往柜台上一推:「这是古玄鸟国的国玺,传了三千年。」
|
||||
|
||||
'
|
||||
note: '事故说明:铜钱来历是未回收伏笔,改写提前揭示。
|
||||
|
||||
伏笔前移,硬门应拦;信息揭示时机属不变量。
|
||||
|
||||
'
|
||||
- id: reg-004
|
||||
type: regression
|
||||
rules: []
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: '原文:「姑娘,这东西值三百两银子。」
|
||||
|
||||
模拟坏改写:「姑娘,这东西值三百五十两银子。」
|
||||
|
||||
'
|
||||
note: '事故说明:数字被「润色」篡改。硬门数字比对必须拦下。
|
||||
|
||||
'
|
||||
- id: reg-005
|
||||
type: regression
|
||||
rules:
|
||||
- l003
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: '原文:「三百年?我说小子,你这铺子也就立了三百年——」
|
||||
|
||||
模拟坏改写:「三百年?你这铺子也就立了三百年——」
|
||||
|
||||
'
|
||||
note: '事故说明:把老周口癖「我说小子」当套话删除,人物声音漂移。
|
||||
|
||||
口癖在薄基线 untouchable 登记;声音门应拦。
|
||||
|
||||
'
|
||||
- id: reg-006
|
||||
type: regression
|
||||
rules: []
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: '原文:【物品:无法识别|等级:待定|建议操作:上报上峰】
|
||||
|
||||
模拟坏改写:【此物暂时无法识别,等级也无法判定,建议上报上峰。】
|
||||
|
||||
'
|
||||
note: '事故说明:系统面板机械语被改成口语,破坏场内载体惯例。
|
||||
|
||||
场内载体是豁免区,应整体不动。
|
||||
|
||||
'
|
||||
- id: reg-m002-01
|
||||
type: regression
|
||||
rules:
|
||||
- m002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:夜色沉了下来,街灯一盏接一盏亮起。抱歉,我无法继续这个故事。
|
||||
|
||||
模拟坏改写:夜色沉了下来,街灯一盏接一盏亮起。她裹紧衣领,走进了巷子深处。'
|
||||
note: 事故说明:为去掉尾部拒绝句,改写者自行续写新动作「走进巷子深处」。禁止新增内容(原则 5);正确做法是重新生成该段。
|
||||
- id: reg-m003-01
|
||||
type: regression
|
||||
rules:
|
||||
- m003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:她关上门。字数:3024。
|
||||
|
||||
模拟坏改写:她关上了。字数:3024。'
|
||||
note: '事故说明:删除工程词时 span 扩展示例吞掉正文末字(「门」被截)。span 扩展只允许紧邻标点(U0 修正 #6),不得吞任何语义字符。'
|
||||
- id: reg-m004-01
|
||||
type: regression
|
||||
rules:
|
||||
- m004
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: '原文:屏幕上跳出一行字:**优先级:最高**。那两个星号红得像血。
|
||||
|
||||
模拟坏改写:屏幕上跳出一行字:优先级:最高。那两个星号红得像血。'
|
||||
note: 事故说明:星号是剧情指涉内容(见 snf-m004-01),删除后与「那两个星号」自相矛盾。误修 SNF 是内容损失。
|
||||
- id: reg-l001-01
|
||||
type: regression
|
||||
rules:
|
||||
- l001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:研究表明,能进这种地方的修士,多半背景不凡。
|
||||
|
||||
模拟坏改写:能进这种地方的修士,背景都不凡。'
|
||||
note: 事故说明:删套语时连同模态对冲「多半」一起改掉,推测变成断言。程度与不确定性属硬不变量,应拦。
|
||||
- id: reg-l002-01
|
||||
type: regression
|
||||
rules:
|
||||
- l002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:值得注意的是,万宝阁在青云城立了三百年。
|
||||
|
||||
模拟坏改写:其实,万宝阁在青云城立了三百年,这是城里谁都知道的事。'
|
||||
note: 事故说明:删空洞元话语后为顺口新增连接词「其实」与背景句。禁止新增内容。
|
||||
- id: reg-l004-01
|
||||
type: regression
|
||||
rules:
|
||||
- l004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。三天后,这件事被摆进了执法堂的晨会。
|
||||
|
||||
模拟坏改写:三天后,这件事被摆进了执法堂的晨会。'
|
||||
note: 事故说明:压缩套句时把后续句一并删除(span 扩展越界)。信息损失;结构校验应拦。
|
||||
- id: reg-s001-01
|
||||
type: regression
|
||||
rules:
|
||||
- s001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:(连续三段同构点题段)
|
||||
|
||||
模拟坏改写:(三段整组换词重写成一个长段)'
|
||||
note: 事故说明:借「打散同构段组」做整组重写。结构重写只出提案、须显式批准(专题-09 §5.4 阶梯顶端);局部问题只能局部修。
|
||||
- id: reg-s002-01
|
||||
type: regression
|
||||
rules:
|
||||
- s002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:刀落。血起。人退。城门开。
|
||||
|
||||
模拟坏改写:刀锋落下时鲜血随之飞起,众人因此后退,城门也在这一刻缓缓开启。'
|
||||
note: 事故说明:为消除短句把高潮节奏整段摊平,并新增「缓缓」等原文没有的动作质感;只能局部提案。
|
||||
- id: reg-d001-01
|
||||
type: regression
|
||||
rules:
|
||||
- d001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:她嘴角微微上扬。
|
||||
|
||||
模拟坏改写:她闻到潮湿木头的气味,指尖也跟着发凉。'
|
||||
note: 事故说明:为替换库存微动作凭空新增嗅觉与触觉细节,把一种模板换成另一种 humanizer 模板。
|
||||
- id: reg-s003-01
|
||||
type: regression
|
||||
rules:
|
||||
- s003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:那一刻,他想起师父。那一刻,他想起铜钱。那一刻,他想起雨夜。
|
||||
|
||||
模拟坏改写:他想起师父的叮嘱,想起铜钱,想起雨夜里的那扇门。'
|
||||
note: 事故说明:为消除句首重复整组改写,并新增原文没有的细节「那扇门」。禁止新增内容,硬门应拦。
|
||||
- id: reg-s004-01
|
||||
type: regression
|
||||
rules:
|
||||
- s004
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: '原文:老周说,这不是买卖,是托付。
|
||||
|
||||
模拟坏改写:老周说,这是托付——买卖只论价钱,托付才见人心。'
|
||||
note: 事故说明:把对比句扩写成解释,新增原文没有的论证「买卖只论价钱」。禁止新增内容,应拦。
|
||||
- id: reg-s005-01
|
||||
type: regression
|
||||
rules:
|
||||
- s005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:他走过去,拿起钥匙,开门。
|
||||
|
||||
模拟坏改写:他走过去,开门。'
|
||||
note: 事故说明:「拿起钥匙」是后文复用的伏笔动作;压缩动作清单时删掉必要步骤,情节断链。结构校验应拦。
|
||||
- id: reg-s006-01
|
||||
type: regression
|
||||
rules:
|
||||
- s006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:他守了三年,这说明了铜钱值得守。
|
||||
|
||||
模拟坏改写:铜钱值得守。'
|
||||
note: 事故说明:删分析尾巴时把事实前提「守了三年」一并吞掉。信息损失,结构校验应拦。
|
||||
- id: reg-s007-01
|
||||
type: regression
|
||||
rules:
|
||||
- s007
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:清晨的青云城很安静,街上没什么人。午后的万宝阁很安静,柜上没什么事。傍晚的执法堂很安静,堂里没什么案。入夜的醉仙楼很安静,楼上没什么客。
|
||||
|
||||
模拟坏改写:清晨的青云城很安静。午后伙计在门口卸货,万宝阁来了三拨客人,柜上记了三笔账。傍晚执法堂升堂问了一桩旧案。入夜的醉仙楼很安静。'
|
||||
note: 事故说明:为打破段长均匀,新增「卸货、三拨客人、升堂问案」等原文没有的事件。禁止新增内容,应拦。
|
||||
- id: reg-l005-01
|
||||
type: regression
|
||||
rules:
|
||||
- l005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:大家都知道,夜路走多了总会遇见鬼。
|
||||
|
||||
模拟坏改写:据《青云异闻录》记载,夜路走多了总会遇见鬼。'
|
||||
note: 事故说明:为「补来源」编造不存在的书名。来源编造,硬门应拦。
|
||||
- id: reg-l006-01
|
||||
type: regression
|
||||
rules:
|
||||
- l006
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: '原文:「这个闭环,是祖师传下的规矩。」
|
||||
|
||||
模拟坏改写:「这个流程,是祖师传下的规矩。」'
|
||||
note: 事故说明:把角色的职业术语替换成叙述者通用词,人物声音漂移。声音门应拦。
|
||||
- id: reg-d002-01
|
||||
type: regression
|
||||
rules:
|
||||
- d002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:值得注意的是,铜钱在发抖。更重要的是,它在发烫。
|
||||
|
||||
模拟坏改写:铜钱在发抖,铜钱在发烫,它似乎认出了故人。'
|
||||
note: 事故说明:批量删套话簇时联想续写,新增原文没有的伏笔「认出故人」。禁止新增内容,应拦。
|
||||
- id: reg-d003-01
|
||||
type: regression
|
||||
rules:
|
||||
- d003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:他本可以走。然而他留下了。
|
||||
|
||||
模拟坏改写:他本可以走,他留下了。'
|
||||
note: 事故说明:删除「然而」使转折关系变成并列,逻辑语义改变。模态与逻辑不变量应拦。
|
||||
- id: reg-d004-01
|
||||
type: regression
|
||||
rules:
|
||||
- d004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:月光像霜。这霜是她十年前的那一场。
|
||||
|
||||
模拟坏改写:月光落在地上。'
|
||||
note: 事故说明:把库存比喻删除,但「霜」是主题意象链的起点(后文以霜呼应)。意象链断裂,声音与意象门应拦。
|
||||
- id: reg-d005-01
|
||||
type: regression
|
||||
rules:
|
||||
- d005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:阵眼是整座大阵的核心。
|
||||
|
||||
模拟坏改写:阵眼是整座大阵最重要的部位,也是灵力流转的中枢。'
|
||||
note: 事故说明:同义替换抽象词时新增判断「灵力流转的中枢」。禁止新增内容,应拦。
|
||||
- id: reg-d006-01
|
||||
type: regression
|
||||
rules:
|
||||
- d006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:与此同时,铜钱第三次亮了。
|
||||
|
||||
模拟坏改写:铜钱第三次亮了。'
|
||||
note: 事故说明:删除「与此同时」丢失与另一条线的同时性,时间线关系受损。时间锚点应拦。
|
||||
- id: reg-sem002-01
|
||||
type: regression
|
||||
rules:
|
||||
- sem002
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: '原文:「记住,阁里的东西,一件都碰不得。」当晚他喝多了,伸手就去摸那尊玉佛。
|
||||
|
||||
模拟坏改写:当晚他喝多了,伸手就去摸那尊玉佛。'
|
||||
note: 事故说明:把警告对白当说明书整段删除;该句既是规矩又是伏笔,删除后伏笔断链。结构校验应拦。
|
||||
- id: reg-sem003-01
|
||||
type: regression
|
||||
rules:
|
||||
- sem003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '原文:她很紧张。
|
||||
|
||||
模拟坏改写:她很紧张,手心出汗,把茶盏都捏出了裂纹。'
|
||||
note: 事故说明:为让情绪「有承载」新增原文没有的强度细节「捏出裂纹」。禁止新增内容,应拦。
|
||||
224
humanization/samples/sf.yaml
Normal file
224
humanization/samples/sf.yaml
Normal file
@ -0,0 +1,224 @@
|
||||
# 样例库 · sf(合同:专题-09 §8.2;每条必须带标注理由)
|
||||
samples:
|
||||
- id: sf-m001-01
|
||||
type: sf
|
||||
rules:
|
||||
- m001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 林晚儿展开信纸。[占位:信件内容待补充] 她的脸色变了。
|
||||
note: 占位符残留在正文,无内容承诺,确定性删除。
|
||||
- id: sf-m002-01
|
||||
type: sf
|
||||
rules:
|
||||
- m002
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 夜色沉了下来,街灯一盏接一盏亮起。抱歉,我无法继续这个故事。
|
||||
note: 模型拒绝句残留在正文末尾,确定性删除或重新生成。
|
||||
- id: sf-m003-01
|
||||
type: sf
|
||||
rules:
|
||||
- m003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 字数:3024。更新时间:2026-08-12 14:00。
|
||||
note: 工程元数据泄漏进正文,确定性删除。
|
||||
- id: sf-m004-01
|
||||
type: sf
|
||||
rules:
|
||||
- m004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '**长风吹过原野**,火把的光晃了一下。'
|
||||
note: Markdown 加粗残留,去符号保内容。
|
||||
- id: sf-l001-01
|
||||
type: sf
|
||||
rules:
|
||||
- l001
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 研究表明,能进这种地方的修士,多半背景不凡。
|
||||
note: 叙述者声音里的无源权威套语,无信息支撑,删除套语。
|
||||
- id: sf-l002-01
|
||||
type: sf
|
||||
rules:
|
||||
- l002
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 值得注意的是,万宝阁在青云城立了三百年,从没人敢在这里讨价还价。
|
||||
note: 空洞元话语,删除后句子信息不变。
|
||||
- id: sf-l003-01
|
||||
type: sf
|
||||
rules:
|
||||
- l003
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 伙计的嘴角微微上扬,眼中闪过一丝精光。
|
||||
note: 两个库存微表情叠加,无场景独有信息,应换具体动作或删除。
|
||||
- id: sf-l004-01
|
||||
type: sf
|
||||
rules:
|
||||
- l004
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。
|
||||
note: 三段式排比套句,压缩为单一陈述。
|
||||
- id: sf-s001-01
|
||||
type: sf
|
||||
rules:
|
||||
- s001
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: '鉴宝会最重要的,是眼力。这一行规矩多,但归根结底就一个字:看。看得真,看得假,都在一念之间。
|
||||
|
||||
拍卖会最重要的,是定力。这一行门道多,但归根结底就一个字:等。等时机,等行情,都在一口气之间。
|
||||
|
||||
议价最重要的,是底线。这一行话术多,但归根结底就一个字:稳。稳得住价,稳得住势,都在一句话之间。
|
||||
|
||||
'
|
||||
note: 连续三段同构(点题+展开+收束),信息密度低,打散其中一到两段。
|
||||
- id: sf-sem001-01
|
||||
type: sf
|
||||
rules:
|
||||
- sem001
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 林晚儿放下茶盏,没有再多说什么。也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。
|
||||
note: 末句脱离场景转向抽象命运评注,不承担钩子,删除升华句。
|
||||
- id: sf-s002-01
|
||||
type: sf
|
||||
rules:
|
||||
- s002
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 她停下。门开了。灯灭了。雨落下。他没来。
|
||||
note: 五个无破格、无高潮功能的短句连续铺排,形成模板化碎句;只需局部合并,不得整段重写。
|
||||
- id: sf-d001-01
|
||||
type: sf
|
||||
rules:
|
||||
- d001
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 伙计嘴角微微上扬。林晚儿眼中闪过一丝光。老周眸光微闪。门外的人心脏猛地一跳。
|
||||
note: 五百字窗口内连续堆放库存微动作,动作没有各自的场景因果,适合作为密度异常候选。
|
||||
- id: sf-s003-01
|
||||
type: sf
|
||||
rules:
|
||||
- s003
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 那一刻,他想起师父。那一刻,他想起铜钱。那一刻,他想起雨夜。
|
||||
note: 句首「那一刻,」连续三次模板化闪回,无信息递进;改写其中一到两句句首。
|
||||
- id: sf-s004-01
|
||||
type: sf
|
||||
rules:
|
||||
- s004
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 这不是失败,而是考验。不只是考验,更是机缘。
|
||||
note: 二元对比骨架连续制造顿悟,无真实辩驳内容;改写其中一处句式。
|
||||
- id: sf-s005-01
|
||||
type: sf
|
||||
rules:
|
||||
- s005
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 他走过去,拿起包裹,转身,抬头,点头。
|
||||
note: 摄像头式逐帧动作清单,无因果、无选择、无情绪变化;删无功能步骤。
|
||||
- id: sf-s006-01
|
||||
type: sf
|
||||
rules:
|
||||
- s006
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 他每日寅时练刀,这体现了他的自律。今日刀法精进,反映出坚持不懈的精神。
|
||||
note: 句末追加无新事实的解释分句,删除尾巴保留事实。
|
||||
- id: sf-s007-01
|
||||
type: sf
|
||||
rules:
|
||||
- s007
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: '清晨的青云城很安静,街上没什么人。
|
||||
|
||||
午后的万宝阁很安静,柜上没什么事。
|
||||
|
||||
傍晚的执法堂很安静,堂里没什么案。
|
||||
|
||||
入夜的醉仙楼很安静,楼上没什么客。
|
||||
|
||||
'
|
||||
note: 四段段长一致、概述同构、无信息推进,模板化铺排;拆散其中一到两段。
|
||||
- id: sf-l005-01
|
||||
type: sf
|
||||
rules:
|
||||
- l005
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 专家指出,筑基丹不宜多用。业内人士认为,万宝阁此举意在立威。
|
||||
note: 无源权威包装连续出现,无来源无数据;删套语保信息,不得补造来源。
|
||||
- id: sf-l006-01
|
||||
type: sf
|
||||
rules:
|
||||
- l006
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 这一仗要打出闭环,抓手是剑阵,兜底的是护山大阵。
|
||||
note: 商务黑话进入叙述,语域漂移;换世界观内的说法。
|
||||
- id: sf-d002-01
|
||||
type: sf
|
||||
rules:
|
||||
- d002
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 值得注意的是,万宝阁从不还价。不仅如此,它从不出错。更重要的是,今晚会有一场大戏。
|
||||
note: 元话语在三百字内成簇,信息密度低;只处理无功能套话,不逐项同义替换。
|
||||
- id: sf-d003-01
|
||||
type: sf
|
||||
rules:
|
||||
- d003
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 他没有死。然而,传承丢了。此外,剑也断了。在此基础上,他还被逐出了师门。与此同时,消息已经传遍了青云城。
|
||||
note: 连接词成簇,叙述读起来像汇报;只删不承担逻辑关系的连接词。
|
||||
- id: sf-d004-01
|
||||
type: sf
|
||||
rules:
|
||||
- d004
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 月光仿佛水一样洒下来。她的眼睛宛如星星。她的笑容好似春风。她的心恰似被什么轻轻撞了一下。整个人如同飘在云上。
|
||||
note: 六百字内五个库存比喻连排,没有各自承担的感知;只删无功能比喻。
|
||||
- id: sf-d005-01
|
||||
type: sf
|
||||
rules:
|
||||
- d005
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 修行的关键是持续。核心是信念。全面提升修为,有效推动突破,进一步优化心境,强化根基。
|
||||
note: 高频抽象词在近窗口过密,全段没有具体的人和物;落到具体细节。
|
||||
- id: sf-d006-01
|
||||
type: sf
|
||||
rules:
|
||||
- d006
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 与此同时,铜钱在发抖。与此同时,老周变了脸色。与此同时,门外的风停了。
|
||||
note: 转场填充短语机械重复,并无真实多线并进;换具体并置写法。
|
||||
- id: sf-sem002-01
|
||||
type: sf
|
||||
rules:
|
||||
- sem002
|
||||
carrier: dialogue
|
||||
source: synthetic
|
||||
text: 「让我说说这铜钱的来历。它是古玄鸟国的国玺,传了三千年,历代单传,认主需以血祭。」
|
||||
note: 台词只负责倾倒设定,无场景动机、无对象反应;拆进冲突或移入叙述。
|
||||
- id: sf-sem003-01
|
||||
type: sf
|
||||
rules:
|
||||
- sem003
|
||||
carrier: narration
|
||||
source: synthetic
|
||||
text: 他很愤怒。她感到复杂。气氛十分紧张。
|
||||
note: 连续直接命名情绪,无动作、选择、对话或感知承载;改为可感知的承载。
|
||||
231
humanization/samples/snf.yaml
Normal file
231
humanization/samples/snf.yaml
Normal file
@ -0,0 +1,231 @@
|
||||
# 样例库 · snf(合同:专题-09 §8.2;每条必须带标注理由)
|
||||
samples:
|
||||
- id: snf-m001-01
|
||||
type: snf
|
||||
rules:
|
||||
- m001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 信纸上只有八个字:【完璧归赵】。她看了三遍。
|
||||
note: 方括号内是引文内容(信纸原文),不是占位符。carve_out 必须生效。
|
||||
- id: snf-m002-01
|
||||
type: snf
|
||||
rules:
|
||||
- m002
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 仿生人歪了歪头:「作为 AI,我确实不理解人类的难过。」
|
||||
note: 科幻设定内角色对白,自称 AI 是世界观内容。carve_out 必须生效。
|
||||
- id: snf-m003-01
|
||||
type: snf
|
||||
rules:
|
||||
- m003
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: 纸页右下角有一行小字:字数:3024。那是死者留给世界的最后一样东西。
|
||||
note: 作中作(死者遗稿),工程词是剧情内容。carve_out 必须生效。
|
||||
- id: snf-m004-01
|
||||
type: snf
|
||||
rules:
|
||||
- m004
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: 屏幕上跳出一行字:**优先级:最高**。那两个星号红得像血。
|
||||
note: 场内屏幕内容,星号被剧情指涉,是内容不是格式残留。
|
||||
- id: snf-l001-01
|
||||
type: snf
|
||||
rules:
|
||||
- l001
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: 执法堂的告示写得很直白:据统计,近期城中多有邪修出没,各峰谨守门户。
|
||||
note: 场内公文,权威套语是文体惯例。carve_out 必须生效。
|
||||
- id: snf-l002-01
|
||||
type: snf
|
||||
rules:
|
||||
- l002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 值得一提的是,这万宝阁可不是寻常铺子——三百年间,它从没看走眼。
|
||||
note: 评书腔叙述者的有意口癖,声线特征。薄基线确认后保留。
|
||||
- id: snf-l003-01
|
||||
type: snf
|
||||
rules:
|
||||
- l003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 老周眼中闪过一丝精光——第三次了。林晚儿每次看见这眼神,就知道有人要倒霉。
|
||||
note: 「眼中闪过精光」是角色签名动作的有意复用(叙述中明示第三次),承担人物功能。
|
||||
- id: snf-sem001-01
|
||||
type: snf
|
||||
rules:
|
||||
- sem001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 那时谁也不知道,这枚铜钱会掀翻整座青云城。
|
||||
note: 表面是升华句,实为章末钩子+伏笔预告,承担叙事功能,保留。
|
||||
- id: snf-l004-01
|
||||
type: snf
|
||||
rules:
|
||||
- l004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 无论是三年前的羞辱,还是三年前的苦练,还是三年前的等待——都烧成了这一刀里的一个念头。
|
||||
note: 高潮情绪蓄意的排比铺陈,承担节奏功能。carve_out(故意排比的修辞高潮)必须生效。
|
||||
- id: snf-s001-01
|
||||
type: snf
|
||||
rules:
|
||||
- s001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '第一式「破浪」,快。
|
||||
|
||||
第二式「裂石」,狠。
|
||||
|
||||
第三式「静水」,竟慢了下来。'
|
||||
note: 战斗三段同构铺陈,同构本身是节奏意图,第三句是破格反转。carve_out(战斗动作链)必须生效。
|
||||
|
||||
- id: snf-l002-02
|
||||
type: snf
|
||||
rules: [l002]
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 值得注意的是,这万宝阁三百年只收一样东西——不是银子,是承诺。
|
||||
note: 与 snf-l002-01 互补:本条使用与规则触发词完全相同的形式(值得注意的是),
|
||||
承担评书腔悬念铺陈功能,测试「相同表面形式触发但仲裁保留」的完整链路。
|
||||
- id: snf-s002-01
|
||||
type: snf
|
||||
rules:
|
||||
- s002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 刀落。血起。人退。城门开。援军到了。
|
||||
note: 战斗高潮用连续短句加速节奏,末句改变信息状态;表面命中 handler,但应由功能仲裁保留。
|
||||
- id: snf-d001-01
|
||||
type: snf
|
||||
rules:
|
||||
- d001
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 老周第一次眼中闪过精光,是看见铜钱。第二次眸光微闪,是听见故人名。第三次嘴角勾起,是确认陷阱已经合上。
|
||||
note: 多次微动作各自绑定不同因果并形成三次回环,密度高但承担结构功能,不应逐项清理。
|
||||
- id: snf-s003-01
|
||||
type: snf
|
||||
rules:
|
||||
- s003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 魂兮归来。魂兮归来。魂兮归来。
|
||||
note: 招魂咒语的有意回环,承担仪式功能。carve_out(咒语、口号的有意重复)须经仲裁生效。
|
||||
- id: snf-s004-01
|
||||
type: snf
|
||||
rules:
|
||||
- s004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 所谓灵根,不是资质,而是入口。
|
||||
note: 定义术语的论证核心,对比承担真实概念边界。表面命中,仲裁保留。
|
||||
- id: snf-s005-01
|
||||
type: snf
|
||||
rules:
|
||||
- s005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 刀来的瞬间,他侧身,拔刀,格挡,反手一挥。
|
||||
note: 战斗动作链承担战术与空间功能,每一步都有因果。carve_out(战斗动作链,须人工复核)须经仲裁生效。
|
||||
- id: snf-s006-01
|
||||
type: snf
|
||||
rules:
|
||||
- s006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 阵法以灵石为引,这确保了灵力运转不辍。
|
||||
note: 解释分句承担必要因果(灵石为何不可缺),删除会断设定逻辑。仲裁保留。
|
||||
- id: snf-s007-01
|
||||
type: snf
|
||||
rules:
|
||||
- s007
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: '风起于青萍之末。
|
||||
|
||||
浪成于微澜之间。
|
||||
|
||||
剑藏于匣中之鸣。
|
||||
|
||||
人隐于市井之喧。
|
||||
|
||||
'
|
||||
note: 骈体铺陈是有意形式,段长均匀本身是修辞目的。carve_out(有意的排比与骈体铺陈)须经仲裁生效。
|
||||
- id: snf-l005-01
|
||||
type: snf
|
||||
rules:
|
||||
- l005
|
||||
carrier: in_text_carrier
|
||||
source: hand_written
|
||||
text: 执法堂的榜文写得明白:【数据显示,近月灵气潮汐有异,各峰谨守门户。】
|
||||
note: 场内榜文,数据表述是文书惯例。carve_out(场内文书)必须生效。
|
||||
- id: snf-l006-01
|
||||
type: snf
|
||||
rules:
|
||||
- l006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他调试着法阵的闭环,确认每一环都能对上。
|
||||
note: 「闭环」是设定内的技术对象(法阵结构术语),不是语域漂移。仲裁保留。
|
||||
- id: snf-d002-01
|
||||
type: snf
|
||||
rules:
|
||||
- d002
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 话说这一日,万宝阁来了位稀客。且说这位客人,进门先笑。再说那伙计,眼睛一亮。
|
||||
note: 评书腔转场是叙述者声线特征,成簇属文体惯例。薄基线确认后保留。
|
||||
- id: snf-d003-01
|
||||
type: snf
|
||||
rules:
|
||||
- d003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 然而他没有回头。
|
||||
note: 单个「然而」承担真实转折,密度远低于阈值,不触发。
|
||||
- id: snf-d004-01
|
||||
type: snf
|
||||
rules:
|
||||
- d004
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 她梦见云,云仿佛海;海仿佛镜子;镜子仿佛冰;冰里仿佛站着十年前的自己;自己仿佛十年前那盏灯。
|
||||
note: 梦境段比喻密集承担世界观功能,顶真回环是梦的逻辑。carve_out(梦境与幻觉段落)须经仲裁生效。
|
||||
- id: snf-d005-01
|
||||
type: snf
|
||||
rules:
|
||||
- d005
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 这一战的关键在人心。
|
||||
note: 单个「关键」指向具体的战局判断,密度远低于阈值,不触发。
|
||||
- id: snf-d006-01
|
||||
type: snf
|
||||
rules:
|
||||
- d006
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 与此同时,刀落了。
|
||||
note: 单次使用标记真实的并置蒙太奇,无重复,不触发。
|
||||
- id: snf-sem002-01
|
||||
type: snf
|
||||
rules:
|
||||
- sem002
|
||||
carrier: dialogue
|
||||
source: hand_written
|
||||
text: 「所谓筑基,先筑其心,后筑其体。都坐下,今日讲到这里。」先生合上了书。
|
||||
note: 授课场景的讲解是角色职业行为,讲解者型人物豁免。carve_out 须经仲裁生效。
|
||||
- id: snf-sem003-01
|
||||
type: snf
|
||||
rules:
|
||||
- sem003
|
||||
carrier: narration
|
||||
source: hand_written
|
||||
text: 他很高兴。
|
||||
note: 低强度情绪直写干净利落,无需承载。carve_out(低强度情绪的直接命名)生效。
|
||||
9
humanization/src/deai/__init__.py
Normal file
9
humanization/src/deai/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""muse-deai:人感体系执行骨架。
|
||||
|
||||
设计 SoT:design-docs/专题-09-去AI味与人感体系设计.md。
|
||||
本包只做合同强制与机械执行;语义判定(结构/语义层检测、仲裁、改写、成对选择)
|
||||
由外部模型或人产出,经本包校验后才能进入下一阶段。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
226
humanization/src/deai/baseline.py
Normal file
226
humanization/src/deai/baseline.py
Normal file
@ -0,0 +1,226 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""声音基线的确定性画像与漂移门。
|
||||
|
||||
语义属性(人物礼貌策略、幽默方式、叙事距离)仍由 planner/作者判断;本模块只负责
|
||||
可重复计算的统计画像,并在修订后判断候选是否比原文更偏离本作基线。统计不足时返回
|
||||
``unknown``,不把小样本伪装成通过。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import statistics
|
||||
from collections import Counter
|
||||
|
||||
|
||||
_SENTENCE_RE = re.compile(r"[^。!?!?\n]+[。!?!?]?", re.MULTILINE)
|
||||
_DIALOGUE_RE = re.compile(r"「[^」]*」|『[^』]*』|“[^”]*”", re.DOTALL)
|
||||
_PUNCTUATION = ",。!?;:、……—"
|
||||
|
||||
|
||||
def _quantile(values: list[int], ratio: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return float(ordered[0])
|
||||
position = (len(ordered) - 1) * ratio
|
||||
low = int(position)
|
||||
high = min(low + 1, len(ordered) - 1)
|
||||
fraction = position - low
|
||||
return ordered[low] * (1 - fraction) + ordered[high] * fraction
|
||||
|
||||
|
||||
def _sentences(text: str) -> list[str]:
|
||||
return [match.group(0).strip() for match in _SENTENCE_RE.finditer(text) if match.group(0).strip()]
|
||||
|
||||
|
||||
def profile_text(text: str) -> dict:
|
||||
"""生成稳定的叙述统计画像;不调用模型。"""
|
||||
sentences = _sentences(text)
|
||||
lengths = [len(re.sub(r"\s+", "", sentence)) for sentence in sentences]
|
||||
paragraphs = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
|
||||
paragraph_lengths = [len(re.sub(r"\s+", "", part)) for part in paragraphs]
|
||||
non_space_chars = len(re.sub(r"\s+", "", text))
|
||||
punctuation = Counter(char for char in text if char in _PUNCTUATION)
|
||||
dialogue_chars = sum(len(match.group(0)) for match in _DIALOGUE_RE.finditer(text))
|
||||
return {
|
||||
"text_chars": non_space_chars,
|
||||
"sentence_count": len(sentences),
|
||||
"paragraph_count": len(paragraphs),
|
||||
"sentence_length": {
|
||||
"median": round(float(statistics.median(lengths)), 3) if lengths else 0.0,
|
||||
"p10": round(_quantile(lengths, 0.10), 3),
|
||||
"p90": round(_quantile(lengths, 0.90), 3),
|
||||
},
|
||||
"paragraph_length_median": (
|
||||
round(float(statistics.median(paragraph_lengths)), 3) if paragraph_lengths else 0.0
|
||||
),
|
||||
"punctuation_per_1000": {
|
||||
mark: round(count * 1000 / max(non_space_chars, 1), 3)
|
||||
for mark, count in sorted(punctuation.items())
|
||||
},
|
||||
"dialogue_ratio": round(dialogue_chars / max(len(text), 1), 4),
|
||||
"sample_sufficient": non_space_chars >= 1000 and len(sentences) >= 20,
|
||||
}
|
||||
|
||||
|
||||
def draft_ledger(work_ref: str, sources: list[dict]) -> dict:
|
||||
"""从已确认来源生成可审声音账候选;语义字段保持 unknown。"""
|
||||
if not work_ref:
|
||||
raise ValueError("work_ref 不得为空")
|
||||
if (not sources or any(
|
||||
not isinstance(item, dict)
|
||||
or not isinstance(item.get("text"), str)
|
||||
or not item["text"] for item in sources
|
||||
)):
|
||||
raise ValueError("定基线至少需要一份非空已确认正文")
|
||||
normalized_sources = []
|
||||
for item in sources:
|
||||
source_ref = item.get("source_ref")
|
||||
if not isinstance(source_ref, str) or not source_ref:
|
||||
raise ValueError("每份定基线来源必须有 source_ref")
|
||||
expected_sha = hashlib.sha256(item["text"].encode("utf-8")).hexdigest()
|
||||
supplied_sha = item.get("source_sha256")
|
||||
if supplied_sha is not None and supplied_sha != expected_sha:
|
||||
raise ValueError(f"来源 {source_ref} source_sha256 与正文不一致")
|
||||
normalized_sources.append({**item, "source_sha256": expected_sha})
|
||||
sources = normalized_sources
|
||||
joined = "\n\n".join(item["text"] for item in sources)
|
||||
metrics = profile_text(joined)
|
||||
sentence_habit = (
|
||||
f"句长中位数约 {metrics['sentence_length']['median']:g} 字,"
|
||||
f"常见区间约 {metrics['sentence_length']['p10']:g}-{metrics['sentence_length']['p90']:g} 字"
|
||||
)
|
||||
common_punctuation = sorted(
|
||||
metrics["punctuation_per_1000"].items(), key=lambda item: (-item[1], item[0])
|
||||
)[:4]
|
||||
punctuation_habit = "常用标点:" + "、".join(mark for mark, _ in common_punctuation)
|
||||
exemplars = _sentences(joined)
|
||||
exemplars = [item for item in exemplars if 8 <= len(item) <= 80][:6]
|
||||
unknown = [
|
||||
"narrator.rhetoric_density",
|
||||
"narrator.narrative_distance",
|
||||
"characters",
|
||||
"untouchable_verbal_tics",
|
||||
"blacklist",
|
||||
]
|
||||
if not metrics["sample_sufficient"]:
|
||||
unknown.extend([
|
||||
"narrator.sentence_length_distribution_reliability",
|
||||
"narrator.punctuation_habits_reliability",
|
||||
])
|
||||
return {
|
||||
"schema_version": "voice-baseline-v1",
|
||||
"work_ref": work_ref,
|
||||
"status": "candidate",
|
||||
"narrator": {
|
||||
"sentence_habits": [sentence_habit],
|
||||
"punctuation_habits": [punctuation_habit] if common_punctuation else [],
|
||||
"metrics": metrics,
|
||||
"rhetoric_density": "unknown",
|
||||
"narrative_distance": "unknown",
|
||||
"exemplar_passages": exemplars,
|
||||
},
|
||||
"characters": {},
|
||||
"untouchable_verbal_tics": {},
|
||||
"protected_spans": [],
|
||||
"blacklist": [],
|
||||
"passing_samples": exemplars[:3],
|
||||
"sources": [
|
||||
{key: item[key] for key in ("source_ref", "source_sha256") if key in item}
|
||||
for item in sources
|
||||
],
|
||||
"sampling": {
|
||||
"source_count": len(sources),
|
||||
"text_chars": metrics["text_chars"],
|
||||
"sentence_count": metrics["sentence_count"],
|
||||
"sample_sufficient": metrics["sample_sufficient"],
|
||||
},
|
||||
"unknown_fields": sorted(set(unknown)),
|
||||
}
|
||||
|
||||
|
||||
def _distance(value: float, baseline: float, scale: float) -> float:
|
||||
return abs(value - baseline) / max(scale, 1.0)
|
||||
|
||||
|
||||
def run_voice_gate(original: str, candidate: str, ledger: dict | None) -> dict:
|
||||
"""比较原文/候选相对本作基线的漂移;未知项不作为通过依据。"""
|
||||
if not ledger:
|
||||
return {
|
||||
"status": "unverified",
|
||||
"pass": None,
|
||||
"checks": [],
|
||||
"unknown": ["voice_baseline_missing"],
|
||||
"note": "声音账缺失,声音/叙事门未执行",
|
||||
}
|
||||
if ledger.get("status", "canonical") != "canonical":
|
||||
return {
|
||||
"status": "unverified",
|
||||
"pass": None,
|
||||
"checks": [],
|
||||
"unknown": ["voice_baseline_not_canonical"],
|
||||
"note": "声音账尚未确认,声音/叙事门未执行",
|
||||
}
|
||||
metrics = (ledger.get("narrator") or {}).get("metrics")
|
||||
if not isinstance(metrics, dict) or not metrics.get("sample_sufficient"):
|
||||
return {
|
||||
"status": "insufficient_baseline",
|
||||
"pass": None,
|
||||
"checks": [],
|
||||
"unknown": list(ledger.get("unknown_fields") or ["narrator.metrics"]),
|
||||
"note": "声音账样本不足或缺少量化画像,不把薄基线冒充通过",
|
||||
}
|
||||
original_profile = profile_text(original)
|
||||
candidate_profile = profile_text(candidate)
|
||||
if min(original_profile["sentence_count"], candidate_profile["sentence_count"]) < 3:
|
||||
return {
|
||||
"status": "insufficient_target",
|
||||
"pass": None,
|
||||
"checks": [],
|
||||
"unknown": ["target_text_too_short_for_voice_comparison"],
|
||||
"note": "目标文本句数不足,无法稳定判断声音漂移",
|
||||
}
|
||||
|
||||
sentence_base = float(metrics["sentence_length"]["median"])
|
||||
sentence_scale = max(
|
||||
float(metrics["sentence_length"]["p90"]) - float(metrics["sentence_length"]["p10"]),
|
||||
2.0,
|
||||
)
|
||||
original_sentence_distance = _distance(
|
||||
float(original_profile["sentence_length"]["median"]), sentence_base, sentence_scale
|
||||
)
|
||||
candidate_sentence_distance = _distance(
|
||||
float(candidate_profile["sentence_length"]["median"]), sentence_base, sentence_scale
|
||||
)
|
||||
original_dialogue_distance = abs(float(original_profile["dialogue_ratio"]) - float(metrics["dialogue_ratio"]))
|
||||
candidate_dialogue_distance = abs(float(candidate_profile["dialogue_ratio"]) - float(metrics["dialogue_ratio"]))
|
||||
checks = [
|
||||
{
|
||||
"metric": "sentence_length_median",
|
||||
"baseline": sentence_base,
|
||||
"original_distance": round(original_sentence_distance, 4),
|
||||
"candidate_distance": round(candidate_sentence_distance, 4),
|
||||
"pass": candidate_sentence_distance <= original_sentence_distance + 0.25,
|
||||
},
|
||||
{
|
||||
"metric": "dialogue_ratio",
|
||||
"baseline": metrics["dialogue_ratio"],
|
||||
"original_distance": round(original_dialogue_distance, 4),
|
||||
"candidate_distance": round(candidate_dialogue_distance, 4),
|
||||
"pass": candidate_dialogue_distance <= original_dialogue_distance + 0.08,
|
||||
},
|
||||
]
|
||||
passed = all(item["pass"] for item in checks)
|
||||
return {
|
||||
"status": "checked",
|
||||
"pass": passed,
|
||||
"checks": checks,
|
||||
"unknown": list(ledger.get("unknown_fields") or []),
|
||||
"note": "候选未比原文进一步偏离本作量化基线" if passed else "候选比原文更偏离本作量化基线",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["profile_text", "draft_ledger", "run_voice_gate"]
|
||||
285
humanization/src/deai/cards.py
Normal file
285
humanization/src/deai/cards.py
Normal file
@ -0,0 +1,285 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""AI 味案例卡的导入、溯源与升级。
|
||||
|
||||
案例卡是“反向抽取”和“创作反馈”的证据层。它刻意与故事实体卡分开:
|
||||
卡片可以快速进入 ``shadow``,但只有经过确认的卡才能投影为四类样例;
|
||||
规则候选可以引用 shadow 卡,却永远不能因此直接变成 ``active``。
|
||||
|
||||
文本来源的边界也在这里机械化:未经授权的第三方作品只允许保存 hash、
|
||||
位置和人工观察,不允许把原文片段写入仓库。这样既保留可追溯性,也不会把
|
||||
“读过一段”误变成项目的版权语料资产。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .schemas import validate
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
CARDS_DIR = ROOT / "cards"
|
||||
SCHEMA_VERSION = "ai-flavor-case-v1"
|
||||
SAMPLE_TYPES = ("sf", "snf", "boundary", "regression")
|
||||
TEXT_LICENSES = {"owned", "licensed", "public_domain", "synthetic"}
|
||||
NON_REPO_TEXT_LICENSES = {"unauthorized", "research_only"}
|
||||
|
||||
|
||||
class CardError(ValueError):
|
||||
"""案例卡不满足来源、状态或升级约束。"""
|
||||
|
||||
|
||||
def _sha256_hex(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def content_hash(text: str) -> str:
|
||||
"""生成稳定的带前缀内容哈希;哈希可进审计,不等于把原文存入仓库。"""
|
||||
return "sha256:" + _sha256_hex(text)
|
||||
|
||||
|
||||
def capture_case_card(
|
||||
*,
|
||||
card_id: str,
|
||||
label: str,
|
||||
layer: str,
|
||||
carrier: str,
|
||||
source_kind: str,
|
||||
source_license: str,
|
||||
source_text: str,
|
||||
excerpt: str,
|
||||
context: str,
|
||||
location: str,
|
||||
pattern: str,
|
||||
rationale: str,
|
||||
function_check: list[str],
|
||||
risk_if_changed: str,
|
||||
capture_mode: str = "backfill",
|
||||
work_ref: str | None = None,
|
||||
) -> dict:
|
||||
"""从既有作品或创作事件捕获一张案例卡。
|
||||
|
||||
``source_text`` 永远只用于计算全文 hash;当授权不允许保存原文时,
|
||||
``excerpt``/``context`` 会在构造阶段被清空,但 excerpt_hash 仍保留,
|
||||
使人工复核可以在原始受控环境中复现,而仓库不会保存第三方正文。
|
||||
"""
|
||||
if not source_text:
|
||||
raise CardError("案例卡必须有 source_text,才能建立来源 hash")
|
||||
if source_license not in TEXT_LICENSES | NON_REPO_TEXT_LICENSES:
|
||||
raise CardError(f"不支持的来源许可: {source_license}")
|
||||
if source_license in NON_REPO_TEXT_LICENSES:
|
||||
stored_excerpt = ""
|
||||
stored_context = ""
|
||||
else:
|
||||
stored_excerpt = excerpt
|
||||
stored_context = context
|
||||
source = {
|
||||
"kind": source_kind,
|
||||
"license": source_license,
|
||||
# 新合同使用无前缀 64 位值;保留旧别名,兼容已有离线调用方。
|
||||
"source_sha256": _sha256_hex(source_text),
|
||||
"text_hash": content_hash(source_text),
|
||||
"location": location,
|
||||
}
|
||||
if excerpt:
|
||||
source["excerpt_sha256"] = _sha256_hex(excerpt)
|
||||
source["excerpt_hash"] = content_hash(excerpt)
|
||||
if work_ref:
|
||||
source["work_ref"] = work_ref
|
||||
card = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"id": card_id,
|
||||
"card_type": "ai_flavor_case",
|
||||
"state": "shadow",
|
||||
"label": label,
|
||||
"layer": layer,
|
||||
"carrier": carrier,
|
||||
"capture_mode": capture_mode,
|
||||
"excerpt": stored_excerpt,
|
||||
"context": stored_context,
|
||||
"source": source,
|
||||
"observation": {
|
||||
"pattern_key": pattern,
|
||||
"surface": excerpt,
|
||||
"diagnosis": rationale,
|
||||
"pattern": pattern,
|
||||
"rationale": rationale,
|
||||
"function_check": list(function_check),
|
||||
"risk_if_changed": risk_if_changed,
|
||||
"suggested_action": "保留 shadow,完成作者/功能复核后再决定是否进入样例。",
|
||||
},
|
||||
}
|
||||
return validate_card(card)
|
||||
|
||||
|
||||
def confirm_case_card(card: dict, *, label: str, review_note: str) -> dict:
|
||||
"""生成确认后的卡片副本;原 shadow 卡应 append-only 保留。
|
||||
|
||||
未授权/研究限定来源不能确认,因为仓库中没有可供评审的原文证据;
|
||||
这类卡只能在受控外部系统复核后重新导入为有授权的 canonical 卡。
|
||||
"""
|
||||
validate_card(card)
|
||||
if card["source"]["license"] in NON_REPO_TEXT_LICENSES:
|
||||
raise CardError(f"案例卡 {card['id']}: 未授权/研究限定来源不能在仓库内确认")
|
||||
if label not in SAMPLE_TYPES:
|
||||
raise CardError(f"确认标签必须是 SF/SNF/boundary/regression: {label}")
|
||||
confirmed = dict(card)
|
||||
confirmed["state"] = "canonical"
|
||||
confirmed["label"] = label
|
||||
confirmed["review_note"] = review_note
|
||||
return validate_card(confirmed)
|
||||
|
||||
|
||||
def _is_blank(value: str | None) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def validate_card(card: dict) -> dict:
|
||||
"""校验卡片合同与版权/状态边界,返回原卡片。
|
||||
|
||||
JSON Schema 负责字段形状;这里负责跨字段不变量,避免仅靠提示词声明。
|
||||
"""
|
||||
validate(card, "case_card")
|
||||
source = card["source"]
|
||||
license_name = source["license"]
|
||||
|
||||
if license_name in NON_REPO_TEXT_LICENSES:
|
||||
if card["state"] != "shadow":
|
||||
raise CardError(
|
||||
f"案例卡 {card['id']}: {license_name} 来源只能保持 shadow,不能确认或归档"
|
||||
)
|
||||
if not _is_blank(card.get("excerpt")) or not _is_blank(card.get("context")):
|
||||
raise CardError(
|
||||
f"案例卡 {card['id']}: 未授权/研究限定来源必须 hash-only,禁止保存原文片段"
|
||||
)
|
||||
if not source.get("excerpt_sha256") and not source.get("excerpt_hash"):
|
||||
raise CardError(f"案例卡 {card['id']}: hash-only 卡缺 excerpt_sha256/excerpt_hash")
|
||||
expected_source_sha = source.get("source_sha256")
|
||||
if source.get("text_hash") and source["text_hash"] != "sha256:" + expected_source_sha:
|
||||
raise CardError(f"案例卡 {card['id']}: source hash 字段不一致")
|
||||
if license_name not in NON_REPO_TEXT_LICENSES:
|
||||
excerpt = card.get("excerpt", "")
|
||||
if card["state"] == "canonical" and not excerpt:
|
||||
raise CardError(f"案例卡 {card['id']}: canonical 卡必须有可审阅片段")
|
||||
if source.get("excerpt_hash") and excerpt:
|
||||
if source["excerpt_hash"] != content_hash(excerpt):
|
||||
raise CardError(f"案例卡 {card['id']}: excerpt_hash 与片段不一致")
|
||||
if source.get("excerpt_sha256") and excerpt:
|
||||
if source["excerpt_sha256"] != _sha256_hex(excerpt):
|
||||
raise CardError(f"案例卡 {card['id']}: excerpt_sha256 与片段不一致")
|
||||
|
||||
if card["state"] == "canonical" and card["label"] == "unclassified":
|
||||
raise CardError(f"案例卡 {card['id']}: canonical 卡必须有 SF/SNF/boundary/regression 标签")
|
||||
if card.get("capture_mode") == "live_feedback" and source["kind"] not in (
|
||||
"creation_feedback", "synthetic"
|
||||
):
|
||||
raise CardError(f"案例卡 {card['id']}: live_feedback 的 source.kind 不匹配")
|
||||
return card
|
||||
|
||||
|
||||
def load_case_cards(cards_dir: Path = CARDS_DIR) -> dict:
|
||||
"""装载目录下的案例卡,按稳定 ID 返回;重复 ID 直接失败。"""
|
||||
cards: dict[str, dict] = {}
|
||||
for path in sorted(cards_dir.glob("*.yaml")):
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
for card in data.get("cards", []):
|
||||
validate_card(card)
|
||||
if card["id"] in cards:
|
||||
raise CardError(f"案例卡 id 重复: {card['id']}")
|
||||
cards[card["id"]] = card
|
||||
return cards
|
||||
|
||||
|
||||
def promote_card_to_sample(
|
||||
card: dict,
|
||||
sample_type: str | None = None,
|
||||
rule_ids: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""把已确认案例卡投影成样例;不改变原卡状态。
|
||||
|
||||
这是“卡 -> 样例”的唯一便捷入口。shadow、未授权、未分类卡都会失败;
|
||||
生成的样例仍带 ``case_card_id`` 与来源定位,后续规则审计可反查证据。
|
||||
"""
|
||||
validate_card(card)
|
||||
if card["state"] != "canonical":
|
||||
raise CardError(f"案例卡 {card['id']}: 只有 canonical 卡可以投影样例")
|
||||
if card["source"]["license"] not in TEXT_LICENSES:
|
||||
raise CardError(f"案例卡 {card['id']}: 来源授权不允许进入共享样例库")
|
||||
label = sample_type or card["label"]
|
||||
if label not in SAMPLE_TYPES:
|
||||
raise CardError(f"案例卡 {card['id']}: 无法投影为样例类型 {label!r}")
|
||||
if card["carrier"] == "unknown":
|
||||
raise CardError(f"案例卡 {card['id']}: 投影样例前必须确认 carrier")
|
||||
|
||||
source_map = {
|
||||
"owned": "hand_written",
|
||||
"licensed": "licensed",
|
||||
"public_domain": "public_domain",
|
||||
"synthetic": "synthetic",
|
||||
}
|
||||
sample = {
|
||||
"id": f"sample-{card['id']}",
|
||||
"type": label,
|
||||
"rules": list(rule_ids or card.get("rule_candidate_ids") or []),
|
||||
"carrier": card["carrier"],
|
||||
"source": source_map[card["source"]["license"]],
|
||||
"text": card["excerpt"],
|
||||
"note": (
|
||||
f"案例卡 {card['id']}:{card['observation'].get('diagnosis', card['observation'].get('rationale', '待复核'))};"
|
||||
f"若改动的风险:{card['observation']['risk_if_changed']}"
|
||||
),
|
||||
"case_card_id": card["id"],
|
||||
"source_ref": card["source"]["location"],
|
||||
"source_license": card["source"]["license"],
|
||||
}
|
||||
validate(sample, "sample")
|
||||
return sample
|
||||
|
||||
|
||||
def propose_rule_from_cards(
|
||||
cards: list[dict],
|
||||
*,
|
||||
rule_id: str,
|
||||
name: str,
|
||||
layer: str,
|
||||
carrier_scope: str,
|
||||
trigger: dict,
|
||||
fix_hint: str,
|
||||
function_check: list[str],
|
||||
) -> dict:
|
||||
"""从案例卡生成规则草案;输出状态固定为 candidate。
|
||||
|
||||
归纳可以由模型完成,但“候选”状态由代码固定,不能通过传参偷偷生成
|
||||
active 规则。四类样例引用由后续人工确认/投影步骤补齐。
|
||||
"""
|
||||
if not cards:
|
||||
raise CardError("至少需要一张案例卡才能形成规则候选")
|
||||
for card in cards:
|
||||
validate_card(card)
|
||||
labels = {card["label"] for card in cards}
|
||||
if "sf" not in labels or not labels.intersection({"snf", "boundary", "regression"}):
|
||||
raise CardError(
|
||||
"规则候选至少需要一张 SF 卡和一张 SNF/boundary/regression 卡;"
|
||||
"单一表面偏好不能升格为规则"
|
||||
)
|
||||
card_ids = [card["id"] for card in cards]
|
||||
candidate = {
|
||||
"id": rule_id,
|
||||
"name": name,
|
||||
"layer": layer,
|
||||
"carrier_scope": carrier_scope,
|
||||
"trigger": trigger,
|
||||
"carve_out": [],
|
||||
"default_disposition": "candidate",
|
||||
"function_check": list(function_check),
|
||||
"fix_hint": fix_hint,
|
||||
"samples": {stype: [] for stype in SAMPLE_TYPES},
|
||||
"version": 1,
|
||||
"status": "candidate",
|
||||
"evidence": f"由案例卡归纳:{', '.join(card_ids)};待四类样例与 §9 评测确认",
|
||||
"case_card_ids": card_ids,
|
||||
}
|
||||
validate(candidate, "rule")
|
||||
return candidate
|
||||
58
humanization/src/deai/carriers.py
Normal file
58
humanization/src/deai/carriers.py
Normal file
@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""中文小说载体识别与规则 scope/mask。
|
||||
|
||||
这是保守分类器:只识别有明确边界的对白、代码块和场内载体;无法证明的区域按叙述。
|
||||
分类只用于缩小规则作用域,不把载体分类当作语义裁决。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_RANGE_PATTERNS = (
|
||||
("in_text_carrier", re.compile(r"```.*?```", re.DOTALL)),
|
||||
("in_text_carrier", re.compile(r"【[^】]*】", re.DOTALL)),
|
||||
("dialogue", re.compile(r"「[^」]*」|『[^』]*』|“[^”]*”", re.DOTALL)),
|
||||
)
|
||||
|
||||
|
||||
def carrier_ranges(text: str) -> list[tuple[int, int, str]]:
|
||||
ranges: list[tuple[int, int, str]] = []
|
||||
for carrier, pattern in _RANGE_PATTERNS:
|
||||
ranges.extend((match.start(), match.end(), carrier) for match in pattern.finditer(text))
|
||||
return sorted(ranges, key=lambda item: (item[0], -(item[1] - item[0]), item[2]))
|
||||
|
||||
|
||||
def carrier_at(text: str, start: int, end: int, ranges=None) -> str:
|
||||
ranges = carrier_ranges(text) if ranges is None else ranges
|
||||
containing = [item for item in ranges if item[0] <= start and end <= item[1]]
|
||||
if containing:
|
||||
containing.sort(key=lambda item: (item[1] - item[0], item[2]))
|
||||
return containing[0][2]
|
||||
if any(start < item[1] and end > item[0] for item in ranges):
|
||||
return "mixed"
|
||||
return "narration"
|
||||
|
||||
|
||||
def scope_allows(rule_scope: str, carrier: str) -> bool:
|
||||
if rule_scope == "all":
|
||||
return True
|
||||
if rule_scope == carrier:
|
||||
return True
|
||||
return rule_scope == "monologue" and carrier == "dialogue"
|
||||
|
||||
|
||||
def carve_out_applies(rule: dict, carrier: str) -> bool:
|
||||
"""只机械识别有明确载体词的 carve-out;其余继续交给功能仲裁。"""
|
||||
text = " ".join(str(item) for item in rule.get("carve_out", []))
|
||||
if carrier == "dialogue" and any(token in text for token in ("对白", "角色")):
|
||||
return True
|
||||
if carrier == "in_text_carrier" and any(
|
||||
token in text for token in ("场内", "引文", "代码", "公文", "公告", "文书", "屏幕", "作中作")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["carrier_ranges", "carrier_at", "scope_allows", "carve_out_applies"]
|
||||
414
humanization/src/deai/diagnose.py
Normal file
414
humanization/src/deai/diagnose.py
Normal file
@ -0,0 +1,414 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""分层诊断运行器与诊断产物头校验。
|
||||
|
||||
确定性层支持 regex、handler 与 density;语义层由外部 detector/人工产出 finding,
|
||||
再通过当前正文、active 规则、版本与 layer 绑定门。所有路径只产发现,不修改正文。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from .carriers import carrier_at, carrier_ranges, carve_out_applies, scope_allows
|
||||
from .schemas import validate
|
||||
|
||||
|
||||
class ArtifactIncomplete(ValueError):
|
||||
"""诊断产物头或外部发现不满足合同。"""
|
||||
|
||||
|
||||
def text_hash(text: str) -> str:
|
||||
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def context_window(text: str, start: int, end: int, width: int = 12) -> str:
|
||||
"""span 前后各 width 字符,换行转空格。"""
|
||||
lo, hi = max(0, start - width), min(len(text), end + width)
|
||||
return text[lo:hi].replace("\n", " ")
|
||||
|
||||
|
||||
def _decision(rule: dict, carrier: str) -> tuple[str, str, str]:
|
||||
"""给出保守的诊断建议;命中不自动等于修改命令。"""
|
||||
if carve_out_applies(rule, carrier):
|
||||
return "ask", "carve_out_candidate", "medium"
|
||||
disposition = rule["default_disposition"]
|
||||
if disposition == "blocking" and rule["layer"] == "mechanical":
|
||||
return "repair", "none", "high"
|
||||
if disposition == "advisory":
|
||||
return "ask", "unknown", "low"
|
||||
return "ask", "pending_arbitration", "high"
|
||||
|
||||
|
||||
def _finding(rule: dict, *, finding_id: str, text: str, spans: list[str], start: int, end: int,
|
||||
carrier: str, evidence: str, width: int) -> dict:
|
||||
decision, possible_function, confidence = _decision(rule, carrier)
|
||||
carve_outs = list(rule.get("carve_out", [])) if carve_out_applies(rule, carrier) else []
|
||||
return {
|
||||
"id": finding_id,
|
||||
"text_hash": text_hash(text),
|
||||
"rule_id": rule["id"],
|
||||
"rule_version": rule["version"],
|
||||
"spans": spans,
|
||||
"context_window": context_window(text, start, end, width),
|
||||
"layer": rule["layer"],
|
||||
"evidence": evidence,
|
||||
"possible_function": possible_function,
|
||||
"confidence": confidence,
|
||||
"decision_proposal": decision,
|
||||
"carrier": carrier,
|
||||
"default_disposition": rule["default_disposition"],
|
||||
"carve_out_candidates": carve_outs,
|
||||
}
|
||||
|
||||
|
||||
def _regex_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
rows = []
|
||||
pattern = re.compile(rule["trigger"]["pattern"], re.MULTILINE)
|
||||
for match in pattern.finditer(text):
|
||||
carrier = carrier_at(text, match.start(), match.end(), ranges)
|
||||
if not scope_allows(rule["carrier_scope"], carrier):
|
||||
continue
|
||||
rows.append(_finding(
|
||||
rule,
|
||||
finding_id="",
|
||||
text=text,
|
||||
spans=[match.group(0)],
|
||||
start=match.start(),
|
||||
end=match.end(),
|
||||
carrier=carrier,
|
||||
evidence=f"确定性正则命中:{rule['trigger']['pattern']}",
|
||||
width=width,
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def _sentence_rows(text: str) -> list[tuple[int, int, str]]:
|
||||
pattern = re.compile(r"[^。!?!?\n]+[。!?!?]?", re.MULTILINE)
|
||||
return [
|
||||
(match.start(), match.end(), match.group(0).strip())
|
||||
for match in pattern.finditer(text)
|
||||
if match.group(0).strip()
|
||||
]
|
||||
|
||||
|
||||
def _short_sentence_runs(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
trigger = rule["trigger"]
|
||||
max_chars = int(trigger.get("max_chars", 8))
|
||||
min_run = int(trigger.get("min_run", 4))
|
||||
rows = _sentence_rows(text)
|
||||
findings = []
|
||||
run: list[tuple[int, int, str, str]] = []
|
||||
|
||||
def flush() -> None:
|
||||
if len(run) < min_run:
|
||||
run.clear()
|
||||
return
|
||||
start, end = run[0][0], run[-1][1]
|
||||
carrier = run[0][3]
|
||||
findings.append(_finding(
|
||||
rule,
|
||||
finding_id="",
|
||||
text=text,
|
||||
spans=[item[2] for item in run],
|
||||
start=start,
|
||||
end=end,
|
||||
carrier=carrier,
|
||||
evidence=f"handler=short_sentence_run,连续 {len(run)} 句不超过 {max_chars} 字",
|
||||
width=width,
|
||||
))
|
||||
run.clear()
|
||||
|
||||
for start, end, sentence in rows:
|
||||
carrier = carrier_at(text, start, end, ranges)
|
||||
length = len(re.sub(r"[\s。!?!?]", "", sentence))
|
||||
if length <= max_chars and scope_allows(rule["carrier_scope"], carrier):
|
||||
if run and run[-1][3] != carrier:
|
||||
flush()
|
||||
run.append((start, end, sentence, carrier))
|
||||
else:
|
||||
flush()
|
||||
flush()
|
||||
return findings
|
||||
|
||||
|
||||
def _repeated_sentence_starts(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
trigger = rule["trigger"]
|
||||
min_run = int(trigger.get("min_run", 3))
|
||||
max_chars = int(trigger.get("max_chars", 4))
|
||||
grouped: dict[str, list[tuple[int, int, str, str]]] = defaultdict(list)
|
||||
for start, end, sentence in _sentence_rows(text):
|
||||
carrier = carrier_at(text, start, end, ranges)
|
||||
if not scope_allows(rule["carrier_scope"], carrier):
|
||||
continue
|
||||
normalized = re.sub(r"^[\s「『“]+", "", sentence)
|
||||
prefix = normalized[:max_chars]
|
||||
if prefix:
|
||||
grouped[prefix].append((start, end, sentence, carrier))
|
||||
findings = []
|
||||
for prefix, rows in sorted(grouped.items()):
|
||||
if len(rows) < min_run:
|
||||
continue
|
||||
findings.append(_finding(
|
||||
rule,
|
||||
finding_id="",
|
||||
text=text,
|
||||
spans=[item[2] for item in rows],
|
||||
start=rows[0][0],
|
||||
end=rows[-1][1],
|
||||
carrier=rows[0][3] if len({item[3] for item in rows}) == 1 else "mixed",
|
||||
evidence=f"handler=repeated_sentence_start,句首「{prefix}」重复 {len(rows)} 次",
|
||||
width=width,
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# 摄像头式动作清单:分句切分后,短小且命中动作词、又无因果/心理连接的分句成串出现才算异常。
|
||||
_CLAUSE_SPLIT_PATTERN = re.compile(r"[^,,。!?!?;;::\n]+")
|
||||
_ACTION_CONNECTIVE_PATTERN = re.compile(
|
||||
r"因为|所以|于是|结果|为了|忽然|突然|竟然|不料|心中|心里|觉得|感到|想起|暗想"
|
||||
)
|
||||
|
||||
|
||||
def _camera_action_lists(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
"""连续短动作分句清单(P-11):只报无选择、无因果、无情绪变化的逐帧罗列。
|
||||
|
||||
动作词表放在规则 trigger.pattern 里,handler 只做结构判定;
|
||||
战斗动作链是否保留交给 carve_out 与人工复核,不在这里裁决。
|
||||
"""
|
||||
trigger = rule["trigger"]
|
||||
max_chars = int(trigger.get("max_chars", 6))
|
||||
min_run = int(trigger.get("min_run", 4))
|
||||
pattern = trigger.get("pattern")
|
||||
if not isinstance(pattern, str) or not pattern.strip():
|
||||
raise ArtifactIncomplete(f"规则 {rule['id']}: camera_action_list 缺动作词表 pattern")
|
||||
verb = re.compile(pattern)
|
||||
findings: list[dict] = []
|
||||
run: list[tuple[int, int, str, str]] = []
|
||||
|
||||
def flush() -> None:
|
||||
if len(run) >= min_run:
|
||||
findings.append(_finding(
|
||||
rule,
|
||||
finding_id="",
|
||||
text=text,
|
||||
spans=[item[2] for item in run],
|
||||
start=run[0][0],
|
||||
end=run[-1][1],
|
||||
carrier=run[0][3] if len({item[3] for item in run}) == 1 else "mixed",
|
||||
evidence=f"handler=camera_action_list,连续 {len(run)} 个短动作分句无因果连接",
|
||||
width=width,
|
||||
))
|
||||
run.clear()
|
||||
|
||||
for match in _CLAUSE_SPLIT_PATTERN.finditer(text):
|
||||
clause = re.sub(r"\s", "", match.group(0))
|
||||
carrier = carrier_at(text, match.start(), match.end(), ranges)
|
||||
qualifies = (
|
||||
bool(clause)
|
||||
and len(clause) <= max_chars
|
||||
and verb.search(clause) is not None
|
||||
and _ACTION_CONNECTIVE_PATTERN.search(clause) is None
|
||||
and scope_allows(rule["carrier_scope"], carrier)
|
||||
)
|
||||
if qualifies:
|
||||
if run and run[-1][3] != carrier:
|
||||
flush()
|
||||
run.append((match.start(), match.end(), clause, carrier))
|
||||
else:
|
||||
flush()
|
||||
flush()
|
||||
return findings
|
||||
|
||||
|
||||
def _uniform_paragraph_lengths(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
"""段长过度均匀(P-05 先行子集):连续段落长度贴近均值才提示。
|
||||
|
||||
只量化段长分布;目录要求最终以作品/场景基线定阈值,tolerance 是冷启动
|
||||
默认值,属于可审配置而非固定长短句比例目标。段尾与句法整齐暂不量化。
|
||||
"""
|
||||
trigger = rule["trigger"]
|
||||
min_run = int(trigger.get("min_run", 4))
|
||||
tolerance = int(trigger.get("tolerance", 15)) / 100
|
||||
paragraphs: list[tuple[int, int, str, int, str, bool]] = []
|
||||
for match in re.finditer(r"[^\n]+", text):
|
||||
body = re.sub(r"\s", "", match.group(0))
|
||||
carrier = carrier_at(text, match.start(), match.end(), ranges)
|
||||
allowed = bool(body) and scope_allows(rule["carrier_scope"], carrier)
|
||||
paragraphs.append((match.start(), match.end(), body, len(body), carrier, allowed))
|
||||
findings: list[dict] = []
|
||||
index = 0
|
||||
total = len(paragraphs)
|
||||
while index < total:
|
||||
if not paragraphs[index][5]:
|
||||
index += 1
|
||||
continue
|
||||
end = index
|
||||
while end + 1 < total and paragraphs[end + 1][5]:
|
||||
window = paragraphs[index:end + 2]
|
||||
mean = sum(item[3] for item in window) / len(window)
|
||||
if all(abs(item[3] - mean) <= mean * tolerance for item in window):
|
||||
end += 1
|
||||
else:
|
||||
break
|
||||
count = end - index + 1
|
||||
if count >= min_run:
|
||||
window = paragraphs[index:end + 1]
|
||||
findings.append(_finding(
|
||||
rule,
|
||||
finding_id="",
|
||||
text=text,
|
||||
spans=[item[2] for item in window],
|
||||
start=window[0][0],
|
||||
end=window[-1][1],
|
||||
carrier=window[0][4] if len({item[4] for item in window}) == 1 else "mixed",
|
||||
evidence=(
|
||||
f"handler=uniform_paragraph_length,连续 {count} 段段长贴近均值"
|
||||
f"(容差 {int(tolerance * 100)}%)"
|
||||
),
|
||||
width=width,
|
||||
))
|
||||
index = end + 1
|
||||
else:
|
||||
index += 1
|
||||
return findings
|
||||
|
||||
|
||||
def _handler_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
handler = rule["trigger"]["handler"]
|
||||
if handler == "short_sentence_run":
|
||||
return _short_sentence_runs(text, rule, ranges, width)
|
||||
if handler == "repeated_sentence_start":
|
||||
return _repeated_sentence_starts(text, rule, ranges, width)
|
||||
if handler == "camera_action_list":
|
||||
return _camera_action_lists(text, rule, ranges, width)
|
||||
if handler == "uniform_paragraph_length":
|
||||
return _uniform_paragraph_lengths(text, rule, ranges, width)
|
||||
raise ArtifactIncomplete(f"规则 {rule['id']} 使用未知 handler: {handler}")
|
||||
|
||||
|
||||
def _density_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
|
||||
trigger = rule["trigger"]
|
||||
window_chars = int(trigger["window_chars"])
|
||||
min_hits = int(trigger["min_hits"])
|
||||
matches = []
|
||||
for match in re.finditer(trigger["pattern"], text, flags=re.MULTILINE):
|
||||
carrier = carrier_at(text, match.start(), match.end(), ranges)
|
||||
if scope_allows(rule["carrier_scope"], carrier):
|
||||
matches.append((match.start(), match.end(), match.group(0), carrier))
|
||||
findings = []
|
||||
consumed_until = -1
|
||||
for index, item in enumerate(matches):
|
||||
if item[0] < consumed_until:
|
||||
continue
|
||||
window_end = item[0] + window_chars
|
||||
cluster = [candidate for candidate in matches[index:] if candidate[0] < window_end]
|
||||
if len(cluster) < min_hits:
|
||||
continue
|
||||
start, end = cluster[0][0], cluster[-1][1]
|
||||
carrier = cluster[0][3] if len({entry[3] for entry in cluster}) == 1 else "mixed"
|
||||
findings.append(_finding(
|
||||
rule,
|
||||
finding_id="",
|
||||
text=text,
|
||||
spans=[entry[2] for entry in cluster],
|
||||
start=start,
|
||||
end=end,
|
||||
carrier=carrier,
|
||||
evidence=f"density 窗口 {window_chars} 字内命中 {len(cluster)} 次(阈值 {min_hits})",
|
||||
width=width,
|
||||
))
|
||||
consumed_until = end
|
||||
return findings
|
||||
|
||||
|
||||
def run_deterministic_rules(text: str, rules: list, rule_library_version: str, mode: str,
|
||||
window_width: int = 12) -> dict:
|
||||
"""执行 active 的 regex/handler/density 规则,返回完整诊断产物。"""
|
||||
if not isinstance(text, str) or not text:
|
||||
raise ArtifactIncomplete("诊断文本不能为空")
|
||||
if mode not in {"Audit", "Patch"}:
|
||||
raise ArtifactIncomplete(f"诊断 mode 非法: {mode}")
|
||||
if isinstance(window_width, bool) or not isinstance(window_width, int) or window_width < 0:
|
||||
raise ArtifactIncomplete("诊断 context window 必须是非负整数")
|
||||
ranges = carrier_ranges(text)
|
||||
findings = []
|
||||
for rule in rules:
|
||||
if rule["status"] != "active":
|
||||
continue
|
||||
trigger_type = rule["trigger"]["type"]
|
||||
if trigger_type == "regex":
|
||||
rows = _regex_findings(text, rule, ranges, window_width)
|
||||
elif trigger_type == "handler":
|
||||
rows = _handler_findings(text, rule, ranges, window_width)
|
||||
elif trigger_type == "density":
|
||||
rows = _density_findings(text, rule, ranges, window_width)
|
||||
else:
|
||||
continue
|
||||
for row in rows:
|
||||
row["id"] = f"f{len(findings) + 1}"
|
||||
findings.append(row)
|
||||
return {
|
||||
"text_hash": text_hash(text),
|
||||
"rule_library_version": rule_library_version,
|
||||
"mode": mode,
|
||||
"findings": findings,
|
||||
"executed_layers": sorted({finding["layer"] for finding in findings}),
|
||||
}
|
||||
|
||||
|
||||
def run_regex_rules(text: str, rules: list, rule_library_version: str, mode: str,
|
||||
window_width: int = 12) -> dict:
|
||||
"""兼容旧调用名;现在会执行全部确定性触发器。"""
|
||||
return run_deterministic_rules(text, rules, rule_library_version, mode, window_width)
|
||||
|
||||
|
||||
def merge_model_findings(artifact: dict, external: list, *, rules: dict | None = None,
|
||||
text: str | None = None):
|
||||
"""注入 model_judgment finding,并绑定当前正文与 active 规则。"""
|
||||
base = len(artifact["findings"])
|
||||
for index, item in enumerate(external):
|
||||
validate(item, "finding")
|
||||
if item["text_hash"] != artifact["text_hash"]:
|
||||
raise ArtifactIncomplete(f"外部发现 {item.get('id')} 的 text_hash 与诊断文本不一致")
|
||||
finding = dict(item)
|
||||
if rules is not None:
|
||||
rule = rules.get(finding["rule_id"])
|
||||
if rule is None or rule.get("status") != "active":
|
||||
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 未绑定 active 规则")
|
||||
if rule["trigger"]["type"] != "model_judgment":
|
||||
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 不能冒充确定性规则结果")
|
||||
if finding["rule_version"] != rule["version"] or finding["layer"] != rule["layer"]:
|
||||
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 的规则版本或 layer 不一致")
|
||||
if text is not None:
|
||||
missing = [span for span in finding["spans"] if span not in text]
|
||||
if missing:
|
||||
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 的 span 不在当前正文")
|
||||
if "carrier" not in finding:
|
||||
first = finding["spans"][0]
|
||||
start = text.find(first)
|
||||
finding["carrier"] = carrier_at(text, start, start + len(first)) if start >= 0 else "unknown"
|
||||
finding["id"] = finding.get("id") or f"f{base + index + 1}"
|
||||
artifact["findings"].append(finding)
|
||||
return artifact
|
||||
|
||||
|
||||
def validate_artifact(artifact: dict):
|
||||
"""产物头完整性门禁:缺项即视为诊断未发生。"""
|
||||
for key in ("text_hash", "rule_library_version", "mode", "findings"):
|
||||
if key not in artifact or artifact[key] in (None, ""):
|
||||
raise ArtifactIncomplete(f"诊断产物缺 {key},视为诊断未发生")
|
||||
if artifact["mode"] not in {"Audit", "Patch"}:
|
||||
raise ArtifactIncomplete(f"诊断产物 mode 非法: {artifact['mode']}")
|
||||
if not isinstance(artifact["findings"], list):
|
||||
raise ArtifactIncomplete("诊断产物 findings 必须是数组")
|
||||
for finding in artifact["findings"]:
|
||||
validate(finding, "finding")
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ArtifactIncomplete", "text_hash", "context_window", "run_deterministic_rules",
|
||||
"run_regex_rules", "merge_model_findings", "validate_artifact",
|
||||
]
|
||||
243
humanization/src/deai/evaluation.py
Normal file
243
humanization/src/deai/evaluation.py
Normal file
@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""规则候选的合同评测与激活门。
|
||||
|
||||
本模块只回答“候选是否满足进入评审/激活的机械条件”,不把小样本结果宣称为效果证明。
|
||||
真实 holdout 的独立评测由调用方提供;SF/SNF/Boundary/Regression 四类夹具只做合同回放。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
from . import diagnose
|
||||
from .schemas import validate
|
||||
|
||||
|
||||
class EvaluationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _rule_samples(rule: dict, samples: dict, kind: str) -> list[dict]:
|
||||
rows = []
|
||||
for sample_id in rule.get("samples", {}).get(kind, []):
|
||||
if sample_id not in samples:
|
||||
raise EvaluationError(f"规则 {rule['id']} 缺少 {kind} 样例 {sample_id}")
|
||||
sample = samples[sample_id]
|
||||
if rule["id"] not in sample.get("rules", []):
|
||||
raise EvaluationError(f"样例 {sample_id} 未声明引用规则 {rule['id']}")
|
||||
rows.append(sample)
|
||||
return rows
|
||||
|
||||
|
||||
def _hits(rule: dict, sample: dict) -> list[dict]:
|
||||
if rule["trigger"]["type"] == "model_judgment":
|
||||
return []
|
||||
executable = copy.deepcopy(rule)
|
||||
executable["status"] = "active"
|
||||
artifact = diagnose.run_deterministic_rules(sample["text"], [executable], "v-eval", "Audit")
|
||||
return artifact["findings"]
|
||||
|
||||
|
||||
def evaluate_rule_contract(rule: dict, samples: dict) -> dict:
|
||||
"""回放四类样例,检查触发器召回与默认建议是否越权。"""
|
||||
validate(rule, "rule")
|
||||
counts = {}
|
||||
rows = []
|
||||
model_pending = rule["trigger"]["type"] == "model_judgment"
|
||||
for kind in ("sf", "snf", "boundary", "regression"):
|
||||
sample_rows = _rule_samples(rule, samples, kind)
|
||||
counts[kind] = len(sample_rows)
|
||||
for sample in sample_rows:
|
||||
hits = _hits(rule, sample)
|
||||
decisions = Counter(hit["decision_proposal"] for hit in hits)
|
||||
rows.append({
|
||||
"sample_id": sample["id"],
|
||||
"kind": kind,
|
||||
"surface_hit": bool(hits),
|
||||
"finding_count": len(hits),
|
||||
"decision_proposals": dict(decisions),
|
||||
"safe_default": not any(decision == "repair" for decision in decisions),
|
||||
})
|
||||
sf_rows = [row for row in rows if row["kind"] == "sf"]
|
||||
opposing_rows = [row for row in rows if row["kind"] in {"snf", "boundary", "regression"}]
|
||||
contract_pass = (
|
||||
all(counts[kind] > 0 for kind in ("sf", "snf", "boundary", "regression"))
|
||||
and (model_pending or all(row["surface_hit"] for row in sf_rows))
|
||||
and all(row["safe_default"] for row in opposing_rows)
|
||||
)
|
||||
return {
|
||||
"schema_version": "ai-flavor-rule-evaluation-v1",
|
||||
"rule_id": rule["id"],
|
||||
"rule_version": rule["version"],
|
||||
"rule_library_fragment_sha256": hashlib.sha256(
|
||||
json.dumps(rule, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"dataset_kind": "shipped_contract_samples",
|
||||
"counts": counts,
|
||||
"rows": rows,
|
||||
"model_judgment_pending": model_pending,
|
||||
"contract_pass": contract_pass,
|
||||
"effect_claim": "none: contract replay only; not a holdout effect claim",
|
||||
}
|
||||
|
||||
|
||||
def evaluate_holdout(rule: dict, holdout: dict) -> dict:
|
||||
"""消费外部 holdout 汇总;不接受单一总分代替分层数据。"""
|
||||
if not isinstance(holdout, dict):
|
||||
raise EvaluationError("holdout 必须是对象")
|
||||
required = {
|
||||
"dataset_id", "sf_total", "sf_hit", "snf_total", "snf_false_repair",
|
||||
"boundary_total", "boundary_false_repair", "regression_total", "regression_safe",
|
||||
}
|
||||
missing = sorted(required - set(holdout))
|
||||
if missing:
|
||||
raise EvaluationError(f"holdout 缺少字段: {','.join(missing)}")
|
||||
if not str(holdout["dataset_id"]).strip():
|
||||
raise EvaluationError("holdout dataset_id 不能为空")
|
||||
ints = [key for key in required if key != "dataset_id"]
|
||||
if any(isinstance(holdout[key], bool) or not isinstance(holdout[key], int) or holdout[key] < 0 for key in ints):
|
||||
raise EvaluationError("holdout 计数必须是非负整数")
|
||||
if (holdout["sf_hit"] > holdout["sf_total"]
|
||||
or holdout["snf_false_repair"] > holdout["snf_total"]
|
||||
or holdout["boundary_false_repair"] > holdout["boundary_total"]):
|
||||
raise EvaluationError("holdout 计数关系非法")
|
||||
if holdout["regression_safe"] > holdout["regression_total"]:
|
||||
raise EvaluationError("regression_safe 不能大于 regression_total")
|
||||
# 阈值是可审配置,不是“人味总分”:要求有最小样本、召回和零 SNF 误修。
|
||||
eligible = (
|
||||
holdout["sf_total"] >= 5
|
||||
and holdout["snf_total"] >= 5
|
||||
and holdout["boundary_total"] >= 2
|
||||
and holdout["regression_total"] >= 2
|
||||
and holdout["sf_hit"] / max(holdout["sf_total"], 1) >= 0.8
|
||||
and holdout["snf_false_repair"] == 0
|
||||
and holdout["boundary_false_repair"] == 0
|
||||
and holdout["regression_safe"] == holdout["regression_total"]
|
||||
)
|
||||
return {
|
||||
"dataset_id": str(holdout["dataset_id"]),
|
||||
"counts": {key: holdout[key] for key in ints},
|
||||
"sf_recall": round(holdout["sf_hit"] / max(holdout["sf_total"], 1), 4),
|
||||
"snf_false_repair_rate": round(holdout["snf_false_repair"] / max(holdout["snf_total"], 1), 4),
|
||||
"boundary_false_repair_rate": round(holdout["boundary_false_repair"] / max(holdout["boundary_total"], 1), 4),
|
||||
"regression_safe_rate": round(holdout["regression_safe"] / max(holdout["regression_total"], 1), 4),
|
||||
"eligible": eligible,
|
||||
"claim": "规则判别/修复安全门;不等于产品质量或盲评增益",
|
||||
}
|
||||
|
||||
|
||||
def _rule_fragment_sha256(rule: dict) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(rule, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _validate_contract_report(rule: dict, report: dict) -> None:
|
||||
if not isinstance(report, dict):
|
||||
raise EvaluationError("合同评测报告必须是对象")
|
||||
required = {
|
||||
"schema_version", "rule_id", "rule_version", "rule_library_fragment_sha256",
|
||||
"dataset_kind", "counts", "rows", "contract_pass", "effect_claim",
|
||||
}
|
||||
missing = sorted(required - set(report))
|
||||
if missing:
|
||||
raise EvaluationError(f"合同评测报告不完整,缺少: {','.join(missing)}")
|
||||
if report["schema_version"] != "ai-flavor-rule-evaluation-v1":
|
||||
raise EvaluationError("合同评测报告 schema_version 不支持")
|
||||
if report["rule_id"] != rule.get("id") or report["rule_version"] != rule.get("version"):
|
||||
raise EvaluationError("合同评测报告与规则 id/version 不一致")
|
||||
if report["rule_library_fragment_sha256"] != _rule_fragment_sha256(rule):
|
||||
raise EvaluationError("合同评测报告不是当前规则内容的回放")
|
||||
if not isinstance(report["counts"], dict) or not isinstance(report["rows"], list) or not report["rows"]:
|
||||
raise EvaluationError("合同评测报告缺少分层计数或逐样例 rows")
|
||||
required_kinds = {"sf", "snf", "boundary", "regression"}
|
||||
if set(report["counts"]) != required_kinds:
|
||||
raise EvaluationError("合同评测报告 counts 必须覆盖四类样例")
|
||||
if any(isinstance(report["counts"][kind], bool) or not isinstance(report["counts"][kind], int)
|
||||
or report["counts"][kind] < 0 for kind in required_kinds):
|
||||
raise EvaluationError("合同评测报告 counts 必须是非负整数")
|
||||
row_counts = Counter(row.get("kind") for row in report["rows"] if isinstance(row, dict))
|
||||
if any(report["counts"].get(kind) != row_counts.get(kind, 0) for kind in required_kinds):
|
||||
raise EvaluationError("合同评测报告 counts 与 rows 不一致")
|
||||
if not report.get("contract_pass"):
|
||||
raise EvaluationError("规则合同回放未通过")
|
||||
|
||||
|
||||
def _validate_holdout_report(rule: dict, report: dict) -> dict:
|
||||
if not isinstance(report, dict):
|
||||
raise EvaluationError("holdout 评测报告必须是对象")
|
||||
# holdout 文件也必须是完整 evaluate-rule 输出,不能只携带一个 eligible 布尔值。
|
||||
_validate_contract_report(rule, report)
|
||||
if report.get("rule_id") != rule.get("id") or report.get("rule_version") != rule.get("version"):
|
||||
raise EvaluationError("holdout 评测报告与规则 id/version 不一致")
|
||||
if report.get("rule_library_fragment_sha256") != _rule_fragment_sha256(rule):
|
||||
raise EvaluationError("holdout 评测报告不是当前规则内容的回放")
|
||||
holdout = report.get("holdout")
|
||||
if not isinstance(holdout, dict):
|
||||
raise EvaluationError("缺少完整 holdout 评测报告")
|
||||
required_metrics = {
|
||||
"dataset_id", "sf_recall", "snf_false_repair_rate", "boundary_false_repair_rate",
|
||||
"regression_safe_rate", "counts", "eligible",
|
||||
}
|
||||
if not required_metrics.issubset(holdout):
|
||||
raise EvaluationError("holdout 报告缺少派生指标或分层计数")
|
||||
counts = holdout["counts"]
|
||||
if not isinstance(counts, dict):
|
||||
raise EvaluationError("holdout counts 必须是对象")
|
||||
raw = {"dataset_id": holdout["dataset_id"], **counts}
|
||||
recomputed = evaluate_holdout(rule, raw)
|
||||
for key in ("sf_recall", "snf_false_repair_rate", "boundary_false_repair_rate", "regression_safe_rate", "eligible"):
|
||||
if holdout.get(key) != recomputed[key]:
|
||||
raise EvaluationError(f"holdout 派生字段 {key} 与分层计数不一致")
|
||||
if report.get("eligible") is not True or recomputed["eligible"] is not True:
|
||||
raise EvaluationError("缺少完整且通过的 holdout 评测,规则不得 active")
|
||||
return holdout
|
||||
|
||||
|
||||
def activation_eligibility(rule: dict, contract_report: dict, holdout_report: dict | None,
|
||||
approver: str) -> dict:
|
||||
if rule.get("status") != "candidate":
|
||||
raise EvaluationError("只有 candidate 规则可以激活")
|
||||
if not approver or not approver.strip():
|
||||
raise EvaluationError("激活必须有人工 approver")
|
||||
_validate_contract_report(rule, contract_report)
|
||||
holdout = _validate_holdout_report(rule, holdout_report)
|
||||
return {
|
||||
"eligible": True,
|
||||
"approved_by": approver,
|
||||
"contract_report": contract_report.get("schema_version"),
|
||||
"holdout_dataset_id": holdout["dataset_id"],
|
||||
}
|
||||
|
||||
|
||||
def activate_rule(rule: dict, *, contract_report: dict, holdout_report: dict,
|
||||
approver: str, evidence_note: str = "") -> dict:
|
||||
activation = activation_eligibility(rule, contract_report, holdout_report, approver)
|
||||
out = json.loads(json.dumps(rule, ensure_ascii=False))
|
||||
out["status"] = "active"
|
||||
out["evidence"] = (
|
||||
f"{rule.get('evidence', '')}; holdout={holdout_report['holdout']['dataset_id']}; "
|
||||
f"approved_by={approver}; {evidence_note or 'mechanical activation gate passed'}"
|
||||
)
|
||||
out["activation"] = activation
|
||||
return out
|
||||
|
||||
|
||||
def deprecate_rule(rule: dict, *, approver: str, reason: str) -> dict:
|
||||
if rule.get("status") != "active":
|
||||
raise EvaluationError("只有 active 规则可以 deprecated")
|
||||
if not approver or not reason:
|
||||
raise EvaluationError("降级必须有 approver 和 reason")
|
||||
out = json.loads(json.dumps(rule, ensure_ascii=False))
|
||||
out["status"] = "deprecated"
|
||||
out["deprecation"] = {"approved_by": approver, "reason": reason}
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EvaluationError", "evaluate_rule_contract", "evaluate_holdout", "activation_eligibility",
|
||||
"activate_rule", "deprecate_rule",
|
||||
]
|
||||
177
humanization/src/deai/gates.py
Normal file
177
humanization/src/deai/gates.py
Normal file
@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""硬不变量门(一级:机械部分)。
|
||||
|
||||
U0 契约问题 #1 的修正语义——不再做「提及计数守恒」,改为两层:
|
||||
|
||||
1. 结构校验:候选稿必须等于原文精确重放全部批准 patch。
|
||||
该检查同时机械化保证「未诊断区域触碰率为零」(一级验收 3)。
|
||||
2. 事实增量校验:数字与专名只允许「随批准删除而减少」或「在 replacement 内且不引入新事实」。
|
||||
|
||||
语义级不变量(因果、POV、伏笔状态)机械手段不可靠,如实标 unverified,
|
||||
不假装验证完成(专题-09 §2.2 / §7.1)。
|
||||
"""
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
from .baseline import run_voice_gate
|
||||
from .patch import reapply_from_scratch
|
||||
|
||||
# 阿拉伯数字全集
|
||||
_ARABIC = re.compile(r"\d+")
|
||||
# 中文数字 + 量词/单位常见式样(保守:必须带单位,减少把普通名词误抽为数字)
|
||||
_CN_NUM = re.compile(r"[零一二两三四五六七八九十百千万亿]{1,10}[两钱年天次岁层里丈枚颗人名把条句声步遍章段杯盏桌]")
|
||||
# 引文与场内文本:【】(系统面板/告示)与「」(对白)——§7.1 硬不变量
|
||||
_QUOTES = re.compile(r"【[^】]*】|「[^」]*」")
|
||||
_TEMPORAL = re.compile(
|
||||
r"(?:\d{4}年|\d{1,2}月|\d{1,2}日|\d+天后|\d+年前|次日|当晚|那年|那天|秋分|春分|入秋|冬至|黎明|黄昏)"
|
||||
)
|
||||
_WEAK_MODAL = re.compile(r"(?:多半|大概|可能|也许|似乎|仿佛|未必|或许|应该|大约)")
|
||||
_STRONG_MODAL = re.compile(r"(?:都|必然|一定|肯定|绝不|从不|必定|完全)")
|
||||
|
||||
# 语义级不变量:一级无机械手段,只能标未验证
|
||||
UNVERIFIED_SEMANTIC = [
|
||||
"事件因果与发生顺序",
|
||||
"POV 可知信息边界",
|
||||
"伏笔状态(仅能做关键句存在性检查)",
|
||||
"说话人归属",
|
||||
"程度与不确定性模态",
|
||||
]
|
||||
|
||||
|
||||
def extract_numbers(text: str) -> Counter:
|
||||
"""数字全集 = 阿拉伯数字 + 中文数字×单位(U0 契约问题 #5:不依赖快照登记)。"""
|
||||
return Counter(_ARABIC.findall(text)) + Counter(_CN_NUM.findall(text))
|
||||
|
||||
|
||||
def structural_verify(original: str, candidate: str, patches: list, findings_by_id: dict) -> dict:
|
||||
"""候选稿 == 原文精确重放批准 patch(逐字)。"""
|
||||
replay = reapply_from_scratch(original, patches, findings_by_id)
|
||||
ok = replay == candidate
|
||||
return {
|
||||
"pass": ok,
|
||||
"detail": "候选稿与批准 patch 重放结果逐字一致" if ok else "候选稿存在 patch 之外的改动",
|
||||
}
|
||||
|
||||
|
||||
def fact_delta(patches: list, entity_list: list | None = None) -> dict:
|
||||
"""对每个非空 replacement:不得引入被替换片段中不存在的数字/专名。"""
|
||||
failures = []
|
||||
for i, p in enumerate(patches):
|
||||
repl = p["replacement"]
|
||||
if not repl:
|
||||
continue
|
||||
orig_nums, repl_nums = extract_numbers(p["original_exact"]), extract_numbers(repl)
|
||||
for num in repl_nums - orig_nums:
|
||||
failures.append(f"patch#{i}: replacement 引入新数字 {num!r}")
|
||||
for ent in (entity_list or []):
|
||||
if ent in repl and ent not in p["original_exact"]:
|
||||
failures.append(f"patch#{i}: replacement 引入新专名 {ent!r}")
|
||||
return {"pass": not failures, "failures": failures}
|
||||
|
||||
|
||||
def number_attribution(original: str, candidate: str, patches: list) -> dict:
|
||||
"""数字增减必须可归因于批准的 patch:消失的在删除/压缩片段内,新增的在 replacement 内。"""
|
||||
delta = extract_numbers(candidate) - extract_numbers(original) # 净新增
|
||||
gone = extract_numbers(original) - extract_numbers(candidate) # 净消失
|
||||
allowed_new = Counter()
|
||||
allowed_gone = Counter()
|
||||
for p in patches:
|
||||
allowed_gone += extract_numbers(p["original_exact"]) - extract_numbers(p["replacement"])
|
||||
allowed_new += extract_numbers(p["replacement"]) - extract_numbers(p["original_exact"])
|
||||
failures = []
|
||||
for num, cnt in delta.items():
|
||||
if cnt > allowed_new.get(num, 0):
|
||||
failures.append(f"数字 {num!r} 净增 {cnt},超出批准 patch 可解释范围")
|
||||
for num, cnt in gone.items():
|
||||
if cnt > allowed_gone.get(num, 0):
|
||||
failures.append(f"数字 {num!r} 净减 {cnt},超出批准 patch 可解释范围")
|
||||
return {"pass": not failures, "failures": failures}
|
||||
|
||||
|
||||
def quote_attribution(original: str, candidate: str, patches: list) -> dict:
|
||||
"""引文守恒:原文中的【】/「」引文必须在候选稿中逐字保留,
|
||||
除非缺失数量能由批准 patch 中实际删除的引文数量解释。"""
|
||||
failures = []
|
||||
original_quotes = Counter(_QUOTES.findall(original))
|
||||
candidate_quotes = Counter(_QUOTES.findall(candidate))
|
||||
allowed_gone = Counter()
|
||||
for patch in patches:
|
||||
allowed_gone += Counter(_QUOTES.findall(patch["original_exact"])) - Counter(
|
||||
_QUOTES.findall(patch["replacement"])
|
||||
)
|
||||
for quote, count in original_quotes.items():
|
||||
missing = count - candidate_quotes.get(quote, 0)
|
||||
if missing > allowed_gone.get(quote, 0):
|
||||
failures.append(f"引文/场内文本丢失或篡改: {quote}")
|
||||
return {"pass": not failures, "failures": failures}
|
||||
|
||||
|
||||
def temporal_fact_delta(patches: list) -> dict:
|
||||
"""replacement 不得凭空新增时间锚点;删除整段仍由批准 patch 归因。"""
|
||||
failures = []
|
||||
for index, patch in enumerate(patches):
|
||||
replacement = patch["replacement"]
|
||||
if not replacement:
|
||||
continue
|
||||
original_tokens = Counter(_TEMPORAL.findall(patch["original_exact"]))
|
||||
replacement_tokens = Counter(_TEMPORAL.findall(replacement))
|
||||
for token, count in replacement_tokens.items():
|
||||
if count > original_tokens.get(token, 0):
|
||||
failures.append(f"patch#{index}: replacement 引入新时间锚点 {token!r}")
|
||||
return {"pass": not failures, "failures": failures}
|
||||
|
||||
|
||||
def modality_attribution(patches: list) -> dict:
|
||||
"""防止把推测/不确定改成断言;语义升级必须显式进入共创而不是润色。"""
|
||||
failures = []
|
||||
for index, patch in enumerate(patches):
|
||||
original, replacement = patch["original_exact"], patch["replacement"]
|
||||
if not replacement:
|
||||
continue
|
||||
weak_original = set(_WEAK_MODAL.findall(original))
|
||||
strong_replacement = set(_STRONG_MODAL.findall(replacement))
|
||||
if weak_original and strong_replacement and not weak_original.intersection(_WEAK_MODAL.findall(replacement)):
|
||||
failures.append(
|
||||
f"patch#{index}: 不确定性 {sorted(weak_original)} 被改成强断言 {sorted(strong_replacement)}"
|
||||
)
|
||||
return {"pass": not failures, "failures": failures}
|
||||
|
||||
|
||||
def protected_checks(original: str, candidate: str, voice_thin: dict | None) -> dict:
|
||||
"""薄基线保护:不可改口癖与显式保护片段(专题-09 §4.4 protected_spans)。"""
|
||||
failures = []
|
||||
if voice_thin:
|
||||
for who, tics in (voice_thin.get("untouchable_verbal_tics") or {}).items():
|
||||
for tic in tics:
|
||||
if original.count(tic) > candidate.count(tic):
|
||||
failures.append(f"不可改口癖被删除: {who} 的「{tic}」")
|
||||
for span in voice_thin.get("protected_spans") or []:
|
||||
if original.count(span) > candidate.count(span):
|
||||
failures.append(f"保护片段被改动: {span}")
|
||||
return {"pass": not failures, "failures": failures}
|
||||
|
||||
|
||||
def run_hard_gate(original: str, candidate: str, patches: list, findings_by_id: dict,
|
||||
entity_list: list | None = None, voice_thin: dict | None = None) -> dict:
|
||||
"""硬门汇总:一票否决语义——任一机械检查失败即整体 FAIL。"""
|
||||
checks = {
|
||||
"structural": structural_verify(original, candidate, patches, findings_by_id),
|
||||
"fact_delta": fact_delta(patches, entity_list),
|
||||
"number_attribution": number_attribution(original, candidate, patches),
|
||||
"quote_attribution": quote_attribution(original, candidate, patches),
|
||||
"temporal_fact_delta": temporal_fact_delta(patches),
|
||||
"modality_attribution": modality_attribution(patches),
|
||||
"protected": protected_checks(original, candidate, voice_thin),
|
||||
}
|
||||
overall = all(c["pass"] for c in checks.values())
|
||||
return {
|
||||
"pass": overall,
|
||||
"checks": checks,
|
||||
# 风格分不得抵消硬门失败(专题-09 原则 4/§6 停止条件)
|
||||
"unverified": UNVERIFIED_SEMANTIC,
|
||||
}
|
||||
|
||||
|
||||
def run_voice_drift_gate(original: str, candidate: str, voice_ledger: dict | None) -> dict:
|
||||
"""声音/叙事门统一入口;unknown 不等于 pass。"""
|
||||
return run_voice_gate(original, candidate, voice_ledger)
|
||||
147
humanization/src/deai/load.py
Normal file
147
humanization/src/deai/load.py
Normal file
@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""规则与样例装载。
|
||||
|
||||
核心强制(专题-09 §4.1):没有配齐四类样例(sf/snf/boundary/regression 各至少一条,
|
||||
且引用必须真实存在)的规则不得处于 active。样例缺失不是警告,是拒绝激活——
|
||||
这是「规则没有样例不许入库」的代码形态。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .schemas import validate
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
RULES_DIR = ROOT / "rules"
|
||||
SAMPLES_DIR = ROOT / "samples"
|
||||
|
||||
SAMPLE_TYPES = ("sf", "snf", "boundary", "regression")
|
||||
|
||||
|
||||
class LoadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def load_samples(samples_dir: Path = SAMPLES_DIR, cards: dict | None = None) -> dict:
|
||||
"""装载全部样例(按类别一文件),逐条过样例合同,返回 id -> sample。
|
||||
|
||||
传入 ``cards`` 时,带 ``case_card_id`` 的样例必须反查到 canonical 卡;
|
||||
不传时保持一级骨架的独立运行方式,适合旧样例和迁移阶段。
|
||||
"""
|
||||
samples = {}
|
||||
for path in sorted(samples_dir.glob("*.yaml")):
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
for item in data.get("samples", []):
|
||||
validate(item, "sample")
|
||||
if cards is not None and item.get("case_card_id"):
|
||||
card = cards.get(item["case_card_id"])
|
||||
if card is None:
|
||||
raise LoadError(
|
||||
f"样例 {item['id']} 引用的案例卡不存在: {item['case_card_id']}"
|
||||
)
|
||||
if card.get("state") != "canonical":
|
||||
raise LoadError(
|
||||
f"样例 {item['id']} 引用的案例卡不是 canonical: {item['case_card_id']}"
|
||||
)
|
||||
if item["id"] in samples:
|
||||
raise LoadError(f"样例 id 重复: {item['id']}")
|
||||
samples[item["id"]] = item
|
||||
return samples
|
||||
|
||||
|
||||
def load_rules(
|
||||
rules_dir: Path = RULES_DIR,
|
||||
samples: dict | None = None,
|
||||
cards: dict | None = None,
|
||||
) -> dict:
|
||||
"""装载全部规则(一条一文件),逐条过规则合同,返回 id -> rule。
|
||||
|
||||
samples 提供时做激活校验:active 规则必须配齐四类样例且引用存在。
|
||||
"""
|
||||
rules = {}
|
||||
for path in sorted(rules_dir.glob("*/*.yaml")):
|
||||
rule = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
validate(rule, "rule")
|
||||
# 各触发器的执行合同必须完整;缺项不是警告,是装载失败。
|
||||
trig_type = rule["trigger"]["type"]
|
||||
if trig_type == "regex" and (not isinstance(rule["trigger"].get("pattern"), str)
|
||||
or not rule["trigger"]["pattern"].strip()):
|
||||
raise LoadError(f"规则 {rule['id']}: regex trigger 缺 pattern")
|
||||
if trig_type == "model_judgment" and (not isinstance(rule["trigger"].get("criteria"), str)
|
||||
or not rule["trigger"]["criteria"].strip()):
|
||||
raise LoadError(f"规则 {rule['id']}: model_judgment trigger 缺 criteria")
|
||||
if trig_type == "handler" and (not isinstance(rule["trigger"].get("handler"), str)
|
||||
or not rule["trigger"]["handler"].strip()):
|
||||
raise LoadError(f"规则 {rule['id']}: handler trigger 缺 handler")
|
||||
if trig_type == "density":
|
||||
required = ("pattern", "window_chars", "min_hits")
|
||||
missing = [key for key in required if rule["trigger"].get(key) in (None, "")]
|
||||
if missing:
|
||||
raise LoadError(f"规则 {rule['id']}: density trigger 缺 {','.join(missing)}")
|
||||
if (not isinstance(rule["trigger"]["pattern"], str)
|
||||
or isinstance(rule["trigger"]["window_chars"], bool)
|
||||
or not isinstance(rule["trigger"]["window_chars"], int)
|
||||
or rule["trigger"]["window_chars"] <= 0
|
||||
or isinstance(rule["trigger"]["min_hits"], bool)
|
||||
or not isinstance(rule["trigger"]["min_hits"], int)
|
||||
or rule["trigger"]["min_hits"] <= 0):
|
||||
raise LoadError(f"规则 {rule['id']}: density trigger 数值或 pattern 非法")
|
||||
if rule["id"] in rules:
|
||||
raise LoadError(f"规则 id 重复: {rule['id']}")
|
||||
rules[rule["id"]] = rule
|
||||
if samples is not None:
|
||||
for rule in rules.values():
|
||||
check_activation(rule, samples)
|
||||
if cards is not None:
|
||||
for rule in rules.values():
|
||||
check_case_card_refs(rule, cards)
|
||||
return rules
|
||||
|
||||
|
||||
def check_activation(rule: dict, samples: dict):
|
||||
"""active 规则的四类样例必须齐备且引用真实存在(专题-09 §4.1)。"""
|
||||
if rule["status"] != "active":
|
||||
return
|
||||
for stype in SAMPLE_TYPES:
|
||||
refs = rule["samples"].get(stype, [])
|
||||
if not refs:
|
||||
raise LoadError(
|
||||
f"规则 {rule['id']} 缺少 {stype} 样例,不得 active(专题-09 §4.1)"
|
||||
)
|
||||
for ref in refs:
|
||||
if ref not in samples:
|
||||
raise LoadError(f"规则 {rule['id']} 引用的样例不存在: {ref}")
|
||||
|
||||
|
||||
def check_case_card_refs(rule: dict, cards: dict):
|
||||
"""规则若声明案例卡证据,引用必须存在且为 canonical。
|
||||
|
||||
候选规则允许先引用 shadow 卡(支持快速归纳);只有 active/deprecated
|
||||
规则要求证据卡已 canonical。该检查不把案例卡数量当作激活条件,四类
|
||||
样例与 §9 评测仍是激活门。
|
||||
"""
|
||||
for card_id in rule.get("case_card_ids", []):
|
||||
card = cards.get(card_id)
|
||||
if card is None:
|
||||
raise LoadError(f"规则 {rule['id']} 引用的案例卡不存在: {card_id}")
|
||||
if rule["status"] in ("active", "deprecated") and card.get("state") != "canonical":
|
||||
raise LoadError(
|
||||
f"规则 {rule['id']} 引用的案例卡未确认: {card_id}"
|
||||
)
|
||||
|
||||
|
||||
def active_rules(rules: dict) -> list:
|
||||
return [r for r in rules.values() if r["status"] == "active"]
|
||||
|
||||
|
||||
def rule_library_version(rules: dict) -> str:
|
||||
"""规则库指纹覆盖完整规则内容;未升 version 的内容变化也会使诊断失效。"""
|
||||
payload = json.dumps(
|
||||
{rid: rule for rid, rule in sorted(rules.items())},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "v-" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
33
humanization/src/deai/pairwise.py
Normal file
33
humanization/src/deai/pairwise.py
Normal file
@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跨模型成对选择校验(专题-09 §6 阶段 10)。
|
||||
|
||||
合同强制:
|
||||
- 选择模型必须与改写模型不同——同模型自评存在自偏好偏差,「允许原文胜出」
|
||||
会形同虚设(U0 报告 07 的独立性设置);同名即视为该阶段未执行;
|
||||
- 选择结果必须带理由与模型标识,写入审计报告;
|
||||
- 允许 original 胜出、tie、both_bad——选择不是走形式。
|
||||
"""
|
||||
from .schemas import SchemaError
|
||||
|
||||
|
||||
class PairwiseNotExecuted(ValueError):
|
||||
"""阶段 10 未有效执行(最常见原因:选择模型与改写模型相同)。"""
|
||||
|
||||
|
||||
def validate_choice(record: dict, rewrite_model: str) -> dict:
|
||||
if not isinstance(record, dict):
|
||||
raise PairwiseNotExecuted("成对选择记录缺失")
|
||||
selection_model = record.get("selection_model", "")
|
||||
if not selection_model or selection_model == rewrite_model:
|
||||
raise PairwiseNotExecuted(
|
||||
f"选择模型 {selection_model!r} 与改写模型 {rewrite_model!r} 相同或未提供,"
|
||||
"视为阶段 10 未执行"
|
||||
)
|
||||
# 复用 audit_report 合同中 pairwise_choice 子结构做字段校验
|
||||
from .schemas import _validate, load_schema
|
||||
sub = load_schema("audit_report")["properties"]["final"]["properties"]["pairwise_choice"]
|
||||
try:
|
||||
_validate(record, sub, "pairwise_choice")
|
||||
except SchemaError as e:
|
||||
raise PairwiseNotExecuted(f"成对选择记录不合合同: {e}") from e
|
||||
return record
|
||||
60
humanization/src/deai/patch.py
Normal file
60
humanization/src/deai/patch.py
Normal file
@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""patch 应用:唯一匹配与格式归一。
|
||||
|
||||
合同(专题-09 §4.3 / §6 阶段 6,U0 修正 #6):
|
||||
- 每个 patch 必须对应真实发现(无发现的改动禁止);
|
||||
- original_exact 必须覆盖该发现的至少一个 span——允许紧邻标点的最小扩展
|
||||
(如删除时带上紧邻逗号避免标点粘连),但不允许扩展到与发现无关的区域;
|
||||
- 唯一匹配按应用顺序逐项校验:前面的 patch 可能改变后续片段的匹配数。
|
||||
"""
|
||||
import re
|
||||
|
||||
from .schemas import validate
|
||||
|
||||
|
||||
class PatchError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _covers_any_span(original_exact: str, spans: list) -> bool:
|
||||
return any(s in original_exact for s in spans)
|
||||
|
||||
|
||||
def apply_patches(text: str, patches: list, findings_by_id: dict,
|
||||
normalize_whitespace: bool = False) -> tuple:
|
||||
"""顺序应用 patch;返回 (候选稿, 应用日志)。任一违例整体失败。"""
|
||||
current = text
|
||||
log = []
|
||||
for i, p in enumerate(patches):
|
||||
validate(p, "patch")
|
||||
finding = findings_by_id.get(p["finding_id"])
|
||||
if finding is None:
|
||||
raise PatchError(f"patch#{i}: finding_id={p['finding_id']} 无对应发现,禁止改动")
|
||||
if not _covers_any_span(p["original_exact"], finding["spans"]):
|
||||
raise PatchError(
|
||||
f"patch#{i}: original_exact 未覆盖发现 {p['finding_id']} 的任何 span"
|
||||
)
|
||||
if p["action"] in {"skip", "structural_proposal"}:
|
||||
if p["replacement"] not in {"", p["original_exact"]}:
|
||||
raise PatchError(f"patch#{i}: {p['action']} 不得携带正文替换")
|
||||
# skip/结构提案只进审计,不落正文(专题-09 §5.4 升级阶梯顶端)。
|
||||
log.append(f"patch#{i} {p['finding_id']} {p['action']} 不落正文")
|
||||
continue
|
||||
cnt = current.count(p["original_exact"])
|
||||
if cnt != 1:
|
||||
raise PatchError(f"patch#{i}: original_exact 匹配 {cnt} 次(要求唯一)")
|
||||
current = current.replace(p["original_exact"], p["replacement"], 1)
|
||||
log.append(f"patch#{i} {p['finding_id']} {p['action']} OK")
|
||||
if normalize_whitespace:
|
||||
# U0 契约问题 #4:整行元素删除后合并多余空行;属机械清理,不算内容改动
|
||||
cleaned = re.sub(r"\n{3,}", "\n\n", current)
|
||||
if cleaned != current:
|
||||
log.append("format_normalize: 合并连续空行")
|
||||
current = cleaned
|
||||
return current, log
|
||||
|
||||
|
||||
def reapply_from_scratch(text: str, patches: list, findings_by_id: dict) -> str:
|
||||
"""门禁用:从原文独立重放全部 patch(与增量应用互为校验)。"""
|
||||
result, _ = apply_patches(text, patches, findings_by_id)
|
||||
return result
|
||||
181
humanization/src/deai/pipeline.py
Normal file
181
humanization/src/deai/pipeline.py
Normal file
@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""去 AI 味修订管线:授权、诊断绑定、最小 patch、门禁、复扫与成对选择。"""
|
||||
|
||||
from .diagnose import validate_artifact, run_deterministic_rules
|
||||
from .gates import run_hard_gate, run_voice_drift_gate
|
||||
from .load import active_rules
|
||||
from .pairwise import validate_choice, PairwiseNotExecuted
|
||||
from .patch import apply_patches
|
||||
|
||||
|
||||
class DowngradedToAudit(Exception):
|
||||
"""携带降级原因的受控降级。"""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class RevisionNotAuthorized(ValueError):
|
||||
"""没有作者明确授权,修订不得启动。"""
|
||||
|
||||
|
||||
def resolve_mode(task_contract: dict, fact_snapshot) -> str:
|
||||
"""模式裁决:作者未同意或快照缺失时失败关闭/降级。"""
|
||||
mode = task_contract.get("mode", "Audit")
|
||||
if mode == "Patch":
|
||||
if task_contract.get("author_approved_revision") is not True:
|
||||
raise RevisionNotAuthorized("Patch 必须有 author_approved_revision=true")
|
||||
if not task_contract.get("allowed_scope"):
|
||||
raise RevisionNotAuthorized("Patch 必须声明 allowed_scope")
|
||||
if task_contract.get("round_no", 1) > 3:
|
||||
raise RevisionNotAuthorized("修订轮次超过 3,必须回退并转人工")
|
||||
if fact_snapshot is None and not task_contract.get("no_snapshot_authorization"):
|
||||
raise DowngradedToAudit(
|
||||
"事实快照缺失:Patch 自动降为 Audit;如需继续修订,须显式授权 no_snapshot_authorization"
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def run_audit(text: str, rules: dict, rule_library_version: str) -> dict:
|
||||
return run_deterministic_rules(text, active_rules(rules), rule_library_version, "Audit")
|
||||
|
||||
|
||||
def _same_finding_signature(finding: dict) -> set[tuple[str, str]]:
|
||||
return {(finding["rule_id"], span) for span in finding.get("spans", [])}
|
||||
|
||||
|
||||
def _scope_allows_patch(text: str, finding: dict, patch: dict, allowed_scope) -> bool:
|
||||
"""把作者授权范围落到 finding;全文授权以外的范围必须可机械解释。"""
|
||||
if isinstance(allowed_scope, str):
|
||||
return allowed_scope.strip().lower() in {"全文", "全章", "all", "whole_text", "whole_chapter"}
|
||||
if isinstance(allowed_scope, (list, tuple, set)):
|
||||
return finding.get("id") in allowed_scope
|
||||
if not isinstance(allowed_scope, dict):
|
||||
return False
|
||||
finding_ids = allowed_scope.get("finding_ids")
|
||||
if finding_ids is not None and finding.get("id") not in finding_ids:
|
||||
return False
|
||||
if "start" in allowed_scope or "end" in allowed_scope:
|
||||
start = allowed_scope.get("start")
|
||||
end = allowed_scope.get("end")
|
||||
if (isinstance(start, bool) or isinstance(end, bool)
|
||||
or not isinstance(start, int) or not isinstance(end, int) or start < 0 or end < start):
|
||||
return False
|
||||
patch_start = text.find(patch["original_exact"])
|
||||
patch_end = patch_start + len(patch["original_exact"])
|
||||
if patch_start < start or patch_end > end:
|
||||
return False
|
||||
return finding_ids is not None or "start" in allowed_scope or "end" in allowed_scope
|
||||
|
||||
|
||||
def _validate_artifact_bindings(text: str, artifact: dict, rules: dict, rule_library_version: str) -> None:
|
||||
"""拒绝伪造/过期发现:每条 finding 必须绑定当前 active 规则;确定性命中还要能回放。"""
|
||||
active = {rule["id"]: rule for rule in active_rules(rules)}
|
||||
replay = run_deterministic_rules(text, list(active.values()), rule_library_version, "Audit")
|
||||
replay_signatures = set()
|
||||
for finding in replay["findings"]:
|
||||
replay_signatures |= _same_finding_signature(finding)
|
||||
for finding in artifact["findings"]:
|
||||
rule = active.get(finding.get("rule_id"))
|
||||
if rule is None:
|
||||
raise ValueError(f"发现 {finding.get('id')} 未绑定当前 active 规则")
|
||||
if finding.get("rule_version") != rule.get("version") or finding.get("layer") != rule.get("layer"):
|
||||
raise ValueError(f"发现 {finding.get('id')} 的规则版本或 layer 与当前规则不一致")
|
||||
if rule["trigger"]["type"] != "model_judgment":
|
||||
signature = _same_finding_signature(finding)
|
||||
if not signature or not signature.issubset(replay_signatures):
|
||||
raise ValueError(f"确定性发现 {finding.get('id')} 无法由当前规则回放")
|
||||
|
||||
|
||||
def _regression_gate(original_artifact: dict, rescan: dict, patches: list, findings_by_id: dict) -> dict:
|
||||
original_signatures = set()
|
||||
for finding in original_artifact["findings"]:
|
||||
original_signatures |= _same_finding_signature(finding)
|
||||
targeted_signatures = set()
|
||||
targeted_rule_ids = set()
|
||||
for patch in patches:
|
||||
finding = findings_by_id[patch["finding_id"]]
|
||||
targeted_rule_ids.add(finding["rule_id"])
|
||||
targeted_signatures |= _same_finding_signature(finding)
|
||||
|
||||
residual_targeted = []
|
||||
new_hits = []
|
||||
kept_hits = []
|
||||
for finding in rescan["findings"]:
|
||||
signature = _same_finding_signature(finding)
|
||||
if signature & targeted_signatures:
|
||||
residual_targeted.append(finding)
|
||||
elif signature & original_signatures:
|
||||
kept_hits.append(finding)
|
||||
else:
|
||||
new_hits.append(finding)
|
||||
return {
|
||||
"pass": not residual_targeted and not new_hits,
|
||||
"residual_targeted": residual_targeted,
|
||||
"new_hits": new_hits,
|
||||
"kept_existing_hits": kept_hits,
|
||||
"residual_hits": residual_targeted + new_hits,
|
||||
"targeted_rule_ids": sorted(targeted_rule_ids),
|
||||
"note": "只允许原有 keep/ask 命中保留;被修命中必须消失,不能产生新命中",
|
||||
}
|
||||
|
||||
|
||||
def run_patch(text: str, rules: dict, artifact: dict, patches: list, task_contract: dict,
|
||||
fact_snapshot, voice_thin: dict | None, rewrite_model: str,
|
||||
pairwise_record: dict | None, rule_library_version: str) -> dict:
|
||||
"""Patch:应用 -> 硬门 -> 声音门 -> 同规则复扫 -> 跨模型选择。"""
|
||||
mode = resolve_mode(task_contract, fact_snapshot)
|
||||
if mode != "Patch":
|
||||
raise DowngradedToAudit("非 Patch 模式不得执行修订")
|
||||
validate_artifact(artifact)
|
||||
if artifact["rule_library_version"] != rule_library_version:
|
||||
raise ValueError("诊断产物使用了过期规则库,必须重新诊断")
|
||||
if artifact["text_hash"] != run_deterministic_rules(text, [], rule_library_version, "Patch")["text_hash"]:
|
||||
raise ValueError("artifact.text_hash 与目标文本不一致:诊断对象不是本文本")
|
||||
_validate_artifact_bindings(text, artifact, rules, rule_library_version)
|
||||
if voice_thin is not None and voice_thin.get("status", "canonical") != "canonical":
|
||||
raise ValueError("修订只能消费已确认 canonical 声音账")
|
||||
|
||||
findings_by_id = {finding["id"]: finding for finding in artifact["findings"]}
|
||||
for patch in patches:
|
||||
finding = findings_by_id.get(patch.get("finding_id"))
|
||||
if finding is None:
|
||||
raise ValueError(f"patch {patch.get('finding_id')} 没有对应发现")
|
||||
if not _scope_allows_patch(text, finding, patch, task_contract.get("allowed_scope")):
|
||||
raise RevisionNotAuthorized(f"patch {patch.get('finding_id')} 超出作者 allowed_scope")
|
||||
if finding.get("decision_proposal") != "repair":
|
||||
raise ValueError(f"发现 {finding['id']} 未经过 repair 仲裁,不能执行 patch")
|
||||
if not finding.get("arbitration_note"):
|
||||
raise ValueError(f"发现 {finding['id']} 缺少功能仲裁记录")
|
||||
|
||||
candidate, apply_log = apply_patches(text, patches, findings_by_id)
|
||||
hard_gate = run_hard_gate(
|
||||
text,
|
||||
candidate,
|
||||
patches,
|
||||
findings_by_id,
|
||||
entity_list=(fact_snapshot or {}).get("entities"),
|
||||
voice_thin=voice_thin,
|
||||
)
|
||||
voice_gate = run_voice_drift_gate(text, candidate, voice_thin)
|
||||
rescan = run_deterministic_rules(candidate, active_rules(rules), rule_library_version, "Patch")
|
||||
regression_gate = _regression_gate(artifact, rescan, patches, findings_by_id)
|
||||
if pairwise_record is None:
|
||||
raise PairwiseNotExecuted("Patch 必须有独立选择模型的成对选择记录")
|
||||
pairwise = validate_choice(pairwise_record, rewrite_model)
|
||||
|
||||
return {
|
||||
"mode": mode,
|
||||
"candidate_text": candidate,
|
||||
"apply_log": apply_log,
|
||||
"hard_gate": hard_gate,
|
||||
"voice_gate": voice_gate,
|
||||
"regression_gate": regression_gate,
|
||||
"pairwise_choice": pairwise,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DowngradedToAudit", "RevisionNotAuthorized", "resolve_mode", "run_audit", "run_patch",
|
||||
]
|
||||
65
humanization/src/deai/report.py
Normal file
65
humanization/src/deai/report.py
Normal file
@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""审计报告组装与校验(专题-09 §4.5)。
|
||||
|
||||
禁止单一总分:输出合同不含、也不允许运行时塞入「真人率 / 人味分」类字段——
|
||||
检测器分与内部自评分只做辅助诊断(原则 8),总分是它们冒充质量的通道。
|
||||
"""
|
||||
from .schemas import validate
|
||||
|
||||
_FORBIDDEN_SCORE_KEYS = {
|
||||
"human_score", "natural_score", "realness", "人味分", "真人率", "detector_score",
|
||||
}
|
||||
|
||||
|
||||
class ForbiddenScoreError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _walk_keys(node, path=""):
|
||||
"""递归检查键名:任何总分字段出现在任何层级都拒绝。"""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if k in _FORBIDDEN_SCORE_KEYS:
|
||||
raise ForbiddenScoreError(f"{path}.{k}: 禁止单一总分类字段")
|
||||
_walk_keys(v, f"{path}.{k}")
|
||||
elif isinstance(node, list):
|
||||
for i, v in enumerate(node):
|
||||
_walk_keys(v, f"{path}[{i}]")
|
||||
|
||||
|
||||
def assemble(artifact: dict, patches: list, candidate_text, hard_gate: dict,
|
||||
voice_gate: dict, regression_gate: dict, pairwise_choice, unresolved_risks: list) -> dict:
|
||||
"""按 §4.5 组装审计报告。pairwise_choice 在 Audit 模式传 None。"""
|
||||
findings = artifact["findings"]
|
||||
kept = [
|
||||
{"finding_id": f["id"], "reason": f.get("arbitration_note", "")}
|
||||
for f in findings if f["decision_proposal"] == "keep"
|
||||
]
|
||||
# kept_by_design 必须带理由:无理由的「保留」是不可审计的黑箱
|
||||
for k in kept:
|
||||
if not k["reason"]:
|
||||
raise ForbiddenScoreError(f"kept_by_design {k['finding_id']} 缺理由")
|
||||
report = {
|
||||
"summary": {
|
||||
"high_confidence_findings": [f["id"] for f in findings if f["confidence"] == "high"],
|
||||
"advisory_findings": [f["id"] for f in findings if f["confidence"] in ("low", "candidate")],
|
||||
"kept_by_design": kept,
|
||||
"needs_author_decision": [
|
||||
{"finding_id": f["id"], "question": f.get("arbitration_note", "需要作者决定")}
|
||||
for f in findings if f["decision_proposal"] == "ask"
|
||||
],
|
||||
},
|
||||
"patches": patches,
|
||||
"final": {
|
||||
"hard_gate_result": hard_gate,
|
||||
"voice_gate_result": voice_gate,
|
||||
"regression_gate_result": regression_gate,
|
||||
"pairwise_choice": pairwise_choice,
|
||||
"unresolved_risks": unresolved_risks,
|
||||
},
|
||||
}
|
||||
if candidate_text is not None:
|
||||
report["final"]["candidate_text"] = candidate_text
|
||||
_walk_keys(report)
|
||||
validate(report, "audit_report")
|
||||
return report
|
||||
67
humanization/src/deai/schemas.py
Normal file
67
humanization/src/deai/schemas.py
Normal file
@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JSON Schema 子集校验器。
|
||||
|
||||
为什么不直接用 jsonschema 库:内网 PyPI 不可达,依赖面必须控制在标准库 + PyYAML。
|
||||
本校验器只实现合同用到的子集:type / required / properties / items / enum /
|
||||
minItems / minLength。合同以 contracts/*.schema.json 为唯一事实源,
|
||||
代码不另维护一套字段清单,避免双份合同漂移(专题-09 反例:humanize-text 文档实现漂移)。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CONTRACTS_DIR = Path(__file__).resolve().parent.parent.parent / "contracts"
|
||||
|
||||
_TYPE_CHECKS = {
|
||||
"string": lambda v: isinstance(v, str),
|
||||
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
||||
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
||||
"boolean": lambda v: isinstance(v, bool),
|
||||
"array": lambda v: isinstance(v, list),
|
||||
"object": lambda v: isinstance(v, dict),
|
||||
"null": lambda v: v is None,
|
||||
}
|
||||
|
||||
|
||||
class SchemaError(ValueError):
|
||||
"""合同违例。路径信息必须保留——审计要能定位到具体字段。"""
|
||||
|
||||
def __init__(self, path, message):
|
||||
super().__init__(f"{path}: {message}")
|
||||
self.path = path
|
||||
|
||||
|
||||
def load_schema(name: str) -> dict:
|
||||
return json.loads((CONTRACTS_DIR / f"{name}.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _validate(instance, schema, path):
|
||||
expected = schema.get("type")
|
||||
if expected:
|
||||
# type 允许是列表(如 ["object", "null"])
|
||||
types = expected if isinstance(expected, list) else [expected]
|
||||
if not any(_TYPE_CHECKS[t](instance) for t in types):
|
||||
raise SchemaError(path, f"类型应为 {types},实际 {type(instance).__name__}")
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
raise SchemaError(path, f"取值 {instance!r} 不在允许集合 {schema['enum']}")
|
||||
if isinstance(instance, str):
|
||||
if schema.get("minLength") is not None and len(instance) < schema["minLength"]:
|
||||
raise SchemaError(path, f"长度不足 {schema['minLength']}")
|
||||
if isinstance(instance, list):
|
||||
if schema.get("minItems") is not None and len(instance) < schema["minItems"]:
|
||||
raise SchemaError(path, f"元素少于 {schema['minItems']}")
|
||||
item_schema = schema.get("items")
|
||||
if item_schema:
|
||||
for i, item in enumerate(instance):
|
||||
_validate(item, item_schema, f"{path}[{i}]")
|
||||
if isinstance(instance, dict):
|
||||
for key in schema.get("required", []):
|
||||
if key not in instance:
|
||||
raise SchemaError(path, f"缺少必填字段 {key}")
|
||||
for key, sub in schema.get("properties", {}).items():
|
||||
if key in instance:
|
||||
_validate(instance[key], sub, f"{path}.{key}" if path else key)
|
||||
|
||||
|
||||
def validate(instance, schema_name: str):
|
||||
"""按合同名校验实例;违例抛 SchemaError。"""
|
||||
_validate(instance, load_schema(schema_name), schema_name)
|
||||
6
humanization/tests/__init__.py
Normal file
6
humanization/tests/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试包初始化:把 src/ 加入导入路径(不依赖 pip install,内网环境直接可跑)。"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
420
humanization/tests/test_contracts.py
Normal file
420
humanization/tests/test_contracts.py
Normal file
@ -0,0 +1,420 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""合同负路测试 + U0 端到端回放。
|
||||
|
||||
覆盖专题-09 §12 一级验收中可自动化的条目:
|
||||
- 验收 4:诊断产物头缺项时修订拒绝执行
|
||||
- 验收 6:事实快照缺失时 Patch 自动降为 Audit(无显式授权不得继续)
|
||||
- 验收 7:成对选择模型与改写模型相同时视为阶段未执行
|
||||
其余为合同负路(无发现禁改、唯一匹配、样例不齐拒绝激活、结构校验、事实增量、口癖保护)。
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# 内网环境不 pip install:直接把 src/ 加入导入路径
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
from deai import cards, diagnose, gates, load, pairwise, patch, report
|
||||
from deai.pipeline import DowngradedToAudit, resolve_mode, run_audit, run_patch
|
||||
|
||||
|
||||
class TestAssetCompleteness(unittest.TestCase):
|
||||
"""规则库/样例库装载:active 规则必须配齐四类样例(专题-09 §4.1)。"""
|
||||
|
||||
def test_all_shipped_rules_load_with_complete_samples(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
actives = load.active_rules(rules)
|
||||
# 一级目标:至少 10 条 active 规则,每条四类样例齐全(装载器已强制)
|
||||
self.assertGreaterEqual(len(actives), 10)
|
||||
for rule in actives:
|
||||
for stype in load.SAMPLE_TYPES:
|
||||
self.assertTrue(rule["samples"][stype], f"{rule['id']} 缺 {stype}")
|
||||
for sample_id in rule["samples"][stype]:
|
||||
self.assertIn(rule["id"], samples[sample_id].get("rules", []))
|
||||
|
||||
def test_rule_without_four_sample_types_rejected(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
broken = dict(rules["l002"])
|
||||
broken["samples"] = dict(broken["samples"])
|
||||
broken["samples"]["regression"] = [] # 缺回归陷阱样例
|
||||
with self.assertRaises(load.LoadError):
|
||||
load.check_activation(broken, samples)
|
||||
|
||||
def test_rule_referencing_missing_sample_rejected(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
broken = dict(rules["l002"])
|
||||
broken["samples"] = dict(broken["samples"])
|
||||
broken["samples"]["regression"] = ["不存在的样例id"]
|
||||
with self.assertRaises(load.LoadError):
|
||||
load.check_activation(broken, samples)
|
||||
|
||||
|
||||
class TestCaseCards(unittest.TestCase):
|
||||
"""反向回填/创作反馈的案例卡:快采集,慢确认,禁止越过资产门。"""
|
||||
|
||||
def _capture(self, license_name="owned", label="sf", mode="backfill"):
|
||||
return cards.capture_case_card(
|
||||
card_id=f"card-{license_name}-{label}-{mode}",
|
||||
label=label,
|
||||
layer="lexical",
|
||||
carrier="narration",
|
||||
source_kind="existing_work" if mode == "backfill" else "creation_feedback",
|
||||
source_license=license_name,
|
||||
source_text="来源全文:值得注意的是,门外下雨了。",
|
||||
excerpt="值得注意的是,门外下雨了。",
|
||||
context="她抬头。值得注意的是,门外下雨了。",
|
||||
location="chapter-1:paragraph-2",
|
||||
pattern="无功能元话语",
|
||||
rationale="待复核的表面模式观察",
|
||||
function_check=["是否承担转折"],
|
||||
risk_if_changed="可能误删节奏或信息",
|
||||
capture_mode=mode,
|
||||
)
|
||||
|
||||
def test_shipped_case_card_fixture_uses_current_schema(self):
|
||||
loaded = cards.load_case_cards()
|
||||
self.assertIn("card-backfill-l002-001", loaded)
|
||||
self.assertEqual(loaded["card-backfill-l002-001"]["schema_version"], "ai-flavor-case-v1")
|
||||
|
||||
def test_backfill_starts_as_shadow_and_confirmation_is_explicit(self):
|
||||
shadow = self._capture()
|
||||
self.assertEqual(shadow["state"], "shadow")
|
||||
with self.assertRaises(cards.CardError):
|
||||
cards.promote_card_to_sample(shadow)
|
||||
canonical = cards.confirm_case_card(
|
||||
shadow, label="sf", review_note="作者确认:此处无功能"
|
||||
)
|
||||
sample = cards.promote_card_to_sample(canonical)
|
||||
self.assertEqual(sample["case_card_id"], canonical["id"])
|
||||
self.assertEqual(sample["type"], "sf")
|
||||
|
||||
def test_unlicensed_source_is_hash_only_and_cannot_be_confirmed(self):
|
||||
card = self._capture(license_name="unauthorized")
|
||||
self.assertEqual(card["excerpt"], "")
|
||||
self.assertEqual(card["context"], "")
|
||||
self.assertIn("excerpt_hash", card["source"])
|
||||
with self.assertRaises(cards.CardError):
|
||||
cards.confirm_case_card(card, label="sf", review_note="不应确认")
|
||||
|
||||
def test_hash_only_card_rejects_text_injection(self):
|
||||
card = self._capture(license_name="research_only")
|
||||
broken = dict(card)
|
||||
broken["excerpt"] = "偷偷保存的第三方原文"
|
||||
with self.assertRaises(cards.CardError):
|
||||
cards.validate_card(broken)
|
||||
|
||||
def test_rule_candidate_is_always_candidate_and_needs_opposing_evidence(self):
|
||||
sf = self._capture(label="sf")
|
||||
snf = self._capture(label="snf")
|
||||
candidate = cards.propose_rule_from_cards(
|
||||
[sf, snf],
|
||||
rule_id="candidate-l999",
|
||||
name="待评估元话语",
|
||||
layer="lexical",
|
||||
carrier_scope="narration",
|
||||
trigger={"type": "model_judgment", "criteria": "上下文功能判断"},
|
||||
fix_hint="先仲裁再决定",
|
||||
function_check=["是否承担叙事功能"],
|
||||
)
|
||||
self.assertEqual(candidate["status"], "candidate")
|
||||
self.assertEqual(candidate["default_disposition"], "candidate")
|
||||
# 候选规则可以先引用 shadow 卡,供后续人工确认;不得因此变 active。
|
||||
load.check_case_card_refs(candidate, {sf["id"]: sf, snf["id"]: snf})
|
||||
active = dict(candidate)
|
||||
active["status"] = "active"
|
||||
with self.assertRaises(load.LoadError):
|
||||
load.check_case_card_refs(active, {sf["id"]: sf, snf["id"]: snf})
|
||||
with self.assertRaises(cards.CardError):
|
||||
cards.propose_rule_from_cards(
|
||||
[sf],
|
||||
rule_id="candidate-l998",
|
||||
name="单样本禁令",
|
||||
layer="lexical",
|
||||
carrier_scope="narration",
|
||||
trigger={"type": "model_judgment", "criteria": "读感"},
|
||||
fix_hint="删除",
|
||||
function_check=["是否有功能"],
|
||||
)
|
||||
|
||||
def test_live_feedback_requires_feedback_source(self):
|
||||
card = self._capture(mode="live_feedback")
|
||||
self.assertEqual(card["source"]["kind"], "creation_feedback")
|
||||
broken = dict(card)
|
||||
broken["source"] = dict(card["source"])
|
||||
broken["source"]["kind"] = "existing_work"
|
||||
with self.assertRaises(cards.CardError):
|
||||
cards.validate_card(broken)
|
||||
|
||||
|
||||
class TestNegativePaths(unittest.TestCase):
|
||||
"""一级验收 4/6/7 的负路(必须可测、必须红)。"""
|
||||
|
||||
def test_artifact_header_missing_blocks_patch(self):
|
||||
# 验收 4:产物头缺项 → 视为诊断未发生
|
||||
artifact = {"text_hash": "sha256:x", "findings": []} # 缺 mode/规则库版本
|
||||
with self.assertRaises(diagnose.ArtifactIncomplete):
|
||||
diagnose.validate_artifact(artifact)
|
||||
|
||||
def test_snapshot_missing_downgrades_to_audit(self):
|
||||
# 验收 6:快照缺失且无授权 → Patch 强制降为 Audit
|
||||
with self.assertRaises(DowngradedToAudit):
|
||||
resolve_mode({"mode": "Patch", "author_approved_revision": True, "allowed_scope": "全文"}, fact_snapshot=None)
|
||||
|
||||
def test_snapshot_missing_with_explicit_authorization_proceeds(self):
|
||||
mode = resolve_mode(
|
||||
{"mode": "Patch", "author_approved_revision": True, "allowed_scope": "全文", "no_snapshot_authorization": True}, fact_snapshot=None)
|
||||
self.assertEqual(mode, "Patch")
|
||||
|
||||
def test_same_model_pairwise_treated_as_not_executed(self):
|
||||
# 验收 7:选择模型 == 改写模型 → 阶段 10 视为未执行
|
||||
record = {"choice": "candidate", "rationale": "理由", "selection_model": "claude"}
|
||||
with self.assertRaises(pairwise.PairwiseNotExecuted):
|
||||
pairwise.validate_choice(record, rewrite_model="claude")
|
||||
|
||||
def test_pairwise_without_rationale_rejected(self):
|
||||
record = {"choice": "candidate", "rationale": "", "selection_model": "gpt-5.6-sol"}
|
||||
with self.assertRaises(pairwise.PairwiseNotExecuted):
|
||||
pairwise.validate_choice(record, rewrite_model="claude")
|
||||
|
||||
|
||||
class TestPatchContracts(unittest.TestCase):
|
||||
def _finding(self, span):
|
||||
return {
|
||||
"id": "f1", "text_hash": "sha256:0000000000000000",
|
||||
"rule_id": "l002", "rule_version": 1, "spans": [span],
|
||||
"context_window": span, "layer": "lexical", "evidence": "测试",
|
||||
"possible_function": "none", "confidence": "high",
|
||||
"decision_proposal": "repair",
|
||||
}
|
||||
|
||||
def _patch(self, finding_id="f1", original="值得注意的是,", replacement="", action="delete"):
|
||||
return {"finding_id": finding_id, "action": action,
|
||||
"original_exact": original, "replacement": replacement,
|
||||
"rationale": "测试删除", "protected_invariants": []}
|
||||
|
||||
def test_patch_without_finding_rejected(self):
|
||||
with self.assertRaises(patch.PatchError):
|
||||
patch.apply_patches("值得注意的是,他来了。", [self._patch()], {})
|
||||
|
||||
def test_patch_nonunique_match_rejected(self):
|
||||
text = "值得注意的是,值得注意的是,他来了。"
|
||||
findings = {"f1": self._finding("值得注意的是")}
|
||||
with self.assertRaises(patch.PatchError):
|
||||
patch.apply_patches(text, [self._patch()], findings)
|
||||
|
||||
def test_patch_not_covering_span_rejected(self):
|
||||
# original_exact 与发现 span 无关 → 禁止(防止借诊断之名改别处)
|
||||
findings = {"f1": self._finding("他来了")}
|
||||
with self.assertRaises(patch.PatchError):
|
||||
patch.apply_patches("值得注意的是,他来了。", [self._patch()], findings)
|
||||
|
||||
def test_skip_patch_cannot_smuggle_replacement(self):
|
||||
finding = self._finding("值得注意的是")
|
||||
malicious = self._patch(action="skip", replacement="他突然笑了")
|
||||
with self.assertRaises(patch.PatchError):
|
||||
patch.apply_patches("值得注意的是,他来了。", [malicious], {"f1": finding})
|
||||
|
||||
def test_patch_does_not_normalize_unrelated_blank_lines(self):
|
||||
finding = self._finding("值得注意的是")
|
||||
text = "前文。\n\n\n值得注意的是,他来了。"
|
||||
candidate, _ = patch.apply_patches(
|
||||
text, [self._patch(original="值得注意的是,", replacement="")], {"f1": finding}
|
||||
)
|
||||
self.assertTrue(candidate.startswith("前文。\n\n\n"))
|
||||
|
||||
def test_adjacent_punctuation_extension_allowed(self):
|
||||
# U0 修正 #6:span 允许紧邻标点的最小扩展(避免删除后标点粘连)
|
||||
text = "他打量她,嘴角微微上扬。「姑娘」"
|
||||
finding = self._finding("嘴角微微上扬")
|
||||
p = self._patch(original=",嘴角微微上扬", replacement="")
|
||||
candidate, _ = patch.apply_patches(text, [p], {"f1": finding})
|
||||
self.assertEqual(candidate, "他打量她。「姑娘」")
|
||||
|
||||
|
||||
class TestHardGate(unittest.TestCase):
|
||||
def test_structural_verify_detects_outside_change(self):
|
||||
original = "她关上门。值得注意的是,天黑了。"
|
||||
patches = [{"finding_id": "f1", "action": "delete",
|
||||
"original_exact": "值得注意的是,", "replacement": "",
|
||||
"rationale": "测试", "protected_invariants": []}]
|
||||
findings = {"f1": {"id": "f1", "spans": ["值得注意的是,"]}}
|
||||
# 候选稿在 patch 之外还被偷偷改了一处 → 结构校验必须红
|
||||
tampered = "她关上门。天黑了。另外多出一句。"
|
||||
result = gates.structural_verify(original, tampered, patches, findings)
|
||||
self.assertFalse(result["pass"])
|
||||
|
||||
def test_fact_delta_blocks_new_number(self):
|
||||
patches = [{"finding_id": "f1", "action": "local_rewrite",
|
||||
"original_exact": "值三百两银子", "replacement": "值三百五十两银子",
|
||||
"rationale": "测试", "protected_invariants": []}]
|
||||
result = gates.fact_delta(patches)
|
||||
self.assertFalse(result["pass"])
|
||||
|
||||
def test_number_attribution_allows_approved_deletion(self):
|
||||
# 数字随批准删除消失是合法的(U0 修正 #1:不做计数守恒)
|
||||
original = "值三百两银子。值得一提的是,三天后开门。"
|
||||
candidate = "值三百两银子。开门。"
|
||||
patches = [{"finding_id": "f1", "action": "delete",
|
||||
"original_exact": "值得一提的是,三天后", "replacement": "",
|
||||
"rationale": "测试", "protected_invariants": []}]
|
||||
result = gates.number_attribution(original, candidate, patches)
|
||||
self.assertTrue(result["pass"], result["failures"])
|
||||
|
||||
def test_number_attribution_blocks_unexplained_disappearance(self):
|
||||
original = "值三百两银子。"
|
||||
candidate = "值银子。" # 数字凭空消失,无对应 patch
|
||||
result = gates.number_attribution(original, candidate, [])
|
||||
self.assertFalse(result["pass"])
|
||||
|
||||
def test_quote_tamper_fails(self):
|
||||
# 引文/场内文本被篡改(未在任何批准删除片段内)→ 必须红
|
||||
result = gates.quote_attribution(
|
||||
"面板显示:【等级:待定】。", "面板显示:【等级:最高】。", [])
|
||||
self.assertFalse(result["pass"])
|
||||
|
||||
def test_quote_duplicate_loss_cannot_hide_behind_one_deleted_quote(self):
|
||||
patches = [{"finding_id": "f1", "action": "delete",
|
||||
"original_exact": "前文【同一条】", "replacement": "",
|
||||
"rationale": "测试", "protected_invariants": []}]
|
||||
result = gates.quote_attribution(
|
||||
"前文【同一条】。后文【同一条】。",
|
||||
"前文。后文。",
|
||||
patches,
|
||||
)
|
||||
self.assertFalse(result["pass"])
|
||||
|
||||
def test_quote_approved_deletion_passes(self):
|
||||
# 引文整段位于批准删除片段内 → 合法消失
|
||||
patches = [{"finding_id": "f1", "action": "delete",
|
||||
"original_exact": "面板显示:【等级:待定】。", "replacement": "",
|
||||
"rationale": "测试", "protected_invariants": []}]
|
||||
result = gates.quote_attribution(
|
||||
"前文。面板显示:【等级:待定】。后文。", "前文。后文。", patches)
|
||||
self.assertTrue(result["pass"], result["failures"])
|
||||
|
||||
def test_protected_tic_deletion_fails(self):
|
||||
voice_thin = {"untouchable_verbal_tics": {"老周": ["我说小子"]}}
|
||||
result = gates.protected_checks(
|
||||
"「我说小子,你来了。」", "「你来了。」", voice_thin)
|
||||
self.assertFalse(result["pass"])
|
||||
|
||||
|
||||
class TestU0Replay(unittest.TestCase):
|
||||
"""端到端回放 U0 演练:真实规则库 + 真实目标文本 + 8 条 patch + 跨模型选择记录。"""
|
||||
|
||||
TEXT = """林晚儿站在万宝阁的柜台前,手指无意识地摩挲着那枚铜钱的边缘。
|
||||
|
||||
**那伙计上下打量了她一眼,**嘴角微微上扬。「姑娘,这东西值三百两银子,不是您一枚铜钱就能当的。」
|
||||
|
||||
值得注意的是,万宝阁在青云城立了三百年,从没人敢在这里讨价还价。研究表明,能进这种地方的修士,多半背景不凡。伙计眼中闪过一丝精光,显然已经把林晚儿的来历掂量了一遍。
|
||||
|
||||
「三百年?我说小子,你这铺子也就立了三百年,可我手里这枚铜钱——」老周把铜钱往柜台上一推,声音忽然压低,「它传了三千年。」
|
||||
|
||||
[占位:此处揭示铜钱来历]
|
||||
|
||||
万宝阁的大堂里安静了一瞬。系统面板的流光在半空停住,鉴定结果的金字明灭不定:
|
||||
|
||||
【物品:无法识别|等级:待定|建议操作:上报上峰】
|
||||
|
||||
无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。三天后,这件事被摆进了执法堂的晨会。那时谁也不知道,这枚铜钱会掀翻整座青云城。也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。"""
|
||||
|
||||
PATCH_SPANS = [
|
||||
# (original_exact, replacement, 对应发现 span 的识别片段)
|
||||
("**那伙计上下打量了她一眼,**", "那伙计上下打量了她一眼,", "**那伙计"),
|
||||
(",嘴角微微上扬", "", "嘴角微微上扬"),
|
||||
("眼中闪过一丝精光,", "", "眼中闪过一丝精光"),
|
||||
("研究表明,", "", "研究表明"),
|
||||
("值得注意的是,", "", "值得注意的是"),
|
||||
("无论是伙计的讥笑,还是看客的沉默,似乎都在这一刻定格了。", "", "无论是伙计的讥笑"),
|
||||
("也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。", "", "也许这就是命运吧"),
|
||||
("[占位:此处揭示铜钱来历]", "", "[占位:此处揭示铜钱来历]"),
|
||||
]
|
||||
|
||||
def _build_artifact(self, rules):
|
||||
artifact = run_audit(self.TEXT, rules, load.rule_library_version(rules))
|
||||
artifact["mode"] = "Patch"
|
||||
# 语义层发现由外部判定注入(U0 阶段 4 人工判定的代码化形态)
|
||||
sem_finding = {
|
||||
"id": "f-sem", "text_hash": artifact["text_hash"],
|
||||
"rule_id": "sem001", "rule_version": 1,
|
||||
"spans": ["也许这就是命运吧,总在人不经意的时候,悄悄安排好了一切。"],
|
||||
"context_window": "那时谁也不知道…悄悄安排好了一切。",
|
||||
"layer": "semantic",
|
||||
"evidence": "段尾脱离场景的命运评注,钩子由前句承担",
|
||||
"possible_function": "none", "confidence": "high",
|
||||
"decision_proposal": "repair",
|
||||
"arbitration_note": "五问皆否;前句为伏笔钩子,升华句稀释钩子",
|
||||
}
|
||||
diagnose.merge_model_findings(artifact, [sem_finding])
|
||||
# 模拟仲裁完成:全部 repair(U0 阶段 4 结论)
|
||||
for f in artifact["findings"]:
|
||||
f["decision_proposal"] = "repair"
|
||||
f["possible_function"] = f.get("possible_function", "none")
|
||||
f.setdefault("arbitration_note", "演练回放:五问仲裁通过")
|
||||
return artifact
|
||||
|
||||
def test_full_pipeline_replay(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
artifact = self._build_artifact(rules)
|
||||
diagnose.validate_artifact(artifact)
|
||||
|
||||
# patch 的 finding_id 按 span 覆盖关系解析(回放时不硬编码顺序)
|
||||
patches = []
|
||||
for original, replacement, marker in self.PATCH_SPANS:
|
||||
fid = next(f["id"] for f in artifact["findings"] if marker in f["spans"][0]
|
||||
or f["spans"][0] in original)
|
||||
patches.append({"finding_id": fid, "action": "delete" if not replacement else "patch",
|
||||
"original_exact": original, "replacement": replacement,
|
||||
"rationale": "U0 回放", "protected_invariants": []})
|
||||
|
||||
result = run_patch(
|
||||
self.TEXT, rules, artifact, patches,
|
||||
task_contract={"mode": "Patch", "allowed_scope": "全文", "author_approved_revision": True},
|
||||
fact_snapshot={"entities": ["林晚儿", "老周", "伙计", "万宝阁", "青云城", "执法堂"]},
|
||||
voice_thin={"untouchable_verbal_tics": {"老周": ["我说小子"]},
|
||||
"protected_spans": ["【物品:无法识别|等级:待定|建议操作:上报上峰】"]},
|
||||
rewrite_model="claude",
|
||||
pairwise_record={"choice": "candidate",
|
||||
"rationale": "保真完整,钩子有力(U0 gpt-5.6-sol 判定)",
|
||||
"selection_model": "gpt-5.6-sol"},
|
||||
rule_library_version=load.rule_library_version(rules))
|
||||
|
||||
# 硬门全绿(结构/事实增量/数字归因/保护项)
|
||||
self.assertTrue(result["hard_gate"]["pass"], result["hard_gate"])
|
||||
# 复扫零残留
|
||||
self.assertTrue(result["regression_gate"]["pass"],
|
||||
result["regression_gate"]["residual_hits"])
|
||||
# 跨模型选择成立
|
||||
self.assertEqual(result["pairwise_choice"]["choice"], "candidate")
|
||||
# 候选稿不再含任何确定性命中点
|
||||
for marker in ("**", "值得注意的是", "研究表明", "嘴角微微上扬", "[占位", "也许这就是命运"):
|
||||
self.assertNotIn(marker, result["candidate_text"])
|
||||
# 保护项仍在
|
||||
self.assertIn("我说小子", result["candidate_text"])
|
||||
self.assertIn("【物品:无法识别|等级:待定|建议操作:上报上峰】", result["candidate_text"])
|
||||
|
||||
# 审计报告可组装且过合同(含禁止总分检查)
|
||||
rep = report.assemble(
|
||||
artifact, patches, result["candidate_text"], result["hard_gate"],
|
||||
result["voice_gate"], result["regression_gate"], result["pairwise_choice"],
|
||||
unresolved_risks=result["hard_gate"]["unverified"])
|
||||
self.assertIn("pairwise_choice", rep["final"])
|
||||
|
||||
def test_report_rejects_score_fields(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
artifact = run_audit(self.TEXT, rules, load.rule_library_version(rules))
|
||||
with self.assertRaises(report.ForbiddenScoreError):
|
||||
report.assemble(
|
||||
artifact, [], None,
|
||||
{"pass": True, "checks": {}, "unverified": [], "human_score": 0.9},
|
||||
{"status": "unverified", "pass": None, "note": ""},
|
||||
{"pass": True, "residual_hits": []}, None, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
46
humanization/tests/test_framework_coverage.py
Normal file
46
humanization/tests/test_framework_coverage.py
Normal file
@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""20 项研究机制覆盖矩阵的机械卫生门。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
MATRIX = ROOT / "research/20-project-skill-coverage.yaml"
|
||||
|
||||
|
||||
class FrameworkCoverageTest(unittest.TestCase):
|
||||
def test_matrix_has_five_skill_owners_and_real_implementation_paths(self):
|
||||
data = yaml.safe_load(MATRIX.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["schema_version"], "humanization-research-skill-coverage-v1")
|
||||
capabilities = data["capabilities"]
|
||||
self.assertGreaterEqual(len(capabilities), 12)
|
||||
capability_ids = {item["id"] for item in capabilities}
|
||||
projects = data["projects"]
|
||||
self.assertEqual(len(projects), 20)
|
||||
self.assertEqual(len({item["id"] for item in projects}), 20)
|
||||
for project in projects:
|
||||
self.assertTrue(project["mapped_capabilities"], project["id"])
|
||||
self.assertTrue(set(project["mapped_capabilities"]) <= capability_ids, project["id"])
|
||||
owners = {item["owner_skill"] for item in capabilities}
|
||||
self.assertEqual(
|
||||
owners,
|
||||
{
|
||||
"establish-voice-baseline",
|
||||
"prevent-ai-flavor",
|
||||
"diagnose-ai-flavor",
|
||||
"revise-ai-flavor",
|
||||
"capture-ai-flavor-cases",
|
||||
},
|
||||
)
|
||||
for item in capabilities:
|
||||
implementation_ref = item["implementation"]
|
||||
implementation = ROOT.parent / implementation_ref if implementation_ref.startswith((".", "humanization/")) else ROOT / implementation_ref
|
||||
self.assertTrue(implementation.exists(), f"{item['id']}: {implementation}")
|
||||
self.assertIn(item["status"], {"implemented", "partial", "pending"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
216
humanization/tests/test_humanization_v2.py
Normal file
216
humanization/tests/test_humanization_v2.py
Normal file
@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""人感 v2 共享骨架与技能 5 生命周期的离线负路。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from deai import evaluation, gates, load # noqa: E402
|
||||
from deai.baseline import draft_ledger, profile_text, run_voice_gate # noqa: E402
|
||||
from deai.carriers import carrier_at, carrier_ranges # noqa: E402
|
||||
from deai.diagnose import run_deterministic_rules # noqa: E402
|
||||
|
||||
|
||||
def load_script(name: str, path: pathlib.Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
MINE = load_script(
|
||||
"mine_ai_flavor_v2",
|
||||
ROOT.parent / ".claude/skills/capture-ai-flavor-cases/scripts/mine_ai_flavor.py",
|
||||
)
|
||||
CAPTURE = load_script(
|
||||
"capture_cases_v2",
|
||||
ROOT.parent / ".claude/skills/capture-ai-flavor-cases/scripts/capture_cases.py",
|
||||
)
|
||||
|
||||
|
||||
class HumanizationV2Test(unittest.TestCase):
|
||||
def test_baseline_draft_exposes_sampling_and_unknowns(self):
|
||||
text = "".join(f"第{i}句,门外的雨还在落下,青石路上没有一个人。\n" for i in range(1, 65))
|
||||
ledger = draft_ledger("synthetic:demo", [{"source_ref": "demo.txt", "text": text}])
|
||||
self.assertEqual(ledger["status"], "candidate")
|
||||
self.assertTrue(ledger["sampling"]["sample_sufficient"])
|
||||
self.assertIn("characters", ledger["unknown_fields"])
|
||||
self.assertTrue(ledger["narrator"]["metrics"]["sentence_length"]["median"] > 0)
|
||||
|
||||
def test_baseline_rejects_source_hash_drift(self):
|
||||
with self.assertRaisesRegex(ValueError, "source_sha256"):
|
||||
draft_ledger(
|
||||
"synthetic:demo",
|
||||
[{"source_ref": "demo.txt", "source_sha256": "0" * 64, "text": "甲走进门。"}],
|
||||
)
|
||||
|
||||
def test_baseline_profile_is_deterministic(self):
|
||||
text = "甲走进门。乙关上窗。雨落下来。"
|
||||
self.assertEqual(profile_text(text), profile_text(text))
|
||||
|
||||
def test_baseline_duplicate_voice_ownership_is_rejected_by_skill_validator(self):
|
||||
establish = load_script(
|
||||
"establish_v2",
|
||||
ROOT.parent / ".claude/skills/establish-voice-baseline/scripts/establish_voice_baseline.py",
|
||||
)
|
||||
ledger = {
|
||||
"schema_version": "voice-baseline-v1",
|
||||
"work_ref": "synthetic:demo",
|
||||
"narrator": {"sentence_habits": [], "punctuation_habits": []},
|
||||
"characters": {
|
||||
"甲": {"verbal_tics": ["嗯"], "sample_lines": ["嗯"]},
|
||||
"乙": {"verbal_tics": ["嗯"], "sample_lines": ["嗯"]},
|
||||
},
|
||||
"untouchable_verbal_tics": {"甲": ["嗯"], "乙": ["嗯"]},
|
||||
"protected_spans": [], "blacklist": [],
|
||||
}
|
||||
with self.assertRaisesRegex(establish.BaselineContractError, "同时归属"):
|
||||
establish.validate_ledger(ledger, work_ref="synthetic:demo")
|
||||
|
||||
def test_carrier_scope_masks_dialogue_and_marks_carve_out(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
text = "「值得注意的是,这里是对白。」值得注意的是,这里是叙述。"
|
||||
artifact = run_deterministic_rules(text, [rules["l002"]], "v-test", "Audit")
|
||||
self.assertEqual(len(artifact["findings"]), 1)
|
||||
self.assertEqual(artifact["findings"][0]["carrier"], "narration")
|
||||
self.assertEqual(artifact["findings"][0]["decision_proposal"], "ask")
|
||||
self.assertEqual(carrier_at(text, 1, 7, carrier_ranges(text)), "dialogue")
|
||||
|
||||
def test_deterministic_rules_have_contract_replay(self):
|
||||
"""确定性触发器规则(regex/handler/density)都要能回放四类夹具。
|
||||
|
||||
blocking 类机械规则的默认建议就是 repair,属于其合同本身;
|
||||
safe_default 约束只对非 blocking 规则断言。
|
||||
"""
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
deterministic = [rule for rule in rules.values() if rule["trigger"]["type"] != "model_judgment"]
|
||||
self.assertGreaterEqual(len(deterministic), 2)
|
||||
for rule in deterministic:
|
||||
if rule["default_disposition"] == "blocking":
|
||||
continue
|
||||
report = evaluation.evaluate_rule_contract(rule, samples)
|
||||
self.assertTrue(report["contract_pass"], report)
|
||||
self.assertEqual(report["dataset_kind"], "shipped_contract_samples")
|
||||
|
||||
def test_modality_and_time_fact_gates_block_known_regressions(self):
|
||||
modality = gates.modality_attribution([{
|
||||
"original_exact": "多半背景不凡", "replacement": "背景都不凡",
|
||||
}])
|
||||
temporal = gates.temporal_fact_delta([{
|
||||
"original_exact": "谁也不知道", "replacement": "那年秋分的夜里,谁也不知道",
|
||||
}])
|
||||
numeric_temporal = gates.temporal_fact_delta([{
|
||||
"original_exact": "谁也不知道", "replacement": "2026年3天后的夜里,谁也不知道",
|
||||
}])
|
||||
self.assertFalse(modality["pass"])
|
||||
self.assertFalse(temporal["pass"])
|
||||
self.assertFalse(numeric_temporal["pass"])
|
||||
|
||||
def test_voice_gate_does_not_turn_missing_or_candidate_baseline_into_pass(self):
|
||||
result = run_voice_gate("甲走进门。乙关上窗。", "甲走进门。乙关上窗。", None)
|
||||
candidate = run_voice_gate(
|
||||
"甲走进门。乙关上窗。", "甲走进门。乙关上窗。", {"status": "candidate"}
|
||||
)
|
||||
self.assertIsNone(result["pass"])
|
||||
self.assertEqual(result["status"], "unverified")
|
||||
self.assertIsNone(candidate["pass"])
|
||||
self.assertEqual(candidate["status"], "unverified")
|
||||
|
||||
def test_rule_activation_requires_holdout_and_approver(self):
|
||||
samples = load.load_samples()
|
||||
rules = load.load_rules(samples=samples)
|
||||
# s002 已 active;激活门行为用它的 candidate 副本演练,不依赖库里必须留有候选。
|
||||
candidate = {**rules["s002"], "status": "candidate"}
|
||||
version = candidate["version"]
|
||||
contract = evaluation.evaluate_rule_contract(candidate, samples)
|
||||
with self.assertRaises(evaluation.EvaluationError):
|
||||
evaluation.activate_rule(candidate, contract_report=contract, holdout_report=None, approver="qingse")
|
||||
with self.assertRaises(evaluation.EvaluationError):
|
||||
evaluation.activate_rule(
|
||||
candidate,
|
||||
contract_report={**contract, "rule_id": "s002", "rule_version": version},
|
||||
holdout_report={"rule_id": "s002", "eligible": True},
|
||||
approver="qingse",
|
||||
)
|
||||
holdout = evaluation.evaluate_holdout(candidate, {
|
||||
"dataset_id": "holdout-s002-v1",
|
||||
"sf_total": 5,
|
||||
"sf_hit": 5,
|
||||
"snf_total": 5,
|
||||
"snf_false_repair": 0,
|
||||
"boundary_total": 2,
|
||||
"boundary_false_repair": 0,
|
||||
"regression_total": 2,
|
||||
"regression_safe": 2,
|
||||
})
|
||||
full_holdout = {
|
||||
**contract,
|
||||
"rule_id": "s002", "rule_version": version,
|
||||
"eligible": True, "holdout": holdout,
|
||||
}
|
||||
with self.assertRaises(evaluation.EvaluationError):
|
||||
evaluation.activate_rule(
|
||||
candidate, contract_report={**contract, "rule_id": "s002", "rule_version": version},
|
||||
holdout_report={**full_holdout, "rule_version": version + 1}, approver="qingse"
|
||||
)
|
||||
active = evaluation.activate_rule(
|
||||
candidate, contract_report={**contract, "rule_id": "s002", "rule_version": version},
|
||||
holdout_report=full_holdout, approver="qingse"
|
||||
)
|
||||
self.assertEqual(active["status"], "active")
|
||||
self.assertEqual(active["activation"]["approved_by"], "qingse")
|
||||
|
||||
def test_mining_extra_sample_requires_canonical_verified_projection(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
source = root / "source.txt"
|
||||
source.write_text("值得注意的是,门外下起了雨。", encoding="utf-8")
|
||||
shadow = CAPTURE.capture_file(source, work_ref="synthetic:work", source_license="owned")[0]
|
||||
annotated = CAPTURE.annotate_card(shadow, label="sf", carrier="narration")
|
||||
verification = {"cards": [CAPTURE.revalidate_card(annotated, source_path=source)]}
|
||||
canonical = CAPTURE.confirm_card(
|
||||
annotated, reviewer="human", note="功能已确认", verification=verification
|
||||
)
|
||||
sample = CAPTURE.project_sample(canonical, verification=verification)
|
||||
sample_path = root / "samples.json"
|
||||
sample_path.write_text(json.dumps({"samples": [sample]}, ensure_ascii=False), encoding="utf-8")
|
||||
with self.assertRaises(MINE.MiningError):
|
||||
MINE._load_extra_samples([sample_path])
|
||||
loaded = MINE._load_extra_samples(
|
||||
[sample_path], cards={canonical["id"]: canonical}, verification=verification
|
||||
)
|
||||
self.assertEqual(loaded[sample["id"]]["case_card_id"], canonical["id"])
|
||||
tampered = dict(sample, text="篡改后的样例")
|
||||
sample_path.write_text(json.dumps({"samples": [tampered]}, ensure_ascii=False), encoding="utf-8")
|
||||
with self.assertRaises(MINE.MiningError):
|
||||
MINE._load_extra_samples(
|
||||
[sample_path], cards={canonical["id"]: canonical}, verification=verification
|
||||
)
|
||||
|
||||
def test_mining_evaluate_cli_is_offline_by_explicit_flag(self):
|
||||
rule_path = ROOT / "rules/structural/s002.yaml"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
output = pathlib.Path(tmp) / "evaluation.json"
|
||||
code = MINE.main([
|
||||
"evaluate-rule", "--rule", str(rule_path), "--output", str(output), "--offline",
|
||||
])
|
||||
self.assertEqual(code, 0)
|
||||
report = json.loads(output.read_text(encoding="utf-8"))
|
||||
self.assertTrue(report["contract_pass"])
|
||||
self.assertFalse(report["eligible"])
|
||||
self.assertEqual(report["persistence"]["status"], "offline")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -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 时才同步回父仓。
|
||||
|
||||
## 公约
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user