feat(sofiia-console): add audit trail for operator actions
Made-with: Cursor
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user