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