Tips generation pipeline
Повний процес POST /stack-ai/tips/generate: від HTTP-запиту з Electron до поточної HTTP-відповіді. Опис бази як механіки таблиць/RPC винесений у supabase.
Route
Controller:
src/modules/tips/tips.controller.ts
POST /stack-ai/tips/generateGuard:
AuthGuard;RolesGuard;- role
operator.
Controller не робить бізнес-логіку. Він передає body у TipsService.generate(body).
Request
DTO: TipsGenerateRequestDto.
{
"family": "golden | prime | udate | chathouse",
"ruId": "RU id",
"tuId": "TU id",
"type": "invite/reply type",
"mood": "mood from client",
"prompt": "optional operator prompt"
}Терміни:
ruId= RU, чоловік-клієнт сайту.tuId= TU, анкета/леді, від імені якої працює оператор.family= партнерський проєкт, через який треба тягнути upstream дані.
Поточна response
Зараз method повертає Promise<string[]> з трьома hardcoded рядками:
[
`(${type}/${mood}) Hey! Great to hear from you - how was your day?`,
`(${type}/${mood}) I was just thinking about you. What are you up to right now?`,
`(${type}/${mood}) Tell me something I don't know about you yet.`,
]Важливо: перед цим код уже реально виконує sync, vector retrieval, LLM workflow і token logging. Але HTTP-відповідь поки не бере workflow.assistantOutput.drafts.
У коді вже є закоментований плановий shape:
{
diagnostics: {
inputOverview,
dailySync,
memoryContext,
modelRouting,
requestId,
},
dialogKey,
drafts,
inviteType,
status,
tokenUsage,
}Крок 1. Старт request
TipsService.generate():
- Генерує
requestId = randomUUID(). - Будує
dialogTag = <family>/ruId:<ruId>/tuId:<tuId> type=<type> mood=<mood>. - Логує
generate start.
requestId потім використовується для token_usage_log.
Крок 2. Daily sync першого кліку
Виклик:
dailySyncService.syncFirstClick({ family, ruId, tuId })Ціль цього кроку: один раз на день підтягнути статичні/напівстатичні дані і оновити базу, якщо вони змінилися.
2.1 Dialog key
DailySyncService бере:
dialogKey = memoryService.getDialogKey({ ruId, tuId })Формула:
sha256(`dialog:${ruId}:${tuId}`)2.2 Перевірка today’s profiles cache
getTodayProfiles() читає tips_daily_entity_snapshots:
select source_role, payload
where dialog_key = dialogKey
and sync_date = today
and entity_type = 'profile'
and source_role in ('ru', 'tu')Якщо на сьогодні є обидва profiles:
- upstream profiles не запитуються;
didRun=false;freshNotes=[];profileChanged=false;photoChanged=false;ruProfile/tuProfileберуться з snapshot payload.
Це cache check тільки по profiles. Він не перевіряє notes/dialog snapshots.
2.3 Якщо cache немає - fetch profiles
Паралельно:
profileService.fetchRu({ family, ruId, tuId })
profileService.fetchTu({ family, ruId, tuId })Це upstream-запити через family.
2.4 Порівняння з останніми snapshots
Для кожної сутності читається останній snapshot:
latestSnapshot(dialogKey, 'profile', 'ru')
latestSnapshot(dialogKey, 'profile', 'tu')
latestSnapshot(dialogKey, 'photo', 'ru')
latestSnapshot(dialogKey, 'photo', 'tu')Потім рахуються hashes:
ruProfileHash;tuProfileHash;ruPhotoHash;tuPhotoHash.
Hash = sha256 від JSON з відсортованими ключами.
Прапори:
profileChanged=true, якщо RU або TU profile hash відрізняється від останнього snapshot;photoChanged=true, якщо RU або TU photo hash відрізняється від останнього snapshot.
2.5 Якщо profile/photo змінилися - оновити vector memory
Якщо profileChanged || photoChanged:
- Видалити старі profile/photo chunks:
memoryService.deleteDialogSources({
ruId,
tuId,
sources: ['profile', 'photo'],
})- Записати RU profile у vector memory:
memoryService.ingestDialogProfile({ profile: ruProfile, ruId, tuId })- Записати TU profile у vector memory:
memoryService.ingestDialogProfile({ profile: tuProfile, ruId, tuId })На рівні бази це означає:
- delete rows з
client_memory_chunksпоdialog_keyіsource in ('profile', 'photo'); - build
profile/photochunks; - Gemini embeddings;
- upsert у
client_memory_chunks.
2.6 Fresh notes
Спочатку визначається остання note у vector memory:
lastNoteAt = notesService.getLastNoteDate({ ruId, tuId })Це читає client_memory_chunks через MemoryService.getDialogStatus().
Потім upstream:
notesService.fetchFresh({ family, ruId, tuId, since: lastNoteAt })Якщо є fresh notes:
notesService.ingestToVector({ notes: freshNotes, ruId, tuId })На рівні бази це note chunks у client_memory_chunks.
2.7 Upsert daily snapshots
Наприкінці DailySyncService пише у tips_daily_entity_snapshots:
- RU profile snapshot;
- TU profile snapshot;
- RU photo snapshot;
- TU photo snapshot;
- кожну fresh note;
- marker
daily_sync.
Upsert conflict key:
dialog_key,sync_date,entity_type,source_role,source_idПомилка snapshot upsert не валить request, тільки логиться warning.
2.8 Результат daily sync
syncFirstClick() повертає:
{
didRun,
freshNotes,
profileChanged,
photoChanged,
ruProfile,
tuProfile
}Цей результат далі використовується для prompt input і diagnostics.
Крок 3. Перевірити останнє повідомлення в vector memory
Виклик:
lastMessageAt = messagesService.getLastMessageAt({ ruId, tuId })Всередині:
memoryService.getDialogStatus({ ruId, tuId })lastMessageAt = max sent_at серед chunks:
source='conversation'.
Тобто джерело істини для “що вже збережено як memory” - client_memory_chunks, не snapshot table.
Крок 4. Дотягнути recent messages з upstream
Виклик:
recentMessages = messagesService.fetchRecent({ family, ruId, tuId })Всередині:
upstreamApi.getMessages({
family,
ruId,
tuId,
timestamp: Date.now(),
})Це не Supabase. Це зовнішній upstream API конкретної family.
Крок 5. Відфільтрувати fresh messages
freshMessages = filterFreshMessages(recentMessages, lastMessageAt)Правила:
- якщо
lastMessageAtнемає, fresh = всіrecentMessages; - якщо
lastMessageAtне парситься як дата, fresh = всіrecentMessages; - інакше fresh = messages, де
Date.parse(message.date) > Date.parse(lastMessageAt).
Крок 6. Якщо є fresh messages - записати їх
Якщо freshMessages.length > 0, виконуються два записи.
6.1 Raw snapshots
dailySyncService.saveDialogItems({
family,
messages: freshMessages,
ruId,
tuId,
})Пише tips_daily_entity_snapshots rows:
| Поле | Значення |
|---|---|
entity_type | dialog |
source_role | '' |
source_id | `${message.date |
entity_at | parsed message.date |
initiator | message.initiator |
text | message.content |
payload | { date, initiator, text } |
Це audit/cache layer, не vector search.
6.2 Vector memory chunks
messagesService.saveRaw({
messages: freshMessages,
ruId,
tuId,
})Перед ingest messages нормалізуються:
{
body: message.content,
id: `${message.date || index}:${message.initiator || 'unknown'}`,
is_incoming: isIncoming(message.initiator, ruId, tuId),
sent_at: message.date,
}isIncoming():
- якщо initiator ==
ruId, incomingtrue; - якщо initiator ==
tuId, incomingfalse; - якщо initiator містить
man|male|client|customer|ru|incoming, incomingtrue; - якщо initiator містить
lady|woman|girl|operator|tu|outgoing, incomingfalse; - fallback
true.
Потім:
memoryService.ingestDialogMessages({ messages, ruId, tuId })На рівні бази:
conversationchunks;- Gemini embeddings;
- upsert у
client_memory_chunks.
Крок 7. Побудувати payload для LLM input
Після sync/messages:
dialogKey = memoryService.getDialogKey({ ruId, tuId })
conversationJson = toConversationPayload(recentMessages, dailySync.freshNotes, ruId, tuId)conversationJson має:
{
aiNotes: dailySync.freshNotes.map(...),
messages: recentMessages.map(...)
}Для notes:
{
date: note.date,
id: `${note.timestamp || note.date || index}`,
text: note.translated || note.text || note.comment || '',
}Для messages:
{
body: message.content,
id: `${message.date || index}:${message.initiator || 'unknown'}`,
is_incoming: isIncoming(message.initiator, ruId, tuId),
sent_at: message.date,
}Profile payload:
profilePayload = dailySync.ruProfile
photoUrl = profileService.photoUrlFromProfile(dailySync.tuProfile)Зараз у prepareInputs() передається RU profile як profilePayload, а photo URL береться з TU profile.
TODO: цей крок має бути посилений шаром photo-analysis-ingest. Замість того щоб кожного разу покладатися на photoUrl або image input, система має брати з Supabase текстовий chunk source='photo', який був створений vision-аналізом фото.
Крок 8. prepareInputs()
prepared = prepareInputs({
conversationPayload: conversationJson,
photoUrl,
profilePayload: dailySync.ruProfile,
})prepareInputs() приводить payload до PreparedInputs:
- normalized profile;
- normalized notes;
- normalized conversation;
photoUrl;inputOverview;- initial
memoryContext.
Initial memoryContext будується локально на hashed lexical vectors. Це fallback до Supabase retrieval.
Крок 9. Побудувати query для vector retrieval
query = buildRetrievalQuery(type, recentMessages, dailySync.freshNotes)Формула:
inviteType: <type>
<last 10 message contents>
<last 8 note texts>Тобто retrieval query складається з:
- типу інвайту/відповіді;
- останніх 10 повідомлень;
- останніх 8 notes.
Крок 10. Supabase vector retrieval
Виклик:
preparedWithMemory = memoryService.enrichPreparedMemory(
prepared,
{
clientId: ruId,
conversationJson,
dialogKey,
favoriteId: dialogKey,
limit: 12,
query,
womanId: tuId,
},
'reply',
)Що відбувається:
- Query перетворюється на Gemini embedding
RETRIEVAL_QUERY. MemoryService.search()викликає RPCmatch_client_memory_chunks.- RPC шукає top chunks по
dialog_key,client_id,compose_mode, cosine similarity і source weights. - Результати перетворюються у
memoryContext.selectedChunks.
Limit зараз 12.
Після enrichment preparedWithMemory.memoryContext містить:
{
cacheKey: dialogKey,
query,
selectedChunks: [
{
id,
score: weighted_score,
source,
text: '[note; score=0.931; 2026-05-20] ...',
vector: []
}
],
strategy: 'Supabase pgvector retrieval...',
totalChunks,
vectorDimensions: 768
}Якщо Supabase retrieval падає, залишається fallback memoryContext з prepareInputs().
Крок 11. Assistant workflow
Виклик:
workflow = assistantService.runWorkflow(preparedWithMemory, {
draftLanguage: 'English',
instruction: `Generate three ready-to-send ${type} reply variants for the current dialog.`,
messageType: type,
messageTypeGuidance: `Invite type from client request: ${type}. Keep the result grounded in Supabase memory and recent messages.`,
moodScore: 5,
operatorLanguage: 'Russian',
photoUrl: preparedWithMemory.photoUrl,
tone: 'natural, warm, lightly flirty, concise',
})Зараз важливий нюанс: request має mood, але в workflow передається moodScore: 5. Тобто mood з body використовується у placeholder response/log, але не як реальний moodScore.
AssistantService отримує:
- normalized current conversation;
- profile facts;
- notes;
- photo URL;
memoryContext.selectedChunksз Supabase або fallback;- message type/instruction/tone.
Всередині workflow генеруються candidate drafts і збирається tokenUsage.
Крок 12. Token usage logging
Виклик:
tokenUsage = tokenUsageService.trackWorkflowUsage({
dialogKey,
entries: workflow.tokenUsage,
fallbackOutput: workflow.assistantOutput,
fallbackPrompt: {
conversationJson,
type,
memoryContext: preparedWithMemory.memoryContext,
},
inviteType: type,
requestId,
ruId,
tuId,
})Пише token_usage_log.
Якщо workflow.tokenUsage порожній, сервіс рахує fallback estimate і пише один usage row з phase='generate'.
Помилка insert не валить request.
Крок 13. Оновити status
Виклик:
status = memoryService.getDialogStatus({ ruId, tuId })Читає client_memory_chunks і повертає:
lastNoteAt;lastMessageAt;profileUpdatedAt;photoUpdatedAt;- counts по sources.
Зараз status рахується, але не повертається клієнту, бо response hardcoded.
Крок 14. HTTP response
Поточний code path повертає hardcoded string[], не workflow.assistantOutput.drafts.
Це головний функціональний gap:
- LLM workflow уже викликаний;
- token usage уже записаний;
- status уже порахований;
- але клієнт не бачить реальні drafts і diagnostics.
Дані по етапах
| Етап | Читає | Пише | Для чого |
|---|---|---|---|
| Daily profile cache | tips_daily_entity_snapshots | - | Зрозуміти, чи profiles вже синкались сьогодні |
| Profile/photo refresh | upstream + snapshots | client_memory_chunks | Оновити profile/photo vector chunks, якщо змінились |
| Fresh notes | client_memory_chunks status + upstream | client_memory_chunks, tips_daily_entity_snapshots | Додати нові notes у memory і audit |
| Recent messages | upstream | - | Отримати останню переписку |
| Fresh message save | client_memory_chunks status | tips_daily_entity_snapshots, client_memory_chunks | Зберегти нові raw messages і vector chunks |
| Retrieval | client_memory_chunks via RPC | - | Вибрати top-N chunks для prompt |
| Assistant | prepared input + memory | - | Згенерувати drafts |
| Usage | workflow token usage | token_usage_log | Зберегти витрати токенів |
| Final status | client_memory_chunks | - | Порахувати latest dates/counts |
Що реально отримує LLM
LLM не отримує всю базу і не отримує всі raw snapshots. У prompt потрапляють:
- current/recent conversation з
conversationJson; - notes з
conversationJson; - normalized profile;
- photo URL зараз;
TODO: замінити або доповнити його текстовим photo analysis chunk зclient_memory_chunks; - selected top-N chunks з
client_memory_chunks.
selectedChunks форматуються приблизно так:
1. [note; score=0.91; 2026-05-20] ...
2. [conversation; score=0.84; 2026-05-21T...] incoming ...Це позиціонується в prompt як long-term memory.
Нюанси і gaps
moodз request зараз не перетворюється в реальнийmoodScore; hardcoded5.promptз request тільки логується, але не входить уAssistantService.runWorkflow()options.POST /stack-ai/tips/generateповертає placeholder strings замість real drafts.status,tokenUsage,dailySync,memoryContext,requestIdуже є в коді, але не повертаються.dailySyncskip перевіряє тільки today’s profiles, не повноту notes/dialog snapshots.- Retrieval limit зараз
12; треба тестами визначити оптимальне N. operatorLanguagehardcodedRussian,draftLanguagehardcodedEnglish.- Photo analysis ще не записується як стабільний текст у Supabase; потрібно реалізувати photo-analysis-ingest.