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
+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