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_conversationstable instead of introducing a polymorphicconversation_entitiestable. - Treat
api2.company_conversationsas the complete set of known conversation-to-company relationships. Every non-nullpublic.conversations.company_idmust have a matching row in this table. - Preserve
public.conversations.company_idas the canonical source-system association and primary company. Never overwrite it with an inferred association. - Read company conversation history through
api2.company_conversationsonce 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-nanoon 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_customersunchanged. 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 nullableContinue using the existing unique index on:
(organization_id, company_id, conversation_id)Suggested source values:
| Source | Meaning | Effective confidence |
|---|---|---|
legacy_company_id | Backfilled canonical company_id | Authoritative |
provider_company_id | Current canonical company_id mirrored during conversation sync or reconciliation | Authoritative |
event | Deterministic provider or product event | Authoritative |
transcript_entity_extraction | Extracted span plus catalog resolution | Explicit numeric value |
identity_propagation | Copied from a linked conversation with the same stable caller identity | Explicit numeric value |
manual | Human-confirmed association | Authoritative |
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_ididentifies 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_partswithauthor_type = 'customer'public.conversation_part_call_detail_transcript_messageswithauthor_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_nameDo 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:
- A conversation customer or requester belongs to exactly one candidate
through
public.customer_companies. - The requester’s email domain matches exactly one candidate’s
companies.domain_names. - A nearby conversation for the same requester, customer, or normalized phone has a canonical association with exactly one candidate.
- Prior manually confirmed or authoritative
company_conversationslinks 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_customersclient 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.85Include 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:
scheduleConversationSyncedProcessingfor email, chat, and already transcribed content.- A thin post-transcription Rails-to-API2 enqueue after
perform_post_sync_analysis, because the existingconversation_syncedwebhook fires before call transcription begins. - 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_atDocument 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:
public.conversations.company_idapi2.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:
| Subtype | Threshold |
|---|---|
high_risk_surge | 2 distinct conversations in 6 hours about the same high-risk issue, excluding an orderly callback requested by Rho |
acute_surge | 3 distinct conversations in 6 hours about the same unresolved issue |
sustained_recurrence | 4 distinct conversations in 7 days about the same unresolved issue |
chronic_recurrence | 5 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
- Add the schema fields and update the shared company-conversation writer.
- Backfill every non-null
public.conversations.company_idand mirror canonical links from each conversation ingestion or update path. - Add a reconciliation job that repairs missing canonical mirrors and safely removes stale canonical-only rows.
- Temporarily switch company-history reads to the deduplicated union of the canonical column and relationship table.
- Add extraction, validation, exact linking, and queue wiring.
- Enable shadow extraction for Rho behind
api2_conversation_company_linking. Completed. - Run the five-record Consensys production canary. Completed.
- Verify automatic enqueueing after the timestamp-normalization fix. Merged; production verification pending.
- Backfill inferred links for the previous 90 days, newest conversations first.
- Review exact matches, duplicate-name cases, unmatched mentions, token usage, and estimated cost.
- Add identity propagation and replay the Consensys incident.
- 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.
- Verify canonical mirror coverage, then remove the union fallback.
- Deliver alerts only after precision and Slack-delivery behavior are verified.
- 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_idis mirrored intocompany_conversationsexactly 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
Consensysfrom 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.