Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
57 changes: 57 additions & 0 deletions backend/app/post_chat_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
order by unit.post_id, unit.unit_index
Comment thread
seonghobae marked this conversation as resolved.
""",
cited_post_ids,
)
Comment thread
seonghobae marked this conversation as 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
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."""
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
60 changes: 60 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,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(<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();
expect(screen.getByText(/Image tags: screenshot, error/)).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
53 changes: 41 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.
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,42 @@ 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?.trim() ? image.caption : t("Untitled image")}
{image.extracted_text ? ` — ${image.extracted_text}` : ""}
{image.tags.length ? ` — ${t("Image tags")}: ${image.tags.join(", ")}` : ""}
</p>
))}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as 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 +4639,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 +4650,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
<small>Enterprise SSO Authentication</small>
</div>
</div>
Comment thread
seonghobae marked this conversation as 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