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.
| Surface | Formal? | Structure | Best For |
|---|---|---|---|
| Workload | Yes | Pipeline stages, tasks, artifacts | Multi-stage deals (Research --> Due Diligence --> Memo) |
| Flow | Yes | ReactFlow graph, node state machine | Repeatable, branching workflows |
| Conversation | No | Flat message thread | Informal, 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_attimestamp. All queries filterWHERE deleted_at IS NULL--- threads are never hard-deleted. - Analytics columns:
model,token_count,latency_ms, andfinish_reasonlive 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_idis 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:
| Tool | When Injected | Purpose |
|---|---|---|
generate_chart | Always | LLM decides a visualization is appropriate and returns an Apache ECharts option object |
run_flow | Only when the tenant has active flows | LLM 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:
- Creates a flow execution
- Runs the executor inline (45s timeout) so results appear in the conversation
- Fetches the completed execution state
- Re-calls the LLM with the actual node outputs so the agent narrates real results, not just "started"
- Persists the assistant message with
flow_execution_idset
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
| Event | Emitted By | Meaning |
|---|---|---|
user_saved | nats_service | User message persisted |
thinking | nats_service | Epistemic grounding + LLM call starting |
flow_started | nats_service | A run_flow tool call kicked off a flow execution |
node_started | FlowExecutor | A flow node began executing |
node_completed | FlowExecutor | A flow node finished |
node_failed | FlowExecutor | A flow node errored |
flow_completed | nats_service | The triggered flow finished |
assistant_message | nats_service | Final assistant message (with any artifacts) |
done | nats_service | Stream 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.
| Component | Role |
|---|---|
| ArtifactCard | Compact card inside an assistant bubble. Shows artifact name, a type icon, and a "Click to open" subtitle. |
| ArtifactPanel | Claude-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. |
| ChartRenderer | Renders 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.*).
| Method | Path | NATS Subject | Description |
|---|---|---|---|
POST | /conversations | chat.do.create | Create a conversation |
GET | /conversations | chat.do.list | List conversations (paginated, user-scoped) |
GET | /conversations/:id | chat.do.get | Get conversation by ID |
PATCH | /conversations/:id | chat.do.update_title | Update title |
DELETE | /conversations/:id | chat.do.delete | Soft-delete (sets deleted_at) |
POST | /conversations/:id/messages | chat.do.send_message | Send message + get LLM response (60s) |
GET | /conversations/:id/messages | chat.do.list_messages | List messages (paginated) |
POST | /conversations/:id/messages/:messageId/rate | chat.do.rate_message | Rate a message (+1 / -1) |
POST | /conversations/:id/messages/stream | chat.do.send_message | SSE 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).