Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,5 @@ EXTERNAL_RETRY_MAX_DELAY_SECONDS=4.0

CIRCUIT_BREAKER_FAILURE_THRESHOLD=3
CIRCUIT_BREAKER_RECOVERY_TIMEOUT_SECONDS=30

MEMORY_AGENT_SERVICE_JWT_SECRET=<另一组随机密钥>
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ cd Mneme
cp .env-example .env
```

Windows PowerShell:

```powershell
# Windows PowerShell
Copy-Item .env-example .env
Expand Down Expand Up @@ -405,6 +407,8 @@ CI 会分别执行前端、后端和集成检查;只有三个阶段全部通
| [Operations Runbook](docs/operations-runbook.md) | 监控、告警、备份、恢复与故障处理 |
| [Deployment](deploy/DEPLOY.md) | 生产部署、发布、回滚及 Memoria 运维 |

规范路径:[docs/architecture.md](docs/architecture.md)、[docs/runtime-contracts.md](docs/runtime-contracts.md)、[docs/current-state.md](docs/current-state.md)。

## 参与贡献

欢迎通过 [Issues](https://github.com/juemimgcd/Reminder/issues) 报告问题或提出建议。提交 Pull Request 前,请运行与改动范围对应的质量检查,并保持以下边界:
Expand Down
17 changes: 14 additions & 3 deletions app/mneme/domains/tasks/outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,21 +471,32 @@ async def enqueue_user_memory_settings_changed(
db: AsyncSession,
*,
owner_id: int,
automatic_conversation_memory: bool,
automatic_conversation_memory: bool | None = None,
ad_personalization_enabled: bool | None = None,
occurred_at: datetime,
) -> OutboxEvent:
payload = {
key: value
for key, value in {
"automatic_conversation_memory": automatic_conversation_memory,
"ad_personalization_enabled": ad_personalization_enabled,
}.items()
if value is not None
}
if not payload:
raise ValueError("at least one memory setting is required")
event = MemoryAgentEvent(
event_id=_memory_event_id(
"memory-settings-changed",
str(owner_id),
occurred_at.isoformat(),
str(automatic_conversation_memory),
repr(sorted(payload.items())),
),
event_type="user.memory_settings.changed",
occurred_at=occurred_at,
owner_id=owner_id,
knowledge_base_id=None,
payload={"automatic_conversation_memory": automatic_conversation_memory},
payload=payload,
)
return await _enqueue_memory_agent_event(
db,
Expand Down
7 changes: 6 additions & 1 deletion app/mneme/memoria/api/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,5 +314,10 @@ async def patch_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_write_database),
):
data = await service.update_settings(db, owner_id=current_user.id, enabled=payload.automatic_conversation_memory)
data = await service.update_settings(
db,
owner_id=current_user.id,
automatic_conversation_memory=payload.automatic_conversation_memory,
ad_personalization_enabled=payload.ad_personalization_enabled,
)
return success_response(data=data, message="memory settings accepted")
18 changes: 18 additions & 0 deletions app/mneme/memoria/clients/memory_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from app.mneme.conf.config import settings
from app.mneme.memoria.schemas.memory_agent import (
AdRecommendationRequest,
AdRecommendationResponse,
CanonicalMemoryData,
ConversationMemorySettingsData,
EventReceipt,
Expand Down Expand Up @@ -116,6 +118,22 @@ async def create_answer(self, request: MemoryAgentAnswerRequest) -> MemoryAgentA
except ValueError as exc:
raise MemoryAgentPermanentFailure("memory agent returned an invalid answer response") from exc

async def recommend_ads(self, request: AdRecommendationRequest) -> AdRecommendationResponse:
"""Rerank caller-filtered ads without exposing governed memory content."""
response = await self._post_json(
path="/v1/ad-recommendations",
payload=request.model_dump(mode="json"),
request_id=request.request_id,
scope="ads:recommend",
owner_id=request.owner_id,
knowledge_base_id=request.knowledge_base_id,
retry_transient=True,
)
try:
return AdRecommendationResponse.model_validate(response.json())
except ValueError as exc:
raise MemoryAgentPermanentFailure("memory agent returned an invalid ad recommendation") from exc

async def stream_answer(self, request: MemoryAgentAnswerRequest) -> AsyncIterator[MemoryAgentStreamEvent]:
"""Yield validated events from the Memoria answer stream.

Expand Down
25 changes: 21 additions & 4 deletions app/mneme/memoria/memory_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from app.mneme.memoria.clients.memory_agent import MemoryAgentClient
from app.mneme.memoria.schemas.memory_agent import (
CanonicalMemoryData,
ConversationMemorySettingsData,
GovernedMemoryPage,
MemoryCandidateData,
MemoryCandidatePage,
Expand Down Expand Up @@ -293,11 +292,29 @@ async def list_candidates(
return MemoryCandidatePage(items=items, next_cursor=next_cursor, total=total, pending_count=total)


async def update_settings(db: AsyncSession, *, owner_id: int, enabled: bool) -> ConversationMemorySettingsData:
async def update_settings(
db: AsyncSession,
*,
owner_id: int,
automatic_conversation_memory: bool | None,
ad_personalization_enabled: bool | None,
) -> dict[str, bool]:
await enqueue_user_memory_settings_changed(
db, owner_id=owner_id, automatic_conversation_memory=enabled, occurred_at=datetime.now(UTC)
db,
owner_id=owner_id,
automatic_conversation_memory=automatic_conversation_memory,
ad_personalization_enabled=ad_personalization_enabled,
occurred_at=datetime.now(UTC),
)
return ConversationMemorySettingsData(automatic_conversation_memory=enabled, applied=False)
return {
key: value
for key, value in {
"automatic_conversation_memory": automatic_conversation_memory,
"ad_personalization_enabled": ad_personalization_enabled,
"applied": False,
}.items()
if value is not None
}


def _secret() -> str:
Expand Down
68 changes: 64 additions & 4 deletions app/mneme/memoria/schemas/memory_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,17 @@
"""

from datetime import datetime
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
from typing import Annotated, Any, Literal

from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
StringConstraints,
field_validator,
model_validator,
)

AnswerMode = Literal[
"kb_qa",
Expand Down Expand Up @@ -154,6 +162,49 @@ class MemoryAgentStreamEvent(BaseModel):
response: MemoryAgentAnswerResponse | None = None


AdTag = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64)]


class AdCandidateData(BaseModel):
model_config = ConfigDict(extra="forbid")

ad_id: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=240)
description: str = Field(default="", max_length=2000)
tags: list[AdTag] = Field(default_factory=list, max_length=20)
business_score: float = Field(default=0.0, ge=0, le=1)


class AdRecommendationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

request_id: str = Field(min_length=1, max_length=128)
owner_id: int = Field(gt=0)
knowledge_base_id: str | None = Field(default=None, max_length=128)
placement: str = Field(min_length=1, max_length=64)
candidates: list[AdCandidateData] = Field(min_length=1, max_length=100)
limit: int = Field(default=1, ge=1, le=10)

@model_validator(mode="after")
def candidate_ids_must_be_unique(self) -> "AdRecommendationRequest":
ids = [candidate.ad_id for candidate in self.candidates]
if len(ids) != len(set(ids)):
raise ValueError("candidate ad IDs must be unique")
return self


class AdRecommendationItem(BaseModel):
ad_id: str
score: float = Field(ge=0, le=1)
matched_topics: list[str] = Field(default_factory=list)


class AdRecommendationResponse(BaseModel):
request_id: str
personalized: bool
items: list[AdRecommendationItem]


class CanonicalMemoryData(BaseModel):
memory_id: str
knowledge_base_id: str | None
Expand All @@ -162,6 +213,7 @@ class CanonicalMemoryData(BaseModel):
predicate: str
value: str
confidence: float
sensitivity: str = "unknown"
status: str
active_revision_id: str
created_at: datetime
Expand Down Expand Up @@ -288,9 +340,17 @@ def exactly_one_selector(self) -> "MemoryPurgeRequest":


class ConversationMemorySettingsUpdate(BaseModel):
automatic_conversation_memory: bool
automatic_conversation_memory: bool | None = None
ad_personalization_enabled: bool | None = None

@model_validator(mode="after")
def require_one_setting(self) -> "ConversationMemorySettingsUpdate":
if self.automatic_conversation_memory is None and self.ad_personalization_enabled is None:
raise ValueError("at least one memory setting is required")
return self


class ConversationMemorySettingsData(BaseModel):
automatic_conversation_memory: bool
ad_personalization_enabled: bool = False
applied: bool
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""persist memory sensitivity and ad personalization consent

Revision ID: 20260830_01
Revises: 20260718_03
Create Date: 2026-08-30
"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "20260830_01"
down_revision: str | Sequence[str] | None = "20260718_03"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"canonical_memories",
sa.Column("sensitivity", sa.String(length=16), server_default="unknown", nullable=False),
)
op.create_check_constraint(
"ck_canonical_memories_sensitivity",
"canonical_memories",
"sensitivity IN ('unknown', 'low', 'sensitive')",
)
op.execute(
"""
UPDATE canonical_memories AS memory
SET sensitivity = 'sensitive'
FROM (
SELECT
owner_id,
knowledge_base_id,
fingerprint
FROM memory_candidates
WHERE status = 'promoted'
AND sensitivity = 'sensitive'
GROUP BY owner_id, knowledge_base_id, fingerprint
) AS candidates
WHERE memory.owner_id = candidates.owner_id
AND memory.knowledge_base_id IS NOT DISTINCT FROM candidates.knowledge_base_id
AND memory.fingerprint = candidates.fingerprint
"""
)
op.add_column(
"memory_settings",
sa.Column("ad_personalization_enabled", sa.Boolean(), server_default="false", nullable=False),
)
op.add_column(
"memory_settings",
sa.Column("ad_personalization_last_event_occurred_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"memory_settings",
sa.Column("ad_personalization_last_event_id", sa.String(length=128), nullable=True),
)


def downgrade() -> None:
op.drop_column("memory_settings", "ad_personalization_last_event_id")
op.drop_column("memory_settings", "ad_personalization_last_event_occurred_at")
op.drop_column("memory_settings", "ad_personalization_enabled")
op.drop_constraint(
"ck_canonical_memories_sensitivity",
"canonical_memories",
type_="check",
)
op.drop_column("canonical_memories", "sensitivity")
6 changes: 5 additions & 1 deletion app/mneme/memoria/server/api/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class CanonicalMemoryDTO(BaseModel):
predicate: str
value: str
confidence: float
sensitivity: str
status: str
active_revision_id: str
created_at: datetime
Expand Down Expand Up @@ -309,6 +310,7 @@ async def get_memory_settings(
row = await db.get(MemorySettings, owner_id)
return {
"automatic_conversation_memory": bool(row and row.automatic_conversation_memory),
"ad_personalization_enabled": bool(row and row.ad_personalization_enabled),
"applied": row is not None,
}

Expand Down Expand Up @@ -371,7 +373,8 @@ async def command_memory(
)
else:
assert command.subject is not None and command.predicate is not None and command.value is not None
if classify_sensitivity(command.subject, command.predicate, command.value) == "secret":
sensitivity = classify_sensitivity(command.subject, command.predicate, command.value)
if sensitivity == "secret":
raise ValueError("secret values cannot be persisted")
row = await memory_commands.revise(
db,
Expand All @@ -382,6 +385,7 @@ async def command_memory(
predicate=command.predicate,
value=command.value,
confidence=command.confidence,
sensitivity=sensitivity,
actor_id=command.actor_id,
reason=command.reason,
)
Expand Down
25 changes: 25 additions & 0 deletions app/mneme/memoria/server/api/recommendations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Expose scoped memory-personalized ad reranking."""

from typing import Annotated, Any

from fastapi import APIRouter, Depends

from app.mneme.memoria.server.api.dependencies import require_claimed_scope, require_service_scope
from app.mneme.memoria.server.contracts.recommendations import AdRecommendationRequest, AdRecommendationResponse
from app.mneme.memoria.server.security.service_tokens import AD_RECOMMENDATIONS_SCOPE
from app.mneme.memoria.server.services.ad_recommendations import recommend_ads

router = APIRouter()


@router.post("/ad-recommendations", response_model=AdRecommendationResponse)
async def create_ad_recommendation(
request: AdRecommendationRequest,
claims: Annotated[dict[str, Any], Depends(require_service_scope(AD_RECOMMENDATIONS_SCOPE))],
) -> AdRecommendationResponse:
require_claimed_scope(
claims,
owner_id=request.owner_id,
knowledge_base_id=request.knowledge_base_id,
)
return await recommend_ads(request)
2 changes: 2 additions & 0 deletions app/mneme/memoria/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from app.mneme.memoria.server.api.events import router as event_router
from app.mneme.memoria.server.api.health import router as health_router
from app.mneme.memoria.server.api.memories import router as memories_router
from app.mneme.memoria.server.api.recommendations import router as recommendations_router
from app.mneme.memoria.server.api.runs import router as runs_router
from app.mneme.memoria.server.config import settings
from app.mneme.memoria.server.observability.context import safe_log
Expand Down Expand Up @@ -46,4 +47,5 @@ def create_memory_agent_app() -> FastAPI:
app.include_router(answers_router, prefix="/v1")
app.include_router(runs_router, prefix="/v1")
app.include_router(memories_router, prefix="/v1")
app.include_router(recommendations_router, prefix="/v1")
return app
1 change: 1 addition & 0 deletions app/mneme/memoria/server/contracts/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class MemoryData(BaseModel):
predicate: str
value: str
confidence: float
sensitivity: str
status: MemoryStatus
created_at: datetime
updated_at: datetime
Loading
Loading