From c25033d4230c64b7dad823355a714c105bee9dfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 14:54:31 +0900 Subject: [PATCH 1/9] fix(search): hide internal relationship plumbing Signed-off-by: Seongho Bae --- AGENTS.md | 6 +++ frontend/scripts/full-product-ui-smoke.mjs | 2 +- frontend/src/app/search/page.test.tsx | 19 ++++---- frontend/src/components/SearchLayout.test.tsx | 6 +++ frontend/src/components/SearchLayout.tsx | 46 ++++++++++++------- 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9104dd1f4..b8bc7f467 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -713,6 +713,12 @@ in this repo. - **Collaboration**: Respect other agents' concurrent work; do not overwrite or dismiss unfamiliar changes. - **Subagent Delegation**: Actively delegate tasks to Subagents. - **UI/Browser Testing**: Use a real browser for testing (do not rely on assumptions). +- **Search presentation boundary**: Search relationship APIs may retain source + message/thread identifiers and machine relationship/action codes for scoped + requests, state, and analytics, but the Search UI must render + customer-facing labels and outcomes only. Unknown enum-like values need a + safe product-language fallback; never expose raw identifiers, `snake_case` + action codes, API names, or ontology plumbing. - **Strict Errors**: Treat `Timeout`, `Fatal`, `Warn`, and `Denied` outputs as hard failures. - **Goal**: Actively manage tasks to ensure open PR counts converge to 0. 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..281904aa6 100644 --- a/frontend/src/app/search/page.test.tsx +++ b/frontend/src/app/search/page.test.tsx @@ -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"); @@ -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..7bfd2a857 100644 --- a/frontend/src/components/SearchLayout.test.tsx +++ b/frontend/src/components/SearchLayout.test.tsx @@ -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(""); + expect(getRecordedProductEvents().some((event) => event.name === "context_search_result_action_created" && event.payload.result_id === 202 && diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx index 92f16b184..116d278f7 100644 --- a/frontend/src/components/SearchLayout.tsx +++ b/frontend/src/components/SearchLayout.tsx @@ -56,7 +56,20 @@ 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] ?? "연결 관계"; +} + +function customerFacingRelationshipText(value: string, fallback: string) { + return /^[a-z0-9_:-]+$/i.test(value.trim()) ? fallback : value; } type SearchResponse = { @@ -189,7 +202,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 +225,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

+

다음 행동

@@ -273,10 +289,6 @@ function SenderDagPanel({

판단 근거

-

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

))} @@ -893,10 +905,10 @@ export function SearchLayout() {

증거 바인딩

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

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

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

관계 맥락과 타임라인

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

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