feat(sofiia-console): add audit trail for operator actions

Made-with: Cursor
This commit is contained in:
Apple
2026-03-02 09:29:14 -08:00
parent 9b89ace2fc
commit 3246440ac8
4 changed files with 407 additions and 2 deletions

View File

@@ -329,6 +329,27 @@ CREATE INDEX IF NOT EXISTS idx_governance_events_scope_time
CREATE INDEX IF NOT EXISTS idx_governance_events_type_time
ON governance_events(event_type, created_at DESC);
-- ── Operator Audit Trail (Sofiia Console) ───────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
ts TEXT NOT NULL,
event TEXT NOT NULL,
operator_id TEXT NOT NULL,
operator_id_missing INTEGER NOT NULL DEFAULT 0,
ip TEXT,
chat_id TEXT,
node_id TEXT,
agent_id TEXT,
status TEXT NOT NULL DEFAULT 'ok',
error_code TEXT,
duration_ms INTEGER,
data_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_operator_ts ON audit_events(operator_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_chat_ts ON audit_events(chat_id, ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_event_ts ON audit_events(event, ts DESC);
-- ── Graph Intelligence (Hygiene + Reflection) ──────────────────────────────
-- These ADD COLUMN statements are idempotent (IF NOT EXISTS requires SQLite 3.37+).
-- On older SQLite they fail silently — init_db() wraps them in a separate try block.
@@ -740,6 +761,93 @@ async def list_messages_page(
return [dict(r) for r in rows]
async def append_audit_event(
event: str,
operator_id: str,
*,
operator_id_missing: bool = False,
ip: Optional[str] = None,
chat_id: Optional[str] = None,
node_id: Optional[str] = None,
agent_id: Optional[str] = None,
status: str = "ok",
error_code: Optional[str] = None,
duration_ms: Optional[int] = None,
data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
db = await get_db()
event_id = str(uuid.uuid4())
now = _now()
payload = json.dumps(data or {}, ensure_ascii=True, separators=(",", ":"))
await db.execute(
"INSERT INTO audit_events("
"id,ts,event,operator_id,operator_id_missing,ip,chat_id,node_id,agent_id,"
"status,error_code,duration_ms,data_json"
") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
event_id,
now,
str(event or "").strip(),
(str(operator_id or "").strip() or "unknown")[:128],
1 if operator_id_missing else 0,
(str(ip or "").strip() or None),
(str(chat_id or "").strip() or None),
(str(node_id or "").strip() or None),
(str(agent_id or "").strip() or None),
(str(status or "ok").strip() or "ok"),
(str(error_code or "").strip() or None),
int(duration_ms) if duration_ms is not None else None,
payload,
),
)
await db.commit()
return {
"id": event_id,
"ts": now,
"event": event,
"operator_id": operator_id,
"status": status,
}
async def list_audit_events(
*,
event: Optional[str] = None,
operator_id: Optional[str] = None,
chat_id: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
db = await get_db()
clauses = ["1=1"]
params: List[Any] = []
if event:
clauses.append("event=?")
params.append(event)
if operator_id:
clauses.append("operator_id=?")
params.append(operator_id)
if chat_id:
clauses.append("chat_id=?")
params.append(chat_id)
params.append(max(1, min(int(limit), 500)))
sql = (
"SELECT * FROM audit_events WHERE "
+ " AND ".join(clauses)
+ " ORDER BY ts DESC, id DESC LIMIT ?"
)
async with db.execute(sql, tuple(params)) as cur:
rows = await cur.fetchall()
out: List[Dict[str, Any]] = []
for r in rows:
row = dict(r)
try:
row["data_json"] = json.loads(row.get("data_json") or "{}")
except Exception:
row["data_json"] = {}
out.append(row)
return out
async def get_dialog_map(session_id: str) -> Dict[str, Any]:
"""Return nodes and edges for the dialog map tree.