291 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"]