Skip to main content

AI Agent Memory — Memory DWH Ingestion Catalog

Decision context (2026-07-22): for this quarter the AI Agent Memory initiative does not read from CEBE — a dedicated Memory DWH is stood up instead. CEBE remains the strategic backbone; this DWH is the interim serving store. To keep the future cutover cheap, target schemas below deliberately mirror the CEBE ANCHOR's module event catalog field sets — when CEBE is live, the migration is a source swap, not a schema redesign.

Every source below is code-verified (repo + table/API + file:line). Four domains, per Dimas: (1) past conversations from the contact's previous rooms, (2) customer profile, (3) associated deals, (4) associated tickets.


0. Identity spine (the join problem, solved first)

Everything in the DWH must key to one customer. What exists today:

KeyWhere it livesEvidenceNote
qontak_customer_idhub-core contacts.qontak_customer_id — a real, indexed columnhub-core/spec/dummy/db/schema.rb:459, index (organization_id, qontak_customer_id) :473Primary DWH customer key.
qontak_customer_idchatbot contacts.parameters['qontak_customer_id']jsonb key, NOT a columnchatbot/app/core/repositories/contacts/find_or_create_by.rb:223-236Ingestion must extract from jsonb; resolved at runtime from param → existing → ContactService phone lookup.
contact_master_id + contact_handlershub-core cross-channel unification (contact_ids[], account_uniq_ids[], channels[])schema.rb:458, contact_handlers :335-348Collapses one person across WhatsApp/IG/email into one handler — use to dedupe.
account_uniq_idhub-core rooms.account_uniq_id (NOT NULL), contacts.account_uniq_id, room_tickets.account_uniq_idrooms :1535-1569The channel identity — joins rooms and tickets to a contact without CRM.
Phone fallbackContactService GET /api/v1/system/contacts/search?phone[]=&company_sso_id=chatbot/lib/contact_service/contacts/search.rb:21-33Last-resort resolution; also how chatbot backfills the jsonb key.

DWH rule: dim_customer is keyed on (organization_id, qontak_customer_id), built from hub-core contacts + contact_handlers; rows lacking qontak_customer_id fall back to contact_handler_id as a provisional key and are flagged identity_confidence: channel_only. This is the same identity boundary the Phase-1 PRD sets (no fuzzy matching at serve time).


1. Past conversations (previous rooms of the contact)

Sources

#SourceKey fieldsEvidenceIngestion note
1.1hub-core rooms (Postgres, uuid PK) — the real conversation entityid, status (unassigned/assigned/resolved/deleted), type (CustomerService/Comment/Review/Group/DirectSend STI), account_uniq_id, organization_id, channel_integration_id, resolved_at, resolved_by_id/type, division_id, created_at, extra (jsonb)hub-core/spec/dummy/db/schema.rb:1535-1569; STI + status enums models/room.rb:19-25,60-66CDC or nightly batch. Filter type = CustomerServiceRoom for v1.
1.2hub-core messages (Postgres, uuid PK, range-partitioned by created_at)room_id, type (STI), sender_type/sender_id, participant_id, text, file, url, status (created/sent/delivered/read/failed/deleted), raw_message (jsonb), reply_id, is_hidden, created_atschema :859-898; status enum models/message.rb:62-69; partition caveat: hub-core AGENTS.mdEvery extract query MUST carry a created_at range or it scans all partitions. High volume — see the "summaries-first" recommendation below.
1.3hub-core participants — room ↔ contact/agent/bot linkroom_id, contact_able_id/type (Customer/Agent/Bot STI), contact_id, active_status, handover_idschema :1345-1362The join path room → contact → identity spine. Also partitioned — same caveat.
1.4chatbot rooms — bot-side room mirror with the outcomecontact_id, channel_room_id, is_closed, closed_reason (indexed — RESOLVE_AI / ASSIGN_AGENT_AI / RESOLVE containment spine), closed_at, assign_channel_agent_id, path_id/path_name, channel_type_codechatbot/db/schema.rb:1761-1811Enrichment: gives each past conversation its resolution outcome (was it AI-contained?). Join to hub-core rooms via channel_room_idrooms.external_id.
1.5chatbot histories — bot turn logroom_id, contact_id, input_content (message text), message_id, intent_id/intent_name, path_id, parameters (jsonb), channel_type_code, created_atchatbot/db/schema.rb:567-613Optional v1 — mostly redundant with 1.2 for memory purposes; useful for bot-turn analytics.
1.6chatbot_gpt omnichannel_room_summaries — AI summary + sentiment per roomroom_id (unique where not deleted), contact_id, company_id, summary, sentiment, channel, room_created_at, is_auto_summarized, gpt vendor/model + token usagechatbot/db/chatbot_gpt_schema.rb:252-285The highest-value memory artifact per byte — compact, already synthesized, contact-keyed. Coverage caveat: only summarized rooms; measure coverage % before relying on it.

Target DWH shape

  • dim_room — one row per past room: hub-core room id + chatbot outcome enrichment (closed_reason, resolved_at, channel, division).
  • fact_conversation_summary — one row per summarized room (source 1.6): summary, sentiment.
  • fact_message (optional / phase 2 of the DWH) — raw messages. Recommendation: do NOT ingest full message bodies in v1. For memory serving, "summaries of prior rooms + last-N messages of the most recent prior room" beats full-history ingestion on volume, PII surface, and prompt-budget — and the summaries table already exists. If raw messages are needed later, ingest per-partition with created_at windows and a rolling retention (e.g. 12 months, matching CEBE's backfill horizon).

2. Customer profile

Sources

#SourceKey fieldsEvidenceIngestion note
2.1hub-core contactsqontak_customer_id, contact_master_id, contact_handler_id, full_name, phone_number, extra (jsonb → email/username), account_uniq_id, channel, channel_integration_id, is_blocked, authority, organization_idschema :437-476; builder surfaces email/username from extra (builders/contact.rb:6-21)Primary profile source. CDC or daily batch.
2.2hub-core contact_handlers — unified person across channelsphone_number, email, username, full_name, channels[], contact_ids[], channel_integration_ids[], account_uniq_ids[]schema :335-348The dedupe layer for dim_customer.
2.3hub-core contact_fields + contact_custom_fields — per-org custom attributesdefinitions: name, type, validation (jsonb), cdp_field_property_id; values: contact_handler_id, contact_custom_field_id, name, value (jsonb), date_valueschema :294-307, :321-333Custom fields are per-org and typed — land as key-value (dim_customer_attribute), not wide columns. Note cdp_field_property_id already anticipates CDP alignment.
2.4chatbot contacts.crm_contact (jsonb) — cached CRM contactknown keys read in code: first_name, last_name, additional_fields, crm_gender_name, creator_id/username/name/emailwritten contacts/update.rb:16-17; populated via QontakCrmService::Contacts::Contact#find_by_phone_number (process_incoming_message_with_resolve.rb:565-566)Cache, not source of truth — freshness is "whenever the contact last messaged". Use only as fallback enrichment; prefer 2.1–2.3 + CRM API.
2.5ContactService (internal contacts service)GET /api/v1/system/contacts/{id}?company_sso_id= · GET /api/v1/system/contacts/search?phone[]=chatbot/lib/contact_service/contacts/find_by_id.rb:16-25, search.rb:21-33API source for point lookups; not a bulk-ingest path.

Target DWH shape

  • dim_customer — identity spine (org + qontak_customer_id + handler), standard fields (name, phone, email, channels, blocked/authority), first/last seen.
  • dim_customer_attribute — custom fields as typed key-value rows (source 2.3).

3. Associated deals

Reality check: no deal table exists in chatbot or hub-core, and no "list deals by contact" endpoint exists anywhere in these repos. Deals live in the external Qontak CRM service; the local repos only hold per-room links and write/read hooks:

#SourceKey fieldsEvidenceIngestion note
3.1hub-core qontak_crm_objects — room ↔ deal linkroom_id, organization_id, external_id (CRM deal id), external_url, object_type (deal|ticket)schema :1461-1471; models/qontak_crm_object.rb:8-16Ingest as the room→deal edge; join deals to conversations (and via room → contact).
3.2CRM API — single dealGET /api/v{v}/deals/{deal_id} (hub-core get_deal); chatbot node qontak_crm_deal_readGET /api/mobile/v2.7/crm/deals?unique_deal_id=hub-core/app/apps/crm/services/deal/apis.rb:8, repositories/room/get_deal.rb:14,42-44; chatbot/.../mekari_qontak_crm/execute.rb:18-25Per-id enrichment only — not a bulk path.
3.3CRM deal webhook — hub-core already receives deal eventsdeal_receiver.rbhub-core/app/apps/crm/repositories/webhook/deal_receiver.rbProves a CRM→consumer deal event feed exists. The cheapest bulk path: point an equivalent webhook/event feed at the DWH.
3.4Contact→CRM linkagechatbot deal-create auto-enriches crm_lead_ids via GET /api/v3.1/contacts?phone=execute.rb:262-296The deal↔customer association key on the CRM side is crm_lead_ids (CRM contact ids).

Target DWH shape + the dependency

  • fact_deal — mirror the CEBE Deals catalog fields so cutover is free: deal_id, pipeline_id, stage_id, deal_size, owner_id, status (open/won/lost), close_date, loss_reason, customer_association (→ qontak_customer_id), timestamps.
  • Blocking dependency (CRM squad): bulk deal ingestion needs ONE of — (a) CRM DB replica/CDC access, (b) a deal event/webhook feed into the DWH (the receiver pattern in 3.3 shows this exists on the CRM side), or (c) a new "list deals by contact/org since X" API. Without one of these, deals are only reachable per-room-link + per-id enrichment (3.1 + 3.2), which covers deals created from conversations but misses deals created directly in CRM.

4. Associated tickets

Tickets are better served than deals — hub-core has a link table with a by-contact query already in production:

#SourceKey fieldsEvidenceIngestion note
4.1hub-core room_tickets — room ↔ ticket link + cached fieldsroom_id, organization_id, account_uniq_id, name, no, description, external_id (CRM ticket id), external_url, external_status, template_id, created/submitted by/atschema :1507-1533Primary local ticket edge; account_uniq_id joins straight to the contact — no CRM call needed for the association.
4.2By-contact ticket history (existing!)RoomTicket.where(account_uniq_id:, organization_id:) → paginate → enrich each via CRM get_ticket(external_id)hub-core/app/apps/crm/repositories/room/get_tickets_by_account_uniq_id.rb:14,47-61The exact access pattern the DWH replicates in batch.
4.3CRM API — single ticketGET /api/v{v}/tickets/{ticket_id}; chatbot node qontak_crm_ticket_readGET /api/mobile/v2.8/tickets/{id}ticket/apis.rb:57; execute.rb:21-23,311-353Per-id enrichment for status/stage detail.
4.4CRM ticket webhook — receiver existsticket_receiver.rbhub-core/app/apps/crm/repositories/webhook/ticket_receiver.rbSame play as deals: event feed → DWH keeps external_status fresh without polling.
4.5Ticket field reference (from the explicit create payload)name, user_id, ticket_stage_id, ticket_priority_id, ticket_category_id, description, due_date, crm_lead_ids[], crm_company_id, additional_fields[]hub-core/app/apps/crm/repositories/ticket/create_to_crm.rb:54-68The field vocabulary for fact_ticket.

Target DWH shape

  • fact_ticket — mirror the CEBE Ticketing catalog: ticket_id, pipeline_id, stage_id, priority, category, assigned_id, status, created_at, resolved_at, due_date, customer_association (via account_uniq_id → identity spine), room_id (originating conversation, when any).
  • Same CRM-side gap as deals for tickets created outside conversations: room_tickets only knows conversation-born tickets. Full coverage needs the webhook/replica path (4.4).

5. Cross-cutting requirements

ConcernRequirement
Multi-tenancyEvery table carries organization_id; note the type mismatch — hub-core uses uuid orgs, chatbot uses bigint + company_id strings in the gpt schema. The DWH normalizes to one org key and stores the source ids.
PIIPII columns: phone, email, name, message text, summaries. Apply the same masking/allow-list policy the Phase-1 PRD demands of the CEBE AI-consumer role — the serving layer exposes only the allow-listed fields to DSAI. Raw message bodies (if ever ingested) are the largest PII surface — another reason for summaries-first.
FreshnessConversations/summaries: ≤ 1h lag is enough (memory reads at next conversation start, not mid-conversation). Profile: daily. Deals/tickets: webhook-driven near-real-time where available, else daily batch + per-id refresh on read.
Partitioned tableshub-core messages + participants extracts must window on created_at (range partitions) — full-scan extracts will not finish.
Retention12-month rolling window (matches CEBE's backfill horizon), summaries retained longer (compact). Erasure: deleting a customer cascades by qontak_customer_id — one key, one delete path.
Serving viewcustomer_memory_profile — one row per (org, customer): profile fields + custom attributes + last-N conversation summaries w/ sentiment + open/recent deals + open/recent tickets + containment stats (closed_reason rollup). This is the read shape the retriever/DSAI consumes — identical to what the Phase-1 PRD's MEM-S02 expects, so the PRD's behavior spec is unchanged by the source swap.

6. CEBE event-catalog field spec (the fields to ingest, per event)

Source: CEBE ANCHOR §6 Module Event Catalog (repo: cdp/customer-event-based-engine-cebe/prds/prd-customer-event-based-engine-cebe-anchor.md). These event shapes are the Memory DWH's target schema; this quarter the interim pipeline populates the same fields from the §1–§4 sources. Modules 4 (Campaign/Ads), 5 (Commerce/Booking), and 7 (Loyalty) are excluded — not part of the four memory domains.

Common envelope (every event): company_sso_id · company_id · qontak_customer_id · actor · event_timestamp · source_module · trigger_from · event_name · event_description · event_attributes

6.1 Past conversations ← Module 1 (Communication)

EventFields to ingestMemory useInterim source (this quarter)
conversation.startedchannel, room_id, phone, email, bsuidConversation index + channel preferencehub-core rooms + participants
conversation.assignedagent_id, assignment_type (human/bot)Prior handlerhub-core rooms.status/participants; chatbot rooms.assign_channel_agent_id
message.receivedmessage_id, message_type, content_previewLast-topic recall (preview only — full text deliberately not in the catalog)hub-core messages (windowed on created_at)
conversation.resolvedresolved_by, resolution_time_seconds, handled_by (human/bot)Outcome per past conversationhub-core rooms.resolved_at/resolved_by_*; chatbot rooms.closed_reasonhandled_by
conversation.escalatedescalation_reason, bot_session_id"Escalated before, on X"chatbot rooms.closed_reason = ASSIGN_AGENT_AI + histories
bot.containedbot_flow_id, containment_flagAI-containment historychatbot rooms.closed_reason = RESOLVE_AI
csat.submitted / nps.submittedscore, comment, agent_idSentiment / at-risk signalhub-core review fields (messages.review_star) / CSAT source
call.* (only if voice in scope)duration_seconds, call_type, recording_urlOptional — skip v1

Catalog gap (flag to CDP squad): no conversation-summary event exists in §6, and the highest-value memory artifact is the per-room summary + sentiment. Ingest omnichannel_room_summaries alongside as an extension — proposed event shape conversation.summarized: room_id, summary, sentiment — and propose it for the CEBE schema so the future cutover keeps parity.

6.2 Customer profile ← Module 6 (CDP)

EventFields to ingest
customer.createdqontak_customer_id, default_fields[], custom_fields[], created_at, created_by
customer.updatedqontak_customer_id, change_fields[], updated_at, updated_by
segment.entered / segment.exitedqontak_customer_id, segment_id, segment_name

Profile serves as the current-state materialization of these events (dim_customer + dim_customer_attribute), built this quarter from hub-core contacts/contact_handlers/ contact_fields (§2).

6.3 Associated deals ← Module 3 (Deals)

EventFields to ingest
deal.createddeal_id, pipeline_id, stage_id, deal_size, owner_id, {other_fields}, customer_association
deal.stage_changeddeal_id, old_stage, new_stage, days_in_stage, timestamp, changed_by, customer_association
deal.wondeal_id, deal_size, close_date, {other_fields}, customer_association, product_association
deal.lostdeal_id, deal_size, lost_date, loss_reason, {other_fields}, customer_association, product_association
deal.rotten_time_breacheddeal_id, deal_size, breach_duration_seconds — low memory value, optional

customer_association resolves to qontak_customer_id (code-side: crm_lead_ids). Interim source: CRM webhook/replica per §3 (the bulk-access dependency).

6.4 Associated tickets ← Module 2 (Ticketing)

EventFields to ingest
ticket.createdticket_id, pipeline_id, stage_id, assigned_id, creator_id, {other_fields}, customer_association
ticket.assignedticket_id, agent_id, team_id, assigned_at
ticket.stage_changedticket_id, old_stage, new_stage, changed_by, timestamp
ticket.resolvedticket_id, resolved_at, resolution_type (first_contact/escalated), agent_id
ticket.sla_breachedticket_id, sla_type, breach_duration_seconds — lets the agent acknowledge a delayed ticket

Interim source: hub-core room_tickets + CRM get_ticket + ticket webhook per §4.

7. Open items

  1. CRM bulk access for deals + tickets (replica/CDC vs webhook feed vs new API) — the one genuinely missing source; owner: CRM squad conversation. Deals are the weakest domain today.
  2. Summary coverage % — what fraction of resolved rooms have an omnichannel_room_summaries row; if low, decide whether the DWH pipeline backfills summaries (batch summarization job) or memory serves last-N raw messages instead.
  3. Identity coverage % — share of hub-core contacts with non-null qontak_customer_id (same feasibility ask as Phase-1 PRD Open Question #5).
  4. Data/BI sizing — messages volume per org per month; decides whether fact_message is ever viable or summaries-only is final.
  5. PRD alignment — the Phase-1 PRD currently names CEBE retriever + DSAI-direct as the source; this quarter's source is the Memory DWH instead. The behavior spec (MEM-S01..S04) and serving contract survive unchanged; the Dependencies section and G0 gate need a targeted update once this direction is confirmed with engineering.