"KB Ask-this-page Q&A — CUO-grounded answer over current + linked docs with span-level citations and answer-or-decline gate"
§1 — Description (BCP-14 normative)
The KB service MUST ship Q&A at services/kb/src/qa/ grounded in current page + 1-hop links, span-cited, decline-on-low-confidence, 4 memory audit kinds.
- MUST validate
qa_answer_kindagainst closed enum per DEC-1941.
- MUST assemble context at
context_assembler.rs::assemble(doc_id, question)per DEC-1940:
- Current doc chunks (all)
- 1-hop linked docs (via TASK-MEMORY-108 inbound/outbound links) → their top-3 most relevant chunks via TASK-KB-006 rerank
- Capped at 50k tokens total
- MUST generate answer at
answer_generator.rs::generate(context, question)per DEC-1940 with TASK-AI-003 prompt:
- System prompt restricts to provided context only
- Output JSON:
{answer, confidence_score, citations: [{doc_id, chunk_id, char_start, char_end}]}
- MUST extract citations at
citation_extractor.rs::extract(answer)per DEC-1942:
- Every factual claim must reference a chunk + char range
- Reject if any claim un-cited → mark as decline_low_confidence
- MUST apply confidence gate per DEC-1943:
- confidence ≥ 0.7 → return confident or partial
- confidence < 0.7 → return decline_low_confidence
- 0 citations → return decline_no_evidence
- MUST enforce rate limit per DEC-1944 — 50/user/day; return 429.
- MUST define table at migration
0007: ``sql CREATE TABLE kb_qa_questions ( question_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, doc_id UUID NOT NULL, question_text TEXT NOT NULL, answer_kind TEXT NOT NULL CHECK (answer_kind IN ('confident','partial','decline_no_evidence','decline_low_confidence')), answer_text TEXT, confidence_score NUMERIC(3,2), citations JSONB NOT NULL DEFAULT '[]', asked_by UUID NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX qa_user_day_idx ON kb_qa_questions(tenant_id, asked_by, created_at DESC); ALTER TABLE kb_qa_questions ENABLE ROW LEVEL SECURITY; CREATE POLICY qa_rls ON kb_qa_questions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON kb_qa_questions FROM cyberos_app; -- Append-only — Q&A history immutable``
- MUST expose endpoint: ``
text POST /v1/kb/docs/{id}/ask body: {question}``
- MUST emit 4 memory audit kinds per DEC-1945. PII per TASK-MEMORY-111: question + answer text SHA-256 hashed; citation ids ok.
- MUST thread trace_id from ask → assemble → generate → cite → audit.
- MUST NOT search open world per DEC-1940 (only doc + 1-hop links).
- MUST NOT return answer without ≥1 citation per DEC-1942.
- MUST NOT return below confidence threshold per DEC-1943.
§2 — Why this design
Why 1-hop linked (DEC-1940)? Docs cross-reference; questions often need linked context (e.g. "what's the policy?" → linked Decree 145).
Why span citations (DEC-1942)? UI highlights source on hover; users verify claims; eliminates trust-but-can't-verify problem.
Why confidence gate (DEC-1943)? Better to say "I don't know" than confidently mislead; trust over coverage.
Why rate limit (DEC-1944)? AI cost per call non-trivial; per-user cap prevents runaway.
§3 — API contract
POST /v1/kb/docs/{id}/ask
Sample request:
{
"question": "What's the maximum OT per week?"
}
Sample response (confident):
{
"answer_kind": "confident",
"answer_text": "Maximum OT per week is 12 hours per Decree 145 Art. 107.",
"confidence_score": 0.92,
"citations": [
{
"doc_id": "uuid-decree-145",
"chunk_id": "uuid-art-107-chunk",
"char_start": 1200,
"char_end": 1280,
"snippet": "...overtime shall not exceed 12 hours per week..."
}
]
}
Sample response (decline):
{
"answer_kind": "decline_no_evidence",
"answer_text": "Not enough evidence in this page + linked docs to answer.",
"confidence_score": 0.3,
"citations": []
}
§4 — Acceptance criteria
- answer_kind enum cardinality 4. 2. Context = current doc + 1-hop linked. 3. No open-world search. 4. Every claim cited with chunk + char range. 5. Confidence ≥0.7 → confident or partial. 6. <0.7 → decline_low_confidence. 7. 0 citations → decline_no_evidence. 8. Rate limit 50/user/day. 9. 4 memory audit kinds emitted. 10. PII scrubbed (question + answer SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. Context capped 50k tokens. 14. Append-only via REVOKE. 15. UI span highlight from citations. 16. Decline messages user-friendly. 17. History queryable per user. 18. Failure → decline_low_confidence + sev-2. 19. Linked docs respect TASK-KB-003 visibility. 20. Question length capped 1000 chars.
§5 — Verification
#[tokio::test]
async fn confident_answer_has_citations() {
let ctx = TestContext::with_doc_about_ot_caps().await;
let r = ctx.ask(ctx.doc_id, "What's the max OT per week?").await;
assert_eq!(r.answer_kind, "confident");
assert!(!r.citations.is_empty());
for c in &r.citations {
assert!(c.char_end > c.char_start);
}
}
#[tokio::test]
async fn decline_when_no_evidence() {
let ctx = TestContext::with_doc_about_finance().await;
let r = ctx.ask(ctx.doc_id, "What's the capital of France?").await;
assert_eq!(r.answer_kind, "decline_no_evidence");
assert_eq!(r.citations.len(), 0);
}
#[tokio::test]
async fn rate_limit_50_per_day() {
let ctx = TestContext::with_user().await;
for _ in 0..50 { ctx.ask(ctx.doc_id, "test").await; }
let r = ctx.ask(ctx.doc_id, "test").await;
assert_eq!(r.status_code, 429);
}
// 5.4..5.10
§7 — Dependencies
Upstream: TASK-KB-006, TASK-CUO-101. Cross-module: TASK-AI-003 (LLM), TASK-MEMORY-108 (link graph for 1-hop), TASK-KB-003 (linked doc visibility), TASK-MEMORY-111 (PII).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| AI timeout | retry 1x | decline_low_confidence + sev-2 | inherent |
| Context exceeds 50k tokens | truncate | sev-3 audit | inherent |
| 1-hop links empty | use doc-only context | inherent | inherent |
| Citation extraction fail | mark decline | sev-2 | inherent |
| Rate limit window edge | sliding 24h | inherent | inherent |
| Cross-tenant ask | RLS | 403 | inherent |
| Linked doc forbidden | filter | exclude from context | inherent |
| Question malformed | validate | 400 | rephrase |
| Citation char range invalid | reject answer | sev-2 | bug fix |
| AI quota | downstream | sev-2 | inherent |
§11 — Implementation notes
- §11.1 AI prompt: "Answer ONLY from provided context. Cite every claim with chunk_id + char range. If insufficient evidence, return decline."
- §11.2 1-hop link query:
TASK-MEMORY-108 link_graph WHERE source=$doc_id OR target=$doc_id. - §11.3 Rate limit via Redis sliding window: 50 ops per 24h per (tenant, user).
- §11.4 memory audit body: doc_id, asked_by, answer_kind, confidence; question+answer SHA256.
- §11.5 UI span highlight: citation char_start/end maps to rendered HTML offset.
End of TASK-KB-007 spec.