Skip to content
Draft
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
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
23 changes: 13 additions & 10 deletions frontend/src/app/search/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe("SearchPage", () => {
parent_sender_email: "user@naruon.ai",
source_message_id: "<q2@example.com>",
source_thread_id: "thread-q2",
relationship_type: "colleague",
relationship_type: "Colleague",
confidence_score: 0.85,
next_action: "track_reply_and_tasks",
action_reason:
Expand Down 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 @@ -315,7 +317,7 @@ describe("SearchPage", () => {
parent_sender_email: null,
source_message_id: "<capture@example.com>",
source_thread_id: "thread-capture",
relationship_type: "colleague",
relationship_type: "Colleague",
confidence_score: 0.85,
next_action: "track_reply_and_tasks",
action_reason:
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
76 changes: 75 additions & 1 deletion frontend/src/components/SearchLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ vi.mock("lucide-react", () => ({
X: () => <svg aria-hidden="true" />,
}));

import { SearchLayout } from "./SearchLayout";
import {
customerFacingRelationshipReason,
customerFacingRelationshipText,
SearchLayout,
} from "./SearchLayout";
import {
clearRecordedProductEvents,
getRecordedProductEvents,
Expand Down Expand Up @@ -185,6 +189,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 +203,68 @@ describe("SearchLayout product events", () => {
)).toBe(true);
expect(JSON.stringify(getRecordedProductEvents())).not.toContain("계약");
});

it.each([
["summarize_then_archive", "요약 후 보관합니다."],
["track_reply_and_tasks", "답장과 후속 작업을 확인합니다."],
["prepare_response_draft", "답장 초안을 준비합니다."],
["classify_sender", "발신자 관계를 확인합니다."],
])("maps production relationship action %s to customer copy", (nextAction, expectedCopy) => {
expect(customerFacingRelationshipText(nextAction, "후속 작업을 확인합니다.")).toBe(
expectedCopy,
);
});

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

it.each([
["summarize_then_archive", "핵심 내용을 확인한 뒤 정리할 수 있습니다."],
["track_reply_and_tasks", "답장 여부와 이어서 할 일을 놓치지 않도록 제안했습니다."],
["prepare_response_draft", "대화를 이어갈 답장이 필요한 관계입니다."],
["classify_sender", "알맞은 후속 행동을 정하려면 발신자 관계 확인이 필요합니다."],
["unknown_action", "선택한 원본과 발신자 관계를 바탕으로 제안했습니다."],
])("maps relationship reason for %s to customer copy", (nextAction, expectedCopy) => {
expect(customerFacingRelationshipReason(nextAction)).toBe(expectedCopy);
});

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");
});
});
75 changes: 56 additions & 19 deletions frontend/src/components/SearchLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,45 @@ 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.trim().toLowerCase()] ?? "연결 관계";
}

const CUSTOMER_FACING_RELATIONSHIP_ACTIONS: Record<string, string> = {
summarize_then_archive: "요약 후 보관합니다.",
track_reply_and_tasks: "답장과 후속 작업을 확인합니다.",
prepare_response_draft: "답장 초안을 준비합니다.",
classify_sender: "발신자 관계를 확인합니다.",
"계약 검토 담당자를 확인합니다.": "계약 검토 담당자를 확인합니다.",
"후속 작업을 확인합니다.": "후속 작업을 확인합니다.",
};

const CUSTOMER_FACING_RELATIONSHIP_REASONS: Record<string, string> = {
summarize_then_archive: "핵심 내용을 확인한 뒤 정리할 수 있습니다.",
track_reply_and_tasks: "답장 여부와 이어서 할 일을 놓치지 않도록 제안했습니다.",
prepare_response_draft: "대화를 이어갈 답장이 필요한 관계입니다.",
classify_sender: "알맞은 후속 행동을 정하려면 발신자 관계 확인이 필요합니다.",
};

export function customerFacingRelationshipText(
relationshipAction: string,
fallbackCopy: string,
) {
const normalizedAction = relationshipAction.trim();
return CUSTOMER_FACING_RELATIONSHIP_ACTIONS[normalizedAction] ?? fallbackCopy;
}

export function customerFacingRelationshipReason(relationshipAction: string) {
return CUSTOMER_FACING_RELATIONSHIP_REASONS[relationshipAction.trim()] ??
"선택한 원본과 발신자 관계를 바탕으로 제안했습니다.";
}

type SearchResponse = {
Expand Down Expand Up @@ -189,7 +227,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 +250,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,28 +293,27 @@ 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">
{relationship.action_reason}
{customerFacingRelationshipReason(relationship.next_action)}
</p>
<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 +568,7 @@ export function SearchLayout() {
setRelationshipState({
sourceKey: activeOntologySourceKey,
items: [],
error: "발신자 DAG를 불러오지 못했습니다.",
error: "발신자 관계를 불러오지 못했습니다.",
});
});

Expand Down Expand Up @@ -893,10 +930,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 +976,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 +988,7 @@ export function SearchLayout() {
aria-hidden="true"
/>
<h3 className="text-lg font-bold">
발신자 DAG (Ontology)
발신자 관계
</h3>
Comment thread
seonghobae marked this conversation as resolved.
</div>
<SenderDagPanel
Expand All @@ -977,7 +1014,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 +1036,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 +1050,7 @@ export function SearchLayout() {
className="size-4"
aria-hidden="true"
/>
{activeResult.thread_id}
메일 흐름 연결됨
</h4>
</div>
) : null}
Expand Down
21 changes: 12 additions & 9 deletions frontend/tests/e2e/dashboard-branding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1376,14 +1376,17 @@ test('renders API-backed context search sender DAG and reply tracking', async ({
expect(ontologyHeaders[headerName]).toBeUndefined();
}

await expect(page.getByRole('heading', { name: '맥락 검색' })).toBeAttached();
await expect(page.getByRole('heading', { name: '맥락 검색', exact: true })).toBeAttached();
await expect(page.getByRole('heading', { name: 'Q2 출시 계획 및 우선순위 조정' }).first()).toBeVisible();
await expect(page.getByText('thread-q2').first()).toBeVisible();
await expect(page.getByText('메일 흐름 연결됨').first()).toBeVisible();
await expect(page.getByText('답장 2건').first()).toBeVisible();
await expect(page.getByText('관계 맥락과 타임라인')).toBeVisible();
await expect(page.getByText('발신자 DAG (Ontology)')).toBeVisible();
await expect(page.getByText('track_reply_and_tasks')).toBeVisible();
await expect(page.getByText('source=<q2@example.com> / thread=thread-q2')).toBeVisible();
await expect(page.getByText('발신자 관계')).toBeVisible();
await expect(page.getByText('답장과 후속 작업을 확인합니다.')).toBeVisible();
await expect(page.getByText('답장 여부와 이어서 할 일을 놓치지 않도록 제안했습니다.')).toBeVisible();
await expect(page.getByText('Same-domain sender; preserve reply and task follow-up.')).toHaveCount(0);
await expect(page.getByText('track_reply_and_tasks')).toHaveCount(0);
await expect(page.getByText('source=<q2@example.com> / thread=thread-q2')).toHaveCount(0);
await expect(page.getByText('김지현 PM').first()).toBeVisible();
const desktopOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(desktopOverflow).toBeLessThanOrEqual(1);
Expand All @@ -1392,7 +1395,7 @@ test('renders API-backed context search sender DAG and reply tracking', async ({
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/search');
await expect(page.getByRole('heading', { name: 'Q2 출시 계획 및 우선순위 조정' }).first()).toBeVisible();
await expect(page.getByText('thread-q2').first()).toBeVisible();
await expect(page.getByText('메일 흐름 연결됨').first()).toBeVisible();
const mobileOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(mobileOverflow).toBeLessThanOrEqual(1);
await page.screenshot({ path: testInfo.outputPath('search-dag-reply-mobile.png'), fullPage: false });
Expand All @@ -1419,9 +1422,9 @@ test('renders API-backed context search sender DAG and reply tracking', async ({
});
expect(detailScrollMetrics.maxScroll).toBeGreaterThan(0);
expect(detailScrollMetrics.after).toBeGreaterThan(detailScrollMetrics.before);
await page.getByText('발신자 DAG (Ontology)').scrollIntoViewIfNeeded();
await expect(page.getByText('track_reply_and_tasks')).toBeVisible();
await page.getByText('track_reply_and_tasks').scrollIntoViewIfNeeded();
await page.getByText('발신자 관계').scrollIntoViewIfNeeded();
await expect(page.getByText('답장과 후속 작업을 확인합니다.')).toBeVisible();
await page.getByText('답장과 후속 작업을 확인합니다.').scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath('search-dag-reply-mobile-dag.png'), fullPage: false });
await page.getByText('관계 이해').scrollIntoViewIfNeeded();
await expect(page.getByText('관계 이해')).toBeVisible();
Expand Down