feat: open source multi-user AI notebook with observability

This commit is contained in:
wuyang
2026-07-28 14:28:00 +08:00
commit 935c411847
30 changed files with 6522 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""续想 — a quiet, AI-curated notebook."""
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from fastapi import HTTPException, Request, Response, status
from .config import Settings
from .db import Database, utc_now
COOKIE_NAME = "xuxiang_session"
SESSION_DAYS = 30
_password_hasher = PasswordHasher()
@dataclass(frozen=True)
class AuthUser:
id: str
label: str
role: str
debug_sharing: bool
@property
def is_admin(self) -> bool:
return self.role == "admin"
def public(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"role": self.role,
"debug_sharing": self.debug_sharing,
}
class LoginLimiter:
def __init__(self) -> None:
self.attempts: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
attempts = self.attempts[key]
while attempts and now - attempts[0] > 900:
attempts.popleft()
if len(attempts) >= 8:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="请稍后再试",
)
def fail(self, key: str) -> None:
self.attempts[key].append(time.monotonic())
def clear(self, key: str) -> None:
self.attempts.pop(key, None)
limiter = LoginLimiter()
def find_user_for_key(access_key: str, db: Database) -> AuthUser | None:
for candidate in db.list_auth_users():
try:
matches = _password_hasher.verify(
candidate["access_key_hash"], access_key
)
except (VerifyMismatchError, InvalidHashError):
matches = False
if matches:
return AuthUser(
id=candidate["id"],
label=candidate["label"],
role=candidate["role"],
debug_sharing=bool(candidate["debug_sharing"]),
)
return None
def _token_hash(token: str, settings: Settings) -> str:
secret = (settings.session_secret or "").encode()
return hmac.new(secret, token.encode(), hashlib.sha256).hexdigest()
def create_session(
response: Response,
db: Database,
settings: Settings,
user_id: str,
) -> None:
token = secrets.token_urlsafe(40)
expires = datetime.now(UTC) + timedelta(days=SESSION_DAYS)
with db.connect() as connection:
connection.execute(
"DELETE FROM sessions WHERE expires_at < ?", (utc_now(),)
)
connection.execute(
"""
INSERT INTO sessions (
token_hash, user_id, created_at, expires_at
) VALUES (?, ?, ?, ?)
""",
(
_token_hash(token, settings),
user_id,
utc_now(),
expires.isoformat(),
),
)
response.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_DAYS * 24 * 60 * 60,
httponly=True,
secure=settings.cookie_secure,
samesite="strict",
path="/",
)
def delete_session(
request: Request, response: Response, db: Database, settings: Settings
) -> None:
token = request.cookies.get(COOKIE_NAME)
if token:
with db.connect() as connection:
connection.execute(
"DELETE FROM sessions WHERE token_hash = ?",
(_token_hash(token, settings),),
)
response.delete_cookie(
COOKIE_NAME,
path="/",
secure=settings.cookie_secure,
httponly=True,
samesite="strict",
)
def require_auth(
request: Request, db: Database, settings: Settings
) -> AuthUser:
if settings.auth_disabled:
users = db.list_auth_users()
if users:
user = users[0]
return AuthUser(
id=user["id"],
label=user["label"],
role="admin",
debug_sharing=True,
)
return AuthUser(
id="development",
label="本地开发",
role="admin",
debug_sharing=True,
)
if not settings.users or not settings.session_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="应用尚未配置访问身份",
)
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
with db.connect() as connection:
row = connection.execute(
"""
SELECT u.id, u.label, u.role, u.debug_sharing, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
""",
(_token_hash(token, settings),),
).fetchone()
if not row or row["expires_at"] <= utc_now():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return AuthUser(
id=row["id"],
label=row["label"],
role=row["role"],
debug_sharing=bool(row["debug_sharing"]),
)
def require_admin(user: AuthUser) -> None:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
def require_same_origin_intent(request: Request) -> None:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return
if request.url.path == "/api/login":
return
if request.headers.get("x-note-client") != "xuxiang-web":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
def _read_secret(file_var: str, value_var: str) -> str | None:
secret_file = os.getenv(file_var)
if secret_file:
path = Path(secret_file)
if path.is_file():
return path.read_text(encoding="utf-8").strip()
value = os.getenv(value_var)
return value.strip() if value else None
def _as_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Settings:
data_dir: Path
database_path: Path
users: tuple["UserSeed", ...]
session_secret: str | None
deepseek_api_key: str | None
deepseek_base_url: str
deepseek_model: str
cookie_secure: bool
auth_disabled: bool
@classmethod
def load(cls) -> "Settings":
data_dir = Path(os.getenv("DATA_DIR", "./data")).resolve()
data_dir.mkdir(parents=True, exist_ok=True)
return cls(
data_dir=data_dir,
database_path=data_dir / "notes.sqlite3",
users=_load_users(),
session_secret=_read_secret("SESSION_SECRET_FILE", "SESSION_SECRET"),
deepseek_api_key=_read_secret(
"DEEPSEEK_API_KEY_FILE", "DEEPSEEK_API_KEY"
),
deepseek_base_url=os.getenv(
"DEEPSEEK_BASE_URL", "https://api.deepseek.com"
).rstrip("/"),
deepseek_model=os.getenv("DEEPSEEK_MODEL", "deepseek-v4-pro"),
cookie_secure=_as_bool("COOKIE_SECURE", True),
auth_disabled=_as_bool("AUTH_DISABLED", False),
)
@dataclass(frozen=True)
class UserSeed:
id: str
label: str
role: str
access_key_hash: str
debug_sharing: bool = False
def _load_users() -> tuple[UserSeed, ...]:
raw = _read_secret("USERS_FILE", "USERS_JSON")
if not raw:
return ()
payload = json.loads(raw)
if not isinstance(payload, list):
raise ValueError("users configuration must be a JSON array")
users: list[UserSeed] = []
seen: set[str] = set()
for item in payload:
if not isinstance(item, dict):
raise ValueError("each configured user must be an object")
user = UserSeed(
id=str(item["id"]),
label=str(item["label"]).strip()[:40],
role=str(item["role"]),
access_key_hash=str(item["access_key_hash"]),
debug_sharing=bool(item.get("debug_sharing", False)),
)
if not user.id or user.id in seen:
raise ValueError("configured user ids must be unique")
if user.role not in {"admin", "member"}:
raise ValueError("configured user role must be admin or member")
if not user.label:
raise ValueError("configured user label cannot be empty")
seen.add(user.id)
users.append(user)
return tuple(users)
+444
View File
@@ -0,0 +1,444 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
from agents import (
Agent,
ModelSettings,
OpenAIChatCompletionsModel,
RunContextWrapper,
Runner,
function_tool,
set_tracing_disabled,
)
from openai import AsyncOpenAI
from openai.types.shared import Reasoning
from pydantic import ValidationError
from .config import Settings
from .db import Database
from .schemas import CuratorDecision
logger = logging.getLogger(__name__)
PROMPT_VERSION = "maturity-2026-07-28.v2"
CURATOR_INSTRUCTIONS = """
你是一个私人思想笔记本内部的“策展者”。用户界面必须安静,所以你的工作发生在幕后。
你的任务不是给句子贴标签,也不是强迫想法进入固定工作流,而是辨认:新片段是否属于一个
持续演化的想法;如果属于,它此刻大致在哪里、正在怎样运动、内在张力是什么。
判断“成熟度”时使用融合性的人类经验,而不是计数规则。综合考察:
1. 想法是否已经有自己的身份、边界与可复述的核心;
2. 它是否能容纳反例、摩擦、矛盾和真实经验,而不只是顺滑口号;
3. 它是否越来越属于这个具体的人,而非可替换的通用正确话;
4. 它与现实是否有接触:观察、制作、对话、试验、承诺或后果;
5. 它是否开始改变判断与行动,产生了真实影响。
0—100 只是连续的“位势坐标”,不是阶段、成绩或完成百分比。成熟度可以后退;新材料也可能
让一个看似成熟的想法重新打开。禁止用笔记数量、时间、链接数、是否列清单来机械打分。
motion 是你对当下运动的自然语言压缩,例如“向现实探去”“重构中”“暂时沉淀”,不使用
预设流水线。position 说明此刻所处的位置。trajectory 只描述最近发生的变化。possible_moves
是 1—4 个根据当前张力即时生成的可能动作,不是任务清单,不承诺固定的下一关。
谨慎合并。仅因主题词相似不能归到同一想法;关注它们是否共享同一个问题、张力或生成方向。
一个片段最多关联两个想法。若现在还不值得形成或归入想法,让它 standalone,原文仍会保留。
新建想法时 idea_id 必须为 null;更新时必须使用工具返回的准确 id。
先查看候选想法;遇到可能相关或可能冲突的候选时,使用 inspect_idea。最终必须只返回 JSON,
不能用 Markdown 包裹,也不能附加解释。JSON 必须符合输入中给出的 schema。
不要在输出中评价用户、诊断心理、说教或伪造证据。
""".strip()
@dataclass
class CuratorContext:
db: Database
run_id: str
user_id: str
ideas: list[dict[str, Any]]
recent_fragments: list[dict[str, Any]]
search_count: int = 0
inspection_count: int = 0
@function_tool(strict_mode=False)
async def search_ideas(
ctx: RunContextWrapper[CuratorContext], query: str
) -> str:
"""Search existing evolving ideas by a short concept, question, or tension."""
ctx.context.search_count += 1
started = time.perf_counter()
terms = [term.lower() for term in query.split() if term.strip()]
ranked: list[tuple[int, dict[str, Any]]] = []
for idea in ctx.context.ideas:
haystack = " ".join(
str(idea.get(key, ""))
for key in ("title", "summary", "position", "tension", "trajectory")
).lower()
score = sum(haystack.count(term) for term in terms)
if score or not terms:
ranked.append((score, idea))
ranked.sort(key=lambda item: (item[0], item[1].get("updated_at", "")), reverse=True)
compact = [
{
"id": idea["id"],
"title": idea["title"],
"summary": idea["summary"],
"maturity": idea["maturity"],
"motion": idea["motion"],
"position": idea["position"],
"tension": idea["tension"],
}
for _, idea in ranked[:8]
]
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_search_ideas",
{
"query": query,
"result_count": len(compact),
"candidates": [
{"id": item["id"], "title": item["title"]} for item in compact
],
},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps(compact, ensure_ascii=False)
@function_tool(strict_mode=False)
async def inspect_idea(
ctx: RunContextWrapper[CuratorContext], idea_id: str
) -> str:
"""Inspect an idea's recent evidence and movement before deciding to update it."""
ctx.context.inspection_count += 1
started = time.perf_counter()
idea = next(
(candidate for candidate in ctx.context.ideas if candidate["id"] == idea_id),
None,
)
if not idea:
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_inspect_idea",
{"idea_id": idea_id, "found": False},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps({"error": "idea not found"})
payload = {
"id": idea["id"],
"title": idea["title"],
"summary": idea["summary"],
"maturity_ai": idea["maturity_ai"],
"maturity_seen_by_user": idea["maturity"],
"user_has_overridden_position": idea["is_overridden"],
"motion": idea["motion"],
"position": idea["position"],
"tension": idea["tension"],
"trajectory": idea["trajectory"],
"possible_moves": idea["possible_moves"],
"recent_fragments": idea.get("recent_fragments", []),
"recent_snapshots": idea.get("recent_snapshots", []),
}
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_inspect_idea",
{
"idea_id": idea_id,
"found": True,
"title": idea["title"],
"fragment_count": len(idea.get("recent_fragments", [])),
"snapshot_count": len(idea.get("recent_snapshots", [])),
},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps(payload, ensure_ascii=False)
class Curator:
def __init__(self, db: Database, settings: Settings):
self.db = db
self.settings = settings
self.queue: asyncio.Queue[str] = asyncio.Queue()
self._worker: asyncio.Task[None] | None = None
@property
def configured(self) -> bool:
return bool(self.settings.deepseek_api_key)
async def start(self) -> None:
for fragment_id in self.db.pending_fragment_ids():
self.queue.put_nowait(fragment_id)
self._worker = asyncio.create_task(self._run_worker())
async def stop(self) -> None:
if self._worker:
self._worker.cancel()
try:
await self._worker
except asyncio.CancelledError:
pass
def enqueue(self, fragment_id: str) -> None:
self.queue.put_nowait(fragment_id)
async def _run_worker(self) -> None:
while True:
fragment_id = await self.queue.get()
try:
await self.analyze(fragment_id)
except Exception as exc:
logger.exception("Curator failed for fragment %s", fragment_id)
self.db.set_fragment_status(
fragment_id, "error", str(exc)[:500]
)
finally:
self.queue.task_done()
async def analyze(self, fragment_id: str) -> None:
fragment = self.db.get_fragment(fragment_id)
if not fragment:
return
user_id = fragment.get("user_id")
if not user_id:
raise RuntimeError("fragment has no user owner")
if not self.configured:
self.db.set_fragment_status(
fragment_id, "pending", "DeepSeek API key is not configured"
)
return
self.db.set_fragment_status(fragment_id, "processing")
run_id = self.db.start_agent_run(
user_id,
fragment_id,
self.settings.deepseek_model,
PROMPT_VERSION,
"high",
)
run_started = time.perf_counter()
ideas, recent_fragments = self.db.curator_context(user_id)
context = CuratorContext(
db=self.db,
run_id=run_id,
user_id=user_id,
ideas=ideas,
recent_fragments=recent_fragments,
)
self.db.add_agent_event(
run_id,
user_id,
"context_loaded",
{
"fragment_characters": len(fragment["content"]),
"idea_count": len(ideas),
"recent_fragment_count": len(recent_fragments),
},
)
output_schema = CuratorDecision.model_json_schema()
lookup_instruction = (
"必须先调用 search_ideas 搜索共享的问题或张力;如果候选可能相关,"
"再调用 inspect_idea 查看它的真实轨迹后判断。\n\n"
if ideas
else "目前没有已有想法,不要调用搜索工具。\n\n"
)
prompt = (
"请策展这个刚刚保存的新片段:\n"
f"<fragment id=\"{fragment_id}\">{fragment['content']}</fragment>\n\n"
f"当前共有 {len(ideas)} 个已有想法。{lookup_instruction}"
"最终只输出符合以下 JSON Schema 的 JSON 对象:\n"
f"{json.dumps(output_schema, ensure_ascii=False)}"
)
set_tracing_disabled(True)
client = AsyncOpenAI(
api_key=self.settings.deepseek_api_key,
base_url=self.settings.deepseek_base_url,
)
model = OpenAIChatCompletionsModel(
model=self.settings.deepseek_model,
openai_client=client,
)
agent: Agent[CuratorContext] = Agent(
name="私人思想策展者",
instructions=CURATOR_INSTRUCTIONS,
model=model,
tools=[search_ideas, inspect_idea],
model_settings=ModelSettings(
max_tokens=2_400,
reasoning=Reasoning(effort="high"),
extra_body={"thinking": {"type": "enabled"}},
extra_args={"response_format": {"type": "json_object"}},
),
)
decision: CuratorDecision | None = None
last_error: Exception | None = None
attempt_count = 0
model_rounds = 0
usage = {
"input_tokens": 0,
"output_tokens": 0,
"reasoning_tokens": 0,
"cached_tokens": 0,
}
try:
for attempt in range(2):
attempt_count = attempt + 1
repair = (
""
if attempt == 0
else "\n\n上一次输出未通过结构校验。重新完整判断,并只返回合法 JSON。"
)
self.db.add_agent_event(
run_id,
user_id,
"attempt_started",
{"attempt": attempt_count, "is_repair": attempt > 0},
)
attempt_started = time.perf_counter()
result = await Runner.run(
agent,
input=prompt + repair,
context=context,
max_turns=4,
)
attempt_duration = round(
(time.perf_counter() - attempt_started) * 1000
)
current_usage = self._result_usage(result)
model_rounds += current_usage.pop("model_rounds")
for key in usage:
usage[key] += current_usage[key]
self.db.add_agent_event(
run_id,
user_id,
"model_attempt_completed",
{
"attempt": attempt_count,
"model_rounds": len(result.raw_responses),
**current_usage,
},
duration_ms=attempt_duration,
)
try:
raw = result.final_output
if not isinstance(raw, str):
raise TypeError("curator output was not text")
decision = CuratorDecision.model_validate_json(raw)
if ideas and context.search_count == 0:
decision = None
raise ValueError("curator skipped required idea search")
break
except (ValidationError, ValueError, TypeError) as exc:
decision = None
last_error = exc
self.db.add_agent_event(
run_id,
user_id,
"validation_failed",
{
"attempt": attempt_count,
"error_type": type(exc).__name__,
"error": str(exc)[:500],
},
)
logger.warning(
"Curator returned invalid JSON for fragment %s (attempt %s)",
fragment_id,
attempt_count,
)
if decision is None:
raise RuntimeError(
"curator returned invalid JSON twice"
) from last_error
assessments = (
[]
if decision.standalone
else [item.model_dump() for item in decision.assessments]
)
affected = self.db.apply_assessments(
user_id, fragment_id, assessments
)
self.db.add_agent_event(
run_id,
user_id,
"decision_committed",
{
"standalone": decision.standalone,
"reasoning_note": decision.reasoning_note,
"assessments": assessments,
"affected_idea_ids": affected,
},
)
self.db.finish_agent_run(
run_id,
status="success",
duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count,
model_rounds=model_rounds,
tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count,
inspection_calls=context.inspection_count,
**usage,
)
logger.info(
"Curator completed fragment %s with %s search and %s inspection calls",
fragment_id,
context.search_count,
context.inspection_count,
)
except Exception as exc:
self.db.add_agent_event(
run_id,
user_id,
"run_failed",
{
"error_type": type(exc).__name__,
"error": str(exc)[:500],
},
)
self.db.finish_agent_run(
run_id,
status="error",
duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count,
model_rounds=model_rounds,
tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count,
inspection_calls=context.inspection_count,
error=exc,
**usage,
)
raise
@staticmethod
def _result_usage(result: Any) -> dict[str, int]:
totals = {
"model_rounds": len(result.raw_responses),
"input_tokens": 0,
"output_tokens": 0,
"reasoning_tokens": 0,
"cached_tokens": 0,
}
for response in result.raw_responses:
response_usage = response.usage
totals["input_tokens"] += response_usage.input_tokens
totals["output_tokens"] += response_usage.output_tokens
totals["reasoning_tokens"] += (
response_usage.output_tokens_details.reasoning_tokens or 0
)
totals["cached_tokens"] += (
response_usage.input_tokens_details.cached_tokens or 0
)
return totals
+963
View File
@@ -0,0 +1,963 @@
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,
maturity_ai REAL NOT NULL,
motion TEXT NOT NULL,
position TEXT NOT NULL,
tension TEXT NOT NULL,
trajectory TEXT NOT NULL,
possible_moves TEXT NOT NULL,
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")
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.maturity_ai, s.motion, s.position, s.tension,
s.trajectory, s.possible_moves, 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"], []),
}
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,
),
)
connection.execute(
"""
INSERT INTO idea_snapshots (
id, idea_id, source_fragment_id, maturity_ai, motion,
position, tension, trajectory, possible_moves, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
idea_id,
fragment_id,
assessment["maturity"],
assessment["motion"],
assessment["position"],
assessment["tension"],
assessment["trajectory"],
_json(assessment["possible_moves"]),
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:
with self.connect() as connection:
result = connection.execute(
"""
UPDATE ideas SET maturity_override = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
(maturity_override, utc_now(), idea_id, user_id),
)
if result.rowcount == 0:
return None
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 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
]
+363
View File
@@ -0,0 +1,363 @@
from __future__ import annotations
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .auth import (
AuthUser,
create_session,
delete_session,
find_user_for_key,
limiter,
require_admin,
require_auth,
require_same_origin_intent,
)
from .config import Settings, UserSeed
from .curator import Curator, PROMPT_VERSION
from .db import Database
from .schemas import (
DebugSharingUpdate,
FragmentCreate,
IdeaOverride,
LoginRequest,
)
settings = Settings.load()
db = Database(settings.database_path)
curator = Curator(db, settings)
started_at = time.time()
@asynccontextmanager
async def lifespan(_: FastAPI):
seeds = list(settings.users)
if settings.auth_disabled and not seeds:
seeds.append(
UserSeed(
id="development",
label="本地开发",
role="admin",
access_key_hash="authentication-disabled",
debug_sharing=True,
)
)
db.initialize(seeds)
db.add_audit_event(
"application_started",
payload={
"configured_users": len(seeds),
"curator_configured": curator.configured,
"prompt_version": PROMPT_VERSION,
},
)
await curator.start()
yield
await curator.stop()
app = FastAPI(
title="续想",
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=()"
)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self'; "
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
)
return response
def authenticated(request: Request) -> AuthUser:
user = require_auth(request, db, settings)
require_same_origin_intent(request)
return user
def administrator(user: AuthUser = Depends(authenticated)) -> AuthUser:
require_admin(user)
return user
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/login")
async def login(payload: LoginRequest, request: Request, response: Response):
if settings.auth_disabled:
user = require_auth(request, db, settings)
return {"authenticated": True, "user": user.public()}
client_key = request.client.host if request.client else "unknown"
limiter.check(client_key)
user = find_user_for_key(payload.access_key, db)
if not user:
limiter.fail(client_key)
db.add_audit_event("login_failed")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="密钥不对"
)
limiter.clear(client_key)
create_session(response, db, settings, user.id)
db.add_audit_event("login_succeeded", user.id)
return {"authenticated": True, "user": user.public()}
@app.post("/api/logout")
async def logout(
request: Request,
response: Response,
user: AuthUser = Depends(authenticated),
):
delete_session(request, response, db, settings)
db.add_audit_event("logout", user.id)
return {"authenticated": False}
@app.get("/api/session")
async def session(user: AuthUser = Depends(authenticated)):
return {"authenticated": True, "user": user.public()}
@app.patch("/api/account/debug-sharing")
async def update_debug_sharing(
payload: DebugSharingUpdate,
user: AuthUser = Depends(authenticated),
):
updated = db.update_debug_sharing(user.id, payload.enabled)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
db.add_audit_event(
"debug_sharing_changed",
user.id,
{"enabled": payload.enabled},
)
return updated
@app.get("/api/fragments")
async def list_fragments(user: AuthUser = Depends(authenticated)):
return {"items": db.list_fragments(user.id)}
@app.post("/api/fragments", status_code=status.HTTP_201_CREATED)
async def create_fragment(
payload: FragmentCreate, user: AuthUser = Depends(authenticated)
):
fragment = db.create_fragment(user.id, payload.content)
db.add_audit_event(
"fragment_created",
user.id,
{"fragment_id": fragment["id"], "characters": len(payload.content)},
)
curator.enqueue(fragment["id"])
return {
"id": fragment["id"],
"content": fragment["content"],
"created_at": fragment["created_at"],
"analysis_status": fragment["analysis_status"],
}
@app.get("/api/ideas")
async def list_ideas(user: AuthUser = Depends(authenticated)):
return {"items": db.list_ideas(user.id)}
@app.get("/api/ideas/{idea_id}")
async def get_idea(idea_id: str, user: AuthUser = Depends(authenticated)):
idea = db.get_idea(user.id, idea_id)
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return idea
@app.patch("/api/ideas/{idea_id}/position")
async def update_idea_position(
idea_id: str,
payload: IdeaOverride,
user: AuthUser = Depends(authenticated),
):
idea = db.update_idea_override(user.id, idea_id, payload.maturity)
if not idea:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
db.add_audit_event(
"idea_position_calibrated",
user.id,
{"idea_id": idea_id, "maturity": payload.maturity},
)
return idea
@app.get("/api/system/status")
async def system_status(user: AuthUser = Depends(authenticated)):
return {
"pending": db.processing_count(user.id),
"curator_configured": curator.configured,
}
@app.get("/api/admin/overview")
async def admin_overview(_: AuthUser = Depends(administrator)):
overview = db.admin_overview()
recent = overview["last_24h"]
if recent["runs"] == 0:
narrative = "过去 24 小时没有 Agent 运行。"
else:
narrative = (
f"过去 24 小时运行 {recent['runs']} 次,"
f"成功率 {recent['success_rate']}%,"
f"平均耗时 {round(recent['avg_duration_ms'] / 1000, 1)} 秒;"
f"其中推理 token {recent['reasoning_tokens']}。"
)
return {
**overview,
"runtime": {
"uptime_seconds": round(time.time() - started_at),
"curator_configured": curator.configured,
"queue_depth": curator.queue.qsize(),
"model": settings.deepseek_model,
"prompt_version": PROMPT_VERSION,
},
"narrative": narrative,
}
@app.get("/api/admin/users")
async def admin_users(_: AuthUser = Depends(administrator)):
return {"items": db.list_users()}
@app.get("/api/admin/runs")
async def admin_runs(
limit: int = Query(default=80, ge=1, le=200),
_: AuthUser = Depends(administrator),
):
return {"items": db.list_agent_runs(limit)}
@app.get("/api/admin/audit")
async def admin_audit(
limit: int = Query(default=80, ge=1, le=200),
_: AuthUser = Depends(administrator),
):
return {"items": db.list_audit_events(limit)}
def _redacted_event(event: dict[str, Any]) -> dict[str, Any]:
payload = event["payload"]
event_type = event["event_type"]
safe: dict[str, Any] = {}
if event_type == "run_started":
safe = payload
elif event_type == "context_loaded":
safe = payload
elif event_type in {"attempt_started", "model_attempt_completed"}:
safe = payload
elif event_type == "tool_search_ideas":
safe = {"result_count": payload.get("result_count", 0)}
elif event_type == "tool_inspect_idea":
safe = {
key: payload.get(key)
for key in ("found", "fragment_count", "snapshot_count")
if key in payload
}
elif event_type == "validation_failed":
safe = {
"attempt": payload.get("attempt"),
"error_type": payload.get("error_type"),
}
elif event_type == "decision_committed":
safe = {
"standalone": payload.get("standalone"),
"assessment_count": len(payload.get("assessments", [])),
}
elif event_type == "run_failed":
safe = {"error_type": payload.get("error_type")}
return {**event, "payload": safe}
@app.get("/api/admin/runs/{run_id}")
async def admin_run_detail(
run_id: str, admin: AuthUser = Depends(administrator)
):
run = db.get_agent_run(run_id)
if not run:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
content_visible = run["user_id"] == admin.id or run["debug_sharing"]
events = db.list_agent_events(run_id)
if not content_visible:
events = [_redacted_event(event) for event in events]
content = run.pop("content")
return {
"run": run,
"fragment": (
{"content": content, "content_visible": True}
if content_visible
else {
"content_visible": False,
"content_length": run["content_length"],
"content_sha256": run["content_sha256"],
}
),
"events": events,
"privacy": {
"content_visible": content_visible,
"reasoning_content_stored": False,
"reason": (
"管理员自己的运行"
if run["user_id"] == admin.id
else (
"用户已允许调试共享"
if run["debug_sharing"]
else "用户未允许调试共享,内容已脱敏"
)
),
},
}
static_dir = Path(__file__).parent / "static"
assets_dir = static_dir / "assets"
if assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def frontend(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
target = static_dir / full_path
if full_path and target.is_file() and static_dir in target.resolve().parents:
return FileResponse(target)
index = static_dir / "index.html"
if index.is_file():
return FileResponse(index)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="前端尚未构建",
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Annotated
from pydantic import (
BaseModel,
Field,
StringConstraints,
field_validator,
model_validator,
)
Content = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
class LoginRequest(BaseModel):
access_key: str = Field(min_length=1, max_length=512)
class FragmentCreate(BaseModel):
content: Content = Field(max_length=20_000)
class IdeaOverride(BaseModel):
maturity: float | None = Field(default=None, ge=0, le=100)
class DebugSharingUpdate(BaseModel):
enabled: bool
class IdeaAssessment(BaseModel):
idea_id: str | None = Field(
description="Existing idea id, or null only when a genuinely new idea is needed."
)
title: str = Field(min_length=1, max_length=80)
summary: str = Field(min_length=1, max_length=280)
maturity: float = Field(ge=0, le=100)
confidence: float = Field(ge=0, le=1)
motion: str = Field(min_length=1, max_length=24)
position: str = Field(min_length=1, max_length=120)
tension: str = Field(min_length=1, max_length=240)
trajectory: str = Field(min_length=1, max_length=280)
possible_moves: list[str] = Field(min_length=1, max_length=4)
relevance: float = Field(default=1, ge=0, le=1)
@field_validator("possible_moves")
@classmethod
def moves_are_brief(cls, moves: list[str]) -> list[str]:
return [move.strip()[:100] for move in moves if move.strip()][:4]
class CuratorDecision(BaseModel):
standalone: bool = Field(
description="True when the fragment should remain only in the raw stream for now."
)
reasoning_note: str = Field(
max_length=200,
description="Brief audit note for the system, never shown in the capture stream.",
)
assessments: list[IdeaAssessment] = Field(default_factory=list, max_length=2)
@model_validator(mode="after")
def decision_is_coherent(self) -> "CuratorDecision":
if self.standalone and self.assessments:
raise ValueError("standalone decisions cannot contain assessments")
if not self.standalone and not self.assessments:
raise ValueError("non-standalone decisions require an assessment")
return self