From af7bb6826863f057f799b605ab28aee289084159 Mon Sep 17 00:00:00 2001
From: wuyang <5700876+banisherwy@user.noreply.gitee.com>
Date: Tue, 28 Jul 2026 17:31:37 +0800
Subject: [PATCH] feat: give the curator continuous context and durable idea
history
---
README.md | 16 +-
app/curator.py | 466 ++++++++++++++++++++++++++++++++--------
app/db.py | 165 +++++++++++++-
app/main.py | 29 ++-
frontend/src/App.tsx | 77 ++++++-
frontend/src/api.ts | 9 +
frontend/src/styles.css | 60 +++++-
tests/test_api.py | 55 ++++-
tests/test_curator.py | 66 ++++++
tests/test_db.py | 138 ++++++++++++
10 files changed, 967 insertions(+), 114 deletions(-)
create mode 100644 tests/test_curator.py
diff --git a/README.md b/README.md
index 9466c9e..7bd8cb1 100644
--- a/README.md
+++ b/README.md
@@ -3,21 +3,23 @@
一个“先保存,后理解”的私人 AI 笔记本。
记录页只呈现用户写下的原文,输入历史像对话一样始终可回看。后台的单一策展
-Agent 使用 DeepSeek 的思考模式,按需搜索已有想法与轨迹,再给出结构化的“想法位势”
-提案。Agent 不能直接写数据库;应用只在 schema 和引用完整性校验通过后,用事务提交
-更新。
+Agent 使用 DeepSeek 的思考模式,结合最近的连续原文与精简想法目录,必要时再查看
+已有想法的完整轨迹,最后给出结构化的“想法位势”提案。Agent 不能直接写数据库;
+应用只在 schema 和引用完整性校验通过后,用事务提交更新。
## 设计边界
- 原始片段先持久化,AI 失败不影响记录。
- 捕获流不显示保存状态、AI 标签、关联或建议。
- 不要求用户先建页面、取标题、选分类或填写日期。
+- 不要求用户建立会话;系统在后台保留滚动的连续语境。
- 成熟度是可回退的连续位势,不是阶段、成绩或任务完成百分比。
- 运动、张力和可能动作由模型结合上下文动态生成,不使用固定关卡。
- 用户手动校准的位势优先展示,AI 估计仍独立保留。
- 每个用户的记录、想法、Session 与 Agent 上下文都以 `user_id` 在 SQL 层隔离。
- 管理员能看运行元数据;其他用户的原文与 AI 产物默认脱敏,只有用户主动开启
“调试共享”后才可见。
+- 每次想法更新和人工校准都会保存完整版本;原始片段始终是不可替代的证据层。
- 管理后台保存模型轮次、工具调用、耗时、token、错误和结构化决策产物,但不保存或
暴露模型隐藏思维链。
@@ -33,11 +35,14 @@ React/Vite ── cookie session ── FastAPI ── SQLite
核心数据流是:
1. `POST /api/fragments` 先提交原文并立即返回;
-2. 后台 Agent 读取当前用户自己的想法上下文;
-3. Agent 通过 `search_ideas`、`inspect_idea` 工具选择相关材料;
+2. 后台 Agent 读取当前用户最近的连续输入和精简想法目录;
+3. Agent 通过 `search_ideas`、`inspect_idea` 工具补充相关材料;
4. 应用校验结构化结果后,以事务写入想法、轨迹和片段关联;
5. 每次运行同时形成 `agent_runs`、`agent_events`,供管理后台聚合分析。
+工具探索超过预算或输出结构无效时,系统会进入一次没有工具的收敛回合;失败运行也可以由
+管理员重新排队。模型用量通过逐回合钩子采集,因此异常退出不会被误记为零。
+
## 本地运行
要求 Python 3.12+、Node.js 22+。
@@ -112,6 +117,7 @@ Session 会归属给配置清单中的第一位管理员;不会把旧数据复
- `GET /api/admin/users`:用户空间、数据量与调试共享状态;
- `GET /api/admin/runs?limit=80`:Agent 运行列表;
- `GET /api/admin/runs/{run_id}`:一次运行的事件、工具、token 与结构化产物;
+- `POST /api/admin/runs/{run_id}/retry`:重新排队一次仍处于错误状态的分析;
- `GET /api/admin/audit?limit=120`:登录、启动、记录、校准等应用审计事件。
普通用户可调用 `PATCH /api/account/debug-sharing` 控制自己的调试内容是否向管理员
diff --git a/app/curator.py b/app/curator.py
index d2c7979..10acfad 100644
--- a/app/curator.py
+++ b/app/curator.py
@@ -3,19 +3,23 @@ from __future__ import annotations
import asyncio
import json
import logging
+import re
import time
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any
from agents import (
Agent,
ModelSettings,
OpenAIChatCompletionsModel,
+ RunHooks,
RunContextWrapper,
Runner,
function_tool,
set_tracing_disabled,
)
+from agents.exceptions import MaxTurnsExceeded
+from agents.items import ModelResponse
from openai import AsyncOpenAI
from openai.types.shared import Reasoning
from pydantic import ValidationError
@@ -25,7 +29,9 @@ from .db import Database
from .schemas import CuratorDecision
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 = """
@@ -52,8 +58,13 @@ motion 是你对当下运动的自然语言压缩,例如“向现实探去”
一个片段最多关联两个想法。若现在还不值得形成或归入想法,让它 standalone,原文仍会保留。
新建想法时 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()
@@ -67,6 +78,187 @@ class CuratorContext:
recent_fragments: list[dict[str, Any]]
search_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"{json.dumps(prior_context, ensure_ascii=False)}\n\n"
+ f"{json.dumps(new_fragment, ensure_ascii=False)}\n\n"
+ f"{json.dumps(later_context, ensure_ascii=False)}\n\n"
+ f"{delayed_note}"
+ f"{json.dumps(catalog, ensure_ascii=False)}\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)
@@ -76,17 +268,31 @@ async def search_ideas(
"""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)
+ if ctx.context.search_count > 2:
+ candidates = [
+ {"id": item["id"], "title": item["title"]}
+ for item in ctx.context.ideas[:8]
+ ]
+ ctx.context.db.add_agent_event(
+ ctx.context.run_id,
+ ctx.context.user_id,
+ "tool_search_ideas",
+ {
+ "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 = [
{
"id": idea["id"],
@@ -97,7 +303,7 @@ async def search_ideas(
"position": idea["position"],
"tension": idea["tension"],
}
- for _, idea in ranked[:8]
+ for idea in ranked
]
ctx.context.db.add_agent_event(
ctx.context.run_id,
@@ -106,6 +312,7 @@ async def search_ideas(
{
"query": query,
"result_count": len(compact),
+ "fallback_to_recent": fallback,
"candidates": [
{"id": item["id"], "title": item["title"]} for item in compact
],
@@ -134,7 +341,39 @@ async def inspect_idea(
{"idea_id": idea_id, "found": False},
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 = {
"id": idea["id"],
"title": idea["title"],
@@ -244,22 +483,19 @@ class Curator:
"fragment_characters": len(fragment["content"]),
"idea_count": len(ideas),
"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()
- 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)}"
- )
+ prompt = build_curator_prompt(fragment, ideas, recent_fragments)
set_tracing_disabled(True)
client = AsyncOpenAI(
@@ -270,63 +506,115 @@ class Curator:
model=self.settings.deepseek_model,
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(
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"}},
+ model_settings=model_settings,
+ )
+ repair_agent: Agent[CuratorContext] = Agent(
+ name="判断收敛器",
+ instructions=(
+ CURATOR_INSTRUCTIONS
+ + "\n\n这是收敛回合。不能调用任何工具;只使用输入中已经提供的连续原文、"
+ "想法目录和 schema,立即给出最终合法 JSON。"
),
+ model=model,
+ tools=[],
+ model_settings=model_settings,
)
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,
- }
+ hooks = CuratorRunHooks()
try:
for attempt in range(2):
attempt_count = attempt + 1
- repair = (
- ""
- if attempt == 0
- else "\n\n上一次输出未通过结构校验。重新完整判断,并只返回合法 JSON。"
- )
+ is_repair = attempt > 0
self.db.add_agent_event(
run_id,
user_id,
"attempt_started",
- {"attempt": attempt_count, "is_repair": attempt > 0},
+ {"attempt": attempt_count, "is_repair": is_repair},
)
attempt_started = time.perf_counter()
- result = await Runner.run(
- agent,
- input=prompt + repair,
- context=context,
- max_turns=4,
- )
+ 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(
+ repair_agent if is_repair else agent,
+ input=(
+ prompt
+ + (
+ "\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(
+ (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": 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 = self._result_usage(result)
- model_rounds += current_usage.pop("model_rounds")
- for key in usage:
- usage[key] += current_usage[key]
+ 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": len(result.raw_responses),
+ "model_rounds": context.model_rounds - rounds_before,
+ "completed": True,
**current_usage,
},
duration_ms=attempt_duration,
@@ -336,9 +624,17 @@ class Curator:
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")
+ valid_idea_ids = {idea["id"] for idea in ideas}
+ unknown_ids = [
+ 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
except (ValidationError, ValueError, TypeError) as exc:
decision = None
@@ -358,9 +654,16 @@ class Curator:
fragment_id,
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:
raise RuntimeError(
- "curator returned invalid JSON twice"
+ "curator could not produce a valid decision"
) from last_error
assessments = (
[]
@@ -386,11 +689,14 @@ class Curator:
status="success",
duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count,
- model_rounds=model_rounds,
+ model_rounds=context.model_rounds,
tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_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(
"Curator completed fragment %s with %s search and %s inspection calls",
@@ -413,32 +719,14 @@ class Curator:
status="error",
duration_ms=round((time.perf_counter() - run_started) * 1000),
attempt_count=attempt_count,
- model_rounds=model_rounds,
+ model_rounds=context.model_rounds,
tool_calls=context.search_count + context.inspection_count,
search_calls=context.search_count,
inspection_calls=context.inspection_count,
error=exc,
- **usage,
+ input_tokens=context.input_tokens,
+ output_tokens=context.output_tokens,
+ reasoning_tokens=context.reasoning_tokens,
+ cached_tokens=context.cached_tokens,
)
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
diff --git a/app/db.py b/app/db.py
index c37f585..d10d7b5 100644
--- a/app/db.py
+++ b/app/db.py
@@ -103,12 +103,18 @@ class Database:
id TEXT PRIMARY KEY,
idea_id TEXT NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
source_fragment_id TEXT REFERENCES fragments(id) ON DELETE SET NULL,
+ title TEXT,
+ summary TEXT,
maturity_ai REAL NOT NULL,
+ maturity_override REAL,
+ confidence REAL,
motion TEXT NOT NULL,
position TEXT NOT NULL,
tension TEXT NOT NULL,
trajectory 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
);
@@ -185,6 +191,26 @@ class Database:
self._ensure_column(connection, "fragments", "user_id", "TEXT")
self._ensure_column(connection, "ideas", "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()
for seed in seeds:
@@ -457,8 +483,10 @@ class Database:
).fetchall()
snapshots = connection.execute(
"""
- SELECT s.maturity_ai, s.motion, s.position, s.tension,
- s.trajectory, s.possible_moves, s.created_at
+ SELECT s.title, s.summary, s.maturity_ai,
+ 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
JOIN ideas i ON i.id = s.idea_id
WHERE s.idea_id = ? AND i.user_id = ?
@@ -471,6 +499,7 @@ class Database:
{
**dict(row),
"possible_moves": _loads(row["possible_moves"], []),
+ "fragment_ids": _loads(row["fragment_ids"], []),
}
for row in snapshots
]
@@ -588,23 +617,55 @@ class Database:
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(
"""
INSERT INTO idea_snapshots (
- id, idea_id, source_fragment_id, maturity_ai, motion,
- position, tension, trajectory, possible_moves, created_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
idea_id,
fragment_id,
+ assessment["title"],
+ assessment["summary"],
assessment["maturity"],
+ (
+ version_state["maturity_override"]
+ if version_state
+ else None
+ ),
+ assessment["confidence"],
assessment["motion"],
assessment["position"],
assessment["tension"],
assessment["trajectory"],
_json(assessment["possible_moves"]),
+ "analysis",
+ _json(fragment_ids),
now,
),
)
@@ -622,16 +683,66 @@ class Database:
def update_idea_override(
self, user_id: str, idea_id: str, maturity_override: float | None
) -> dict[str, Any] | None:
+ now = utc_now()
with self.connect() as connection:
result = connection.execute(
"""
UPDATE ideas SET maturity_override = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
- (maturity_override, utc_now(), idea_id, user_id),
+ (maturity_override, now, idea_id, user_id),
)
if result.rowcount == 0:
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)
@staticmethod
@@ -928,6 +1039,48 @@ class Database:
).hexdigest()[:16]
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]]:
with self.connect() as connection:
rows = connection.execute(
diff --git a/app/main.py b/app/main.py
index 7fc505f..79c5fa7 100644
--- a/app/main.py
+++ b/app/main.py
@@ -276,7 +276,12 @@ def _redacted_event(event: dict[str, Any]) -> dict[str, Any]:
safe = payload
elif event_type == "context_loaded":
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
elif event_type == "tool_search_ideas":
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"
assets_dir = static_dir / "assets"
if assets_dir.is_dir():
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b66d1cf..f147cc1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -363,12 +363,41 @@ function IdeaDetail({
+ {idea.snapshots && idea.snapshots.length > 1 && (
+
+ 这个想法走过的路
+
+ {[...idea.snapshots].reverse().map((snapshot, index) => (
+
+
+
+ {snapshot.change_kind === "manual_calibration"
+ ? "你重新放置了它"
+ : snapshot.motion}
+
+ {snapshot.trajectory}
+
+ ))}
+
+
+ )}
+
{idea.fragments && idea.fragments.length > 0 && (
- 构成这个想法的片段
+ 原始脉络
{idea.fragments.map((fragment) => (
-
{fragment.content}
+
+
+ {fragment.content}
+
))}
@@ -454,6 +483,8 @@ const eventNames: Record = {
tool_search_ideas: "搜索已有想法",
tool_inspect_idea: "查看想法轨迹",
model_attempt_completed: "模型返回",
+ model_round_completed: "完成一轮判断",
+ convergence_repair_started: "转入收敛判断",
validation_failed: "结构校验未通过",
decision_committed: "提交分析结果",
run_failed: "运行失败",
@@ -466,6 +497,7 @@ const auditNames: Record = {
logout: "退出登录",
fragment_created: "保存新片段",
idea_position_calibrated: "人工校准位势",
+ agent_run_requeued: "重新分析",
debug_sharing_changed: "调整调试共享",
};
@@ -510,6 +542,18 @@ function EventPayload({ event }: { event: AgentEvent }) {
);
}
+ if (event.event_type === "model_round_completed") {
+ return (
+
+ 输入 {compactNumber(Number(payload.input_tokens ?? 0))} · 输出{" "}
+ {compactNumber(Number(payload.output_tokens ?? 0))} · 推理{" "}
+ {compactNumber(Number(payload.reasoning_tokens ?? 0))}
+
+ );
+ }
+ if (event.event_type === "convergence_repair_started") {
+ return 工具探索没有及时收束,系统改用已有上下文直接完成判断。
;
+ }
if (event.event_type === "validation_failed") {
return (
@@ -561,11 +605,24 @@ function RunDetail({
onClose: () => void;
}) {
const [detail, setDetail] = useState(null);
+ const [retrying, setRetrying] = useState(false);
+ const [requeued, setRequeued] = useState(false);
useEffect(() => {
api.adminRun(runId).then(setDetail);
}, [runId]);
+ async function retry() {
+ if (retrying || requeued) return;
+ setRetrying(true);
+ try {
+ await api.retryAdminRun(runId);
+ setRequeued(true);
+ } finally {
+ setRetrying(false);
+ }
+ }
+
return (
{detail.fragment.content}
) : (
- 内容已隔离 · {detail.fragment.content_length} 字 · 指纹{" "}
- {detail.fragment.content_sha256}
+ 内容已隔离 · {detail.fragment.content_length} 字
)}
{detail.privacy.reason}
@@ -636,6 +692,19 @@ function RunDetail({
))}
+ {detail.run.status === "error" && (
+
+ )}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 956ac71..ebbe352 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -12,12 +12,17 @@ export type User = {
};
export type Snapshot = {
+ title: string | null;
+ summary: string | null;
maturity_ai: number;
+ maturity_override: number | null;
+ confidence: number | null;
motion: string;
position: string;
tension: string;
trajectory: string;
possible_moves: string[];
+ change_kind: "analysis" | "manual_calibration" | "legacy_partial";
created_at: string;
};
@@ -226,6 +231,10 @@ export const api = {
request<{ items: AgentRun[] }>(`/api/admin/runs?limit=${limit}`),
adminRun: (id: string) =>
request
(`/api/admin/runs/${id}`),
+ retryAdminRun: (id: string) =>
+ request<{ queued: boolean }>(`/api/admin/runs/${id}/retry`, {
+ method: "POST",
+ }),
adminAudit: (limit = 80) =>
request<{ items: AuditEvent[] }>(`/api/admin/audit?limit=${limit}`),
};
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 7bdaad1..6452736 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -885,12 +885,18 @@ summary:focus-visible {
color: var(--warm);
}
+.idea-history,
.evidence {
margin-top: 50px;
border-top: 1px solid var(--line);
color: var(--muted);
}
+.idea-history + .evidence {
+ margin-top: 0;
+}
+
+.idea-history summary,
.evidence summary {
padding: 18px 0;
cursor: pointer;
@@ -898,12 +904,44 @@ summary:focus-visible {
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 {
- margin: 0;
- padding: 15px 0;
+ margin: 5px 0 0;
+ padding: 0;
color: #4e5752;
font: 400 14px/1.85 "Songti SC", "STSong", serif;
- border-bottom: 1px solid rgba(23, 37, 31, 0.07);
}
.admin-view {
@@ -1411,6 +1449,22 @@ summary:focus-visible {
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 {
margin-top: 45px;
padding-top: 16px;
diff --git a/tests/test_api.py b/tests/test_api.py
index 45667ae..ad397d5 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -82,9 +82,10 @@ def test_login_isolation_roles_and_debug_privacy(
headers=WRITE_HEADERS,
)
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
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)
- 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()
assert redacted["fragment"]["content_visible"] is False
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()
assert visible["fragment"]["content_visible"] is True
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
diff --git a/tests/test_curator.py b/tests/test_curator.py
new file mode 100644
index 0000000..5f418d7
--- /dev/null
+++ b/tests/test_curator.py
@@ -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
+ ]
diff --git a/tests/test_db.py b/tests/test_db.py
index 17bb344..895115f 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -1,6 +1,7 @@
from pathlib import Path
from app.config import UserSeed
+from app.curator import _rank_ideas, build_curator_prompt
from app.db import Database
@@ -97,6 +98,105 @@ def test_apply_assessment_builds_trajectory(tmp_path: Path):
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("", 1)[1].split(
+ "", 1
+ )[0]
+ later_section = prompt.split("", 1)[1].split(
+ "", 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):
db = database(tmp_path)
fragment = db.create_fragment(ADMIN.id, "一个想法")
@@ -188,3 +288,41 @@ def test_agent_run_records_metrics_and_events(tmp_path: Path):
"tool_search_ideas",
]
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