feat: give the curator continuous context and durable idea history

This commit is contained in:
wuyang
2026-07-28 17:31:37 +08:00
parent 5c483536b4
commit af7bb68268
10 changed files with 967 additions and 114 deletions
+11 -5
View File
@@ -3,21 +3,23 @@
一个“先保存,后理解”的私人 AI 笔记本。 一个“先保存,后理解”的私人 AI 笔记本。
记录页只呈现用户写下的原文,输入历史像对话一样始终可回看。后台的单一策展 记录页只呈现用户写下的原文,输入历史像对话一样始终可回看。后台的单一策展
Agent 使用 DeepSeek 的思考模式,按需搜索已有想法与轨迹,再给出结构化的“想法位势” Agent 使用 DeepSeek 的思考模式,结合最近的连续原文与精简想法目录,必要时再查看
提案。Agent 不能直接写数据库;应用只在 schema 和引用完整性校验通过后,用事务提交 已有想法的完整轨迹,最后给出结构化的“想法位势”提案。Agent 不能直接写数据库;
更新。 应用只在 schema 和引用完整性校验通过后,用事务提交更新。
## 设计边界 ## 设计边界
- 原始片段先持久化,AI 失败不影响记录。 - 原始片段先持久化,AI 失败不影响记录。
- 捕获流不显示保存状态、AI 标签、关联或建议。 - 捕获流不显示保存状态、AI 标签、关联或建议。
- 不要求用户先建页面、取标题、选分类或填写日期。 - 不要求用户先建页面、取标题、选分类或填写日期。
- 不要求用户建立会话;系统在后台保留滚动的连续语境。
- 成熟度是可回退的连续位势,不是阶段、成绩或任务完成百分比。 - 成熟度是可回退的连续位势,不是阶段、成绩或任务完成百分比。
- 运动、张力和可能动作由模型结合上下文动态生成,不使用固定关卡。 - 运动、张力和可能动作由模型结合上下文动态生成,不使用固定关卡。
- 用户手动校准的位势优先展示,AI 估计仍独立保留。 - 用户手动校准的位势优先展示,AI 估计仍独立保留。
- 每个用户的记录、想法、Session 与 Agent 上下文都以 `user_id` 在 SQL 层隔离。 - 每个用户的记录、想法、Session 与 Agent 上下文都以 `user_id` 在 SQL 层隔离。
- 管理员能看运行元数据;其他用户的原文与 AI 产物默认脱敏,只有用户主动开启 - 管理员能看运行元数据;其他用户的原文与 AI 产物默认脱敏,只有用户主动开启
“调试共享”后才可见。 “调试共享”后才可见。
- 每次想法更新和人工校准都会保存完整版本;原始片段始终是不可替代的证据层。
- 管理后台保存模型轮次、工具调用、耗时、token、错误和结构化决策产物,但不保存或 - 管理后台保存模型轮次、工具调用、耗时、token、错误和结构化决策产物,但不保存或
暴露模型隐藏思维链。 暴露模型隐藏思维链。
@@ -33,11 +35,14 @@ React/Vite ── cookie session ── FastAPI ── SQLite
核心数据流是: 核心数据流是:
1. `POST /api/fragments` 先提交原文并立即返回; 1. `POST /api/fragments` 先提交原文并立即返回;
2. 后台 Agent 读取当前用户自己的想法上下文; 2. 后台 Agent 读取当前用户最近的连续输入和精简想法目录;
3. Agent 通过 `search_ideas`、`inspect_idea` 工具选择相关材料; 3. Agent 通过 `search_ideas`、`inspect_idea` 工具补充相关材料;
4. 应用校验结构化结果后,以事务写入想法、轨迹和片段关联; 4. 应用校验结构化结果后,以事务写入想法、轨迹和片段关联;
5. 每次运行同时形成 `agent_runs`、`agent_events`,供管理后台聚合分析。 5. 每次运行同时形成 `agent_runs`、`agent_events`,供管理后台聚合分析。
工具探索超过预算或输出结构无效时,系统会进入一次没有工具的收敛回合;失败运行也可以由
管理员重新排队。模型用量通过逐回合钩子采集,因此异常退出不会被误记为零。
## 本地运行 ## 本地运行
要求 Python 3.12+、Node.js 22+。 要求 Python 3.12+、Node.js 22+。
@@ -112,6 +117,7 @@ Session 会归属给配置清单中的第一位管理员;不会把旧数据复
- `GET /api/admin/users`:用户空间、数据量与调试共享状态; - `GET /api/admin/users`:用户空间、数据量与调试共享状态;
- `GET /api/admin/runs?limit=80`:Agent 运行列表; - `GET /api/admin/runs?limit=80`:Agent 运行列表;
- `GET /api/admin/runs/{run_id}`:一次运行的事件、工具、token 与结构化产物; - `GET /api/admin/runs/{run_id}`:一次运行的事件、工具、token 与结构化产物;
- `POST /api/admin/runs/{run_id}/retry`:重新排队一次仍处于错误状态的分析;
- `GET /api/admin/audit?limit=120`:登录、启动、记录、校准等应用审计事件。 - `GET /api/admin/audit?limit=120`:登录、启动、记录、校准等应用审计事件。
普通用户可调用 `PATCH /api/account/debug-sharing` 控制自己的调试内容是否向管理员 普通用户可调用 `PATCH /api/account/debug-sharing` 控制自己的调试内容是否向管理员
+375 -87
View File
@@ -3,19 +3,23 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any from typing import Any
from agents import ( from agents import (
Agent, Agent,
ModelSettings, ModelSettings,
OpenAIChatCompletionsModel, OpenAIChatCompletionsModel,
RunHooks,
RunContextWrapper, RunContextWrapper,
Runner, Runner,
function_tool, function_tool,
set_tracing_disabled, set_tracing_disabled,
) )
from agents.exceptions import MaxTurnsExceeded
from agents.items import ModelResponse
from openai import AsyncOpenAI from openai import AsyncOpenAI
from openai.types.shared import Reasoning from openai.types.shared import Reasoning
from pydantic import ValidationError from pydantic import ValidationError
@@ -25,7 +29,9 @@ from .db import Database
from .schemas import CuratorDecision from .schemas import CuratorDecision
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROMPT_VERSION = "maturity-2026-07-28.v2" PROMPT_VERSION = "maturity-2026-07-28.v3"
RECENT_CONTEXT_LIMIT = 12
IDEA_CATALOG_LIMIT = 40
CURATOR_INSTRUCTIONS = """ CURATOR_INSTRUCTIONS = """
@@ -52,8 +58,13 @@ motion 是你对当下运动的自然语言压缩,例如“向现实探去”
一个片段最多关联两个想法。若现在还不值得形成或归入想法,让它 standalone,原文仍会保留。 一个片段最多关联两个想法。若现在还不值得形成或归入想法,让它 standalone,原文仍会保留。
新建想法时 idea_id 必须为 null;更新时必须使用工具返回的准确 id。 新建想法时 idea_id 必须为 null;更新时必须使用工具返回的准确 id。
先查看候选想法;遇到可能相关或可能冲突的候选时,使用 inspect_idea。最终必须只返回 JSON, 输入会直接提供最近的连续原文和精简想法目录。把“这个、上面、第二点、继续、刚才”等指代
不能用 Markdown 包裹,也不能附加解释。JSON 必须符合输入中给出的 schema。 放回连续语境中理解;不要因为新片段换了关键词就丢掉思想上的延续。目录中的 id 只供系统内部
引用。遇到可能相关或冲突的候选时,使用 inspect_idea;search_ideas 只用于目录过大或需要
补充候选时,最多调用两次;同一个想法最多 inspect 一次。搜索无结果时不要反复改写查询,
也绝不能用 none、unknown 等占位符调用 inspect_idea。工具探索后必须及时收敛并返回最终判断。
最终必须只返回 JSON,不能用 Markdown 包裹,也不能附加解释。JSON 必须符合输入中给出的 schema。
不要在输出中评价用户、诊断心理、说教或伪造证据。 不要在输出中评价用户、诊断心理、说教或伪造证据。
""".strip() """.strip()
@@ -67,6 +78,187 @@ class CuratorContext:
recent_fragments: list[dict[str, Any]] recent_fragments: list[dict[str, Any]]
search_count: int = 0 search_count: int = 0
inspection_count: int = 0 inspection_count: int = 0
model_rounds: int = 0
input_tokens: int = 0
output_tokens: int = 0
reasoning_tokens: int = 0
cached_tokens: int = 0
llm_started_at: float | None = None
inspected_idea_ids: set[str] = field(default_factory=set)
def _search_units(value: str) -> list[str]:
units: list[str] = []
for token in re.findall(r"[a-zA-Z0-9_-]+|[\u3400-\u9fff]+", value.lower()):
if token not in units:
units.append(token)
if re.fullmatch(r"[\u3400-\u9fff]+", token) and len(token) > 2:
for index in range(len(token) - 1):
pair = token[index : index + 2]
if pair not in units:
units.append(pair)
return units[:24]
def _rank_ideas(
ideas: list[dict[str, Any]], query: str
) -> tuple[list[dict[str, Any]], bool]:
terms = _search_units(query)
ranked: list[tuple[int, str, dict[str, Any]]] = []
for idea in ideas:
evidence = " ".join(
str(fragment.get("content", ""))
for fragment in idea.get("recent_fragments", [])
)
haystack = " ".join(
str(idea.get(key, ""))
for key in (
"title",
"summary",
"position",
"tension",
"trajectory",
)
)
haystack = f"{haystack} {evidence}".lower()
score = sum(
(4 if term in str(idea.get("title", "")).lower() else 1)
* haystack.count(term)
for term in terms
)
if score:
ranked.append((score, str(idea.get("updated_at", "")), idea))
fallback = not ranked
if fallback:
ranked = [
(0, str(idea.get("updated_at", "")), idea) for idea in ideas
]
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
return [idea for _, _, idea in ranked[:8]], fallback
class CuratorRunHooks(RunHooks[CuratorContext]):
async def on_llm_start(
self,
context: RunContextWrapper[CuratorContext],
agent: Agent[CuratorContext],
system_prompt: str | None,
input_items: list[Any],
) -> None:
context.context.llm_started_at = time.perf_counter()
async def on_llm_end(
self,
context: RunContextWrapper[CuratorContext],
agent: Agent[CuratorContext],
response: ModelResponse,
) -> None:
state = context.context
state.model_rounds += 1
usage = response.usage
input_tokens = int(usage.input_tokens or 0)
output_tokens = int(usage.output_tokens or 0)
reasoning_tokens = int(
usage.output_tokens_details.reasoning_tokens or 0
)
cached_tokens = int(
usage.input_tokens_details.cached_tokens or 0
)
state.input_tokens += input_tokens
state.output_tokens += output_tokens
state.reasoning_tokens += reasoning_tokens
state.cached_tokens += cached_tokens
duration_ms = (
round((time.perf_counter() - state.llm_started_at) * 1000)
if state.llm_started_at is not None
else None
)
state.db.add_agent_event(
state.run_id,
state.user_id,
"model_round_completed",
{
"round": state.model_rounds,
"agent": agent.name,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"reasoning_tokens": reasoning_tokens,
"cached_tokens": cached_tokens,
},
duration_ms=duration_ms,
)
state.llm_started_at = None
def build_curator_prompt(
fragment: dict[str, Any],
ideas: list[dict[str, Any]],
recent_fragments: list[dict[str, Any]],
) -> str:
prior_context = [
{
"id": item["id"],
"created_at": item["created_at"],
"content": str(item["content"])[:1_200],
}
for item in recent_fragments
if item["id"] != fragment["id"]
and item["created_at"] <= fragment["created_at"]
][-RECENT_CONTEXT_LIMIT:]
later_context = [
{
"created_at": item["created_at"],
"content": str(item["content"])[:1_200],
}
for item in recent_fragments
if item["id"] != fragment["id"]
and item["created_at"] > fragment["created_at"]
][-6:]
catalog = [
{
"id": idea["id"],
"title": idea["title"],
"summary": idea["summary"],
"motion": idea["motion"],
"position": idea["position"],
"tension": idea["tension"],
"updated_at": idea["updated_at"],
}
for idea in ideas[:IDEA_CATALOG_LIMIT]
]
new_fragment = {
"id": fragment["id"],
"created_at": fragment["created_at"],
"content": fragment["content"],
}
output_schema = CuratorDecision.model_json_schema()
catalog_note = (
"目录已经覆盖全部已有想法。优先从目录直接判断候选,相关时用准确 id 调用 "
"inspect_idea;一般不需要 search_ideas。"
if len(ideas) <= IDEA_CATALOG_LIMIT
else "目录只包含最近更新的想法;找不到可能候选时可调用 search_ideas。"
)
delayed_note = (
"这是一次延迟重整。later_context 是这条记录之后才发生的输入,只用于避免把现有想法"
"的状态和轨迹倒退;不要把它们误当成当时的上文。应把当前片段作为迟到的证据,结合"
"想法目录中的最新状态作出增量判断。\n\n"
if later_context
else ""
)
return (
"请策展这个刚刚保存的新片段。最近原文按时间从早到晚排列,它们是理解“这个、"
"第二点、继续、刚才”等连续表达的第一依据;不要要求用户显式建立会话。\n\n"
f"<recent_context>{json.dumps(prior_context, ensure_ascii=False)}</recent_context>\n\n"
f"<new_fragment>{json.dumps(new_fragment, ensure_ascii=False)}</new_fragment>\n\n"
f"<later_context>{json.dumps(later_context, ensure_ascii=False)}</later_context>\n\n"
f"{delayed_note}"
f"<idea_catalog>{json.dumps(catalog, ensure_ascii=False)}</idea_catalog>\n\n"
f"当前共有 {len(ideas)} 个已有想法。{catalog_note}\n"
"如果新片段既验证了一个既有想法、又形成了一个新的独立问题,可以给出两个 assessment;"
"不要因为表面主题变化而遗漏它对上一段思想过程的反馈。\n\n"
"最终只输出符合以下 JSON Schema 的 JSON 对象:\n"
f"{json.dumps(output_schema, ensure_ascii=False)}"
)
@function_tool(strict_mode=False) @function_tool(strict_mode=False)
@@ -76,17 +268,31 @@ async def search_ideas(
"""Search existing evolving ideas by a short concept, question, or tension.""" """Search existing evolving ideas by a short concept, question, or tension."""
ctx.context.search_count += 1 ctx.context.search_count += 1
started = time.perf_counter() started = time.perf_counter()
terms = [term.lower() for term in query.split() if term.strip()] if ctx.context.search_count > 2:
ranked: list[tuple[int, dict[str, Any]]] = [] candidates = [
for idea in ctx.context.ideas: {"id": item["id"], "title": item["title"]}
haystack = " ".join( for item in ctx.context.ideas[:8]
str(idea.get(key, "")) ]
for key in ("title", "summary", "position", "tension", "trajectory") ctx.context.db.add_agent_event(
).lower() ctx.context.run_id,
score = sum(haystack.count(term) for term in terms) ctx.context.user_id,
if score or not terms: "tool_search_ideas",
ranked.append((score, idea)) {
ranked.sort(key=lambda item: (item[0], item[1].get("updated_at", "")), reverse=True) "query": query,
"result_count": len(candidates),
"budget_exhausted": True,
"candidates": candidates,
},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps(
{
"notice": "搜索额度已用完。使用已有目录并立即形成最终判断。",
"candidates": candidates,
},
ensure_ascii=False,
)
ranked, fallback = _rank_ideas(ctx.context.ideas, query)
compact = [ compact = [
{ {
"id": idea["id"], "id": idea["id"],
@@ -97,7 +303,7 @@ async def search_ideas(
"position": idea["position"], "position": idea["position"],
"tension": idea["tension"], "tension": idea["tension"],
} }
for _, idea in ranked[:8] for idea in ranked
] ]
ctx.context.db.add_agent_event( ctx.context.db.add_agent_event(
ctx.context.run_id, ctx.context.run_id,
@@ -106,6 +312,7 @@ async def search_ideas(
{ {
"query": query, "query": query,
"result_count": len(compact), "result_count": len(compact),
"fallback_to_recent": fallback,
"candidates": [ "candidates": [
{"id": item["id"], "title": item["title"]} for item in compact {"id": item["id"], "title": item["title"]} for item in compact
], ],
@@ -134,7 +341,39 @@ async def inspect_idea(
{"idea_id": idea_id, "found": False}, {"idea_id": idea_id, "found": False},
duration_ms=round((time.perf_counter() - started) * 1000), duration_ms=round((time.perf_counter() - started) * 1000),
) )
return json.dumps({"error": "idea not found"}) return json.dumps(
{
"error": "idea not found",
"valid_candidates": [
{"id": item["id"], "title": item["title"]}
for item in ctx.context.ideas[:8]
],
},
ensure_ascii=False,
)
if idea_id in ctx.context.inspected_idea_ids:
ctx.context.db.add_agent_event(
ctx.context.run_id,
ctx.context.user_id,
"tool_inspect_idea",
{
"idea_id": idea_id,
"found": True,
"reused": True,
"title": idea["title"],
},
duration_ms=round((time.perf_counter() - started) * 1000),
)
return json.dumps(
{
"notice": (
"这个想法已经检查过。使用上一份结果并立即形成最终判断,"
"不要再次调用工具。"
)
},
ensure_ascii=False,
)
ctx.context.inspected_idea_ids.add(idea_id)
payload = { payload = {
"id": idea["id"], "id": idea["id"],
"title": idea["title"], "title": idea["title"],
@@ -244,22 +483,19 @@ class Curator:
"fragment_characters": len(fragment["content"]), "fragment_characters": len(fragment["content"]),
"idea_count": len(ideas), "idea_count": len(ideas),
"recent_fragment_count": len(recent_fragments), "recent_fragment_count": len(recent_fragments),
"prior_fragment_count": sum(
item["id"] != fragment_id
and item["created_at"] <= fragment["created_at"]
for item in recent_fragments
),
"later_fragment_count": sum(
item["id"] != fragment_id
and item["created_at"] > fragment["created_at"]
for item in recent_fragments
),
}, },
) )
output_schema = CuratorDecision.model_json_schema() prompt = build_curator_prompt(fragment, ideas, recent_fragments)
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) set_tracing_disabled(True)
client = AsyncOpenAI( client = AsyncOpenAI(
@@ -270,63 +506,115 @@ class Curator:
model=self.settings.deepseek_model, model=self.settings.deepseek_model,
openai_client=client, openai_client=client,
) )
model_settings = ModelSettings(
max_tokens=2_400,
reasoning=Reasoning(effort="high"),
extra_body={"thinking": {"type": "enabled"}},
extra_args={"response_format": {"type": "json_object"}},
)
agent: Agent[CuratorContext] = Agent( agent: Agent[CuratorContext] = Agent(
name="私人思想策展者", name="私人思想策展者",
instructions=CURATOR_INSTRUCTIONS, instructions=CURATOR_INSTRUCTIONS,
model=model, model=model,
tools=[search_ideas, inspect_idea], tools=[search_ideas, inspect_idea],
model_settings=ModelSettings( model_settings=model_settings,
max_tokens=2_400, )
reasoning=Reasoning(effort="high"), repair_agent: Agent[CuratorContext] = Agent(
extra_body={"thinking": {"type": "enabled"}}, name="判断收敛器",
extra_args={"response_format": {"type": "json_object"}}, instructions=(
CURATOR_INSTRUCTIONS
+ "\n\n这是收敛回合。不能调用任何工具;只使用输入中已经提供的连续原文、"
"想法目录和 schema,立即给出最终合法 JSON。"
), ),
model=model,
tools=[],
model_settings=model_settings,
) )
decision: CuratorDecision | None = None decision: CuratorDecision | None = None
last_error: Exception | None = None last_error: Exception | None = None
attempt_count = 0 attempt_count = 0
model_rounds = 0 hooks = CuratorRunHooks()
usage = {
"input_tokens": 0,
"output_tokens": 0,
"reasoning_tokens": 0,
"cached_tokens": 0,
}
try: try:
for attempt in range(2): for attempt in range(2):
attempt_count = attempt + 1 attempt_count = attempt + 1
repair = ( is_repair = attempt > 0
""
if attempt == 0
else "\n\n上一次输出未通过结构校验。重新完整判断,并只返回合法 JSON。"
)
self.db.add_agent_event( self.db.add_agent_event(
run_id, run_id,
user_id, user_id,
"attempt_started", "attempt_started",
{"attempt": attempt_count, "is_repair": attempt > 0}, {"attempt": attempt_count, "is_repair": is_repair},
) )
attempt_started = time.perf_counter() attempt_started = time.perf_counter()
rounds_before = context.model_rounds
usage_before = {
"input_tokens": context.input_tokens,
"output_tokens": context.output_tokens,
"reasoning_tokens": context.reasoning_tokens,
"cached_tokens": context.cached_tokens,
}
try:
result = await Runner.run( result = await Runner.run(
agent, repair_agent if is_repair else agent,
input=prompt + repair, input=(
context=context, prompt
max_turns=4, + (
"\n\n上一次工具探索或输出没有收敛。不要再探索,"
"重新完整判断并只返回合法 JSON。"
if is_repair
else ""
) )
),
context=context,
max_turns=2 if is_repair else 6,
hooks=hooks,
)
except MaxTurnsExceeded as exc:
attempt_duration = round( attempt_duration = round(
(time.perf_counter() - attempt_started) * 1000 (time.perf_counter() - attempt_started) * 1000
) )
current_usage = self._result_usage(result) current_usage = {
model_rounds += current_usage.pop("model_rounds") key: getattr(context, key) - value
for key in usage: for key, value in usage_before.items()
usage[key] += current_usage[key] }
self.db.add_agent_event( self.db.add_agent_event(
run_id, run_id,
user_id, user_id,
"model_attempt_completed", "model_attempt_completed",
{ {
"attempt": attempt_count, "attempt": attempt_count,
"model_rounds": len(result.raw_responses), "model_rounds": (
context.model_rounds - rounds_before
),
"completed": False,
"error_type": type(exc).__name__,
**current_usage,
},
duration_ms=attempt_duration,
)
last_error = exc
if not is_repair:
self.db.add_agent_event(
run_id,
user_id,
"convergence_repair_started",
{"reason": "tool_loop_exceeded"},
)
continue
attempt_duration = round(
(time.perf_counter() - attempt_started) * 1000
)
current_usage = {
key: getattr(context, key) - value
for key, value in usage_before.items()
}
self.db.add_agent_event(
run_id,
user_id,
"model_attempt_completed",
{
"attempt": attempt_count,
"model_rounds": context.model_rounds - rounds_before,
"completed": True,
**current_usage, **current_usage,
}, },
duration_ms=attempt_duration, duration_ms=attempt_duration,
@@ -336,9 +624,17 @@ class Curator:
if not isinstance(raw, str): if not isinstance(raw, str):
raise TypeError("curator output was not text") raise TypeError("curator output was not text")
decision = CuratorDecision.model_validate_json(raw) decision = CuratorDecision.model_validate_json(raw)
if ideas and context.search_count == 0: valid_idea_ids = {idea["id"] for idea in ideas}
decision = None unknown_ids = [
raise ValueError("curator skipped required idea search") item.idea_id
for item in decision.assessments
if item.idea_id
and item.idea_id not in valid_idea_ids
]
if unknown_ids:
raise ValueError(
"curator referenced an unknown existing idea"
)
break break
except (ValidationError, ValueError, TypeError) as exc: except (ValidationError, ValueError, TypeError) as exc:
decision = None decision = None
@@ -358,9 +654,16 @@ class Curator:
fragment_id, fragment_id,
attempt_count, attempt_count,
) )
if not is_repair:
self.db.add_agent_event(
run_id,
user_id,
"convergence_repair_started",
{"reason": "output_validation_failed"},
)
if decision is None: if decision is None:
raise RuntimeError( raise RuntimeError(
"curator returned invalid JSON twice" "curator could not produce a valid decision"
) from last_error ) from last_error
assessments = ( assessments = (
[] []
@@ -386,11 +689,14 @@ class Curator:
status="success", status="success",
duration_ms=round((time.perf_counter() - run_started) * 1000), duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count, attempt_count=attempt_count,
model_rounds=model_rounds, model_rounds=context.model_rounds,
tool_calls=context.search_count + context.inspection_count, tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count, search_calls=context.search_count,
inspection_calls=context.inspection_count, inspection_calls=context.inspection_count,
**usage, input_tokens=context.input_tokens,
output_tokens=context.output_tokens,
reasoning_tokens=context.reasoning_tokens,
cached_tokens=context.cached_tokens,
) )
logger.info( logger.info(
"Curator completed fragment %s with %s search and %s inspection calls", "Curator completed fragment %s with %s search and %s inspection calls",
@@ -413,32 +719,14 @@ class Curator:
status="error", status="error",
duration_ms=round((time.perf_counter() - run_started) * 1000), duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count, attempt_count=attempt_count,
model_rounds=model_rounds, model_rounds=context.model_rounds,
tool_calls=context.search_count + context.inspection_count, tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count, search_calls=context.search_count,
inspection_calls=context.inspection_count, inspection_calls=context.inspection_count,
error=exc, error=exc,
**usage, input_tokens=context.input_tokens,
output_tokens=context.output_tokens,
reasoning_tokens=context.reasoning_tokens,
cached_tokens=context.cached_tokens,
) )
raise 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
+159 -6
View File
@@ -103,12 +103,18 @@ class Database:
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE, idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
source_fragment_id TEXT REFERENCES fragments(id) ON DELETE SET NULL, source_fragment_id TEXT REFERENCES fragments(id) ON DELETE SET NULL,
title TEXT,
summary TEXT,
maturity_ai REAL NOT NULL, maturity_ai REAL NOT NULL,
maturity_override REAL,
confidence REAL,
motion TEXT NOT NULL, motion TEXT NOT NULL,
position TEXT NOT NULL, position TEXT NOT NULL,
tension TEXT NOT NULL, tension TEXT NOT NULL,
trajectory TEXT NOT NULL, trajectory TEXT NOT NULL,
possible_moves TEXT NOT NULL, possible_moves TEXT NOT NULL,
change_kind TEXT NOT NULL DEFAULT 'legacy_partial',
fragment_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -185,6 +191,26 @@ class Database:
self._ensure_column(connection, "fragments", "user_id", "TEXT") self._ensure_column(connection, "fragments", "user_id", "TEXT")
self._ensure_column(connection, "ideas", "user_id", "TEXT") self._ensure_column(connection, "ideas", "user_id", "TEXT")
self._ensure_column(connection, "sessions", "user_id", "TEXT") self._ensure_column(connection, "sessions", "user_id", "TEXT")
self._ensure_column(connection, "idea_snapshots", "title", "TEXT")
self._ensure_column(connection, "idea_snapshots", "summary", "TEXT")
self._ensure_column(
connection, "idea_snapshots", "maturity_override", "REAL"
)
self._ensure_column(
connection, "idea_snapshots", "confidence", "REAL"
)
self._ensure_column(
connection,
"idea_snapshots",
"change_kind",
"TEXT NOT NULL DEFAULT 'legacy_partial'",
)
self._ensure_column(
connection,
"idea_snapshots",
"fragment_ids",
"TEXT NOT NULL DEFAULT '[]'",
)
now = utc_now() now = utc_now()
for seed in seeds: for seed in seeds:
@@ -457,8 +483,10 @@ class Database:
).fetchall() ).fetchall()
snapshots = connection.execute( snapshots = connection.execute(
""" """
SELECT s.maturity_ai, s.motion, s.position, s.tension, SELECT s.title, s.summary, s.maturity_ai,
s.trajectory, s.possible_moves, s.created_at s.maturity_override, s.confidence, s.motion,
s.position, s.tension, s.trajectory, s.possible_moves,
s.change_kind, s.fragment_ids, s.created_at
FROM idea_snapshots s FROM idea_snapshots s
JOIN ideas i ON i.id = s.idea_id JOIN ideas i ON i.id = s.idea_id
WHERE s.idea_id = ? AND i.user_id = ? WHERE s.idea_id = ? AND i.user_id = ?
@@ -471,6 +499,7 @@ class Database:
{ {
**dict(row), **dict(row),
"possible_moves": _loads(row["possible_moves"], []), "possible_moves": _loads(row["possible_moves"], []),
"fragment_ids": _loads(row["fragment_ids"], []),
} }
for row in snapshots for row in snapshots
] ]
@@ -588,23 +617,55 @@ class Database:
now, now,
), ),
) )
version_state = connection.execute(
"""
SELECT maturity_override FROM ideas
WHERE id = ? AND user_id = ?
""",
(idea_id, user_id),
).fetchone()
fragment_ids = [
row["fragment_id"]
for row in connection.execute(
"""
SELECT l.fragment_id
FROM idea_fragments l
JOIN fragments f ON f.id = l.fragment_id
WHERE l.idea_id = ? AND f.user_id = ?
ORDER BY f.created_at ASC, f.rowid ASC
""",
(idea_id, user_id),
).fetchall()
]
connection.execute( connection.execute(
""" """
INSERT INTO idea_snapshots ( INSERT INTO idea_snapshots (
id, idea_id, source_fragment_id, maturity_ai, motion, id, idea_id, source_fragment_id, title, summary,
position, tension, trajectory, possible_moves, created_at maturity_ai, maturity_override, confidence, motion,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) position, tension, trajectory, possible_moves,
change_kind, fragment_ids, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
str(uuid.uuid4()), str(uuid.uuid4()),
idea_id, idea_id,
fragment_id, fragment_id,
assessment["title"],
assessment["summary"],
assessment["maturity"], assessment["maturity"],
(
version_state["maturity_override"]
if version_state
else None
),
assessment["confidence"],
assessment["motion"], assessment["motion"],
assessment["position"], assessment["position"],
assessment["tension"], assessment["tension"],
assessment["trajectory"], assessment["trajectory"],
_json(assessment["possible_moves"]), _json(assessment["possible_moves"]),
"analysis",
_json(fragment_ids),
now, now,
), ),
) )
@@ -622,16 +683,66 @@ class Database:
def update_idea_override( def update_idea_override(
self, user_id: str, idea_id: str, maturity_override: float | None self, user_id: str, idea_id: str, maturity_override: float | None
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
now = utc_now()
with self.connect() as connection: with self.connect() as connection:
result = connection.execute( result = connection.execute(
""" """
UPDATE ideas SET maturity_override = ?, updated_at = ? UPDATE ideas SET maturity_override = ?, updated_at = ?
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
""", """,
(maturity_override, utc_now(), idea_id, user_id), (maturity_override, now, idea_id, user_id),
) )
if result.rowcount == 0: if result.rowcount == 0:
return None return None
idea = connection.execute(
"""
SELECT title, summary, maturity_ai, maturity_override,
confidence, motion, position, tension, trajectory,
possible_moves
FROM ideas WHERE id = ? AND user_id = ?
""",
(idea_id, user_id),
).fetchone()
fragment_ids = [
row["fragment_id"]
for row in connection.execute(
"""
SELECT l.fragment_id
FROM idea_fragments l
JOIN fragments f ON f.id = l.fragment_id
WHERE l.idea_id = ? AND f.user_id = ?
ORDER BY f.created_at ASC, f.rowid ASC
""",
(idea_id, user_id),
).fetchall()
]
connection.execute(
"""
INSERT INTO idea_snapshots (
id, idea_id, source_fragment_id, title, summary,
maturity_ai, maturity_override, confidence, motion,
position, tension, trajectory, possible_moves,
change_kind, fragment_ids, created_at
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
idea_id,
idea["title"],
idea["summary"],
idea["maturity_ai"],
idea["maturity_override"],
idea["confidence"],
idea["motion"],
idea["position"],
idea["tension"],
idea["trajectory"],
idea["possible_moves"],
"manual_calibration",
_json(fragment_ids),
now,
),
)
return self.get_idea(user_id, idea_id) return self.get_idea(user_id, idea_id)
@staticmethod @staticmethod
@@ -928,6 +1039,48 @@ class Database:
).hexdigest()[:16] ).hexdigest()[:16]
return run return run
def prepare_agent_retry(self, run_id: str) -> dict[str, str] | None:
with self.connect() as connection:
row = connection.execute(
"""
SELECT r.user_id, r.fragment_id, r.status AS run_status,
f.analysis_status
FROM agent_runs r
JOIN fragments f ON f.id = r.fragment_id
WHERE r.id = ? AND f.user_id = r.user_id
""",
(run_id,),
).fetchone()
if (
not row
or row["run_status"] != "error"
or row["analysis_status"] != "error"
):
return None
newer = connection.execute(
"""
SELECT 1 FROM agent_runs
WHERE fragment_id = ? AND user_id = ?
AND id != ? AND status IN ('running', 'success')
LIMIT 1
""",
(row["fragment_id"], row["user_id"], run_id),
).fetchone()
if newer:
return None
connection.execute(
"""
UPDATE fragments
SET analysis_status = 'pending', analysis_error = NULL
WHERE id = ? AND user_id = ?
""",
(row["fragment_id"], row["user_id"]),
)
return {
"user_id": row["user_id"],
"fragment_id": row["fragment_id"],
}
def list_agent_events(self, run_id: str) -> list[dict[str, Any]]: def list_agent_events(self, run_id: str) -> list[dict[str, Any]]:
with self.connect() as connection: with self.connect() as connection:
rows = connection.execute( rows = connection.execute(
+28 -1
View File
@@ -276,7 +276,12 @@ def _redacted_event(event: dict[str, Any]) -> dict[str, Any]:
safe = payload safe = payload
elif event_type == "context_loaded": elif event_type == "context_loaded":
safe = payload safe = payload
elif event_type in {"attempt_started", "model_attempt_completed"}: elif event_type in {
"attempt_started",
"model_round_completed",
"model_attempt_completed",
"convergence_repair_started",
}:
safe = payload safe = payload
elif event_type == "tool_search_ideas": elif event_type == "tool_search_ideas":
safe = {"result_count": payload.get("result_count", 0)} safe = {"result_count": payload.get("result_count", 0)}
@@ -341,6 +346,28 @@ async def admin_run_detail(
} }
@app.post(
"/api/admin/runs/{run_id}/retry",
status_code=status.HTTP_202_ACCEPTED,
)
async def retry_admin_run(
run_id: str, admin: AuthUser = Depends(administrator)
):
retry = db.prepare_agent_retry(run_id)
if not retry:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="这次分析现在不能重新运行",
)
curator.enqueue(retry["fragment_id"])
db.add_audit_event(
"agent_run_requeued",
admin.id,
{"run_id": run_id, "owner_user_id": retry["user_id"]},
)
return {"queued": True}
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
assets_dir = static_dir / "assets" assets_dir = static_dir / "assets"
if assets_dir.is_dir(): if assets_dir.is_dir():
+73 -4
View File
@@ -363,12 +363,41 @@ function IdeaDetail({
</ul> </ul>
</section> </section>
{idea.snapshots && idea.snapshots.length > 1 && (
<details className="idea-history">
<summary>这个想法走过的路</summary>
<div>
{[...idea.snapshots].reverse().map((snapshot, index) => (
<article key={`${snapshot.created_at}-${index}`}>
<time>
{new Intl.DateTimeFormat("zh-CN", {
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(snapshot.created_at))}
</time>
<strong>
{snapshot.change_kind === "manual_calibration"
? "你重新放置了它"
: snapshot.motion}
</strong>
<p>{snapshot.trajectory}</p>
</article>
))}
</div>
</details>
)}
{idea.fragments && idea.fragments.length > 0 && ( {idea.fragments && idea.fragments.length > 0 && (
<details className="evidence"> <details className="evidence">
<summary>构成这个想法的片段</summary> <summary>原始脉络</summary>
<div> <div>
{idea.fragments.map((fragment) => ( {idea.fragments.map((fragment) => (
<blockquote key={fragment.id}>{fragment.content}</blockquote> <article key={fragment.id}>
<time>{dayLabel(fragment.created_at)}</time>
<blockquote>{fragment.content}</blockquote>
</article>
))} ))}
</div> </div>
</details> </details>
@@ -454,6 +483,8 @@ const eventNames: Record<string, string> = {
tool_search_ideas: "搜索已有想法", tool_search_ideas: "搜索已有想法",
tool_inspect_idea: "查看想法轨迹", tool_inspect_idea: "查看想法轨迹",
model_attempt_completed: "模型返回", model_attempt_completed: "模型返回",
model_round_completed: "完成一轮判断",
convergence_repair_started: "转入收敛判断",
validation_failed: "结构校验未通过", validation_failed: "结构校验未通过",
decision_committed: "提交分析结果", decision_committed: "提交分析结果",
run_failed: "运行失败", run_failed: "运行失败",
@@ -466,6 +497,7 @@ const auditNames: Record<string, string> = {
logout: "退出登录", logout: "退出登录",
fragment_created: "保存新片段", fragment_created: "保存新片段",
idea_position_calibrated: "人工校准位势", idea_position_calibrated: "人工校准位势",
agent_run_requeued: "重新分析",
debug_sharing_changed: "调整调试共享", debug_sharing_changed: "调整调试共享",
}; };
@@ -510,6 +542,18 @@ function EventPayload({ event }: { event: AgentEvent }) {
</p> </p>
); );
} }
if (event.event_type === "model_round_completed") {
return (
<p>
输入 {compactNumber(Number(payload.input_tokens ?? 0))} · 输出{" "}
{compactNumber(Number(payload.output_tokens ?? 0))} · 推理{" "}
{compactNumber(Number(payload.reasoning_tokens ?? 0))}
</p>
);
}
if (event.event_type === "convergence_repair_started") {
return <p>工具探索没有及时收束,系统改用已有上下文直接完成判断。</p>;
}
if (event.event_type === "validation_failed") { if (event.event_type === "validation_failed") {
return ( return (
<p> <p>
@@ -561,11 +605,24 @@ function RunDetail({
onClose: () => void; onClose: () => void;
}) { }) {
const [detail, setDetail] = useState<AgentRunDetail | null>(null); const [detail, setDetail] = useState<AgentRunDetail | null>(null);
const [retrying, setRetrying] = useState(false);
const [requeued, setRequeued] = useState(false);
useEffect(() => { useEffect(() => {
api.adminRun(runId).then(setDetail); api.adminRun(runId).then(setDetail);
}, [runId]); }, [runId]);
async function retry() {
if (retrying || requeued) return;
setRetrying(true);
try {
await api.retryAdminRun(runId);
setRequeued(true);
} finally {
setRetrying(false);
}
}
return ( return (
<div className="sheet-backdrop" role="presentation" onMouseDown={onClose}> <div className="sheet-backdrop" role="presentation" onMouseDown={onClose}>
<article <article
@@ -614,8 +671,7 @@ function RunDetail({
<blockquote>{detail.fragment.content}</blockquote> <blockquote>{detail.fragment.content}</blockquote>
) : ( ) : (
<div className="redacted-content"> <div className="redacted-content">
内容已隔离 · {detail.fragment.content_length} 字 · 指纹{" "} 内容已隔离 · {detail.fragment.content_length} 字
{detail.fragment.content_sha256}
</div> </div>
)} )}
<small>{detail.privacy.reason}</small> <small>{detail.privacy.reason}</small>
@@ -636,6 +692,19 @@ function RunDetail({
</article> </article>
))} ))}
</section> </section>
{detail.run.status === "error" && (
<button
className="retry-run"
onClick={() => void retry()}
disabled={retrying || requeued}
>
{requeued
? "已经重新交给策展者"
: retrying
? "正在重新交付…"
: "重新整理这条记录"}
</button>
)}
<footer className="reasoning-boundary"> <footer className="reasoning-boundary">
不保存模型隐藏思维链;这里展示的是输入、工具行为、用量、校验与最终结构化产物。 不保存模型隐藏思维链;这里展示的是输入、工具行为、用量、校验与最终结构化产物。
</footer> </footer>
+9
View File
@@ -12,12 +12,17 @@ export type User = {
}; };
export type Snapshot = { export type Snapshot = {
title: string | null;
summary: string | null;
maturity_ai: number; maturity_ai: number;
maturity_override: number | null;
confidence: number | null;
motion: string; motion: string;
position: string; position: string;
tension: string; tension: string;
trajectory: string; trajectory: string;
possible_moves: string[]; possible_moves: string[];
change_kind: "analysis" | "manual_calibration" | "legacy_partial";
created_at: string; created_at: string;
}; };
@@ -226,6 +231,10 @@ export const api = {
request<{ items: AgentRun[] }>(`/api/admin/runs?limit=${limit}`), request<{ items: AgentRun[] }>(`/api/admin/runs?limit=${limit}`),
adminRun: (id: string) => adminRun: (id: string) =>
request<AgentRunDetail>(`/api/admin/runs/${id}`), request<AgentRunDetail>(`/api/admin/runs/${id}`),
retryAdminRun: (id: string) =>
request<{ queued: boolean }>(`/api/admin/runs/${id}/retry`, {
method: "POST",
}),
adminAudit: (limit = 80) => adminAudit: (limit = 80) =>
request<{ items: AuditEvent[] }>(`/api/admin/audit?limit=${limit}`), request<{ items: AuditEvent[] }>(`/api/admin/audit?limit=${limit}`),
}; };
+57 -3
View File
@@ -885,12 +885,18 @@ summary:focus-visible {
color: var(--warm); color: var(--warm);
} }
.idea-history,
.evidence { .evidence {
margin-top: 50px; margin-top: 50px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
color: var(--muted); color: var(--muted);
} }
.idea-history + .evidence {
margin-top: 0;
}
.idea-history summary,
.evidence summary { .evidence summary {
padding: 18px 0; padding: 18px 0;
cursor: pointer; cursor: pointer;
@@ -898,12 +904,44 @@ summary:focus-visible {
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.idea-history article {
display: grid;
grid-template-columns: 92px 1fr;
gap: 4px 14px;
padding: 14px 0;
border-bottom: 1px solid rgba(23, 37, 31, 0.07);
}
.idea-history time,
.evidence time {
color: #92958f;
font-size: 9px;
}
.idea-history strong {
color: var(--ink);
font-size: 12px;
font-weight: 500;
}
.idea-history p {
grid-column: 2;
margin: 2px 0 0;
color: #626b66;
font-size: 11px;
line-height: 1.7;
}
.evidence article {
padding: 13px 0;
border-bottom: 1px solid rgba(23, 37, 31, 0.07);
}
.evidence blockquote { .evidence blockquote {
margin: 0; margin: 5px 0 0;
padding: 15px 0; padding: 0;
color: #4e5752; color: #4e5752;
font: 400 14px/1.85 "Songti SC", "STSong", serif; font: 400 14px/1.85 "Songti SC", "STSong", serif;
border-bottom: 1px solid rgba(23, 37, 31, 0.07);
} }
.admin-view { .admin-view {
@@ -1411,6 +1449,22 @@ summary:focus-visible {
margin: 0 !important; margin: 0 !important;
} }
.retry-run {
margin-top: 28px;
padding: 9px 13px;
border: 1px solid rgba(153, 104, 72, 0.28);
border-radius: 4px;
background: transparent;
color: var(--warm);
cursor: pointer;
font-size: 10px;
}
.retry-run:disabled {
cursor: default;
opacity: 0.6;
}
.reasoning-boundary { .reasoning-boundary {
margin-top: 45px; margin-top: 45px;
padding-top: 16px; padding-top: 16px;
+49 -6
View File
@@ -82,9 +82,10 @@ def test_login_isolation_roles_and_debug_privacy(
headers=WRITE_HEADERS, headers=WRITE_HEADERS,
) )
assert member_fragment.status_code == 201 assert member_fragment.status_code == 201
assert [item["content"] for item in client.get("/api/fragments").json()["items"]] == [ assert [
"用户二的记录" item["content"]
] for item in client.get("/api/fragments").json()["items"]
] == ["用户二的记录"]
assert client.get("/api/admin/overview").status_code == 403 assert client.get("/api/admin/overview").status_code == 403
run_id = database.start_agent_run( run_id = database.start_agent_run(
@@ -105,9 +106,10 @@ def test_login_isolation_roles_and_debug_privacy(
) )
client.cookies.set(COOKIE_NAME, admin_cookie) client.cookies.set(COOKIE_NAME, admin_cookie)
assert [item["content"] for item in client.get("/api/fragments").json()["items"]] == [ assert [
"管理员的记录" item["content"]
] for item in client.get("/api/fragments").json()["items"]
] == ["管理员的记录"]
redacted = client.get(f"/api/admin/runs/{run_id}").json() redacted = client.get(f"/api/admin/runs/{run_id}").json()
assert redacted["fragment"]["content_visible"] is False assert redacted["fragment"]["content_visible"] is False
assert redacted["events"][-1]["payload"] == { assert redacted["events"][-1]["payload"] == {
@@ -127,3 +129,44 @@ def test_login_isolation_roles_and_debug_privacy(
visible = client.get(f"/api/admin/runs/{run_id}").json() visible = client.get(f"/api/admin/runs/{run_id}").json()
assert visible["fragment"]["content_visible"] is True assert visible["fragment"]["content_visible"] is True
assert visible["fragment"]["content"] == "用户二的记录" assert visible["fragment"]["content"] == "用户二的记录"
retry_error = RuntimeError("tool loop exceeded")
database.finish_agent_run(
run_id,
status="error",
duration_ms=100,
attempt_count=1,
model_rounds=4,
tool_calls=4,
search_calls=3,
inspection_calls=1,
input_tokens=400,
output_tokens=80,
reasoning_tokens=60,
cached_tokens=0,
error=retry_error,
)
database.set_fragment_status(
member_fragment.json()["id"], "error", str(retry_error)
)
queued: list[str] = []
monkeypatch.setattr(main.curator, "enqueue", queued.append)
client.cookies.set(COOKIE_NAME, member_cookie)
assert client.post(
f"/api/admin/runs/{run_id}/retry",
headers=WRITE_HEADERS,
).status_code == 403
client.cookies.set(COOKIE_NAME, admin_cookie)
retried = client.post(
f"/api/admin/runs/{run_id}/retry",
headers=WRITE_HEADERS,
)
assert retried.status_code == 202
assert retried.json() == {"queued": True}
assert queued == [member_fragment.json()["id"]]
assert client.post(
f"/api/admin/runs/{run_id}/retry",
headers=WRITE_HEADERS,
).status_code == 409
+66
View File
@@ -0,0 +1,66 @@
import asyncio
from pathlib import Path
from types import SimpleNamespace
from agents.exceptions import MaxTurnsExceeded
from app.config import Settings, UserSeed
from app.curator import Curator, Runner
from app.db import Database
def test_tool_loop_uses_no_tool_convergence_repair(
tmp_path: Path, monkeypatch
):
admin = UserSeed(
id="admin",
label="管理员",
role="admin",
access_key_hash="unused",
)
database = Database(tmp_path / "repair.sqlite3")
database.initialize([admin])
fragment = database.create_fragment("admin", "一个需要收敛的念头")
settings = Settings(
data_dir=tmp_path,
database_path=tmp_path / "repair.sqlite3",
users=(admin,),
session_secret="unused",
deepseek_api_key="test-key",
deepseek_base_url="https://api.deepseek.com",
deepseek_model="deepseek-v4-pro",
cookie_secure=False,
auth_disabled=True,
)
calls = 0
async def fake_run(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise MaxTurnsExceeded("Max turns (6) exceeded")
assert kwargs["max_turns"] == 2
assert args[0].tools == []
return SimpleNamespace(
final_output=(
'{"standalone":true,"reasoning_note":"暂时独立保留",'
'"assessments":[]}'
)
)
monkeypatch.setattr(Runner, "run", fake_run)
curator = Curator(database, settings)
asyncio.run(curator.analyze(fragment["id"]))
assert calls == 2
assert database.get_fragment(fragment["id"], "admin")[
"analysis_status"
] == "done"
run = database.list_agent_runs(1)[0]
events = database.list_agent_events(run["id"])
assert run["status"] == "success"
assert run["attempt_count"] == 2
assert "convergence_repair_started" in [
event["event_type"] for event in events
]
+138
View File
@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from app.config import UserSeed from app.config import UserSeed
from app.curator import _rank_ideas, build_curator_prompt
from app.db import Database from app.db import Database
@@ -97,6 +98,105 @@ def test_apply_assessment_builds_trajectory(tmp_path: Path):
assert idea["maturity"] == 46 assert idea["maturity"] == 46
def test_curator_prompt_contains_recent_continuity_and_idea_catalog(
tmp_path: Path,
):
db = database(tmp_path)
first = db.create_fragment(
ADMIN.id,
"我先让 AI 完成综述,接下来会抽取几个感兴趣的地方。",
)
db.apply_assessments(ADMIN.id, first["id"], [assessment()])
current = db.create_fragment(
ADMIN.id,
"我识别出了两个新的认知,第一点让我耳目一新。",
)
ideas, fragments = db.curator_context(ADMIN.id)
prompt = build_curator_prompt(current, ideas, fragments)
assert "我先让 AI 完成综述" in prompt
assert "我识别出了两个新的认知" in prompt
assert "无分类的笔记入口" in prompt
assert "不要要求用户显式建立会话" in prompt
def test_delayed_reanalysis_keeps_later_input_out_of_prior_context(
tmp_path: Path,
):
db = database(tmp_path)
previous = db.create_fragment(ADMIN.id, "当时真正的上文")
target = db.create_fragment(ADMIN.id, "后来需要重新整理的这一条")
future = db.create_fragment(ADMIN.id, "这句话发生在目标片段之后")
timeline = (
(previous, "2026-07-28T10:00:00.000+00:00"),
(target, "2026-07-28T10:10:00.000+00:00"),
(future, "2026-07-28T10:20:00.000+00:00"),
)
with db.connect() as connection:
for fragment, created_at in timeline:
fragment["created_at"] = created_at
connection.execute(
"UPDATE fragments SET created_at = ? WHERE id = ?",
(created_at, fragment["id"]),
)
ideas, fragments = db.curator_context(ADMIN.id)
prompt = build_curator_prompt(target, ideas, fragments)
recent_section = prompt.split("<recent_context>", 1)[1].split(
"</recent_context>", 1
)[0]
later_section = prompt.split("<later_context>", 1)[1].split(
"</later_context>", 1
)[0]
assert previous["content"] in recent_section
assert future["content"] not in recent_section
assert future["content"] in later_section
assert "延迟重整" in prompt
def test_idea_search_falls_back_to_recent_catalog():
idea = {
"id": "idea-one",
"title": "先综述再深入",
"summary": "一种理解策略",
"position": "正在实践",
"tension": "可靠性",
"trajectory": "从原则走向实践",
"updated_at": "2026-07-28",
"recent_fragments": [],
}
candidates, fallback = _rank_ideas(
[idea], "agent architecture long task reliability"
)
assert fallback is True
assert candidates == [idea]
def test_idea_versions_preserve_complete_state(tmp_path: Path):
db = database(tmp_path)
first = db.create_fragment(ADMIN.id, "第一条原始证据")
db.apply_assessments(ADMIN.id, first["id"], [assessment(maturity=30)])
idea = db.list_ideas(ADMIN.id)[0]
first_version = db.get_idea(ADMIN.id, idea["id"])["snapshots"][-1]
assert first_version["title"] == "无分类的笔记入口"
assert first_version["summary"] == "先保留念头,再让结构在后台形成。"
assert first_version["confidence"] == 0.76
assert first_version["change_kind"] == "analysis"
assert first_version["fragment_ids"] == [first["id"]]
db.update_idea_override(ADMIN.id, idea["id"], 61)
versions = db.get_idea(ADMIN.id, idea["id"])["snapshots"]
assert versions[-1]["change_kind"] == "manual_calibration"
assert versions[-1]["maturity_override"] == 61
assert versions[-1]["fragment_ids"] == [first["id"]]
def test_manual_position_is_authoritative(tmp_path: Path): def test_manual_position_is_authoritative(tmp_path: Path):
db = database(tmp_path) db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "一个想法") fragment = db.create_fragment(ADMIN.id, "一个想法")
@@ -188,3 +288,41 @@ def test_agent_run_records_metrics_and_events(tmp_path: Path):
"tool_search_ideas", "tool_search_ideas",
] ]
assert overview["last_24h"]["success_rate"] == 100 assert overview["last_24h"]["success_rate"] == 100
def test_failed_agent_run_can_be_safely_requeued(tmp_path: Path):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "需要重新整理")
run_id = db.start_agent_run(
ADMIN.id,
fragment["id"],
"deepseek-v4-pro",
"prompt.v2",
"high",
)
error = RuntimeError("temporary failure")
db.finish_agent_run(
run_id,
status="error",
duration_ms=100,
attempt_count=1,
model_rounds=1,
tool_calls=0,
search_calls=0,
inspection_calls=0,
input_tokens=10,
output_tokens=2,
reasoning_tokens=1,
cached_tokens=0,
error=error,
)
db.set_fragment_status(fragment["id"], "error", str(error))
retry = db.prepare_agent_retry(run_id)
assert retry == {
"user_id": ADMIN.id,
"fragment_id": fragment["id"],
}
assert db.get_fragment(fragment["id"], ADMIN.id)["analysis_status"] == "pending"
assert db.prepare_agent_retry(run_id) is None