From cdf01f582bcc0d0afbe70ab78bb481e4828a794c Mon Sep 17 00:00:00 2001 From: plind <59729252+plind-junior@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:45:33 -0400 Subject: [PATCH 01/12] Demo update (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transcript): locate raw Claude session files by id * feat(transcript): parse Claude JSONL into normalized blocks * feat(transcript): orchestrate load with observation fallback * feat(transcript): expose kb.session_transcript across all surfaces * feat(webapp): transcript client types and fetch * feat(webapp): thinking, diff, and code block renderers * feat(webapp): tool block with per-tool rendering and diffs * feat(webapp): sessions tab with full transcript viewer * feat(transcript): parse Codex rollouts into normalized blocks * test(webapp): cover degraded render and subagent drill-down * test(webapp): e2e smoke for the sessions transcript tab * docs(transcript): session transcript viewer design and plan * refactor(webapp): merge session transcript into Review, drop Sessions tab * docs(transcript): note the merge of the viewer into Review * feat(activity): expose kb.activity audit buckets across all surfaces one pass over the audit log returning per-day counts (with proposal/decision breakdowns), an hour-of-week matrix, and actor/event histograms — the data a dashboard needs that kb.audit's tail-limited raw events and kb.stats' review aggregates don't cover. windowed in viewer-local calendar days so the oldest heatmap cell is never partially counted (days=0 for all-time, negatives rejected). local bucketing prefers an iana tz name (dst-correct across the year) and falls back to a clamped fixed utc offset. scope-filtered through the same viewer context as kb.audit so multi-project kbs don't leak actor names or activity timing across project boundaries. registered at all four sites (mcp tool, jsonl handler, capabilities methods, vouch activity cli) plus trust read methods. * feat(webapp): dashboard view of kb activity, landing on / new /dashboard view driven by kb.activity: a 12-month activity calendar (ember heat ramp themed for dark and light via --heat-* css vars), last-30-days bars, an hour-of-week heatmap, and top-actor / event-mix bar lists, with stat tiles fed by kb.status and kb.stats. the calendar fetches 371 days so its oldest drawn column always sits inside the server window, sends the browser's iana timezone for dst-correct bucketing, and degrades cleanly: endpoints that don't advertise kb.activity get an upgrade hint, empty audit logs get an empty state, and a malformed stats payload no longer crashes the view. / now redirects to the dashboard instead of chat and the tab leads the sidebar; the e2e smoke flow clicks through to chat accordingly. * feat(console): add vouch-ui console, demo setup, and web integration add web-based console with dashboard view of kb activity and session management. integrate vouch-ui webapp, docker-compose demo environment, and llm-backed actions (compile + summarize). includes hatch build script and full test coverage. * fix(review): remove remaining conflict markers cleaned up merge conflict markers that were preventing the component from loading. * feat(synthesize): llm answer backend grounded in pages, live in chat implement the reserved llm=true path of kb.synthesize: retrieval picks kb pages and approved claims, the deployment-configured compile.llm_cmd drafts the prose, and code verifies every [id] citation against the offered sources — invented ids are stripped, and a draft left with no verifiable citation returns an empty answer instead of a guess. the wire shape is unchanged plus additive pages and _meta.synthesis_backend fields. the console chat now asks with llm: true and falls back to deterministic claim synthesis when no llm_cmd is configured; page citations open the page drawer and llm answers carry a badge. cli mirror: vouch synthesize --llm. the mcp tool gains the llm param; the jsonl/http surface already forwarded it. --- CHANGELOG.md | 12 ++ src/vouch/cli.py | 11 +- src/vouch/llm_draft.py | 17 +++ src/vouch/server.py | 14 +- src/vouch/synthesize.py | 213 +++++++++++++++++++++++++++-- tests/test_synthesize.py | 91 +++++++++++- webapp/src/lib/types.ts | 4 +- webapp/src/views/ChatView.test.tsx | 56 ++++++++ webapp/src/views/ChatView.tsx | 36 ++++- 9 files changed, 427 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 707ebe2d..d907b836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,18 @@ All notable changes to vouch are documented here. Format follows filtered like `kb.audit`. - console Dashboard view: 12-month activity calendar, last-30-days bars, hour-of-week heatmap, top actors and event mix, driven by `kb.activity`. +- `kb.synthesize` llm backend: `llm=true` drafts the answer with the + deployment-configured `compile.llm_cmd`, grounded in retrieved kb pages + and approved claims. code still verifies every `[id]` citation against + the offered sources — invented ids are stripped, and a draft left with no + verifiable citation returns an empty answer rather than a guess. the wire + shape is unchanged plus additive `pages` and `_meta.synthesis_backend` + fields. cli mirror: `vouch synthesize --llm`; the jsonl/http surface + already forwarded the flag. +- console Chat: llm answers activated — the chat asks `kb.synthesize` with + `llm: true` and falls back to deterministic claim synthesis when no + `compile.llm_cmd` is configured. page citations open the page drawer, and + llm answers carry an `llm` badge next to the confidence grade. - codex: wired `UserPromptSubmit` to `vouch context-hook` for the first time, reusing the existing command unmodified — codex's hook payload/response shape matches claude-code's exactly. (#425) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5ea1460e..abe36889 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2642,12 +2642,17 @@ def context_hook() -> None: @click.argument("query") @click.option("--depth", default=3, show_default=True, type=int) @click.option("--max-chars", default=4000, show_default=True, type=int) -def synthesize(query: str, depth: int, max_chars: int) -> None: - """Answer a query from approved claims only, with inline citations.""" +@click.option( + "--llm", "use_llm", is_flag=True, + help="Draft the answer with the configured compile.llm_cmd, grounded in " + "pages and approved claims (citations still verified mechanically).", +) +def synthesize(query: str, depth: int, max_chars: int, use_llm: bool) -> None: + """Answer a query from the KB, with inline citations.""" store = _load_store() with _cli_errors(): result = synth.synthesize( - store, query=query, depth=depth, max_chars=max_chars, + store, query=query, depth=depth, max_chars=max_chars, llm=use_llm, ) _emit_json(result) diff --git a/src/vouch/llm_draft.py b/src/vouch/llm_draft.py index cba54cf3..3c3f1943 100644 --- a/src/vouch/llm_draft.py +++ b/src/vouch/llm_draft.py @@ -56,6 +56,23 @@ def run_llm( return proc.stdout +def parse_object(raw: str, *, noun: str = "answer") -> dict[str, Any]: + """Parse LLM stdout into a single JSON object. + + The object-shaped sibling of `parse_drafts`: same fence stripping, same + LLMDraftError contract, for callers whose LLM returns one object (e.g. + synthesize's `{"answer": …, "gaps": […]}`) rather than an array of drafts. + """ + text = _FENCE_RE.sub("", raw.strip()).strip() + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise LLMDraftError(f"{noun} output is not valid JSON: {e}") from e + if not isinstance(data, dict): + raise LLMDraftError(f"{noun} output must be a single JSON object") + return data + + def parse_drafts(raw: str, *, noun: str = "page") -> list[dict[str, Any]]: """Parse LLM stdout into a list of draft dicts. diff --git a/src/vouch/server.py b/src/vouch/server.py index c395b6e1..7f4bfde5 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -346,14 +346,20 @@ def kb_synthesize( query: str, depth: int = 3, max_chars: int = 4000, + llm: bool = False, ) -> dict[str, Any]: - """Answer a query from approved claims only, with inline `[claim_id]` - citations, an explicit gaps block, and a synthesis_confidence grade. + """Answer a query from the review-gated KB, with inline `[id]` citations, + an explicit gaps block, and a synthesis_confidence grade. Unlike `kb_context` (a ranked list), this returns prose where every - sentence is traceable to an approved claim. + sentence is traceable to a source. Deterministic by default (approved + claims only); `llm=True` drafts the answer with the deployment-configured + LLM (compile.llm_cmd) grounded in pages and approved claims — citations + are still verified mechanically, and the call is synchronous. """ - return synthesize(_store(), query=query, depth=depth, max_chars=max_chars) + return synthesize( + _store(), query=query, depth=depth, max_chars=max_chars, llm=llm, + ) @mcp.tool() diff --git a/src/vouch/synthesize.py b/src/vouch/synthesize.py index a45eee73..20577b85 100644 --- a/src/vouch/synthesize.py +++ b/src/vouch/synthesize.py @@ -7,17 +7,25 @@ no claim for in an explicit `gaps` block, and grades its own confidence from the lifecycle status of the claims it cited. -The synthesis is deterministic in v1 — there is no LLM in the loop. The -`llm` flag is reserved so the wire shape is stable when an opt-in generative -backend lands; passing `llm=True` raises rather than silently degrading. +The default synthesis is deterministic — no LLM in the loop. `llm=True` +opts into the generative backend: the deployment-configured LLM command +(``compile.llm_cmd``, the same one the wiki compiler uses) drafts prose +grounded in retrieved *pages* and approved claims. The division of labor +stays llm-wiki's: the LLM writes, code verifies — every ``[id]`` citation +in the draft must resolve to a source that was actually offered, or it is +stripped; an answer left with no verifiable citation is returned empty +rather than uncited. Requesting `llm=True` without a configured command +raises rather than silently degrading. """ from __future__ import annotations +import re from typing import Any, Literal +from . import llm_draft from .context import build_context_pack -from .models import Claim, ClaimStatus +from .models import Claim, ClaimStatus, Page, PageStatus from .storage import ArtifactNotFoundError, KBStore Confidence = Literal["high", "medium", "low"] @@ -71,6 +79,183 @@ def _confidence(statuses: list[ClaimStatus]) -> Confidence: return "medium" +# LLM-backend grounding budgets. Pages are the product (llm-wiki): they get +# the deep budget; claims ground the sentences pages haven't compiled yet. +_LLM_PAGE_LIMIT = 6 +_LLM_CLAIM_LIMIT = 12 +_LLM_PAGE_CHARS = 4000 + +# Bracketed tokens in the draft. Anything bracketed that is not an offered +# source id is stripped — the model must not mint provenance. +_MARKER_RE = re.compile(r"\s*\[([^\[\]]+)\]") + + +def _llm_grounding( + store: KBStore, query: str, depth: int +) -> tuple[list[Page], list[Claim]]: + """Retrieve the pages and approved claims the LLM may cite. + + Retrieval-first via the context pack; when the index surfaces no page for + the query, fall back to the most recently updated pages so the chat still + checks the wiki on small or sparsely-indexed KBs. + """ + pack = build_context_pack(store, query=query, limit=max(depth * 4, 12)) + items = pack["items"] if isinstance(pack, dict) else pack.items + page_ids: list[str] = [] + claim_ids: list[str] = [] + for item in items: + kind = item["type"] if isinstance(item, dict) else item.type + iid = item["id"] if isinstance(item, dict) else item.id + bucket = page_ids if kind == "page" else claim_ids if kind == "claim" else None + if bucket is not None and iid not in bucket: + bucket.append(iid) + + pages: list[Page] = [] + for pid in page_ids: + if len(pages) >= _LLM_PAGE_LIMIT: + break + try: + page = store.get_page(pid) + except ArtifactNotFoundError: + continue + if page.status != PageStatus.ARCHIVED: + pages.append(page) + if not pages: + live = [p for p in store.list_pages() if p.status != PageStatus.ARCHIVED] + live.sort(key=lambda p: p.updated_at, reverse=True) + pages = live[:_LLM_PAGE_LIMIT] + + claims: list[Claim] = [] + for cid in claim_ids: + if len(claims) >= _LLM_CLAIM_LIMIT: + break + try: + claims.append(store.get_claim(cid)) + except ArtifactNotFoundError: + continue + return pages, claims + + +def _llm_prompt( + query: str, pages: list[Page], claims: list[Claim], max_chars: int +) -> str: + lines = [ + "You answer a question from a review-gated knowledge base.", + "Use ONLY the sources below — never outside knowledge.", + "After every sentence, cite the id of the supporting source in square " + "brackets, e.g. [some-id]. Only ids listed below count as citations.", + "If part of the question is not covered by the sources, do not guess — " + "name that topic in gaps instead.", + f"Keep the answer under {max_chars} characters.", + "Output exactly one JSON object and nothing else, no code fence:", + '{"answer": "…", "gaps": ["uncovered topic", "…"]}', + "", + f"Question: {query}", + "", + "Pages:", + ] + for page in pages: + body = page.body.strip() + if len(body) > _LLM_PAGE_CHARS: + body = body[:_LLM_PAGE_CHARS] + " …" + lines.append(f"[{page.id}] {page.title}\n{body}\n") + if not pages: + lines.append("(none)") + lines.append("Claims:") + for claim in claims: + lines.append(f"[{claim.id}] ({claim.status.value}) {claim.text}") + if not claims: + lines.append("(none)") + return "\n".join(lines) + + +def _llm_synthesize( + store: KBStore, *, query: str, depth: int, max_chars: int +) -> dict[str, Any]: + # The LLM command is deployment config shared with the wiki compiler — + # one knob (compile.llm_cmd) turns on every generative feature. + from .compile import load_config + + cfg = load_config(store) + if not cfg.llm_cmd: + raise ValueError( + "llm synthesis is not configured — set compile.llm_cmd in " + '.vouch/config.yaml, e.g.\ncompile:\n llm_cmd: "claude -p --model sonnet"' + ) + + pages, claims = _llm_grounding(store, query, depth) + if not pages and not claims: + return { + "query": query, + "answer": "", + "claims": [], + "pages": [], + "gaps": _salient_terms(query), + "_meta": { + "synthesis_confidence": _confidence([]), + "synthesis_backend": "llm", + }, + } + + prompt = _llm_prompt(query, pages, claims, max_chars) + try: + raw = llm_draft.run_llm( + cfg.llm_cmd, prompt, + timeout_seconds=cfg.timeout_seconds, label="compile.llm_cmd", + ) + data = llm_draft.parse_object(raw, noun="synthesis") + except llm_draft.LLMDraftError as e: + raise ValueError(str(e)) from e + + answer = str(data.get("answer") or "").strip() + gaps = [str(g) for g in data.get("gaps") or [] if str(g).strip()] + + # Code verifies what the model drafted: strip any bracketed token that is + # not an offered source id, then truncate to budget on a citation boundary + # so no dangling half-citation survives. + offered = {p.id for p in pages} | {c.id for c in claims} + dropped: list[str] = [] + + def _keep(m: re.Match[str]) -> str: + if m.group(1) in offered: + return m.group(0) + dropped.append(m.group(1)) + return "" + + answer = _MARKER_RE.sub(_keep, answer) + answer = re.sub(r" +([.,;:])", r"\1", re.sub(r" {2,}", " ", answer)).strip() + if len(answer) > max_chars: + cut = answer.rfind("]", 0, max_chars) + answer = answer[: cut + 1] if cut > 0 else answer[:max_chars] + + cited = [m.group(1) for m in _MARKER_RE.finditer(answer)] + cited = list(dict.fromkeys(cited)) + if not cited: + # an uncited draft is a guess — the KB stays silent instead + answer = "" + gaps = gaps or _salient_terms(query) + + claim_by_id = {c.id: c for c in claims} + cited_claims = [cid for cid in cited if cid in claim_by_id] + cited_pages = [cid for cid in cited if cid not in claim_by_id] + meta: dict[str, Any] = { + "synthesis_confidence": _confidence( + [claim_by_id[cid].status for cid in cited_claims] + ), + "synthesis_backend": "llm", + } + if dropped: + meta["dropped_citations"] = dropped + return { + "query": query, + "answer": answer, + "claims": cited_claims, + "pages": cited_pages, + "gaps": gaps, + "_meta": meta, + } + + def synthesize( store: KBStore, *, @@ -79,16 +264,18 @@ def synthesize( max_chars: int = 4000, llm: bool = False, ) -> dict[str, Any]: - """Answer `query` from approved claims only, with inline citations. + """Answer `query` from the review-gated KB, with inline citations. Returns a dict with `query`, `answer` (citation-bearing prose, possibly - empty), `claims` (the cited claim ids), `gaps` (query topics no approved - claim covered) and `_meta.synthesis_confidence`. + empty), `claims` (the cited claim ids), `pages` (cited page ids — always + empty on the deterministic path), `gaps` (query topics no source covered) + and `_meta.synthesis_confidence`. With `llm=True` the answer is drafted + by the deployment-configured LLM grounded in pages and approved claims; + every citation is still verified mechanically. """ if llm: - raise ValueError( - "llm synthesis backend not configured; " - "deterministic synthesis is the default" + return _llm_synthesize( + store, query=query, depth=depth, max_chars=max_chars, ) pack = build_context_pack(store, query=query, limit=depth) @@ -135,6 +322,10 @@ def synthesize( "query": query, "answer": answer, "claims": cited, + "pages": [], "gaps": gaps, - "_meta": {"synthesis_confidence": _confidence(statuses)}, + "_meta": { + "synthesis_confidence": _confidence(statuses), + "synthesis_backend": "deterministic", + }, } diff --git a/tests/test_synthesize.py b/tests/test_synthesize.py index 9d406467..f2dec904 100644 --- a/tests/test_synthesize.py +++ b/tests/test_synthesize.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import re from pathlib import Path @@ -9,7 +10,7 @@ from vouch import capabilities, health, synthesize from vouch.jsonl_server import HANDLERS, handle_request -from vouch.models import Claim, ClaimStatus +from vouch.models import Claim, ClaimStatus, Page, PageStatus from vouch.storage import KBStore _CITE = re.compile(r"\[([^\[\]]+)\]") @@ -99,11 +100,95 @@ def test_confidence_reflects_claim_status(store: KBStore) -> None: assert contested["_meta"]["synthesis_confidence"] == "low" -def test_llm_flag_is_reserved(store: KBStore) -> None: - with pytest.raises(ValueError, match="llm synthesis backend not configured"): +def test_llm_without_config_raises(store: KBStore) -> None: + with pytest.raises(ValueError, match="llm synthesis is not configured"): synthesize.synthesize(store, query="auth", llm=True) +# --- the llm backend -------------------------------------------------------- + + +def _wire_llm(store: KBStore, tmp_path: Path, payload: object) -> None: + """Point compile.llm_cmd at a stub that emits `payload` as JSON.""" + out = tmp_path / "answer.json" + out.write_text(json.dumps(payload), encoding="utf-8") + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + f'\ncompile:\n llm_cmd: "cat {out}"\n', + encoding="utf-8", + ) + + +def _auth_page(store: KBStore) -> str: + page = Page( + id="auth-overview", title="Auth Overview", + body="Access tokens are short-lived JWTs; refresh tokens rotate.", + status=PageStatus.ACTIVE, + ) + store.put_page(page) + health.rebuild_index(store) + return page.id + + +def test_llm_synthesize_cites_pages_and_strips_invented_ids( + store: KBStore, tmp_path: Path, +) -> None: + _auth_kb(store) + pid = _auth_page(store) + _wire_llm(store, tmp_path, { + "answer": f"Auth uses short-lived JWTs [{pid}]. " + "Refresh tokens rotate on every use [c-auth-2]. " + "Billing rounds half-up [invented-id].", + "gaps": ["billing"], + }) + result = synthesize.synthesize(store, query="auth tokens", llm=True) + assert result["pages"] == [pid] + assert result["claims"] == ["c-auth-2"] + assert "[invented-id]" not in result["answer"] + assert "billing" in result["gaps"] + assert result["_meta"]["synthesis_backend"] == "llm" + assert result["_meta"]["dropped_citations"] == ["invented-id"] + assert result["_meta"]["synthesis_confidence"] == "high" + + +def test_llm_uncited_draft_stays_silent(store: KBStore, tmp_path: Path) -> None: + _auth_page(store) + _wire_llm(store, tmp_path, {"answer": "Auth is fine, trust me.", "gaps": []}) + result = synthesize.synthesize(store, query="auth", llm=True) + assert result["answer"] == "" + assert result["claims"] == [] + assert result["pages"] == [] + assert result["gaps"] + + +def test_llm_unusable_output_raises(store: KBStore, tmp_path: Path) -> None: + _auth_page(store) + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + '\ncompile:\n llm_cmd: "echo not json"\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="not valid JSON"): + synthesize.synthesize(store, query="auth", llm=True) + + +def test_jsonl_synthesize_llm_passthrough( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + pid = _auth_page(store) + _wire_llm(store, tmp_path, { + "answer": f"Access tokens are short-lived JWTs [{pid}].", "gaps": [], + }) + monkeypatch.chdir(store.root) + resp = handle_request({ + "id": "s2", "method": "kb.synthesize", + "params": {"query": "auth tokens", "llm": True}, + }) + assert resp["ok"] + assert resp["result"]["pages"] == [pid] + assert resp["result"]["_meta"]["synthesis_backend"] == "llm" + + def test_capabilities_lists_synthesize() -> None: assert "kb.synthesize" in capabilities.capabilities().methods assert "kb.synthesize" in HANDLERS diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index ae3bd673..b13ae54d 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -18,8 +18,10 @@ export interface SynthesizeResult { query: string answer: string claims: string[] + /** Cited page ids (llm backend only; absent on older servers). */ + pages?: string[] gaps: string[] - _meta?: { synthesis_confidence?: Confidence } + _meta?: { synthesis_confidence?: Confidence; synthesis_backend?: string } } export interface SearchHit { diff --git a/webapp/src/views/ChatView.test.tsx b/webapp/src/views/ChatView.test.tsx index 76493da0..8026b556 100644 --- a/webapp/src/views/ChatView.test.tsx +++ b/webapp/src/views/ChatView.test.tsx @@ -75,10 +75,66 @@ test('submits a query to kb.synthesize and renders the cited answer', async () = expect(rpc).toHaveBeenCalledWith( expect.objectContaining({ endpoint: TEST_ENDPOINT }), 'kb.synthesize', + { query: 'what does the vouch http server bind', llm: true }, + ) +}) + +test('falls back to deterministic synthesis when the llm is not configured', async () => { + vi.mocked(rpc).mockImplementation(async (_c, method, params) => { + if (method !== 'kb.synthesize') throw new Error(`unexpected ${method}`) + if ((params as { llm?: boolean }).llm) { + throw new VouchRpcError( + 'invalid_params', + 'llm synthesis is not configured — set compile.llm_cmd in .vouch/config.yaml', + ) + } + return ANSWER + }) + renderWithProviders() + await ask('what does the vouch http server bind') + expect(await screen.findByText(/binds 127\.0\.0\.1:8731 by default/)).toBeInTheDocument() + expect(rpc).toHaveBeenCalledWith( + expect.anything(), + 'kb.synthesize', { query: 'what does the vouch http server bind' }, ) }) +test('a broken llm surfaces as an error instead of silently degrading', async () => { + vi.mocked(rpc).mockRejectedValue( + new VouchRpcError('invalid_params', 'compile.llm_cmd failed (1): boom'), + ) + renderWithProviders() + await ask('bind') + expect((await screen.findAllByText(/llm_cmd failed/)).length).toBeGreaterThan(0) + expect(rpc).toHaveBeenCalledTimes(1) +}) + +test('llm answers badge the backend and open page citations as pages', async () => { + const llmAnswer = { + query: 'how does auth work', + answer: 'Access tokens are short-lived JWTs [auth-overview].', + claims: [], + pages: ['auth-overview'], + gaps: [], + _meta: { synthesis_confidence: 'medium' as const, synthesis_backend: 'llm' }, + } + vi.mocked(rpc).mockImplementation(async (_c, method) => { + if (method === 'kb.synthesize') return llmAnswer + if (method === 'kb.read_page') + return { id: 'auth-overview', title: 'Auth Overview', body: 'JWTs.', type: 'concept', status: 'active' } + throw new Error(`unexpected ${method}`) + }) + renderWithProviders() + await ask('how does auth work') + expect(await screen.findByTestId('synthesis-backend')).toHaveTextContent('llm') + await userEvent.click(screen.getByRole('button', { name: 'auth-overview' })) + expect(await screen.findByTestId('drawer')).toBeInTheDocument() + await waitFor(() => + expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.read_page', { page_id: 'auth-overview' }), + ) +}) + test('clicking a citation chip opens the claim drawer', async () => { vi.mocked(rpc).mockImplementation(async (_c, method) => { if (method === 'kb.synthesize') return ANSWER diff --git a/webapp/src/views/ChatView.tsx b/webapp/src/views/ChatView.tsx index cf91d9d8..be88efa9 100644 --- a/webapp/src/views/ChatView.tsx +++ b/webapp/src/views/ChatView.tsx @@ -51,7 +51,15 @@ function ConfidenceBadge({ level }: { level?: Confidence }) { ) } -function AnswerBody({ result, onCite }: { result: SynthesizeResult; onCite: (id: string) => void }) { +function AnswerBody({ + result, + onCite, +}: { + result: SynthesizeResult + onCite: (kind: 'claim' | 'page', id: string) => void +}) { + const citeKind = (id: string): 'claim' | 'page' => + result.pages?.includes(id) ? 'page' : 'claim' if (result.answer === '') { return (
@@ -78,7 +86,7 @@ function AnswerBody({ result, onCite }: { result: SynthesizeResult; onCite: (id: ) : (