291 lines
14 KiB
Python
291 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""正文生产运行的统一数据库写入适配器。
|
||
|
||
调用顺序由生产编排负责:assemble -> persist_freeze -> run_writer_with_receipt
|
||
-> mechanical gate -> 本模块 -> 用户 confirm。这里不接受正文、不接受用户决策,
|
||
只把本次 writer 的候选、身份回执和机械质量结果写进 Shadow 账本。
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import pathlib
|
||
import sys
|
||
from typing import Any, Mapping
|
||
|
||
|
||
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||
EVIDENCE_DIR = SCRIPT_DIR.parents[1] / "record-run-evidence" / "scripts"
|
||
READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
|
||
for path in (EVIDENCE_DIR, READ_CONTEXT_DIR):
|
||
if str(path) not in sys.path:
|
||
sys.path.insert(0, str(path))
|
||
|
||
from muse_db import connect # noqa: E402
|
||
from persist_context_freeze import persist_freeze # noqa: E402
|
||
from run_registry import finish_run, start_run # noqa: E402
|
||
from lesson_registry import propose_lesson_dedup # noqa: E402
|
||
|
||
|
||
CREATOR = "continuation"
|
||
|
||
|
||
class WriterPersistenceError(RuntimeError):
|
||
"""生产候选缺少必要身份或证据时失败关闭。"""
|
||
|
||
|
||
def _propose_writer_lesson(
|
||
*,
|
||
run_id: str,
|
||
work_id: int,
|
||
target_chapter: int,
|
||
passed: bool,
|
||
semantic_status: str,
|
||
mechanical_report: Mapping[str, Any],
|
||
candidate_sha: str | None,
|
||
) -> dict[str, Any]:
|
||
"""正文生产落库后登记效果信号。"""
|
||
|
||
return propose_lesson_dedup(
|
||
kind="win" if passed else "lesson",
|
||
title="正文生产机械门通过" if passed else "正文生产机械门未通过",
|
||
detail={
|
||
"skill": "write-next-chapter",
|
||
"work_id": work_id,
|
||
"target_chapter": target_chapter,
|
||
"mechanical_passed": passed,
|
||
"semantic_status": semantic_status,
|
||
"blocking_failures": mechanical_report.get("blockingFailures", []),
|
||
},
|
||
work_id=work_id,
|
||
run_id=run_id,
|
||
candidate_sha256=candidate_sha,
|
||
creator=CREATOR,
|
||
)
|
||
|
||
|
||
def _bare_hash(value, field):
|
||
value = str(value or "")
|
||
if value.startswith("sha256:"):
|
||
value = value[7:]
|
||
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
||
raise WriterPersistenceError(f"{field} 必须是 64 位小写 SHA-256")
|
||
return value
|
||
|
||
|
||
def _json(value):
|
||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
||
|
||
|
||
def _raw_response(conn, run_id):
|
||
row = conn.execute(
|
||
"SELECT id,raw_content_id FROM example_llm_call "
|
||
"WHERE run_id=%s AND caller='writer' AND out_tokens>0 AND raw_content_id IS NOT NULL "
|
||
"ORDER BY id DESC LIMIT 1",
|
||
(run_id,),
|
||
).fetchone()
|
||
if not row:
|
||
raise WriterPersistenceError(
|
||
f"writer 运行缺少成功调用 raw:run_id={run_id};禁止写入看似完整的候选"
|
||
)
|
||
return row
|
||
|
||
|
||
_SEMANTIC_STATUSES = frozenset({"passed", "failed", "needs_evidence"})
|
||
|
||
|
||
def _semantic_verdict(semantic_report, context, candidate_sha, candidate_version):
|
||
"""校验语义报告绑定并派生候选语义状态;任何不一致失败关闭。
|
||
|
||
返回 (semantic_status, semantic_report_sha256 裸哈希)。报告权威在检测适配器,
|
||
这里只复核绑定与哈希自洽,防止把别的候选/运行的报告记到本候选头上。
|
||
"""
|
||
|
||
if not isinstance(semantic_report, dict):
|
||
raise WriterPersistenceError("语义报告必须是对象")
|
||
if semantic_report.get("schemaVersion") != "semantic-detection-v3":
|
||
raise WriterPersistenceError("语义报告版本非法,拒绝落库")
|
||
status = semantic_report.get("status")
|
||
if status not in _SEMANTIC_STATUSES:
|
||
raise WriterPersistenceError(f"语义报告 status 非法: {status!r}")
|
||
expected = {
|
||
"runId": context.get("runId"),
|
||
"candidateVersion": candidate_version,
|
||
"candidateSha256": "sha256:" + candidate_sha,
|
||
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
|
||
}
|
||
for field, value in expected.items():
|
||
got = semantic_report.get(field)
|
||
# candidateVersion 在报告里可能是 int,candidate 里也可能是 str,统一成字符串比
|
||
if str(got) != str(value):
|
||
raise WriterPersistenceError(f"语义报告未绑定当前候选: {field}")
|
||
report_sha = str(semantic_report.get("reportSha256") or "")
|
||
body = {key: value for key, value in semantic_report.items() if key != "reportSha256"}
|
||
recomputed = "sha256:" + hashlib.sha256(_json(body).encode("utf-8")).hexdigest()
|
||
if report_sha != recomputed:
|
||
raise WriterPersistenceError("语义报告哈希自洽校验失败")
|
||
return status, _bare_hash(report_sha, "semanticReportSha256")
|
||
|
||
|
||
def persist_writer_execution(
|
||
context: Mapping[str, Any],
|
||
candidate: Mapping[str, Any],
|
||
receipt: Any,
|
||
mechanical_report: Mapping[str, Any],
|
||
*,
|
||
semantic_report: Mapping[str, Any] | None = None,
|
||
assemble_result: Mapping[str, Any] | None = None,
|
||
writer_raw_ref: tuple[Any, Any] | None = None,
|
||
dry_run: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""落库一次 writer Shadow 运行;失败不会创建半套候选账本。
|
||
|
||
semantic_report 非 None 时校验绑定并把语义状态固化到候选行(接受通道的 DB 兜底依据);
|
||
None 时候选 semantic_status 留空,write_canonical 一律不得接受(先审后入,失败关闭)。
|
||
|
||
writer_raw_ref:(调用账 id, raw 内容 id)。直调链在本运行下有 caller='writer'
|
||
的成功调用账,缺省即从本运行查;框架派发链的模型证据记在派发运行下,
|
||
由编排方查得后显式传入。两条路径都要求真实证据,缺一失败关闭。
|
||
"""
|
||
|
||
run_id = str(context.get("runId") or "")
|
||
work_id = context.get("workId")
|
||
target_chapter = context.get("targetChapter")
|
||
if not run_id or not isinstance(work_id, int) or not isinstance(target_chapter, int):
|
||
raise WriterPersistenceError("WriterContext 缺 runId/workId/targetChapter")
|
||
if candidate.get("runId") != run_id:
|
||
raise WriterPersistenceError("候选 runId 与上下文不一致")
|
||
candidate_sha = _bare_hash(candidate.get("candidateSha256"), "candidateSha256")
|
||
context_sha = _bare_hash(context["contextSnapshot"]["contextSha256"], "contextSha256")
|
||
candidate_version = str(candidate.get("candidateVersion"))
|
||
body = candidate.get("candidateBody")
|
||
if not body:
|
||
raise WriterPersistenceError("候选正文为空")
|
||
passed = mechanical_report.get("passed")
|
||
if not isinstance(passed, bool):
|
||
raise WriterPersistenceError("机械门报告缺少布尔 passed")
|
||
semantic_status = semantic_sha = None
|
||
if semantic_report is not None:
|
||
semantic_status, semantic_sha = _semantic_verdict(
|
||
semantic_report, context, candidate_sha, candidate.get("candidateVersion"))
|
||
|
||
if assemble_result is not None:
|
||
persist_freeze(assemble_result, dry_run=dry_run)
|
||
if not dry_run:
|
||
start_run(
|
||
run_id=run_id, work_id=work_id, target_chapter=target_chapter,
|
||
trigger_detail={"stage": "writer-production", "contextSha256": context_sha},
|
||
creator=CREATOR,
|
||
)
|
||
|
||
try:
|
||
with connect() as conn:
|
||
if writer_raw_ref is not None:
|
||
call_id, raw_content_id = writer_raw_ref
|
||
if call_id is None or raw_content_id is None:
|
||
raise WriterPersistenceError(
|
||
"writer_raw_ref 缺调用账或 raw 内容:禁止写入看似完整的候选")
|
||
else:
|
||
call_id, raw_content_id = _raw_response(conn, run_id)
|
||
existing = conn.execute(
|
||
"SELECT id,candidate_sha256,state FROM example_candidate "
|
||
"WHERE tenant_id=0 AND work_id=%s AND target_chapter=%s AND candidate_version=%s",
|
||
(work_id, target_chapter, candidate_version),
|
||
).fetchone()
|
||
if existing:
|
||
if existing[1] != candidate_sha:
|
||
raise WriterPersistenceError(
|
||
f"候选版本已绑定其他正文:work={work_id} chapter={target_chapter} version={candidate_version}"
|
||
)
|
||
candidate_id = existing[0]
|
||
else:
|
||
candidate_id = conn.execute(
|
||
"INSERT INTO example_candidate(work_id,target_chapter,run_id,attempt,run_type,"
|
||
"candidate_version,candidate_sha256,candidate_body,context_sha256,quality_policy_version,"
|
||
"mode,source_role,state,acceptance_eligible,semantic_status,semantic_report_sha256,creator) "
|
||
"VALUES (%s,%s,%s,%s,'production',%s,%s,%s,%s,'writer-production-v1','continuation',"
|
||
"'writer',%s,TRUE,%s,%s,%s) RETURNING id",
|
||
(work_id, target_chapter, run_id, candidate.get("attempt", 1), candidate_version,
|
||
candidate_sha, body, context_sha, "passed" if passed else "rejected",
|
||
semantic_status, semantic_sha, CREATOR),
|
||
).fetchone()[0]
|
||
|
||
receipt_row = conn.execute(
|
||
"SELECT COALESCE(MAX(revision),0)+1 FROM example_run_receipt "
|
||
"WHERE tenant_id=0 AND run_id=%s AND sample_id=%s",
|
||
(run_id, f"writer-ch{target_chapter}"),
|
||
).fetchone()
|
||
revision = receipt_row[0]
|
||
usage = getattr(receipt, "usage", None) or {}
|
||
cost = getattr(receipt, "total_cost_usd", None)
|
||
receipt_id = conn.execute(
|
||
"INSERT INTO example_run_receipt(run_id,sample_id,revision,adapter_role,stage_kind,"
|
||
"attempt,candidate_version,candidate_sha256,context_sha256,requested_model_id,"
|
||
"actual_model_id,model_match,effort,total_cost_usd,usage,stop_reason,terminal_reason,"
|
||
"is_error,safe_summary,raw_content_id,creator,tenant_id) "
|
||
"VALUES (%s,%s,%s,'writer','generation',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s::jsonb,%s,%s,0) RETURNING id",
|
||
(run_id, f"writer-ch{target_chapter}", revision, candidate.get("attempt", 1),
|
||
candidate_version, candidate_sha, context_sha,
|
||
getattr(receipt, "requested_model_id", None), getattr(receipt, "actual_model_id", None),
|
||
getattr(receipt, "model_match", False), getattr(receipt, "effort", None), cost,
|
||
_json(dict(usage)), getattr(receipt, "stop_reason", None),
|
||
getattr(receipt, "terminal_reason", None), bool(getattr(receipt, "is_error", False)),
|
||
_json({"mechanicalPassed": passed,
|
||
"blockingFailures": mechanical_report.get("blockingFailures", []),
|
||
"sourceCallId": call_id}), raw_content_id, CREATOR),
|
||
).fetchone()[0]
|
||
quality_id = conn.execute(
|
||
"INSERT INTO example_quality_result(run_id,receipt_id,candidate_sha256,judge_kind,"
|
||
"scale_version,conclusion,failure_class,detail,raw_content_id,creator,tenant_id) "
|
||
"VALUES (%s,%s,%s,'detection','writer-production-v1',%s,%s,%s::jsonb,%s,%s,0) "
|
||
"ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING RETURNING id",
|
||
(run_id, receipt_id, candidate_sha, "pass" if passed else "fail",
|
||
None if passed else "mechanical_gate_failed", _json(dict(mechanical_report)),
|
||
raw_content_id, CREATOR),
|
||
).fetchone()
|
||
# 语义审查证据单独一行(dimension='semantic'),与机械门证据并列可审计
|
||
semantic_quality_id = None
|
||
if semantic_report is not None:
|
||
semantic_quality_row = conn.execute(
|
||
"INSERT INTO example_quality_result(run_id,receipt_id,candidate_sha256,judge_kind,"
|
||
"dimension,scale_version,conclusion,failure_class,detail,raw_content_id,creator,tenant_id) "
|
||
"VALUES (%s,%s,%s,'detection','semantic','semantic-detection-v3',%s,%s,%s::jsonb,%s,%s,0) "
|
||
"ON CONFLICT (tenant_id,run_id,judge_kind,COALESCE(dimension,''),COALESCE(candidate_sha256,'')) DO NOTHING RETURNING id",
|
||
(run_id, receipt_id, candidate_sha,
|
||
"pass" if semantic_status == "passed" else "fail",
|
||
None if semantic_status == "passed" else f"semantic_{semantic_status}",
|
||
_json(dict(semantic_report)), raw_content_id, CREATOR),
|
||
).fetchone()
|
||
semantic_quality_id = semantic_quality_row[0] if semantic_quality_row else None
|
||
result = {"status": "dry_run_ok" if dry_run else "persisted",
|
||
"candidate_id": candidate_id, "receipt_id": receipt_id,
|
||
"quality_id": quality_id[0] if quality_id else None,
|
||
"semantic_quality_id": semantic_quality_id,
|
||
"semantic_status": semantic_status,
|
||
"raw_content_id": raw_content_id, "state": "passed" if passed else "rejected"}
|
||
if dry_run:
|
||
conn.rollback()
|
||
result["note"] = "试跑已回滚,未落库"
|
||
return result
|
||
conn.commit()
|
||
finish_run(run_id, "completed", creator=CREATOR,
|
||
trigger_detail={"stage": "writer-production", "candidateId": candidate_id,
|
||
"mechanicalPassed": passed})
|
||
lesson = _propose_writer_lesson(
|
||
run_id=run_id,
|
||
work_id=work_id,
|
||
target_chapter=target_chapter,
|
||
passed=passed,
|
||
semantic_status=semantic_status,
|
||
mechanical_report=mechanical_report,
|
||
candidate_sha=candidate_sha,
|
||
)
|
||
result["lesson"] = lesson
|
||
return result
|
||
except Exception:
|
||
if not dry_run:
|
||
finish_run(run_id, "failed", creator=CREATOR,
|
||
trigger_detail={"stage": "writer-production", "error": "persistence_failed"})
|
||
raise
|
||
|
||
|
||
__all__ = ["WriterPersistenceError", "persist_writer_execution", "_propose_writer_lesson"]
|