mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
1184 lines
41 KiB
Python
1184 lines
41 KiB
Python
"""Durable audit candidate store for evidence-backed skill evolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Generator, Mapping
|
|
|
|
from openspace.skill_engine.evidence.types import (
|
|
EvidenceEvent,
|
|
EvidencePacket,
|
|
ResourceRef,
|
|
)
|
|
from openspace.utils.logging import Logger
|
|
|
|
if TYPE_CHECKING:
|
|
from openspace.skill_engine.evidence.store import EvidenceStore
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
_STATUSES = {"pending", "rejected", "promoted", "superseded"}
|
|
_RECURRENCES = {"single", "repeated", "user_explicit"}
|
|
_VOLATILE_TAGS = {
|
|
"provisional_evolution_disabled",
|
|
"admission_candidate",
|
|
"fix_only_mode_non_fix",
|
|
"low_confidence",
|
|
"candidate",
|
|
}
|
|
_DDL = """
|
|
CREATE TABLE IF NOT EXISTS evolution_candidates (
|
|
candidate_id TEXT PRIMARY KEY,
|
|
proposed_action TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
admission_id TEXT NOT NULL,
|
|
source_task_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
target_skill_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
decision_id TEXT NOT NULL,
|
|
decision_snapshot_json TEXT NOT NULL DEFAULT '{}',
|
|
evidence_refs_json TEXT NOT NULL DEFAULT '[]',
|
|
similar_skill_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
recurrence TEXT NOT NULL DEFAULT 'single',
|
|
recurrence_count INTEGER NOT NULL DEFAULT 1,
|
|
merge_key TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
promoted_action_id TEXT,
|
|
rejection_reason TEXT,
|
|
last_recheck_result_json TEXT,
|
|
blocked_reason TEXT,
|
|
needed_evidence_json TEXT NOT NULL DEFAULT '[]'
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_candidates_status
|
|
ON evolution_candidates(status, updated_at);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_candidates_admission
|
|
ON evolution_candidates(admission_id);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_candidates_pending_merge
|
|
ON evolution_candidates(merge_key)
|
|
WHERE status='pending';
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class EvolutionCandidate:
|
|
candidate_id: str
|
|
proposed_action: str
|
|
status: str
|
|
admission_id: str
|
|
source_task_ids: list[str]
|
|
target_skill_ids: list[str]
|
|
decision_id: str
|
|
decision_snapshot: dict[str, Any]
|
|
evidence_refs: list[str]
|
|
similar_skill_ids: list[str]
|
|
recurrence: str
|
|
recurrence_count: int
|
|
merge_key: str
|
|
created_at: str
|
|
updated_at: str
|
|
promoted_action_id: str | None = None
|
|
rejection_reason: str | None = None
|
|
last_recheck_result: dict[str, Any] | None = None
|
|
blocked_reason: str | None = None
|
|
needed_evidence: list[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_mapping(cls, data: Mapping[str, Any]) -> "EvolutionCandidate":
|
|
return cls(
|
|
candidate_id=str(data.get("candidate_id") or ""),
|
|
proposed_action=str(data.get("proposed_action") or ""),
|
|
status=_status(data.get("status")),
|
|
admission_id=str(data.get("admission_id") or ""),
|
|
source_task_ids=_str_list(data.get("source_task_ids")),
|
|
target_skill_ids=_str_list(data.get("target_skill_ids")),
|
|
decision_id=str(data.get("decision_id") or ""),
|
|
decision_snapshot=_dict_or_empty(data.get("decision_snapshot")),
|
|
evidence_refs=_str_list(data.get("evidence_refs")),
|
|
similar_skill_ids=_str_list(data.get("similar_skill_ids")),
|
|
recurrence=_recurrence(data.get("recurrence")),
|
|
recurrence_count=max(1, _int_or_one(data.get("recurrence_count"))),
|
|
merge_key=str(data.get("merge_key") or ""),
|
|
created_at=str(data.get("created_at") or ""),
|
|
updated_at=str(data.get("updated_at") or ""),
|
|
promoted_action_id=_none_or_str(data.get("promoted_action_id")),
|
|
rejection_reason=_none_or_str(data.get("rejection_reason")),
|
|
last_recheck_result=(
|
|
_dict_or_empty(data.get("last_recheck_result"))
|
|
if data.get("last_recheck_result") is not None
|
|
else None
|
|
),
|
|
blocked_reason=_none_or_str(data.get("blocked_reason")),
|
|
needed_evidence=_str_list(data.get("needed_evidence")),
|
|
)
|
|
|
|
|
|
class EvolutionCandidateStore:
|
|
"""Long-lived audit store for proposals that admission did not commit."""
|
|
|
|
def __init__(
|
|
self,
|
|
db_path: str | Path | None = None,
|
|
*,
|
|
evidence_store: "EvidenceStore | None" = None,
|
|
) -> None:
|
|
if evidence_store is not None:
|
|
db_path = evidence_store.db_path
|
|
if db_path is None:
|
|
raise ValueError("EvolutionCandidateStore requires db_path or evidence_store")
|
|
|
|
self.evidence_store = evidence_store
|
|
self._db_path = Path(db_path).expanduser().resolve()
|
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._mu = threading.Lock()
|
|
self._closed = False
|
|
self._conn = self._make_connection(read_only=False)
|
|
self._init_db()
|
|
|
|
@property
|
|
def db_path(self) -> Path:
|
|
return self._db_path
|
|
|
|
def create_or_merge(
|
|
self,
|
|
decision: Any,
|
|
admission: Any,
|
|
packet: EvidencePacket | None = None,
|
|
*,
|
|
job: Any | None = None,
|
|
reason: str | None = None,
|
|
) -> EvolutionCandidate:
|
|
if packet is None:
|
|
packet = self._load_packet_for_admission(admission)
|
|
|
|
draft = self._candidate_from_inputs(
|
|
decision=decision,
|
|
admission=admission,
|
|
packet=packet,
|
|
job=job,
|
|
reason=reason,
|
|
)
|
|
candidate = self._insert_or_merge(draft)
|
|
self._upsert_candidate_ref(candidate, packet=packet)
|
|
return candidate
|
|
|
|
def load_candidate(self, candidate_id: str) -> EvolutionCandidate | None:
|
|
with self._reader() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM evolution_candidates WHERE candidate_id=?",
|
|
(candidate_id,),
|
|
).fetchone()
|
|
return _row_to_candidate(row) if row is not None else None
|
|
|
|
def find_by_admission(self, admission_id: str) -> list[EvolutionCandidate]:
|
|
with self._reader() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM evolution_candidates
|
|
WHERE admission_id=?
|
|
ORDER BY created_at, candidate_id
|
|
""",
|
|
(admission_id,),
|
|
).fetchall()
|
|
return [_row_to_candidate(row) for row in rows]
|
|
|
|
def load_candidates_by_admission(
|
|
self,
|
|
admission_id: str,
|
|
) -> list[EvolutionCandidate]:
|
|
return self.find_by_admission(admission_id)
|
|
|
|
def list_candidates(
|
|
self,
|
|
status: str = "pending",
|
|
limit: int = 100,
|
|
) -> list[EvolutionCandidate]:
|
|
capped_limit = max(1, int(limit or 100))
|
|
with self._reader() as conn:
|
|
if status:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM evolution_candidates
|
|
WHERE status=?
|
|
ORDER BY updated_at DESC, candidate_id
|
|
LIMIT ?
|
|
""",
|
|
(_status(status), capped_limit),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM evolution_candidates
|
|
ORDER BY updated_at DESC, candidate_id
|
|
LIMIT ?
|
|
""",
|
|
(capped_limit,),
|
|
).fetchall()
|
|
return [_row_to_candidate(row) for row in rows]
|
|
|
|
def update_candidate_status(
|
|
self,
|
|
candidate_id: str,
|
|
status: str,
|
|
*,
|
|
rejection_reason: str | None = None,
|
|
) -> EvolutionCandidate:
|
|
normalized_status = _status(status)
|
|
if normalized_status not in {"rejected", "superseded"}:
|
|
raise ValueError("audit candidates can only be rejected or superseded")
|
|
now = _utc_now()
|
|
blocked_reason = None
|
|
needed_evidence: list[str] = []
|
|
with self._mu:
|
|
self._ensure_open()
|
|
row = self._conn.execute(
|
|
"SELECT * FROM evolution_candidates WHERE candidate_id=?",
|
|
(candidate_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise KeyError(f"Unknown evolution candidate: {candidate_id}")
|
|
|
|
self._conn.execute(
|
|
"""
|
|
UPDATE evolution_candidates
|
|
SET status=?,
|
|
updated_at=?,
|
|
promoted_action_id=?,
|
|
rejection_reason=?,
|
|
blocked_reason=?,
|
|
needed_evidence_json=?
|
|
WHERE candidate_id=?
|
|
""",
|
|
(
|
|
normalized_status,
|
|
now,
|
|
None,
|
|
rejection_reason if normalized_status == "rejected" else None,
|
|
blocked_reason,
|
|
_json(needed_evidence),
|
|
candidate_id,
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
updated = self._conn.execute(
|
|
"SELECT * FROM evolution_candidates WHERE candidate_id=?",
|
|
(candidate_id,),
|
|
).fetchone()
|
|
if updated is None:
|
|
raise RuntimeError("candidate update did not return a row")
|
|
candidate = _row_to_candidate(updated)
|
|
self._upsert_candidate_ref(candidate, packet=None)
|
|
return candidate
|
|
|
|
def reject_candidate(
|
|
self,
|
|
candidate_id: str,
|
|
reason: str,
|
|
) -> EvolutionCandidate:
|
|
return self.update_candidate_status(
|
|
candidate_id,
|
|
"rejected",
|
|
rejection_reason=reason,
|
|
)
|
|
|
|
def close(self) -> None:
|
|
with self._mu:
|
|
if self._closed:
|
|
return
|
|
self._conn.commit()
|
|
self._conn.close()
|
|
self._closed = True
|
|
|
|
def _make_connection(self, *, read_only: bool) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(
|
|
str(self._db_path),
|
|
timeout=30.0,
|
|
check_same_thread=False,
|
|
)
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=30000")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
if read_only:
|
|
conn.execute("PRAGMA query_only=ON")
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
def _init_db(self) -> None:
|
|
with self._mu:
|
|
self._conn.executescript(_DDL)
|
|
_ensure_columns(
|
|
self._conn,
|
|
"evolution_candidates",
|
|
{
|
|
"last_recheck_result_json": "TEXT",
|
|
"blocked_reason": "TEXT",
|
|
"needed_evidence_json": "TEXT NOT NULL DEFAULT '[]'",
|
|
},
|
|
)
|
|
self._reconcile_recurrence_locked()
|
|
self._conn.commit()
|
|
|
|
def _reconcile_recurrence_locked(self) -> None:
|
|
rows = self._conn.execute(
|
|
"SELECT candidate_id, source_task_ids_json, recurrence, "
|
|
"recurrence_count FROM evolution_candidates"
|
|
).fetchall()
|
|
for row in rows:
|
|
source_task_ids = _json_list(row["source_task_ids_json"])
|
|
recurrence_count = max(1, len(set(source_task_ids)))
|
|
recurrence = str(row["recurrence"] or "single")
|
|
if recurrence != "user_explicit":
|
|
recurrence = "repeated" if recurrence_count >= 2 else "single"
|
|
if (
|
|
recurrence_count == int(row["recurrence_count"] or 1)
|
|
and recurrence == str(row["recurrence"] or "single")
|
|
):
|
|
continue
|
|
self._conn.execute(
|
|
"UPDATE evolution_candidates SET recurrence=?, recurrence_count=? "
|
|
"WHERE candidate_id=?",
|
|
(recurrence, recurrence_count, row["candidate_id"]),
|
|
)
|
|
|
|
@contextmanager
|
|
def _reader(self) -> Generator[sqlite3.Connection, None, None]:
|
|
self._ensure_open()
|
|
conn = self._make_connection(read_only=True)
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
def _candidate_from_inputs(
|
|
self,
|
|
*,
|
|
decision: Any,
|
|
admission: Any,
|
|
packet: EvidencePacket | None,
|
|
job: Any | None,
|
|
reason: str | None,
|
|
) -> EvolutionCandidate:
|
|
decision_id = str(_attr(decision, "decision_id") or "")
|
|
admission_id = str(_attr(admission, "admission_id") or "")
|
|
if not decision_id:
|
|
raise ValueError("DecisionRationale.decision_id is required")
|
|
if not admission_id:
|
|
raise ValueError("AdmissionResult.admission_id is required")
|
|
|
|
proposed_action = _proposed_action(decision)
|
|
target_skill_ids = _str_list(_attr(decision, "target_skill_ids"))
|
|
evidence_refs = _evidence_refs(decision, admission, packet)
|
|
source_task_ids = _source_task_ids(packet)
|
|
if not source_task_ids:
|
|
source_task_ids = _str_list(_attr(job, "source_task_ids"))
|
|
decision_snapshot = _snapshot(decision)
|
|
if packet is not None and packet.scope.session_id:
|
|
decision_snapshot.setdefault("source_session_id", packet.scope.session_id)
|
|
if reason:
|
|
decision_snapshot.setdefault("candidate_reason", reason)
|
|
similar_skill_ids = _str_list(_attr(decision, "similar_skill_ids"))
|
|
merge_key = _merge_key(
|
|
proposed_action=proposed_action,
|
|
target_skill_ids=target_skill_ids,
|
|
semantic_identity=_candidate_semantic_identity(decision),
|
|
reason_tags=[
|
|
*_str_list(_attr(decision, "reason_tags")),
|
|
*_str_list(_attr(admission, "warnings")),
|
|
],
|
|
packet=packet,
|
|
evidence_refs=evidence_refs,
|
|
)
|
|
now = _utc_now()
|
|
recurrence = (
|
|
"user_explicit"
|
|
if _is_user_explicit(decision, admission, packet, reason)
|
|
else "single"
|
|
)
|
|
blocked_reason = _blocked_reason_from_inputs(
|
|
reason=reason,
|
|
admission=admission,
|
|
decision=decision,
|
|
)
|
|
needed_evidence = _needed_evidence_from_inputs(
|
|
reason=reason,
|
|
admission=admission,
|
|
decision=decision,
|
|
)
|
|
candidate_id = f"cand_{_digest({'merge_key': merge_key, 'admission_id': admission_id, 'nonce': uuid.uuid4().hex})[:20]}"
|
|
return EvolutionCandidate(
|
|
candidate_id=candidate_id,
|
|
proposed_action=proposed_action,
|
|
status="pending",
|
|
admission_id=admission_id,
|
|
source_task_ids=source_task_ids,
|
|
target_skill_ids=target_skill_ids,
|
|
decision_id=decision_id,
|
|
decision_snapshot=decision_snapshot,
|
|
evidence_refs=evidence_refs,
|
|
similar_skill_ids=similar_skill_ids,
|
|
recurrence=recurrence,
|
|
recurrence_count=1,
|
|
merge_key=merge_key,
|
|
created_at=now,
|
|
updated_at=now,
|
|
blocked_reason=blocked_reason,
|
|
needed_evidence=needed_evidence,
|
|
)
|
|
|
|
def _insert_or_merge(self, draft: EvolutionCandidate) -> EvolutionCandidate:
|
|
with self._mu:
|
|
self._ensure_open()
|
|
self._conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
existing = self._conn.execute(
|
|
"""
|
|
SELECT * FROM evolution_candidates
|
|
WHERE merge_key=? AND status='pending'
|
|
LIMIT 1
|
|
""",
|
|
(draft.merge_key,),
|
|
).fetchone()
|
|
if existing is None:
|
|
self._insert_locked(draft)
|
|
self._conn.commit()
|
|
row = self._conn.execute(
|
|
"SELECT * FROM evolution_candidates WHERE candidate_id=?",
|
|
(draft.candidate_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise RuntimeError("candidate insert did not return a row")
|
|
return _row_to_candidate(row)
|
|
|
|
merged = self._merge_locked(_row_to_candidate(existing), draft)
|
|
self._conn.commit()
|
|
return merged
|
|
except sqlite3.IntegrityError:
|
|
self._conn.rollback()
|
|
return self._merge_after_conflict(draft)
|
|
except Exception:
|
|
self._conn.rollback()
|
|
raise
|
|
|
|
def _insert_locked(self, candidate: EvolutionCandidate) -> None:
|
|
self._conn.execute(
|
|
"""
|
|
INSERT INTO evolution_candidates (
|
|
candidate_id, proposed_action, status, admission_id,
|
|
source_task_ids_json, target_skill_ids_json, decision_id,
|
|
decision_snapshot_json, evidence_refs_json,
|
|
similar_skill_ids_json, recurrence, recurrence_count, merge_key,
|
|
created_at, updated_at, promoted_action_id, rejection_reason,
|
|
last_recheck_result_json, blocked_reason, needed_evidence_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
_candidate_row_values(candidate),
|
|
)
|
|
|
|
def _merge_locked(
|
|
self,
|
|
existing: EvolutionCandidate,
|
|
draft: EvolutionCandidate,
|
|
) -> EvolutionCandidate:
|
|
snapshot = _merged_snapshot(existing, draft)
|
|
source_task_ids = _union(existing.source_task_ids, draft.source_task_ids)
|
|
recurrence_count = max(1, len(source_task_ids))
|
|
recurrence = _merged_recurrence(existing, draft, recurrence_count)
|
|
now = _utc_now()
|
|
merged = EvolutionCandidate(
|
|
candidate_id=existing.candidate_id,
|
|
proposed_action=existing.proposed_action,
|
|
status=existing.status,
|
|
admission_id=draft.admission_id,
|
|
source_task_ids=source_task_ids,
|
|
target_skill_ids=_union(existing.target_skill_ids, draft.target_skill_ids),
|
|
decision_id=draft.decision_id,
|
|
decision_snapshot=snapshot,
|
|
evidence_refs=_union(existing.evidence_refs, draft.evidence_refs),
|
|
similar_skill_ids=_union(existing.similar_skill_ids, draft.similar_skill_ids),
|
|
recurrence=recurrence,
|
|
recurrence_count=recurrence_count,
|
|
merge_key=existing.merge_key,
|
|
created_at=existing.created_at,
|
|
updated_at=now,
|
|
promoted_action_id=existing.promoted_action_id,
|
|
rejection_reason=existing.rejection_reason,
|
|
last_recheck_result=existing.last_recheck_result,
|
|
blocked_reason=draft.blocked_reason,
|
|
needed_evidence=draft.needed_evidence,
|
|
)
|
|
self._conn.execute(
|
|
"""
|
|
UPDATE evolution_candidates
|
|
SET admission_id=?,
|
|
source_task_ids_json=?,
|
|
target_skill_ids_json=?,
|
|
decision_id=?,
|
|
decision_snapshot_json=?,
|
|
evidence_refs_json=?,
|
|
similar_skill_ids_json=?,
|
|
recurrence=?,
|
|
recurrence_count=?,
|
|
updated_at=?,
|
|
promoted_action_id=?,
|
|
rejection_reason=?,
|
|
blocked_reason=?,
|
|
needed_evidence_json=?
|
|
WHERE candidate_id=?
|
|
""",
|
|
(
|
|
merged.admission_id,
|
|
_json(merged.source_task_ids),
|
|
_json(merged.target_skill_ids),
|
|
merged.decision_id,
|
|
_json(merged.decision_snapshot),
|
|
_json(merged.evidence_refs),
|
|
_json(merged.similar_skill_ids),
|
|
merged.recurrence,
|
|
merged.recurrence_count,
|
|
merged.updated_at,
|
|
merged.promoted_action_id,
|
|
merged.rejection_reason,
|
|
merged.blocked_reason,
|
|
_json(merged.needed_evidence),
|
|
merged.candidate_id,
|
|
),
|
|
)
|
|
return merged
|
|
|
|
def _merge_after_conflict(self, draft: EvolutionCandidate) -> EvolutionCandidate:
|
|
with self._mu:
|
|
self._ensure_open()
|
|
self._conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
row = self._conn.execute(
|
|
"""
|
|
SELECT * FROM evolution_candidates
|
|
WHERE merge_key=? AND status='pending'
|
|
LIMIT 1
|
|
""",
|
|
(draft.merge_key,),
|
|
).fetchone()
|
|
if row is None:
|
|
self._insert_locked(draft)
|
|
self._conn.commit()
|
|
return draft
|
|
merged = self._merge_locked(_row_to_candidate(row), draft)
|
|
self._conn.commit()
|
|
return merged
|
|
except Exception:
|
|
self._conn.rollback()
|
|
raise
|
|
|
|
def _upsert_candidate_ref(
|
|
self,
|
|
candidate: EvolutionCandidate,
|
|
*,
|
|
packet: EvidencePacket | None,
|
|
) -> None:
|
|
if self.evidence_store is None:
|
|
return
|
|
raw_backrefs = list(
|
|
dict.fromkeys(
|
|
[
|
|
f"decision:{candidate.decision_id}",
|
|
f"admission:{candidate.admission_id}",
|
|
*(
|
|
[f"packet:{packet.packet_id}"]
|
|
if packet is not None and packet.packet_id
|
|
else []
|
|
),
|
|
*candidate.evidence_refs,
|
|
]
|
|
)
|
|
)
|
|
raw_backrefs = [item for item in raw_backrefs if item]
|
|
task_id = candidate.source_task_ids[0] if candidate.source_task_ids else None
|
|
session_id = (
|
|
packet.scope.session_id
|
|
if packet is not None
|
|
else _none_or_str(candidate.decision_snapshot.get("source_session_id"))
|
|
)
|
|
metadata = candidate.to_dict()
|
|
metadata["primary_tool_keys"] = _tool_keys_from_refs(packet, candidate.evidence_refs)
|
|
ref = ResourceRef(
|
|
ref_id=f"candidate:{candidate.candidate_id}",
|
|
ref_type="evolution_candidate_ref",
|
|
session_id=session_id,
|
|
task_id=task_id,
|
|
producer="candidate_store",
|
|
created_at=candidate.updated_at,
|
|
reliability="derived",
|
|
role="derived",
|
|
preview=(
|
|
f"{candidate.proposed_action} candidate {candidate.status} "
|
|
f"recurrence={candidate.recurrence_count}"
|
|
),
|
|
metadata=metadata,
|
|
raw_backrefs=raw_backrefs,
|
|
)
|
|
event = EvidenceEvent.create(
|
|
event_id=f"evt_candidate_{_digest({'candidate_id': candidate.candidate_id, 'updated_at': candidate.updated_at})}",
|
|
event_type="evolution_candidate_persisted",
|
|
producer="candidate_store",
|
|
created_at=candidate.updated_at,
|
|
session_id=ref.session_id,
|
|
task_id=ref.task_id,
|
|
idempotency_key=(
|
|
f"evolution_candidate:{candidate.candidate_id}:{candidate.updated_at}"
|
|
),
|
|
derived_refs=[ref],
|
|
metadata={
|
|
"candidate_id": candidate.candidate_id,
|
|
"status": candidate.status,
|
|
"admission_id": candidate.admission_id,
|
|
"decision_id": candidate.decision_id,
|
|
},
|
|
)
|
|
self.evidence_store.ingest_event(event)
|
|
|
|
def _load_packet_for_admission(self, admission: Any) -> EvidencePacket | None:
|
|
packet_id = str(_attr(admission, "packet_id") or "")
|
|
if not packet_id or self.evidence_store is None:
|
|
return None
|
|
load_packet = getattr(self.evidence_store, "load_packet", None)
|
|
if not callable(load_packet):
|
|
return None
|
|
try:
|
|
return load_packet(packet_id)
|
|
except Exception:
|
|
logger.debug("Failed to load packet %s for candidate", packet_id, exc_info=True)
|
|
return None
|
|
|
|
def _ensure_open(self) -> None:
|
|
if self._closed:
|
|
raise RuntimeError("EvolutionCandidateStore is closed")
|
|
|
|
|
|
def _row_to_candidate(row: sqlite3.Row) -> EvolutionCandidate:
|
|
return EvolutionCandidate(
|
|
candidate_id=str(row["candidate_id"]),
|
|
proposed_action=str(row["proposed_action"]),
|
|
status=_status(row["status"]),
|
|
admission_id=str(row["admission_id"]),
|
|
source_task_ids=_json_list(row["source_task_ids_json"]),
|
|
target_skill_ids=_json_list(row["target_skill_ids_json"]),
|
|
decision_id=str(row["decision_id"]),
|
|
decision_snapshot=_json_object(row["decision_snapshot_json"]),
|
|
evidence_refs=_json_list(row["evidence_refs_json"]),
|
|
similar_skill_ids=_json_list(row["similar_skill_ids_json"]),
|
|
recurrence=_recurrence(row["recurrence"]),
|
|
recurrence_count=max(1, int(row["recurrence_count"] or 1)),
|
|
merge_key=str(row["merge_key"]),
|
|
created_at=str(row["created_at"]),
|
|
updated_at=str(row["updated_at"]),
|
|
promoted_action_id=_none_or_str(row["promoted_action_id"]),
|
|
rejection_reason=_none_or_str(row["rejection_reason"]),
|
|
last_recheck_result=(
|
|
_json_object(row["last_recheck_result_json"])
|
|
if "last_recheck_result_json" in row.keys()
|
|
and row["last_recheck_result_json"]
|
|
else None
|
|
),
|
|
blocked_reason=(
|
|
_none_or_str(row["blocked_reason"])
|
|
if "blocked_reason" in row.keys()
|
|
else None
|
|
),
|
|
needed_evidence=(
|
|
_json_list(row["needed_evidence_json"])
|
|
if "needed_evidence_json" in row.keys()
|
|
else []
|
|
),
|
|
)
|
|
|
|
|
|
def _ensure_columns(
|
|
conn: sqlite3.Connection,
|
|
table: str,
|
|
columns: Mapping[str, str],
|
|
) -> None:
|
|
existing = {
|
|
str(row["name"])
|
|
for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
}
|
|
for name, ddl in columns.items():
|
|
if name in existing:
|
|
continue
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
|
|
|
|
|
|
def _candidate_row_values(candidate: EvolutionCandidate) -> tuple[Any, ...]:
|
|
return (
|
|
candidate.candidate_id,
|
|
candidate.proposed_action,
|
|
candidate.status,
|
|
candidate.admission_id,
|
|
_json(candidate.source_task_ids),
|
|
_json(candidate.target_skill_ids),
|
|
candidate.decision_id,
|
|
_json(candidate.decision_snapshot),
|
|
_json(candidate.evidence_refs),
|
|
_json(candidate.similar_skill_ids),
|
|
candidate.recurrence,
|
|
candidate.recurrence_count,
|
|
candidate.merge_key,
|
|
candidate.created_at,
|
|
candidate.updated_at,
|
|
candidate.promoted_action_id,
|
|
candidate.rejection_reason,
|
|
_json(candidate.last_recheck_result) if candidate.last_recheck_result else None,
|
|
candidate.blocked_reason,
|
|
_json(candidate.needed_evidence),
|
|
)
|
|
|
|
|
|
def _evidence_refs(
|
|
decision: Any,
|
|
admission: Any,
|
|
packet: EvidencePacket | None,
|
|
) -> list[str]:
|
|
refs: list[str] = []
|
|
for claim in list(_attr(decision, "evidence_claims") or []):
|
|
refs.extend(_str_list(_attr(claim, "refs")))
|
|
refs.extend(_str_list(_attr(admission, "required_refs_checked")))
|
|
if not refs and packet is not None:
|
|
refs.extend(
|
|
ref.ref_id
|
|
for group in packet.selected_refs.values()
|
|
for ref in group
|
|
if ref.ref_id
|
|
)
|
|
return list(dict.fromkeys(refs))
|
|
|
|
|
|
def _source_task_ids(packet: EvidencePacket | None) -> list[str]:
|
|
if packet is None:
|
|
return []
|
|
ids = [packet.scope.task_id or "", *packet.scope.source_task_ids]
|
|
for group in packet.selected_refs.values():
|
|
ids.extend(ref.task_id or "" for ref in group)
|
|
return [item for item in dict.fromkeys(ids) if item]
|
|
|
|
|
|
def _merge_key(
|
|
*,
|
|
proposed_action: str,
|
|
target_skill_ids: list[str],
|
|
semantic_identity: str,
|
|
reason_tags: list[str],
|
|
packet: EvidencePacket | None,
|
|
evidence_refs: list[str],
|
|
) -> str:
|
|
action = _normalize_token(proposed_action).upper()
|
|
skills = ",".join(sorted(_normalize_token(item) for item in target_skill_ids))
|
|
tags = ",".join(
|
|
sorted(
|
|
tag
|
|
for tag in (_normalize_token(item).lower() for item in reason_tags)
|
|
if tag and not _is_volatile_tag(tag)
|
|
)
|
|
)
|
|
refs = ",".join(
|
|
f"{key}:{count}" for key, count in sorted(_ref_type_histogram(packet, evidence_refs).items())
|
|
)
|
|
tools = ",".join(sorted(_normalize_token(item) for item in _tool_keys_from_refs(packet, evidence_refs)))
|
|
parts = [
|
|
action,
|
|
f"skills={skills}",
|
|
f"semantic={semantic_identity}",
|
|
f"tags={tags}",
|
|
f"refs={refs}",
|
|
]
|
|
if tools:
|
|
parts.append(f"tool={tools}")
|
|
return "|".join(parts)
|
|
|
|
|
|
def _candidate_semantic_identity(decision: Any) -> str:
|
|
summary = " ".join(str(_attr(decision, "reason_summary") or "").split()).lower()
|
|
category_path = _normalize_token(_attr(decision, "local_category_path")).lower()
|
|
if not summary and not category_path:
|
|
return _normalize_token(_attr(decision, "decision_id"))
|
|
return _digest({"summary": summary, "category_path": category_path})[:20]
|
|
|
|
|
|
def _ref_type_histogram(
|
|
packet: EvidencePacket | None,
|
|
evidence_refs: list[str],
|
|
) -> dict[str, int]:
|
|
histogram: dict[str, int] = {}
|
|
if packet is None:
|
|
return histogram
|
|
allowed = set(evidence_refs)
|
|
for ref in _packet_refs(packet):
|
|
if allowed and ref.ref_id not in allowed:
|
|
continue
|
|
histogram[ref.ref_type] = histogram.get(ref.ref_type, 0) + 1
|
|
return histogram
|
|
|
|
|
|
def _tool_keys_from_refs(
|
|
packet: EvidencePacket | None,
|
|
evidence_refs: list[str],
|
|
) -> list[str]:
|
|
if packet is None:
|
|
return []
|
|
allowed = set(evidence_refs)
|
|
keys: list[str] = []
|
|
for ref in _packet_refs(packet):
|
|
if allowed and ref.ref_id not in allowed:
|
|
continue
|
|
for metadata_field in (
|
|
"tool_key",
|
|
"affected_tool_key",
|
|
"tool_keys",
|
|
"critical_tools",
|
|
):
|
|
keys.extend(_str_list(ref.metadata.get(metadata_field)))
|
|
return list(dict.fromkeys(item for item in keys if item))
|
|
|
|
|
|
def _packet_refs(packet: EvidencePacket) -> list[ResourceRef]:
|
|
return [
|
|
ref
|
|
for group in packet.selected_refs.values()
|
|
for ref in group
|
|
if ref.ref_id
|
|
]
|
|
|
|
|
|
def _merged_snapshot(
|
|
existing: EvolutionCandidate,
|
|
draft: EvolutionCandidate,
|
|
) -> dict[str, Any]:
|
|
snapshot = dict(draft.decision_snapshot)
|
|
previous_decision_ids = _union(
|
|
_snapshot_list(existing.decision_snapshot, "previous_decision_ids"),
|
|
[existing.decision_id],
|
|
_snapshot_list(existing.decision_snapshot, "decision_ids"),
|
|
)
|
|
decision_ids = _union(previous_decision_ids, [draft.decision_id])
|
|
admission_ids = _union(
|
|
_snapshot_list(existing.decision_snapshot, "admission_ids"),
|
|
[existing.admission_id, draft.admission_id],
|
|
)
|
|
snapshot["previous_decision_ids"] = previous_decision_ids
|
|
snapshot["decision_ids"] = decision_ids
|
|
snapshot["admission_ids"] = admission_ids
|
|
snapshot["last_merged_at"] = _utc_now()
|
|
return snapshot
|
|
|
|
|
|
def _snapshot_list(snapshot: Mapping[str, Any], key: str) -> list[str]:
|
|
return _str_list(snapshot.get(key))
|
|
|
|
|
|
def _merged_recurrence(
|
|
existing: EvolutionCandidate,
|
|
draft: EvolutionCandidate,
|
|
recurrence_count: int,
|
|
) -> str:
|
|
if existing.recurrence == "user_explicit" or draft.recurrence == "user_explicit":
|
|
return "user_explicit"
|
|
return "repeated" if recurrence_count >= 2 else "single"
|
|
|
|
|
|
def _is_user_explicit(
|
|
decision: Any,
|
|
admission: Any,
|
|
packet: EvidencePacket | None,
|
|
reason: str | None,
|
|
) -> bool:
|
|
if packet is not None and packet.selected_refs.get("manual_request_ref"):
|
|
return True
|
|
if str(_attr(decision, "recurrence") or "").strip().lower() == "user_explicit":
|
|
return True
|
|
structured_values = {
|
|
str(reason or "").strip().lower(),
|
|
str(_attr(decision, "candidate_policy") or "").strip().lower(),
|
|
*(
|
|
str(tag).strip().lower()
|
|
for tag in _str_list(_attr(decision, "reason_tags"))
|
|
),
|
|
*(
|
|
str(tag).strip().lower()
|
|
for tag in _str_list(_attr(admission, "warnings"))
|
|
),
|
|
}
|
|
return bool(
|
|
structured_values
|
|
& {"manual", "user_explicit", "user_requested", "capture_requested"}
|
|
)
|
|
|
|
|
|
def _snapshot(value: Any) -> dict[str, Any]:
|
|
if hasattr(value, "to_dict") and callable(value.to_dict):
|
|
data = value.to_dict()
|
|
return dict(data) if isinstance(data, Mapping) else {}
|
|
if is_dataclass(value):
|
|
data = asdict(value)
|
|
return dict(data) if isinstance(data, Mapping) else {}
|
|
if isinstance(value, Mapping):
|
|
return dict(value)
|
|
result: dict[str, Any] = {}
|
|
for key in (
|
|
"decision_id",
|
|
"trigger_job_id",
|
|
"proposed_action",
|
|
"candidate_policy",
|
|
"target_skill_ids",
|
|
"reason_summary",
|
|
"reason_tags",
|
|
"confidence",
|
|
"risks",
|
|
"source_analysis_id",
|
|
"noop_reason",
|
|
"analyzed_by",
|
|
"created_at",
|
|
):
|
|
if hasattr(value, key):
|
|
result[key] = getattr(value, key)
|
|
return result
|
|
|
|
|
|
def _attr(value: Any, name: str) -> Any:
|
|
if isinstance(value, Mapping):
|
|
return value.get(name)
|
|
return getattr(value, name, None)
|
|
|
|
|
|
def _blocked_reason_from_inputs(
|
|
*,
|
|
reason: str | None,
|
|
admission: Any,
|
|
decision: Any,
|
|
) -> str | None:
|
|
reason_text = str(reason or "").strip()
|
|
if reason_text == "fix_only_mode_non_fix":
|
|
return "policy_blocked:fix_only_non_fix"
|
|
if reason_text == "semantic_validation_failed":
|
|
return "validation_failed:semantic"
|
|
if reason_text == "validation_failed":
|
|
return "validation_failed:deterministic"
|
|
if reason_text == "missing_behavior_eval":
|
|
return "validation_failed:missing_behavior_eval"
|
|
if reason_text.startswith("behavior_eval_failed:"):
|
|
return reason_text
|
|
if reason_text == "behavior_eval_failed":
|
|
return "behavior_eval_failed"
|
|
outcome = str(_attr(admission, "outcome") or "").strip().lower()
|
|
failures = _str_list(_attr(admission, "hard_failures"))
|
|
warnings = _str_list(_attr(admission, "warnings"))
|
|
risks = _str_list(_attr(decision, "risks"))
|
|
if failures:
|
|
return f"admission_blocked:{failures[0]}"
|
|
if outcome == "candidate":
|
|
return _candidate_warning_reason(warnings or risks) or "admission_candidate"
|
|
if outcome in {"needs_human_review", "human_review"}:
|
|
return "needs_human_review"
|
|
return None
|
|
|
|
|
|
def _candidate_warning_reason(tags: list[str]) -> str | None:
|
|
lowered = {str(tag).lower() for tag in tags}
|
|
if "provisional_evolution_disabled" in lowered:
|
|
return "policy_blocked:provisional_evolution_disabled"
|
|
if "no_derived_divergence" in lowered:
|
|
return "needs_more_evidence:derived_divergence"
|
|
if "reusable_boundary_uncertain" in lowered:
|
|
return "needs_more_evidence:reusable_boundary"
|
|
if "workflow_trivial_or_uncertain" in lowered:
|
|
return "needs_more_evidence:workflow_significance"
|
|
if "low_signal_capture" in lowered:
|
|
return "needs_more_evidence:stronger_capture_signal"
|
|
if "fallback_only_capture_evidence" in lowered:
|
|
return "needs_more_evidence:primary_execution_evidence"
|
|
if "existing_skill_covers_workflow" in lowered:
|
|
return "blocked_by_existing_skill"
|
|
if "ephemeral_or_secret_dependent_capture" in lowered:
|
|
return "blocked_by_ephemeral_or_secret_dependency"
|
|
if tags:
|
|
return f"admission_candidate:{_normalize_token(tags[0])}"
|
|
return None
|
|
|
|
|
|
def _needed_evidence_from_inputs(
|
|
*,
|
|
reason: str | None,
|
|
admission: Any,
|
|
decision: Any,
|
|
) -> list[str]:
|
|
reason_text = str(reason or "").strip()
|
|
if reason_text == "fix_only_mode_non_fix":
|
|
return []
|
|
if reason_text == "semantic_validation_failed":
|
|
return ["narrower_source_supported_capability_or_artifact_repair"]
|
|
if reason_text == "validation_failed":
|
|
return ["deterministic_validation_repair"]
|
|
if reason_text == "missing_behavior_eval":
|
|
return ["behavior_eval_result"]
|
|
if reason_text.startswith("behavior_eval_failed:") or reason_text == "behavior_eval_failed":
|
|
return _behavior_eval_needed_evidence(reason_text)
|
|
tags = [
|
|
*_str_list(_attr(admission, "hard_failures")),
|
|
*_str_list(_attr(admission, "warnings")),
|
|
*_str_list(_attr(decision, "risks")),
|
|
]
|
|
needed: list[str] = []
|
|
for tag in tags:
|
|
text = str(tag).strip()
|
|
lower = text.lower()
|
|
if lower == "no_derived_divergence":
|
|
needed.append("derived_divergence_evidence")
|
|
elif lower in {"reusable_boundary_uncertain", "workflow_trivial_or_uncertain"}:
|
|
needed.append("reusable_workflow_boundary_evidence")
|
|
elif lower == "low_signal_capture":
|
|
needed.append("stronger_successful_workflow_evidence")
|
|
elif lower == "fallback_only_capture_evidence":
|
|
needed.append("primary_runtime_or_transcript_evidence")
|
|
elif lower.startswith("missing_ref:"):
|
|
needed.append(text)
|
|
elif lower.startswith("missing_"):
|
|
needed.append(text)
|
|
return list(dict.fromkeys(needed))
|
|
|
|
|
|
def _behavior_eval_needed_evidence(reason: str) -> list[str]:
|
|
lower = str(reason or "").strip().lower()
|
|
needed: list[str] = []
|
|
if "missing_executable_eval_evidence" in lower:
|
|
needed.append("executable_eval_evidence")
|
|
if "missing_executable_eval_cases" in lower:
|
|
needed.append("executable_eval_cases")
|
|
if "replay_tasks_require_external_runner" in lower:
|
|
needed.append("external_replay_runner")
|
|
if "missing_required_replay_runner" in lower:
|
|
needed.append("replay_runner")
|
|
if "routing_" in lower:
|
|
needed.append("routing_eval_repair")
|
|
if "deterministic_assertion_failed" in lower:
|
|
needed.append("deterministic_assertion_repair")
|
|
if "candidate_score_regressed" in lower:
|
|
needed.append("non_regressing_replay_score")
|
|
if not needed:
|
|
needed.append("behavior_eval_resolution")
|
|
return list(dict.fromkeys(needed))
|
|
|
|
|
|
def _proposed_action(decision: Any) -> str:
|
|
raw = (
|
|
_attr(decision, "proposed_action")
|
|
or _attr(decision, "action_type")
|
|
or _attr(decision, "evolution_type")
|
|
or ""
|
|
)
|
|
raw = getattr(raw, "value", raw)
|
|
text = str(raw or "").strip()
|
|
return text.upper() if text else "UNKNOWN"
|
|
|
|
|
|
def _status(value: Any) -> str:
|
|
status = str(value or "pending").strip().lower()
|
|
if status not in _STATUSES:
|
|
raise ValueError(f"Unsupported evolution candidate status: {value}")
|
|
return status
|
|
|
|
|
|
def _recurrence(value: Any) -> str:
|
|
recurrence = str(value or "single").strip().lower()
|
|
return recurrence if recurrence in _RECURRENCES else "single"
|
|
|
|
|
|
def _normalize_token(value: Any) -> str:
|
|
text = str(value or "").strip()
|
|
text = re.sub(r"\s+", "_", text)
|
|
text = re.sub(r"[^A-Za-z0-9:_.-]+", "", text)
|
|
return text
|
|
|
|
|
|
def _is_volatile_tag(tag: str) -> bool:
|
|
if tag in _VOLATILE_TAGS:
|
|
return True
|
|
if tag.startswith(("task:", "session:", "packet:", "decision:", "admission:", "job:")):
|
|
return True
|
|
return bool(re.search(r"[0-9a-f]{8,}", tag))
|
|
|
|
|
|
def _union(*groups: list[str]) -> list[str]:
|
|
items: list[str] = []
|
|
for group in groups:
|
|
items.extend(_str_list(group))
|
|
return list(dict.fromkeys(items))
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
|
|
|
|
def _json_object(value: Any) -> dict[str, Any]:
|
|
try:
|
|
loaded = json.loads(str(value or "{}"))
|
|
except Exception:
|
|
return {}
|
|
return dict(loaded) if isinstance(loaded, Mapping) else {}
|
|
|
|
|
|
def _json_list(value: Any) -> list[str]:
|
|
try:
|
|
loaded = json.loads(str(value or "[]"))
|
|
except Exception:
|
|
return []
|
|
return _str_list(loaded)
|
|
|
|
|
|
def _dict_or_empty(value: Any) -> dict[str, Any]:
|
|
return dict(value) if isinstance(value, Mapping) else {}
|
|
|
|
|
|
def _str_list(value: Any) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, str):
|
|
return [value] if value else []
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [str(item) for item in value if str(item)]
|
|
return []
|
|
|
|
|
|
def _int_or_one(value: Any) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
|
|
def _none_or_str(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value)
|
|
return text if text else None
|
|
|
|
|
|
def _digest(value: Any) -> str:
|
|
return hashlib.sha256(
|
|
json.dumps(value, sort_keys=True, default=str).encode("utf-8")
|
|
).hexdigest()[:24]
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|