from pathlib import Path import pytest from pydantic import ValidationError from app.config import UserSeed from app.curator import ( CURATOR_INSTRUCTIONS, _rank_ideas, build_curator_prompt, ) from app.db import Database from app.schemas import CuratorDecision ADMIN = UserSeed( id="admin-user", label="管理员", role="admin", access_key_hash="hash-admin", ) MEMBER = UserSeed( id="member-user", label="用户二", role="member", access_key_hash="hash-member", ) def database(tmp_path: Path) -> Database: db = Database(tmp_path / "test.sqlite3") db.initialize([ADMIN, MEMBER]) return db def assessment( idea_id: str | None = None, maturity: float = 38, supporting_fragment_ids: list[str] | None = None, ): return { "idea_id": idea_id, "title": "无分类的笔记入口", "summary": "先保留念头,再让结构在后台形成。", "maturity": maturity, "confidence": 0.76, "motion": "从抱怨聚成原则", "position": "已经有清楚的交互原则,还没有碰到真实使用。", "tension": "自由输入与系统内部结构之间需要同时成立。", "trajectory": "从输入摩擦的感受,收束成了产品边界。", "possible_moves": ["做一个只保留原文的输入原型"], "relevance": 1, "supporting_fragment_ids": supporting_fragment_ids or [], } def test_fragment_is_saved_before_analysis(tmp_path: Path): db = database(tmp_path) fragment = db.create_fragment(ADMIN.id, "一个还没有分类的念头") assert db.list_fragments(ADMIN.id) == [ { "id": fragment["id"], "content": "一个还没有分类的念头", "created_at": fragment["created_at"], } ] assert db.get_fragment(fragment["id"], ADMIN.id)["analysis_status"] == "pending" def test_user_data_is_isolated_at_query_layer(tmp_path: Path): db = database(tmp_path) admin_fragment = db.create_fragment(ADMIN.id, "管理员的私密念头") member_fragment = db.create_fragment(MEMBER.id, "用户二的私密念头") db.apply_assessments(ADMIN.id, admin_fragment["id"], [assessment()]) admin_idea = db.list_ideas(ADMIN.id)[0] assert [item["content"] for item in db.list_fragments(ADMIN.id)] == [ "管理员的私密念头" ] assert [item["content"] for item in db.list_fragments(MEMBER.id)] == [ "用户二的私密念头" ] assert db.get_fragment(member_fragment["id"], ADMIN.id) is None assert db.get_idea(MEMBER.id, admin_idea["id"]) is None assert db.update_idea_override(MEMBER.id, admin_idea["id"], 80) is None assert db.curator_context(MEMBER.id)[0] == [] def test_apply_assessment_builds_trajectory(tmp_path: Path): db = database(tmp_path) first = db.create_fragment(ADMIN.id, "笔记不应该要求先分类") db.apply_assessments(ADMIN.id, first["id"], [assessment()]) ideas = db.list_ideas(ADMIN.id) assert len(ideas) == 1 assert ideas[0]["maturity"] == 38 assert ideas[0]["fragment_count"] == 1 second = db.create_fragment(ADMIN.id, "AI 的判断不能出现在记录流里") db.apply_assessments( ADMIN.id, second["id"], [assessment(ideas[0]["id"], maturity=46)], ) idea = db.get_idea(ADMIN.id, ideas[0]["id"]) assert idea["fragment_count"] == 2 assert len(idea["snapshots"]) == 2 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 assert "supporting_fragment_ids" in prompt assert "是否形成新的未完成方向" in prompt def test_explicit_user_direction_cannot_be_silently_discarded(): with pytest.raises( ValidationError, match="explicit user-declared direction cannot remain standalone", ): CuratorDecision.model_validate( { "direction_signal": "explicit", "standalone": True, "reasoning_note": "目标太大,暂时不值得跟踪", "assessments": [], } ) assert "有根据的信任" in CURATOR_INSTRUCTIONS assert "用户拥有自己话语的严肃程度" in CURATOR_INSTRUCTIONS assert "不能成为拒绝创建的门槛" in CURATOR_INSTRUCTIONS assert "保持诚实,不做空洞吹捧" in CURATOR_INSTRUCTIONS 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( 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, "一个想法") db.apply_assessments(ADMIN.id, fragment["id"], [assessment(maturity=30)]) idea = db.list_ideas(ADMIN.id)[0] overridden = db.update_idea_override(ADMIN.id, idea["id"], 61) assert overridden["maturity"] == 61 assert overridden["maturity_ai"] == 30 assert overridden["is_overridden"] is True restored = db.update_idea_override(ADMIN.id, idea["id"], None) assert restored["maturity"] == 30 assert restored["is_overridden"] is False def test_existing_unowned_data_migrates_to_first_admin(tmp_path: Path): db = Database(tmp_path / "legacy.sqlite3") db.initialize() with db.connect() as connection: connection.execute( """ INSERT INTO fragments ( id, user_id, content, created_at, analysis_status ) VALUES ('legacy-fragment', NULL, '旧记录', '2026-01-01', 'done') """ ) connection.execute( """ INSERT INTO ideas ( id, user_id, title, summary, maturity_ai, confidence, motion, position, tension, trajectory, possible_moves, created_at, updated_at ) VALUES ( 'legacy-idea', NULL, '旧想法', '摘要', 10, .5, '浮现', '位置', '张力', '轨迹', '[]', '2026-01-01', '2026-01-01' ) """ ) db.initialize([ADMIN, MEMBER]) assert db.get_fragment("legacy-fragment", ADMIN.id)["content"] == "旧记录" assert db.get_idea(ADMIN.id, "legacy-idea")["title"] == "旧想法" assert db.get_fragment("legacy-fragment", MEMBER.id) is None def test_agent_run_records_metrics_and_events(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.v1", "high", ) db.add_agent_event( run_id, ADMIN.id, "tool_search_ideas", {"query": "可观测", "result_count": 0}, duration_ms=3, ) db.finish_agent_run( run_id, status="success", duration_ms=1200, attempt_count=1, model_rounds=2, tool_calls=1, search_calls=1, inspection_calls=0, input_tokens=100, output_tokens=50, reasoning_tokens=30, cached_tokens=20, ) run = db.get_agent_run(run_id) events = db.list_agent_events(run_id) overview = db.admin_overview() assert run["status"] == "success" assert run["reasoning_tokens"] == 30 assert [event["event_type"] for event in events] == [ "run_started", "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