기술 블로그

← 모든 글

Personal Memory Engine: Supabase RLS + FastAPI Implementation

UI는 한국어입니다. 글 본문은 아직 영어 또는 중국어만 있습니다.

Engineering System ADR-001 defined the architecture. Invest AI ADR-031 defined the API surface. Here's how we actually built it.

The Schema

One table in Supabase, extending the existing user_settings pattern:

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())
);

UNIQUE on user_id means upsert is a simple on_conflict=user_id — no secondary lookup needed.

Row Level Security

Four policies, one per operation. The pattern is identical to 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);

Why separate policies instead of one ALL policy? Explicit. Each operation's intent is readable. Adding a future admin-read policy (for support tooling) doesn't accidentally grant admin write.

MemoryService — The httpx Pattern

The service follows the exact same pattern as SettingsService — no new abstractions:

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 bypasses RLS on the backend — the JWT auth is enforced at the FastAPI layer (get_current_user), not repeated at the Supabase level. This matches how every other Supabase service in this project works.

The LLM Context Block

The key method is build_llm_context_block:

async def build_llm_context_block(self, user_id: str) -> str:
    memory = await self.get_memory(user_id)
    lines = ["## User Context (Personal Memory)"]
    if memory.get("age"):
        lines.append(f"- Age: {memory['age']}")
    if memory.get("goals"):
        lines.append(f"- Goals: {', '.join(memory['goals'])}")
    # ... health_conditions, financial_profile, family_members ...
    return "\n".join(lines)

This string gets prepended to the worker's LLM system prompt. The daily brief worker now knows the user is 55, conservative, planning for a daughter's medical school — without any UI change.

The API Routes

Three routes in app.py, all behind 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 returns 204 (no content). It's the GDPR erasure endpoint — hard delete, no soft-delete toggle.

Privacy: No Sensitive Fields in Logs

health_conditions and financial_profile never appear in application logs. The _strip_sensitive_logging function enforces this — it's called before any dict is passed to a logger. The rule is enforced structurally, not by convention.

What's Next

  • Wire build_llm_context_block into the daily brief worker (ADR-008 worker path)
  • Add "My Profile" page in the frontend to read/edit memory
  • Meal AI's worker reads memory for health condition context (ADR-002 already specifies this)

Related ADRs: Engineering System ADR-001 · Invest AI ADR-031