Files
note-zero/app/curator.py
T

445 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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