228 lines
11 KiB
Python
228 lines
11 KiB
Python
#!/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())
|