How Puccha thinks (team edition)
Updated 2026-09-18 · owner natthawat@harmonyx.co · pipeline handoff tickets status observability
The expanded version of How Puccha answers, for anyone who has to read the code or field a deep question from a customer. File paths are under platform/apps/app/src/ unless stated otherwise.
1. No “FAQ mode” — only gates before the LLM
Section titled “1. No “FAQ mode” — only gates before the LLM”Every surface (widget /api/chat, /api/answer, LINE/FB/IG/TikTok/X via lib/channels/channel-turn.ts, MCP) follows one principle: retrieve, then generate, with several short-circuits before the model is called. The order in routes/api/chat/+server.ts:
| # | Gate | Decided by | Outcome and cacheStatus in query_log |
|---|---|---|---|
| 0 | Guardrails | sanitizeInput (injection regex, length ≤ 2000), rate limit 40/h per visitor + 300/h per IP (ADR-0185), duplicate-query dedupe → 429 duplicate_query |
Rejected before anything runs |
| 1 | Intent regex | classifyQueryIntent() in lib/rag/scope-gate.ts: the whole message is a greeting/thanks (Thai polite particles handled); first turn of a conversation only |
cannedReplyFor() → canned |
| 2 | Retrieval | hybridSearch() in lib/rag/search.ts: Vectorize (bge-m3) → FTS5 → substring fallback → RRF → rerank (Cohere v3.5 → Workers AI bge-reranker → LLM, ADR-0115) → checkAcl |
Chunks with scores |
| 3 | Scope gate | SCOPE_RERANK_MIN_SCORE = 0.30 or tenants.scope_min_score, only when the reranker is calibrated (Cohere); bypassed for commercial intent (ADR-0112) |
Refuse without the LLM → out_of_scope + handoff button (ADR-0121) |
| 4 | Answer cache | lib/rag/answer-cache.ts — CF Cache API, key = SHA-256(tenant, workspace, locale, query, sorted chunk ids, cache generation, persona fingerprint), 15 min per colo |
hit, answered instantly |
| 5 | LLM | Haiku 4.5 via AI Gateway (ADR-0119), single grounded call (ADR-0173), chunks inside <context> marked untrusted, prompt caching on system+tools |
miss |
| 6 | Post-check | isIdkText() detects refusal language → no citations, gap_outcome (ADR-0186); citations validated server-side against the chunk set |
Recorded via finishChatTurn() in lib/chat/chat-pipeline.ts |
What users call the “FAQ” is gate 4. It invalidates itself when documents change (chunk set changes → key changes) or when an admin clears the cache (lib/rag/cache-gen.ts bumps the generation).
cacheStatus has one more value: assessment_locked, for tenants with the interlock on (ADR-0182), which is checked before gate 1.
2. Multi-turn and follow-ups
Section titled “2. Multi-turn and follow-ups”- The client never sends history — it is always loaded from D1 (ADR-0017). This is chat’s number-one security invariant.
- From the second turn on, the intent regex is skipped and there is no raw-query prefetch; the question is rewritten into a standalone query first (ADR-0118), then retrieved.
- Long histories are compacted lazily (
compactConversationHistoryinlib/chat/conversation.ts), on widget and channel turns alike (ADR-0172).
3. What changes a status
Section titled “3. What changes a status”Conversation — lib/chat/handoff.ts
Section titled “Conversation — lib/chat/handoff.ts”conversations.status ∈ active | waiting | resolved | archived | orphaned | deleted
| Transition | Trigger | Function |
|---|---|---|
active → waiting |
Any handoff: (a) the LLM calls the escalateToHuman tool — its description says to use it when “the user explicitly asks to talk to a person, you cannot help after searching, or the user is frustrated”; (b) the visitor presses the button after a scope-gate refusal → POST /api/conversations/:id/escalate; (c) an agent escalates from the inbox. All gated by features.humanHandoff |
escalateToHuman() — round-robin assignment; opens a ticket if nobody is available |
waiting → active |
The agent’s first reply | claimConversation() — linked ticket → in_progress |
active → resolved |
Only an agent pressing Resolve — no auto-resolve on inactivity | resolveConversation() — linked ticket → resolved, writes a conversation_resolved system row |
resolved → active |
Agent presses Reopen, or the visitor writes after resolution | reopenConversation() / maybeReopenForCustomerReply() |
While waiting, the bot stays quiet on channels (bot-quiet guard, ADR-0146) so it never talks over the “hello? anyone there?” window.
Ticket — lib/tickets/tickets.ts, ADR-0037
Section titled “Ticket — lib/tickets/tickets.ts, ADR-0037”tickets.status ∈ new | open | in_progress | pending_customer | pending_agent | resolved | reopened | closed
- Created by a handoff with no available agent (
createdVia: 'ai_escalation'), a new email thread, orPOST /api/tickets. pending_customerpauses the resolution SLA clock;new/opencount against first-response only; breach sweep inlib/tickets/breach-sweep.ts+ cron/api/cron/sla-breach.resolved → closedis set by cron only:routes/api/cron/retention/+server.tscloses ticketsresolvedfor more than 7 days (reopen_window_days), or an agent callsPOST /api/tickets/:id/close.- A visitor reply within 7 days →
reopened → pending_agent(only the resolution due date resets); afterclosed→ a new ticket linked byparent_ticket_id. closedtickets are hard-deleted once past the plan’s retention window (PDPA §37).
So the closed label in the UI always belongs to a ticket; conversations never carry it.
4. Answers you can give a customer immediately
Section titled “4. Answers you can give a customer immediately”| Customer asks | Short answer | Reference |
|---|---|---|
| “It’s in the docs but Puccha says it has no information” | The scope gate scores QA-relevance, not topic; oversized chunks or very short questions score low. Tune scope_min_score per tenant or split the document |
ADR-0112, memory scope-gate-refuses-sales-leads |
| “I edited a document but the answer is stale” | Answer cache 15 min per colo + widget config edge cache (/api/widget/home 5 min). Clear cache or wait |
ADR-0129 |
| “The bot replied while an agent was handling it” | Known limitation after claim (active): no human-handling state yet |
ADR-0146 § Known limitation |
| “LINE never replies” | The OA’s “Auto-response messages” must be off, or it consumes the reply token | LINE guide, trap 2 |
| “The whole office got 429” | Old limit was per IP; now 40/h per visitor + 300/h IP backstop | ADR-0185 |
5. Observability that confirms behaviour
Section titled “5. Observability that confirms behaviour”query_log.cache_status/embedding_cache_status— which gate ended the turn (query hash only, no raw text — PDPA).- AE dataset
chat.turnoutcome (grounded | idk | out_of_scope | canned | error) — on/admin/tenants/[id]/health(ADR-0050). - Response headers
x-llm-route(gateway | openrouter | direct) andX-Access-Tier: publicwhen an interlocked tenant answered from the public layer.