Skip to content

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.

  • 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 (compactConversationHistory in lib/chat/conversation.ts), on widget and channel turns alike (ADR-0172).

conversations.statusactive | 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.

tickets.statusnew | 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, or POST /api/tickets.
  • pending_customer pauses the resolution SLA clock; new/open count against first-response only; breach sweep in lib/tickets/breach-sweep.ts + cron /api/cron/sla-breach.
  • resolved → closed is set by cron only: routes/api/cron/retention/+server.ts closes tickets resolved for more than 7 days (reopen_window_days), or an agent calls POST /api/tickets/:id/close.
  • A visitor reply within 7 days → reopened → pending_agent (only the resolution due date resets); after closed → a new ticket linked by parent_ticket_id.
  • closed tickets 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
  • query_log.cache_status / embedding_cache_status — which gate ended the turn (query hash only, no raw text — PDPA).
  • AE dataset chat.turn outcome (grounded | idk | out_of_scope | canned | error) — on /admin/tenants/[id]/health (ADR-0050).
  • Response headers x-llm-route (gateway | openrouter | direct) and X-Access-Tier: public when an interlocked tenant answered from the public layer.