feat: give the curator continuous context and durable idea history
This commit is contained in:
+49
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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("<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):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user