700 lines
37 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
"""真写一章 · 阶段二:走完整生产链写下一章(默认写目标作品的下一章)。
生产链(meta/chains continuation 登记的保护节点序列落地):
读已确认细纲 + 前章正文基线
→ build_retrieval_plan + retrieve_writer_sources(生产仓储;新书无卡诚实返空)
→ assemble_context 冻结 WriterContext v1
→ run_writer_pipeline(持久 CAS 状态链 + 机械门 + 语义 detector,单次收敛 + 授权终态合同)
writer 真调经 run_writer_with_receipt:runtime 自动把模型输入/输出原文与调用明细原子落库;
篇幅越界在 writer 适配层自动重抽(最多三遍);语义 detector 走冻结 detector profile 真调;
语义 needs_evidence 时走 production_evidence_reassemble(检索已有正典摘录);零命中当新设定交人闸,不禁写不重写
→ persist_writer_execution(冻结 + 运行注册 + 候选[含语义状态] + 回执 + 机械/语义质量证据一次落库)
→ accept_preflight(check_writer_acceptance 纯函数 + acceptance_state 实时重读)
→ 停止并展示候选,等待用户明确选择改 / 丢弃 / 采纳
用法:.venv/bin/python .agent/skills/write-next-chapter/scripts/produce_next_chapter.py [目标章号]
[--instruction "本轮人指令原文"]
缺省写下一章(库内最大章序 +1)。前置:目标章已建立、存在 confirmed 细纲,且门锚合同
GATE_ANCHORS 已登记该章。
--instruction:写入本轮输入第 7 项(人指令),经 styleConstraints 注入 writer;
与 05 §2.3 同序拼装合同对齐。正式采纳只由用户明确决定后调用 decide-candidate。
"""
import hashlib
import json
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from typing import Any, Mapping
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parents[3]
AGENT_ROOT = SCRIPT_DIR.parents[2]
SKILLS = AGENT_ROOT / "skills"
for sub in (
"assemble-context/scripts",
"write-next-chapter/scripts",
"record-run-evidence/scripts",
"check-content-consistency/scripts",
"decide-candidate/scripts",
"prevent-ai-flavor/scripts",
"diagnose-ai-flavor/scripts",
"replay-writer-gate/scripts",
):
p = str(SKILLS / sub)
if p not in sys.path:
sys.path.insert(0, p)
from muse_db import connect, DSN # noqa: E402
from assemble_writer_context import assemble_context # noqa: E402
from prevent_ai_flavor import ( # noqa: E402
PreventionContractError, build_prevention_contract, persist_prevention, render_writer_constraints,
)
from diagnose_ai_flavor import run_diagnosis, persist_diagnosis # noqa: E402
from retrieve_writer_sources import ( # noqa: E402
ProductionCardIndexRepository, FrozenProseRepository, RetrievalError,
build_retrieval_plan, retrieve_writer_sources, load_confirmed_fine_outline,
load_confirmed_pattern_bindings, load_confirmed_style,
)
from run_writer import ( # noqa: E402
WriterAdapterError, build_writer_execution_profile,
build_production_length_contracts, calculate_dynamic_output_contract,
run_writer_with_receipt,
)
from run_writer_pipeline import PipelineError, run_writer_pipeline # noqa: E402
from production_evidence_reassemble import ( # noqa: E402
EvidenceReassembleError,
reassemble_writer_context_for_gaps,
)
from candidate_cas import PostgresCasStateStore # noqa: E402
from run_writer_semantic_detector import ( # noqa: E402
SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA, build_safe_semantic_diagnostic,
build_semantic_input_v3, is_semantic_schema_specialization,
run_writer_semantic_detector,
)
from muse_role import ( # noqa: E402
RoleExecutionProfile,
run_role,
sha256_json,
)
from run_writer_replay import profile_from_mapping # noqa: E402
from persist_llm_call import persist_call as persist_llm_event # noqa: E402
from persist_writer_run import persist_writer_execution # noqa: E402
from dispatch_writer_bridge import DispatchWriterError, run_writer_via_dispatch # noqa: E402
from two_phase_writer import ExplorationError, run_two_phase_writer # noqa: E402
from run_registry import finish_run, start_run # noqa: E402
from check_writer_acceptance import AcceptanceError, check_writer_acceptance # noqa: E402
from acceptance_state import LiveStateError, build_live_acceptance_state # noqa: E402
WORK_ID = 12
MAX_ATTEMPTS = 3 # 篇幅越界重抽上限(编排层响应,对齐 replay 自纠环语义)
GATE_A_CONFIG = json.loads((SKILLS / "replay-writer-gate" / "configs" /
"writer-gate-a-deep-space-v1.json").read_text(encoding="utf-8"))
GATE_A_WRITER = GATE_A_CONFIG["executionProfiles"]["writer"]
GATE_A_DETECTOR = GATE_A_CONFIG["executionProfiles"]["semantic_detector"]
PRODUCTION_LENGTH_PROMPT = (
"\n9. 篇幅是硬门:按输入 lengthContract 的汉字数口径,成稿必须达到 minChars;"
"输出前自行估算,若不足就继续展开场景。宁可接近 maxChars,也不得低于 minChars。"
"不要把标点、数字或拉丁字母计入汉字数。"
"\n10. 写手可以设计本章新出现的地名、能力、器物、感知或宇宙规则;"
"它们只是候选正文里的新设定,不是已确认正典,不要写成设定文档里早已成立的事实。"
"与已给出的事实约束冲突的内容不要写。新设定是否入库由人决定。"
)
SYSTEM_PROMPT = GATE_A_WRITER["systemPrompt"] + PRODUCTION_LENGTH_PROMPT
SYSTEM_PROMPT_ID = "writer-production-system-v4-new-settings"
SYSTEM_PROMPT_SHA256 = "sha256:" + hashlib.sha256(SYSTEM_PROMPT.encode("utf-8")).hexdigest()
ARTIFACTS = REPO_ROOT / "docs" / "write-chapter" / "artifacts"
# 门锚合同按章登记:锚点是章级创作判断,any-hit 子串匹配。新章必须先登记再跑。
# 机械门(check_writer_candidate)只认这些子串,不认语义等价;必须投影给写手,
# 否则写手只见细纲「完成第一次升级」却因禁抄长句而避开「升级」二字 → HARD_EVENT_MISSING。
GATE_ANCHORS = {
2: {
"requiredEvents": [
{"requirementId": "event-1-isolation",
"anchors": ["隔离", "收押", "禁闭", "关押", "封锁"]},
{"requirementId": "event-2-interrogation",
"anchors": ["审讯", "审问", "询问", "盘问", "讯问"]},
{"requirementId": "event-3-conceal",
"anchors": ["隐瞒", "没有告诉", "没说", "没有说", "咽了回去", "沉默", "闭上嘴"]},
{"requirementId": "event-4-hunger",
"anchors": ["饥饿", "渴望", "吞噬", "进食", "吃", "贪"]},
],
"requiredCharacters": ["林深", "何岚"],
"foreshadowingActions": [
{"requirementId": "foreshadow-upgrade",
"anchors": ["异种核心", "融合", "升级"]},
],
"chapterEndHook": {
"requirementId": "hook-ch2",
"anchors": ["调令", "实战", "出击", "部署", "任务", "出征", "离不开", "不愿离开"],
"maxDistanceFromEnd": 900,
},
},
3: {
# 接第2章结尾硬钩子(茧撕开舱门出击、要吃掉更强核心)。锚点 any-hit 子串匹配;
# requiredCharacters 只硬约束主角(避免过度约束触发 costly 重抽),其余靠事件锚点。
"requiredEvents": [
{"requirementId": "event-1-sortie",
"anchors": ["出击", "实战", "战斗", "交火", "搏杀", "拦截", "扑向", "战场"]},
{"requirementId": "event-2-devour",
"anchors": ["吞噬", "吞食", "吃掉", "进食", "撕碎", "吸收", "吞下", "吞"]},
{"requirementId": "event-3-upgrade",
"anchors": ["升级", "蜕变", "进化", "变强", "增强", "新的力量", "蜕变"]},
{"requirementId": "event-4-pollution",
"anchors": ["黑纹", "污染", "扩散", "蔓延", "加深", "恶化"]},
],
"requiredCharacters": ["林深"],
"foreshadowingActions": [
{"requirementId": "foreshadow-voice-merge",
"anchors": ["分不清", "像他自己", "脑内的声音", "低语", "渴望", "哪个念头", "另一个"]},
],
"chapterEndHook": {
"requirementId": "hook-ch3",
"anchors": ["深渊", "更深", "回应", "召唤", "更大", "下一", "不止", "饥饿", "注视", "凝视"],
"maxDistanceFromEnd": 900,
},
},
}
def format_mechanical_gate_constraints(requirements: Mapping[str, Any]) -> list[str]:
"""把 GATE_ANCHORS 投影成写手可见约束(机械门规则对写手透明)。
规则(check_writer_candidate._anchors_present):每个 requirement 的 anchors
列表里**任意一个子串**出现在正文即通过;缺则 HARD_EVENT_MISSING 等,且
机械失败不进补证环、直接拒绝。语义「写了升级这件事」不够,必须命中子串。
"""
lines: list[str] = [
"机械门验收标记(确定性子串,非细纲长句):下列每组至少在正文自然出现其中一个词;"
"这是验收标记,允许写进戏剧化句子,禁止整句复读硬约束长句。",
]
for item in requirements.get("requiredEvents") or []:
if not isinstance(item, Mapping):
continue
rid = item.get("requirementId") or "event"
anchors = [a for a in (item.get("anchors") or []) if isinstance(a, str) and a]
if anchors:
lines.append(f"硬事件[{rid}] 须含其一:{' / '.join(anchors)}")
chars = [c for c in (requirements.get("requiredCharacters") or []) if isinstance(c, str) and c]
if chars:
lines.append(f"必须出场角色(全文须出现姓名):{'、'.join(chars)}")
for item in requirements.get("foreshadowingActions") or []:
if not isinstance(item, Mapping):
continue
rid = item.get("requirementId") or "foreshadow"
anchors = [a for a in (item.get("anchors") or []) if isinstance(a, str) and a]
if anchors:
lines.append(f"伏笔动作[{rid}] 须含其一:{' / '.join(anchors)}")
hook = requirements.get("chapterEndHook")
if isinstance(hook, Mapping):
rid = hook.get("requirementId") or "hook"
anchors = [a for a in (hook.get("anchors") or []) if isinstance(a, str) and a]
dist = hook.get("maxDistanceFromEnd")
if anchors:
lines.append(
f"章末钩子[{rid}] 须在结尾约 {dist} 字内含其一:{' / '.join(anchors)}"
)
return lines
def build_semantic_detector_profile() -> RoleExecutionProfile:
"""按正式配置重建语义 detector 的冻结角色 profile。"""
return profile_from_mapping(GATE_A_DETECTOR, role="semantic_detector")
class ProductionSemanticRunner:
"""语义 detector 生产 runner:角色调用随 run_id 统一落库。"""
def __init__(self, profile: RoleExecutionProfile, *, run_id: str) -> None:
self.profile = profile
self.run_id = run_id
def run(self, *, adapter_role: str, model_input: Mapping[str, Any],
output_schema: Mapping[str, Any]) -> Mapping[str, Any]:
if self.profile.adapter_role != adapter_role:
raise PipelineError("SEMANTIC_DETECTOR_FAILED", "RoleExecutionProfile 与语义 detector 适配不一致")
if not is_semantic_schema_specialization(self.profile.json_schema, output_schema):
raise PipelineError(
"SEMANTIC_DETECTOR_FAILED",
"output_schema 必须是基座 schema 的闭集特化",
)
from dataclasses import replace
call_profile = replace(
self.profile,
json_schema=dict(output_schema),
json_schema_sha256=sha256_json(output_schema),
)
def persist_detector_event(event):
# runtime 默认把非 writer 调用标为 evaluation;生产链语义审查改标 production_detection。
event = dict(event)
event["purpose"] = "production_detection"
return persist_llm_event(event)
result = run_role(
call_profile,
model_input,
run_id=self.run_id,
caller="semantic_detector",
persist_call=persist_detector_event,
)
receipt = result.receipt
receipt_dict = receipt.as_dict() if hasattr(receipt, "as_dict") else receipt
return {"structuredOutput": dict(result.structured_output),
"modelReceiptSha256": sha256_json(receipt_dict)}
def resolve_target_chapter(requested: int | None) -> int:
with connect(readonly=True) as conn:
if requested is not None:
return requested
row = conn.execute(
"SELECT COALESCE(MAX(order_no),0)+1 FROM muse_content_chapter "
"WHERE work_id=%s AND deleted=false", (WORK_ID,)).fetchone()
return int(row[0])
def _dump(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=1), encoding="utf-8")
def main():
argv = sys.argv[1:]
if "--dry-run" in argv:
raise SystemExit("--dry-run 已移除:生成入口只落 Shadow,不执行 accept")
human_instruction = ""
if "--instruction" in argv:
idx = argv.index("--instruction")
if idx + 1 >= len(argv) or argv[idx + 1].startswith("--"):
raise SystemExit("--instruction 后须跟本轮人指令原文")
human_instruction = argv[idx + 1].strip()
argv = argv[:idx] + argv[idx + 2 :]
def _take(flag: str):
nonlocal argv
if flag not in argv:
return None
idx = argv.index(flag)
if idx + 1 >= len(argv) or argv[idx + 1].startswith("--"):
raise SystemExit(f"{flag} 后须跟值")
value = argv[idx + 1]
argv = argv[:idx] + argv[idx + 2:]
return value
dispatch_mode = "--dispatch-writer" in argv
if dispatch_mode:
argv = [arg for arg in argv if arg != "--dispatch-writer"]
two_phase_mode = "--two-phase" in argv
if two_phase_mode:
argv = [arg for arg in argv if arg != "--two-phase"]
dispatch_mode = True
dispatch_provider = _take("--provider")
dispatch_model = _take("--model")
dispatch_thinking = _take("--thinking")
continue_from = _take("--continue-from")
if dispatch_mode and (not dispatch_provider or not dispatch_model):
raise SystemExit("--dispatch-writer 必须显式给出 --provider 与 --model")
args = [arg for arg in argv if not arg.startswith("--")]
target = resolve_target_chapter(int(args[0]) if args else None)
as_of = target - 1
run_id = f"run-prod-work12-ch{target}-{uuid.uuid4().hex[:8]}"
generated_at = datetime.now(timezone.utc).isoformat()
ARTIFACTS.mkdir(exist_ok=True)
if target not in GATE_ANCHORS:
raise SystemExit(f"第{target}章门锚合同未登记(GATE_ANCHORS),先登记锚点再跑。")
# 1) 已确认细纲(read-context 统一消费点)+ 前章全文基线(asOf 起连续四章;不足四章从第1章起)
with connect(readonly=True) as conn:
try:
fine_outline = load_confirmed_fine_outline(conn, work_id=WORK_ID, target_chapter=target)
except RetrievalError as exc:
raise SystemExit(f"{exc}(目标章必须先建立并确认细纲)")
first = max(1, as_of - 3)
recent_rows = conn.execute(
"SELECT c.order_no, b.id, b.revision, b.content_text FROM muse_content_chapter c "
"JOIN muse_content_block b ON b.chapter_id=c.id AND b.deleted=false "
"WHERE c.work_id=%s AND c.deleted=false AND c.order_no BETWEEN %s AND %s "
"ORDER BY c.order_no", (WORK_ID, first, as_of)).fetchall()
style_constraints = load_confirmed_style(conn, work_id=WORK_ID)
pattern_references = load_confirmed_pattern_bindings(conn, work_id=WORK_ID)
# 第 7 项人指令:与文风并列注入 styleConstraints,看板/raw 可回看
if human_instruction:
style_constraints = list(style_constraints) + [f"本轮人指令:{human_instruction}"]
print(f"本轮人指令已注入({len(human_instruction)} 字)")
# 机械门锚点必须对写手可见(根因修复:#120 写了升级语义但未命中子串)
gate_lines = format_mechanical_gate_constraints(GATE_ANCHORS[target])
style_constraints = list(style_constraints) + gate_lines
print(f"机械门锚点已注入写手约束({len(gate_lines)} 条)")
recent_chapters = [{
"chapter": order_no,
"sourceRef": {"sourceId": f"content-block:{block_id}",
"sourceVersion": f"rev{revision}",
"blockId": int(block_id), "chapter": order_no,
"startCodePoint": 0, "endCodePoint": len(body)},
"text": body,
} for order_no, block_id, revision, body in recent_rows]
expected = list(range(first, as_of + 1))
got = [item["chapter"] for item in recent_chapters]
if got != expected:
raise SystemExit(f"连续前章基线缺章:期望 {expected},实际 {got}。")
print(f"目标第{target}章(asOf={as_of});细纲已读,基线 {got},"
f"基线总字数 {sum(len(item['text']) for item in recent_chapters)}")
# 1.5) 人感前置预防:规则/声音账先形成合同,再冻结进 WriterContext。
try:
humanization_contract = build_prevention_contract(
f"work:{WORK_ID}", load_database=True
)
humanization_contract["writer_constraints"] = render_writer_constraints(humanization_contract)
prevention_receipt = persist_prevention(humanization_contract)
except PreventionContractError as exc:
raise SystemExit(f"人感前置预防合同失败:{exc}") from exc
print(f"人感前置预防:约束 {len(humanization_contract['writer_constraints'])} 条,"
f"规则库 {humanization_contract['built_from']['rule_library_version']},"
f"run_id={prevention_receipt['run_id']}")
# 2) 检索计划 + 执行(生产仓储;新书无卡诚实返空)
token_budget = {"maxContextChars": 200000}
plan = build_retrieval_plan(
run_id=run_id, work_id=WORK_ID, target_chapter=target, as_of=as_of,
fine_outline=fine_outline, card_index_version="knowledge-index-v1",
prose_index_version="content-block-v1", token_budget=token_budget)
retrieval = retrieve_writer_sources(
plan=plan, card_repository=ProductionCardIndexRepository(),
prose_repository=FrozenProseRepository(dsn=DSN, tenant_id=0))
_dump(ARTIFACTS / f"{run_id}-retrieval.json",
{"plan": plan, "resultCounts": {k: len(v) for k, v in retrieval.items()
if isinstance(v, list)}})
print(f"检索: 卡={len(retrieval['cards'])} 事实={len(retrieval['factEvidence'])} "
f"原文={len(retrieval['proseEvidence'])}")
# 3) 组装冻结 WriterContext v1
dynamic_output_contract = calculate_dynamic_output_contract(
fine_outline=fine_outline,
recent_chapter_bodies=[item["text"] for item in recent_chapters])
output_contract, generation_length_contract = build_production_length_contracts(
dynamic_output_contract)
narrative_state = {
"time": f"第{as_of}章结束后",
"location": "承接上一章结尾的场景",
"characterPositions": {},
"immediateSituation": "按细纲 chapterGoal 展开(上一章结尾状态见基线正文)。",
}
authorization_snapshot = {"snapshotId": "auth-work12-production-v1",
"allowedPurpose": "production_generation",
"verifiedAt": generated_at, "sourceVersion": "v1"}
assembled = assemble_context(
run_id=run_id, attempt=1, mode="production", purpose="production",
quality_policy_version="writer-production-v1", work_id=WORK_ID,
target_chapter=target, as_of=as_of, source_version="outline@v1",
authorization_snapshot=authorization_snapshot, source_status="active",
retrieval_plan=plan, retrieval_result=retrieval, fine_outline=fine_outline,
narrative_state=narrative_state, recent_chapters=recent_chapters,
output_contract=output_contract, token_budget=token_budget,
generation_length_contract=generation_length_contract,
pattern_references=pattern_references, style_constraints=style_constraints,
humanization_contract=humanization_contract, generated_at=generated_at,
evidence_strategy="production_dual_evidence")
writer_context = assembled["context"]
(ARTIFACTS / f"{run_id}-writer-context.json").write_text(
assembled["contextJson"], encoding="utf-8")
print(f"上下文冻结: contextSha256={writer_context['contextSnapshot']['contextSha256'][:24]}... "
f"写手篇幅 {generation_length_contract['minChars']}-{generation_length_contract['maxChars']}"
f"(目标 {generation_length_contract['targetChars']}),"
f"机械接受 >3000(系统上限 {output_contract['maxChars']}),"
f"范式绑定 {len(pattern_references)} 张,"
f"文风约束 {len(style_constraints)} 条")
# 4) 生产 pipeline:持久 CAS + writer 真调 + 机械门 + 语义 detector(先审后入)
writer_profile = build_writer_execution_profile(
max_budget_usd_per_call=Decimal("5.0"),
timeout_seconds=1200,
max_context_chars=token_budget["maxContextChars"],
system_prompt=SYSTEM_PROMPT,
system_prompt_id=SYSTEM_PROMPT_ID,
)
detector_profile = build_semantic_detector_profile()
semantic_runner = ProductionSemanticRunner(detector_profile, run_id=run_id)
state_store = PostgresCasStateStore(
work_id=WORK_ID, target_chapter=target, creator="continuation")
receipts_by_version: dict[int, Any] = {}
candidates_by_version: dict[int, dict] = {}
contexts_by_attempt: dict[int, dict] = {}
writer_raw_refs: dict[int, tuple[Any, Any]] = {}
explorations_by_version: dict[int, dict] = {}
def _diagnose_candidate(candidate_body: str, candidate_version: int) -> None:
"""人感技能 3:每个候选先做只读诊断并自动落质量账;不在这里改正文。"""
deai_artifact = run_diagnosis(
candidate_body, work_ref=f"work:{WORK_ID}",
chapter_ref=f"chapter:{target}", mode="Audit",
)
_dump(ARTIFACTS / f"{run_id}-ai-flavor-diagnosis-v{candidate_version}.json", deai_artifact)
persist_diagnosis(deai_artifact, text=candidate_body)
print(f"AI 味诊断(v{candidate_version}):发现 {len(deai_artifact['findings'])} 条")
def production_writer(current_context: Mapping[str, Any], candidate_version: int):
"""writer 适配:直调链篇幅越界自动重抽(≤3 遍);派发链失败关闭。"""
contexts_by_attempt[current_context["attempt"]] = dict(current_context)
if dispatch_mode:
if two_phase_mode:
try:
candidate, receipt, raw_ref, exploration = run_two_phase_writer(
current_context,
candidate_version=candidate_version,
repo_root=REPO_ROOT,
provider=dispatch_provider,
model=dispatch_model,
thinking=dispatch_thinking,
human_instruction=human_instruction,
spec_dir=ARTIFACTS,
)
except ExplorationError as exc:
raise PipelineError(exc.code, f"两阶段写手失败: {exc}",
details=exc.details) from exc
explorations_by_version[candidate_version] = exploration
_dump(ARTIFACTS / f"{run_id}-exploration-summary-v{candidate_version}.json",
exploration)
print(f"两阶段写手模式: exploration_run={exploration['explorationRunId']} "
f"材料={exploration['materialCount']} 生成_run={exploration['generationRunId']}")
else:
try:
candidate, receipt, raw_ref = run_writer_via_dispatch(
current_context,
candidate_version=candidate_version,
repo_root=REPO_ROOT,
provider=dispatch_provider,
model=dispatch_model,
thinking=dispatch_thinking,
human_instruction=human_instruction,
spec_path=ARTIFACTS / f"{run_id}-writer-task-v{candidate_version}.json",
)
except DispatchWriterError as exc:
raise PipelineError(exc.code, f"writer 派发失败: {exc}",
details=exc.details) from exc
receipts_by_version[candidate_version] = receipt
candidates_by_version[candidate_version] = candidate
writer_raw_refs[candidate_version] = raw_ref
try:
_diagnose_candidate(candidate["candidateBody"], candidate_version)
except Exception as exc:
raise PipelineError(
"AI_FLAVOR_DIAGNOSIS_FAILED", "候选 AI 味诊断或落库失败",
details={"errorType": type(exc).__name__, "message": str(exc)},
) from exc
print(f"writer 派发模式: dispatch_run={receipt.dispatch_run_id}")
return candidate
for retry in range(1, MAX_ATTEMPTS + 1):
try:
candidate, receipt = run_writer_with_receipt(
current_context,
profile=writer_profile,
candidate_version=candidate_version,
run_id=run_id,
caller="writer",
persist_call=persist_llm_event,
)
receipts_by_version[candidate_version] = receipt
candidates_by_version[candidate_version] = candidate
try:
_diagnose_candidate(candidate["candidateBody"], candidate_version)
except Exception as exc:
raise PipelineError(
"AI_FLAVOR_DIAGNOSIS_FAILED", "候选 AI 味诊断或落库失败",
details={"errorType": type(exc).__name__, "message": str(exc)},
) from exc
return candidate
except WriterAdapterError as exc:
if exc.code != "candidate_length_out_of_range" or retry == MAX_ATTEMPTS:
raise PipelineError(exc.code, f"writer 真调失败: {exc}",
details=exc.details) from exc
print(f" 第{retry}遍越界({exc.details.get('actualHanChars')} 字),重抽……")
def production_semantic_detector(current_context, candidate, mechanical_report):
"""语义 detector 适配:构造冻结输入、真调模型、留档输入输出。"""
version = candidate["candidateVersion"]
detector_input = build_semantic_input_v3(
run_id=run_id, sample_id=f"writer-ch{target}", opaque_arm_id="production",
writer_context=current_context, candidate=candidate)
_dump(ARTIFACTS / f"{run_id}-semantic-input-v{version}.json", detector_input)
outcome = run_writer_semantic_detector(detector_input, model_runner=semantic_runner)
_dump(ARTIFACTS / f"{run_id}-semantic-output-v{version}.json", outcome)
if outcome.get("ok") is not True or not isinstance(outcome.get("report"), Mapping):
diagnostic = build_safe_semantic_diagnostic(outcome)
raise PipelineError(
"SEMANTIC_DETECTOR_FAILED",
f"语义 detector 未产生有效报告: {diagnostic['primaryCode']}",
details={"safeDiagnostic": diagnostic})
print(f"语义 detector(v{version}): status={outcome['status']} "
f"调用={outcome['attemptCount']}次 纠错={outcome['correctionCount']}次")
return outcome["report"]
def production_evidence_provider(context, gaps, attempt):
"""语义缺口 → 正典检索;命中则注入摘录。零命中由 pipeline 当新设定交人闸。"""
try:
next_ctx = reassemble_writer_context_for_gaps(
context, gaps, attempt, work_id=WORK_ID)
except EvidenceReassembleError as exc:
raise PipelineError(
"PRODUCTION_EVIDENCE_REASSEMBLE_FAILED",
f"补证重组装失败(缺口 {len(gaps)}): {exc}",
) from exc
print(
f"补证重组装: attempt={next_ctx['attempt']} "
f"facts={len(next_ctx.get('factEvidence') or [])} "
f"gaps={len(gaps)}"
)
return next_ctx
# 候选版本接续:候选表对 (作品,章,candidate_version) 唯一,重跑同章必须从已有最大版本+1 起,
# 否则与上一轮留库的被拒候选撞版本。
with connect(readonly=True) as conn:
max_version_row = conn.execute(
"SELECT COALESCE(MAX(CASE WHEN candidate_version ~ '^[0-9]+$' "
"THEN candidate_version::integer END),0) FROM example_candidate "
"WHERE tenant_id=0 AND work_id=%s AND target_chapter=%s AND deleted=false",
(WORK_ID, target)).fetchone()
initial_candidate_version = int(max_version_row[0]) + 1
if continue_from:
request_path = ARTIFACTS / f"{continue_from}-authorization-request.json"
try:
request = json.loads(request_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise SystemExit(f"授权请求读取失败: {request_path}: {exc}")
if request.get("workId") != WORK_ID or request.get("targetChapter") != target:
raise SystemExit("授权请求与本次作品/章不匹配,拒绝继续")
gaps = request.get("evidenceGaps") or []
if not gaps:
raise SystemExit("授权请求无证据缺口,无需继续")
writer_context = production_evidence_provider(writer_context, gaps, writer_context["attempt"] + 1)
print(f"[授权继续] 前序运行 {continue_from}:命中缺口 {len(gaps)} 项,"
f"本次以补证后上下文(attempt={writer_context['attempt']})继续。")
start_run(run_id=run_id, work_id=WORK_ID, target_chapter=target,
trigger_detail={"stage": "writer-production-pipeline",
"contextSha256": writer_context["contextSnapshot"]["contextSha256"],
**({"continueFrom": continue_from} if continue_from else {}),
**({"writerMode": "dispatch"} if dispatch_mode else {})},
creator="continuation")
try:
pipeline_result = run_writer_pipeline(
context=writer_context,
requirements=GATE_ANCHORS[target],
writer=production_writer,
evidence_provider=production_evidence_provider,
semantic_detector=production_semantic_detector,
state_store=state_store,
result_path=ARTIFACTS / f"{run_id}-pipeline-result.json",
initial_candidate_version=initial_candidate_version,
)
except PipelineError as exc:
# 被拒候选留痕:凡跑过机械门的版本都落 Shadow(state=rejected + 机械/语义证据)
trace = (exc.result or {}).get("trace") or []
audit_entry = next((entry for entry in reversed(trace)
if isinstance(entry.get("mechanicalReport"), Mapping)), None)
if audit_entry is not None:
version = audit_entry.get("candidateVersion")
failed_candidate = candidates_by_version.get(version)
failed_receipt = receipts_by_version.get(version)
if failed_candidate is not None and failed_receipt is not None:
try:
persisted = persist_writer_execution(
contexts_by_attempt.get(failed_candidate.get("attempt"), writer_context),
failed_candidate, failed_receipt, audit_entry["mechanicalReport"],
semantic_report=audit_entry.get("semanticReport"),
assemble_result=assembled,
writer_raw_ref=writer_raw_refs.get(failed_candidate.get("candidateVersion")))
print(f"[被拒候选留库] candidate_id={persisted['candidate_id']} "
f"state={persisted['state']} semantic={persisted.get('semantic_status')}")
except Exception as persist_exc: # 留痕失败不掩盖原始失败码
print(f"[警告] 被拒候选留库失败: {persist_exc}", file=sys.stderr)
finish_run(run_id, "failed", creator="continuation",
trigger_detail={"stage": "writer-production-pipeline", "failureCode": exc.code})
if exc.code == "AUTHORIZATION_REQUIRED":
gaps = (exc.details or {}).get("evidenceGaps") or []
_dump(ARTIFACTS / f"{run_id}-authorization-request.json", {
"schemaVersion": "authorization-request-v1",
"runId": run_id,
"workId": WORK_ID,
"targetChapter": target,
"humanInstruction": human_instruction,
"evidenceGaps": gaps,
"nextAttempt": (exc.details or {}).get("nextAttempt"),
"reassembledContextSha256": (exc.details or {}).get("reassembledContextSha256"),
"candidateSha256": (exc.result or {}).get("candidateSha256"),
"generatedAt": generated_at,
})
print(f"授权请求已留痕: artifacts/{run_id}-authorization-request.json")
print("[需要授权] 语义检查发现证据缺口,补证或重写需要人授权:")
for item in gaps:
print(f" - {item.get('gapId')}: {item.get('reason')}(检索:{item.get('query')})")
print("授权后由主代理发起新运行继续补证(本运行已收敛 REJECTED,新运行接续候选版本)。")
print(f"[停止] 生产 pipeline 未通过: code={exc.code};{exc}")
print(f"复核 artifacts/{run_id}-pipeline-result.json 后决定下一步。")
print(f"\nRUN_ID={run_id}")
raise SystemExit(1)
except Exception as exc:
# 非 PipelineError(库连接断、适配层异常等)也要收口运行态,不留 running 悬挂
try:
finish_run(run_id, "failed", creator="continuation",
trigger_detail={"stage": "writer-production-pipeline",
"error": type(exc).__name__})
except Exception:
pass
raise
# 5) pipeline 通过:机械门 + 语义 detector 双证据落库(候选 semantic_status=passed)
candidate = pipeline_result["candidateArtifact"]
final_context = contexts_by_attempt.get(pipeline_result["attempt"], writer_context)
final_trace = pipeline_result["trace"][-1]
receipt = receipts_by_version[pipeline_result["candidateVersion"]]
persisted = persist_writer_execution(
final_context, candidate, receipt, final_trace["mechanicalReport"],
semantic_report=final_trace.get("semanticReport"), assemble_result=assembled,
writer_raw_ref=writer_raw_refs.get(candidate.get("candidateVersion")))
cand_id = persisted["candidate_id"]
print(f"writer 产出: sha256={candidate['candidateSha256'][:24]}..., "
f"实际模型={receipt.actual_model_id}, 成本=${receipt.total_cost_usd}")
print(f"落库: candidate_id={cand_id}, receipt_id={persisted['receipt_id']}, "
f"raw_content_id={persisted['raw_content_id']}, state={persisted['state']}, "
f"semantic={persisted['semantic_status']}")
# 6) 接受前置检查:实时状态重读 + 纯函数全检(上下文/授权/来源/有效期/detector 终态)
try:
live_state = build_live_acceptance_state(final_context)
preflight = check_writer_acceptance(
decision="accept", confirmed=True, context=final_context, candidate=candidate,
detector_result=pipeline_result, live_state=live_state,
expected_revision=live_state["canonicalRevision"])
except (LiveStateError, AcceptanceError) as exc:
finish_run(run_id, "failed", creator="continuation",
trigger_detail={"stage": "accept-preflight",
"error": getattr(exc, "code", type(exc).__name__)})
print(f"[停止] 接受前置检查未通过: {getattr(exc, 'code', '')} {exc}")
print(f"候选 {cand_id} 已留库(state=passed, semantic=passed),人工复核后决定。")
print(f"\nCANDIDATE_ID={cand_id}\nRUN_ID={run_id}")
raise SystemExit(1)
print(f"接受前置检查: {preflight['status']} canonicalRevision={live_state['canonicalRevision']}")
# 7) 人闸:生成入口到此停止,正式正文只能由用户明确决定后走 decide-candidate。
print(f"候选 {cand_id} 已通过机械门、语义门与接受前置检查,尚未写入正式正文。")
print(f"候选详情: http://127.0.0.1:8765/candidates/{cand_id}")
print("请决定:改:<具体要求> / 丢弃 / 采纳")
print(f"\nCANDIDATE_ID={cand_id}\nRUN_ID={run_id}")
if __name__ == "__main__":
main()