generation-pipeline

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/generate

Guard:

  • 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():

  1. Генерує requestId = randomUUID().
  2. Будує dialogTag = <family>/ruId:<ruId>/tuId:<tuId> type=<type> mood=<mood>.
  3. Логує 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:

  1. Видалити старі profile/photo chunks:
memoryService.deleteDialogSources({
  ruId,
  tuId,
  sources: ['profile', 'photo'],
})
  1. Записати RU profile у vector memory:
memoryService.ingestDialogProfile({ profile: ruProfile, ruId, tuId })
  1. Записати TU profile у vector memory:
memoryService.ingestDialogProfile({ profile: tuProfile, ruId, tuId })

На рівні бази це означає:

  • delete rows з client_memory_chunks по dialog_key і source in ('profile', 'photo');
  • build profile/photo chunks;
  • 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_typedialog
source_role''
source_id`${message.date
entity_atparsed message.date
initiatormessage.initiator
textmessage.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, incoming true;
  • якщо initiator == tuId, incoming false;
  • якщо initiator містить man|male|client|customer|ru|incoming, incoming true;
  • якщо initiator містить lady|woman|girl|operator|tu|outgoing, incoming false;
  • fallback true.

Потім:

memoryService.ingestDialogMessages({ messages, ruId, tuId })

На рівні бази:

  • conversation chunks;
  • 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',
)

Що відбувається:

  1. Query перетворюється на Gemini embedding RETRIEVAL_QUERY.
  2. MemoryService.search() викликає RPC match_client_memory_chunks.
  3. RPC шукає top chunks по dialog_key, client_id, compose_mode, cosine similarity і source weights.
  4. Результати перетворюються у 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 cachetips_daily_entity_snapshots-Зрозуміти, чи profiles вже синкались сьогодні
Profile/photo refreshupstream + snapshotsclient_memory_chunksОновити profile/photo vector chunks, якщо змінились
Fresh notesclient_memory_chunks status + upstreamclient_memory_chunks, tips_daily_entity_snapshotsДодати нові notes у memory і audit
Recent messagesupstream-Отримати останню переписку
Fresh message saveclient_memory_chunks statustips_daily_entity_snapshots, client_memory_chunksЗберегти нові raw messages і vector chunks
Retrievalclient_memory_chunks via RPC-Вибрати top-N chunks для prompt
Assistantprepared input + memory-Згенерувати drafts
Usageworkflow token usagetoken_usage_logЗберегти витрати токенів
Final statusclient_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; hardcoded 5.
  • prompt з request тільки логується, але не входить у AssistantService.runWorkflow() options.
  • POST /stack-ai/tips/generate повертає placeholder strings замість real drafts.
  • status, tokenUsage, dailySync, memoryContext, requestId уже є в коді, але не повертаються.
  • dailySync skip перевіряє тільки today’s profiles, не повноту notes/dialog snapshots.
  • Retrieval limit зараз 12; треба тестами визначити оптимальне N.
  • operatorLanguage hardcoded Russian, draftLanguage hardcoded English.
  • Photo analysis ще не записується як стабільний текст у Supabase; потрібно реалізувати photo-analysis-ingest.

Пов’язані документи