Skip to content
Kien

VNTRIP Assistant — a pilot AI ops core that hands off instead of guessing

A prototype built in October 2026, not in production: nine inbound channels normalised into one event shape, a tiered intent classifier that routes each turn to exactly one of 10 domain agents, and guardrails that hand anything risky to a human instead of letting the model guess.

Period
10/2026 — pilot, not in production
Role
Built the pilot core service

Stack

  • NestJS 10
  • Fastify
  • TypeScript
  • PostgreSQL + pgvector
  • Redis
  • n8n
  • Socket.IO
  • BGE-M3 on llama.cpp

Status: a pilot / prototype built in October 2026. It has run locally, not in production. Everything below describes what the code does today, and says so where a part is only designed.

The problem

Ops messages at VNTRIP arrive through many channels, and the expensive failure is not a wrong answer — it is the message nobody picked up. An assistant that guesses is worse than none, so the core had to be built around knowing when not to answer.

Intake: nine channels, one event, exactly once

Nine inbound channels — Telegram, email over IMAP, Zalo OA (customer and internal), Chatwoot (including personal Zalo), PBX missed calls and recordings, TMS chat, Copilot and Mattermost — go through n8n workflows that normalise them into one event shape.

The backend intake is idempotent: claim the event, then INSERT … ON CONFLICT DO NOTHING plus FOR UPDATE SKIP LOCKED so two workers never process the same event, at most 3 attempts, then complete. A channel that retries its webhook cannot create a second ticket.

Routing: a tiered classifier, exactly one agent per turn

Intent is classified in tiers, cheapest first, and a tier only answers when it clears its confidence threshold:

Tier What it is Threshold
Rules 17 intents, 135 phrases on diacritic-stripped text, entity rules for order refs and PNRs 0.85
kNN nearest labelled examples — planned 0.85
Small LLM a cheap model for what rules cannot place 0.7
Strong LLM the last resort 0.5

The output is exactly one agent per turn, and that is enforced by the types, not by convention — there is no code path that fans a turn out to two agents.

Agents under an autonomy ladder

There are 10 domain agents. Flight, hotel and knowledge are live in the pilot; post-booking, finance, corporate, dispatch, quality, proactive and sourcing are designed but not built.

Every action sits on an autonomy tier: T0 read, T1 internal write, T2 external send, T3 money or seats. The pilot executes only T0 and T1. Anything at T2 or T3 always hands off to a human.

Guardrails

  • PII vault redaction on every LLM, embedding and rerank call — personal data is swapped out before any model sees the text.
  • Six restricted topics — visa, immigration, legal, compensation, refund amount and VAT invoice — trigger an immediate human handover.
  • Prompt-injection attempts are flagged.
  • Knowledge answers are cited sentence by sentence, with a check for unverified claims; an answer that cannot be grounded is refused. How that works is in the RAG write-up.

Tickets, SLA and realtime

A ticket state machine with 9 statuses, 31 transitions and 19 ticket types. SLA runs on a business-hours engine over a Vietnamese holiday calendar — 8 clocks, a 30-second scanner using SKIP LOCKED, and alerts to Telegram and Mattermost sent through an outbox.

Socket.IO realtime events are emitted only after commit, so a screen never shows a state the database rolled back. Every interaction is kept in an append-only, redacted log.

The numbers

About 50k lines of TypeScript, about 5.4k lines of hand-written SQL, and 1,182 unit tests.

In a local pilot run with the LLM switched off, the knowledge path answered 99 questions from the knowledge base with citations, at a p50 of roughly 1.9–2.4 s, and gave an honest "no source" on 33 more — the behaviour the whole design is built to protect.