一、技能重组(动作-对象命名) - 旧目录 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)按"框架与创作分开"未入本提交。
297 lines
11 KiB
Python
297 lines
11 KiB
Python
#!/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())
|