diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..d80c811fd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -167,6 +167,7 @@ ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph from backend.app.post_chat_ingestion import ( + cited_post_images, fetch_persisted_chat, fetch_persisted_chats, find_linked_post_ids, @@ -2643,6 +2644,7 @@ async def ask_agent( "cited_posts": [], "source_post_ids": [], "cited_post_evidence": [], + "cited_post_images": [], "next_action": "No authorized source posts are available for this question.", } try: @@ -2653,11 +2655,14 @@ async def ask_agent( f"Ask Agent is unavailable: {exc}", ) from exc cited_ids = list(answer.cited_post_ids) + async with pool.acquire() as conn: + images = await cited_post_images(conn, cited_ids) return { "answer_text": answer.answer_text, "cited_post_ids": cited_ids, "cited_posts": cited_post_summaries(sources, cited_ids), "cited_post_evidence": cited_post_evidence(sources, cited_ids), + "cited_post_images": images, "source_post_ids": [source.post_id for source in sources], } diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py index 71c0f2053..8494eead7 100644 --- a/backend/app/post_chat_ingestion.py +++ b/backend/app/post_chat_ingestion.py @@ -553,6 +553,63 @@ async def gather_global_chat_sources( return sources +async def cited_post_images( + conn: asyncpg.Connection, + cited_post_ids: list[str], +) -> list[dict[str, Any]]: + """Persisted image evidence (caption/OCR/tags) for already-cited posts. + + Global Ask cites a post's *text*; when that post's evidence actually + came from an embedded picture (a screenshot, a diagram), the reader has + no way to tell the difference -- the answer just reads as a text claim. + This surfaces the same persisted, never-raw-bytes image description + `GET /api/posts/{id}/content` already renders (`post_content_image`, + ADR-tracked alongside its region locations), scoped to the posts this + answer already cited. + + No ABAC re-check here: `cited_post_ids` only ever contains ids drawn + from `gather_global_chat_sources`'s already-authorized source set, the + same trust boundary `cited_post_evidence`/`cited_post_summaries` rely + on (`lineageweave.post_chat`). + """ + if not cited_post_ids: + return [] + rows = await conn.fetch( + """ + select unit.post_id, unit.unit_index, image.mime_type, + image.description_status_code, image.extracted_text, image.caption, + coalesce( + array_agg(tag.tag_text order by tag.tag_text) + filter (where tag.tag_text is not null), + '{}'::text[] + ) as tags + from post_content_unit unit + join post_content_image image + on image.post_content_unit_id = unit.post_content_unit_id + left join post_content_image_tag tag + on tag.post_content_image_id = image.post_content_image_id + where unit.post_id = any($1::uuid[]) + group by unit.post_id, unit.unit_index, image.mime_type, + image.description_status_code, image.extracted_text, image.caption + order by unit.post_id, unit.unit_index + """, + cited_post_ids, + ) + return [ + { + "post_id": str(row["post_id"]), + "unit_index": row["unit_index"], + "mime_type": row["mime_type"], + "status_code": row["description_status_code"], + "extracted_text": row["extracted_text"], + "caption": row["caption"], + "tags": list(row["tags"] or []), + } + for row in rows + if row["caption"] or row["extracted_text"] or row["tags"] + ] + + @dataclass(frozen=True) class SeededChat: """Synthetic Q&A for a reconstruct/calendar/demo fixture -- not an LLM.""" diff --git a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md index 717954045..c22317628 100644 --- a/docs/doctoring/DESIGN_TOKEN_REFERENCES.md +++ b/docs/doctoring/DESIGN_TOKEN_REFERENCES.md @@ -1,8 +1,8 @@ # Design-token and Storybook traceability **Status:** Active PR evidence; not protected-main truth until merge. -**Scope:** `frontend/src/styles/tokens.css`, repeated chip/close modules, and -the Storybook inventory. +**Scope:** `frontend/src/styles/tokens.css`, repeated chip/close modules, the +Ask evidence dialog, and the Storybook inventory. ## Standards mapped to implementation @@ -11,6 +11,7 @@ the Storybook inventory. | W3C Design Tokens Format Module 2025.10 | Name color, space, type, and radius once; consume those names from repeated objects. | `frontend/src/styles/tokens.css` defines `--color-*`, `--space-*`, `--size-control-min`, `--radius-chip`, `--radius-control`, `--radius-panel`, and `--font-*`. `CitationChip`, `PopupCloseButton`, `CutoffKnownBody`, and `LineageEntityPicker` read those names through `App.css`. | | Storybook for React & Vite | Catalog repeated controls so a buyer can try the next click without reading `App.tsx`. | `frontend/src/components/*.stories.tsx` and `docs/storybook-inventory.md`. | | WCAG 2.2 | Give interactive controls programmatic names and announce an asynchronous evidence failure instead of leaving a perpetual loading state. | Component interaction tests exercise the named controls; `EvidencePanel` exposes its terminal failure with `role="alert"`. This is targeted evidence, not a claim of complete WCAG conformance. | +| WAI-ARIA APG Dialog (Modal) Pattern | A surface marked `aria-modal="true"` must behave modally: focus moves inside, `Tab` and `Shift+Tab` remain inside, and `Escape` closes the layer. | `AskEvidenceLayerPopup` moves initial focus inside the dialog and explicitly cycles forward/backward keyboard focus between its actionable controls; component tests cover both focus-loop directions and Escape. Its evidence lists use dialog-specific accessible labels so assistive technology can distinguish the modal list from the still-rendered inline answer. | ## APA 7th references @@ -23,3 +24,7 @@ https://storybook.js.org/docs/get-started/frameworks/react-vite World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (n.d.). *Dialog (modal) pattern*. WAI-ARIA +Authoring Practices Guide. Retrieved August 22, 2026, from +https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 28c59bd48..a92dccf02 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,6 +6,7 @@ buyer-facing control you can click before changing product CSS. | Story | Buyer next action | Token / module | |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | +| `Evidence/AskEvidenceLayerPopup` | Inspect one citation without leaving the answer; close to continue the answer or open the complete source post. Stories cover text/image evidence, no-evidence, missing OCR, null caption, and blank-caption fallback states. | shared popup tokens through `App.css`, `PopupCloseButton`, `AskEvidenceLayerPopup` | | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..8bf829643 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -90,6 +90,7 @@ describe("App, authenticated", () => { customerEntityHierarchy?: boolean; staleSummary?: boolean; contentAfterSummary?: boolean; + askImageCitation?: boolean; }): ReturnType & { releaseMe: () => void } { const statusLabel: Record = { open: "Open", @@ -1560,6 +1561,19 @@ describe("App, authenticated", () => { ], }, ], + cited_post_images: options?.askImageCitation + ? [ + { + post_id: "post-2", + unit_index: 1, + mime_type: "image/png", + status_code: "described", + extracted_text: "Error code 500 on checkout", + caption: "Screenshot of the checkout error", + tags: ["screenshot", "error"], + }, + ] + : [], source_post_ids: ["post-1", "post-2"], }), ); @@ -1674,6 +1688,52 @@ describe("App, authenticated", () => { expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument(); }); + it("cites a cited post's persisted image evidence under that post", async () => { + stubBackend({ askImageCitation: true }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByText(/Image evidence: Screenshot of the checkout error/)).toBeInTheDocument(); + expect(screen.getByText(/Error code 500 on checkout/)).toBeInTheDocument(); + expect(screen.getByText(/Image tags: screenshot, error/)).toBeInTheDocument(); + }); + + it("shows no image evidence line when the answer cites no image", async () => { + stubBackend(); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + expect(await screen.findByRole("list", { name: "Evidence facts" })).toBeInTheDocument(); + expect(screen.queryByText(/Image evidence:/)).not.toBeInTheDocument(); + }); + + it("opens a cited post's evidence in a Layer Popup without leaving the answer", async () => { + stubBackend({ askImageCitation: true }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Ask Agent" })); + await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Which project?"); + await userEvent.click(screen.getByRole("button", { name: "Ask" })); + + await userEvent.click(await screen.findByRole("button", { name: "View evidence" })); + + const dialog = await screen.findByRole("dialog", { name: "Linked post" }); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText(/project: Semantic project/)).toBeInTheDocument(); + expect(within(dialog).getByText("Screenshot of the checkout error")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Close evidence panel" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + // The answer itself is still on screen -- the layer never navigated away. + expect(screen.getByRole("button", { name: "View evidence" })).toBeInTheDocument(); + }); + it("labels the Customer Master entity level and Keymen side, never the raw lookup code", async () => { // Live UI finding (2026-08-19): read_customer_master() skipped the // common_lookup_value join both endpoints elsewhere already use, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..69d27b3eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -85,7 +85,9 @@ import { import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; +import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PopupCloseButton } from "./components/PopupCloseButton"; +import { chatEvidenceKindLabel } from "./evidenceKindLabels"; import { BuyerNav, type BuyerDestination } from "./components/BuyerNav"; import { LineageDag } from "./LineageDag"; import { PostBody } from "./PostBody"; @@ -777,16 +779,6 @@ function projectProvenanceLabel(provenance: string): string { return t(PROJECT_PROVENANCE_LABELS[provenance] ?? "Recorded evidence"); } -const CHAT_EVIDENCE_KIND_LABELS: Record = { - source_field: "Source field hint", - semantic_project: "Semantic project", - semantic_role: "Semantic role", - semantic_keyman: "Semantic Keyman", -}; - -function chatEvidenceKindLabel(kind: string): string { - return t(CHAT_EVIDENCE_KIND_LABELS[kind] ?? "Evidence"); -} const VERIFICATION_BADGE: Record = { verify_pending: "Not yet checked", @@ -4477,6 +4469,7 @@ function AskAgentPanel({ const [answer, setAnswer] = useState(null); const [error, setError] = useState(null); const [asking, setAsking] = useState(false); + const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null); async function handleAsk() { const normalized = question.trim(); @@ -4525,6 +4518,13 @@ function AskAgentPanel({ + {answer.cited_post_evidence?.find((item) => item.post_id === post.post_id)?.facts.length ? (
    {answer.cited_post_evidence @@ -4537,6 +4537,18 @@ function AskAgentPanel({ ))}
) : null} + {answer.cited_post_images + ?.filter((image) => image.post_id === post.post_id) + .map((image) => ( +

+ {t("Image evidence")}: {image.caption?.trim() ? image.caption : t("Untitled image")} + {image.extracted_text ? ` — ${image.extracted_text}` : ""} + {image.tags.length ? ` — ${t("Image tags")}: ${image.tags.join(", ")}` : ""} +

+ ))} ))} @@ -4544,6 +4556,23 @@ function AskAgentPanel({ )} )} + {evidenceLayerPostId && answer ? ( + post.post_id === evidenceLayerPostId)?.post_title ?? + evidenceLayerPostId + } + facts={ + answer.cited_post_evidence?.find((item) => item.post_id === evidenceLayerPostId)?.facts ?? [] + } + images={ + answer.cited_post_images?.filter((image) => image.post_id === evidenceLayerPostId) ?? [] + } + onClose={() => setEvidenceLayerPostId(null)} + onOpenPost={onOpenPost} + /> + ) : null} ); } @@ -4610,7 +4639,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
- {destination === "admin" ? : null}