Triage and risk engine
Two-tier risk gating, agent routing, and the final decision that auto-sends or parks a reply for human approval.
Every incoming message goes through the same triage pipeline. The goal is to classify risk, route to the right agent, ground the reply in knowledge bases, and only send automatically when the verdict is green.
Components
TriageOrchestrator.processIncoming(ticket)— drives the whole flow.Router.pick(category, requiredSkills)— chooses the best enabled agent.AgentService.executeAgent(agent, turns, { sources })— builds the prompt and streams the draft.RiskAssessor— Layer 1 deterministic rules and Layer 2 LLM classifier.
Message flow
IncomingMessage
→ persist message + upsert ticket
→ TriageOrchestrator.processIncoming
├── RiskAssessor.layer1(text) → rules + level
├── RiskAssessor.layer2(text) → { category, risk, confidence,
│ reasoning, requiredSkills }
│ skipped if layer1 = red
├── Router.pick(category, requiredSkills) → Agent
├── if agent.knowledgeBaseIds.length > 0:
│ RAG.search(...) → SourceSnippet[]
├── AgentService.executeAgent(agent, turns, { sources })
│ → build prompt (system + language + sources + turn)
│ → runner.generate(stream) → draft
├── save agent draft + risk_assessment
└── decide by finalLevel:
├── green → send to channel, status = auto_replied
└── yellow | red → status = waiting_operator
→ operator Inbox → approve / edit / rejectLayer 1 — deterministic rules
Layer 1 is a fast rule engine that runs in less than 10 ms. Each rule is a pure function
that returns { triggered, level, reason }. Rules live in the risk_rules
table; built-in rules are loaded at startup and cannot be deleted, only
disabled.
Built-in categories:
| Category | Level | Examples |
|---|---|---|
pii | yellow | email, international phone |
credentials | red | sk-…, xoxb-…, JWT, PEM, credit card (Luhn) |
destructive | red | drop table, rm -rf, "удалить всё" |
destructive | yellow | disable, "отключить", "забанить" |
financial | red | refund, lawsuit, "возврат денег" |
financial | yellow | price, billing, "цена" |
access | red | grant access, make admin, "выдать права" |
volume | yellow | message over 5000 chars, over 10 URLs |
profanity | yellow | lightweight tone screen |
Rule types:
regex— a RegExp string.keywords— comma-separated list, case-insensitive, word-boundary match.builtin— identifier of a built-in checker (luhn_credit_card,oversize_message,excess_urls,profanity_lite).
Layer 1 result:
{ level: 'green' | 'yellow' | 'red', triggeredRules: [...] }The level is the maximum of triggered rules. Empty rules → green, and Layer 2 is invoked.
Layer 2 — LLM classifier
If Layer 1 is not red, a small classifier model (default qwen3-0.6b-q4) produces
a structured JSON verdict via grammar:
type LLMRiskVerdict = {
category: 'coding' | 'business' | 'marketing' | 'seo' | 'general' | 'other'
risk: 'green' | 'yellow' | 'red'
confidence: number // 0..1
reasoning: string // one-line audit
}The classifier prompt is stored in risk_rules_config.llm_prompt and can be
edited in Settings → Risk & Triage.
Routing
Router.pick scores enabled agents by:
agent.enabled === true.- Exact role match (
agent.role === category) oragent.role === 'general'as fallback. - Highest skill intersection with
requiredSkills. - Tie-break by
last_used_at ASC(round-robin).
Final verdict and decision
finalLevel = max(L1.level, L2.risk)
finalCategory = L2.category ?? firstFiredRule.category ?? 'general'green— reply is sent automatically.yelloworred— reply becomes a ticket in the operator Inbox. A human can approve, edit, or reject before it ever reaches the channel.
Audit
Every verdict is saved to risk_assessments:
layer1_result— full Layer 1 JSON.layer2_result— full Layer 2 JSON, ornullif skipped.final_level,final_category.created_at.
In the desktop app, open a ticket in the Inbox and expand the Assessment section to inspect the raw verdict.
Configuration
Operators manage risk in Settings → Risk & Triage:
- Add, edit, enable, or disable rules with a live preview.
- Edit the Layer 2 classifier prompt.
- View built-in rules (disabled allowed, deletion blocked).