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['content']}\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