diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs index 008d61267..581629a3a 100644 --- a/frontend/scripts/full-product-ui-smoke.mjs +++ b/frontend/scripts/full-product-ui-smoke.mjs @@ -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(); diff --git a/frontend/src/app/search/page.test.tsx b/frontend/src/app/search/page.test.tsx index 8374b0953..048053000 100644 --- a/frontend/src/app/search/page.test.tsx +++ b/frontend/src/app/search/page.test.tsx @@ -121,7 +121,7 @@ describe("SearchPage", () => { parent_sender_email: "user@naruon.ai", source_message_id: "", source_thread_id: "thread-q2", - relationship_type: "colleague", + relationship_type: "Colleague", confidence_score: 0.85, next_action: "track_reply_and_tasks", action_reason: @@ -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="); + expect(container.textContent).toContain("발신자 관계"); + expect(container.textContent).toContain("답장과 후속 작업을 확인합니다."); + expect(container.textContent).not.toContain("track_reply_and_tasks"); + expect(container.textContent).not.toContain(""); 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"); @@ -315,7 +317,7 @@ describe("SearchPage", () => { parent_sender_email: null, source_message_id: "", source_thread_id: "thread-capture", - relationship_type: "colleague", + relationship_type: "Colleague", confidence_score: 0.85, next_action: "track_reply_and_tasks", action_reason: @@ -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= / 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(""); + expect(container.textContent).not.toContain("thread-capture"); }); it("renders a fail-closed error state when the search API rejects the query", async () => { diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx index f9a47b66c..11cc81a19 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -30,7 +30,11 @@ vi.mock("lucide-react", () => ({ X: () => , })); -import { SearchLayout } from "./SearchLayout"; +import { + customerFacingRelationshipReason, + customerFacingRelationshipText, + SearchLayout, +} from "./SearchLayout"; import { clearRecordedProductEvents, getRecordedProductEvents, @@ -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(""); + expect(getRecordedProductEvents().some((event) => event.name === "context_search_result_action_created" && event.payload.result_id === 202 && @@ -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(); + }); + await waitForCondition(() => + container?.textContent?.includes("발신자 관계를 불러오지 못했습니다.") ?? false, + ); + expect(container.textContent).not.toContain("DAG"); + }); }); diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 92f16b184..5cf5ad72f 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -56,7 +56,45 @@ const EVIDENCE_KIND_LABELS: Record = { 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 = { + sender_context: "발신자 맥락", + colleague: "업무 관계", +}; + +function relationshipTypeLabel(relationshipType: string) { + return RELATIONSHIP_TYPE_LABELS[relationshipType.trim().toLowerCase()] ?? "연결 관계"; +} + +const CUSTOMER_FACING_RELATIONSHIP_ACTIONS: Record = { + summarize_then_archive: "요약 후 보관합니다.", + track_reply_and_tasks: "답장과 후속 작업을 확인합니다.", + prepare_response_draft: "답장 초안을 준비합니다.", + classify_sender: "발신자 관계를 확인합니다.", + "계약 검토 담당자를 확인합니다.": "계약 검토 담당자를 확인합니다.", + "후속 작업을 확인합니다.": "후속 작업을 확인합니다.", +}; + +const CUSTOMER_FACING_RELATIONSHIP_REASONS: Record = { + 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 = { @@ -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를 불러오는 중입니다. + 발신자 관계를 불러오는 중입니다. ); } @@ -212,7 +250,7 @@ function SenderDagPanel({ {canCapture ? (

- 원본 메일의 sender/thread 근거로 관계와 다음 액션을 캡처합니다. + 선택한 원본 메일을 기준으로 관계와 다음 행동을 확인합니다.

- {relationship.relationship_type} ·{" "} + {relationshipTypeLabel(relationship.relationship_type)} ·{" "} {(relationship.confidence_score * 100).toFixed(0)}%

- {relationship.next_action} + {customerFacingRelationshipText( + relationship.next_action, + "후속 작업을 확인합니다.", + )}

-

Agent next action

+

다음 행동

- {relationship.action_reason} + {customerFacingRelationshipReason(relationship.next_action)}

판단 근거

-

- source={relationship.source_message_id ?? "global"} / thread= - {relationship.source_thread_id ?? "none"} -

))} @@ -531,7 +568,7 @@ export function SearchLayout() { setRelationshipState({ sourceKey: activeOntologySourceKey, items: [], - error: "발신자 DAG를 불러오지 못했습니다.", + error: "발신자 관계를 불러오지 못했습니다.", }); }); @@ -893,10 +930,10 @@ export function SearchLayout() {

증거 바인딩

- {activeResult.source_message_id ? "원본 메시지 필터로 관계 API를 조회합니다." : "원본 메시지 필터가 없는 결과입니다."} + {activeResult.source_message_id ? "선택한 원본 메일을 기준으로 관계를 확인합니다." : "연결할 원본 메일이 없는 결과입니다."}

- 관계 조회는 선택된 맥락 검색 결과의 source/thread 범위 안에서만 수행됩니다. + 다른 메일의 관계가 섞이지 않도록 선택한 결과 안에서만 확인합니다.

@@ -939,7 +976,7 @@ export function SearchLayout() {

관계 맥락과 타임라인

- source/thread API 연결 + 원본 근거 연결
@@ -951,7 +988,7 @@ export function SearchLayout() { aria-hidden="true" />

- 발신자 DAG (Ontology) + 발신자 관계