445 lines
17 KiB
Python
445 lines
17 KiB
Python
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
|