Conversations

Overview

Conversations are ad-hoc chat threads between a user and one or more agents, outside of flow executions and pipeline workloads.

Qadra is a workload orchestration platform, not a chat app --- but conversations exist as a supplementary, informal interaction mode. Where a workload moves through pipeline stages and a flow executes a structured graph, a conversation is a free-form thread. There is no pipeline, no stage progression, no immutable snapshot.

SurfaceFormal?StructureBest For
WorkloadYesPipeline stages, tasks, artifactsMulti-stage deals (Research --> Due Diligence --> Memo)
FlowYesReactFlow graph, node state machineRepeatable, branching workflows
ConversationNoFlat message threadInformal, direct interaction with an agent

A conversation can still reach into the formal machinery --- an agent can autonomously trigger a flow from inside a conversation (see Flow Tool-Use) --- but the conversation itself stays informal.

Data Model

Two tables (migration 029): conversations (the thread) and conversation_messages (the turns).

CREATE TABLE conversations (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
    title       TEXT,
    is_archived BOOLEAN NOT NULL DEFAULT false,
    deleted_at  TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE conversation_messages (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    conversation_id     UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    agent_id            UUID REFERENCES agents(id) ON DELETE SET NULL,
    role                TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
    content             TEXT NOT NULL,
    model               TEXT,
    token_count         INTEGER,
    latency_ms          INTEGER,
    finish_reason       TEXT,
    rating              SMALLINT CHECK (rating IN (-1, 1)),
    flow_execution_id   UUID REFERENCES flow_executions(id) ON DELETE SET NULL,
    metadata            JSONB NOT NULL DEFAULT '{}',
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Key design choices:

  • Soft delete: Conversations carry a deleted_at timestamp. All queries filter WHERE deleted_at IS NULL --- threads are never hard-deleted.
  • Analytics columns: model, token_count, latency_ms, and finish_reason live inline on each message (not in a side table) so training-data export, cost tracking, and latency monitoring are direct SQL queries with no joins.
  • Rating: Each assistant message can be rated thumbs up (+1) or thumbs down (-1).
  • Flow linkage: flow_execution_id is a real FK linking a message to a flow execution the agent triggered.
  • Auto-title: The first exchange auto-generates a conversation title from the user's message (truncated to ~60 characters).

Per-Message Agent Switching

The agent_id lives on the message, not the conversation. A user can talk to the Research Analyst, then pivot to the Memo Writer mid-thread --- all in one conversation. User messages have agent_id = NULL; assistant messages carry the agent that produced them.

This is why there is no agent_id column on conversations: locking the whole thread to one agent would force a new conversation just to switch specialists.

The send_message Pipeline

POST /conversations/:id/messages maps to the NATS subject qadra.{tid}.chat.do.send_message, handled in src/nats_service.rs. This is the heart of a conversation --- it runs an end-to-end pipeline for every user turn.

User message
    |
    v
1. Persist user message (agent_id = NULL)
    |
    v
2. Epistemic grounding: handle_query() on message content
   (SPO lookup, curvature ranking, contradiction + gap detection)
   --> Competence Gate runs inside handle_query
    |
    v
3. Load agent (tenant-scoped) + active flows
    |
    v
4. Build LLM context:
   system_prompt + epistemic grounding (System msg) + flow catalog + history (cap 50)
    |
    v
5. Call LLM with tools: generate_chart (always) + run_flow (if active flows)
    |
    +-- generate_chart tool call --> serialize ECharts artifact --> re-call LLM for narration
    +-- run_flow tool call       --> execute flow inline (45s) --> re-call LLM with outputs
    |
    v
6. Verification gates (post-synthesis, both LLM-free, both no-op on empty context):
   6a. verify_response_rule_based(response, triples) --> metadata["verification"]
   6b. verify_with_belnap(parser, response, triples) --> metadata["belnap"]
    |
    v
7. Persist assistant message (model, latency_ms, token_count, finish_reason, flow_execution_id)
   --> auto-title on first exchange

Epistemic Grounding Injection

Before the LLM is called, the user's message is run through the existing epistemic pipeline via handle_query() --- the same machinery behind qadra.{tid}.epistemic.query. This performs SPO triple lookup, curvature-based triple ranking, contradiction detection, and gap detection.

The retrieved facts, contradictions, and knowledge gaps are formatted as a System message and injected after the agent's system prompt, before the conversation history:

FACTS:
(Apple, founded_in_year, 1976) [fact]

CONTRADICTIONS:
(Acme Corp, ceo, John Smith) vs (Acme Corp, ceo, Jane Doe)

KNOWLEDGE GAPS:
No triples found for "discount rate methodology"

Graceful degradation: If the epistemic query fails or returns no triples, the LLM call proceeds without grounding context. No grounding never blocks a response.

LLM Tools

Two tools are injected into the LlmProvider::complete() call:

ToolWhen InjectedPurpose
generate_chartAlwaysLLM decides a visualization is appropriate and returns an Apache ECharts option object
run_flowOnly when the tenant has active flowsLLM maps the user's intent to a flow and triggers its execution

Both follow a two-call pattern: the first LLM call returns a tool call, the handler executes it, then the handler re-calls the LLM with the result so the agent can narrate what happened rather than dumping raw data.

generate_chart

When the LLM returns a generate_chart tool call (with chart_name and chart_config), the handler serializes the ECharts config as a chart artifact inside metadata.artifacts with type: "chart" and content as the JSON-serialized option. It then makes a follow-up LLM call for narration. No new DB columns or API endpoints --- charts travel in the existing metadata JSONB.

Flow Tool-Use

When the LLM returns a run_flow tool call, the handler:

  1. Creates a flow execution
  2. Runs the executor inline (45s timeout) so results appear in the conversation
  3. Fetches the completed execution state
  4. Re-calls the LLM with the actual node outputs so the agent narrates real results, not just "started"
  5. Persists the assistant message with flow_execution_id set

Flow IDs are constrained via a JSON Schema enum on the tool definition, and the flow catalog is included in the system prompt so the LLM has full context. If the flow exceeds the 45s timeout, the handler falls back to a status message.

KG-SMILE Verification Gate

After synthesis, the response passes through the post-synthesis Verification Gate --- verify_response_rule_based(response, triples, config). It extracts the implied triples from the assistant response and checks whether each is grounded in the context triples returned by handle_query.

The result is a VerificationGateResult stored in the message's metadata["verification"]; ungrounded claims are logged as warnings (and surfaced, never silently suppressed).

#![allow(unused)]
fn main() {
pub struct VerificationGateResult {
    pub passed: bool,
    pub fidelity: f32,
    pub faithfulness: f32,
    pub attributions: Vec<AttributedEdge>,
    pub ungrounded_claims: Vec<UngroundedClaim>,
    pub reason: Option<String>,
}
}

No-op when context is empty: If handle_query returned no triples, the gate is skipped and no verification metadata is written. This prevents false negatives when the knowledge graph simply has nothing relevant for the query.

Belnap Four-Valued Gate

Immediately after the rule-based gate, a second, complementary verifier runs --- verify_with_belnap(shared_parser(), response, triples) from the qadra-belnap crate. It UD-parses the response into claims, grounds each against a polarity-tagged SPO graph, and reconciles the claim over Belnap four-valued logic: Grounded (True), Contradicted (False), Contested (Both), or Ungrounded (Neither). The gate fails only when the graph contradicts a claim; contradicted claims are logged as warnings. The BelnapVerification result is stored in metadata["belnap"] (only when the response yields at least one claim).

The parser comes from a process-wide shared_parser(): under the onnx-ort Cargo feature it loads the real ONNX goeswith UD model; otherwise it is a StubParser that makes the gate a passing no-op, so the default build is unchanged. See the Knowledge Graph chapter for the full four-valued model and the perturbation-based attribution path.

Persistence and Analytics

The assistant message is persisted with its analytics columns populated --- model, latency_ms, token_count, finish_reason --- plus flow_execution_id when a flow ran. On the first exchange, the conversation title is auto-generated from the user's message. Because the analytics live inline, cost and latency reporting are plain aggregate queries.

SSE Streaming

The standard send_message is NATS request-reply --- one request, one response, no progress. For real-time progress, POST /conversations/:id/messages/stream streams Server-Sent Events.

The gateway creates a unique NATS progress subject (_PROGRESS.{uuid}), subscribes to it, and passes the subject in the send_message request payload. Rust Core publishes FlowProgressEvent messages to that subject during execution; the gateway forwards each as an SSE event (event: {type}\ndata: {json}\n\n) and closes the stream on done.

React (SSE consumer) <--SSE-- Gateway <--NATS pub/sub-- Rust Core
   subscribes to                creates _PROGRESS.{uuid}    FlowExecutor +
   text/event-stream            subscribes, forwards        nats_service emit
                                closes on "done"            progress events

Events

EventEmitted ByMeaning
user_savednats_serviceUser message persisted
thinkingnats_serviceEpistemic grounding + LLM call starting
flow_startednats_serviceA run_flow tool call kicked off a flow execution
node_startedFlowExecutorA flow node began executing
node_completedFlowExecutorA flow node finished
node_failedFlowExecutorA flow node errored
flow_completednats_serviceThe triggered flow finished
assistant_messagenats_serviceFinal assistant message (with any artifacts)
donenats_serviceStream complete --- gateway closes the SSE connection

FlowProgressEvent is { event: string, data: Value, seq: u32 }, defined in crates/qadra-nats/src/messages.rs. A monotonically increasing AtomicU32 sequence counter guarantees ordering. Non-flow messages still emit the subset: user_saved --> thinking --> assistant_message --> done.

Artifacts

When an assistant message carries metadata.artifacts, the frontend renders compact, clickable cards inside the message bubble.

ComponentRole
ArtifactCardCompact card inside an assistant bubble. Shows artifact name, a type icon, and a "Click to open" subtitle.
ArtifactPanelClaude-style 480px slide-out side panel. Renders full artifact content as markdown, supports tab switching for multiple artifacts and copy-to-clipboard. Animated via framer-motion.
ChartRendererRenders chart artifacts (type: "chart") via Apache ECharts (echarts-for-react), using the Qadra dark theme. SVG renderer with getDataURL() for PNG export.

Because the full artifact content lives in the side panel, the LLM's follow-up narration prompt asks for a brief summary highlighting key findings rather than dumping the full content into the chat. Chart artifacts travel inside the existing metadata.artifacts JSONB --- no separate artifact API call is needed.

API and NATS Surface

All routes require requireAuth. The gateway translates HTTP to tenant-scoped NATS subjects (qadra.{tid}.chat.do.*).

MethodPathNATS SubjectDescription
POST/conversationschat.do.createCreate a conversation
GET/conversationschat.do.listList conversations (paginated, user-scoped)
GET/conversations/:idchat.do.getGet conversation by ID
PATCH/conversations/:idchat.do.update_titleUpdate title
DELETE/conversations/:idchat.do.deleteSoft-delete (sets deleted_at)
POST/conversations/:id/messageschat.do.send_messageSend message + get LLM response (60s)
GET/conversations/:id/messageschat.do.list_messagesList messages (paginated)
POST/conversations/:id/messages/:messageId/ratechat.do.rate_messageRate a message (+1 / -1)
POST/conversations/:id/messages/streamchat.do.send_messageSSE streaming send (65s)

Both list_messages and the underlying queries enforce tenant isolation --- messages are scoped to conversations owned by the requesting tenant (WHERE c.tenant_id = $1 AND c.deleted_at IS NULL).