Files
deerflow-code/offline-backend-20260512/backend/tests/test_analysis_project_memory_integration.py

535 lines
20 KiB
Python

"""End-to-end effects tests for project-scoped cross-conversation memory.
These tests deliberately use the real SQLite repositories and the real run
policy. Only infrastructure outside this feature's boundary is substituted:
the completed-thread checkpointer, the extraction LLM, and the unrelated
generic/personal memory switch used by independence regressions.
"""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, HTTPException, Request
from langchain_core.messages import AIMessage, HumanMessage
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from app.gateway import analysis_project_memory as memory_runtime
from app.gateway import analysis_projects as project_policy
from app.gateway.routers.thread_runs import RunCreateRequest
from deerflow.agents.analysis_project import project_system_prompt
from deerflow.config import system_settings
from deerflow.persistence.analysis_project_memory import (
AnalysisProjectMemoryItemRow,
AnalysisProjectMemoryJobRow,
AnalysisProjectMemoryStateRow,
AnalysisProjectMemoryStore,
)
from deerflow.persistence.analysis_projects import (
AnalysisProjectConfigRow,
AnalysisProjectRow,
AnalysisProjectStore,
AnalysisProjectThreadRow,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
class _Checkpoint:
"""Small mutable checkpointer implementing the one read used by memory."""
def __init__(self) -> None:
self._messages: dict[str, list[object]] = {}
def set_messages(self, thread_id: str, messages: list[object]) -> None:
self._messages[thread_id] = messages
async def aget_tuple(self, config: dict) -> SimpleNamespace | None:
thread_id = str(config.get("configurable", {}).get("thread_id") or "")
messages = self._messages.get(thread_id)
if messages is None:
return None
return SimpleNamespace(
checkpoint={"channel_values": {"messages": messages}}
)
@dataclass
class _Harness:
engine: AsyncEngine
projects: AnalysisProjectStore
memories: AnalysisProjectMemoryStore
checkpointer: _Checkpoint
app: FastAPI
request: Request
async def close(self) -> None:
await self.engine.dispose()
async def _make_harness(tmp_path) -> _Harness:
engine = create_async_engine(
f"sqlite+aiosqlite:///{tmp_path / 'project-memory-integration.db'}"
)
@event.listens_for(engine.sync_engine, "connect")
def _enable_foreign_keys(dbapi_connection, _connection_record): # noqa: ANN001
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# Create only the production tables exercised by this feature. This keeps
# the test independent from unrelated schemas while preserving all real
# foreign keys, constraints, transactions, and repository code paths.
async with engine.begin() as connection:
for table in (
AnalysisProjectConfigRow.__table__,
AnalysisProjectRow.__table__,
AnalysisProjectThreadRow.__table__,
AnalysisProjectMemoryStateRow.__table__,
AnalysisProjectMemoryItemRow.__table__,
AnalysisProjectMemoryJobRow.__table__,
):
await connection.run_sync(table.create)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
projects = AnalysisProjectStore(session_factory)
memories = AnalysisProjectMemoryStore(session_factory)
checkpointer = _Checkpoint()
app = FastAPI()
app.state.analysis_project_store = projects
app.state.analysis_project_memory_store = memories
app.state.analysis_project_memory_dispatcher = None
app.state.checkpointer = checkpointer
# Absence of an encoder is a supported production degradation path.
app.state.llmwiki_embedding = None
request = Request(
{
"type": "http",
"app": app,
"headers": [],
"method": "POST",
"path": "/api/threads/test/runs",
"query_string": b"",
"root_path": "",
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
}
)
return _Harness(engine, projects, memories, checkpointer, app, request)
@contextmanager
def _as_user(user_id: str):
token = set_current_user(SimpleNamespace(id=user_id))
try:
yield
finally:
reset_current_user(token)
def _set_master_switch(monkeypatch: pytest.MonkeyPatch, *, enabled: bool) -> None:
"""Set the unrelated generic-memory switch for independence regressions."""
monkeypatch.setattr(
system_settings,
"load_system_settings",
lambda: SimpleNamespace(memory=SimpleNamespace(enabled=enabled)),
)
def _run_body(question: str) -> RunCreateRequest:
return RunCreateRequest(
input={"messages": [{"role": "user", "content": question}]},
context={},
config={"configurable": {}, "context": {}},
)
@pytest.mark.asyncio
async def test_thread_a_extraction_is_recalled_by_thread_b_and_enters_prompt(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Exercise the complete durable path users depend on across conversations."""
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
# Project memory must remain fully functional while generic/personal
# memory is disabled deployment-wide.
_set_master_switch(monkeypatch, enabled=False)
harness = await _make_harness(tmp_path)
try:
project = await harness.projects.create(
"alice", mode="qa", name="火星登陆", background="制定登陆方案"
)
await harness.projects.bind_thread(project["id"], "alice", "thread-a")
await harness.projects.bind_thread(project["id"], "alice", "thread-b")
harness.checkpointer.set_messages(
"thread-a",
[
HumanMessage(id="message-a-user", content="最终着陆点定在哪里?"),
AIMessage(
id="message-a-assistant",
content="已经确认:最终着陆点为乌托邦平原。",
),
],
)
queued = await memory_runtime.enqueue_completed_project_run(
app=harness.app,
project=project,
thread_id="thread-a",
run_id="run-a",
)
assert queued is not None
assert queued["status"] == "queued"
[job] = await harness.memories.claim_jobs(
lease_owner="integration-worker",
lease_until=datetime.now(UTC) + timedelta(minutes=3),
limit=1,
)
executor = memory_runtime.AnalysisProjectMemoryExecutor(harness.app)
extraction_llm = AsyncMock(
return_value={
"items": [
{
"logical_key": "landing-site",
"kind": "decision",
"content": "最终着陆点为乌托邦平原",
"confidence": 0.98,
"importance": 0.95,
"pinned": False,
}
],
"snapshot": {"summary": "登陆地点已经确认"},
}
)
monkeypatch.setattr(executor, "_model_extraction", extraction_llm)
await executor.execute(job, lease_owner="integration-worker")
extraction_llm.assert_awaited_once()
stored_items = await harness.memories.list_items(project["id"], "alice")
stored = next(
item for item in stored_items if item["logical_key"] == "landing-site"
)
assert stored["content"] == "最终着陆点为乌托邦平原"
assert stored["source_thread_id"] == "thread-a"
assert stored["source_message_ids"] == [
"message-a-user",
"message-a-assistant",
]
assert isinstance(stored["metadata"]["source_order"], float)
assert any(
item["kind"] == "episode"
and item["metadata"].get("raw_project_conversation") is True
for item in stored_items
)
[completed_job] = await harness.memories.list_jobs(project["id"], "alice")
assert completed_job["status"] == "completed"
assert completed_job["result"]["fallback"] is False
with _as_user("alice"):
prepared, resolved = await project_policy.prepare_project_run(
_run_body("上次确定的着陆点是什么?"),
"thread-b",
harness.request,
)
assert resolved is not None and resolved["id"] == project["id"]
assert prepared.context["project_memory_scope"] == {
"kind": "analysis_project",
"project_id": project["id"],
"owner_user_id": "alice",
"enabled": True,
}
recalled = prepared.context["project_memory"]
# Revision 1 is the immediate raw-exchange index written when the run
# completes; the extractor merge advances the durable project state
# once more.
assert recalled["revision"] == 2
assert recalled["items"][0]["content"] == "最终着陆点为乌托邦平原"
assert recalled["items"][0]["source_thread_id"] == "thread-a"
prompt = project_system_prompt(prepared.context)
assert "<project_memory>" in prompt
assert "最终着陆点为乌托邦平原" in prompt
assert "同一项目历史对话整理出的背景数据" in prompt
finally:
await harness.close()
@pytest.mark.asyncio
async def test_thread_b_recalls_immediate_nickname_while_incremental_job_is_running(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An ordinary extractor job must not hide its already indexed context."""
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
harness = await _make_harness(tmp_path)
try:
project = await harness.projects.create(
"alice", mode="qa", name="昵称记忆测试"
)
await harness.projects.bind_thread(project["id"], "alice", "thread-a")
await harness.projects.bind_thread(project["id"], "alice", "thread-b")
harness.checkpointer.set_messages(
"thread-a",
[
HumanMessage(
id="nickname-user",
content="你现在叫铁蛋,我给你起的新名字。",
),
AIMessage(id="nickname-assistant", content="好的。"),
],
)
queued = await memory_runtime.enqueue_completed_project_run(
app=harness.app,
project=project,
thread_id="thread-a",
run_id="nickname-run",
)
assert queued is not None
immediate_items = await harness.memories.list_items(project["id"], "alice")
assert any(
item["logical_key"] == "assistant.nickname"
and "铁蛋" in item["content"]
for item in immediate_items
)
[running] = await harness.memories.claim_jobs(
lease_owner="slow-incremental-worker",
lease_until=datetime.now(UTC) + timedelta(minutes=3),
limit=1,
)
assert running["id"] == queued["id"]
state = await harness.memories.get_state(project["id"], "alice")
assert state is not None and state["status"] == "building"
with _as_user("alice"):
prepared, resolved = await project_policy.prepare_project_run(
_run_body("我给你起的新昵称叫什么?"),
"thread-b",
harness.request,
)
assert resolved is not None and resolved["id"] == project["id"]
recalled = prepared.context["project_memory"]
assert recalled is not None
assert any("铁蛋" in item["content"] for item in recalled["items"])
assert "铁蛋" in project_system_prompt(prepared.context)
finally:
await harness.close()
@pytest.mark.asyncio
async def test_project_and_owner_boundaries_prevent_memory_leakage(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
_set_master_switch(monkeypatch, enabled=True)
harness = await _make_harness(tmp_path)
try:
alpha = await harness.projects.create("alice", mode="qa", name="Alpha")
beta = await harness.projects.create("alice", mode="qa", name="Beta")
bob_project = await harness.projects.create("bob", mode="qa", name="Bob")
await harness.projects.bind_thread(alpha["id"], "alice", "alpha-thread")
await harness.projects.bind_thread(beta["id"], "alice", "beta-thread")
await harness.projects.bind_thread(bob_project["id"], "bob", "bob-thread")
await harness.memories.upsert_item(
alpha["id"],
"alice",
logical_key="alpha-secret",
kind="fact",
content="Alpha 项目的代号是朱雀",
source_thread_id="alpha-source",
)
await harness.memories.upsert_item(
beta["id"],
"alice",
logical_key="beta-secret",
kind="fact",
content="Beta 项目的代号是玄武",
source_thread_id="beta-source",
)
await harness.memories.upsert_item(
bob_project["id"],
"bob",
logical_key="bob-secret",
kind="fact",
content="Bob 项目的代号是白虎",
source_thread_id="bob-source",
)
with _as_user("alice"):
prepared, _ = await project_policy.prepare_project_run(
_run_body("项目代号是什么?"), "beta-thread", harness.request
)
serialized = str(prepared.context["project_memory"])
assert "Beta 项目的代号是玄武" in serialized
assert "Alpha 项目的代号是朱雀" not in serialized
assert "Bob 项目的代号是白虎" not in serialized
# Repository defence-in-depth requires both project and owner; knowing
# a project id alone cannot reveal its memory.
wrong_owner = await harness.memories.recall(
alpha["id"], "bob", query="朱雀"
)
assert wrong_owner["state"] is None
assert wrong_owner["items"] == []
# The run-policy boundary also rejects an owner trying to use another
# user's bound thread before any memory can be injected.
with _as_user("bob"), pytest.raises(HTTPException) as exc_info:
await project_policy.prepare_project_run(
_run_body("Alpha 项目的代号是什么?"),
"alpha-thread",
harness.request,
)
assert exc_info.value.status_code == 404
finally:
await harness.close()
@pytest.mark.asyncio
async def test_generic_memory_switch_off_does_not_block_project_memory(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
_set_master_switch(monkeypatch, enabled=False)
harness = await _make_harness(tmp_path)
try:
project = await harness.projects.create("alice", mode="qa", name="关闭记忆")
await harness.projects.bind_thread(project["id"], "alice", "thread-a")
await harness.projects.bind_thread(project["id"], "alice", "thread-b")
await harness.memories.upsert_item(
project["id"],
"alice",
logical_key="project-date",
kind="fact",
content="项目约定日期为2026年12月31日",
source_thread_id="thread-a",
)
harness.checkpointer.set_messages(
"thread-a",
[
HumanMessage(id="u", content="项目约定日期改为2026年12月31日"),
AIMessage(id="a", content="已确认项目约定日期为2026年12月31日"),
],
)
with _as_user("alice"):
prepared, _ = await project_policy.prepare_project_run(
_run_body("项目约定日期是哪天?"), "thread-b", harness.request
)
assert prepared.context["project_memory_scope"]["enabled"] is True
assert prepared.context["project_memory"]["items"][0]["content"] == (
"项目约定日期为2026年12月31日"
)
assert "项目约定日期为2026年12月31日" in project_system_prompt(
prepared.context
)
queued = await memory_runtime.enqueue_completed_project_run(
app=harness.app,
project=project,
thread_id="thread-a",
run_id="run-a",
)
assert queued is not None and queued["status"] == "queued"
class CompletingExecutor:
app = harness.app
async def execute(self, job, *, lease_owner):
await harness.memories.complete_job(
job["id"],
lease_owner=lease_owner,
result={"project_memory_independent": True},
)
dispatcher = memory_runtime.AnalysisProjectMemoryDispatcher(
harness.memories,
CompletingExecutor(),
concurrency=1,
)
assert await dispatcher.dispatch_once() == 1
tasks = list(dispatcher._running.values())
await asyncio.gather(*tasks)
[completed] = await harness.memories.list_jobs(project["id"], "alice")
assert completed["id"] == queued["id"]
assert completed["status"] == "completed"
assert completed["result"] == {"project_memory_independent": True}
finally:
await harness.close()
@pytest.mark.asyncio
async def test_keyword_recall_is_effective_without_vector_encoder(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Vector unavailability must degrade to useful deterministic recall."""
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
_set_master_switch(monkeypatch, enabled=True)
harness = await _make_harness(tmp_path)
try:
project = await harness.projects.create("alice", mode="qa", name="预算项目")
await harness.projects.bind_thread(project["id"], "alice", "query-thread")
await harness.memories.upsert_item(
project["id"],
"alice",
logical_key="project-budget",
kind="fact",
content="The confirmed project budget is 500000 yuan",
importance=0.2,
source_thread_id="budget-thread",
)
await harness.memories.upsert_item(
project["id"],
"alice",
logical_key="project-venue",
kind="fact",
content="The conference venue is Shanghai",
importance=1.0,
source_thread_id="venue-thread",
)
direct = await harness.memories.recall(
project["id"],
"alice",
query="What is the confirmed project budget in yuan?",
)
assert direct["items"][0]["logical_key"] == "project-budget"
assert direct["items"][0]["recall_signals"]["lexical"] > 0
assert all(
item["recall_signals"]["vector"] == 0 for item in direct["items"]
)
assert all(
item["recall_signals"]["vector_compatible"] is False
for item in direct["items"]
)
with _as_user("alice"):
prepared, _ = await project_policy.prepare_project_run(
_run_body("What is the confirmed project budget in yuan?"),
"query-thread",
harness.request,
)
assert prepared.context["project_memory"]["items"][0]["content"] == (
"The confirmed project budget is 500000 yuan"
)
assert "The confirmed project budget is 500000 yuan" in project_system_prompt(
prepared.context
)
finally:
await harness.close()