Personal Memory Engine:Supabase RLS + FastAPI 实现详解
Engineering System ADR-001 定义了架构。Invest AI ADR-031 定义了 API 接口。这篇文章记录我们具体怎么实现的。
Schema 设计
Supabase 中一张表,延续现有的 user_settings 模式:
create table if not exists public.user_memory (
id bigint generated by default as identity primary key,
user_id uuid not null unique references auth.users (id) on delete cascade,
age integer,
goals text[] not null default '{}',
constraints text[] not null default '{}',
health_conditions text[] not null default '{}',
financial_profile jsonb not null default '{}',
family_members jsonb[] not null default '{}',
preferences jsonb not null default '{}',
decision_style text check (decision_style in ('analytical', 'intuitive', 'collaborative')),
language text not null default 'en' check (language in ('en', 'zh', 'ko')),
version integer not null default 1,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now())
);
user_id 上的 UNIQUE 约束意味着 upsert 只需简单的 on_conflict=user_id——不需要额外的查找。
行级安全策略
四条策略,每个操作一条。模式与 user_settings 完全相同:
alter table public.user_memory enable row level security;
create policy "users can read own memory"
on public.user_memory for select
using (auth.uid() = user_id);
create policy "users can insert own memory"
on public.user_memory for insert
with check (auth.uid() = user_id);
create policy "users can update own memory"
on public.user_memory for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "users can delete own memory"
on public.user_memory for delete
using (auth.uid() = user_id);
为什么用四条策略而不是一条 ALL 策略?更明确。每个操作的意图一目了然。未来添加管理员读取策略(用于支持工具)时,不会意外授予管理员写权限。
MemoryService —— httpx 模式
该服务遵循与 SettingsService 完全相同的模式——没有新的抽象:
class MemoryService:
async def get_memory(self, user_id: str) -> dict[str, Any]:
supabase = get_supabase_config(self._config)
headers = build_rest_headers(service_role=True, config=supabase)
url = build_rest_url(
f"rest/v1/user_memory?select=*&user_id=eq.{user_id}&limit=1",
config=supabase,
)
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
rows = response.json()
return _parse_row(rows[0]) if rows else _empty_memory(user_id)
service_role=True 在后端绕过 RLS——JWT auth 在 FastAPI 层(get_current_user)强制执行,不在 Supabase 层重复。这与项目中所有其他 Supabase 服务的工作方式一致。
LLM Context Block
关键方法是 build_llm_context_block:
async def build_llm_context_block(self, user_id: str) -> str:
memory = await self.get_memory(user_id)
lines = ["## 用户上下文(个人记忆)"]
if memory.get("age"):
lines.append(f"- 年龄:{memory['age']}")
if memory.get("goals"):
lines.append(f"- 目标:{', '.join(memory['goals'])}")
# ... health_conditions, financial_profile, family_members ...
return "\n".join(lines)
这个字符串被添加到 worker 的 LLM system prompt 前面。每日简报 worker 现在知道用户 55 岁、保守型、正在为女儿的医学院做规划——无需任何 UI 变更。
API 路由
app.py 中三条路由,全部需要 get_current_user:
@api_v1.get("/memory")
async def get_memory(current_user=Depends(get_current_user), ...):
return await memory_service.get_memory(current_user["user_id"])
@api_v1.put("/memory")
async def update_memory(payload: dict, current_user=Depends(get_current_user), ...):
return await memory_service.upsert_memory(current_user["user_id"], payload)
@api_v1.delete("/memory", status_code=204)
async def delete_memory(current_user=Depends(get_current_user), ...):
await memory_service.delete_memory(current_user["user_id"])
DELETE 返回 204(无内容)。这是 GDPR 删除接口——硬删除,没有软删除开关。
隐私:敏感字段不进日志
health_conditions 和 financial_profile 永远不出现在应用日志中。_strip_sensitive_logging 函数在结构上强制执行这一点——在任何 dict 传给 logger 之前都会调用它。这条规则是结构性的,不依赖约定。
下一步
- 将
build_llm_context_block接入每日简报 worker(ADR-008 worker 路径) - 在前端添加"我的资料"页面以读取/编辑记忆
- Meal AI 的 worker 读取记忆获取健康状况上下文(ADR-002 已有说明)
相关 ADR: Engineering System ADR-001 · Invest AI ADR-031