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

196 lines
8.1 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
"""Canonical 投影的登记、失效与恢复(08 数据权威)。
摘要、handoff、embedding、章后抽取、看板缓存都是正文的幂等投影:派生数据坏了可重建,
绝不能反向成为事实源。本模块把每个投影绑定到 source_revision + source_text_hash:
- register_pending_projections:正文提交同事务登记 pending 投影(幂等键防重放重复登记);
- mark_stale_before_revision:正文换新 revision 时,同事务把旧 revision 的投影翻 stale;
- finish_projection / retry_projection:投影 worker 报告完成/失败与显式重试(attempt+1);
- refresh_staleness:恢复巡检——按当前正文 revision/哈希对账,漂移的投影标 stale。
投影失败记 failed 并留原因,绝不显示 completed;stale/failed 不得直接洗白成 completed(DB 触发器兜底)。
"""
from __future__ import annotations
import hashlib
import pathlib
import sys
from typing import Any, Iterable
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
DB_DIR = SCRIPT_DIR.parents[1] / "access-database" / "scripts"
if str(DB_DIR) not in sys.path:
sys.path.insert(0, str(DB_DIR))
from db import connect # noqa: E402
PROJECTION_KINDS = frozenset({"summary", "handoff", "embedding", "extraction", "dashboard"})
CREATOR = "confirm"
class ProjectionError(RuntimeError):
"""投影登记/状态流转非法——失败关闭。"""
def _bare_sha(value: str) -> str:
value = str(value or "")
return value[len("sha256:"):] if value.startswith("sha256:") else value
def idempotency_key(work_id: int, target_chapter: int | None, kind: str, source_revision: int) -> str:
"""同一提交重放不重复登记的幂等键:work:chapter:kind:revN。"""
chapter_part = f"ch{target_chapter}" if target_chapter is not None else "book"
return f"{work_id}:{chapter_part}:{kind}:rev{source_revision}"
def register_pending_projections(
conn,
*,
work_id: int,
target_chapter: int | None,
kinds: Iterable[str],
source_revision: int,
source_text_hash_bare: str,
candidate_sha256_bare: str | None = None,
creator: str = CREATOR,
) -> list[int]:
"""在调用方事务内登记 pending 投影;重放同一幂等键回读已有行,不重复登记。"""
kinds = list(dict.fromkeys(kinds)) # 去重保序
unknown = sorted(set(kinds) - PROJECTION_KINDS)
if unknown:
raise ProjectionError(f"投影类型非法: {unknown}")
if not kinds:
return []
ids: list[int] = []
for kind in kinds:
key = idempotency_key(work_id, target_chapter, kind, source_revision)
row = conn.execute(
"INSERT INTO example_projection_run(work_id, target_chapter, kind, source_revision, "
"source_text_hash, candidate_sha256, status, attempt, idempotency_key, creator) "
"VALUES (%s,%s,%s,%s,%s,%s,'pending',1,%s,%s) "
"ON CONFLICT (tenant_id, idempotency_key) DO NOTHING RETURNING id",
(work_id, target_chapter, kind, source_revision, source_text_hash_bare,
candidate_sha256_bare, key, creator),
).fetchone()
if row is None:
row = conn.execute(
"SELECT id FROM example_projection_run WHERE tenant_id=0 AND idempotency_key=%s",
(key,),
).fetchone()
if row is None:
raise ProjectionError(f"投影幂等回读失败: {key}")
ids.append(row[0])
return ids
def mark_stale_before_revision(
conn, *, work_id: int, target_chapter: int | None, new_revision: int, creator: str = CREATOR
) -> int:
"""同事务失效:新 revision 提交后,旧 revision 的活动投影一律 stale。"""
cur = conn.execute(
"UPDATE example_projection_run SET status='stale', updater=%s "
"WHERE tenant_id=0 AND work_id=%s AND target_chapter IS NOT DISTINCT FROM %s "
"AND source_revision < %s AND status IN ('pending','completed','failed') AND deleted=false",
(creator, work_id, target_chapter, new_revision),
)
return cur.rowcount
def finish_projection(projection_id: int, status: str, *, detail: Any = None,
creator: str = "projection") -> dict[str, Any]:
"""投影 worker 报告 completed/failed;仅 pending 可报告,竞争或状态不对即失败关闭。"""
if status not in ("completed", "failed"):
raise ProjectionError("finish_projection 只接受 completed/failed")
import json
detail_json = json.dumps(detail, ensure_ascii=False) if detail is not None else None
with connect() as conn:
try:
cur = conn.execute(
"UPDATE example_projection_run SET status=%s, detail=%s::jsonb, updater=%s "
"WHERE id=%s AND status='pending' AND deleted=false",
(status, detail_json, creator, projection_id),
)
if cur.rowcount != 1:
raise ProjectionError(f"投影 {projection_id} 不在 pending 状态,拒绝报告 {status}")
conn.commit()
except Exception:
conn.rollback()
raise
return {"status": status, "projection_id": projection_id}
def retry_projection(projection_id: int, *, creator: str = "projection") -> dict[str, Any]:
"""显式重试 failed/stale 投影:回 pending 且 attempt+1;completed 不得重试。"""
with connect() as conn:
try:
row = conn.execute(
"UPDATE example_projection_run SET status='pending', attempt=attempt+1, "
"detail=NULL, updater=%s WHERE id=%s AND status IN ('failed','stale') "
"AND deleted=false RETURNING attempt",
(creator, projection_id),
).fetchone()
if row is None:
raise ProjectionError(f"投影 {projection_id} 不在 failed/stale 状态,拒绝重试")
conn.commit()
except Exception:
conn.rollback()
raise
return {"status": "pending", "projection_id": projection_id, "attempt": row[0]}
def refresh_staleness(*, work_id: int, creator: str = "projection") -> list[int]:
"""恢复巡检:按当前正文块 revision/哈希对账,把漂移的投影标 stale,返回受影响 id。"""
stale_ids: list[int] = []
with connect() as conn:
try:
current = {}
rows = conn.execute(
"SELECT c.order_no, b.revision, b.content_text FROM muse_content_block b "
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
"WHERE c.work_id=%s AND b.deleted=false",
(work_id,),
).fetchall()
for order_no, revision, text in rows:
if order_no not in current or revision > current[order_no][0]:
current[order_no] = (revision, hashlib.sha256(
(text or "").encode("utf-8")).hexdigest())
projections = conn.execute(
"SELECT id, target_chapter, source_revision, source_text_hash "
"FROM example_projection_run WHERE tenant_id=0 AND work_id=%s "
"AND status IN ('pending','completed','failed') AND deleted=false",
(work_id,),
).fetchall()
for proj_id, chapter, source_revision, source_hash in projections:
latest = current.get(chapter)
drifted = (
latest is None
or source_revision < latest[0]
or source_hash != latest[1]
)
if drifted:
conn.execute(
"UPDATE example_projection_run SET status='stale', updater=%s WHERE id=%s",
(creator, proj_id),
)
stale_ids.append(proj_id)
conn.commit()
except Exception:
conn.rollback()
raise
return stale_ids
__all__ = [
"PROJECTION_KINDS", "ProjectionError", "idempotency_key",
"register_pending_projections", "mark_stale_before_revision",
"finish_projection", "retry_projection", "refresh_staleness",
]