From 953f69e092ce882588fdc88f4d9a438aa3bf83ed Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Wed, 29 Jul 2026 16:21:48 +0800 Subject: [PATCH] feat: recover evolving threads from freeform notes --- README.md | 2 ++ app/curator.py | 59 ++++++++++++++++++++++++++++++------- app/db.py | 45 +++++++++++++++++++--------- app/schemas.py | 21 +++++++++++++ frontend/src/App.tsx | 6 ++++ tests/test_curator.py | 68 +++++++++++++++++++++++++++++++++++++++++++ tests/test_db.py | 61 +++++++++++++++++++++++++++++++++++++- 7 files changed, 238 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7bd8cb1..4ba9edb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Agent 使用 DeepSeek 的思考模式,结合最近的连续原文与精简想 - 捕获流不显示保存状态、AI 标签、关联或建议。 - 不要求用户先建页面、取标题、选分类或填写日期。 - 不要求用户建立会话;系统在后台保留滚动的连续语境。 +- 后台的“想法”是广义的演化脉络,也包括项目、工作、学习、问题和现实承诺。 +- 后续输入让一个方向成形时,系统可以把先前暂存的原文补回完整证据链。 - 成熟度是可回退的连续位势,不是阶段、成绩或任务完成百分比。 - 运动、张力和可能动作由模型结合上下文动态生成,不使用固定关卡。 - 用户手动校准的位势优先展示,AI 估计仍独立保留。 diff --git a/app/curator.py b/app/curator.py index 10acfad..f0e9356 100644 --- a/app/curator.py +++ b/app/curator.py @@ -29,7 +29,7 @@ from .db import Database from .schemas import CuratorDecision logger = logging.getLogger(__name__) -PROMPT_VERSION = "maturity-2026-07-28.v3" +PROMPT_VERSION = "maturity-2026-07-29.v4" RECENT_CONTEXT_LIMIT = 12 IDEA_CATALOG_LIMIT = 40 @@ -38,7 +38,9 @@ CURATOR_INSTRUCTIONS = """ 你是一个私人思想笔记本内部的“策展者”。用户界面必须安静,所以你的工作发生在幕后。 你的任务不是给句子贴标签,也不是强迫想法进入固定工作流,而是辨认:新片段是否属于一个 -持续演化的想法;如果属于,它此刻大致在哪里、正在怎样运动、内在张力是什么。 +持续演化的脉络;如果属于,它此刻大致在哪里、正在怎样运动、内在张力是什么。这里的“想法” +是面向用户的安静称呼,后台必须把它理解得更广:观点、问题、项目、工作、学习、创作、决策和 +现实承诺,只要会随时间变化、值得未来回来,都可能是一个脉络。 判断“成熟度”时使用融合性的人类经验,而不是计数规则。综合考察: 1. 想法是否已经有自己的身份、边界与可复述的核心; @@ -54,9 +56,23 @@ motion 是你对当下运动的自然语言压缩,例如“向现实探去” 预设流水线。position 说明此刻所处的位置。trajectory 只描述最近发生的变化。possible_moves 是 1—4 个根据当前张力即时生成的可能动作,不是任务清单,不承诺固定的下一关。 +在决定 standalone 或新建前,必须把两个问题分开: +1. 它是否延续某个已有脉络; +2. 即使不延续,它是否带着值得未来回来的未完成方向。 +“不属于已有想法”绝不等于“应当 standalone”。如果片段已经显露出可命名的对象,并带有 +未解问题、现实承诺、明确意图或下一步动作之一,即使只有第一条,也通常应建立一个低位势的 +新脉络。尤其是用户明确说“新工作”“需要做”“我想研究”“我要先……”时,不要把它当作 +不值得跟踪的杂讯。只有纯粹随手事实、情绪或转瞬观察,既无可辨认对象也无未来张力,或信息 +少到无法诚实命名时,才 standalone。 + 谨慎合并。仅因主题词相似不能归到同一想法;关注它们是否共享同一个问题、张力或生成方向。 -一个片段最多关联两个想法。若现在还不值得形成或归入想法,让它 standalone,原文仍会保留。 -新建想法时 idea_id 必须为 null;更新时必须使用工具返回的准确 id。 +一个片段最多关联两个想法。新建必须克制,但克制意味着不凭关键词重复建,不意味着压制真实 +的新方向。新建想法时 idea_id 必须为 null;更新时必须使用工具返回的准确 id。 + +如果 recent_context 中较早的原文与当前判断共同构成同一脉络,但它当时可能只被暂存,使用 +supporting_fragment_ids 把它补回证据链。只可填写 recent_context 中出现的准确 id,最多六条; +当前 new_fragment 会被系统自动关联,不要填写它。不要为主题词相似而回收,也不要填写 +later_context 中的记录。 输入会直接提供最近的连续原文和精简想法目录。把“这个、上面、第二点、继续、刚才”等指代 放回连续语境中理解;不要因为新片段换了关键词就丢掉思想上的延续。目录中的 id 只供系统内部 @@ -190,12 +206,10 @@ class CuratorRunHooks(RunHooks[CuratorContext]): 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 = [ +def _prior_context( + fragment: dict[str, Any], recent_fragments: list[dict[str, Any]] +) -> list[dict[str, Any]]: + return [ { "id": item["id"], "created_at": item["created_at"], @@ -205,6 +219,14 @@ def build_curator_prompt( if item["id"] != fragment["id"] and item["created_at"] <= fragment["created_at"] ][-RECENT_CONTEXT_LIMIT:] + + +def build_curator_prompt( + fragment: dict[str, Any], + ideas: list[dict[str, Any]], + recent_fragments: list[dict[str, Any]], +) -> str: + prior_context = _prior_context(fragment, recent_fragments) later_context = [ { "created_at": item["created_at"], @@ -256,6 +278,9 @@ def build_curator_prompt( f"当前共有 {len(ideas)} 个已有想法。{catalog_note}\n" "如果新片段既验证了一个既有想法、又形成了一个新的独立问题,可以给出两个 assessment;" "不要因为表面主题变化而遗漏它对上一段思想过程的反馈。\n\n" + "先分别判断“是否延续已有脉络”与“是否形成新的未完成方向”。若较早的 recent_context " + "与当前片段共同构成同一脉络,在对应 assessment 的 supporting_fragment_ids 中补回;" + "没有确切证据时保持为空。\n\n" "最终只输出符合以下 JSON Schema 的 JSON 对象:\n" f"{json.dumps(output_schema, ensure_ascii=False)}" ) @@ -635,6 +660,20 @@ class Curator: raise ValueError( "curator referenced an unknown existing idea" ) + eligible_fragment_ids = { + item["id"] + for item in _prior_context(fragment, recent_fragments) + } + unknown_fragment_ids = [ + supporting_id + for item in decision.assessments + for supporting_id in item.supporting_fragment_ids + if supporting_id not in eligible_fragment_ids + ] + if unknown_fragment_ids: + raise ValueError( + "curator referenced a fragment outside recent_context" + ) break except (ValidationError, ValueError, TypeError) as exc: decision = None diff --git a/app/db.py b/app/db.py index d10d7b5..9b1b781 100644 --- a/app/db.py +++ b/app/db.py @@ -604,19 +604,38 @@ class Database: continue seen.add(idea_id) affected.append(idea_id) - connection.execute( - """ - INSERT OR IGNORE INTO idea_fragments ( - idea_id, fragment_id, relevance, created_at - ) VALUES (?, ?, ?, ?) - """, - ( - idea_id, - fragment_id, - assessment.get("relevance", 1), - now, - ), - ) + evidence_fragment_ids: list[str] = [] + for evidence_fragment_id in [ + fragment_id, + *assessment.get("supporting_fragment_ids", []), + ]: + if evidence_fragment_id in evidence_fragment_ids: + continue + evidence_exists = connection.execute( + """ + SELECT 1 FROM fragments + WHERE id = ? AND user_id = ? + """, + (evidence_fragment_id, user_id), + ).fetchone() + if not evidence_exists: + raise ValueError( + "supporting fragment not found for user" + ) + evidence_fragment_ids.append(evidence_fragment_id) + connection.execute( + """ + INSERT OR IGNORE INTO idea_fragments ( + idea_id, fragment_id, relevance, created_at + ) VALUES (?, ?, ?, ?) + """, + ( + idea_id, + evidence_fragment_id, + assessment.get("relevance", 1), + now, + ), + ) version_state = connection.execute( """ SELECT maturity_override FROM ideas diff --git a/app/schemas.py b/app/schemas.py index 2a5db2f..32144a3 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -44,12 +44,33 @@ class IdeaAssessment(BaseModel): trajectory: str = Field(min_length=1, max_length=280) possible_moves: list[str] = Field(min_length=1, max_length=4) relevance: float = Field(default=1, ge=0, le=1) + supporting_fragment_ids: list[str] = Field( + default_factory=list, + max_length=6, + description=( + "Ids of earlier fragments from recent_context that are substantive " + "evidence for this same evolving thread. The new fragment is linked " + "automatically and must not be included." + ), + ) @field_validator("possible_moves") @classmethod def moves_are_brief(cls, moves: list[str]) -> list[str]: return [move.strip()[:100] for move in moves if move.strip()][:4] + @field_validator("supporting_fragment_ids") + @classmethod + def supporting_fragments_are_unique( + cls, fragment_ids: list[str] + ) -> list[str]: + unique: list[str] = [] + for fragment_id in fragment_ids: + value = fragment_id.strip() + if value and value not in unique: + unique.append(value) + return unique[:6] + class CuratorDecision(BaseModel): standalone: bool = Field( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f147cc1..7e99190 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -577,6 +577,12 @@ function EventPayload({ event }: { event: AgentEvent }) { {String(assessment.motion ?? "")}
{String(assessment.position ?? "")}
+ {Array.isArray(assessment.supporting_fragment_ids) && + assessment.supporting_fragment_ids.length > 0 && ( + + 同时补回 {assessment.supporting_fragment_ids.length} 条早期记录 + + )} ))} diff --git a/tests/test_curator.py b/tests/test_curator.py index 5f418d7..60785d4 100644 --- a/tests/test_curator.py +++ b/tests/test_curator.py @@ -64,3 +64,71 @@ def test_tool_loop_uses_no_tool_convergence_repair( assert "convergence_repair_started" in [ event["event_type"] for event in events ] + + +def test_curator_recovers_visible_prior_fragment( + tmp_path: Path, monkeypatch +): + admin = UserSeed( + id="admin", + label="管理员", + role="admin", + access_key_hash="unused", + ) + database = Database(tmp_path / "recovery.sqlite3") + database.initialize([admin]) + earlier = database.create_fragment( + "admin", "有个新工作可能要做小爱架构。" + ) + database.apply_assessments("admin", earlier["id"], []) + current = database.create_fragment( + "admin", "确定要做,先熟悉 harness 5.0。" + ) + settings = Settings( + data_dir=tmp_path, + database_path=tmp_path / "recovery.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, + ) + + async def fake_run(*args, **kwargs): + return SimpleNamespace( + final_output=( + "{" + '"standalone":false,' + '"reasoning_note":"明确的新工作,应形成可跟踪脉络",' + '"assessments":[{' + '"idea_id":null,' + '"title":"小爱新架构开发",' + '"summary":"熟悉现有方案并推进新架构开发。",' + '"maturity":10,' + '"confidence":0.9,' + '"motion":"启动中",' + '"position":"先熟悉 harness 5.0 核心方案。",' + '"tension":"任务已明确,但范围与现有方案尚未理解。",' + '"trajectory":"从可能插入的工作变成明确承诺。",' + '"possible_moves":["阅读核心方案"],' + '"relevance":1,' + f'"supporting_fragment_ids":["{earlier["id"]}"]' + "}]}" + ) + ) + + monkeypatch.setattr(Runner, "run", fake_run) + curator = Curator(database, settings) + + asyncio.run(curator.analyze(current["id"])) + + idea = database.get_idea( + "admin", database.list_ideas("admin")[0]["id"] + ) + assert idea["title"] == "小爱新架构开发" + assert [item["id"] for item in idea["fragments"]] == [ + earlier["id"], + current["id"], + ] diff --git a/tests/test_db.py b/tests/test_db.py index 895115f..b3567da 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -25,7 +25,11 @@ def database(tmp_path: Path) -> Database: return db -def assessment(idea_id: str | None = None, maturity: float = 38): +def assessment( + idea_id: str | None = None, + maturity: float = 38, + supporting_fragment_ids: list[str] | None = None, +): return { "idea_id": idea_id, "title": "无分类的笔记入口", @@ -38,6 +42,7 @@ def assessment(idea_id: str | None = None, maturity: float = 38): "trajectory": "从输入摩擦的感受,收束成了产品边界。", "possible_moves": ["做一个只保留原文的输入原型"], "relevance": 1, + "supporting_fragment_ids": supporting_fragment_ids or [], } @@ -119,6 +124,60 @@ def test_curator_prompt_contains_recent_continuity_and_idea_catalog( assert "我识别出了两个新的认知" in prompt assert "无分类的笔记入口" in prompt assert "不要要求用户显式建立会话" in prompt + assert "supporting_fragment_ids" in prompt + assert "是否形成新的未完成方向" in prompt + + +def test_later_fragment_can_recover_earlier_standalone_evidence( + tmp_path: Path, +): + db = database(tmp_path) + earlier = db.create_fragment( + ADMIN.id, "也许该重新设计小爱的架构,但现在还说不清。" + ) + db.apply_assessments(ADMIN.id, earlier["id"], []) + current = db.create_fragment( + ADMIN.id, "这个新工作确定要做,我先熟悉 harness 5.0。" + ) + + db.apply_assessments( + ADMIN.id, + current["id"], + [assessment(supporting_fragment_ids=[earlier["id"]])], + ) + + idea = db.get_idea(ADMIN.id, db.list_ideas(ADMIN.id)[0]["id"]) + assert [item["content"] for item in idea["fragments"]] == [ + earlier["content"], + current["content"], + ] + assert idea["snapshots"][-1]["fragment_ids"] == [ + earlier["id"], + current["id"], + ] + + +def test_recovered_evidence_cannot_cross_user_boundary(tmp_path: Path): + db = database(tmp_path) + member_fragment = db.create_fragment(MEMBER.id, "另一个人的记录") + current = db.create_fragment(ADMIN.id, "管理员的新方向") + + try: + db.apply_assessments( + ADMIN.id, + current["id"], + [ + assessment( + supporting_fragment_ids=[member_fragment["id"]] + ) + ], + ) + except ValueError as exc: + assert str(exc) == "supporting fragment not found for user" + else: + raise AssertionError("cross-user supporting fragment was accepted") + + assert db.list_ideas(ADMIN.id) == [] def test_delayed_reanalysis_keeps_later_input_out_of_prior_context(