feat: 导入 MemRelay 初始源码
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from memrelay.models import (
|
||||
CurationDependencyState,
|
||||
CurationJob,
|
||||
CurationModelConfig,
|
||||
CurationSettings,
|
||||
)
|
||||
from tests.conftest import csrf_headers
|
||||
from tests.fakes import FakeBasicMemory
|
||||
|
||||
|
||||
def _runtime_model() -> tuple[str, str, str]:
|
||||
base_url = os.getenv("MEMRELAY_TEST_MODEL_BASE_URL")
|
||||
token = os.getenv("MEMRELAY_TEST_MODEL_TOKEN")
|
||||
model = os.getenv("MEMRELAY_TEST_MODEL_NAME", "gpt-5.6-sol")
|
||||
if not base_url or not token:
|
||||
pytest.skip("真实整理测试只在运行时注入模型凭证后执行")
|
||||
return base_url, token, model
|
||||
|
||||
|
||||
def _save_memory(
|
||||
client: TestClient,
|
||||
*,
|
||||
request_id: str,
|
||||
scope: str,
|
||||
title: str,
|
||||
content: str,
|
||||
project_id: str | None = None,
|
||||
) -> dict:
|
||||
response = client.post(
|
||||
"/api/v1/memories",
|
||||
json={
|
||||
"request_id": request_id,
|
||||
"scope": scope,
|
||||
"project_id": project_id,
|
||||
"memory_type": "fact",
|
||||
"title": title,
|
||||
"content": content,
|
||||
"tags": ["live-curation"],
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def _run_job(client: TestClient, payload: dict, *, recover_transient: bool = True) -> dict:
|
||||
queued = client.post(
|
||||
"/api/v1/curation/jobs",
|
||||
json=payload,
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert queued.status_code == 200, queued.text
|
||||
assert asyncio.run(client.app.state.curation.process_once()) is True
|
||||
job_id = queued.json()["id"]
|
||||
detail = client.get(f"/api/v1/curation/status?job_id={job_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
result = detail.json()
|
||||
for _ in range(3):
|
||||
if not recover_transient or result["status"] != "waiting_dependency":
|
||||
break
|
||||
capabilities = asyncio.run(client.app.state.curation.test_model_connection(test_both=False))
|
||||
if not capabilities["available"]:
|
||||
break
|
||||
assert asyncio.run(client.app.state.curation.process_once()) is True
|
||||
detail = client.get(f"/api/v1/curation/status?job_id={job_id}")
|
||||
assert detail.status_code == 200, detail.text
|
||||
result = detail.json()
|
||||
return result
|
||||
|
||||
|
||||
def test_real_curation_end_to_end_when_runtime_credentials_are_supplied(
|
||||
initialized_client: TestClient,
|
||||
) -> None:
|
||||
base_url, token, model = _runtime_model()
|
||||
client = initialized_client
|
||||
fake = FakeBasicMemory()
|
||||
client.app.state.basic_memory = fake
|
||||
manager = client.app.state.curation
|
||||
manager.basic_memory = fake
|
||||
|
||||
project_response = client.post(
|
||||
"/api/v1/projects",
|
||||
json={
|
||||
"name": "Live curation workspace",
|
||||
"workspace_type": "development",
|
||||
"curation_enabled": True,
|
||||
"source_strategy": "mcp",
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert project_response.status_code == 201, project_response.text
|
||||
project_id = project_response.json()["id"]
|
||||
_save_memory(
|
||||
client,
|
||||
request_id="live-global-source",
|
||||
scope="global",
|
||||
title="Global collaboration preference",
|
||||
content="The user prefers concise Chinese communication and concrete verification results.",
|
||||
)
|
||||
_save_memory(
|
||||
client,
|
||||
request_id="live-project-source-one",
|
||||
scope="project",
|
||||
project_id=project_id,
|
||||
title="Workspace purpose",
|
||||
content="This workspace validates unattended AI curation over the Responses API.",
|
||||
)
|
||||
|
||||
with client.app.state.session_factory() as db:
|
||||
settings = db.get(CurationSettings, 1)
|
||||
dependency = db.get(CurationDependencyState, 1)
|
||||
assert settings is not None and dependency is not None
|
||||
config = CurationModelConfig(
|
||||
revision=1,
|
||||
name="runtime-live-curation",
|
||||
base_url=base_url,
|
||||
encrypted_token=manager.secret_box.encrypt(token, "curation-model-token"),
|
||||
text_model=model,
|
||||
protocol="responses",
|
||||
effective_protocol="responses",
|
||||
stream=False,
|
||||
timeout_seconds=300,
|
||||
capability_json=json.dumps(
|
||||
{
|
||||
"responses": {"status": "supported"},
|
||||
"non_stream": {"status": "supported"},
|
||||
"stream": {"status": "supported"},
|
||||
"native_structured_output": {"status": "supported"},
|
||||
}
|
||||
),
|
||||
active=True,
|
||||
)
|
||||
db.add(config)
|
||||
db.flush()
|
||||
settings.enabled = True
|
||||
dependency.status = "available"
|
||||
db.commit()
|
||||
working_config_id = config.id
|
||||
|
||||
global_job = _run_job(
|
||||
client,
|
||||
{
|
||||
"scope": "global",
|
||||
"mode": "incremental",
|
||||
"source_strategy": "mcp",
|
||||
"document_types": ["profile"],
|
||||
},
|
||||
)
|
||||
assert global_job["status"] == "completed", (
|
||||
global_job["status"],
|
||||
global_job["error_code"],
|
||||
global_job["error_message"],
|
||||
global_job["model_calls"],
|
||||
)
|
||||
|
||||
first_project_job = _run_job(
|
||||
client,
|
||||
{
|
||||
"scope": "project",
|
||||
"project_id": project_id,
|
||||
"mode": "incremental",
|
||||
"source_strategy": "mcp",
|
||||
"document_types": ["overview"],
|
||||
},
|
||||
)
|
||||
assert first_project_job["status"] == "completed", (
|
||||
first_project_job["status"],
|
||||
first_project_job["error_code"],
|
||||
first_project_job["error_message"],
|
||||
first_project_job["model_calls"],
|
||||
)
|
||||
|
||||
_save_memory(
|
||||
client,
|
||||
request_id="live-project-source-two",
|
||||
scope="project",
|
||||
project_id=project_id,
|
||||
title="Workspace progress",
|
||||
content="The live global and initial project curation checks completed successfully.",
|
||||
)
|
||||
second_project_job = _run_job(
|
||||
client,
|
||||
{
|
||||
"scope": "project",
|
||||
"project_id": project_id,
|
||||
"mode": "incremental",
|
||||
"source_strategy": "mcp",
|
||||
"document_types": ["overview"],
|
||||
"pending_policy": "replace",
|
||||
},
|
||||
)
|
||||
assert second_project_job["status"] == "completed", (
|
||||
second_project_job["status"],
|
||||
second_project_job["error_code"],
|
||||
second_project_job["error_message"],
|
||||
second_project_job["model_calls"],
|
||||
)
|
||||
|
||||
documents = client.get(
|
||||
"/api/v1/curation/documents",
|
||||
params={"scope": "project", "project_id": project_id},
|
||||
)
|
||||
assert documents.status_code == 200, documents.text
|
||||
overview = next(item for item in documents.json() if item["document_type"] == "overview")
|
||||
history = client.get(f"/api/v1/curation/documents/{overview['id']}/history")
|
||||
assert history.status_code == 200, history.text
|
||||
assert len(history.json()) >= 2
|
||||
first_revision = min(item["revision"] for item in history.json())
|
||||
latest_revision = max(item["revision"] for item in history.json())
|
||||
difference = client.get(
|
||||
f"/api/v1/curation/documents/{overview['id']}/diff",
|
||||
params={"from_revision": first_revision, "to_revision": latest_revision},
|
||||
)
|
||||
assert difference.status_code == 200, difference.text
|
||||
assert difference.json()["from_revision"] == first_revision
|
||||
reverted = client.post(
|
||||
f"/api/v1/curation/documents/{overview['id']}/revert",
|
||||
params={"revision": first_revision},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert reverted.status_code == 200, reverted.text
|
||||
assert reverted.json()["revision"] == latest_revision + 1
|
||||
|
||||
_save_memory(
|
||||
client,
|
||||
request_id="live-project-source-three",
|
||||
scope="project",
|
||||
project_id=project_id,
|
||||
title="Recovery source",
|
||||
content="A temporarily unavailable model task must resume after connectivity returns.",
|
||||
)
|
||||
with client.app.state.session_factory() as db:
|
||||
working = db.get(CurationModelConfig, working_config_id)
|
||||
assert working is not None
|
||||
working.active = False
|
||||
outage = CurationModelConfig(
|
||||
revision=2,
|
||||
name="runtime-network-outage",
|
||||
base_url="http://127.0.0.1:9/v1",
|
||||
encrypted_token=manager.secret_box.encrypt(
|
||||
"temporary-test-token", "curation-model-token"
|
||||
),
|
||||
text_model=model,
|
||||
protocol="responses",
|
||||
effective_protocol="responses",
|
||||
timeout_seconds=5,
|
||||
active=True,
|
||||
)
|
||||
db.add(outage)
|
||||
db.commit()
|
||||
|
||||
waiting = _run_job(
|
||||
client,
|
||||
{
|
||||
"scope": "project",
|
||||
"project_id": project_id,
|
||||
"mode": "incremental",
|
||||
"source_strategy": "mcp",
|
||||
"document_types": ["overview"],
|
||||
"pending_policy": "replace",
|
||||
},
|
||||
recover_transient=False,
|
||||
)
|
||||
assert waiting["status"] == "waiting_dependency", waiting
|
||||
assert waiting["error_code"] == "MODEL_UNAVAILABLE"
|
||||
|
||||
with client.app.state.session_factory() as db:
|
||||
working = db.get(CurationModelConfig, working_config_id)
|
||||
outage = db.get(CurationModelConfig, waiting["model_config_id"])
|
||||
assert working is not None and outage is not None
|
||||
outage.active = False
|
||||
working.active = True
|
||||
db.commit()
|
||||
capabilities = asyncio.run(
|
||||
manager.test_model_connection(config_id=working_config_id, test_both=False)
|
||||
)
|
||||
assert capabilities["available"] is True
|
||||
with client.app.state.session_factory() as db:
|
||||
resumed = db.get(CurationJob, waiting["id"])
|
||||
assert resumed is not None
|
||||
resumed_status = resumed.status
|
||||
if resumed_status != "completed":
|
||||
processed = asyncio.run(manager.process_once())
|
||||
assert processed is True or resumed_status in {"collecting", "running", "applying"}
|
||||
for _ in range(120):
|
||||
with client.app.state.session_factory() as db:
|
||||
resumed = db.get(CurationJob, waiting["id"])
|
||||
assert resumed is not None
|
||||
if resumed.status in {"completed", "failed", "waiting_dependency", "cancelled"}:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
with client.app.state.session_factory() as db:
|
||||
resumed = db.get(CurationJob, waiting["id"])
|
||||
assert resumed is not None and resumed.status == "completed", (
|
||||
resumed.status if resumed else None,
|
||||
resumed.error_code if resumed else None,
|
||||
resumed.error_message if resumed else None,
|
||||
)
|
||||
Reference in New Issue
Block a user