Files
mini-openclaw-v2/backend/services/assistant_store.py
T
qiaoqichaoandClaude Code c59fff68d3 feat(v2): S1 存储层——材料导入批次与事实冲突检测
T02 第二个子卡。复用 assistant_objects(新 kind),**未新建表**;旧库结构不变。

新增服务:
- services/material_import.py 批次生命周期:create/load/listing/mark_item/
  retry/cancel;状态由逐项状态派生(ready/partial/failed/parsing)
- services/fact_conflict.py 冲突检测/阻断/裁定;FactConflictUnresolved(E-17)

解决的**真实缺口**(BR-23 / AC-24):既有 apply_candidate 对 values/facts 是
后写覆盖,sources 虽累积多来源但无人检测分歧、无人阻断。本层在**新路径**前置
检测:冲突时证据仍入 sources(BR-22 留痕)但值不采用(BR-23),已确认值不被覆盖。

增量改动(均为可加性,未改既有语义与调用方行为):
- assistant_store.py:新增 idempotent_lookup / idempotent_remember 共享 helper。
  既有 enqueue 与 create_report 的内联实现**未改写**。用 INSERT OR REPLACE 而非
  裸 INSERT:重试路径上裸 INSERT 会抛 sqlite3.IntegrityError(非 Conflict,
  translate() 无法映射为 409)。
- assistant_materials.apply_candidate 新增 adopt=True 形参。adopt=False 只记证据
  不改值;既有调用方不传参,行为完全不变(有回归测试)。

设计决定:
- T00/04 曾提 3 个 kind;实现时发现契约中 ImportItem 无 revision 且内嵌于批次,
  items 是值对象,故收敛为 2 个 kind(import_batch / fact_conflict)
- E-17 有两个面:确认动作被拒 = 422;运行级结果 = HTTP 200 且冲突存于
  degradations。契约映射与注释均已写明,并有测试锁定

测试:新增 test_prescreen_storage.py(33 项)。全量 196 项 OK(124 → 196)。
scripts/verify.py 9/9 PASS(exit 0)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 14:49:03 +08:00

163 lines
6.5 KiB
Python

"""Transactional JSON storage. Connections are closed, including error paths."""
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from hashlib import sha256
from pathlib import Path
from uuid import uuid4
def now():
return datetime.now(timezone.utc).isoformat()
def uid(prefix):
return prefix + "-" + uuid4().hex
def digest(value):
return sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
@contextmanager
def transaction(path):
path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(path, timeout=15)
db.row_factory = sqlite3.Row
db.execute("PRAGMA busy_timeout=15000")
try:
with db:
yield db
finally:
db.close()
def initialize(path):
with transaction(path) as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS assistant_objects (
kind TEXT NOT NULL, id TEXT NOT NULL, project_id TEXT NOT NULL,
revision INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(kind,id));
CREATE INDEX IF NOT EXISTS assistant_scope ON assistant_objects(kind,project_id);
CREATE TABLE IF NOT EXISTS assistant_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL,
run_id TEXT, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS assistant_idempotency (
actor TEXT, operation TEXT, key TEXT, request_hash TEXT, object_id TEXT,
PRIMARY KEY(actor,operation,key));
CREATE TABLE IF NOT EXISTS assistant_jobs (
id TEXT PRIMARY KEY, project_id TEXT, state TEXT, owner TEXT,
lease_until REAL NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS assistant_meta (key TEXT PRIMARY KEY, value TEXT);
""")
schema = Path(__file__).resolve().parents[1] / "fixtures" / "xingbang_demo" / "business_flow_schema.sql"
if schema.exists():
db.executescript(schema.read_text(encoding="utf-8"))
def get(db, kind, object_id):
row = db.execute("SELECT body FROM assistant_objects WHERE kind=? AND id=?", (kind, object_id)).fetchone()
if row is None:
raise KeyError(f"{kind} 不存在")
return json.loads(row[0])
def listing(db, kind, project_id=None):
if project_id is None:
rows = db.execute("SELECT body FROM assistant_objects WHERE kind=? ORDER BY rowid DESC", (kind,))
else:
rows = db.execute("SELECT body FROM assistant_objects WHERE kind=? AND project_id=? ORDER BY rowid DESC", (kind, project_id))
return [json.loads(row[0]) for row in rows]
def put(db, kind, body, expected=None, immutable=False):
old = db.execute("SELECT revision FROM assistant_objects WHERE kind=? AND id=?", (kind, body["id"])).fetchone()
if immutable and old:
raise ValueError("发布版本和历史快照不可覆盖")
if expected is not None and (not old or old[0] != expected):
raise Conflict("内容已更新,请刷新后重试")
body = dict(body, revision=(old[0] + 1 if old else 1))
db.execute("INSERT INTO assistant_objects VALUES (?,?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET revision=excluded.revision,body=excluded.body",
(kind, body["id"], body.get("project_id", ""), body["revision"], json.dumps(body, ensure_ascii=False, allow_nan=False)))
return body
def event(db, project_id, action, actor, detail=None, run_id=None):
body = {"time": now(), "action": action, "actor": actor, "detail": detail or {}}
db.execute("INSERT INTO assistant_events(project_id,run_id,body) VALUES (?,?,?)", (project_id, run_id, json.dumps(body, ensure_ascii=False)))
def idempotent_lookup(db, actor, operation, key, request_hash):
"""幂等键查询(新增的共享 helper)。
既有 enqueue / create_report 各自内联同一段逻辑;本函数是新增实现,供新路径
使用。**未改写那两处**,避免影响既有运行与报告流程。
返回既有 object_id;同键异载荷抛 Conflict(对应 E-09 IDEMPOTENCY_CONFLICT)。
"""
prior = db.execute(
"SELECT request_hash,object_id FROM assistant_idempotency WHERE actor=? AND operation=? AND key=?",
(actor, operation, key),
).fetchone()
if not prior:
return None
if prior[0] != request_hash:
raise Conflict("幂等键已用于不同请求")
return prior[1]
def idempotent_remember(db, actor, operation, key, request_hash, object_id):
"""登记幂等键。
用 INSERT OR REPLACE 而非既有内联代码的裸 INSERT:重试路径上同一个键可能再次
到达,裸 INSERT 会因主键冲突抛 sqlite3.IntegrityError,那不是 Conflict,无法被
translate() 映射为 409。同键同载荷在 lookup 阶段已提前返回,故此处的覆盖分支
不可达,语义不变。
"""
db.execute(
"INSERT OR REPLACE INTO assistant_idempotency VALUES (?,?,?,?,?)",
(actor, operation, key, request_hash, object_id),
)
def time_event(db, project_id, phase_code, phase_name, role, category, actor, source_type, source_id,
status="running", started_at=None, ended_at=None, detail=None, event_id=None):
"""Create an immutable phase interval; callers close it with finish_time_event."""
event_id = event_id or uid("time")
db.execute(
"INSERT INTO business_time_events VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(event_id, project_id, phase_code, phase_name, role, category, status,
started_at or now(), ended_at, actor, source_type, source_id,
json.dumps(detail or {}, ensure_ascii=False)),
)
return event_id
def finish_time_event(db, source_type, source_id, status="completed", ended_at=None):
db.execute(
"UPDATE business_time_events SET status=?,ended_at=? WHERE source_type=? AND source_id=? AND status='running'",
(status, ended_at or now(), source_type, source_id),
)
def time_listing(db, project_ids=None):
query = "SELECT * FROM business_time_events"
params = []
if project_ids is not None:
if not project_ids:
return []
query += " WHERE project_id IN (" + ",".join("?" for _ in project_ids) + ")"
params.extend(project_ids)
query += " ORDER BY started_at"
result = []
for row in db.execute(query, params):
item = dict(row)
item["detail"] = json.loads(item["detail"] or "{}")
result.append(item)
return result
class Conflict(ValueError):
pass