feat: recover evolving threads from freeform notes

This commit is contained in:
wuyang
2026-07-29 16:21:48 +08:00
parent af7bb68268
commit 953f69e092
7 changed files with 238 additions and 24 deletions
+2
View File
@@ -13,6 +13,8 @@ Agent 使用 DeepSeek 的思考模式,结合最近的连续原文与精简想
- 捕获流不显示保存状态、AI 标签、关联或建议。
- 不要求用户先建页面、取标题、选分类或填写日期。
- 不要求用户建立会话;系统在后台保留滚动的连续语境。
- 后台的“想法”是广义的演化脉络,也包括项目、工作、学习、问题和现实承诺。
- 后续输入让一个方向成形时,系统可以把先前暂存的原文补回完整证据链。
- 成熟度是可回退的连续位势,不是阶段、成绩或任务完成百分比。
- 运动、张力和可能动作由模型结合上下文动态生成,不使用固定关卡。
- 用户手动校准的位势优先展示,AI 估计仍独立保留。
+49 -10
View File
@@ -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
+20 -1
View File
@@ -604,6 +604,25 @@ class Database:
continue
seen.add(idea_id)
affected.append(idea_id)
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 (
@@ -612,7 +631,7 @@ class Database:
""",
(
idea_id,
fragment_id,
evidence_fragment_id,
assessment.get("relevance", 1),
now,
),
+21
View File
@@ -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(
+6
View File
@@ -577,6 +577,12 @@ function EventPayload({ event }: { event: AgentEvent }) {
{String(assessment.motion ?? "")}
</span>
<p>{String(assessment.position ?? "")}</p>
{Array.isArray(assessment.supporting_fragment_ids) &&
assessment.supporting_fragment_ids.length > 0 && (
<small>
同时补回 {assessment.supporting_fragment_ids.length} 条早期记录
</small>
)}
</div>
))}
</div>
+68
View File
@@ -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"],
]
+60 -1
View File
@@ -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(