feat: add milvus profile projection

This commit is contained in:
张胜宇
2026-09-11 20:45:50 +08:00
parent 50a611274a
commit cbcf7c7444
3 changed files with 258 additions and 0 deletions
@@ -0,0 +1,127 @@
"""Milvus 长期记忆投影适配器。
只写入已经审核的 `memory_sources`,不接受画像快照整体冒充单条记忆。
"""
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any, Protocol
from uuid import UUID
from app.core.conversation_privacy import sanitize_customer_service_message
from app.core.errors import RecoverableAgentError
PROFILE_COLLECTION = "user_long_term_memory_v1"
VECTOR_DIM = 1024
class MilvusProfileClient(Protocol):
async def query(self, **kwargs: Any) -> list[dict[str, Any]]: ...
async def upsert(self, **kwargs: Any) -> Any: ...
EmbeddingProvider = Callable[[str], Awaitable[list[float]]]
class MilvusProfileProjection:
"""按记忆 UUID 幂等写入长期记忆向量。"""
def __init__(
self,
client: MilvusProfileClient,
embed: EmbeddingProvider,
*,
collection: str = PROFILE_COLLECTION,
) -> None:
self._client = client
self._embed = embed
self._collection = collection
async def upsert(self, payload: dict[str, Any]) -> None:
customer_id, profile_version, sources = self._normalize(payload)
load_collection = getattr(self._client, "load_collection", None)
if load_collection is not None:
await load_collection(collection_name=self._collection)
rows: list[dict[str, Any]] = []
for source in sources:
vector = await self._embed(source["content"])
if len(vector) != VECTOR_DIM:
raise RecoverableAgentError("画像向量维度不一致")
existing = await self._client.query(
collection_name=self._collection,
filter=f'memory_uuid == "{source["memory_uuid"]}"',
output_fields=["memory_uuid", "version", "customer_id"],
)
if existing and int(existing[0].get("version", 0)) > source["version"]:
continue
rows.append({
"memory_uuid": source["memory_uuid"],
"customer_id": customer_id,
"content": source["content"],
"embedding": vector,
"memory_type": source["memory_type"],
"memory_key": source["memory_key"],
"confidence": source["confidence"],
"version": source["version"],
"status": "active",
"valid_until_ts": source["valid_until_ts"],
"updated_at_ts": source["updated_at_ts"],
})
if rows:
await self._client.upsert(collection_name=self._collection, data=rows)
@staticmethod
def _normalize(
payload: dict[str, Any],
) -> tuple[int, int, list[dict[str, Any]]]:
customer_id = payload.get("customer_id")
profile_version = payload.get("profile_version")
sources = payload.get("memory_sources")
if not isinstance(customer_id, int) or customer_id <= 0:
raise ValueError("customer_id is invalid")
if not isinstance(profile_version, int) or profile_version <= 0:
raise ValueError("profile_version is invalid")
if not isinstance(sources, list):
raise ValueError("memory_sources is invalid")
normalized: list[dict[str, Any]] = []
now = int(datetime.now(UTC).timestamp())
for source in sources:
if not isinstance(source, dict):
raise ValueError("memory source is invalid")
required = [source.get(name) for name in (
"memory_uuid", "memory_key", "content", "memory_type"
)]
if not all(isinstance(value, str) and value.strip() for value in required):
raise ValueError("memory source fields are invalid")
try:
memory_uuid = str(UUID(str(source["memory_uuid"])))
except ValueError as exc:
raise ValueError("memory_uuid is invalid") from exc
memory_key = str(source["memory_key"]).strip()
if not (memory_key.startswith("preference:") or memory_key.startswith("goal:")):
raise ValueError("memory key is not projectable")
confidence = source.get("confidence")
version = source.get("version")
if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
raise ValueError("memory confidence is invalid")
if not isinstance(version, int) or version <= 0:
raise ValueError("memory version is invalid")
valid_until = source.get("valid_until")
valid_until_ts = None
if isinstance(valid_until, str) and valid_until:
try:
valid_until_ts = int(datetime.fromisoformat(valid_until).timestamp())
except ValueError as exc:
raise ValueError("memory valid_until is invalid") from exc
normalized.append({
"memory_uuid": memory_uuid,
"memory_key": memory_key,
"content": sanitize_customer_service_message(str(source["content"])).strip(),
"memory_type": str(source["memory_type"]).strip(),
"confidence": float(confidence),
"version": version,
"valid_until_ts": valid_until_ts,
"updated_at_ts": now,
})
return customer_id, profile_version, normalized
+35
View File
@@ -4,8 +4,12 @@ import logging
from typing import Any
from app.core.config import get_settings
from app.core.errors import RecoverableAgentError
from app.infrastructure.db import engine
from app.infrastructure.milvus_profile_projection import MilvusProfileProjection
from app.infrastructure.neo4j_profile_projection import Neo4jProfileProjection
from app.service.agent.bootstrap import get_memory_embedding_service
from app.service.model_gateway import DatabaseModelEndpointResolver
from app.worker.memory_sync_outbox_worker import MemorySyncOutboxWorker
from app.worker.offsite_mail_worker import OffsiteMailWorker
from app.worker.runtime import WorkerRuntime
@@ -18,7 +22,9 @@ async def serve(*, once: bool = False) -> None:
runtime = WorkerRuntime(settings=settings)
offsite_worker = OffsiteMailWorker(settings)
neo4j_driver: Any | None = None
milvus_client: Any | None = None
memory_sync_worker: MemorySyncOutboxWorker | None = None
memory_sync_handlers: dict[str, Any] = {}
if settings.neo4j_password:
from neo4j import AsyncGraphDatabase
@@ -31,6 +37,33 @@ async def serve(*, once: bool = False) -> None:
})
else:
logger.warning("Neo4j password not configured; profile projection remains pending")
if settings.resolved_milvus_uri and settings.knowledge_embedding_endpoint_code:
from pymilvus import AsyncMilvusClient # type: ignore[import-untyped]
milvus_client = AsyncMilvusClient(
uri=settings.resolved_milvus_uri,
token=settings.milvus_token or None,
)
async def embed_profile(text: str) -> list[float]:
endpoints = await DatabaseModelEndpointResolver().resolve(
agent_type="memory_projection", task_type="embedding"
)
if not endpoints:
raise RecoverableAgentError("没有可用的 embedding 端点")
return (await get_memory_embedding_service().embed(endpoints, text)).vector
memory_sync_handlers["milvus"] = MilvusProfileProjection(
milvus_client, embed_profile
).upsert
else:
logger.warning(
"Milvus profile projection not configured; profile projection remains pending"
)
if neo4j_driver is not None:
memory_sync_handlers["neo4j"] = Neo4jProfileProjection(neo4j_driver).upsert
if memory_sync_handlers:
memory_sync_worker = MemorySyncOutboxWorker(memory_sync_handlers)
try:
while True:
try:
@@ -58,6 +91,8 @@ async def serve(*, once: bool = False) -> None:
await offsite_worker.close()
if neo4j_driver is not None:
await neo4j_driver.close()
if milvus_client is not None:
await milvus_client.close()
await engine.dispose()
@@ -0,0 +1,96 @@
from uuid import uuid4
import pytest
from app.core.errors import RecoverableAgentError
from app.infrastructure.milvus_profile_projection import MilvusProfileProjection
class FakeMilvus:
def __init__(self, existing: list[dict[str, object]] | None = None) -> None:
self.existing = existing or []
self.queries: list[dict[str, object]] = []
self.upserts: list[dict[str, object]] = []
async def query(self, **kwargs: object) -> list[dict[str, object]]:
self.queries.append(kwargs)
return self.existing
async def upsert(self, **kwargs: object) -> None:
self.upserts.append(kwargs)
def payload() -> dict[str, object]:
return {
"customer_id": 7,
"profile_version": 1,
"memory_sources": [{
"memory_uuid": str(uuid4()),
"memory_key": "preference:risk_level",
"content": "稳健型",
"memory_type": "preference",
"confidence": 0.9,
"version": 2,
"valid_until": None,
}],
}
@pytest.mark.asyncio
async def test_upsert_writes_schema_fields_and_vector() -> None:
client = FakeMilvus()
projection = MilvusProfileProjection(client, _embed)
await projection.upsert(payload())
assert len(client.upserts) == 1
row = client.upserts[0]["data"][0]
assert row["customer_id"] == 7
assert row["status"] == "active"
assert len(row["embedding"]) == 1024
@pytest.mark.asyncio
async def test_lower_memory_version_is_not_overwritten() -> None:
data = payload()
source = data["memory_sources"][0]
assert isinstance(source, dict)
memory_uuid = source["memory_uuid"]
client = FakeMilvus(existing=[{
"memory_uuid": memory_uuid, "customer_id": 7, "version": 3,
}])
await MilvusProfileProjection(client, _embed).upsert(data)
assert client.upserts == []
@pytest.mark.asyncio
async def test_embedding_dimension_is_enforced() -> None:
with pytest.raises(RecoverableAgentError, match="维度"):
await MilvusProfileProjection(client=FakeMilvus(), embed=_embed_short).upsert(
payload()
)
@pytest.mark.asyncio
async def test_non_uuid_memory_id_is_rejected() -> None:
data = payload()
source = data["memory_sources"][0]
assert isinstance(source, dict)
source["memory_uuid"] = "unsafe\" or true"
with pytest.raises(ValueError, match="memory_uuid"):
await MilvusProfileProjection(FakeMilvus(), _embed).upsert(data)
def _vector(size: int = 1024) -> list[float]:
return [0.0] * size
async def _embed(_: str) -> list[float]:
return _vector()
async def _embed_short(_: str) -> list[float]:
return _vector(3)