ReMe/tests/unit/test_proactive_refresh.py

1479 lines
60 KiB
Python

"""Unit tests for the proactive refresh chain (PROACTIVE_SPEC.md A8 mapping).
Coverage: F1 contracts, F2 discovery, F3 isolation, F4 idle gate/budget/timeout,
F5 read extensions, plus the M2 extends branch and semantic dedup.
"""
import asyncio
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, TopicUpdate
from reme.steps.evolve.dream.extract import DreamExtractStep
from reme.steps.evolve.proactive.extract import ProactiveExtractStep
from reme.steps.evolve.proactive.finish import ProactiveFinishStep
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_state,
normalize_topic,
parse_interests_topics,
scan_material_daily,
topic_id,
)
from reme.steps.evolve.wait_for_idle import WaitForIdleStep
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, keywords=None):
return {
"title": title,
"reason": reason,
"confidence": confidence,
"evidence": evidence or (paths[0] if paths else ""),
"keywords": keywords or [],
"paths": paths or [],
}
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": "",
"keywords": [],
"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_delayed_association_context_window(tmp_path):
"""v5.2 trigger/context split: consumed resources re-enter fired rounds.
Round 1 consumes the resource (checkpointed). Round 2 is triggered by a new
daily note; the blob must carry the unchanged resource again so the extends
branch can associate the new discussion with the earlier upload.
"""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "initial discussion")
resource = _touch(ws / "resource" / "survey.md", "survey on citation credibility")
resource_rel = resource.relative_to(ws).as_posix()
reply1 = _reply(follow_ups=[_topic("初始话题", paths=[f"daily/{DAY}/session.md"])])
reply2 = _reply(
extends=[_topic("延迟关联主题", confidence=0.5, paths=[f"daily/{DAY}/1500-extra.md", resource_rel])],
)
wrapper = _AgentWrapper([reply1, reply2])
catalog = _Catalog()
await _run_chain(ws, wrapper=wrapper, catalog=catalog)
assert wrapper.calls == 1
_touch(ws / "daily" / DAY / "1500-extra.md", "now discussing citation credibility")
_, _, _, context2, _ = await _run_chain(ws, wrapper=wrapper, catalog=catalog)
assert wrapper.calls == 2
assert resource_rel in wrapper.last_inputs[1] # resource back in the blob as context
state = context2.get("proactive")
assert state["early_exit"] in (None, "")
extends = state["extends"]
assert len(extends) == 1 and resource_rel in extends[0]["paths"]
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/status/confidence/action 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
assert TopicUpdate(id="x", action="???").action == "keep"
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)
carry = context.get("proactive")["carry_forward_all"]
assert [t["title"] for t in carry] == ["历史主题"] # 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_all"] == []
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):
"""wait_for_idle -> 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)
wait = WaitForIdleStep(app_context=app, max_wait=5, poll_interval=0.05)
resp_wait = await wait(context)
assert resp_wait.success is True
assert "proactive_skip" not in context
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 and no resources -> 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_resource_watermark_consumed_once(tmp_path):
"""v5.1: resources share the proactive watermark and are consumed once.
Round 1 consumes a fresh upload (1 LLM call, checkpointed); round 2 sees
nothing changed anywhere and early-exits with zero LLM calls; round 3
re-uploads the resource (new mtime) and consumes it again.
"""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "discussion about attribution")
resource = _touch(ws / "resource" / "notes.md", "fresh upload")
reply1 = _reply(follow_ups=[_topic("未决问题", paths=[f"daily/{DAY}/session.md"])])
reply2 = _reply(follow_ups=[_topic("资源后续", paths=["resource/notes.md"])])
wrapper = _AgentWrapper([reply1, reply2])
catalog = _Catalog()
await _run_chain(ws, wrapper=wrapper, catalog=catalog)
assert wrapper.calls == 1
assert {n.path for n in catalog.upserts} >= {f"daily/{DAY}/session.md", "resource/notes.md"}
_, _, _, context2, _ = await _run_chain(ws, wrapper=wrapper, catalog=catalog)
assert wrapper.calls == 1 # early exit, no second LLM call
assert context2.get("proactive")["early_exit"] == "no_new_evidence"
stat = resource.stat()
os.utime(resource, (stat.st_atime + 60, stat.st_mtime + 60))
_, _, _, context3, _ = await _run_chain(ws, wrapper=wrapper, catalog=catalog)
assert wrapper.calls == 2 # changed resource is consumed again
assert not context3.get("proactive")["early_exit"]
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",
"keywords": [],
"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 idle gate, short-circuit, budget, timeout
# ---------------------------------------------------------------------------
def test_idle_gate(tmp_path):
"""Running/quiet-window jobs block; idle trunk proceeds; fnmatch patterns apply."""
async def run():
now_mono = time.monotonic()
app = ApplicationContext(workspace_dir=str(tmp_path))
app.metadata["__job_last_run"] = {
"auto_memory_batch": {"running": True, "last_start": now_mono, "last_end": now_mono - 999},
"unrelated_job": {"running": True, "last_start": now_mono, "last_end": now_mono},
}
step = WaitForIdleStep(app_context=app, max_wait=0.3, poll_interval=0.05)
context = RuntimeContext()
response = await step(context)
assert response.success is True # giving up is not a failure
flag = context.get("proactive_skip")
assert flag["reason"] == "busy"
assert flag["busy_jobs"] == ["auto_memory_batch"] # unrelated_job not matched
app.metadata["__job_last_run"] = {
"auto_memory_batch": {"running": False, "last_start": now_mono - 900, "last_end": now_mono - 900},
}
context2 = RuntimeContext()
response2 = await WaitForIdleStep(app_context=app, quiet_window=120)(context2)
assert response2.success is True
assert "proactive_skip" not in context2
asyncio.run(run())
def test_idle_timeout_skip(tmp_path):
"""Waiting past max_wait gives up the round: success=True, skip flag set, no writes."""
async def run():
app = ApplicationContext(workspace_dir=str(tmp_path))
app.metadata["__job_last_run"] = {
"dream_cron": {"running": True, "last_start": time.monotonic(), "last_end": 0.0},
}
step = WaitForIdleStep(app_context=app, max_wait=0.2, poll_interval=0.05)
context = RuntimeContext()
started = time.monotonic()
response = await step(context)
assert time.monotonic() - started >= 0.2
assert response.success is True
assert "Skipped" in str(response.answer)
assert context.get("proactive_skip")["reason"] == "busy"
assert not _interests(tmp_path).exists()
asyncio.run(run())
def test_wait_for_idle_skip_key(tmp_path):
"""The short-circuit flag key is parameterized, not hardcoded."""
async def run():
app = ApplicationContext(workspace_dir=str(tmp_path))
app.metadata["__job_last_run"] = {
"dream_cron": {"running": True, "last_start": time.monotonic(), "last_end": 0.0},
}
step = WaitForIdleStep(app_context=app, max_wait=0.15, poll_interval=0.05, skip_key="custom_skip")
context = RuntimeContext()
await step(context)
assert context.get("custom_skip")["reason"] == "busy"
assert context.get("proactive_skip") is None
asyncio.run(run())
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())
# ---------------------------------------------------------------------------
# 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",
"keywords": [],
"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_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"}
# 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_resources(tmp_path):
"""Branch B infers extends from recent uploads; out-of-M paths are dropped."""
async def run():
ws = tmp_path
_touch(ws / "daily" / DAY / "session.md", "discussion about source attribution")
_touch(ws / "resource" / "citation-notes.md", "freshly uploaded notes")
resource_rel = "resource/citation-notes.md"
reply = _reply(
follow_ups=[],
extends=[
_topic("引用质量评测", confidence=0.5, paths=[resource_rel, 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 resource_rel 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"], keywords=["alpha"]),
],
)
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())