From e3eb16d0e471a20209edce89178798606217dc9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 16:54:53 +0900 Subject: [PATCH] feat(search-ui): grounded AI answer card with citation chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces /api/search/answer in the Search workspace: an answer card above the result detail with the grounded answer, provenance label, and citation chips that activate the cited email in the result list. Graceful by design: the card is an enhancement — when the answer endpoint is unavailable or returns no answer, the card hides and search behaves exactly as before (verified: all pre-existing search page tests pass unchanged with the endpoint 404ing). State updates only happen in promise callbacks (react-hooks/set-state-in-effect compliant); empty queries hide the card via a render guard. Tests: new page test renders the card (answer + citation chip + provenance) and asserts the POST body; existing 5 tests green; eslint + tsc clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017RkKdtHRLG4wSLh6PVsp8J --- frontend/src/app/search/page.test.tsx | 74 ++++++++++++++++++++ frontend/src/components/SearchLayout.tsx | 86 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/frontend/src/app/search/page.test.tsx b/frontend/src/app/search/page.test.tsx index 0daf13222..169a8ea20 100644 --- a/frontend/src/app/search/page.test.tsx +++ b/frontend/src/app/search/page.test.tsx @@ -475,4 +475,78 @@ describe("SearchPage", () => { container.querySelector('button[aria-label="맥락 검색어 지우기"]'), ).not.toBeNull(); }); + + it("renders the grounded AI answer card with citations and hides it when the endpoint is unavailable", async () => { + const fetchMock = vi.fn((...args: [RequestInfo | URL, RequestInit?]) => { + const [input] = args; + const url = String(input); + if (url.endsWith("/api/search")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + id: 7, + source_message_id: "", + subject: "Q2 출시 계획 및 우선순위 조정", + sender: "김지현 PM", + date: "2026-05-11T09:30:00Z", + snippet: "Q2 일정 요약", + thread_id: "thread-q2", + reply_count: 3, + score: 0.87, + }, + ], + }), + ); + } + if (url.endsWith("/api/search/answer")) { + return Promise.resolve( + jsonResponse({ + answer: "Q2 출시는 5월 셋째 주로 조정되었습니다.", + citations: [ + { + email_id: 7, + subject: "Q2 출시 계획 및 우선순위 조정", + sender: "김지현 PM", + snippet: "Q2 일정 요약", + }, + ], + provenance: "OpenAI (gpt-test)", + }), + ); + } + if (url.includes("/api/ontology/relationships")) { + return Promise.resolve(jsonResponse([])); + } + if (url.endsWith("/api/network/graph")) { + return Promise.resolve(jsonResponse({ nodes: [], edges: [] })); + } + return Promise.resolve(jsonResponse({}, false, 404)); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + // Answer card rendered from /api/search/answer with validated citations. + const card = container.querySelector('[data-testid="grounded-answer-card"]'); + expect(card).not.toBeNull(); + expect(card?.textContent).toContain("Q2 출시는 5월 셋째 주로 조정되었습니다."); + expect(card?.textContent).toContain("근거: Q2 출시 계획 및 우선순위 조정"); + expect(card?.textContent).toContain("OpenAI (gpt-test)"); + + const answerCall = fetchMock.mock.calls.find(([input]) => + String(input).endsWith("/api/search/answer"), + ); + expect(answerCall?.[1]?.method).toBe("POST"); + expect(answerCall?.[1]?.body).toBe( + JSON.stringify({ query: "런칭 캠페인", limit: 5 }), + ); + }); + }); diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 0441ca209..c8e5cec5f 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -43,6 +43,23 @@ type SearchResponse = { results: SearchResultItem[]; }; +type AnswerCitation = { + email_id: number; + subject: string | null; + sender: string | null; + snippet: string; +}; + +type AnswerResponse = { + answer: string | null; + citations: AnswerCitation[]; + provenance: string | null; +}; + +type AnswerState = + | { status: "idle" | "hidden" } + | { status: "done"; answer: string; citations: AnswerCitation[]; provenance: string | null }; + type SenderRelationship = { sender_email: string; parent_sender_email: string | null; @@ -322,6 +339,9 @@ export function SearchLayout() { useState("context"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [answerState, setAnswerState] = useState({ + status: "idle", + }); useEffect(() => { const trimmedQuery = submittedQuery.trim(); @@ -353,6 +373,39 @@ export function SearchLayout() { return () => controller.abort(); }, [submittedQuery]); + useEffect(() => { + const trimmedQuery = submittedQuery.trim(); + const controller = new AbortController(); + if (!trimmedQuery) return () => controller.abort(); + + apiClient + .post( + "/api/search/answer", + { query: trimmedQuery, limit: 5 }, + { signal: controller.signal }, + ) + .then((response) => { + if (controller.signal.aborted) return; + if (!response.answer) { + setAnswerState({ status: "hidden" }); + return; + } + setAnswerState({ + status: "done", + answer: response.answer, + citations: response.citations ?? [], + provenance: response.provenance, + }); + }) + .catch(() => { + // The grounded answer is an enhancement: search must not degrade + // when the endpoint is unavailable, so the card simply hides. + if (!controller.signal.aborted) setAnswerState({ status: "hidden" }); + }); + + return () => controller.abort(); + }, [submittedQuery]); + const filteredResults = useMemo(() => { if (activeFilter === "thread") return results.filter((result) => (result.reply_count ?? 1) > 1); @@ -576,6 +629,39 @@ export function SearchLayout() {
+ {answerState.status === "done" && submittedQuery.trim() ? ( +
+
+

AI 답변

+ {answerState.provenance ? ( + + {answerState.provenance} + + ) : null} +
+

+ {answerState.answer} +

+ {answerState.citations.length > 0 ? ( +
+ {answerState.citations.map((citation) => ( + + ))} +
+ ) : null} +
+ ) : null} {!activeResult ? (
결과를 선택하면 메일 스레드, 답장 추적, 발신자 관계를 함께