Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion frontend/scripts/full-product-ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,7 +1135,7 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) {
if (routeSpec.name === "search") {
await page.getByText("20B readiness result", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("tab", { name: "관계 원본", exact: true }).click();
await page.getByText("원본 메시지 필터로 관계 API를 조회합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByText("선택한 원본 메일을 기준으로 관계를 확인합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("tab", { name: "판단 보조", exact: true }).click();
await page.getByText("외부 실행은 사용자가 메일, 일정, 관계 캡처 액션을 명시적으로 선택할 때만 진행됩니다.", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("button", { name: "관계 캡처", exact: true }).click();
Expand Down
19 changes: 11 additions & 8 deletions frontend/src/app/search/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,18 @@ describe("SearchPage", () => {
await flushAsyncWork();

expect(container.textContent).toContain("Q2 출시 계획 및 우선순위 조정");
expect(container.textContent).toContain("thread-q2");
expect(container.textContent).toContain("메일 흐름 연결됨");
expect(container.textContent).not.toContain("thread-q2");
expect(container.textContent).toContain("답장 3건");
expect(container.textContent).toContain("신뢰도 87%");
expect(container.textContent).toContain("증거 바인딩");
expect(container.textContent).toContain("맥락 정보");
expect(container.textContent).toContain("메일 열기");
expect(container.textContent).toContain("관계 맥락과 타임라인");
expect(container.textContent).toContain("발신자 DAG");
expect(container.textContent).toContain("track_reply_and_tasks");
expect(container.textContent).toContain("source=<q2@example.com>");
expect(container.textContent).toContain("발신자 관계");
expect(container.textContent).toContain("후속 작업을 확인합니다.");
expect(container.textContent).not.toContain("track_reply_and_tasks");
expect(container.textContent).not.toContain("<q2@example.com>");
expect(container.querySelector("#search-detail-tab-context")?.getAttribute("aria-controls")).toBe("search-detail-panel-context");
expect(container.querySelector("#search-detail-panel-context")?.getAttribute("role")).toBe("tabpanel");

Expand Down Expand Up @@ -376,10 +378,11 @@ describe("SearchPage", () => {
expect(captureHeaders[headerName]).toBeUndefined();
}
expect(container.textContent).toContain("minjae@naruon.ai");
expect(container.textContent).toContain("track_reply_and_tasks");
expect(container.textContent).toContain(
"source=<capture@example.com> / thread=thread-capture",
);
expect(container.textContent).toContain("후속 작업을 확인합니다.");
expect(container.textContent).toContain("업무 관계");
expect(container.textContent).not.toContain("track_reply_and_tasks");
expect(container.textContent).not.toContain("<capture@example.com>");
expect(container.textContent).not.toContain("thread-capture");
});

it("renders a fail-closed error state when the search API rejects the query", async () => {
Expand Down
55 changes: 54 additions & 1 deletion frontend/src/components/SearchLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ vi.mock("lucide-react", () => ({
X: () => <svg aria-hidden="true" />,
}));

import { SearchLayout } from "./SearchLayout";
import { customerFacingRelationshipText, SearchLayout } from "./SearchLayout";
import {
clearRecordedProductEvents,
getRecordedProductEvents,
Expand Down Expand Up @@ -185,6 +185,12 @@ describe("SearchLayout product events", () => {
));
await waitForCondition(() => getRecordedProductEvents().some((event) => event.name === "context_search_result_action_created"));

expect(container.textContent).toContain("발신자 맥락");
expect(container.textContent).toContain("다음 행동");
expect(container.textContent).not.toContain("sender_context");
expect(container.textContent).not.toContain("thread-contract");
expect(container.textContent).not.toContain("<contract-source@example.com>");

expect(getRecordedProductEvents().some((event) =>
event.name === "context_search_result_action_created" &&
event.payload.result_id === 202 &&
Expand All @@ -193,4 +199,51 @@ describe("SearchLayout product events", () => {
)).toBe(true);
expect(JSON.stringify(getRecordedProductEvents())).not.toContain("계약");
});

it.each([
"track_reply_and_tasks",
"reply.follow-up",
"reply/follow_up",
"reply follow_up",
"회신_대기",
"",
])("fails closed for non-catalog relationship action %s", (nextAction) => {
expect(customerFacingRelationshipText(nextAction, "후속 작업을 확인합니다.")).toBe(
"후속 작업을 확인합니다.",
);
});

it("shows customer-facing copy when relationship loading fails", async () => {
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/search")) {
return Promise.resolve(jsonResponse({ results: [{
id: 1,
source_message_id: "source@example.com",
subject: "Search result",
sender: "pm@example.com",
date: "2026-05-20T09:00:00Z",
snippet: "Result",
thread_id: "thread-1",
reply_count: 0,
score: 0.9,
}] }));
}
if (url.includes("/api/ontology/relationships?")) {
return Promise.reject(new Error("relationship service unavailable"));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}));

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<SearchLayout />);
});
await waitForCondition(() =>
container?.textContent?.includes("발신자 관계를 불러오지 못했습니다.") ?? false,
);
expect(container.textContent).not.toContain("DAG");
});
});
56 changes: 38 additions & 18 deletions frontend/src/components/SearchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,28 @@ const EVIDENCE_KIND_LABELS: Record<string, string> = {

function evidenceKindLabel(kind: string | null | undefined) {
if (!kind || kind === "email_body") return null;
return EVIDENCE_KIND_LABELS[kind] ?? kind;
return EVIDENCE_KIND_LABELS[kind] ?? "연결 근거";
}

const RELATIONSHIP_TYPE_LABELS: Record<string, string> = {
sender_context: "발신자 맥락",
colleague: "업무 관계",
};

function relationshipTypeLabel(relationshipType: string) {
return RELATIONSHIP_TYPE_LABELS[relationshipType] ?? "연결 관계";
Comment thread
seonghobae marked this conversation as resolved.
Resolved
}

const CUSTOMER_FACING_RELATIONSHIP_ACTIONS = new Set([
"계약 검토 담당자를 확인합니다.",
"후속 작업을 확인합니다.",
]);
Comment thread
seonghobae marked this conversation as resolved.
Resolved

export function customerFacingRelationshipText(value: string, fallback: string) {
const normalizedValue = value.trim();
return CUSTOMER_FACING_RELATIONSHIP_ACTIONS.has(normalizedValue)
? normalizedValue
: fallback;
}

type SearchResponse = {
Expand Down Expand Up @@ -189,7 +210,7 @@ function SenderDagPanel({
aria-live="polite"
className="rounded-lg border border-border bg-background p-4 text-sm font-semibold text-muted-foreground"
>
발신자 DAG를 불러오는 중입니다.
발신자 관계를 불러오는 중입니다.
</div>
);
}
Expand All @@ -212,7 +233,7 @@ function SenderDagPanel({
{canCapture ? (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs">
원본 메일의 sender/thread 근거로 관계와 다음 액션을 캡처합니다.
선택한 원본 메일을 기준으로 관계와 다음 행동을 확인합니다.
</p>
<button
type="button"
Expand Down Expand Up @@ -255,16 +276,19 @@ function SenderDagPanel({
</p>
</div>
<div className="shrink-0 rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary">
{relationship.relationship_type} ·{" "}
{relationshipTypeLabel(relationship.relationship_type)} ·{" "}
{(relationship.confidence_score * 100).toFixed(0)}%
</div>
</div>
<div className="mt-4 grid gap-2 text-xs font-semibold text-muted-foreground sm:grid-cols-2">
<div className="rounded border border-border bg-card px-3 py-2">
<p className="break-all text-foreground">
{relationship.next_action}
{customerFacingRelationshipText(
relationship.next_action,
"후속 작업을 확인합니다.",
)}
</p>
<p className="mt-1">Agent next action</p>
<p className="mt-1">다음 행동</p>
</div>
<div className="rounded border border-border bg-card px-3 py-2">
<p className="break-words text-foreground">
Expand All @@ -273,10 +297,6 @@ function SenderDagPanel({
<p className="mt-1">판단 근거</p>
</div>
</div>
<p className="mt-3 break-words rounded bg-secondary/40 px-3 py-2 text-[11px] font-semibold text-muted-foreground">
source={relationship.source_message_id ?? "global"} / thread=
{relationship.source_thread_id ?? "none"}
</p>
</article>
))}
</div>
Expand Down Expand Up @@ -531,7 +551,7 @@ export function SearchLayout() {
setRelationshipState({
sourceKey: activeOntologySourceKey,
items: [],
error: "발신자 DAG를 불러오지 못했습니다.",
error: "발신자 관계를 불러오지 못했습니다.",
});
});

Expand Down Expand Up @@ -893,10 +913,10 @@ export function SearchLayout() {
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-black text-primary">증거 바인딩</p>
<p className="mt-2 font-semibold text-foreground">
{activeResult.source_message_id ? "원본 메시지 필터로 관계 API를 조회합니다." : "원본 메시지 필터가 없는 결과입니다."}
{activeResult.source_message_id ? "선택한 원본 메일을 기준으로 관계를 확인합니다." : "연결할 원본 메일이 없는 결과입니다."}
</p>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
관계 조회는 선택된 맥락 검색 결과의 source/thread 범위 안에서만 수행됩니다.
다른 메일의 관계가 섞이지 않도록 선택한 결과 안에서만 확인합니다.
</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
Expand Down Expand Up @@ -939,7 +959,7 @@ export function SearchLayout() {
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-bold">관계 맥락과 타임라인</h2>
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary">
source/thread API 연결
원본 근거 연결
</span>
</div>

Expand All @@ -951,7 +971,7 @@ export function SearchLayout() {
aria-hidden="true"
/>
<h3 className="text-lg font-bold">
발신자 DAG (Ontology)
발신자 관계
</h3>
Comment thread
seonghobae marked this conversation as resolved.
Resolved
</div>
<SenderDagPanel
Expand All @@ -977,7 +997,7 @@ export function SearchLayout() {
className="size-5 text-primary"
aria-hidden="true"
/>
<h3 className="text-lg font-bold">타임라인 (Timeline)</h3>
<h3 className="text-lg font-bold">활동 흐름</h3>
</div>
<div className="relative ml-3 space-y-6 border-l-2 border-border">
<div className="relative pl-6">
Expand All @@ -999,7 +1019,7 @@ export function SearchLayout() {
</h4>
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CheckCircle2 className="size-3" aria-hidden="true" />
thread reply_count={activeResult.reply_count ?? 1}
답장 {activeResult.reply_count ?? 1}건
</p>
</div>
{activeResult.thread_id ? (
Expand All @@ -1013,7 +1033,7 @@ export function SearchLayout() {
className="size-4"
aria-hidden="true"
/>
{activeResult.thread_id}
메일 흐름 연결됨
</h4>
</div>
) : null}
Expand Down
Loading