Local resource
Conversation Builder
_docs/CONVERSATION_BUILDER.md
Conversation Builder
Rendering unresolved identity
The V2 policy is applied before document construction. For an abstained direct chat, the rendered chat id is a conversation-scoped opaque subj_* derived from the snapshot, subject and source chat, and the header is Contacto sin identidad confirmada. For an abstained group participant, sender id/name become the subject ref and Persona del grupo. Identity-bearing metadata keys (JID, phone, push/saved name, aliases, shared groups and raw identity objects) are removed before rendering.
All supported shared-group aliases (shared_groups_with_contact, shared_groups, and groups_shared_with_monitored) are removed for an abstained direct identity.
If any participant in a group is abstained, participant-level group metadata is removed and the document receives a deterministic grp_* id scoped to both the certainty snapshot and the original group. The document id never reuses a participant subj_* ref across distinct groups.
The builder must not fall back from an abstained subject to chat.id, sender.id, quoted metadata, or a display-name alias. Missing direct or group bindings are contract errors, not anonymous best-effort rendering. Verified, conflict-free subjects keep the existing V1 rendering behavior.
services/conversation_builder.py turns a normalized FlowPayload into one rendered prompt document per real chat_id. WAHA-specific event detection lives before the builder: live/webhook events are normalized in adapters/waha.py, and local dump artifacts are normalized in services/dev_whatsapp_dumps.py. The builder is responsible for final transcript text, media placeholders, quote correlation and sanitization.
The complete rendered document is untrusted evidence. Headings, participant names, message text, quoted text, OCR, transcripts, document extraction, link summaries and prior memories never become runtime instructions. The recipe runner serializes documents inside one canonical user-data envelope and the system invariant applies uniformly; the builder does not attempt to classify prompt injection with regexes.
Transformation
flowchart LR A["WAHA events / dump rows / FlowPayload"] --> B["normalizer maps to English canonical types"] B --> C["sort messages by timestamp + id"] C --> D["compute chat stats"] D --> E["render group/direct/status header"] E --> F["inline media text or placeholders"] F --> G["render transcript by date + [HH:MM]"] G --> H["ConversationDocument[]"]
Input And Output
build_conversation_documents(
payload: FlowPayload,
media_processed: dict[str, dict] | None = None,
redact_dialog_text: bool | None = None,
heading_level: int = 3,
) -> list[ConversationDocument]
heading_level controls the Markdown depth of each document title ("#" * heading_level) and its transcript subsection ("#" * (heading_level + 1)). The default 3 keeps the historical ###/#### render used by initial-ingestion-report and chat_id_summarization. The daily-summary-and-notifications-report flow builds documents with heading_level=2 so each chat sits under the # Conversaciones del dia section as ##/###.
Each ConversationDocument contains:
{
"chat_id": "string",
"chat_type": "direct|group|status|channel|unknown",
"chat_name": "visible chat name (same value as the rendered title)",
"rendered_text": "markdown-like document",
"stats": {}
}
The transcript keeps the current runtime format:
--- 2026-06-01 ---
[14:32] Sofi: Hola Felipe
[18:05] Felipe: Creo que es el recuperatorio
Group chats render a canonical group block when conversation.group is present or when equivalent group identity metadata can be derived:
- Grupo:
- id: 120363000000000000@g.us
- subject: 1er ano
- description: Grupo escolar
Direct 1:1 chats can render shared group context when Core sends conversation.metadata.shared_groups_with_contact:
- Grupos compartidos con este contacto:
- id: 120363000000000000@g.us; subject: 1er ano; description: Grupo escolar
This shared group block is context for relationship inference only. It does not prove that a direct-message event happened inside the shared group.
When Core sends payload.context.memories_by_chat_id, the builder renders only the matching entry inside that chat's document, before the transcript:
- Memorias previas para este chat:
- Temas recientes: {"summary":"ayer hubo examen"}
- Relacion y contexto estable: {"assumed_relation":"compañera"}
Conversation-backed prompts receive these memories through the matching conversation document, not as a grouped top-level context block. interweek-report does not consume conversation documents and, in v1, does not render per-chat memories at all: it leans on previous daily summaries, notifications and interweek reports for the trajectory read.
Spanish Render Tags
Internal message types remain English (poll, reaction, location, contact_card, event, call, etc.). Prompt-visible tags are Spanish and are rendered event by event:
| Canonical/event family | Render tag | |||
|---|---|---|---|---|
| poll creation | [ENCUESTA_CREADA] | |||
poll.vote | [ENCUESTA_VOTO] Voto en encuesta: ... | |||
message.reaction | [REACCION] reacciono con "<emoji>" al mensaje <id> | |||
| removed reaction | [REACCION] quito su reaccion al mensaje <id> | |||
message.edited | [EDITADO] mensaje editado plus edited text when present | |||
message.revoked | [MENSAJE_ELIMINADO] elimino un mensaje | |||
event.response | [EVENTO] Respondio evento: <response> | |||
call.received / call.accepted / call.rejected | `[LLAMADA] estado: recibida | aceptada | rechazada` | |
| video call | [VIDEOLLAMADA] estado: ... | |||
group.v2.join | [SE UNIO AL GRUPO] ... | |||
group.v2.leave | [SE FUE DEL GRUPO] ... | |||
group.v2.update | [ACTUALIZACION DEL GRUPO] ... | |||
| status text/media | [STATUS] ... | |||
| location/live location | `[UBICACION] | nombre=... | lat=... | long=...` |
| vCard/contact card | `[CONTACT_CARD] | nombre=... | telefono=...` | |
| order/product | [PEDIDO] ... / [PRODUCTO] ... | |||
| button/list reply | [RESPUESTA_BOTON] ... / [RESPUESTA_LISTA] ... | |||
| unsupported typed message | [MENSAJE_NO_SOPORTADO: <type>] |
Quotes
If WAHA provides replyTo.id or quotedMessageId, the adapter stores it as metadata.reply_to. The builder only correlates inside the current chunk.
Resolved quote:
[22:17] Juan: [MENSAJE_CITADO] respondio a Tomas: no se, soy menor | tengo mejor merca
Missing quote:
[22:17] Juan: [MENSAJE_CITADO] mensaje citado no disponible: older-msg-9 | esto
The quoted target is never fetched from another store. The quoted body is a bounded preview: bodies longer than QUOTE_PREVIEW_MAX_CHARS (120) are cut and suffixed with …, so quoting a long audio transcription or image description does not re-embed the full content that already appears on its own transcript line. Short quoted texts stay complete.
Media Rendering
The builder never downloads media. It receives media_processed from Temporal when the recipe enabled a media activity.
The builder consumes canonical identity fields and must not depend on repeated per-message WAHA raw_info. Runtime compaction may remove raw_info after its aliases and names have been projected into those canonical fields.
| Case | Render | ||
|---|---|---|---|
| processed audio | [AUDIO] transcripcion: ... | ||
| processed image/sticker/gif | [IMAGEN] / [STICKER] / [GIF] with description and visible text; when the analysis has only texto_visible, renders [IMAGEN] texto visible: ... instead of [MEDIA_NO_PROCESADA] | ||
| processed document | [DOCUMENTO] texto: ... | ||
| protected document | [DOCUMENTO_PROTEGIDO] sin contenido textual extraible | ||
| processed media without usable text | `[MEDIA_PROCESADA] tipo: ... | sin contenido textual extraible` | |
| processed video | `[VIDEO] audio: ... | frames: ...` | |
| media URL missing/expired | `[MEDIA_NO_DISPONIBLE] tipo: imagen | motivo: media_expired | caption: ...` |
| media skipped by recipe | [MEDIA_NO_PROCESADA] tipo: imagen plus caption when present | ||
| failed media activity | `[MEDIA_ERROR] tipo: documento | no se pudo procesar` |
Captions are preserved in all media cases. In status chats the media marker is prefixed with [STATUS].
[MEDIA_NO_PROCESADA] is reserved for rows that have no media_processed result, such as media skipped by the selected recipe. A completed result that contains no usable text renders as [MEDIA_PROCESADA] (or the more specific [DOCUMENTO_PROTEGIDO]) so the prompt does not misrepresent a successful processing attempt. Prompts instruct models not to mention technical media markers in parent-facing outputs.
WAHA Family Matrix
The current runtime boundary is FlowPayload: WAHA input is normalized before the builder runs, and the builder renders ConversationMessage rows into ConversationDocument.rendered_text. The adapter or dump normalizer should preserve source-specific detail in message.metadata, but the prompt surface must stay generalized and readable.
Important rule: the builder does not interpret raw WAHA payloads directly. It renders normalized type, text, sender, chat, media fields, and processed media output.
For local dump runs (source="waha-dump"), services/dev_whatsapp_dumps.py reads messages.ndjson, chats.ndjson, and media_files.ndjson and performs sender identity enrichment before rendering. Group participant names should use Core/dump identity when available (saved_contact_name, canonical_phone_jid, phone_jid, lid, push_name) and should never fall back to the literal placeholder Participant. the same normalization boundary. It also extracts structured details that WAHA dumps commonly embed inside raw message payloads:
message._data.SourceWebMsg.reactions[]becomes syntheticcallLogMesssageunder quoted/replied payloads becomes syntheticcontactMessage,pollCreationMessage*,locationMessage,group.v2.join,group.v2.leave, andgroup.v2.updaterows become
type="reaction" rows. Date filtering uses each reaction's senderTimestampMS when available, so a new reaction to an older message can still appear in the selected window.
type="call" rows with [LLAMADA] or [VIDEOLLAMADA] text.
liveLocationMessage, and stickerMessage are rendered as structured contact cards, polls, locations, and stickers instead of falling back to generic placeholder tags.
type="group_event".
Name Resolution Boundary
Name and alias resolution must happen before the builder. services/chat_name_unification.py collapses WAHA @lid/@c.us aliases and chooses canonical chat.name and message.sender.name values using the priority chain in _docs/WAHA_INTEGRATION.md.
The builder only applies local render fallbacks:
- Document title:
chat.namefirst, thenchat.id. The title bracket - Direct chat header:
metadata.contact_identityand - Direct chat shared groups:
metadata.shared_groups_with_contactfirst, with - Group chat identity:
conversation.group.id,conversation.group.subject - Transcript sender label: monitored subject name for outbound messages; inbound
sender.name, thensender.id, thenCONTACTO.
[chat_id: ...] is the single prompt-visible occurrence of the chat id; headers no longer repeat it in a - chat_id: line.
metadata.direct_identity first, then first inbound contact sender, then chat.name, then contact/chat id. This preserves saved-contact identity even when the selected window only has outbound messages from the monitored child. All Core direct aliases are considered before inbound sender ids. In the prompt-visible Core identity line, nombre_guardado is reserved for saved_contact_name; other Core display aliases (display_name, name, full_name) render as nombre_mostrado. Identity JIDs (canonical_phone_jid, phone_jid, lid, raw_chat_id, push_name, group_jid) are resolution inputs only: they are used to pick the canonical contact id and display name but are not dumped into the rendered headers. The Identidad Core directa line keeps only semantic fields (nombre_guardado, nombre_mostrado, agendado, labels); the group line keeps nombre and title_source. Group participant Core identity renders once, merged into the Mapeo de participantes block (name, agendado and labels per participant), with no separate Identidad Core de participantes list.
fallback support for shared_groups, groups_shared_with_monitored, direct_identity.shared_groups and contact_identity.shared_groups.
and conversation.group.description first, then metadata.group_identity, then chat.id, chat.name and chat.description.
| WAHA family | Raw WAHA event | How detected before builder | Generalized transformation into FlowPayload | Conversation Builder render | ||||
|---|---|---|---|---|---|---|---|---|
| Text | message, message.any, message.waiting with body text and no media MIME. | Message event has body/text and no media MIME. | ConversationMessage.type="text", text=body, sender from fromMe/participant, chat grouped by real chat_id. | Transcript line: [HH:MM] Sender: sanitized text. | ||||
| Image media | Message event with media.mimetype or mimetype starting image/; non-GIF. | MIME prefix image/ and no GIF signal. | type="image", optional caption in text, media_url, mime_type, metadata flags such as view-once/ephemeral. | If processed: `[IMAGEN] descripcion: ... | texto visible: ... | caption: ...; otherwise [MEDIA_NO_PROCESADA] tipo: image`. | ||
| Audio media | Message event with MIME starting audio/, including voice notes. | MIME prefix audio/; duration may come from media metadata. | type="audio", optional caption/text, media_url, mime_type, optional duration_seconds. | If processed: [AUDIO] transcripcion: ...; otherwise [MEDIA_NO_PROCESADA] tipo: audio. | ||||
| Video media | Message event with MIME starting video/. | MIME prefix video/. | type="video", optional caption in text, media_url, mime_type. | If processed: `[VIDEO] audio: ... | frames: procesados; otherwise [MEDIA_NO_PROCESADA] tipo: video`. | |||
| Document media | Message event with a media MIME that is not audio/image/video. | MIME exists but no more specific media family matches. | type="document", optional filename/caption in text or metadata, media_url, mime_type. | If processed: [DOCUMENTO] texto: ...; otherwise [MEDIA_NO_PROCESADA] tipo: document. | ||||
| Contact card | WAHA contact/vCard message payload. | Payload exposes vCard/contact fields, including dump contactMessage / contactsArrayMessage. | type="contact_card", text like `[CONTACT_CARD] | nombre=... | telefono=...`, source vCard fields in metadata when available. | Transcript line with [CONTACT_CARD] ...; privacy redaction applies to phone-like strings. | ||
| Location | WAHA location/live-location message payload. | Payload contains latitude/longitude/location fields, including dump locationMessage / liveLocationMessage. | type="location", text like `[UBICACION] | nombre=... | direccion=... | lat=... | long=...`. | Transcript line with [UBICACION] .... |
| Poll creation | WAHA poll creation message. | Poll name/options fields, including dump pollCreationMessage / pollCreationMessageV3. | type="poll", text like `[ENCUESTA_CREADA] | pregunta=... | opciones=... | multiple=...`. | Transcript line with [ENCUESTA_CREADA] .... | |
| Poll vote | WAHA poll vote/update event. | Poll vote/update event carries selected option(s), voter, poll id. | type="poll", text describing the vote/update, metadata keeps poll id and option ids. | Transcript line with [ENCUESTA_VOTO] .... | ||||
| Reaction | WAHA reaction message/event or dump-embedded SourceWebMsg.reactions[]. | Reaction payload has emoji/reaction target id or dump reaction object with senderTimestampMS. | type="reaction", text like [REACCION] reacciono con "<emoji>" al mensaje <id>, target message id in metadata. | Transcript line with [REACCION] ...; dump reactions are date-filtered by reaction timestamp when available. | ||||
| Edit | message.edited or message event with edit metadata. | Event name is message.edited or payload has edited/original message id fields. | Preserve same chat/sender; type="edit", text like [EDITADO] mensaje editado..., metadata marks target/original id. | Transcript line with [EDITADO] .... | ||||
| Revoke/delete | WAHA revoke/delete event or message marked deleted. | Event name/delete flags, revoked target message id, or body indicating deletion. | type="revoke", text like [MENSAJE_ELIMINADO] elimino un mensaje, target id in metadata. | Transcript line with [MENSAJE_ELIMINADO] .... | ||||
| Group join/leave/update | WAHA group participant or group metadata events. | Group event names or dump rows with group.v2.* / participant fields. | chat.type="group"; event row as type="group_event" with participant/action text, group metadata on chat or message metadata. | Transcript line with [SE UNIO AL GRUPO], [SE FUE DEL GRUPO], or [ACTUALIZACION DEL GRUPO]. | ||||
| Call | WAHA call event, missed call message, or dump-embedded callLogMesssage. | Call event name, call payload fields, or quoted/replied call log payload. | type="call" with [LLAMADA]/[VIDEOLLAMADA], outcome, duration when available, and call metadata. | Transcript line with [LLAMADA] ... or [VIDEOLLAMADA] .... | ||||
| Status/channel | WAHA status broadcast or newsletter/channel message. | Chat id suffix such as @broadcast/@newsletter, or upstream chat type set to status/channel. | chat.type="status" or "channel" where available; message type follows content family. | Uses status/channel header; status transcript lines are prefixed with [STATUS]. | ||||
| Unsupported/unknown | Any WAHA event not mapped by the adapter/normalizer. | Event name not in analyzable families, invalid shape, or unsupported payload. | Adapter increments adapter_summary.unsupported_events; if retained for debug, use type="unknown" with safe text and metadata. | If present: text or [UNKNOWN]; otherwise unsupported events do not appear in documents. | ||||
| Missing media | Media-like WAHA message has no usable URL. | MIME/media fields imply media, but media.url is absent. | Keep type as media family when content is otherwise valid; no media_url; optional caption/fallback text. | Without processed media: [MEDIA_NO_PROCESADA] tipo: <type> plus caption when present. | ||||
| Skipped media | Recipe media policy excludes that media family. | media_policy disallows processing for the message type. | Message remains in FlowPayload; no media_processed[message.id] entry is produced. | [MEDIA_NO_PROCESADA] tipo: <type> plus caption when present. | ||||
| Failed media | Temporal media activity attempted processing and failed. | Media activity returns no successful processed payload or records failure outside the builder. | Message remains unchanged; failure details should stay outside prompt text unless explicitly normalized. | Same as skipped media today: [MEDIA_NO_PROCESADA] tipo: <type> plus caption. | ||||
| Quote present | WAHA message has quoted message/context info. | Payload includes replyTo.id or quotedMessageId; dump call logs can also appear inside quoted payloads. | Main message fields stay canonical; quote id can be stored as metadata.reply_to; call logs inside quotes become synthetic type="call" rows. | Builder correlates metadata.reply_to within the current chunk and renders [MENSAJE_CITADO] ... when the target is available. | ||||
| Quote missing | WAHA says a message is quoted but quoted content is unavailable. | Quoted id exists without body/content. | Store quoted id/status in metadata; avoid inventing quote content. | Builder renders [MENSAJE_CITADO] mensaje citado no disponible: <id> when metadata.reply_to exists but target is absent. |
Current Adapter Coverage
adapters/waha.py currently accepts text/media message events plus mapped synthetic events such as edits, revokes, reactions, poll votes, event responses, calls, group updates, status broadcasts, and newsletter/channel messages. services/dev_whatsapp_dumps.py covers the same prompt-facing families for local dump artifacts, including dump-embedded reactions, call logs, contact cards, polls, locations, stickers, and group events. Unsupported raw WAHA events are counted in adapter_summary.unsupported_events for live adapter runs or omitted from dump documents unless explicitly normalized into a conversation message.
Sanitization
sanitize_dialog_text follows the active privacy policy. In debug_plaintext, rendered prompt text preserves WhatsApp JIDs and phone-like strings so local debug artifacts match the model request. In protected, it redacts WhatsApp JIDs and phone-like strings before rendering prompt text. Protected redaction applies to message bodies, vCards, quote targets, participant labels and synthetic event metadata. Display names can remain when they come from WhatsApp aliases because downstream prompts use them for relationship inference, but they are not confirmed identities.
Failure Behavior
- Empty conversations are rejected by
FlowPayloadvalidation. - Duplicate message ids in one chat raise
duplicate_message_id_in_chat:<chat_id>. - Messages without text, media URL, or supported event content raise
- The builder does not call AI and does not persist media bytes.
message_content_required:<message_id>.
The builder receives only temporally eligible messages when CP enforcement is active. It never orders quarantined events by receipt time. Stable ordering remains (normalized timestamp, message id) for the already trusted subset, so night/school percentages and transcript times cannot be derived from a fallback clock.