mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-15 23:31:05 +00:00
* refractor(proactive): upgrade proactive feature with disentangled job and steps * refactor(proactive): apply audit fixes - rename read-side job 'proactive' -> 'proactive_read' (less confusing vs the refresh pipeline) - drop dedicated agent_wrapper.proactive; extraction reuses the default wrapper - simplify schema: remove unused ProactiveExtractOutput/TopicUpdate, drop resource_paths - extract no longer scans resource/ directly (daily notes already carry resource content) - update tests and docs accordingly * feat(proactive): strict extract-output gate and prompt total budget - parse_extract_reply now requires a contract section (follow_ups/extends/updates as a list); non-empty replies with misspelled section names trigger the existing one-shot retry instead of silently checkpointing changed files - pack_paths gains max_total_chars; extract packs newest daily material first, keeps the first file on overflow, and records omitted files in a trailer (default budget 300000 chars, configurable via max_total_chars) - tests: schema gate unit, schema-error retry e2e, budget unit + e2e * feat(proactive): add scenario-card plan step and generative agenda step * feat(proactive): digest-personal profile personalization and leaner LLM contract - extract/plan/agenda now draw a user profile block from <digest_dir>/personal/*.md (frontmatter description + body excerpt, per-file budget, profile.md fallback) - all daily access honours the configured daily_dir (prompt paths parameterized, config-driven fallbacks) so workspaces using e.g. memory/ work unchanged - schema trim: drop dead fields errors/material_paths, carry_forward_all -> count - shrink LLM output contract: new topics emit title/reason/confidence/paths only; keywords removed end-to-end, evidence derived from paths[0] (updates keep it) * fix(proactive): skip checkpoint when extract reply stays unusable after retry Two consecutive unparseable replies now short-circuit the round without checkpointing, so the same material is retried next round instead of being silently consumed (closes the residual audit #1 gap: the structural gate detected schema-wrong output but a double failure still checkpointed). * fix(proactive): replace running bool with reference-counted job activity tracker for the idle gate * refactor(proactive): remove job activity tracking and idle gate, restore job tree to upstream * fix(proactive): address second audit round (readonly reader, mtime checkpoint, wider fallbacks, profile containment, horizon content, expiry boundary) * refactor(dream): strip interests.yaml ownership from dream, proactive is now the sole writer * refactor(dream): separate proactive topic generation * ci: update renamed auto dream smoke test * fix(proactive): complete refresh migration and docs --------- Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
1986 lines
81 KiB
Python
1986 lines
81 KiB
Python
# pylint: disable=too-many-lines
|
|
"""Unit tests for the proactive refresh chain (PROACTIVE_SPEC.md A8 mapping).
|
|
|
|
Coverage: F1 contracts, F2 discovery, F3 isolation, F4 budget/timeout,
|
|
F5 read extensions, plus the M2 extends branch and semantic dedup.
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime as dt
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import yaml
|
|
from agentscope.model import ChatModelBase
|
|
|
|
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
|
|
from reme.components.application_context import ApplicationContext
|
|
from reme.components.file_catalog import BaseFileCatalog
|
|
from reme.components.file_store import BaseFileStore
|
|
from reme.components.runtime_context import RuntimeContext
|
|
from reme.schema import FileNode, ProactiveTopic
|
|
from reme.steps.evolve.dream.extract import DreamExtractStep
|
|
from reme.steps.evolve.dream.utils import pack_paths
|
|
from reme.steps.evolve.proactive.agenda import ProactiveAgendaStep
|
|
from reme.steps.evolve.proactive.extract import ProactiveExtractStep
|
|
from reme.steps.evolve.proactive.finish import ProactiveFinishStep
|
|
from reme.steps.evolve.proactive.plan import ProactivePlanStep
|
|
from reme.steps.evolve.proactive.proactive import ProactiveStep
|
|
from reme.steps.evolve.proactive.topics import ProactiveTopicsStep
|
|
from reme.steps.evolve.proactive.utils import (
|
|
load_carry_forward,
|
|
load_personal_profile_block,
|
|
load_state,
|
|
load_yaml_topics,
|
|
normalize_topic,
|
|
parse_interests_topics,
|
|
parse_extract_reply,
|
|
scan_material_daily,
|
|
strip_frontmatter,
|
|
topic_id,
|
|
trim_state_file,
|
|
)
|
|
|
|
DAY = "2026-08-13"
|
|
|
|
|
|
def _touch(path: Path, text: str = "x") -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
class _Catalog(BaseFileCatalog):
|
|
"""In-memory catalog stub recording every mutation."""
|
|
|
|
def __init__(self, nodes=None):
|
|
super().__init__()
|
|
self.nodes = list(nodes or [])
|
|
self.upserts = []
|
|
self.dumps = 0
|
|
self.deleted = []
|
|
|
|
async def upsert(self, nodes):
|
|
self.upserts.extend(nodes)
|
|
known = {n.path: n for n in self.nodes}
|
|
for node in nodes:
|
|
known[node.path] = node
|
|
self.nodes = list(known.values())
|
|
|
|
async def delete(self, path):
|
|
paths = path if isinstance(path, list) else [path]
|
|
self.deleted.extend(paths)
|
|
drop = set(paths)
|
|
self.nodes = [n for n in self.nodes if n.path not in drop]
|
|
|
|
async def get_nodes(self, paths=None):
|
|
if paths is None:
|
|
return list(self.nodes)
|
|
wanted = set(paths)
|
|
return [n for n in self.nodes if n.path in wanted]
|
|
|
|
async def dump(self):
|
|
self.dumps += 1
|
|
|
|
|
|
class _FileStore(BaseFileStore):
|
|
def __init__(self, workspace: Path):
|
|
super().__init__()
|
|
self._workspace_path = workspace
|
|
|
|
@property
|
|
def workspace_path(self) -> Path:
|
|
return self._workspace_path
|
|
|
|
async def upsert(self, files):
|
|
return None
|
|
|
|
async def delete(self, path):
|
|
return None
|
|
|
|
async def clear(self):
|
|
return None
|
|
|
|
async def get_nodes(self, paths=None):
|
|
return []
|
|
|
|
async def get_outlinks(self, path, scope=None):
|
|
return []
|
|
|
|
async def get_inlinks(self, path, scope=None):
|
|
return []
|
|
|
|
async def vector_search(self, query, limit, search_filter):
|
|
return []
|
|
|
|
async def keyword_search(self, query, limit, search_filter):
|
|
return []
|
|
|
|
|
|
class _AgentWrapper(BaseAgentWrapper):
|
|
"""Deterministic LLM stub with scripted replies and call accounting."""
|
|
|
|
def __init__(self, replies=None, delay: float = 0.0):
|
|
super().__init__()
|
|
self.replies = list(replies or [])
|
|
self.delay = delay
|
|
self.calls = 0
|
|
self.last_inputs = []
|
|
self.last_system_prompts = []
|
|
|
|
async def reply(self, inputs, **kwargs):
|
|
self.calls += 1
|
|
self.last_inputs.append(inputs)
|
|
self.last_system_prompts.append(str(kwargs.get("system_prompt") or ""))
|
|
item = self.replies.pop(0) if self.replies else ""
|
|
if isinstance(item, tuple):
|
|
text, delay = item
|
|
else:
|
|
text, delay = item, self.delay
|
|
if delay:
|
|
await asyncio.sleep(delay)
|
|
return {
|
|
"session_id": "stub",
|
|
"result": text,
|
|
"last_message": {"content": [{"type": "text", "text": text}]},
|
|
}
|
|
|
|
|
|
class _DummyModel(ChatModelBase):
|
|
"""Placeholder model satisfying dream extract's llm_available check."""
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
|
|
class _FakeEmbedding:
|
|
"""Scripted embedding oracle: exact text -> vector, else orthogonal default.
|
|
|
|
Declares ``kwargs``/``dimensions`` so the topics step can fingerprint the
|
|
"model" against ``known_threshold_calibrated_for`` (defaults match).
|
|
"""
|
|
|
|
def __init__(self, vectors: dict[str, list[float]], model: str = "text-embedding-v4", dimensions: int = 1024):
|
|
self.vectors = vectors
|
|
self.calls = 0
|
|
self.kwargs = {"model": model}
|
|
self.dimensions = dimensions
|
|
|
|
async def __call__(self, inputs, **kwargs):
|
|
self.calls += 1
|
|
return [list(self.vectors.get(text, [0.0, 1.0])) for text in inputs]
|
|
|
|
|
|
def _reply(follow_ups=None, extends=None, updates=None) -> str:
|
|
doc = {}
|
|
if follow_ups is not None:
|
|
doc["follow_ups"] = follow_ups
|
|
if extends is not None:
|
|
doc["extends"] = extends
|
|
if updates is not None:
|
|
doc["updates"] = updates
|
|
body = yaml.safe_dump(doc, allow_unicode=True, sort_keys=False)
|
|
return f"```yaml\n{body}```"
|
|
|
|
|
|
def _topic(title, reason="because", confidence=0.7, paths=None, evidence=None):
|
|
doc = {
|
|
"title": title,
|
|
"reason": reason,
|
|
"confidence": confidence,
|
|
"paths": paths or [],
|
|
}
|
|
if evidence:
|
|
doc["evidence"] = evidence
|
|
return doc
|
|
|
|
|
|
def _state_topic(title, day=DAY, kind="follow_up", confidence=0.8, topic_id_value=None, status="open"):
|
|
return {
|
|
"id": topic_id_value or topic_id(title),
|
|
"title": title,
|
|
"reason": "seeded",
|
|
"kind": kind,
|
|
"confidence": confidence,
|
|
"status": status,
|
|
"first_seen": day,
|
|
"last_evidence_at": day,
|
|
"evidence": "",
|
|
"paths": [],
|
|
}
|
|
|
|
|
|
def _write_state(ws, budget=None, exposure=None, open_topics=None, resolved=None, omit_open_topics=False):
|
|
data = {"version": 1}
|
|
if budget is not None:
|
|
data["budget"] = budget
|
|
if exposure is not None:
|
|
data["exposure"] = exposure
|
|
if not omit_open_topics:
|
|
data["open_topics"] = open_topics or []
|
|
if resolved is not None:
|
|
data["resolved"] = resolved
|
|
_touch(ws / "daily" / "_proactive.yaml", yaml.safe_dump(data, allow_unicode=True, sort_keys=False))
|
|
|
|
|
|
def _read_state(ws) -> dict:
|
|
return yaml.safe_load((ws / "daily" / "_proactive.yaml").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _interests(ws, day=DAY) -> Path:
|
|
return ws / "daily" / day / "interests.yaml"
|
|
|
|
|
|
def _write_interests_v1(ws, day, topics_yaml: list[dict]):
|
|
payload = {"date": day, "topic_count": len(topics_yaml), "topics": topics_yaml}
|
|
_touch(_interests(ws, day), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
|
|
|
|
|
|
def _write_interests_v2(ws, day, topics: list[dict], push=True, skip_reason=""):
|
|
payload = {
|
|
"version": 2,
|
|
"date": day,
|
|
"generated_at": f"{day}T09:00:00+08:00",
|
|
"push": push,
|
|
"skip_reason": skip_reason,
|
|
"topics": topics,
|
|
}
|
|
_touch(_interests(ws, day), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
|
|
|
|
|
|
async def _run_chain(
|
|
ws,
|
|
replies=None,
|
|
*,
|
|
wrapper=None,
|
|
catalog=None,
|
|
app=None,
|
|
extract_kwargs=None,
|
|
topics_kwargs=None,
|
|
extra_ctx=None,
|
|
context=None,
|
|
):
|
|
app = app or ApplicationContext(workspace_dir=str(ws))
|
|
wrapper = wrapper or _AgentWrapper(replies or [])
|
|
catalog = catalog or _Catalog()
|
|
context = context or RuntimeContext(
|
|
date=DAY,
|
|
file_catalog=catalog,
|
|
file_store=_FileStore(ws),
|
|
agent_wrapper=wrapper,
|
|
**(extra_ctx or {}),
|
|
)
|
|
extract = ProactiveExtractStep(app_context=app, **(extract_kwargs or {}))
|
|
resp_extract = await extract(context)
|
|
topics = ProactiveTopicsStep(app_context=app, **(topics_kwargs or {}))
|
|
resp_topics = await topics(context)
|
|
finish = ProactiveFinishStep(app_context=app)
|
|
resp_finish = await finish(context)
|
|
return app, wrapper, catalog, context, (resp_extract, resp_topics, resp_finish)
|
|
|
|
|
|
def test_known_threshold_model_binding(tmp_path):
|
|
"""known_threshold is model-bound (v5.1): fingerprint mismatch degrades.
|
|
|
|
The oracle maps every text to the same vector (sim 1.0 >= known_threshold),
|
|
but the "model" differs from the calibration, so the semantic gate must be
|
|
disabled: a paraphrase survives while an exact normalize duplicate of a
|
|
historical interests title still drops.
|
|
"""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "notes")
|
|
_write_state(ws, open_topics=[_state_topic("检索引用质量评测")])
|
|
_write_interests_v1(ws, "2026-08-12", [{"title": "向量数据库选型", "reason": "done"}])
|
|
embedder = _FakeEmbedding({}, model="some-other-model-v9")
|
|
reply = _reply(
|
|
follow_ups=[
|
|
_topic("检索引用可信度的评测方法", paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("向量数据库选型", paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
|
|
state = context.get("proactive")
|
|
assert state["dropped_known"] == 0 # semantic gate disabled by mismatch
|
|
assert state["dropped_duplicate"] == 1 # exact compare still active
|
|
titles = [t["title"] for t in _read_state(ws)["open_topics"]]
|
|
assert "检索引用可信度的评测方法" in titles
|
|
assert "向量数据库选型" not in titles
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_overage_remention_restarts(tmp_path):
|
|
"""Over-age open topics re-mentioned today restart instead of vanishing.
|
|
|
|
Same-id candidate refreshes first_seen so trim keeps the topic (unfinished
|
|
business must keep being re-executed). After the restart the revived title
|
|
is back in the comparison set, so a paraphrase of it drops as known.
|
|
A dying topic with NO same-id candidate stays excluded from comparison:
|
|
its paraphrase re-mention survives and opens a fresh topic.
|
|
"""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "notes")
|
|
old_day = "2026-07-28" # 16 days before DAY, over-age at carry_forward_days=14
|
|
_write_state(
|
|
ws,
|
|
open_topics=[_state_topic("旧任务确认", day=old_day), _state_topic("遗留事项", day=old_day)],
|
|
)
|
|
# Discriminative oracle: only the legacy-task / its-supplement pair scores sim 1.0;
|
|
# every other pair is orthogonal. If the dying title leaked into the
|
|
# comparison set, the paraphrase below would drop as known.
|
|
embedder = _FakeEmbedding({"遗留事项的补充。because": [1.0, 0.0], "遗留事项。seeded": [1.0, 0.0]})
|
|
reply = _reply(
|
|
follow_ups=[
|
|
_topic("旧任务确认", paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("旧任务确认的后续安排", paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("遗留事项的补充", paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": embedder})
|
|
state = context.get("proactive")
|
|
assert state["dropped_known"] == 1 # paraphrase of the REVIVED topic (default sim 1.0 vs it)
|
|
topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
|
|
assert topics[topic_id("旧任务确认")]["first_seen"] == DAY # restarted, not trimmed
|
|
assert topic_id("旧任务确认的后续安排") not in topics # known vs revived topic
|
|
assert topic_id("遗留事项的补充") in topics # dying title excluded from comparison
|
|
assert topic_id("遗留事项") not in topics # over-age, never re-mentioned -> trimmed
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_update_evidence_anchor(tmp_path):
|
|
"""v5.2: action=update must anchor this round's new material (hard check).
|
|
|
|
An update citing a file outside the round's changed material degrades to
|
|
keep (no evidence rewrite, no freshness refresh); an update citing a
|
|
changed file applies, and last_evidence_at is parsed from the evidence
|
|
path date instead of defaulting to today.
|
|
"""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "today notes")
|
|
_touch(ws / "daily" / "2026-08-12" / "old-note.md", "yesterday notes")
|
|
_write_state(
|
|
ws,
|
|
open_topics=[_state_topic("证据改挂主题"), _state_topic("昨日证据主题")],
|
|
)
|
|
reply = _reply(
|
|
updates=[
|
|
{"id": topic_id("证据改挂主题"), "action": "update", "evidence": "daily/2026-08-11/absent.md"},
|
|
{
|
|
"id": topic_id("昨日证据主题"),
|
|
"action": "update",
|
|
"evidence": "daily/2026-08-12/old-note.md#L5",
|
|
"confidence": 0.9,
|
|
},
|
|
],
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply])
|
|
state = context.get("proactive")
|
|
assert state["updates_applied"] == 1
|
|
topics = {t["id"]: t for t in _read_state(ws)["open_topics"]}
|
|
rejected = topics[topic_id("证据改挂主题")]
|
|
assert rejected["last_evidence_at"] == DAY # untouched, degraded to keep
|
|
assert rejected["evidence"] != "daily/2026-08-11/absent.md"
|
|
applied = topics[topic_id("昨日证据主题")]
|
|
assert applied["last_evidence_at"] == "2026-08-12" # parsed from evidence path
|
|
assert applied["evidence"] == "daily/2026-08-12/old-note.md#L5"
|
|
assert applied["confidence"] == 0.9
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_no_embedding_configured(tmp_path):
|
|
"""No as_embedding anywhere: the chain runs and dedups exactly (BM25-only).
|
|
|
|
Paraphrase-level candidates survive (no semantic gate), exact normalize
|
|
duplicates of historical exposure still drop; nothing crashes or skips.
|
|
"""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "notes")
|
|
_write_state(ws, open_topics=[_state_topic("已知主题")])
|
|
_write_interests_v1(ws, "2026-08-12", [{"title": "历史曝光主题", "reason": "x"}])
|
|
reply = _reply(
|
|
follow_ups=[
|
|
_topic("已知主题的不同说法", paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("历史曝光主题", paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply]) # no as_embedding in context/app
|
|
state = context.get("proactive")
|
|
assert state["dropped_known"] == 0
|
|
assert state["dropped_duplicate"] == 1
|
|
titles = [t["title"] for t in _read_state(ws)["open_topics"]]
|
|
assert "已知主题的不同说法" in titles
|
|
assert "历史曝光主题" not in titles
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F1 contracts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_normalize_and_topic_id_frozen():
|
|
"""A7 frozen contract: NFKC+casefold, keep L/N only; id is a 12-hex sha1 prefix."""
|
|
assert normalize_topic("Memory Search: 可解释性评估!") == "memorysearch可解释性评估"
|
|
assert topic_id("A") == topic_id("a") == topic_id(" A ")
|
|
assert topic_id("A") != topic_id("B")
|
|
assert len(topic_id("anything")) == 12
|
|
|
|
|
|
def test_legacy_file_parse(tmp_path):
|
|
"""v1 files parse without error; new fields take deterministic defaults (A2)."""
|
|
content = (
|
|
'date: "2026-08-13"\n'
|
|
"topic_count: 1\n"
|
|
"topics:\n"
|
|
" - title: Retrieval quality\n"
|
|
" reason: Search behavior changed.\n"
|
|
" evidence: daily/2026-08-13/session.md\n"
|
|
)
|
|
_touch(_interests(tmp_path), content)
|
|
topics, is_v1, push = parse_interests_topics(yaml.safe_load(content), DAY)
|
|
assert is_v1 is True and push is True
|
|
topic = topics[0]
|
|
assert topic.id == topic_id("Retrieval quality")
|
|
assert topic.kind == "interest_extend"
|
|
assert topic.confidence == 0.5
|
|
assert topic.first_seen == DAY
|
|
assert topic.last_evidence_at == DAY
|
|
|
|
async def run():
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(tmp_path)))
|
|
assert response.success is True
|
|
assert response.metadata["push"] is True
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_field_fallback():
|
|
"""Illegal kind/confidence values fall back deterministically."""
|
|
topic = ProactiveTopic(title="T", reason="R", kind="bogus", confidence="not-a-number")
|
|
assert topic.kind == "interest_extend"
|
|
assert topic.confidence == 0.5
|
|
assert ProactiveTopic(confidence=1.7).confidence == 1.0
|
|
assert ProactiveTopic(confidence=-3).confidence == 0.0
|
|
|
|
|
|
def test_resolved_registry(tmp_path):
|
|
"""Resolved ids disappear from carry-forward and from F5 reads."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
id_a, id_b = topic_id("Topic A"), topic_id("Topic B")
|
|
_write_state(
|
|
ws,
|
|
open_topics=[_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
|
|
resolved=[{"id": id_b, "title": "Topic B", "resolved_at": DAY, "evidence": ""}],
|
|
)
|
|
state_file, needs_bootstrap = load_state(ws)
|
|
assert needs_bootstrap is False
|
|
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
|
|
assert [t.title for t in carry_all] == ["Topic A"]
|
|
|
|
_write_interests_v2(
|
|
ws,
|
|
DAY,
|
|
[_state_topic("Topic A", topic_id_value=id_a), _state_topic("Topic B", topic_id_value=id_b)],
|
|
)
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
|
|
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
|
|
assert [t["title"] for t in response.answer["topics"]] == ["Topic A"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_carry_forward_bootstrap(tmp_path):
|
|
"""First run (missing state file) bootstraps from interests.yaml history; first_seen = min."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_interests_v1(ws, "2026-08-11", [_topic("Topic T", paths=["daily/2026-08-11/a.md"])])
|
|
_write_interests_v1(
|
|
ws,
|
|
"2026-08-12",
|
|
[_topic("Topic T", paths=["daily/2026-08-12/a.md"]), _topic("Topic U", paths=["daily/2026-08-12/b.md"])],
|
|
)
|
|
|
|
# Upgrade path: no _proactive.yaml yet -> one-time bootstrap from history.
|
|
state_file, needs_bootstrap = load_state(ws)
|
|
assert needs_bootstrap is True
|
|
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
|
|
by_title = {t.title: t for t in carry_all}
|
|
assert set(by_title) == {"Topic T", "Topic U"}
|
|
assert by_title["Topic T"].first_seen == "2026-08-11" # min across days, not latest
|
|
assert by_title["Topic U"].first_seen == "2026-08-12"
|
|
|
|
persisted = _read_state(ws)
|
|
assert len(persisted["open_topics"]) == 2
|
|
_, needs_bootstrap2 = load_state(ws)
|
|
assert needs_bootstrap2 is False # no re-bootstrap once the key exists
|
|
|
|
# Empty open_topics list is a normal state, not a bootstrap trigger.
|
|
_write_state(ws, open_topics=[])
|
|
_, needs_bootstrap3 = load_state(ws)
|
|
assert needs_bootstrap3 is False
|
|
|
|
# An existing file lacking the key (e.g. hand-edited) still triggers it.
|
|
_write_state(ws, budget={}, omit_open_topics=True)
|
|
_, needs_bootstrap4 = load_state(ws)
|
|
assert needs_bootstrap4 is True
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_state_file_corrupt_no_bootstrap(tmp_path):
|
|
"""Corrupt _proactive.yaml rebuilds empty WITHOUT bootstrap (spec F1.3/A2/A5)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_interests_v1(ws, "2026-08-12", [_topic("Topic U", paths=["daily/2026-08-12/b.md"])])
|
|
_touch(ws / "daily" / "_proactive.yaml", "version: [unclosed")
|
|
|
|
state_file, needs_bootstrap = load_state(ws)
|
|
assert needs_bootstrap is False
|
|
assert state_file.open_topics == []
|
|
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20, needs_bootstrap=needs_bootstrap)
|
|
assert carry_all == []
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_bootstrap_upgrade_chain(tmp_path):
|
|
"""Upgrade path: the first refresh run seeds the truth source from interests history."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_interests_v1(ws, "2026-08-12", [_topic("历史主题", paths=["daily/2026-08-12/a.md"])])
|
|
_touch(ws / "daily" / DAY / "s1.md", "new evidence")
|
|
reply = _reply(follow_ups=[_topic("新主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
|
|
_, _, _, context, responses = await _run_chain(ws, [reply])
|
|
assert all(r.success for r in responses)
|
|
|
|
assert context.get("proactive")["carry_forward_count"] == 1 # history reached the chain context
|
|
|
|
truth_titles = {t["title"] for t in _read_state(ws)["open_topics"]}
|
|
assert truth_titles == {"历史主题", "新主题"}
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert {t["title"] for t in data["topics"]} == {"历史主题", "新主题"}
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_carry_forward_expiry(tmp_path):
|
|
"""Topics older than carry_forward_days drop from carry-forward and are pruned."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_state(ws, open_topics=[_state_topic("Old Topic", day="2026-07-01")])
|
|
state_file, _ = load_state(ws)
|
|
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
|
|
assert carry_all == []
|
|
|
|
_touch(ws / "daily" / DAY / "note.md", "new evidence")
|
|
reply = _reply(follow_ups=[], updates=[])
|
|
_, _, _, context, responses = await _run_chain(ws, [reply])
|
|
assert all(r.success for r in responses)
|
|
assert context.get("proactive")["carry_forward_count"] == 0
|
|
assert _read_state(ws)["open_topics"] == [] # pruned from the truth source
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_material_filter(tmp_path):
|
|
"""Day indexes, ``_*`` files and interests.yaml never enter the material set (INV-11)."""
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / f"{DAY}.md", "day index")
|
|
_touch(ws / "daily" / DAY / "_hidden.md", "underscore file")
|
|
_touch(ws / "daily" / DAY / "interests.yaml", "topics: []\n")
|
|
_touch(ws / "daily" / DAY / "good.md", "material")
|
|
assert scan_material_daily(ws, DAY, "daily", 2) == [f"daily/{DAY}/good.md"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F2 discovery + chain behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_refresh_chain_e2e(tmp_path):
|
|
"""extract -> topics -> finish runs end to end."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
note = _touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan; nothing landed")
|
|
reply = _reply(
|
|
follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=["daily/2026-08-13/session.md"])],
|
|
)
|
|
app = ApplicationContext(workspace_dir=str(ws))
|
|
wrapper = _AgentWrapper([reply])
|
|
catalog = _Catalog()
|
|
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
|
|
|
|
extract = ProactiveExtractStep(app_context=app)
|
|
resp_extract = await extract(context)
|
|
topics = ProactiveTopicsStep(app_context=app)
|
|
resp_topics = await topics(context)
|
|
finish = ProactiveFinishStep(app_context=app)
|
|
resp_finish = await finish(context)
|
|
|
|
assert resp_extract.success and resp_topics.success and resp_finish.success
|
|
state = context.get("proactive")
|
|
assert state["llm_calls"] == 1
|
|
assert state["push"] is True
|
|
assert state["file_skip_reason"] == ""
|
|
assert state["interests_written"] is True
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["version"] == 2
|
|
assert data["push"] is True
|
|
assert data["topics"][0]["title"] == "记忆检索的可解释性评估"
|
|
assert data["topics"][0]["kind"] == "follow_up"
|
|
|
|
truth = _read_state(ws)
|
|
assert len(truth["open_topics"]) == 1
|
|
checkpoint_paths = {n.path for n in catalog.upserts}
|
|
assert checkpoint_paths == {note.relative_to(ws).as_posix()} # interests.yaml not checkpointed (R6)
|
|
assert catalog.dumps == 1
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_open_loop_follow_up(tmp_path):
|
|
"""Branch A emits follow_ups with stable ids; paths restricted to M."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "open loop content")
|
|
reply = _reply(
|
|
follow_ups=[
|
|
_topic("未决事项甲", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("越界事项", confidence=0.9, paths=["daily/2026-08-01/absent.md"]),
|
|
],
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply])
|
|
state = context.get("proactive")
|
|
assert len(state["follow_ups"]) == 1
|
|
follow_up = state["follow_ups"][0]
|
|
assert follow_up["kind"] == "follow_up"
|
|
assert follow_up["id"] == topic_id("未决事项甲")
|
|
assert state["dropped_missing"] == 1 # out-of-M paths dropped, never repaired
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert [t["title"] for t in data["topics"]] == ["未决事项甲"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_updates_actions(tmp_path):
|
|
"""keep/update/resolve act on the truth source; unknown ids ignored."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
id_a, id_b, id_c = topic_id("Topic A"), topic_id("Topic B"), topic_id("Topic C")
|
|
_write_state(
|
|
ws,
|
|
open_topics=[
|
|
{**_state_topic("Topic A", day="2026-08-12", topic_id_value=id_a)},
|
|
{**_state_topic("Topic B", day="2026-08-12", topic_id_value=id_b)},
|
|
{**_state_topic("Topic C", day="2026-08-12", topic_id_value=id_c)},
|
|
],
|
|
)
|
|
_touch(ws / "daily" / DAY / "session.md", "new evidence today")
|
|
evidence = f"daily/{DAY}/session.md"
|
|
reply = _reply(
|
|
follow_ups=[],
|
|
updates=[
|
|
{"id": id_a, "action": "keep"},
|
|
{"id": id_b, "action": "update", "evidence": evidence},
|
|
{"id": id_c, "action": "resolve", "evidence": evidence},
|
|
{"id": "ffffffffffff", "action": "resolve"},
|
|
],
|
|
)
|
|
_, _, _, context, responses = await _run_chain(ws, [reply])
|
|
assert all(r.success for r in responses)
|
|
state = context.get("proactive")
|
|
assert state["updates_applied"] == 1
|
|
assert state["updates_resolved"] == 1
|
|
|
|
truth = _read_state(ws)
|
|
by_id = {t["id"]: t for t in truth["open_topics"]}
|
|
assert set(by_id) == {id_a, id_b}
|
|
assert by_id[id_a]["last_evidence_at"] == "2026-08-12" # keep: untouched
|
|
assert by_id[id_b]["last_evidence_at"] == DAY
|
|
assert by_id[id_b]["evidence"] == evidence
|
|
resolved_ids = {r["id"] for r in truth["resolved"]}
|
|
assert resolved_ids == {id_c}
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert {t["title"] for t in data["topics"]} == {"Topic A", "Topic B"}
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_resolved_resurrect(tmp_path):
|
|
"""A candidate hitting a tombstone resurrects it: tombstone removed, first_seen kept."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
tid = topic_id("Revived Topic")
|
|
_write_state(
|
|
ws,
|
|
resolved=[
|
|
{
|
|
"id": tid,
|
|
"title": "Revived Topic",
|
|
"resolved_at": "2026-08-12",
|
|
"first_seen": "2026-08-08",
|
|
"evidence": "",
|
|
},
|
|
],
|
|
)
|
|
_touch(ws / "daily" / DAY / "session.md", "the plan was reopened today")
|
|
reply = _reply(follow_ups=[_topic("Revived Topic", confidence=0.7, paths=[f"daily/{DAY}/session.md"])])
|
|
_, _, _, context, _ = await _run_chain(ws, [reply])
|
|
state = context.get("proactive")
|
|
assert [c["title"] for c in state["candidates"]] == ["Revived Topic"]
|
|
|
|
truth = _read_state(ws)
|
|
assert truth["resolved"] == [] # tombstone removed
|
|
by_id = {t["id"]: t for t in truth["open_topics"]}
|
|
assert tid in by_id
|
|
assert by_id[tid]["first_seen"] == "2026-08-08" # original age anchor kept
|
|
assert by_id[tid]["last_evidence_at"] == DAY
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_trim_freshness_first(tmp_path):
|
|
"""max_topics trims by the freshness-first order, regardless of kind (v5 aging)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
stale = [
|
|
{**_state_topic(f"Stale Follow {i}", day="2026-08-05", kind="follow_up"), "last_evidence_at": "2026-08-05"}
|
|
for i in range(3)
|
|
]
|
|
fresh = [_state_topic(f"Fresh Extend {i}", day=DAY, kind="interest_extend") for i in range(2)]
|
|
_write_state(ws, open_topics=stale + fresh)
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
reply = _reply(follow_ups=[_topic("New Follow", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
|
|
_, _, _, context, _ = await _run_chain(ws, [reply], topics_kwargs={"max_topics": 4})
|
|
titles = [t["title"] for t in context.get("proactive")["topics_out"]]
|
|
assert len(titles) == 4
|
|
assert titles[0] == "New Follow" # today's follow_up first
|
|
assert set(titles[1:3]) == {"Fresh Extend 0", "Fresh Extend 1"} # today's extends next
|
|
assert titles[3].startswith("Stale Follow") # only one stale topic survives the cap
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_dedup_fallback(tmp_path):
|
|
"""M1 fallback: normalize_topic exact match against recent interests drops duplicates."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_interests_v1(ws, "2026-08-12", [_topic("Retrieval Evaluation", paths=["daily/2026-08-12/a.md"])])
|
|
# Seed an empty truth source so the history topic stays out of open_topics
|
|
# (otherwise first-run bootstrap would merge the same-id candidate instead
|
|
# of dropping it as a duplicate).
|
|
_write_state(ws, open_topics=[])
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
reply = _reply(
|
|
follow_ups=[_topic("retrieval evaluation!!", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply])
|
|
state = context.get("proactive")
|
|
assert state["dropped_duplicate"] == 1
|
|
assert state["candidates"] == []
|
|
assert state["file_skip_reason"] == "all_duplicates" # metadata-only since v5 (R7)
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["push"] is False
|
|
assert data["topics"] == []
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_skip_low_confidence(tmp_path):
|
|
"""Candidates under min_push_confidence persist but are not pushed."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "weak signal")
|
|
reply = _reply(follow_ups=[_topic("弱信号主题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])])
|
|
_, _, _, context, _ = await _run_chain(ws, [reply])
|
|
state = context.get("proactive")
|
|
assert state["push"] is False
|
|
assert state["file_skip_reason"] == "low_confidence"
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["push"] is False
|
|
assert "skip_reason" not in data # metadata-only since v5 (R7)
|
|
assert [t["title"] for t in data["topics"]] == ["弱信号主题"] # rendered from truth source
|
|
assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["弱信号主题"] # persisted
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_no_new_evidence_zero_llm(tmp_path):
|
|
"""No changed material -> early exit with zero LLM calls."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
note = _touch(ws / "daily" / DAY / "session.md", "already seen")
|
|
rel = note.relative_to(ws).as_posix()
|
|
catalog = _Catalog([FileNode(path=rel, st_mtime=note.stat().st_mtime)])
|
|
wrapper = _AgentWrapper(["should never be consumed"])
|
|
_, wrapper, catalog, context, responses = await _run_chain(
|
|
ws,
|
|
wrapper=wrapper,
|
|
catalog=catalog,
|
|
)
|
|
state = context.get("proactive")
|
|
assert state["early_exit"] == "no_new_evidence"
|
|
assert wrapper.calls == 0
|
|
assert not _interests(ws).exists()
|
|
assert catalog.upserts == [] and catalog.dumps == 0
|
|
assert all(r.success for r in responses)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_push_sticky(tmp_path):
|
|
"""Within one day push only goes up: derived from the cumulative truth source (R1)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "s1.md", "strong evidence")
|
|
reply1 = _reply(follow_ups=[_topic("高置信主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
|
|
_, _, _, context1, _ = await _run_chain(ws, [reply1])
|
|
assert context1.get("proactive")["push"] is True
|
|
assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
|
|
|
|
_touch(ws / "daily" / DAY / "s2.md", "weak evidence")
|
|
reply2 = _reply(follow_ups=[_topic("低置信主题", confidence=0.3, paths=[f"daily/{DAY}/s2.md"])])
|
|
_, _, _, context2, _ = await _run_chain(ws, [reply2])
|
|
state2 = context2.get("proactive")
|
|
assert state2["push"] is True # sticky: the 0.9 topic discovered today persists in truth source
|
|
assert state2["file_skip_reason"] == ""
|
|
assert yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["push"] is True
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_idempotent_write(tmp_path):
|
|
"""Same render skips writing; nightly v1 is adopted via bootstrap (v5 R1)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "s1.md", "evidence one")
|
|
reply1 = _reply(follow_ups=[_topic("主题甲", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
|
|
_, _, _, context1, _ = await _run_chain(ws, [reply1])
|
|
assert context1.get("proactive")["interests_written"] is True
|
|
mtime_before = _interests(ws).stat().st_mtime
|
|
|
|
_touch(ws / "daily" / DAY / "s2.md", "evidence two, no new topics")
|
|
reply2 = _reply(follow_ups=[], updates=[])
|
|
time.sleep(0.01)
|
|
_, _, _, context2, _ = await _run_chain(ws, [reply2])
|
|
assert context2.get("proactive")["interests_written"] is False # identical render
|
|
assert _interests(ws).stat().st_mtime == mtime_before
|
|
|
|
asyncio.run(run())
|
|
|
|
async def run_v1_nightly_adoption():
|
|
"""v5 R1: no v1 guard - first-run bootstrap adopts the nightly topic."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
ws = Path(tmp)
|
|
nightly = [
|
|
{
|
|
"title": "Nightly topic",
|
|
"reason": "written by dream",
|
|
"evidence": f"daily/{DAY}/n.md",
|
|
"paths": [f"daily/{DAY}/n.md"],
|
|
},
|
|
]
|
|
_write_interests_v1(ws, DAY, nightly)
|
|
_touch(ws / "daily" / DAY / "s1.md", "material")
|
|
reply = _reply(follow_ups=[], updates=[])
|
|
_, _, _, context, _ = await _run_chain(ws, [reply])
|
|
state = context.get("proactive")
|
|
# Bootstrap gives the nightly topic first_seen = file date (today);
|
|
# push is derived true because its fallback confidence 0.5 passes.
|
|
assert state["push"] is True
|
|
assert state["interests_written"] is True
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["version"] == 2
|
|
assert [t["title"] for t in data["topics"]] == ["Nightly topic"]
|
|
|
|
asyncio.run(run_v1_nightly_adoption())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F3 isolation / interleave
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_refresh_isolation(tmp_path):
|
|
"""Refresh never writes digest, never touches the dream catalog or day index."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
digest_file = _touch(ws / "digest" / "wiki" / "existing.md", "digest content")
|
|
day_index = _touch(ws / "daily" / f"{DAY}.md", "index content")
|
|
_touch(ws / "daily" / DAY / "session.md", "conversation")
|
|
reply = _reply(follow_ups=[_topic("隔离主题", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
|
|
proactive_catalog = _Catalog()
|
|
dream_catalog = _Catalog()
|
|
_, _, _, _, responses = await _run_chain(ws, [reply], catalog=proactive_catalog)
|
|
assert all(r.success for r in responses)
|
|
|
|
assert digest_file.read_text(encoding="utf-8") == "digest content"
|
|
assert day_index.read_text(encoding="utf-8") == "index content"
|
|
assert not dream_catalog.upserts and dream_catalog.dumps == 0 and not dream_catalog.deleted
|
|
assert proactive_catalog.dumps == 1
|
|
assert {n.path for n in proactive_catalog.upserts} == {f"daily/{DAY}/session.md"}
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_nightly_overwrite_continuity(tmp_path):
|
|
"""Nightly v1 overwrite cannot break continuity: truth source restores exposure."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "s1.md", "round one")
|
|
reply1 = _reply(follow_ups=[_topic("持续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
|
|
await _run_chain(ws, [reply1])
|
|
truth_before = _read_state(ws)["open_topics"]
|
|
|
|
# Nightly dream overwrites the exposure product with a v1 file.
|
|
_write_interests_v1(ws, DAY, [_topic("Nightly only", paths=[f"daily/{DAY}/s1.md"])])
|
|
|
|
_touch(ws / "daily" / DAY / "s2.md", "round two")
|
|
reply2 = _reply(follow_ups=[], updates=[])
|
|
_, _, _, context, _ = await _run_chain(ws, [reply2])
|
|
assert context.get("proactive")["interests_written"] is True
|
|
|
|
assert _read_state(ws)["open_topics"] == truth_before # truth source intact
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["version"] == 2 # exposure restored from the truth source
|
|
assert [t["title"] for t in data["topics"]] == ["持续主题"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_refresh_nightly_interleave(tmp_path):
|
|
"""Refresh and nightly writes interleave without corrupting state or continuity."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "s1.md", "round one")
|
|
reply1 = _reply(follow_ups=[_topic("连续主题", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])])
|
|
await _run_chain(ws, [reply1])
|
|
|
|
# Nightly dream overwrites interests.yaml with a v1 file.
|
|
_write_interests_v1(ws, DAY, [_topic("Nightly topic", paths=[f"daily/{DAY}/s1.md"])])
|
|
nightly = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert nightly.get("version") is None
|
|
|
|
# Next refresh round re-renders from the intact truth source.
|
|
_touch(ws / "daily" / DAY / "s2.md", "round two")
|
|
reply2 = _reply(follow_ups=[], updates=[])
|
|
_, _, _, _, responses = await _run_chain(ws, [reply2])
|
|
assert all(r.success for r in responses)
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["version"] == 2
|
|
assert data["push"] is True
|
|
assert [t["title"] for t in data["topics"]] == ["连续主题"]
|
|
assert [t["title"] for t in _read_state(ws)["open_topics"]] == ["连续主题"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_nightly_unit_set_invariant():
|
|
"""Dream extract sees identical units whether or not refresh ran first (INV-9)."""
|
|
|
|
dream_reply = (
|
|
"```yaml\n"
|
|
"units:\n"
|
|
" - name: eval-plan-gap\n"
|
|
" bucket: wiki\n"
|
|
" summary: The evaluation plan is discussed but never lands.\n"
|
|
f" paths: [daily/{DAY}/session.md]\n"
|
|
"topics: []\n"
|
|
"```"
|
|
)
|
|
|
|
async def dream_units(ws) -> list[dict]:
|
|
catalog = _Catalog()
|
|
wrapper = _AgentWrapper([dream_reply])
|
|
step = DreamExtractStep(scan_days=1, app_context=ApplicationContext(workspace_dir=str(ws)))
|
|
with patch("reme.steps.evolve.dream.extract.refresh_day_index", return_value={}):
|
|
response = await step(
|
|
RuntimeContext(
|
|
date=DAY,
|
|
file_catalog=catalog,
|
|
file_store=_FileStore(ws),
|
|
agent_wrapper=wrapper,
|
|
as_llm=_DummyModel(),
|
|
),
|
|
)
|
|
assert response.success is True
|
|
return response.metadata["dream"]["units"]
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp_a, tempfile.TemporaryDirectory() as tmp_b:
|
|
ws_a, ws_b = Path(tmp_a), Path(tmp_b)
|
|
for ws in (ws_a, ws_b):
|
|
_touch(ws / "daily" / DAY / "session.md", "same discussion content")
|
|
|
|
# Run the full proactive chain on ws_a only.
|
|
reply = _reply(follow_ups=[_topic("主动主题", confidence=0.9, paths=[f"daily/{DAY}/session.md"])])
|
|
await _run_chain(ws_a, [reply])
|
|
|
|
units_a = await dream_units(ws_a)
|
|
units_b = await dream_units(ws_b)
|
|
assert units_a == units_b
|
|
assert len(units_a) == 1
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F4 short-circuit, budget, timeout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_llm_timeout(tmp_path):
|
|
"""A hanging reply times out, short-circuits the round, and retries next round."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "fresh material")
|
|
wrapper = _AgentWrapper(["never returned"], delay=0.5)
|
|
catalog = _Catalog()
|
|
_, wrapper, catalog, context, responses = await _run_chain(
|
|
ws,
|
|
wrapper=wrapper,
|
|
catalog=catalog,
|
|
extract_kwargs={"llm_timeout_seconds": 0.1},
|
|
)
|
|
assert context.get("proactive_skip")["reason"] == "llm_timeout"
|
|
assert wrapper.calls == 1
|
|
assert not _interests(ws).exists()
|
|
assert catalog.upserts == [] and catalog.dumps == 0
|
|
assert all(r.success for r in responses)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_parse_failure_retry_then_empty(tmp_path):
|
|
"""Unparseable output retries once; still failing yields empty, not error."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
wrapper = _AgentWrapper(["this is not structured at all", _reply(follow_ups=[], updates=[])])
|
|
_, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
|
|
assert wrapper.calls == 2
|
|
assert all(r.success for r in responses)
|
|
assert context.get("proactive")["follow_ups"] == []
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_parse_double_failure_keeps_material_uncheckpointed(tmp_path):
|
|
"""Two unusable replies skip without checkpoint; material retries next round (audit #1)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
wrapper = _AgentWrapper(["garbage one", "garbage two"])
|
|
catalog = _Catalog()
|
|
app = ApplicationContext(workspace_dir=str(ws))
|
|
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
|
|
extract = ProactiveExtractStep(app_context=app)
|
|
resp_extract = await extract(context)
|
|
topics = ProactiveTopicsStep(app_context=app)
|
|
resp_topics = await topics(context)
|
|
finish = ProactiveFinishStep(app_context=app)
|
|
resp_finish = await finish(context)
|
|
assert all(r.success for r in (resp_extract, resp_topics, resp_finish))
|
|
assert wrapper.calls == 2
|
|
assert context.get("proactive_skip") == {"reason": "extract_parse_failed"}
|
|
assert not catalog.upserts # nothing checkpointed; material stays changed
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_parse_extract_reply_schema_gate():
|
|
"""Non-empty replies without contract sections count as parse failures (audit #1)."""
|
|
assert parse_extract_reply("followups:\n - title: misspelled section\n") == {}
|
|
assert parse_extract_reply("foo: 1") == {}
|
|
assert parse_extract_reply("```yaml\nfollow_ups: []\n```") == {"follow_ups": []}
|
|
assert parse_extract_reply("follow_ups: []\nupdates: []") == {"follow_ups": [], "updates": []}
|
|
|
|
|
|
def test_schema_error_reply_retries(tmp_path):
|
|
"""Non-empty YAML with wrong section names retries once like a parse failure (audit #1)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
bad = "followups:\n - title: 拼错段名的话题\n reason: because\n"
|
|
good = _reply(follow_ups=[_topic("补救话题", paths=[f"daily/{DAY}/session.md"])])
|
|
wrapper = _AgentWrapper([bad, good])
|
|
_, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper)
|
|
assert wrapper.calls == 2
|
|
assert all(r.success for r in responses)
|
|
assert [t["title"] for t in context.get("proactive")["follow_ups"]] == ["补救话题"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_pack_paths_total_budget(tmp_path):
|
|
"""max_total_chars keeps the first file and reports how many were omitted (audit #5)."""
|
|
for name in ("a.md", "b.md", "c.md"):
|
|
_touch(tmp_path / name, "x" * 100)
|
|
packed = pack_paths(tmp_path, ["a.md", "b.md", "c.md"], limit_per_file=60000, max_total_chars=150)
|
|
assert "### a.md" in packed
|
|
assert "### b.md" not in packed
|
|
assert "(omitted 2 file(s)" in packed
|
|
|
|
|
|
def test_extract_material_budget(tmp_path):
|
|
"""Blob packing honors max_total_chars; newest daily material is packed first (audit #5)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / "2026-08-12" / "big-old.md", "x" * 500)
|
|
_touch(ws / "daily" / DAY / "fresh.md", "fresh material")
|
|
reply = _reply(follow_ups=[_topic("预算话题", paths=[f"daily/{DAY}/fresh.md"])])
|
|
wrapper = _AgentWrapper([reply])
|
|
await _run_chain(ws, wrapper=wrapper, extract_kwargs={"max_total_chars": 200})
|
|
blob = wrapper.last_inputs[0]
|
|
assert "### daily/2026-08-13/fresh.md" in blob # newest day survives the budget
|
|
assert "### daily/2026-08-12/big-old.md" not in blob # omitted from the material section
|
|
assert "omitted" in blob
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F5 read interface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_proactive_backward_compat(tmp_path):
|
|
"""Default params on a v1 file keep the legacy answer field-for-field."""
|
|
|
|
async def run():
|
|
content = (
|
|
"date: 2026-05-28\n"
|
|
"topics:\n"
|
|
" - title: Retrieval quality\n"
|
|
" reason: Search behavior changed repeatedly.\n"
|
|
" evidence: daily/2026-05-28/session.md\n"
|
|
)
|
|
_touch(tmp_path / "daily" / "2026-05-28" / "interests.yaml", content)
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
response = await step(
|
|
RuntimeContext(date="2026-05-28", include_content=True, file_store=_FileStore(tmp_path)),
|
|
)
|
|
assert response.success is True
|
|
assert response.answer == {
|
|
"summary": "Read 1 proactive topic(s) from daily/2026-05-28/interests.yaml",
|
|
"topics": [
|
|
{
|
|
"title": "Retrieval quality",
|
|
"reason": "Search behavior changed repeatedly.",
|
|
"evidence": "daily/2026-05-28/session.md",
|
|
"paths": [],
|
|
},
|
|
],
|
|
"content": content,
|
|
}
|
|
assert response.metadata["topics"] == response.answer["topics"]
|
|
assert response.metadata["content"] == content
|
|
assert response.metadata["push"] is True # additive keys only
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_proactive_answer_omits_unrequested_content(tmp_path):
|
|
"""Raw YAML is absent from the primary answer when include_content is false."""
|
|
|
|
async def run():
|
|
_touch(tmp_path / "daily" / DAY / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n")
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
|
|
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(tmp_path)))
|
|
|
|
assert response.success is True
|
|
assert "content" not in response.answer
|
|
assert response.answer["topics"][0]["title"] == "Topic"
|
|
assert response.metadata["content"] == ""
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_proactive_missing_file_is_an_explicit_skip(tmp_path):
|
|
"""A missing single-day exposure remains distinguishable without metadata."""
|
|
|
|
async def run():
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
|
|
response = await step(RuntimeContext(date=DAY, file_store=_FileStore(tmp_path)))
|
|
|
|
assert response.success is True
|
|
assert response.answer == f"Skipped: interests file not found at daily/{DAY}/interests.yaml"
|
|
assert response.metadata["skipped"] is True
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_strict_topic_loading_rejects_lossy_fields(tmp_path):
|
|
"""Strict legacy loading rejects values that would otherwise be discarded."""
|
|
target = _touch(tmp_path / "interests.yaml", "topics:\n - title: Topic\n reason: Reason\n custom: keep me\n")
|
|
|
|
try:
|
|
load_yaml_topics(target, strict=True)
|
|
except ValueError as exc:
|
|
assert "unknown field(s): custom" in str(exc)
|
|
else:
|
|
raise AssertionError("strict topic loading accepted a lossy field")
|
|
|
|
|
|
def test_strict_topic_loading_rejects_invalid_field_types(tmp_path):
|
|
"""Strict legacy loading rejects list fields that would otherwise be normalized away."""
|
|
target = _touch(
|
|
tmp_path / "interests.yaml",
|
|
"topics:\n - title: Topic\n reason: Reason\n paths: daily/source.md\n",
|
|
)
|
|
|
|
try:
|
|
load_yaml_topics(target, strict=True)
|
|
except ValueError as exc:
|
|
assert "topics[0].paths must be a list of non-empty strings" in str(exc)
|
|
else:
|
|
raise AssertionError("strict topic loading accepted an invalid paths type")
|
|
|
|
|
|
def test_horizon_merge(tmp_path):
|
|
"""Horizon>1 reads the truth source filtered by evidence recency (v5 R4)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
id_x, id_y = topic_id("Topic X"), topic_id("Topic Y")
|
|
_write_state(
|
|
ws,
|
|
open_topics=[
|
|
_state_topic("Topic X", day=DAY, topic_id_value=id_x, confidence=0.9),
|
|
_state_topic("Topic Y", day="2026-08-09", topic_id_value=id_y, kind="interest_extend", confidence=0.6),
|
|
],
|
|
)
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), horizon_days=3)
|
|
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
|
|
assert response.success is True
|
|
assert [t["title"] for t in response.answer["topics"]] == ["Topic X"] # Y evidence too old
|
|
assert response.metadata["path"] == "daily/_proactive.yaml"
|
|
|
|
# A wider horizon includes the older topic again.
|
|
response2 = await step(
|
|
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws), horizon_days=7),
|
|
)
|
|
assert {t["title"] for t in response2.answer["topics"]} == {"Topic X", "Topic Y"}
|
|
|
|
# Historical reads are bounded on both sides and cannot expose topics
|
|
# whose evidence is newer than the requested date.
|
|
historical = await step(
|
|
RuntimeContext(date="2026-08-10", include_content=False, file_store=_FileStore(ws), horizon_days=7),
|
|
)
|
|
assert [t["title"] for t in historical.answer["topics"]] == ["Topic Y"]
|
|
|
|
# Empty truth source -> skipped, not an error.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
empty_ws = Path(tmp)
|
|
response3 = await step(
|
|
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(empty_ws), horizon_days=3),
|
|
)
|
|
assert response3.success is True
|
|
assert response3.metadata["skipped"] is True
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_min_confidence(tmp_path):
|
|
"""min_confidence filters v2 topics; v1 topics fall back to 0.5."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_interests_v2(
|
|
ws,
|
|
DAY,
|
|
[
|
|
_state_topic("High topic", confidence=0.7),
|
|
_state_topic("Low topic", kind="interest_extend", confidence=0.3),
|
|
],
|
|
)
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)), min_confidence=0.5)
|
|
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
|
|
assert [t["title"] for t in response.answer["topics"]] == ["High topic"]
|
|
|
|
# Default min_confidence=0.4 sits below the 0.5 fallback but above weak 0.3.
|
|
step_default = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
|
|
default_response = await step_default(
|
|
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)),
|
|
)
|
|
assert [t["title"] for t in default_response.answer["topics"]] == ["High topic"]
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
ws_v1 = Path(tmp)
|
|
_write_interests_v1(ws_v1, DAY, [_topic("Legacy topic", paths=[f"daily/{DAY}/a.md"])])
|
|
step_v1 = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws_v1)))
|
|
ok = await step_v1(
|
|
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.5),
|
|
)
|
|
assert [t["title"] for t in ok.answer["topics"]] == ["Legacy topic"] # fallback 0.5 passes
|
|
filtered = await step_v1(
|
|
RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws_v1), min_confidence=0.6),
|
|
)
|
|
assert filtered.answer["topics"] == []
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# M2: extends branch + semantic dedup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extend_from_daily_material(tmp_path):
|
|
"""Branch B infers extends from daily material; out-of-M paths are dropped."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "discussion about source attribution")
|
|
reply = _reply(
|
|
follow_ups=[],
|
|
extends=[
|
|
_topic("引用质量评测", confidence=0.5, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("越界扩展", confidence=0.5, paths=["daily/2026-08-01/absent.md"]),
|
|
],
|
|
)
|
|
_, wrapper, _, context, _ = await _run_chain(ws, [reply])
|
|
assert "extends" in wrapper.last_inputs[0] # extends section rendered
|
|
state = context.get("proactive")
|
|
assert len(state["extends"]) == 1
|
|
extend = state["extends"][0]
|
|
assert extend["kind"] == "interest_extend"
|
|
assert f"daily/{DAY}/session.md" in extend["paths"]
|
|
assert state["dropped_missing"] == 1
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert [t["title"] for t in data["topics"]] == ["引用质量评测"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_extends_disabled_m1_shape():
|
|
"""extends_enabled=False restores the M1 prompt shape and ignores extends output."""
|
|
|
|
async def run_once(extends_enabled: bool):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
ws = Path(tmp)
|
|
_touch(ws / "daily" / DAY / "s1.md", "material")
|
|
reply = _reply(
|
|
follow_ups=[_topic("Follow Up Topic", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])],
|
|
extends=[_topic("Extends Topic", confidence=0.9, paths=[f"daily/{DAY}/s1.md"])],
|
|
)
|
|
_, wrapper, _, context, _ = await _run_chain(
|
|
ws,
|
|
[reply],
|
|
extract_kwargs={"extends_enabled": extends_enabled},
|
|
)
|
|
return wrapper, context.get("proactive")
|
|
|
|
async def run():
|
|
wrapper_on, state_on = await run_once(True)
|
|
assert "extends:" in wrapper_on.last_system_prompts[0] # M2 default renders branch B
|
|
assert [t["title"] for t in state_on["extends"]] == ["Extends Topic"]
|
|
|
|
wrapper_off, state_off = await run_once(False)
|
|
system_off = wrapper_off.last_system_prompts[0]
|
|
assert "extends" not in system_off.replace("interest_extend", "") # M1 prompt shape
|
|
assert state_off["extends"] == [] # extends entries in the reply are ignored
|
|
assert [t["title"] for t in state_off["topics_out"]] == ["Follow Up Topic"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_semantic_dedup(tmp_path):
|
|
"""With embeddings, similarity below known_threshold is kept; no LLM tier (v5 R2)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_state(ws, open_topics=[_state_topic("Gray Comparison", kind="interest_extend")])
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
reply = _reply(
|
|
follow_ups=[
|
|
_topic("Candidate Topic", confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
fake = _FakeEmbedding(
|
|
{
|
|
# v5.2 scheme: candidate and known sides both embed "title + CJK period + reason"
|
|
"Candidate Topic。because": [1.0, 0.0],
|
|
"Gray Comparison。seeded": [0.8, 0.6], # cos = 0.800 < 0.85 -> kept, no second tier
|
|
},
|
|
)
|
|
_, wrapper, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
|
|
state = context.get("proactive")
|
|
assert state["dropped_known"] == 0
|
|
assert state["dropped_duplicate"] == 0
|
|
assert [c["title"] for c in state["candidates"]] == ["Candidate Topic"]
|
|
assert fake.calls >= 1
|
|
assert wrapper.calls == 1 # topics step is pure computation now
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_known_drop(tmp_path):
|
|
"""sim >= known_threshold drops the candidate as already known."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_write_state(ws, open_topics=[_state_topic("Known Topic", kind="interest_extend")])
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
reply = _reply(
|
|
follow_ups=[_topic("Fresh Candidate", confidence=0.8, paths=[f"daily/{DAY}/session.md"])],
|
|
)
|
|
fake = _FakeEmbedding(
|
|
{
|
|
"Fresh Candidate。because": [1.0, 0.0],
|
|
"Known Topic。seeded": [1.0, 0.0], # identical -> sim 1.0 >= 0.85
|
|
},
|
|
)
|
|
_, _, _, context, _ = await _run_chain(ws, [reply], extra_ctx={"as_embedding": fake})
|
|
state = context.get("proactive")
|
|
assert state["dropped_known"] == 1
|
|
assert state["candidates"] == []
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert [t["title"] for t in data["topics"]] == ["Known Topic"]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F2.5/F2.6 plan + agenda steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _plan_reply(cards: list[dict]) -> str:
|
|
body = yaml.safe_dump({"cards": cards}, allow_unicode=True, sort_keys=False)
|
|
return f"```yaml\n{body}```"
|
|
|
|
|
|
def _agenda_reply(agenda: list[dict], suppressed: list[dict] | None = None) -> str:
|
|
doc = {"agenda": agenda}
|
|
if suppressed is not None:
|
|
doc["suppressed"] = suppressed
|
|
body = yaml.safe_dump(doc, allow_unicode=True, sort_keys=False)
|
|
return f"```yaml\n{body}```"
|
|
|
|
|
|
def _card(topic_id_value, index=0, scenario="resume_task"):
|
|
return {
|
|
"topic_id": topic_id_value,
|
|
"scenario_type": scenario,
|
|
"opener": f"对了,上次那件事还差一步({index}),要不要现在动起来?",
|
|
"next_action": f"执行第 {index} 步",
|
|
"preconditions": [],
|
|
"delivery": "in_conversation",
|
|
}
|
|
|
|
|
|
async def _run_full_chain(
|
|
ws,
|
|
replies=None,
|
|
*,
|
|
wrapper=None,
|
|
catalog=None,
|
|
app=None,
|
|
extract_kwargs=None,
|
|
topics_kwargs=None,
|
|
plan_kwargs=None,
|
|
agenda_kwargs=None,
|
|
extra_ctx=None,
|
|
context=None,
|
|
):
|
|
app = app or ApplicationContext(workspace_dir=str(ws))
|
|
wrapper = wrapper or _AgentWrapper(replies or [])
|
|
catalog = catalog or _Catalog()
|
|
context = context or RuntimeContext(
|
|
date=DAY,
|
|
file_catalog=catalog,
|
|
file_store=_FileStore(ws),
|
|
agent_wrapper=wrapper,
|
|
**(extra_ctx or {}),
|
|
)
|
|
extract = ProactiveExtractStep(app_context=app, **(extract_kwargs or {}))
|
|
resp_extract = await extract(context)
|
|
topics = ProactiveTopicsStep(app_context=app, **(topics_kwargs or {}))
|
|
resp_topics = await topics(context)
|
|
plan = ProactivePlanStep(app_context=app, **(plan_kwargs or {}))
|
|
resp_plan = await plan(context)
|
|
agenda = ProactiveAgendaStep(app_context=app, **(agenda_kwargs or {}))
|
|
resp_agenda = await agenda(context)
|
|
finish = ProactiveFinishStep(app_context=app)
|
|
resp_finish = await finish(context)
|
|
return app, wrapper, catalog, context, (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish)
|
|
|
|
|
|
def test_plan_agenda_chain_e2e(tmp_path):
|
|
"""extract -> topics -> plan -> agenda -> finish writes the enriched v2 file."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "we discussed the eval plan")
|
|
extract_reply = _reply(
|
|
follow_ups=[_topic("记忆检索的可解释性评估", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
|
|
extends=[_topic("多跳 RAG 综述", confidence=0.5, paths=[f"daily/{DAY}/session.md"])],
|
|
)
|
|
fid, eid = topic_id("记忆检索的可解释性评估"), topic_id("多跳 RAG 综述")
|
|
plan_reply = _plan_reply(
|
|
[
|
|
{
|
|
"topic_id": fid,
|
|
"scenario_type": "resume_task",
|
|
"opener": "对了,上次那个评估还差一步,要不要现在跑起来?",
|
|
"next_action": "运行评估脚本",
|
|
"preconditions": ["数据集已就绪"],
|
|
"delivery": "agenda_item",
|
|
},
|
|
_card(eid, 1, scenario="explore_interest"),
|
|
],
|
|
)
|
|
agenda_text = _agenda_reply(
|
|
[{"topic_id": fid, "order_reason": "卡点已解除,今天最适合收尾"}],
|
|
[{"topic_id": eid, "reason": f"merged into {fid}"}],
|
|
)
|
|
_, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
|
|
assert all(r.success for r in resps)
|
|
assert wrapper.calls == 3
|
|
state = context.get("proactive")
|
|
assert state["llm_calls"] == 1 # extract only
|
|
assert state["plan_llm_calls"] == 2 # plan + agenda
|
|
assert state["push"] is True
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["version"] == 2
|
|
assert data["push"] is True
|
|
assert len(data["agenda"]) == 1
|
|
item = data["agenda"][0]
|
|
assert item["topic_id"] == fid
|
|
assert item["title"] == "记忆检索的可解释性评估"
|
|
assert item["scenario_type"] == "resume_task"
|
|
assert item["opener"].startswith("对了")
|
|
assert item["next_action"] == "运行评估脚本"
|
|
assert item["preconditions"] == ["数据集已就绪"]
|
|
assert item["delivery"] == "agenda_item"
|
|
assert item["linked_memory"] == [f"daily/{DAY}/session.md"]
|
|
assert item["order_reason"] == "卡点已解除,今天最适合收尾"
|
|
assert data["suppressed"] == [
|
|
{"topic_id": eid, "title": "多跳 RAG 综述", "reason": f"merged into {fid}"},
|
|
]
|
|
assert {t["id"] for t in data["topics"]} == {fid, eid} # topics view intact
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_plan_agenda_fallback_without_llm(tmp_path):
|
|
"""Unusable plan/agenda replies degrade to fallback cards + deterministic agenda."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
extract_reply = _reply(
|
|
follow_ups=[
|
|
_topic("任务一", confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic("任务二", confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
# Only the extract reply is scripted; plan/agenda receive "" and must degrade.
|
|
_, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
|
|
assert all(r.success for r in resps)
|
|
assert wrapper.calls == 3
|
|
state = context.get("proactive")
|
|
assert state["plan_llm_calls"] == 2 # attempted but unusable
|
|
assert len(state["scenario_cards"]) == 2
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["push"] is True
|
|
assert [i["title"] for i in data["agenda"]] == ["任务一", "任务二"]
|
|
assert all(i["order_reason"] == "deterministic fallback: freshness/confidence order" for i in data["agenda"])
|
|
assert data["suppressed"] == []
|
|
assert all(i["scenario_type"] == "resume_task" for i in data["agenda"])
|
|
assert all(i["opener"] for i in data["agenda"])
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_agenda_no_candidates_keeps_topics_render(tmp_path):
|
|
"""Without push candidates plan/agenda skip and the topics-only render stays."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
extract_reply = _reply(
|
|
follow_ups=[_topic("低置信话题", confidence=0.3, paths=[f"daily/{DAY}/session.md"])],
|
|
)
|
|
_, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply])
|
|
assert all(r.success for r in resps)
|
|
assert wrapper.calls == 1 # extract only; plan/agenda made zero LLM calls
|
|
state = context.get("proactive")
|
|
assert state["push"] is False
|
|
assert state["push_candidates"] == []
|
|
assert state["scenario_cards"] == []
|
|
assert state["agenda"] == []
|
|
assert state["suppressed"] == []
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert data["push"] is False
|
|
assert "agenda" not in data and "suppressed" not in data
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_agenda_single_candidate_skips_llm(tmp_path):
|
|
"""Exactly one candidate is auto-agenda without an agenda LLM call."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
extract_reply = _reply(
|
|
follow_ups=[_topic("唯一候选", confidence=0.9, paths=[f"daily/{DAY}/session.md"])],
|
|
)
|
|
fid = topic_id("唯一候选")
|
|
plan_reply = _plan_reply([_card(fid, scenario="answer_pending")])
|
|
_, wrapper, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply])
|
|
assert wrapper.calls == 2 # extract + plan only
|
|
state = context.get("proactive")
|
|
assert state["plan_llm_calls"] == 1
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert len(data["agenda"]) == 1
|
|
assert data["agenda"][0]["topic_id"] == fid
|
|
assert data["agenda"][0]["order_reason"] == "single push candidate; auto-agenda without LLM"
|
|
assert data["suppressed"] == []
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_agenda_merge_and_full_accounting(tmp_path):
|
|
"""LLM merges two entrances of one matter; every candidate is accounted for."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
titles = ["GPU 排期确认", "跑 benchmark 的 GPU 档期", "多跳引用可信度"]
|
|
extract_reply = _reply(
|
|
follow_ups=[
|
|
_topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic(titles[2], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
ids = [topic_id(t) for t in titles]
|
|
plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
|
|
agenda_text = _agenda_reply(
|
|
[{"topic_id": ids[0], "order_reason": "主入口"}, {"topic_id": ids[2], "order_reason": "独立事项"}],
|
|
[{"topic_id": ids[1], "reason": f"merged into {ids[0]}"}],
|
|
)
|
|
_, _, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
|
|
state = context.get("proactive")
|
|
assert state["push"] is True
|
|
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[2]]
|
|
assert data["suppressed"] == [
|
|
{"topic_id": ids[1], "title": titles[1], "reason": f"merged into {ids[0]}"},
|
|
]
|
|
accounted = {i["topic_id"] for i in data["agenda"]} | {x["topic_id"] for x in data["suppressed"]}
|
|
assert accounted == set(ids)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_agenda_invalid_ids_and_missing_accounting(tmp_path):
|
|
"""Bogus agenda ids are dropped; candidates the LLM forgot are still suppressed."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
titles = ["候选甲", "候选乙"]
|
|
extract_reply = _reply(
|
|
follow_ups=[
|
|
_topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic(titles[1], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
ids = [topic_id(t) for t in titles]
|
|
plan_reply = _plan_reply([_card(tid, i) for i, tid in enumerate(ids)])
|
|
agenda_text = _agenda_reply(
|
|
[{"topic_id": "nonexistent01", "order_reason": "bogus"}, {"topic_id": ids[0], "order_reason": "real"}],
|
|
[],
|
|
)
|
|
_, _, _, _, _ = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text])
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert [i["topic_id"] for i in data["agenda"]] == [ids[0]]
|
|
assert data["suppressed"] == [
|
|
{"topic_id": ids[1], "title": titles[1], "reason": "not selected for today's agenda"},
|
|
]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_plan_over_budget_suppressed(tmp_path):
|
|
"""Candidates beyond max_plan_topics stay card-less and are suppressed with reason."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "session.md", "material")
|
|
titles = ["高置信一", "高置信二", "高置信三"]
|
|
extract_reply = _reply(
|
|
follow_ups=[
|
|
_topic(titles[0], confidence=0.9, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic(titles[1], confidence=0.8, paths=[f"daily/{DAY}/session.md"]),
|
|
_topic(titles[2], confidence=0.7, paths=[f"daily/{DAY}/session.md"]),
|
|
],
|
|
)
|
|
ids = [topic_id(t) for t in titles]
|
|
# deterministic sort order: confidence desc -> ids[0], ids[1] selected; ids[2] over budget
|
|
plan_reply = _plan_reply([_card(ids[0], 0), _card(ids[1], 1)])
|
|
agenda_text = _agenda_reply(
|
|
[{"topic_id": ids[0], "order_reason": "first"}, {"topic_id": ids[1], "order_reason": "second"}],
|
|
[],
|
|
)
|
|
_, _, _, context, _ = await _run_full_chain(
|
|
ws,
|
|
[extract_reply, plan_reply, agenda_text],
|
|
plan_kwargs={"max_plan_topics": 2},
|
|
)
|
|
state = context.get("proactive")
|
|
assert len(state["scenario_cards"]) == 2
|
|
data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))
|
|
assert [i["topic_id"] for i in data["agenda"]] == [ids[0], ids[1]]
|
|
assert data["suppressed"] == [
|
|
{
|
|
"topic_id": ids[2],
|
|
"title": titles[2],
|
|
"reason": "over plan budget: not expanded into a scenario card",
|
|
},
|
|
]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_read_side_agenda_passthrough(tmp_path):
|
|
"""F5 read passes the agenda through, filtering resolved/unknown/low-confidence ids."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
id_a, id_b = topic_id("议程主题"), topic_id("已解决主题")
|
|
payload = {
|
|
"version": 2,
|
|
"date": DAY,
|
|
"generated_at": f"{DAY}T09:00:00",
|
|
"push": True,
|
|
"topics": [
|
|
_state_topic("议程主题", topic_id_value=id_a, confidence=0.9),
|
|
_state_topic("已解决主题", topic_id_value=id_b, confidence=0.9),
|
|
],
|
|
"agenda": [
|
|
{"topic_id": id_a, "opener": "对了,那件事可以动了"},
|
|
{"topic_id": id_b, "opener": "resolved, must be filtered"},
|
|
{"topic_id": "unknown000000", "opener": "not in kept"},
|
|
],
|
|
"suppressed": [],
|
|
}
|
|
_touch(_interests(ws, DAY), yaml.safe_dump(payload, allow_unicode=True, sort_keys=False))
|
|
_write_state(ws, resolved=[{"id": id_b, "title": "已解决主题", "resolved_at": DAY, "evidence": ""}])
|
|
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
|
|
response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws)))
|
|
assert [t["title"] for t in response.answer["topics"]] == ["议程主题"]
|
|
assert response.answer["agenda"] == [{"topic_id": id_a, "opener": "对了,那件事可以动了"}]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# F2.7 personal profile (digest/personal) + configured daily_dir
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_strip_frontmatter_and_profile_loader(tmp_path):
|
|
"""digest/personal/*.md -> profile block; per-file budget; fallbacks."""
|
|
ws = tmp_path
|
|
personal = ws / "digest" / "personal"
|
|
personal.mkdir(parents=True)
|
|
(personal / "user-profile.md").write_text(
|
|
"---\ndescription: 用户张三的身份画像\nname: user-profile\n---\n\n用户张三,偏好西瓜。\n",
|
|
encoding="utf-8",
|
|
)
|
|
(personal / "user-position.md").write_text(
|
|
"---\ndescription: 持仓约束\nname: user-position\n---\n\n持有小米,浮亏较深。\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
meta, body = strip_frontmatter((personal / "user-profile.md").read_text(encoding="utf-8"))
|
|
assert meta["name"] == "user-profile"
|
|
assert body == "用户张三,偏好西瓜。"
|
|
|
|
block = load_personal_profile_block(ws, "digest", 4000)
|
|
assert "### user-profile" in block and "### user-position" in block
|
|
assert "用户张三的身份画像" in block and "偏好西瓜" in block
|
|
assert "持仓约束" in block
|
|
|
|
tiny = load_personal_profile_block(ws, "digest", 300)
|
|
assert len(tiny) <= 300
|
|
|
|
# absent personal dir -> legacy single-file fallback -> sentinel
|
|
empty_ws = tmp_path / "empty"
|
|
empty_ws.mkdir()
|
|
assert load_personal_profile_block(empty_ws, "digest", 1000) == "(no user profile)"
|
|
(empty_ws / "profile.md").write_text("legacy profile", encoding="utf-8")
|
|
assert load_personal_profile_block(empty_ws, "digest", 1000, "profile.md") == "legacy profile"
|
|
assert load_personal_profile_block(empty_ws, "digest", 0, "profile.md") == "(no user profile)"
|
|
|
|
|
|
def test_chain_with_configured_daily_dir(tmp_path):
|
|
"""The full chain honours daily_dir=memory (paths, truth source, prompts)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "memory" / DAY / "session.md", "小米财报讨论")
|
|
_touch(
|
|
ws / "digest" / "personal" / "user-profile.md",
|
|
"---\ndescription: 张三画像\nname: user-profile\n---\n\n张三关注小米与中概股。\n",
|
|
)
|
|
extract_reply = _reply(
|
|
follow_ups=[_topic("小米Q2财报跟进", confidence=0.9, paths=[f"memory/{DAY}/session.md"])],
|
|
)
|
|
fid = topic_id("小米Q2财报跟进")
|
|
plan_reply = _plan_reply([_card(fid)])
|
|
agenda_text = _agenda_reply([{"topic_id": fid, "order_reason": "only candidate"}], [])
|
|
|
|
app = ApplicationContext(workspace_dir=str(ws), daily_dir="memory", digest_dir="digest")
|
|
wrapper = _AgentWrapper([extract_reply, plan_reply, agenda_text])
|
|
catalog = _Catalog()
|
|
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
|
|
extract = ProactiveExtractStep(app_context=app)
|
|
resp_extract = await extract(context)
|
|
topics = ProactiveTopicsStep(app_context=app)
|
|
resp_topics = await topics(context)
|
|
plan = ProactivePlanStep(app_context=app)
|
|
resp_plan = await plan(context)
|
|
agenda = ProactiveAgendaStep(app_context=app)
|
|
resp_agenda = await agenda(context)
|
|
finish = ProactiveFinishStep(app_context=app)
|
|
resp_finish = await finish(context)
|
|
assert all(r.success for r in (resp_extract, resp_topics, resp_plan, resp_agenda, resp_finish))
|
|
|
|
state = context.get("proactive")
|
|
assert state["daily_dir"] == "memory"
|
|
assert state["changed_paths"] == [f"memory/{DAY}/session.md"]
|
|
# truth source + exposure land under memory/, not daily/
|
|
assert (ws / "memory" / "_proactive.yaml").is_file()
|
|
data = yaml.safe_load((ws / "memory" / DAY / "interests.yaml").read_text(encoding="utf-8"))
|
|
assert data["push"] is True
|
|
assert [t["id"] for t in data["topics"]] == [fid]
|
|
assert not (ws / "daily").exists()
|
|
# profile + configured daily dir reached the prompts
|
|
assert any("张三画像" in inp or "张三关注小米" in inp for inp in wrapper.last_inputs)
|
|
assert any(f"memory/{DAY}" in inp for inp in wrapper.last_inputs)
|
|
assert any("memory/<date>" in sp for sp in wrapper.last_system_prompts)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_finish_defers_paths_modified_during_round(tmp_path):
|
|
"""Files edited while the LLM calls run are left for the next round (audit 3)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
_touch(ws / "daily" / DAY / "a.md", "first note")
|
|
note_b = _touch(ws / "daily" / DAY / "b.md", "second note")
|
|
reply = _reply(follow_ups=[], extends=[])
|
|
app = ApplicationContext(workspace_dir=str(ws))
|
|
wrapper = _AgentWrapper([reply])
|
|
catalog = _Catalog()
|
|
context = RuntimeContext(date=DAY, file_catalog=catalog, file_store=_FileStore(ws), agent_wrapper=wrapper)
|
|
|
|
resp_extract = await ProactiveExtractStep(app_context=app)(context)
|
|
assert resp_extract.success is True
|
|
state = context.get("proactive")
|
|
assert set(state["changed_paths"]) == {f"daily/{DAY}/a.md", f"daily/{DAY}/b.md"}
|
|
assert state["changed_mtimes"][f"daily/{DAY}/b.md"] == note_b.stat().st_mtime
|
|
|
|
# The user edits b.md while plan/agenda LLM calls are still running.
|
|
note_b.write_text("second note, edited mid-round", encoding="utf-8")
|
|
future = time.time() + 5
|
|
os.utime(note_b, (future, future))
|
|
|
|
resp_finish = await ProactiveFinishStep(app_context=app)(context)
|
|
assert resp_finish.success is True
|
|
final_state = context.get("proactive")
|
|
assert final_state["checkpoint_paths"] == [f"daily/{DAY}/a.md"]
|
|
assert "deferred 1" in resp_finish.answer
|
|
assert {n.path for n in catalog.upserts} == {f"daily/{DAY}/a.md"}
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_read_single_corrupt_yaml_is_read_only_failure(tmp_path):
|
|
"""Corrupt interests.yaml: the read fails loudly and never moves the file (audit 2)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
corrupt = "topics: [unclosed"
|
|
path = _touch(ws / "daily" / DAY / "interests.yaml", corrupt)
|
|
step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws)))
|
|
response = await step(RuntimeContext(date=DAY, file_store=_FileStore(ws)))
|
|
assert response.success is False
|
|
assert response.metadata.get("error")
|
|
assert path.is_file()
|
|
assert path.read_text(encoding="utf-8") == corrupt
|
|
assert not list(path.parent.glob("interests.corrupt-*"))
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_carry_forward_expiry_boundary_consistent(tmp_path):
|
|
"""Age == carry_forward_days is over-age in both prompt loading and trim (audit 9)."""
|
|
|
|
async def run():
|
|
ws = tmp_path
|
|
edge_day = (dt.date.fromisoformat(DAY) - dt.timedelta(days=14)).isoformat()
|
|
_write_state(ws, open_topics=[_state_topic("Edge Topic", day=edge_day)])
|
|
state_file, _ = load_state(ws)
|
|
carry_all, _ = await load_carry_forward(ws, state_file, DAY, 14, 20)
|
|
assert carry_all == []
|
|
|
|
trim_state_file(state_file, DAY, 14)
|
|
assert state_file.open_topics == []
|
|
|
|
asyncio.run(run())
|