zizi b0bc7a8745 框架: 技能按动作-对象重组 + 先审后入创作闭环
一、技能重组(动作-对象命名)
- 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为
  clean-book-text/decide-candidate/write-next-chapter/access-database/
  check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持)
- agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名

二、先审后入创作闭环(本次核心)
正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交",
DB 级兜底,编排层跳步即被硬拒。
- candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链
- fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量,
  模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本
- projection_registry.py + example_projection_run(107):投影登记与恢复
- acceptance_state.py:接受前置实时状态重读
- lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格
- DDL 105:example_candidate 增 semantic_status/semantic_report_sha256
- write_canonical.accept:语义兜底+同事务合并增量+登记投影;
  run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链
- claude_runtime:兼容新 CLI modelUsage 信息字段

三、审查修复(独立子代理四维审查后)
- 事实增量 propose→approve 翻态正道,不撞唯一键
- 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记
- 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理

测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。
创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
2026-08-14 10:24:08 +08:00

297 lines
11 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
"""构建、调用、校验并接纳前期设计候选的逐章串行统合结果。"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import re
import subprocess
import sys
H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
H3_RE = re.compile(r"^#{3,6}\s+", re.MULTILINE)
RANGE_RE = re.compile(r"<!--\s*S(\d+)\s*[-—–]\s*S(\d+)\s*-->")
SETTING_RE = re.compile(r"^\s*[-*]\s+(?:\*\*)?(S\d{3,})(?=[^\d])", re.MULTILINE)
CHAPTER_RE = re.compile(r"<chapter>\s*(.*?)\s*</chapter>", re.DOTALL)
DECISION_RE = re.compile(r"<decision>\s*(.*?)\s*</decision>", re.DOTALL)
class SerialMergeError(ValueError):
"""串行统合合同不满足。"""
@dataclass(frozen=True)
class Section:
heading: str
text: str
def effective_chars(text: str) -> int:
return len(re.sub(r"\s+", "", text))
def sections(text: str) -> list[Section]:
matches = list(H2_RE.finditer(text))
result: list[Section] = []
for index, match in enumerate(matches):
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
result.append(Section(match.group(1).strip(), text[match.start():end].rstrip()))
return result
def exact_section(text: str, heading: str) -> Section:
matches = [section for section in sections(text) if section.heading == heading]
if len(matches) != 1:
raise SerialMergeError(f"章节“{heading}”必须且只能出现一次")
return matches[0]
def root_section(text: str) -> Section:
matches = [section for section in sections(text) if "根设定" in section.heading]
if len(matches) != 1:
raise SerialMergeError("根设定章节缺失或重复")
return matches[0]
def content_headings(text: str) -> list[str]:
return [section.heading for section in sections(text) if section.heading != "目录"]
def validate_prefix(integrated: str, source: str, heading: str) -> tuple[list[str], int]:
expected = content_headings(source)
if heading not in expected:
raise SerialMergeError(f"来源候选中没有章节“{heading}”")
index = expected.index(heading)
actual = content_headings(integrated)
if actual != expected[:index]:
raise SerialMergeError(
f"统合前文不是冻结目录前缀:期望 {expected[:index]},实际 {actual}"
)
return expected, index
def build_packet(args: argparse.Namespace) -> None:
root_text = args.root_doc.read_text(encoding="utf-8")
integrated_text = args.integrated_doc.read_text(encoding="utf-8")
source_texts = [path.read_text(encoding="utf-8") for path in args.candidates]
if not source_texts:
raise SerialMergeError("至少需要一份来源候选")
expected, index = validate_prefix(integrated_text, source_texts[0], args.heading)
source_shape = ["目录", *expected]
blocks: list[str] = []
ranges: set[tuple[str, ...]] = set()
for path, text in zip(args.candidates, source_texts, strict=True):
if [section.heading for section in sections(text)] != source_shape:
raise SerialMergeError(f"来源候选目录漂移:{path}")
current = exact_section(text, args.heading)
found_ranges = RANGE_RE.findall(current.text)
if len(found_ranges) != 1:
raise SerialMergeError(f"来源章节号段异常:{path}")
ranges.add(found_ranges[0])
blocks.append(f"### 来源候选:{path.name}\n\n{current.text}")
if len(ranges) != 1:
raise SerialMergeError(f"来源章节号段不一致:{sorted(ranges)}")
range_start, range_end = next(iter(ranges))
prior = integrated_text.rstrip()
packet = (
"# 逐章串行统合输入包\n\n"
f"- 当前序号:{index + 1}/{len(expected)}\n"
f"- 当前标题:{args.heading}\n"
f"- 冻结号段:S{int(range_start):03d}-S{int(range_end):03d}\n\n"
"## 最新根设定(最高权威)\n\n"
f"{root_section(root_text).text}\n\n"
"## 已审定统合前文(次高权威)\n\n"
f"{prior}\n\n"
"## 五份来源候选的当前章节\n\n"
+ "\n\n".join(blocks)
+ "\n"
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(packet, encoding="utf-8")
print(
f"SERIAL_MERGE_PACKET_OK: {args.heading},来源 {len(blocks)} 份,"
f"输入 {effective_chars(packet)} 有效字符"
)
def run_luna(args: argparse.Namespace) -> None:
prompt = (
f"你是当前第 {args.index} 章的独立串行统合代理。完整阅读合同与输入包,"
f"只处理“{args.heading}”。选择并重写 {args.min_settings}—{args.max_settings} 项高质量设定,"
f"章节有效字符不少于 {args.min_chars}。严格遵守根设定与已统合前文,不读取或猜测后续章节。"
"输出必须且只能包含 <decision> 与 <chapter> 两个区块,不加代码围栏。"
)
command = [
"pi",
"--model",
args.model,
"--thinking",
args.thinking,
"--no-tools",
"--no-session",
"--no-context-files",
"--no-skills",
"--no-extensions",
"--mode",
"text",
"-p",
f"@{args.contract}",
f"@{args.packet}",
prompt,
]
completed = subprocess.run(
command,
cwd=args.cwd,
text=True,
capture_output=True,
timeout=args.timeout,
check=False,
)
if completed.returncode != 0:
if completed.stderr:
print(completed.stderr, file=sys.stderr)
raise SerialMergeError(f"Luna Max 调用失败,退出码 {completed.returncode}")
if not completed.stdout.strip():
raise SerialMergeError("Luna Max 返回空结果")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(completed.stdout, encoding="utf-8")
print(
f"SERIAL_MERGE_LUNA_OK: model={args.model} thinking={args.thinking} "
f"output={args.output}"
)
def parse_raw(
raw: str,
heading: str,
range_start: int,
range_end: int,
min_settings: int,
max_settings: int,
min_chars: int,
) -> tuple[str, str, list[int]]:
chapter_matches = CHAPTER_RE.findall(raw)
decision_matches = DECISION_RE.findall(raw)
if len(chapter_matches) != 1 or len(decision_matches) != 1:
raise SerialMergeError("输出必须各含一个 <decision> 与 <chapter> 区块")
chapter = chapter_matches[0].strip()
decision = decision_matches[0].strip()
chapter_sections = sections(chapter)
if len(chapter_sections) != 1 or chapter_sections[0].heading != heading:
raise SerialMergeError(f"章节标题必须为“{heading}”且不能出现其他二级标题")
if H3_RE.search(chapter):
raise SerialMergeError("章节草案出现三级或更深标题")
found_ranges = RANGE_RE.findall(chapter)
normalized_ranges = [(int(start), int(end)) for start, end in found_ranges]
expected_range = (range_start, range_end)
if len(normalized_ranges) != 1 or normalized_ranges[0] != expected_range:
raise SerialMergeError(
f"章节号段必须为 S{range_start:03d}-S{range_end:03d}"
)
ids = [int(match.group(1)[1:]) for match in SETTING_RE.finditer(chapter)]
if ids != sorted(ids) or len(ids) != len(set(ids)):
raise SerialMergeError("设定编号必须递增且不重复")
if any(value < range_start or value > range_end for value in ids):
raise SerialMergeError("设定编号越出冻结号段")
if not min_settings <= len(ids) <= max_settings:
raise SerialMergeError(
f"当前章 {len(ids)} 项,要求 {min_settings}—{max_settings} 项"
)
if effective_chars(chapter) < min_chars:
raise SerialMergeError(
f"当前章 {effective_chars(chapter)} 有效字符,低于 {min_chars}"
)
return decision, chapter, ids
def check_output(args: argparse.Namespace, accept: bool = False) -> None:
raw = args.raw.read_text(encoding="utf-8")
decision, chapter, ids = parse_raw(
raw,
args.heading,
args.range_start,
args.range_end,
args.min_settings,
args.max_settings,
args.min_chars,
)
if accept:
integrated_text = args.integrated_doc.read_text(encoding="utf-8")
source_text = args.source_candidate.read_text(encoding="utf-8")
validate_prefix(integrated_text, source_text, args.heading)
updated = integrated_text.rstrip() + "\n\n" + chapter.rstrip() + "\n"
args.integrated_doc.write_text(updated, encoding="utf-8")
action = "ACCEPTED" if accept else "CHECK_OK"
print(
f"SERIAL_MERGE_{action}: {args.heading},{len(ids)} 项,"
f"{effective_chars(chapter)} 有效字符;决策摘要 {effective_chars(decision)} 字符"
)
def shared_output_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--raw", type=Path, required=True)
parser.add_argument("--heading", required=True)
parser.add_argument("--range-start", type=int, required=True)
parser.add_argument("--range-end", type=int, required=True)
parser.add_argument("--min-settings", type=int, default=10)
parser.add_argument("--max-settings", type=int, default=50)
parser.add_argument("--min-chars", type=int, default=4200)
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description=__doc__)
subparsers = root.add_subparsers(dest="command", required=True)
packet = subparsers.add_parser("packet")
packet.add_argument("--root-doc", type=Path, required=True)
packet.add_argument("--integrated-doc", type=Path, required=True)
packet.add_argument("--heading", required=True)
packet.add_argument("--output", type=Path, required=True)
packet.add_argument("candidates", type=Path, nargs="+")
packet.set_defaults(handler=build_packet)
run = subparsers.add_parser("run")
run.add_argument("--packet", type=Path, required=True)
run.add_argument("--contract", type=Path, required=True)
run.add_argument("--heading", required=True)
run.add_argument("--index", type=int, required=True)
run.add_argument("--output", type=Path, required=True)
run.add_argument("--cwd", type=Path, required=True)
run.add_argument("--model", default="catproxy-openai/gpt-5.6-luna")
run.add_argument("--thinking", default="max")
run.add_argument("--min-settings", type=int, default=12)
run.add_argument("--max-settings", type=int, default=18)
run.add_argument("--min-chars", type=int, default=4200)
run.add_argument("--timeout", type=int, default=1200)
run.set_defaults(handler=run_luna)
check = subparsers.add_parser("check")
shared_output_args(check)
check.set_defaults(handler=lambda args: check_output(args, accept=False))
accept = subparsers.add_parser("accept")
shared_output_args(accept)
accept.add_argument("--integrated-doc", type=Path, required=True)
accept.add_argument("--source-candidate", type=Path, required=True)
accept.set_defaults(handler=lambda args: check_output(args, accept=True))
return root
def main() -> int:
args = parser().parse_args()
try:
args.handler(args)
except (OSError, SerialMergeError, subprocess.TimeoutExpired) as error:
print(f"SERIAL_MERGE_FAILED: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())