feat: open source multi-user AI notebook with observability
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""续想 — a quiet, AI-curated notebook."""
|
||||
|
||||
+204
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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="前端尚未构建",
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user