Skip to Content
Internal docs are powered by Nextra Docs Theme.
SystemsPlatformAPI2Conversation-to-company linking

Conversation-to-company linking plan

Goal

Link conversations to relevant known companies when the source system did not populate public.conversations.company_id. The first use case is account-level drift detection: several individually acceptable conversations may collectively show that a company is experiencing a serious unresolved problem.

The Consensys incident is the reference case. Five calls and voicemails lacked a canonical company association even though the caller named Consensys and the contacts formed one escalating fraud incident.

Status as of 2026-08-23

V1 is enabled for Rho behind api2_conversation_company_linking. GPT-5 nano Flex extracts mentions, API2 resolves known companies, and api2.company_conversations stores provenance and evidence for the agent_api.company_conversations view.

A five-record production canary linked every previously unlinked Consensys call and voicemail; all six records now resolve under CONSENSYS SOFTWARE INC. Multi-entity links remain valid, so account-level consumers must distinguish the supported customer from merchants, counterparties, Rho, and other mentions.

Automatic enqueueing initially failed because production returned last_synced_at as a string before .toISOString() was called. The fix merged in PR #10556  under ENG-2553 . Verify one automatic production enqueue before broad backfill.

Decisions

  • Extend the existing api2.company_conversations table instead of introducing a polymorphic conversation_entities table.
  • Treat api2.company_conversations as the complete set of known conversation-to-company relationships. Every non-null public.conversations.company_id must have a matching row in this table.
  • Preserve public.conversations.company_id as the canonical source-system association and primary company. Never overwrite it with an inferred association.
  • Read company conversation history through api2.company_conversations once canonical links have been backfilled and all writers mirror them reliably.
  • Gate paid extraction per organization with the temporary Flipper flag api2_conversation_company_linking. Canonical mirroring and reads are never gated.
  • Extract organization names with gpt-5-nano on the Flex tier.
  • Link extracted names to the organization’s existing company catalog inside API2. The model does not receive or select database IDs during extraction.
  • Use deterministic evidence before asking a model to disambiguate duplicate company names.
  • Do not force ambiguous links. A resolver must be allowed to return no match.
  • Keep public.conversation_customers unchanged. Its rows mean actual customer participants, which is stronger than a person’s name merely appearing in a transcript.

Architecture

Data model

api2.company_conversations already stores source and metadata, and already contains rows backfilled from public.conversations.company_id. It becomes the complete relationship table, not an inferred-links-only table. Add only the fields needed to manage inferred links:

confidence real nullable source_version timestamptz nullable

Continue using the existing unique index on:

(organization_id, company_id, conversation_id)

Suggested source values:

SourceMeaningEffective confidence
legacy_company_idBackfilled canonical company_idAuthoritative
provider_company_idCurrent canonical company_id mirrored during conversation sync or reconciliationAuthoritative
eventDeterministic provider or product eventAuthoritative
transcript_entity_extractionExtracted span plus catalog resolutionExplicit numeric value
identity_propagationCopied from a linked conversation with the same stable caller identityExplicit numeric value
manualHuman-confirmed associationAuthoritative

confidence remains nullable for existing authoritative rows. Consumers derive an effective confidence of 1.0 for authoritative sources and use the stored value for inferred sources.

The core invariant is:

Every known conversation-to-company relationship exists in api2.company_conversations; public.conversations.company_id identifies which relationship is primary and provider-assigned.

Canonical mirroring must be idempotent. When public.conversations.company_id changes, insert the new authoritative row and remove the old row only when it was supported solely by the previous canonical association. Do not delete links supported by an independent event, manual action, or transcript inference.

Store evidence in the existing metadata JSON rather than adding another column:

{ "model": "gpt-5-nano", "prompt_version": "extract_companies_v1", "matched_text": "Consensys", "source_message_id": "123", "source_message_type": "call_transcript_message", "match_method": "unique_exact_company_name", "resolver_version": "company_linker_v1" }

Update attachCompanyConversation so an upsert refreshes metadata, confidence, and source_version; it currently refreshes only source and updated_at.

Extraction pipeline

Input

Load only customer-authored content:

  • public.conversation_parts with author_type = 'customer'
  • public.conversation_part_call_detail_transcript_messages with author_type = 'customer'
  • Original-language text, not only translated English
  • No internal notes or agent-authored messages

Preserve the part or transcript-message ID with every utterance. Process source messages rather than public.conversations.transcript, because the aggregate transcript loses precise evidence provenance.

Chunk long conversations to a bounded input size, initially 10,000-15,000 tokens per request.

Model call

Use the existing AI SDK and direct OpenAI provider:

generateText({ model: createModel("gpt-5-nano"), output: Output.object({ schema: companyExtractionSchema }), providerOptions: withBackgroundFlexProviderOptions({ openai: { reasoningEffort: "none", store: false, }, }), });

Do not enable withBackgroundFlexFallback. Flex capacity errors should be retried by BullMQ instead of silently upgrading to standard-tier pricing.

The strict model output contains only:

{ "organizations": [ { "source_message_id": "123", "text": "Consensys", "evidence_quote": "Hi, this is Greg Brenner calling from Consensys about several suspicious charges." } ] }

The prompt instructs the model to:

  • Return exact substrings from the supplied customer messages
  • Return a short exact transcript excerpt containing each organization mention
  • Extract named organizations only
  • Avoid translating or normalizing names
  • Avoid inferring relationships or database records
  • Return an empty array when no organization is present

Every output field must be required to satisfy OpenAI strict structured-output requirements. API2 verifies that both text and evidence_quote occur in the claimed source message and that the quote contains the extracted text. The validated quote, adjacent messages, caller identity, and candidate dossiers can then be reused by the optional disambiguation call without trusting a generated summary. Cap evidence_quote at 500 characters.

Company candidate linking

Normalize validated spans with Unicode NFKC normalization, case folding, whitespace collapsing, and surrounding-punctuation removal. Candidate lookup is always scoped to the current Rulebase organization.

Unique exact match

If exactly one active company has the normalized name, link it with:

source = transcript_entity_extraction confidence = 0.95 match_method = unique_exact_company_name

Do not create a new inferred row when the same company is already represented by an authoritative company_conversations row. The unique index makes this an upsert: stronger authoritative evidence takes precedence over inferred evidence for the same company-conversation pair.

Duplicate names

When several active companies have the same normalized name, resolve them using evidence in this order:

  1. A conversation customer or requester belongs to exactly one candidate through public.customer_companies.
  2. The requester’s email domain matches exactly one candidate’s companies.domain_names.
  3. A nearby conversation for the same requester, customer, or normalized phone has a canonical association with exactly one candidate.
  4. Prior manually confirmed or authoritative company_conversations links for the same stable identity point to exactly one candidate.

A deterministic resolver should require both a minimum score and a meaningful margin over the runner-up. The initial implementation should tune those values from labeled examples rather than treating an arbitrary score as calibrated probability.

If no candidate clearly wins, do not insert any candidate. Record the ambiguous extraction in Braintrust/logging so its frequency can be measured.

Optional high-risk adjudication

Only ambiguous conversations that belong to an already concerning cluster are eligible for a second model call. Reuse GPT-5 nano Flex and provide a compact candidate dossier:

  • Current transcript excerpt
  • Caller name, email domain, and normalized phone when available
  • Candidate company ID, name, domains, and known matching customers
  • At most a few recent conversation summaries per candidate

The output is company_id | null. null is the expected result when the evidence does not distinguish the candidates. Do not send complete company conversation histories and do not use a larger model in V1.

Identity propagation

After creating a high-confidence company link, find unlinked conversations that share a stable caller identity:

  • Same requester ID
  • Same conversation_customers client ID
  • Same normalized customer phone
  • Existing related-conversation thread

Restrict propagation to a bounded time window, initially 24 hours, and refuse to propagate across a conflicting canonical company_id. Persist propagated links with:

source = identity_propagation confidence = 0.85

Include the seed conversation, shared identifier type, and time distance in metadata. This step links silent voicemails and calls where the company name was not repeated.

Queue and trigger design

Add a conversation-company-linking BullMQ queue with job data:

type ConversationCompanyLinkingJobData = { conversationId: number; railsOrganizationId: number; organizationSlug: string; sourceVersion: string; };

Use a deterministic job ID based on organization, conversation, and source version. Configure five attempts, exponential backoff beginning at one minute, the long-running AI worker lock, and conservative initial concurrency.

Trigger the job from:

  1. scheduleConversationSyncedProcessing for email, chat, and already transcribed content.
  2. A thin post-transcription Rails-to-API2 enqueue after perform_post_sync_analysis, because the existing conversation_synced webhook fires before call transcription begins.
  3. Any API2-owned transcription completion path, such as sales-call processing.

API2 owns eligibility, deterministic job IDs, extraction, linking, and persistence. The Rails hook only announces that post-transcription content is ready.

Cost gate

Use the organization actor gate api2_conversation_company_linking. Check it before enqueueing and again when the worker begins, so disabling it stops both new and already queued model calls. Existing links remain readable when the flag is disabled.

This is a temporary backend operational flag:

  • Owner: AI Platform
  • Created: 2026-08-21
  • Rollout objective: validate precision and cost with Rho
  • Review or replacement date: 2026-10-01

If company linking becomes a permanent customer entitlement, replace the flag with durable organization configuration and remove the Flipper branch.

Agent and workflow access

Expose an organization-scoped agent_api.company_conversations view with:

conversation_id company_id company_name source confidence metadata updated_at

Document it in agent-api-descriptions.ts. Account-level workflow queries should use this view rather than relying exclusively on agent_api.conversations.company_id. The Rulebase MCP’s list_conversations(company_name_contains) filter and any new exact company_id filter must use the same relationship set so inferred calls appear beside provider-linked tickets.

During rollout, company-history reads should use a deduplicated union of:

  1. public.conversations.company_id
  2. api2.company_conversations

After backfill, writer coverage, and reconciliation are verified, remove the union fallback and query api2.company_conversations alone. This gives agents, MCP clients, workflows, and product surfaces one steady-state query path.

Rho’s live #10 Find account repeat-contact patterns workflow groups direct and inferred relationships, counts distinct conversations, includes solved and closed tickets, and evaluates one semantic incident against four thresholds:

SubtypeThreshold
high_risk_surge2 distinct conversations in 6 hours about the same high-risk issue, excluding an orderly callback requested by Rho
acute_surge3 distinct conversations in 6 hours about the same unresolved issue
sustained_recurrence4 distinct conversations in 7 days about the same unresolved issue
chronic_recurrence5 distinct conversations in 45 days about the same unresolved issue

It verifies the supported account and same incident. Empty voicemails and failed calls provide context but do not satisfy a threshold. Alerts explain the evidence without exposing relationship metadata:

Consensys contacted Rho three times in 2.5 hours about related suspicious card transactions. Earlier calls were already marked solved, but the underlying fraud question remained unresolved.

The separate #13 Find unresolved follow-ups signal covers missed promises, no response, repeated requests, no progress, and re-explanation. Contact velocity creates the incident; follow-up language shows worsening service.

Rollout

  1. Add the schema fields and update the shared company-conversation writer.
  2. Backfill every non-null public.conversations.company_id and mirror canonical links from each conversation ingestion or update path.
  3. Add a reconciliation job that repairs missing canonical mirrors and safely removes stale canonical-only rows.
  4. Temporarily switch company-history reads to the deduplicated union of the canonical column and relationship table.
  5. Add extraction, validation, exact linking, and queue wiring.
  6. Enable shadow extraction for Rho behind api2_conversation_company_linking. Completed.
  7. Run the five-record Consensys production canary. Completed.
  8. Verify automatic enqueueing after the timestamp-normalization fix. Merged; production verification pending.
  9. Backfill inferred links for the previous 90 days, newest conversations first.
  10. Review exact matches, duplicate-name cases, unmatched mentions, token usage, and estimated cost.
  11. Add identity propagation and replay the Consensys incident.
  12. Expose the agent and MCP read paths and enable one shadow account-drift workflow branch. Agent view and account-repeat branch are live; replay is pending.
  13. Verify canonical mirror coverage, then remove the union fallback.
  14. Deliver alerts only after precision and Slack-delivery behavior are verified.
  15. Expand organization coverage and remove the temporary rollout flag.

Verification

Automated tests

  • Strict output schema requires every model-produced property.
  • Hallucinated spans and unknown source-message IDs are rejected.
  • Unique exact company names link correctly.
  • Duplicate company names use deterministic evidence and skip unresolved ties.
  • Canonical links are not overwritten.
  • Every non-null canonical company_id is mirrored into company_conversations exactly once.
  • Company-history reads return canonical and inferred links without duplicate conversations during the union phase.
  • Canonical reassignment removes only the stale canonical-only relationship.
  • Resyncing the same source version is idempotent.
  • A newer source version refreshes automated metadata without deleting authoritative links.
  • Identity propagation respects the time window and canonical-company conflict guard.
  • Flex capacity errors are retried by BullMQ without automatic tier escalation.
  • Disabled organizations never enqueue or execute a model call.
  • Agent-facing queries are organization scoped.

Consensys acceptance test

Replay the six relevant conversations and verify that:

  • Extract Consensys from customer-authored call text. Passed.
  • Resolve Rho’s Consensys record while retaining duplicate and multi-entity links. Passed.
  • Link calls and voicemails with null canonical company_id. Passed for all five records.
  • At least three linked conversations are visible by the third live call.
  • Closed call tickets remain in the account-level cluster.
  • The later canonical email strengthens the cluster without duplicating links.
  • The workflow produces an alert candidate even though five source records have no canonical company_id.
  • A failed Slack post is not stamped as successfully alerted.

Relationship assertions passed; time-ordered workflow replay and Slack delivery remain open.

Deferred work

  • Fuzzy or embedding-based company-name matching
  • A durable alias editor for company names
  • Inferred person/customer associations
  • Cross-organization company identity
  • Automatic company-record deduplication or merging
  • Relationship classification such as caller, merchant, vendor, or employer

These are not required to validate account-level drift with the Consensys case.

Last updated on