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