673 lines
36 KiB
Python
673 lines
36 KiB
Python
#!/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 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"]
|
||
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]] = {}
|
||
|
||
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:
|
||
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()
|