1117 lines
42 KiB
Python
1117 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sqlite3
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Iterator
|
|
|
|
from .config import UserSeed
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _loads(value: str | None, fallback: Any) -> Any:
|
|
if not value:
|
|
return fallback
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return fallback
|
|
|
|
|
|
class Database:
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
|
|
@contextmanager
|
|
def connect(self) -> Iterator[sqlite3.Connection]:
|
|
connection = sqlite3.connect(self.path, timeout=10)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
connection.execute("PRAGMA busy_timeout = 10000")
|
|
try:
|
|
yield connection
|
|
connection.commit()
|
|
except Exception:
|
|
connection.rollback()
|
|
raise
|
|
finally:
|
|
connection.close()
|
|
|
|
def initialize(self, user_seeds: Iterable[UserSeed] = ()) -> None:
|
|
seeds = list(user_seeds)
|
|
with self.connect() as connection:
|
|
connection.executescript(
|
|
"""
|
|
PRAGMA journal_mode = WAL;
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
label TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
|
access_key_hash TEXT NOT NULL,
|
|
debug_sharing INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS fragments (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT,
|
|
content TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
analysis_status TEXT NOT NULL DEFAULT 'pending',
|
|
analysis_error TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS ideas (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT,
|
|
title TEXT NOT NULL,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
maturity_ai REAL NOT NULL DEFAULT 12,
|
|
maturity_override REAL,
|
|
confidence REAL NOT NULL DEFAULT 0.5,
|
|
motion TEXT NOT NULL DEFAULT '浮现',
|
|
position TEXT NOT NULL DEFAULT '',
|
|
tension TEXT NOT NULL DEFAULT '',
|
|
trajectory TEXT NOT NULL DEFAULT '',
|
|
possible_moves TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS idea_fragments (
|
|
idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
|
|
fragment_id TEXT NOT NULL REFERENCES fragments(id) ON DELETE CASCADE,
|
|
relevance REAL NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (idea_id, fragment_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS idea_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
|
|
source_fragment_id TEXT REFERENCES fragments(id) ON DELETE SET NULL,
|
|
title TEXT,
|
|
summary TEXT,
|
|
maturity_ai REAL NOT NULL,
|
|
maturity_override REAL,
|
|
confidence REAL,
|
|
motion TEXT NOT NULL,
|
|
position TEXT NOT NULL,
|
|
tension TEXT NOT NULL,
|
|
trajectory TEXT NOT NULL,
|
|
possible_moves TEXT NOT NULL,
|
|
change_kind TEXT NOT NULL DEFAULT 'legacy_partial',
|
|
fragment_ids TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
user_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS agent_runs (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
fragment_id TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
prompt_version TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
thinking_effort TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
duration_ms INTEGER,
|
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
model_rounds INTEGER NOT NULL DEFAULT 0,
|
|
tool_calls INTEGER NOT NULL DEFAULT 0,
|
|
search_calls INTEGER NOT NULL DEFAULT 0,
|
|
inspection_calls INTEGER NOT NULL DEFAULT 0,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
|
error_type TEXT,
|
|
error_message TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS agent_events (
|
|
id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL REFERENCES agent_runs(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
event_at TEXT NOT NULL,
|
|
duration_ms INTEGER,
|
|
payload TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_events (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
payload TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_fragments_created
|
|
ON fragments(created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_fragments_status
|
|
ON fragments(analysis_status);
|
|
CREATE INDEX IF NOT EXISTS idx_ideas_updated
|
|
ON ideas(updated_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_links_fragment
|
|
ON idea_fragments(fragment_id);
|
|
CREATE INDEX IF NOT EXISTS idx_snapshots_idea
|
|
ON idea_snapshots(idea_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_runs_started
|
|
ON agent_runs(started_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_runs_status
|
|
ON agent_runs(status, started_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_events_run
|
|
ON agent_events(run_id, event_at ASC);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_created
|
|
ON audit_events(created_at DESC);
|
|
"""
|
|
)
|
|
|
|
self._ensure_column(connection, "fragments", "user_id", "TEXT")
|
|
self._ensure_column(connection, "ideas", "user_id", "TEXT")
|
|
self._ensure_column(connection, "sessions", "user_id", "TEXT")
|
|
self._ensure_column(connection, "idea_snapshots", "title", "TEXT")
|
|
self._ensure_column(connection, "idea_snapshots", "summary", "TEXT")
|
|
self._ensure_column(
|
|
connection, "idea_snapshots", "maturity_override", "REAL"
|
|
)
|
|
self._ensure_column(
|
|
connection, "idea_snapshots", "confidence", "REAL"
|
|
)
|
|
self._ensure_column(
|
|
connection,
|
|
"idea_snapshots",
|
|
"change_kind",
|
|
"TEXT NOT NULL DEFAULT 'legacy_partial'",
|
|
)
|
|
self._ensure_column(
|
|
connection,
|
|
"idea_snapshots",
|
|
"fragment_ids",
|
|
"TEXT NOT NULL DEFAULT '[]'",
|
|
)
|
|
|
|
now = utc_now()
|
|
for seed in seeds:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO users (
|
|
id, label, role, access_key_hash, debug_sharing,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
label = excluded.label,
|
|
role = excluded.role,
|
|
access_key_hash = excluded.access_key_hash,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(
|
|
seed.id,
|
|
seed.label,
|
|
seed.role,
|
|
seed.access_key_hash,
|
|
int(seed.debug_sharing),
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
|
|
if seeds:
|
|
primary = next(
|
|
(seed for seed in seeds if seed.role == "admin"), seeds[0]
|
|
)
|
|
for table in ("fragments", "ideas", "sessions"):
|
|
connection.execute(
|
|
f"""
|
|
UPDATE {table} SET user_id = ?
|
|
WHERE user_id IS NULL OR user_id = ''
|
|
""",
|
|
(primary.id,),
|
|
)
|
|
|
|
connection.executescript(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_fragments_user_created
|
|
ON fragments(user_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_ideas_user_updated
|
|
ON ideas(user_id, updated_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user
|
|
ON sessions(user_id, expires_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_runs_user_started
|
|
ON agent_runs(user_id, started_at DESC);
|
|
"""
|
|
)
|
|
|
|
@staticmethod
|
|
def _ensure_column(
|
|
connection: sqlite3.Connection, table: str, column: str, definition: str
|
|
) -> None:
|
|
columns = {
|
|
row["name"]
|
|
for row in connection.execute(f"PRAGMA table_info({table})").fetchall()
|
|
}
|
|
if column not in columns:
|
|
connection.execute(
|
|
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
|
)
|
|
|
|
# Users and sessions -------------------------------------------------
|
|
|
|
def list_auth_users(self) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id, label, role, access_key_hash, debug_sharing
|
|
FROM users ORDER BY created_at ASC
|
|
"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_user(self, user_id: str) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id, label, role, debug_sharing, created_at
|
|
FROM users WHERE id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
user = dict(row)
|
|
user["debug_sharing"] = bool(user["debug_sharing"])
|
|
return user
|
|
|
|
def list_users(self) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT u.id, u.label, u.role, u.debug_sharing, u.created_at,
|
|
COUNT(DISTINCT f.id) AS fragment_count,
|
|
COUNT(DISTINCT i.id) AS idea_count
|
|
FROM users u
|
|
LEFT JOIN fragments f ON f.user_id = u.id
|
|
LEFT JOIN ideas i ON i.user_id = u.id
|
|
GROUP BY u.id
|
|
ORDER BY u.created_at ASC
|
|
"""
|
|
).fetchall()
|
|
result = [dict(row) for row in rows]
|
|
for user in result:
|
|
user["debug_sharing"] = bool(user["debug_sharing"])
|
|
return result
|
|
|
|
def update_debug_sharing(
|
|
self, user_id: str, enabled: bool
|
|
) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
result = connection.execute(
|
|
"""
|
|
UPDATE users SET debug_sharing = ?, updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(int(enabled), utc_now(), user_id),
|
|
)
|
|
if result.rowcount == 0:
|
|
return None
|
|
return self.get_user(user_id)
|
|
|
|
# Raw fragments ------------------------------------------------------
|
|
|
|
def create_fragment(self, user_id: str, content: str) -> dict[str, Any]:
|
|
fragment = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_id": user_id,
|
|
"content": content,
|
|
"created_at": utc_now(),
|
|
"analysis_status": "pending",
|
|
}
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO fragments (
|
|
id, user_id, content, created_at, analysis_status
|
|
) VALUES (:id, :user_id, :content, :created_at, :analysis_status)
|
|
""",
|
|
fragment,
|
|
)
|
|
return fragment
|
|
|
|
def list_fragments(
|
|
self, user_id: str, limit: int = 300
|
|
) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id, content, created_at
|
|
FROM fragments
|
|
WHERE user_id = ?
|
|
ORDER BY created_at DESC, rowid DESC
|
|
LIMIT ?
|
|
""",
|
|
(user_id, limit),
|
|
).fetchall()
|
|
return [dict(row) for row in reversed(rows)]
|
|
|
|
def get_fragment(
|
|
self, fragment_id: str, user_id: str | None = None
|
|
) -> dict[str, Any] | None:
|
|
query = "SELECT * FROM fragments WHERE id = ?"
|
|
params: list[Any] = [fragment_id]
|
|
if user_id is not None:
|
|
query += " AND user_id = ?"
|
|
params.append(user_id)
|
|
with self.connect() as connection:
|
|
row = connection.execute(query, params).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def pending_fragment_ids(self, limit: int = 100) -> list[str]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id FROM fragments
|
|
WHERE analysis_status IN ('pending', 'processing')
|
|
AND user_id IS NOT NULL
|
|
ORDER BY created_at ASC, rowid ASC LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
connection.execute(
|
|
"""
|
|
UPDATE fragments SET analysis_status = 'pending'
|
|
WHERE analysis_status = 'processing'
|
|
"""
|
|
)
|
|
return [row["id"] for row in rows]
|
|
|
|
def set_fragment_status(
|
|
self, fragment_id: str, status: str, error: str | None = None
|
|
) -> None:
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE fragments
|
|
SET analysis_status = ?, analysis_error = ?
|
|
WHERE id = ?
|
|
""",
|
|
(status, error, fragment_id),
|
|
)
|
|
|
|
def processing_count(self, user_id: str | None = None) -> int:
|
|
query = """
|
|
SELECT COUNT(*) AS count FROM fragments
|
|
WHERE analysis_status IN ('pending', 'processing')
|
|
"""
|
|
params: list[Any] = []
|
|
if user_id:
|
|
query += " AND user_id = ?"
|
|
params.append(user_id)
|
|
with self.connect() as connection:
|
|
row = connection.execute(query, params).fetchone()
|
|
return int(row["count"])
|
|
|
|
# Ideas --------------------------------------------------------------
|
|
|
|
def list_ideas(self, user_id: str) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT i.*,
|
|
COUNT(l.fragment_id) AS fragment_count,
|
|
MAX(f.created_at) AS latest_fragment_at
|
|
FROM ideas i
|
|
LEFT JOIN idea_fragments l ON l.idea_id = i.id
|
|
LEFT JOIN fragments f
|
|
ON f.id = l.fragment_id AND f.user_id = i.user_id
|
|
WHERE i.user_id = ?
|
|
GROUP BY i.id
|
|
ORDER BY i.updated_at DESC
|
|
""",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [self._idea_from_row(row) for row in rows]
|
|
|
|
def get_idea(self, user_id: str, idea_id: str) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT i.*,
|
|
COUNT(l.fragment_id) AS fragment_count,
|
|
MAX(f.created_at) AS latest_fragment_at
|
|
FROM ideas i
|
|
LEFT JOIN idea_fragments l ON l.idea_id = i.id
|
|
LEFT JOIN fragments f
|
|
ON f.id = l.fragment_id AND f.user_id = i.user_id
|
|
WHERE i.id = ? AND i.user_id = ?
|
|
GROUP BY i.id
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
idea = self._idea_from_row(row)
|
|
fragments = connection.execute(
|
|
"""
|
|
SELECT f.id, f.content, f.created_at
|
|
FROM fragments f
|
|
JOIN idea_fragments l ON l.fragment_id = f.id
|
|
WHERE l.idea_id = ? AND f.user_id = ?
|
|
ORDER BY f.created_at ASC, f.rowid ASC
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchall()
|
|
snapshots = connection.execute(
|
|
"""
|
|
SELECT s.title, s.summary, s.maturity_ai,
|
|
s.maturity_override, s.confidence, s.motion,
|
|
s.position, s.tension, s.trajectory, s.possible_moves,
|
|
s.change_kind, s.fragment_ids, s.created_at
|
|
FROM idea_snapshots s
|
|
JOIN ideas i ON i.id = s.idea_id
|
|
WHERE s.idea_id = ? AND i.user_id = ?
|
|
ORDER BY s.created_at ASC, s.rowid ASC
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchall()
|
|
idea["fragments"] = [dict(row) for row in fragments]
|
|
idea["snapshots"] = [
|
|
{
|
|
**dict(row),
|
|
"possible_moves": _loads(row["possible_moves"], []),
|
|
"fragment_ids": _loads(row["fragment_ids"], []),
|
|
}
|
|
for row in snapshots
|
|
]
|
|
return idea
|
|
|
|
def curator_context(
|
|
self, user_id: str
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
ideas = self.list_ideas(user_id)
|
|
fragments = self.list_fragments(user_id, limit=80)
|
|
for idea in ideas:
|
|
detailed = self.get_idea(user_id, idea["id"])
|
|
idea["recent_fragments"] = (detailed or {}).get("fragments", [])[-8:]
|
|
idea["recent_snapshots"] = (detailed or {}).get("snapshots", [])[-5:]
|
|
return ideas, fragments
|
|
|
|
def apply_assessments(
|
|
self,
|
|
user_id: str,
|
|
fragment_id: str,
|
|
assessments: list[dict[str, Any]],
|
|
) -> list[str]:
|
|
now = utc_now()
|
|
affected: list[str] = []
|
|
with self.connect() as connection:
|
|
fragment_exists = connection.execute(
|
|
"""
|
|
SELECT 1 FROM fragments
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(fragment_id, user_id),
|
|
).fetchone()
|
|
if not fragment_exists:
|
|
raise ValueError("fragment not found for user")
|
|
|
|
seen: set[str] = set()
|
|
for assessment in assessments[:2]:
|
|
idea_id = assessment.get("idea_id")
|
|
if idea_id:
|
|
exists = connection.execute(
|
|
"""
|
|
SELECT 1 FROM ideas
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchone()
|
|
if not exists:
|
|
idea_id = None
|
|
if not idea_id:
|
|
idea_id = str(uuid.uuid4())
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO ideas (
|
|
id, user_id, title, summary, maturity_ai,
|
|
confidence, motion, position, tension, trajectory,
|
|
possible_moves, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
idea_id,
|
|
user_id,
|
|
assessment["title"],
|
|
assessment["summary"],
|
|
assessment["maturity"],
|
|
assessment["confidence"],
|
|
assessment["motion"],
|
|
assessment["position"],
|
|
assessment["tension"],
|
|
assessment["trajectory"],
|
|
_json(assessment["possible_moves"]),
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
elif idea_id not in seen:
|
|
connection.execute(
|
|
"""
|
|
UPDATE ideas SET
|
|
title = ?, summary = ?, maturity_ai = ?,
|
|
confidence = ?, motion = ?, position = ?,
|
|
tension = ?, trajectory = ?, possible_moves = ?,
|
|
updated_at = ?
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(
|
|
assessment["title"],
|
|
assessment["summary"],
|
|
assessment["maturity"],
|
|
assessment["confidence"],
|
|
assessment["motion"],
|
|
assessment["position"],
|
|
assessment["tension"],
|
|
assessment["trajectory"],
|
|
_json(assessment["possible_moves"]),
|
|
now,
|
|
idea_id,
|
|
user_id,
|
|
),
|
|
)
|
|
|
|
if idea_id in seen:
|
|
continue
|
|
seen.add(idea_id)
|
|
affected.append(idea_id)
|
|
connection.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO idea_fragments (
|
|
idea_id, fragment_id, relevance, created_at
|
|
) VALUES (?, ?, ?, ?)
|
|
""",
|
|
(
|
|
idea_id,
|
|
fragment_id,
|
|
assessment.get("relevance", 1),
|
|
now,
|
|
),
|
|
)
|
|
version_state = connection.execute(
|
|
"""
|
|
SELECT maturity_override FROM ideas
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchone()
|
|
fragment_ids = [
|
|
row["fragment_id"]
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT l.fragment_id
|
|
FROM idea_fragments l
|
|
JOIN fragments f ON f.id = l.fragment_id
|
|
WHERE l.idea_id = ? AND f.user_id = ?
|
|
ORDER BY f.created_at ASC, f.rowid ASC
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchall()
|
|
]
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO idea_snapshots (
|
|
id, idea_id, source_fragment_id, title, summary,
|
|
maturity_ai, maturity_override, confidence, motion,
|
|
position, tension, trajectory, possible_moves,
|
|
change_kind, fragment_ids, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
idea_id,
|
|
fragment_id,
|
|
assessment["title"],
|
|
assessment["summary"],
|
|
assessment["maturity"],
|
|
(
|
|
version_state["maturity_override"]
|
|
if version_state
|
|
else None
|
|
),
|
|
assessment["confidence"],
|
|
assessment["motion"],
|
|
assessment["position"],
|
|
assessment["tension"],
|
|
assessment["trajectory"],
|
|
_json(assessment["possible_moves"]),
|
|
"analysis",
|
|
_json(fragment_ids),
|
|
now,
|
|
),
|
|
)
|
|
|
|
connection.execute(
|
|
"""
|
|
UPDATE fragments
|
|
SET analysis_status = 'done', analysis_error = NULL
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(fragment_id, user_id),
|
|
)
|
|
return affected
|
|
|
|
def update_idea_override(
|
|
self, user_id: str, idea_id: str, maturity_override: float | None
|
|
) -> dict[str, Any] | None:
|
|
now = utc_now()
|
|
with self.connect() as connection:
|
|
result = connection.execute(
|
|
"""
|
|
UPDATE ideas SET maturity_override = ?, updated_at = ?
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(maturity_override, now, idea_id, user_id),
|
|
)
|
|
if result.rowcount == 0:
|
|
return None
|
|
idea = connection.execute(
|
|
"""
|
|
SELECT title, summary, maturity_ai, maturity_override,
|
|
confidence, motion, position, tension, trajectory,
|
|
possible_moves
|
|
FROM ideas WHERE id = ? AND user_id = ?
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchone()
|
|
fragment_ids = [
|
|
row["fragment_id"]
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT l.fragment_id
|
|
FROM idea_fragments l
|
|
JOIN fragments f ON f.id = l.fragment_id
|
|
WHERE l.idea_id = ? AND f.user_id = ?
|
|
ORDER BY f.created_at ASC, f.rowid ASC
|
|
""",
|
|
(idea_id, user_id),
|
|
).fetchall()
|
|
]
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO idea_snapshots (
|
|
id, idea_id, source_fragment_id, title, summary,
|
|
maturity_ai, maturity_override, confidence, motion,
|
|
position, tension, trajectory, possible_moves,
|
|
change_kind, fragment_ids, created_at
|
|
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
idea_id,
|
|
idea["title"],
|
|
idea["summary"],
|
|
idea["maturity_ai"],
|
|
idea["maturity_override"],
|
|
idea["confidence"],
|
|
idea["motion"],
|
|
idea["position"],
|
|
idea["tension"],
|
|
idea["trajectory"],
|
|
idea["possible_moves"],
|
|
"manual_calibration",
|
|
_json(fragment_ids),
|
|
now,
|
|
),
|
|
)
|
|
return self.get_idea(user_id, idea_id)
|
|
|
|
@staticmethod
|
|
def _idea_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
|
idea = dict(row)
|
|
idea["possible_moves"] = _loads(idea["possible_moves"], [])
|
|
idea["maturity"] = (
|
|
idea["maturity_override"]
|
|
if idea["maturity_override"] is not None
|
|
else idea["maturity_ai"]
|
|
)
|
|
idea["is_overridden"] = idea["maturity_override"] is not None
|
|
idea.pop("user_id", None)
|
|
return idea
|
|
|
|
# Observability ------------------------------------------------------
|
|
|
|
def start_agent_run(
|
|
self,
|
|
user_id: str,
|
|
fragment_id: str,
|
|
model: str,
|
|
prompt_version: str,
|
|
thinking_effort: str,
|
|
) -> str:
|
|
run_id = str(uuid.uuid4())
|
|
now = utc_now()
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO agent_runs (
|
|
id, user_id, fragment_id, status, prompt_version, model,
|
|
thinking_effort, started_at
|
|
) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
run_id,
|
|
user_id,
|
|
fragment_id,
|
|
prompt_version,
|
|
model,
|
|
thinking_effort,
|
|
now,
|
|
),
|
|
)
|
|
self.add_agent_event(
|
|
run_id,
|
|
user_id,
|
|
"run_started",
|
|
{
|
|
"model": model,
|
|
"thinking_effort": thinking_effort,
|
|
"prompt_version": prompt_version,
|
|
},
|
|
)
|
|
return run_id
|
|
|
|
def add_agent_event(
|
|
self,
|
|
run_id: str,
|
|
user_id: str,
|
|
event_type: str,
|
|
payload: dict[str, Any] | None = None,
|
|
duration_ms: int | None = None,
|
|
) -> None:
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO agent_events (
|
|
id, run_id, user_id, event_type, event_at,
|
|
duration_ms, payload
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
run_id,
|
|
user_id,
|
|
event_type,
|
|
utc_now(),
|
|
duration_ms,
|
|
_json(payload or {}),
|
|
),
|
|
)
|
|
|
|
def finish_agent_run(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
status: str,
|
|
duration_ms: int,
|
|
attempt_count: int,
|
|
model_rounds: int,
|
|
tool_calls: int,
|
|
search_calls: int,
|
|
inspection_calls: int,
|
|
input_tokens: int,
|
|
output_tokens: int,
|
|
reasoning_tokens: int,
|
|
cached_tokens: int,
|
|
error: Exception | None = None,
|
|
) -> None:
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE agent_runs SET
|
|
status = ?, finished_at = ?, duration_ms = ?,
|
|
attempt_count = ?, model_rounds = ?, tool_calls = ?,
|
|
search_calls = ?, inspection_calls = ?,
|
|
input_tokens = ?, output_tokens = ?,
|
|
reasoning_tokens = ?, cached_tokens = ?,
|
|
error_type = ?, error_message = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
status,
|
|
utc_now(),
|
|
duration_ms,
|
|
attempt_count,
|
|
model_rounds,
|
|
tool_calls,
|
|
search_calls,
|
|
inspection_calls,
|
|
input_tokens,
|
|
output_tokens,
|
|
reasoning_tokens,
|
|
cached_tokens,
|
|
type(error).__name__ if error else None,
|
|
str(error)[:500] if error else None,
|
|
run_id,
|
|
),
|
|
)
|
|
|
|
def add_audit_event(
|
|
self,
|
|
event_type: str,
|
|
user_id: str | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
) -> None:
|
|
with self.connect() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO audit_events (
|
|
id, user_id, event_type, created_at, payload
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
user_id,
|
|
event_type,
|
|
utc_now(),
|
|
_json(payload or {}),
|
|
),
|
|
)
|
|
|
|
def admin_overview(self) -> dict[str, Any]:
|
|
since = (datetime.now(UTC) - timedelta(hours=24)).isoformat()
|
|
seven_days = (datetime.now(UTC) - timedelta(days=6)).date().isoformat()
|
|
with self.connect() as connection:
|
|
totals = {
|
|
"users": connection.execute(
|
|
"SELECT COUNT(*) FROM users"
|
|
).fetchone()[0],
|
|
"fragments": connection.execute(
|
|
"SELECT COUNT(*) FROM fragments"
|
|
).fetchone()[0],
|
|
"ideas": connection.execute(
|
|
"SELECT COUNT(*) FROM ideas"
|
|
).fetchone()[0],
|
|
"pending": connection.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM fragments
|
|
WHERE analysis_status IN ('pending', 'processing')
|
|
"""
|
|
).fetchone()[0],
|
|
"audit_events": connection.execute(
|
|
"SELECT COUNT(*) FROM audit_events"
|
|
).fetchone()[0],
|
|
}
|
|
recent = dict(
|
|
connection.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS runs,
|
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END)
|
|
AS successes,
|
|
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END)
|
|
AS errors,
|
|
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
|
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
|
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
|
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
|
|
COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
|
|
COALESCE(SUM(model_rounds), 0) AS model_rounds,
|
|
COALESCE(SUM(tool_calls), 0) AS tool_calls
|
|
FROM agent_runs WHERE started_at >= ?
|
|
""",
|
|
(since,),
|
|
).fetchone()
|
|
)
|
|
daily = connection.execute(
|
|
"""
|
|
SELECT substr(started_at, 1, 10) AS day,
|
|
COUNT(*) AS runs,
|
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END)
|
|
AS successes,
|
|
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END)
|
|
AS errors,
|
|
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens
|
|
FROM agent_runs
|
|
WHERE substr(started_at, 1, 10) >= ?
|
|
GROUP BY day ORDER BY day ASC
|
|
""",
|
|
(seven_days,),
|
|
).fetchall()
|
|
recent_errors = connection.execute(
|
|
"""
|
|
SELECT r.id, r.started_at, r.error_type, r.error_message,
|
|
u.label AS user_label
|
|
FROM agent_runs r
|
|
JOIN users u ON u.id = r.user_id
|
|
WHERE r.status = 'error'
|
|
ORDER BY r.started_at DESC LIMIT 5
|
|
"""
|
|
).fetchall()
|
|
audit_24h = dict(
|
|
connection.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS events,
|
|
SUM(CASE WHEN event_type = 'login_failed'
|
|
THEN 1 ELSE 0 END) AS failed_logins
|
|
FROM audit_events WHERE created_at >= ?
|
|
""",
|
|
(since,),
|
|
).fetchone()
|
|
)
|
|
recent["successes"] = int(recent["successes"] or 0)
|
|
recent["errors"] = int(recent["errors"] or 0)
|
|
audit_24h["events"] = int(audit_24h["events"] or 0)
|
|
audit_24h["failed_logins"] = int(
|
|
audit_24h["failed_logins"] or 0
|
|
)
|
|
recent["success_rate"] = (
|
|
round(recent["successes"] / recent["runs"] * 100, 1)
|
|
if recent["runs"]
|
|
else 100.0
|
|
)
|
|
return {
|
|
"totals": totals,
|
|
"last_24h": recent,
|
|
"daily": [dict(row) for row in daily],
|
|
"recent_errors": [dict(row) for row in recent_errors],
|
|
"audit_24h": audit_24h,
|
|
}
|
|
|
|
def list_agent_runs(self, limit: int = 80) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT r.*, u.label AS user_label, u.role AS user_role,
|
|
u.debug_sharing, length(f.content) AS content_length
|
|
FROM agent_runs r
|
|
JOIN users u ON u.id = r.user_id
|
|
JOIN fragments f ON f.id = r.fragment_id
|
|
ORDER BY r.started_at DESC LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
result = [dict(row) for row in rows]
|
|
for run in result:
|
|
run["debug_sharing"] = bool(run["debug_sharing"])
|
|
return result
|
|
|
|
def get_agent_run(self, run_id: str) -> dict[str, Any] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT r.*, u.label AS user_label, u.role AS user_role,
|
|
u.debug_sharing, f.content,
|
|
length(f.content) AS content_length
|
|
FROM agent_runs r
|
|
JOIN users u ON u.id = r.user_id
|
|
JOIN fragments f ON f.id = r.fragment_id
|
|
WHERE r.id = ?
|
|
""",
|
|
(run_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
run = dict(row)
|
|
run["debug_sharing"] = bool(run["debug_sharing"])
|
|
run["content_sha256"] = hashlib.sha256(
|
|
run["content"].encode("utf-8")
|
|
).hexdigest()[:16]
|
|
return run
|
|
|
|
def prepare_agent_retry(self, run_id: str) -> dict[str, str] | None:
|
|
with self.connect() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT r.user_id, r.fragment_id, r.status AS run_status,
|
|
f.analysis_status
|
|
FROM agent_runs r
|
|
JOIN fragments f ON f.id = r.fragment_id
|
|
WHERE r.id = ? AND f.user_id = r.user_id
|
|
""",
|
|
(run_id,),
|
|
).fetchone()
|
|
if (
|
|
not row
|
|
or row["run_status"] != "error"
|
|
or row["analysis_status"] != "error"
|
|
):
|
|
return None
|
|
newer = connection.execute(
|
|
"""
|
|
SELECT 1 FROM agent_runs
|
|
WHERE fragment_id = ? AND user_id = ?
|
|
AND id != ? AND status IN ('running', 'success')
|
|
LIMIT 1
|
|
""",
|
|
(row["fragment_id"], row["user_id"], run_id),
|
|
).fetchone()
|
|
if newer:
|
|
return None
|
|
connection.execute(
|
|
"""
|
|
UPDATE fragments
|
|
SET analysis_status = 'pending', analysis_error = NULL
|
|
WHERE id = ? AND user_id = ?
|
|
""",
|
|
(row["fragment_id"], row["user_id"]),
|
|
)
|
|
return {
|
|
"user_id": row["user_id"],
|
|
"fragment_id": row["fragment_id"],
|
|
}
|
|
|
|
def list_agent_events(self, run_id: str) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id, event_type, event_at, duration_ms, payload
|
|
FROM agent_events
|
|
WHERE run_id = ?
|
|
ORDER BY event_at ASC, rowid ASC
|
|
""",
|
|
(run_id,),
|
|
).fetchall()
|
|
return [
|
|
{**dict(row), "payload": _loads(row["payload"], {})}
|
|
for row in rows
|
|
]
|
|
|
|
def list_audit_events(self, limit: int = 80) -> list[dict[str, Any]]:
|
|
with self.connect() as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT a.id, a.event_type, a.created_at, a.payload,
|
|
u.label AS user_label
|
|
FROM audit_events a
|
|
LEFT JOIN users u ON u.id = a.user_id
|
|
ORDER BY a.created_at DESC, a.rowid DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
return [
|
|
{**dict(row), "payload": _loads(row["payload"], {})}
|
|
for row in rows
|
|
]
|