Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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],
}

Expand Down
56 changes: 56 additions & 0 deletions backend/app/post_chat_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,62 @@ 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
Comment thread
seonghobae marked this conversation as resolved.
Resolved
Comment thread
seonghobae marked this conversation as resolved.
Resolved
order by unit.post_id, unit.unit_index
Comment thread
seonghobae marked this conversation as resolved.
Resolved
""",
cited_post_ids,
)
Comment thread
seonghobae marked this conversation as resolved.
Resolved
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
]


@dataclass(frozen=True)
class SeededChat:
"""Synthetic Q&A for a reconstruct/calendar/demo fixture -- not an LLM."""
Expand Down
9 changes: 7 additions & 2 deletions docs/doctoring/DESIGN_TOKEN_REFERENCES.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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/
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe("App, authenticated", () => {
customerEntityHierarchy?: boolean;
staleSummary?: boolean;
contentAfterSummary?: boolean;
askImageCitation?: boolean;
}): ReturnType<typeof vi.fn> & { releaseMe: () => void } {
const statusLabel: Record<string, string> = {
open: "Open",
Expand Down Expand Up @@ -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"],
}),
);
Expand Down Expand Up @@ -1674,6 +1688,51 @@ 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(<App />);
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();
});

it("shows no image evidence line when the answer cites no image", async () => {
stubBackend();
render(<App />);
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(<App />);
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,
Expand Down
52 changes: 40 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -777,16 +779,6 @@ function projectProvenanceLabel(provenance: string): string {
return t(PROJECT_PROVENANCE_LABELS[provenance] ?? "Recorded evidence");
}

const CHAT_EVIDENCE_KIND_LABELS: Record<string, string> = {
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<string, string> = {
verify_pending: "Not yet checked",
Expand Down Expand Up @@ -4477,6 +4469,7 @@ function AskAgentPanel({
const [answer, setAnswer] = useState<AskAgentResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [asking, setAsking] = useState(false);
const [evidenceLayerPostId, setEvidenceLayerPostId] = useState<string | null>(null);

async function handleAsk() {
const normalized = question.trim();
Comment thread
seonghobae marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -4525,6 +4518,13 @@ function AskAgentPanel({
<button className="post-list-item" onClick={() => onOpenPost(post.post_id)}>
<strong>{post.post_title}</strong>
</button>
<button
type="button"
className="citation-chip"
onClick={() => setEvidenceLayerPostId(post.post_id)}
>
{t("View evidence")}
</button>
{answer.cited_post_evidence?.find((item) => item.post_id === post.post_id)?.facts.length ? (
<ul className="post-evidence-list" aria-label={t("Evidence facts")}>
{answer.cited_post_evidence
Expand All @@ -4537,13 +4537,41 @@ function AskAgentPanel({
))}
</ul>
) : null}
{answer.cited_post_images
?.filter((image) => image.post_id === post.post_id)
.map((image) => (
<p
key={`${image.post_id}:${image.unit_index}`}
className="post-meta ask-agent-image-citation"
>
{t("Image evidence")}: {image.caption ?? t("Untitled image")}
Comment thread
github-actions[bot] marked this conversation as resolved.
Resolved
{image.extracted_text ? ` — ${image.extracted_text}` : ""}
</p>
))}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
seonghobae marked this conversation as resolved.
Resolved
Comment thread
seonghobae marked this conversation as resolved.
Resolved
</li>
))}
</ul>
</>
)}
</section>
)}
{evidenceLayerPostId && answer ? (
<AskEvidenceLayerPopup
postId={evidenceLayerPostId}
postTitle={
answer.cited_posts?.find((post) => 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}
</section>
);
}
Expand Down Expand Up @@ -4610,7 +4638,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
</div>
<div className="login-controls">
<button className="btn-primary" onClick={() => {
const returnUrl = window.location.pathname + window.location.search;
const returnUrl = returnUrlFromLocation();
rememberOidcReturnUrl(returnUrl);
void auth.signinRedirect({ state: { returnUrl } });
}}>
{t("Log in")}
Expand All @@ -4620,7 +4649,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
<small>Enterprise SSO Authentication</small>
</div>
</div>
Comment thread
seonghobae marked this conversation as resolved.
Resolved
{destination === "admin" ? <AdminPanel currentBrandName={brandName} onBrandNameChange={setBrandName} accessToken={accessToken} /> : null}
</main>
<footer className="app-footer" role="contentinfo">
<div className="app-footer-title">
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,22 @@ export interface ChatHistory {
exchanges: ChatExchange[];
}

export interface CitedPostImage {
post_id: string;
unit_index: number;
mime_type: string;
status_code: string;
extracted_text: string | null;
caption: string | null;
tags: string[];
}

export interface AskAgentResponse {
answer_text: string;
cited_post_ids: string[];
cited_posts?: CitedPostRef[];
cited_post_evidence?: CitedPostEvidence[];
cited_post_images?: CitedPostImage[];
source_post_ids: string[];
next_action?: string;
}
Expand Down
Loading
Loading