Skip to content
Open
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
74 changes: 74 additions & 0 deletions frontend/src/app/search/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<q2@example.com>",
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(<SearchPage />);
});
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 }),
);
});

});
86 changes: 86 additions & 0 deletions frontend/src/components/SearchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -322,6 +339,9 @@ export function SearchLayout() {
useState<DetailTab>("context");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [answerState, setAnswerState] = useState<AnswerState>({
status: "idle",
});

useEffect(() => {
const trimmedQuery = submittedQuery.trim();
Expand Down Expand Up @@ -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<AnswerResponse>(
"/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);
Expand Down Expand Up @@ -576,6 +629,39 @@ export function SearchLayout() {

<main className="flex-1 overflow-y-auto bg-background p-4 pb-[calc(6rem+env(safe-area-inset-bottom))] md:p-8">
<div className="mx-auto max-w-5xl space-y-6">
{answerState.status === "done" && submittedQuery.trim() ? (
<section
aria-label="AI 답변"
data-testid="grounded-answer-card"
className="rounded-lg border border-primary/30 bg-primary/5 p-5 shadow-sm"
>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-bold text-primary">AI 답변</h3>
{answerState.provenance ? (
<span className="text-xs text-muted-foreground">
{answerState.provenance}
</span>
) : null}
</div>
<p className="whitespace-pre-wrap text-sm leading-6">
{answerState.answer}
</p>
{answerState.citations.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{answerState.citations.map((citation) => (
<button
key={citation.email_id}
type="button"
onClick={() => setActiveResultId(citation.email_id)}
className="rounded-full border border-primary/30 bg-card px-3 py-1 text-xs font-semibold text-primary hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
근거: {citation.subject ?? `메일 #${citation.email_id}`}
</button>
))}
</div>
) : null}
</section>
) : null}
{!activeResult ? (
<div className="rounded-lg border border-border bg-card p-6 text-sm font-semibold text-muted-foreground shadow-sm">
결과를 선택하면 메일 스레드, 답장 추적, 발신자 관계를 함께
Expand Down
Loading