Support assistant
The failure this prevents: a customer asks about refunds after 30 days, your help centre never says, and the assistant invents a policy. Now you either honour a refund policy you never wrote, or you tell a customer your own bot was wrong.
-
Ingest the help centre. Markdown, HTML, PDFs and a crawled site all work; ingest is idempotent by content hash, so re-running is cheap and safe.
for path in Path("help-centre").rglob("*.md"):rag.ingest(path) -
Answer in strict mode — the default.
response = rag.ask(customer_question) -
Branch on the decision, and never paper over a refusal.
from citenexus.answer.result import Decisionif response.evidence.decision is Decision.answered:src = response.sources[0]reply(response.answer, cite=(src.document, src.source_uri))else:escalate_to_human(customer_question, reason=response.missing_evidence)
Same outcome, different shape: you load the help centre into a slice yourself
(there is no ingest() and no crawler here), then branch on the same decision.
Answer-or-escalate is the whole build:
package main
import ( "fmt"
"github.com/muthuishere/citenexus/golang/answer")
func main() { helpCentre := []answer.Doc{ {DocumentID: "help/shipping", Text: "We ship to Ireland within five business days."}, {DocumentID: "help/returns", Text: "Unopened items may be returned within 30 days of delivery."}, }
for _, q := range []string{ "Can I return an unopened item?", "What is your refund policy?", "Is there a restocking fee?", } { res := answer.Ask(helpCentre, q, answer.DefaultTopK) if res.Evidence.Decision == "answered" { fmt.Printf("%-32s -> %s (%s)\n", q, res.Answer, res.Sources[0].Document) } else { fmt.Printf("%-32s -> ESCALATE: %v\n", q, res.MissingEvidence) } }}Can I return an unopened item? -> Unopened items may be returned within 30 days of delivery. (help/returns)What is your refund policy? -> ESCALATE: [no sufficiently relevant evidence found]Is there a restocking fee? -> ESCALATE: [no sufficiently relevant evidence found]Two of three refuse, and the refund policy the corpus never states is not invented. What you supply yourself: reading the Markdown off disk, and re-reading it when the docs change — there is no content-hash idempotence because there is no store.
Same outcome, different shape: load the help centre into an array yourself (no
ingest(), no crawler), then branch on the same decision:
import { ask } from "@muthuishere/citenexus"
const helpCentre = [ { document_id: "help/shipping", text: "We ship to Ireland within five business days." }, { document_id: "help/returns", text: "Unopened items may be returned within 30 days of delivery." },]
for (const q of [ "Can I return an unopened item?", "What is your refund policy?", "Is there a restocking fee?",]) { const res = ask(helpCentre, q) if (res.evidence.decision === "answered") { console.log(q, "->", res.answer, `(${res.sources[0].document})`) } else { console.log(q, "-> ESCALATE:", res.missing_evidence) }}Can I return an unopened item? -> Unopened items may be returned within 30 days of delivery. (help/returns)What is your refund policy? -> ESCALATE: [ 'no sufficiently relevant evidence found' ]Is there a restocking fee? -> ESCALATE: [ 'no sufficiently relevant evidence found' ]The refund policy the corpus never states is refused, not invented. What you supply yourself: loading the files and refreshing them — no store, so no content-hash idempotence.
The refusal is a feature, and it is routable
Section titled “The refusal is a feature, and it is routable”A refusal is not a dead end — it is a signal that your documentation has a
hole. missing_evidence tells you which one:
response.missing_evidence# ('no sufficiently relevant evidence found',)res.MissingEvidence // [no sufficiently relevant evidence found]Same field, same string — it is part of the pinned Result. Log it with the
question and you get the same ranked list of documentation holes.
res.missing_evidence // [ 'no sufficiently relevant evidence found' ]Same field, same string — part of the pinned Result. Log it with the question
to get the same ranked list of documentation holes.
Log every refusal with its question. The result is a ranked list of what your help centre should say next, generated by real customer demand rather than guesswork. That is usually worth more than the answers.
Multi-turn without losing the thread
Section titled “Multi-turn without losing the thread”rag.ask("Do you ship to Ireland?", conversation_id="ticket-8891")rag.ask("How long does it take?", conversation_id="ticket-8891")No conversation memory in this port. Ask takes no conversation_id and
keeps no state between calls — each question is answered against the slice you
pass and nothing else. A follow-up like “how long does it take?” has to carry its
own context in the question text, because there is no prior turn to resolve it
against.
No conversation memory in this port. ask takes no conversation_id and
holds no state between calls. A follow-up must carry its own context in the
question text — there is no prior turn to resolve “it” against.
Conversation memory is partition-scoped and keyed by conversation_id, so
one ticket’s turns can never surface in another’s.
Tuning how strict to be
Section titled “Tuning how strict to be”from citenexus.domain.trust import TrustMode
rag.ask(q, mode=TrustMode.strict) # default — refuse unless clearly supportedrag.ask(q, mode=TrustMode.normal) # answer with signals surfacedThere is no knob: this port is always strict. result.TrustModeStrict is the
only mode defined and Ask takes no mode argument. For a public-facing bot that
is the setting you wanted anyway.
There is no knob: this port is always strict. TrustMode exports the single
value "strict" and ask takes no mode argument — which is the setting a
public-facing bot should be on regardless.
For a public-facing bot, keep strict. The cost of a refusal is one escalation;
the cost of an invented policy is a support incident and, in a regulated
industry, potentially a commitment you have to honour.
Keeping the index honest as docs change
Section titled “Keeping the index honest as docs change”Re-ingest is idempotent by content hash, so a nightly sync only re-embeds what actually changed. When a page is retired, revoke it rather than leaving it indexed — a deleted help article that still answers questions is worse than no article:
rag.delete("help/legacy-refund-policy")Nothing to revoke — this port keeps no index, so retiring an article means not putting it in the slice on the next call. The flip side is that nothing prunes itself either: a stale array is answered from until you rebuild it.
Nothing to revoke — no index is kept, so retiring an article means dropping it from the array. Equally, nothing prunes itself: a stale array keeps answering until you rebuild it.
See Right to erasure for proving the removal was complete, and Regulated audit for catching pages that were never supposed to be indexed at all.