Supabase база даних
src/database/database.module.ts · src/database/supabase.service.ts · supabase/migrations/*
Цей документ описує механіку бази і пам’яті: що таке vector DB, які є таблиці, які поля зберігаються, які числові ліміти діють для chunks, embeddings, snapshots і пошуку.
Роль Supabase у stack-ai
Supabase використовується як Postgres + pgvector storage для трьох різних задач:
| Шар | Таблиця/RPC | Для чого |
|---|---|---|
| Vector memory | client_memory_chunks + RPC match_client_memory_chunks | Довга пам’ять діалогу RU←>TU: profile/photo/notes/messages у вигляді chunks з embedding |
| Daily snapshots | tips_daily_entity_snapshots | Денний cache/audit raw/normalized даних, які прийшли з upstream: profiles, photos, notes, dialog messages, marker daily sync |
| Token usage | token_usage_log | Облік LLM-викликів по request/dialog/provider/model/phase/tokens |
Legend зараз не пише результати у Supabase. Legend jobs/results живуть у process memory LegendJobStore, див. legend-service.
Що таке vector DB у цьому сервісі
Vector DB тут не означає окрему базу даних. Це звичайний Postgres у Supabase з extension pgvector.
Звичайний пошук працює по точному тексту: text contains "travel". Векторний пошук працює інакше:
- Текст перетворюється у числовий вектор, тобто embedding.
- Схожі за змістом тексти отримують близькі вектори.
- Коли приходить новий запит, він теж перетворюється у embedding.
- База шукає chunks, embedding яких найближчий до embedding запиту.
Для stack-ai це потрібно, щоб знаходити не тільки точні слова, а релевантний контекст діалогу:
- нотатка може містити “he worries about trust”;
- новий query може бути про “serious relationship”;
- exact text match може не спрацювати;
- semantic search все одно може підняти цю note, бо сенс близький.
Базова логіка даних
У сервісі є два типи збереження даних.
1. Long-term memory
client_memory_chunks зберігає vector memory:
| Дані | Як пишуться |
|---|---|
| Profile | До 10 profile lines, 1 row на кожну непорожню line |
| Photo | 1 row для main photo URL, якщо URL є |
| Note | Text обрізається до 800 символів, потім ріжеться на chunks до 420 символів |
| Message | Body обрізається до 420 символів; у memory беруться останні 80 meaningful messages |
Кожен row отримує embedding vector(768) і може бути знайдений через semantic search.
2. Daily audit/cache
tips_daily_entity_snapshots зберігає snapshots на 14 днів. Це не vector memory: там немає embedding і semantic search.
| Дані | Як пишуться |
|---|---|
| Profile snapshot | entity_type='profile', source_role='ru' або source_role='tu' |
| Photo snapshot | entity_type='photo', payload { url } |
| Note snapshot | entity_type='note', payload { date, text, timestamp } |
| Dialog snapshot | entity_type='dialog', payload { date, initiator, text } |
| Daily marker | entity_type='daily_sync', source_id='daily-sync' |
Ця таблиця потрібна для cache/audit і hash-порівняння payload, а не для prompt retrieval.
TODO: для фото потрібно додати повноцінний текстовий шар. Зараз система може зберігати URL фото або snapshot payload, але має зберігати результат vision-аналізу як client_memory_chunks.source='photo'. Деталі: photo-analysis і photo-analysis-ingest.
Ключові числа
| Параметр | Значення | Де використовується |
|---|---|---|
| Embedding dimensions | 768 | client_memory_chunks.embedding vector(768) |
| Embedding model | gemini-embedding-001 | ingest/search |
| Embedding batch size | 32 | batch document embeddings при ingest |
| Note input limit | 800 chars | normalizeNote() |
| Chunk split size | 420 chars | splitLongText() для notes |
| Message body limit | 420 chars | normalizeMessage() |
| Messages per ingest | 80 latest | conversation.messages.slice(-80) |
| Search API default limit | 12 chunks | MemoryService.search() |
| Search API max limit | 24 chunks | clamp у TypeScript |
| RPC hard max limit | 50 chunks | clamp у SQL |
| Snapshot retention | 14 days | expires_at default now() + interval '14 days' |
| Local fallback vector | 128 dims | hashed lexical fallback у payload-utils |
Як дані потрапляють у prompt
Скорочена схема:
upstream data
-> normalize
-> chunks
-> embeddings
-> client_memory_chunks
-> query embedding
-> match_client_memory_chunks
-> memoryContext.selectedChunks
-> AssistantService promptУ prompt не потрапляє вся база. У prompt потрапляють тільки top-N selected chunks, які RPC знайшла як найбільш релевантні для поточного query. Зараз у tips-flow limit = 12.
Формат selected chunks після retrieval:
Selected vector memory chunks:
1. [note; score=0.91; 2026-05-20] ...
2. [conversation; score=0.86; 2026-05-21T...] ...
3. [profile; score=0.82] ...Саме тому важливо, які chunks записані і як вони ранжуються: це напряму впливає на те, що LLM буде вважати важливим контекстом.
Підключення
Підключення створюється у DatabaseModule, який позначений @Global().
Env
URL:
BESCO_SUPABASE_URL;- fallback
SUPABASE_URL.
Key, перший знайдений:
BESCO_SUPABASE_SERVICE_ROLE_KEY;SUPABASE_SERVICE_ROLE_KEY;BESCO_SUPABASE_PUBLISHABLE_KEY;SUPABASE_PUBLISHABLE_KEY;SUPABASE_ANON_KEY.
Якщо URL або key відсутні, backend падає на старті. Це fail-fast: без Supabase сервіс не повинен підніматися.
Клієнт створюється через createClient(url, key) і далі використовується сервісами для .from(...), .rpc(...), .upsert(...), .select(...).
Таблиця client_memory_chunks
Це основна таблиця довгої пам’яті. Вона зберігає атомарні chunks з semantic embedding. Пошук по ній іде через pgvector RPC match_client_memory_chunks.
Повної базової create-table міграції в репозиторії зараз немає. Поточні міграції очікують, що таблиця вже існує:
20260519_gemini_memory_embeddings.sql:- вмикає extension
vector; - робить
truncate table public.client_memory_chunks; - пересоздає
embedding vector(768) not null; - створює HNSW index;
- створює RPC
match_client_memory_chunks.
- вмикає extension
20260520_client_memory_photo_source.sql:- додає
photoу check constraintsource; - пересоздає RPC з boost для
photo.
- додає
Поля row
MemoryService.toMemoryRow() формує row так:
| Поле | Значення |
|---|---|
dialog_key | sha256 ключ пари RU←>TU |
content_hash | sha256 ідемпотентності chunk |
client_id | RU id (manId_api/client/man у legacy назвах) або null |
woman_id | TU id (ladyId_api/анкета/леді у legacy назвах) або null; це технічна назва поля, не окрема доменна сутність “woman” |
favorite_id | favorite id або dialog/cache fallback |
source | profile, photo, note, conversation, summary |
source_id | id джерела: note timestamp/id, message id, profile:N, photo URL |
text | очищений текст chunk без службового incoming:/outgoing: prefix |
direction | incoming, outgoing, null |
compose_mode | reply, both |
embedding | pgvector literal [number,...] |
embedding_model | gemini-embedding-001:768 |
language | зараз null |
note_date | дата note |
sent_at | дата message |
tags | source tag + regex tags |
metadata | { originalChunkId, scoreAtIngestion } |
Dialog key
У коді є два алгоритми:
| Місце | Формула | Коментар |
|---|---|---|
| Новий tips-flow | sha256("dialog:<ruId>:<tuId>") | Використовує MemoryService.getDialogKey({ ruId, tuId }) |
| Generic memory API fallback | sha256(["dialog", womanId, clientId].join(":")) | womanId = TU, clientId = RU |
Якщо request уже містить dialogKey, використовується він. Для неоднозначних payload краще передавати dialogKey явно, щоб не отримати інший порядок id.
Idempotency
content_hash рахується від стабільної комбінації:
sha256(dialogKey + source + sourceId + text)Запис іде через upsert:
supabase.from('client_memory_chunks').upsert(rows, {
onConflict: 'dialog_key,content_hash',
})Один і той самий chunk при повторному ingest не створює дубль, а оновлює існуючий row.
Sources і ліміти
| Source | Row count / limit | Як формується |
|---|---|---|
profile | 0..10 rows | profileMemoryLines() створює окремий row для кожної непорожньої line: Name, Age, Location, Occupation, Body type, Marital status, Personality type, Goals, Hobbies, Traits |
photo | 0..1 row зараз | Якщо є profile.photoUrl, пишеться Main profile photo is available: <url>. TODO: замінити/доповнити це vision summary з photo-analysis |
note | 0..N rows | normalizeNote() бере text/note/summary, обрізає до 800 chars, splitLongText() ріже на chunks до 420 chars |
conversation | 0..80 rows | normalizeMessage() бере body + attachment labels, обрізає до 420 chars, відкидає system messages, buildMemoryChunks() бере останні 80 messages |
summary | 0 rows зараз | Source дозволений типами/схемою, але поточний ingest його не створює |
Tags
Код завжди додає source tag, плюс regex tags:
| Tag | Regex terms |
|---|---|
sexual | sex, sexual, horny, kiss, naked, cock, pussy, cum, orgasm, fantasy |
trust | wife, married, trust, honest, jealous, serious, relationship |
emotional | miss, love, feel, lonely, care, warm, soft, heart |
Tags зараз тільки metadata. RPC по tags не фільтрує.
Як chunks створюються перед записом
MemoryService.ingest() приймає conversationJson, profileJson, photoUrl, ids і optional dialogKey.
prepareRequest()нормалізує ids,dialogKey,composeMode, JSON payload.prepareInputs()витягує profile, notes, messages, photo URL.buildMemoryChunks()створює chunks:- profile lines →
source='profile'; - main photo URL →
source='photo'; - notes →
source='note'; - останні 80 messages →
source='conversation'.
- profile lines →
- Для кожного chunk рахується
content_hash. - Для кожного chunk запитується Gemini embedding.
- Rows upsert-яться в
client_memory_chunks.
Raw snapshots з tips_daily_entity_snapshots не йдуть напряму у vector search. Для пошуку дані мають бути записані в client_memory_chunks з embedding.
Embeddings
Для запису використовується Gemini:
| Параметр | Значення |
|---|---|
| Env key | BESCO_GEMINI_API_KEY |
| Model | gemini-embedding-001 |
| Dimensions | 768 |
| Document task | RETRIEVAL_DOCUMENT |
| Batch size | 32 |
| DB type | vector(768) |
Якщо BESCO_GEMINI_API_KEY відсутній, ingest/search падає. Якщо Gemini повернув неправильну кількість vectors або не 768 dimensions, ingest падає з BadGatewayException.
Що повертає ingest
Якщо chunks створені:
{
favoriteId,
dialogKey,
inserted: rows.length,
sources: {
profile: 5,
note: 8,
conversation: 18,
photo: 1
},
totalChunks: rows.length
}Якщо chunks не створились:
{
favoriteId,
inserted: 0,
message: 'No memory chunks were produced from the payload.',
totalChunks: 0
}RPC match_client_memory_chunks
Це SQL RPC для semantic search по client_memory_chunks.
Вхідні параметри
MemoryService.search() викликає:
supabase.rpc('match_client_memory_chunks', {
match_client_id: parsed.clientId,
match_compose_mode: parsed.composeMode === 'both' ? 'reply' : parsed.composeMode,
match_count: clampInteger(request.limit, 12, 1, 24),
match_dialog_key: parsed.dialogKey,
query_embedding: '[...]',
source_weights: {
note: 0.18,
photo: 0.05,
profile: 0.08,
},
})query_embedding рахується через Gemini gemini-embedding-001, dimensions 768, task type RETRIEVAL_QUERY.
SQL-механіка пошуку
RPC робить:
- Фільтр по
dialog_key: тільки chunks конкретної пари RU←>TU. - Фільтр по
client_id:- якщо
match_client_id is null, фільтр не застосовується; - якщо row
client_id is null, row допускається; - інакше
chunk.client_id = match_client_id.
- якщо
- Фільтр по
compose_mode:- якщо
match_compose_mode is null, row допускається; - якщо
chunk.compose_mode = 'both', row допускається; - інакше
chunk.compose_mode = match_compose_mode.
- якщо
- Cosine similarity:
1 - (chunk.embedding <=> query_embedding)- Додає source boost для типів, які бізнесово важливіші за звичайний chat.
- Рахує
weighted_score. - Сортує
order by weighted_score desc. - Обмежує кількість:
greatest(1, least(coalesce(match_count, 12), 50)).
Чому такі boosts
Базовий score - це cosine similarity. Boost не замінює semantic similarity, а лише трохи піднімає типи даних, які зазвичай корисніші для відповіді.
| Source | Boost | Чому |
|---|---|---|
note | +0.18 | Notes - це curated контекст від оператора або AI. Там часто є найважливіші факти: що RU любить, чого уникати, які були домовленості |
profile | +0.08 | Profile потрібен для стабільних фактів, але не має перебивати актуальні notes |
photo | +0.05 | Photo URL або майбутній vision summary може бути корисним контекстом, але не має перебивати notes |
conversation не має бізнесового boost. Це baseline: якщо conversation semantic-схожий на query, він і так підніметься за cosine similarity. Додавати окремий boost для кожної звичайної репліки небезпечно, бо короткі chat messages можуть забити notes/profile просто кількістю. У SQL це технічно означає fallback 0, тобто “без додаткового підняття”.
Що повертає RPC
| Поле | Для чого |
|---|---|
id | id chunk |
source | тип джерела |
text | текст chunk |
weighted_score | cosine similarity + source boost |
note_date | дата note, якщо є |
sent_at | дата message, якщо є |
direction | incoming/outgoing, якщо є |
RPC не повертає embedding, tags, metadata, favorite_id, woman_id.
Що повертає API search
POST /stack-ai/tips/memory/search обгортає RPC результат:
{
favoriteId,
dialogKey,
query,
results: [
{
id,
source,
text,
weighted_score,
note_date,
sent_at,
direction
}
]
}Fallback search
До Supabase enrichment prepareInputs() будує локальний memoryContext на 128-мірних hashed lexical vectors. Якщо Supabase search падає, enrichPreparedMemory() логить warning і залишає цей fallback. Це не pgvector і не semantic search.
Таблиця tips_daily_entity_snapshots
Це не vector table. Вона зберігає daily cache/audit того, що було завантажено з upstream.
Міграція: 20260520_tips_daily_entity_snapshots.sql.
Поля
| Поле | Значення |
|---|---|
id | uuid primary key |
sync_date | дата синку, default current_date |
dialog_key | sha256 ключ пари RU←>TU |
family | upstream family |
ru_id | RU/man/client id |
tu_id | TU/lady/profile id |
entity_type | daily_sync, dialog, note, photo, profile |
source_role | ru, tu, або порожній рядок |
source_id | id джерела всередині entity type |
entity_at | timestamp події, якщо є |
initiator | хто ініціював dialog message |
text | короткий текст для перегляду |
payload | raw або normalized JSON |
content_hash | sha256 від відсортованого payload |
created_at | insert time |
updated_at | update time |
expires_at | default now + 14 days |
Індекси
Unique:
(dialog_key, sync_date, entity_type, source_role, source_id)Lookup:
(dialog_key, entity_type, source_role, sync_date desc)Expiry:
(expires_at)Код зараз не видаляє expired rows. Індекс є, cleanup job у сервісах не видно.
Entity types
entity_type | source_role | source_id | payload | text |
|---|---|---|---|---|
profile | ru або tu | ru або tu | normalized profile | не обов’язково |
photo | ru або tu | role | { url } зараз; TODO: додати payload vision-аналізу | photo URL / майбутній photo analysis |
note | '' | timestamp/date/index | { date, text, timestamp } | note text |
dialog | '' | date/index:initiator | { date, initiator, text } | message content |
daily_sync | '' | daily-sync | { completedAt } | не обов’язково |
Upsert
Snapshots пишуться через:
supabase.from('tips_daily_entity_snapshots').upsert(rows, {
onConflict: 'dialog_key,sync_date,entity_type,source_role,source_id',
})Помилка upsert snapshots не валить основний процес. Код пише warning і продовжує.
Hash
content_hash рахується як sha256 від JSON з відсортованими ключами:
createHash('sha256').update(JSON.stringify(sortJson(value))).digest('hex')Це дозволяє порівнювати payload незалежно від порядку ключів.
Таблиця token_usage_log
Міграція: 20260520_token_usage_log.sql.
Поля
| Поле | Значення |
|---|---|
id | uuid primary key |
at | timestamp call |
request_id | UUID одного backend request |
dialog_key | dialog hash |
ru_id | RU id |
tu_id | TU id |
invite_type | type з request |
provider | gemini або xai |
model | модель |
phase | photo, context, draft-batch, evaluate, repair, embed, generate |
prompt_tokens | prompt/input tokens |
completion_tokens | completion/output tokens |
total_tokens | total |
Індекси:
(dialog_key, at desc);(request_id).
Запис
TokenUsageService.trackWorkflowUsage() отримує workflow.tokenUsage з AssistantService.
Якщо entries є, кожен entry пишеться окремою row.
Якщо entries немає, сервіс створює fallback estimate:
phase='generate';provider='gemini';model='unknown';- tokens рахуються через
estimateTokens().
Insert error не валить генерацію. Помилка логиться як token_usage_log insert skipped: <message>.
Читання
TokenUsageService.getDialogUsage(dialogKey, since?):
- Бере local in-memory entries.
- Читає
token_usage_logпоdialog_key. - Якщо
sinceпереданий, додає.gte('at', sinceIso). - Сумує
calls,prompt,completion,total.
Публічного route для цього методу зараз не видно.
Health/status операції
GET /stack-ai/tips/supabase/health
SupabaseService.checkHealth():
- HTTP
GET <url>/auth/v1/healthзapikey. - Head probe:
supabase
.from('client_memory_chunks')
.select('id', { count: 'exact', head: true })ok=true тільки якщо Auth health ok і client_memory_chunks доступна.
GET /stack-ai/tips/memory/status
MemoryService.status() робить head count client_memory_chunks і повертає:
ready;table: 'client_memory_chunks';totalChunks;- при помилці:
error,needsMigration.
POST /stack-ai/tips/status
MemoryService.getDialogStatus({ ruId, tuId }) читає:
supabase
.from('client_memory_chunks')
.select('source, note_date, sent_at, updated_at')
.eq('dialog_key', dialogKey)Повертає counts і latest dates:
lastNoteAt= maxnote_dateдляsource='note';lastMessageAt= maxsent_atдляsource='conversation';profileUpdatedAt= maxupdated_atдляsource='profile';photoUpdatedAt= maxupdated_atдляsource='photo';countsпоprofile,note,conversation,photo,summary.
Що база не робить
- Не генерує drafts.
- Не тримає бізнес-логіку tips pipeline.
- Не зберігає Legend pipeline/result.
- Не зберігає mood з
TipsMemoryService.dialogMoodScores; це processMap. - Не зберігає generated drafts в окрему таблицю.
- Не фільтрує retrieval по
tags. - Не повертає embeddings назовні.
- Не чистить
tips_daily_entity_snapshots.expires_atу коді. - Не зберігає повний vision-аналіз фото в
client_memory_chunksзараз; це описано як TODO у photo-analysis-ingest.
Поточні ризики
- Немає базової create-table міграції
client_memory_chunks. 20260519_gemini_memory_embeddings.sqlробитьtruncate client_memory_chunks.- Є два порядки dialog key: new tips
ruId:tuIdі generic memorywomanId:clientId, деwomanId= TU,clientId= RU. tips_daily_entity_snapshotsмає expiry index, але cleanup job не описаний у коді.token_usage_loginsert errors не валять request, тому usage може бути неповним.- HNSW index створений без tuning параметрів у міграції.