148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
"""Delete-impact computation service (T95, Phase 4).
|
|
|
|
Walks event_log forward from a target event_id and produces an ImpactReport
|
|
describing what would be removed if rewind-to-target were invoked. Pure
|
|
computation — does NOT mutate the database. Used by T98's drawer surgical-
|
|
delete UI to render an 'are you sure?' modal before invoking the actual
|
|
rewind path (chat/services/rewind.py).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import json
|
|
from sqlite3 import Connection
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class DeletedItem(BaseModel):
|
|
kind: str
|
|
description: str
|
|
target_id: int | str | None = None
|
|
|
|
|
|
class ImpactReport(BaseModel):
|
|
target_event_id: int
|
|
cascading: list[DeletedItem] = Field(default_factory=list)
|
|
notes: list[str] = Field(default_factory=list)
|
|
|
|
|
|
def _excerpt(text: str, n: int = 60) -> str:
|
|
text = (text or "").strip().replace("\n", " ")
|
|
return text if len(text) <= n else text[: n - 1] + "…"
|
|
|
|
|
|
def compute_delete_impact(
|
|
conn: Connection,
|
|
*,
|
|
target_event_id: int,
|
|
) -> ImpactReport:
|
|
"""Compute the cascading impact of rewinding to target_event_id."""
|
|
# Verify target exists.
|
|
target_row = conn.execute(
|
|
"SELECT id, kind, payload_json FROM event_log WHERE id = ?",
|
|
(target_event_id,),
|
|
).fetchone()
|
|
if target_row is None:
|
|
return ImpactReport(
|
|
target_event_id=target_event_id,
|
|
cascading=[],
|
|
notes=[f"target event_id {target_event_id} not found"],
|
|
)
|
|
|
|
# Walk forward: every event with id >= target_event_id is in scope.
|
|
rows = conn.execute(
|
|
"SELECT id, kind, payload_json FROM event_log "
|
|
"WHERE id >= ? ORDER BY id ASC",
|
|
(target_event_id,),
|
|
).fetchall()
|
|
|
|
cascading: list[DeletedItem] = []
|
|
notes: list[str] = []
|
|
scene_close_present = False
|
|
regenerated_from = None
|
|
|
|
for row_id, kind, payload_json in rows:
|
|
try:
|
|
payload = json.loads(payload_json) if payload_json else {}
|
|
except (json.JSONDecodeError, TypeError):
|
|
payload = {}
|
|
|
|
if kind == "memory_written":
|
|
cascading.append(
|
|
DeletedItem(
|
|
kind=kind,
|
|
description=f"memory: {_excerpt(payload.get('pov_summary', ''))}",
|
|
target_id=payload.get("memory_id"),
|
|
)
|
|
)
|
|
elif kind == "edge_update":
|
|
src = payload.get("source_id", "?")
|
|
tgt = payload.get("target_id", "?")
|
|
cascading.append(
|
|
DeletedItem(
|
|
kind=kind,
|
|
description=f"edge update: {src} -> {tgt}",
|
|
target_id=f"{src}->{tgt}",
|
|
)
|
|
)
|
|
elif kind == "scene_closed":
|
|
scene_close_present = True
|
|
cascading.append(
|
|
DeletedItem(
|
|
kind=kind,
|
|
description=f"scene close at {payload.get('closed_at', '?')}",
|
|
target_id=payload.get("scene_id"),
|
|
)
|
|
)
|
|
elif kind in ("user_turn", "user_turn_edit", "assistant_turn"):
|
|
speaker = payload.get("speaker_id") or ("you" if kind.startswith("user") else "?")
|
|
prose = payload.get("prose") or payload.get("text") or ""
|
|
cascading.append(
|
|
DeletedItem(
|
|
kind=kind,
|
|
description=f"turn {row_id} ({speaker}: {_excerpt(prose, 50)})",
|
|
target_id=row_id,
|
|
)
|
|
)
|
|
if regenerated_from is None and payload.get("regenerated_from"):
|
|
regenerated_from = payload["regenerated_from"]
|
|
elif kind == "manual_edit":
|
|
target_kind = payload.get("target_kind", "?")
|
|
cascading.append(
|
|
DeletedItem(
|
|
kind=kind,
|
|
description=f"manual edit: {target_kind}",
|
|
target_id=payload.get("target_id"),
|
|
)
|
|
)
|
|
else:
|
|
cascading.append(
|
|
DeletedItem(
|
|
kind=kind,
|
|
description=f"{kind} event",
|
|
target_id=row_id,
|
|
)
|
|
)
|
|
|
|
# Notes / warnings.
|
|
notes.append(f"{len(rows)} events would be discarded total")
|
|
if scene_close_present:
|
|
notes.append(
|
|
"scene close events are in scope — closing-scene per-POV summaries "
|
|
"and group_node updates will be reverted"
|
|
)
|
|
if regenerated_from is not None:
|
|
notes.append(
|
|
f"target turn was regenerated from event_id {regenerated_from}; "
|
|
f"the original turn remains intact"
|
|
)
|
|
|
|
return ImpactReport(
|
|
target_event_id=target_event_id,
|
|
cascading=cascading,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
__all__ = ["DeletedItem", "ImpactReport", "compute_delete_impact"]
|