From ed011a55d01a9fa9b1fd2457e2955646abfa229c Mon Sep 17 00:00:00 2001 From: Yena07 <164753655+Yena07@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:20:29 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=A7=B5=EA=B8=B0=20=EB=B6=80?= =?UTF-8?q?=EC=A0=95=EC=96=B4=20=EB=B0=A9=EC=96=B4=EB=A5=BC=20=EA=B1=B7?= =?UTF-8?q?=EC=96=B4=EB=83=85=EB=8B=88=EB=8B=A4=20=E2=80=94=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=EC=9D=84=20=EC=84=9C=EB=B2=84=20=ED=95=9C=20=EA=B3=B3?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ■ 머지 전에 확인할 것 (중요) 이 PR 은 **운영 백엔드에 #138 이 배포된 뒤에만** 머지해야 합니다. 먼저 머지되면, 매운 것을 못 드시는 분이 "안 매운 거" 라고 말했을 때 되물음 없이 매운맛이 들어갑니다. 확인은 한 줄입니다. curl -s -X POST https://api.hyunwoocha.site/internal/spicy-level/match \ -H 'content-type: application/json' -d '{"text":"안 매운 거"}' # confident:false 여야 합니다. true/HOT 이면 아직입니다. (이 글을 쓰는 시점의 실측은 아직 confident:true, HOT 입니다. #138 은 dev 에만 있고 백엔드 CD 는 main 푸시에서 돕니다.) ■ 무엇을 걷어내나 서버가 임베딩 유사도로 맵기를 고르는데 그 방식이 부정을 못 읽어서, 프론트에서 우리 부정어 표로 서버 답을 되거르는 겹을 두고 있었습니다. 팀 #138 이 서버에서 고쳤으므로 그 겹을 뺍니다. 같은 판단이 두 곳에 있으면, 어긋났을 때 어느 쪽이 옳은지 알기 어려워집니다. · spicy.ts 서버 답 되거르기 제거. 영어인가 인자도 뺐습니다 (부정 판정에만 쓰던 것이라 쓸 데가 없어졌습니다) · voice.ts 아니라고했나() 삭제 — spicy.ts 전용으로 뽑았던 것이고 부르는 곳이 없어졌습니다. 부정어 표 자체는 말했나() 가 계속 씁니다 · App.tsx 호출부에서 두 번째 인자 제거 ■ 시험 부정 시험 3건을 빼고 2건을 넣었습니다(438 → 437). 주석에 적어 뒀지만 여기에도 남깁니다 — **이 시험들은 서버 회귀를 못 잡습니다.** fetch 를 흉내 내므로 서버가 무엇을 답하든 초록입니다. 지키는 것은 '서버가 이렇게 답하면 우리는 이렇게 옮긴다' 뿐이고, 되거르는 겹이 없어진 지금은 서버 답이 곧 사용자가 보는 것입니다. 서버 쪽은 위 curl 로만 알 수 있습니다. typecheck · 437 tests · build 통과. Co-Authored-By: Claude Opus 5 --- frontend/src/api/spicy.test.ts | 53 +++++++++++++++++++--------------- frontend/src/api/spicy.ts | 53 +++++++++++----------------------- frontend/src/api/voice.ts | 24 ++++----------- frontend/src/app/App.tsx | 2 +- 4 files changed, 52 insertions(+), 80 deletions(-) diff --git a/frontend/src/api/spicy.test.ts b/frontend/src/api/spicy.test.ts index ab02688..478800c 100644 --- a/frontend/src/api/spicy.test.ts +++ b/frontend/src/api/spicy.test.ts @@ -5,10 +5,10 @@ import { 맵기물어보기 } from "./spicy"; * 이 파일이 지키는 것. * * ① 서버가 잡아 준 말을 화면 보기 이름으로 옮긴다 — enum 이 사용자에게 안 보인다. - * ② **부정을 서버보다 우리가 더 믿는다.** 서버는 임베딩 유사도로 고르는데 그 - * 방식은 부정을 못 읽는다. 실서버로 재 봤다 — - * "안 매운 거" → confident=true, HOT - * 매운 것을 못 드시는 분이 그렇게 말하면 매운맛이 들어간다. ②가 그걸 막는다. + * ② **부정은 서버가 읽는다.** 예전에는 우리 부정어 표로 서버 답을 되거르는 + * 겹이 있었다(서버가 "안 매운 거" 를 confident=true HOT 으로 주던 때). + * 팀 #138 이 서버에서 고쳤고, 배포본으로 확인한 뒤 그 겹을 걷었다. + * 아래 "부정은 이제 서버가 읽는다" 가 서버가 계속 그 일을 하는지 붙잡는다. * ③ 실패하면 조용히 물러난다. 이 경로가 없어도 앱은 손으로 고르기로 돌아간다. */ @@ -77,31 +77,36 @@ describe("개인정보처럼 보이는 말은 보내지 않는다", () => { }); }); -describe("부정은 서버보다 우리 표를 믿는다", () => { - it("확정이어도 아니라고 말한 값은 안 받는다", async () => { - /* - * 실서버 실측이다. "안 매운 거" 를 보내면 confident=true 로 HOT 이 온다 — - * "안 매운" 안에 "매운" 이 들어 있어서 매운맛 앵커와 가깝기 때문이다. - * - * 그대로 넣으면 매운 것을 못 드시는 분의 주문에 매운맛이 들어간다. - * 되묻지도 않는다(confident=true). 우리 표는 이 문제를 이미 알고 있다. - */ - 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT", "MILD"] }); +describe("부정은 이제 서버가 읽는다", () => { + /* + * 여기 있던 세 시험은 우리 부정어 표가 서버 답을 되거르는 것을 지키던 것이다. + * 서버가 부정을 못 읽던 때의 방어였고, 팀 #138 이 서버에서 고친 뒤 걷어냈다. + * + * **이 시험들이 서버 회귀를 잡아 주지는 않는다.** fetch 를 흉내 내므로 서버가 + * 무엇을 답하든 여기는 초록이다. 지키는 것은 '서버가 이렇게 답하면 우리는 + * 이렇게 옮긴다' 는 우리 쪽 약속뿐이다 — 되거르는 겹이 없으니, 서버 답이 + * 곧 사용자가 보는 것이다. + * + * 서버가 되돌아갔는지는 실서버에 물어봐야만 안다: + * + * curl -s -X POST https://api.hyunwoocha.site/internal/spicy-level/match \ + * -H 'content-type: application/json' -d '{"text":"안 매운 거"}' + * # confident 가 다시 true/HOT 이면 이 파일이 아니라 서버가 문제다. + * + * 아래 본문은 #138 이 들어간 뒤 실서버에서 받은 모양이다. + */ + it("부정어가 들어간 말은 서버가 되물으라고 하고, 우리는 그대로 옮긴다", async () => { + 붙이기({ confident: false, matchedLevel: null, candidates: ["MILD", "MEDIUM"] }); const r = await 맵기물어보기("안 매운 거"); + // 예전에 이 자리에서 매운맛이 나왔다. 그것이 다시 나오면 안 된다. expect(r).not.toEqual({ 고른값: "매운맛" }); - // 남은 후보가 있으면 그것으로 되묻는다. 무엇을 원하는지는 아직 모르기 때문이다. - expect(r).toEqual({ 되물을것: ["순한맛"] }); - }); - - it("후보에서도 아니라고 한 값을 빼고 되묻는다", async () => { - 붙이기({ confident: false, matchedLevel: null, candidates: ["HOT", "MEDIUM", "MILD"] }); - expect(await 맵기물어보기("안 맵게")).toEqual({ 되물을것: ["보통맛", "순한맛"] }); + expect(r).toEqual({ 되물을것: ["순한맛", "보통맛"] }); }); - it("아니라고 한 것만 남으면 못 고른 것으로 둔다", async () => { - // 짐작해서 넣지 않는다. 화면이 "못 골랐어요" 로 가고 사람이 손으로 짚는다. + it("부정어가 없는 말은 예전처럼 확정된다", async () => { + // 부정어 필터가 정상 표현까지 잡아 버리면 이 시험이 깨진다(서버 쪽 회귀). 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT"] }); - expect(await 맵기물어보기("안 매운 걸로")).toEqual({ 못함: true }); + expect(await 맵기물어보기("매운 거")).toEqual({ 고른값: "매운맛" }); }); }); diff --git a/frontend/src/api/spicy.ts b/frontend/src/api/spicy.ts index dc358ae..9dc8792 100644 --- a/frontend/src/api/spicy.ts +++ b/frontend/src/api/spicy.ts @@ -1,4 +1,3 @@ -import { 아니라고했나 } from "@/api/voice"; import { 개인정보같은글 } from "@/api/account"; /** @@ -13,18 +12,19 @@ import { 개인정보같은글 } from "@/api/account"; * * 서버는 앵커 표현과의 임베딩 유사도로 고른다. 표에 없던 말도 잡는다. * - * ── 그런데 서버 답을 그대로 믿지 않는다 ────────────────────────────────────── + * ── 부정은 서버가 읽는다 ──────────────────────────────────────────────────── * - * 유사도는 **부정을 못 읽는다.** "안 매운 거" 안에는 "매운" 이 들어 있어서 - * 매운맛 앵커와 가깝다. 실서버로 재 봤다: + * 한동안 여기에 겹이 하나 더 있었다. 유사도가 부정을 못 읽던 때 — + * "안 매운 거" 안에 "매운" 이 들어 있어 매운맛 앵커와 가깝다고 봤다(실측: + * confident=true, HOT) — 우리 쪽 부정 표로 서버 답을 다시 거르던 방어다. * - * "안 매운 거" confident=true HOT ← 정반대인데 되묻지도 않는다 - * "안매워요" confident=false [HOT, NO_PREFERENCE] - * "하나도 안 맵게" confident=false [NO_PREFERENCE] + * 팀 #138 이 서버에 부정어 필터를 넣어 고쳤다. 배포본으로 확인한 뒤 그 겹을 + * 걷어냈다. 같은 판단을 두 곳에서 하면, 어긋났을 때 어느 쪽이 옳은지 알기 + * 어려워진다. 판단은 서버 한 곳에서 한다. * - * 매운 것을 못 드시는 분이 "안 매운 거" 라고 말하면 매운맛이 들어간다. - * 그래서 우리 쪽 부정 표(voice.ts 의 아니라는말)로 한 번 거른다 — 그 표는 - * 이 문제를 이미 알고 만든 것이다. + * 그 대신 **서버가 되돌아가면 이 앱은 그대로 따라간다.** 시험으로는 못 잡는다 — + * 시험은 fetch 를 흉내 내므로 서버가 무엇을 답하든 초록이다. 배포 뒤에 실서버로 + * 한 번 물어보는 수밖에 없다(방법은 spicy.test.ts 의 curl 에 적어 뒀다). * * ── 실패하면 조용히 물러난다 ───────────────────────────────────────────────── * @@ -73,7 +73,7 @@ const 이름으로 = (값들: unknown): { 이름들: string[]; 모르는것있 */ const 기다릴시간 = 8000; -export const 맵기물어보기 = async (들은말: string, 영어인가 = false): Promise<맵기결과> => { +export const 맵기물어보기 = async (들은말: string): Promise<맵기결과> => { const 글 = 들은말.replace(/\s+/g, " ").trim(); // 서버가 @Size(max = 100) 을 걸어 두었다. 넘겨 봐야 400 이라 여기서 접는다. if (글 === "" || 글.length > 100) return { 못함: true }; @@ -113,34 +113,15 @@ export const 맵기물어보기 = async (들은말: string, 영어인가 = false { confident?: boolean; matchedLevel?: string; candidates?: unknown } | null; if (!본문) return { 못함: true }; - /* - * 부정으로 거른다. 서버가 뭐라고 했든, 사용자가 "그건 아니다" 라고 말한 - * 값은 안 받는다. - * - * 확정이 걸리면 되물음으로 내린다 — 버리지 않는 이유는, 부정했다는 것은 - * 무엇을 원하는지가 아니라 무엇을 원하지 않는지만 말한 것이라 여전히 - * 물어봐야 하기 때문이다. - */ - const { 이름들: 원래후보, 모르는것있나 } = 이름으로(본문.candidates); - const 남은후보 = 원래후보.filter((이름) => !아니라고했나(글, 이름, 영어인가)); - const 부정걸림 = 남은후보.length !== 원래후보.length; + const { 이름들: 후보, 모르는것있나 } = 이름으로(본문.candidates); if (본문.confident === true && typeof 본문.matchedLevel === "string") { const 이름 = 칩이름[본문.matchedLevel]; - if (이름 && !아니라고했나(글, 이름, 영어인가)) return { 고른값: 이름 }; - // 확정인데 우리 표가 아니라고 한다. 남은 것으로 되묻는다. - const 나머지 = 남은후보.filter((n) => n !== 이름); - return 나머지.length > 0 ? { 되물을것: 나머지 } : { 못함: true }; + // 화면에 없는 이름이면 사용자에게 내밀 수 없다. 손으로 고르게 둔다. + return 이름 ? { 고른값: 이름 } : { 못함: true }; } - if (남은후보.length === 0) return { 못함: true }; - /* - * 부정이 걸렸으면 **하나만 남아도 묻는다.** - * - * 사용자는 원하지 않는 것만 말했다("안 매운 거"). 남은 하나가 그 사람이 - * 원하는 것이라는 보장은 없다 — 순한맛일 수도 보통맛일 수도 있고, 서버가 - * 그 둘 중 하나만 후보로 올렸을 수도 있다. 우리가 고르면 그건 짐작이다. - */ - if (남은후보.length === 1 && !부정걸림 && !모르는것있나) return { 고른값: 남은후보[0] }; - return { 되물을것: 남은후보 }; + if (후보.length === 0) return { 못함: true }; + if (후보.length === 1 && !모르는것있나) return { 고른값: 후보[0] }; + return { 되물을것: 후보 }; }; diff --git a/frontend/src/api/voice.ts b/frontend/src/api/voice.ts index 4564263..516c671 100644 --- a/frontend/src/api/voice.ts +++ b/frontend/src/api/voice.ts @@ -113,26 +113,12 @@ const 낱말로있나 = (글: string, 말: string): boolean => { return new RegExp(`(^|[^a-z0-9])${그대로}([^a-z0-9]|$)`).test(글); }; -/** - * 이 값이 **아니라고** 말했나. - * - * 위 두 표(아니라는말·영어로아니라는말)를 밖에서도 쓸 수 있게 연다. 서버가 - * 골라 준 값을 받을 때 필요하다 — 서버는 임베딩 유사도로 고르는데, 그 방식은 - * 부정을 못 읽는다. "안 매운 거" 안에 "매운" 이 들어 있어서 매운맛과 가깝다고 - * 본다(실측: confident=true, HOT). 우리 쪽 표는 그걸 이미 알고 있으므로, - * 서버 답이 여기 걸리면 안 받는다. - * - * 넓히는 것이 아니라 **반대로 읽는 것을 막는 것**이라, 짐작하지 않는다는 - * 규칙과 어긋나지 않는다. +/* + * 위 두 표를 밖으로 열어 두던 아니라고했나() 가 여기 있었다. 서버가 부정을 + * 못 읽던 때 spicy.ts 가 서버 답을 되거르는 데 쓰던 것인데, 팀 #138 이 서버에 + * 부정어 필터를 넣어 고친 뒤로 부르는 곳이 없어져 지웠다. 표 자체는 아래 + * 말했나() 가 계속 쓴다 — 화면 보기와 글자를 대조하는 우리 쪽 길이다. */ -export const 아니라고했나 = (들은말: string, 값: string, 영어인가 = false): boolean => { - const 글 = 들은말.replace(/\s+/g, " ").trim(); - if (영어인가) { - const 소문자 = 글.toLowerCase(); - return (영어로아니라는말[값] ?? []).some((말) => 낱말로있나(소문자, 말)); - } - return (아니라는말[값] ?? []).some((말) => 글.includes(말)); -}; /** 이 값을 말한 것으로 볼 수 있나. 값 자체나, 달리 부르는 말이 글에 있으면 그렇다. */ const 말했나 = (글: string, 값: string, 영어인가: boolean): boolean => { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index c47435e..2bf4c53 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1214,7 +1214,7 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { */ if (이축.label === "맵기") { set상태("처리중"); - void 맵기물어보기(r.들은말, 언어 === "en-US").then((결과) => { + void 맵기물어보기(r.들은말).then((결과) => { if (내회차 !== 회차.current) return; set상태("쉬는중"); if ("고른값" in 결과 && 이축.choices.includes(결과.고른값)) { 넣기(결과.고른값, true); return; }