agnes-the-ai-analyst/src/repositories/audit.py
ZdenekSrotyr e86dd5edc5 fix(anthropic): strict json_schema (additionalProperties=false) + add /admin/scheduler-runs UI
E2E test on a real BQ deploy showed every verification-extraction call
fails with HTTP 400 invalid_request_error: "output_config.format.schema:
For 'object' type, 'additionalProperties' must be explicitly set to false".
The Anthropic structured-output API now requires the field on every object
node in the json_schema. Fix: connectors/llm/anthropic_provider.py wraps
the caller-supplied schema through a recursive _strict_json_schema()
walker that adds the field where missing (preserving any explicit
override), then passes the strict variant to the API. Six unit tests in
TestStrictJsonSchema pin the recursion across nested objects, array items,
and the no-mutation invariant.

Adds /admin/scheduler-runs — a read-only admin page that surfaces the
last 200 audit-log entries from scheduler-driven actions. New
AuditRepository.query_actions(actions, limit) helper, new admin nav
entry. Failed scheduler ticks (HTTP 401, network errors) don't reach
the audit_log; the page calls that out with a hint to set
SCHEDULER_API_TOKEN if no rows show up.
2026-05-05 08:00:57 +02:00

70 lines
2.3 KiB
Python

"""Repository for audit logging."""
import json
import uuid
from datetime import datetime, timezone
from typing import Any, Optional, List, Dict
import duckdb
class AuditRepository:
def __init__(self, conn: duckdb.DuckDBPyConnection):
self.conn = conn
def log(
self,
user_id: Optional[str] = None,
action: str = "",
resource: Optional[str] = None,
params: Optional[dict] = None,
result: Optional[str] = None,
duration_ms: Optional[int] = None,
) -> str:
entry_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
self.conn.execute(
"""INSERT INTO audit_log (id, timestamp, user_id, action, resource, params, result, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
[entry_id, now, user_id, action, resource,
json.dumps(params) if params else None, result, duration_ms],
)
return entry_id
def query(
self,
user_id: Optional[str] = None,
action: Optional[str] = None,
limit: int = 50,
) -> List[Dict[str, Any]]:
sql = "SELECT * FROM audit_log WHERE 1=1"
params: List[Any] = []
if user_id:
sql += " AND user_id = ?"
params.append(user_id)
if action:
sql += " AND action = ?"
params.append(action)
sql += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
results = self.conn.execute(sql, params).fetchall()
if not results:
return []
columns = [desc[0] for desc in self.conn.description]
return [dict(zip(columns, row)) for row in results]
def query_actions(
self,
actions: List[str],
limit: int = 200,
) -> List[Dict[str, Any]]:
"""Return rows whose action is in the given list, newest first."""
if not actions:
return []
placeholders = ",".join("?" for _ in actions)
sql = f"SELECT * FROM audit_log WHERE action IN ({placeholders}) ORDER BY timestamp DESC LIMIT ?"
results = self.conn.execute(sql, list(actions) + [limit]).fetchall()
if not results:
return []
columns = [desc[0] for desc in self.conn.description]
return [dict(zip(columns, row)) for row in results]