From 983847afefeb42c7ab73347278af8a11f072568d 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 1/3] =?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; } From e64616845794ab504add253e3dd58dcb6a5f7bdd Mon Sep 17 00:00:00 2001 From: Yena07 <164753655+Yena07@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:56:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=ED=99=95=EC=8B=A0=20=EB=AA=BB=20=ED=95=9C=20=EB=8B=B5=EC=9D=80?= =?UTF-8?q?=20=ED=9B=84=EB=B3=B4=EA=B0=80=20=ED=95=98=EB=82=98=EC=97=AC?= =?UTF-8?q?=EB=8F=84=20=EB=90=98=EB=AC=BB=EC=8A=B5=EB=8B=88=EB=8B=A4=20(?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=F0=9F=94=B4=201=EA=B1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드래빗 🔴 Critical 1건. 두 부분인데 앞은 이미 걸어 둔 보류와 같은 얘기이고, 뒤는 새로 찾은 결함입니다. ■ 배포 전 머지 금지 (이미 반영돼 있음) PR 제목이 [HOLD] 이고 본문 맨 위에 확인 절차가 있습니다. 리뷰어가 같은 결론에 이른 것이라 그대로 둡니다. ■ 확신 못 한 답을 우리가 골라 버리던 것 (새 결함) 후보가 하나면 그것을 확정으로 삼고 있었습니다. 그런데 서버는 그때 되물을 문장까지 같이 보냅니다. 실측입니다. "하나도 안 맵게" confident=false candidates=["NO_PREFERENCE"] clarificationQuestion="\"하나도 안 맵게\"은(는) 상관없음인가요?" 서버가 묻고 싶다고 한 것을 우리가 대신 골랐습니다. 맵기를 못 드셔서 그렇게 말한 분의 주문이 물어본 적도 없이 '상관없음' 으로 넘어갑니다. 이제 confident 가 true 가 아니면 개수와 상관없이 되묻습니다. 부정어 방어를 걷어낸 이 PR 에서 특히 중요합니다 — 예전에는 우리 부정어 표에 걸리면 되물음으로 내려가는 길이 하나 더 있었지만, 이제 없습니다. 그 길을 없애면서 이 구멍이 드러났습니다. ■ 곁들여 정리 이름으로() 가 '모르는 값이 있었나' 를 같이 돌려주던 것을 뺐습니다. 후보가 하나일 때 우리가 버려서 하나가 된 것인지 원래 하나였는지를 갈라야 해서 세던 값인데, 이제 늘 되물으므로 그 구분이 필요 없습니다. ■ 시험 · 확신 못 한 답은 후보가 하나여도 되묻는다 (실측 본문 그대로) · 확신한 답은 예전처럼 그대로 확정한다 (헛통과 방지) typecheck · 438 tests · build 통과. Co-Authored-By: Claude Opus 5 --- frontend/src/api/spicy.test.ts | 19 ++++++++++++++-- frontend/src/api/spicy.ts | 41 ++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/frontend/src/api/spicy.test.ts b/frontend/src/api/spicy.test.ts index 478800c..c0dbf98 100644 --- a/frontend/src/api/spicy.test.ts +++ b/frontend/src/api/spicy.test.ts @@ -54,9 +54,24 @@ describe("서버가 잡아 준 맵기를 화면 이름으로 옮긴다", () => { expect(await 맵기물어보기("얼큰한맛")).toEqual({ 되물을것: ["보통맛"] }); }); - it("모르는 값이 없으면 하나 남은 후보는 그대로 쓴다", async () => { + it("확신 못 한 답은 후보가 하나여도 되묻는다", async () => { + /* + * 실서버 실측이다. 서버는 이때 되물을 문장까지 같이 보낸다: + * + * "하나도 안 맵게" + * confident=false candidates=["NO_PREFERENCE"] + * clarificationQuestion="\"하나도 안 맵게\"은(는) 상관없음인가요?" + * + * 서버가 묻고 싶다고 한 것을 우리가 대신 고르면, 맵기를 못 드셔서 그렇게 + * 말한 분의 주문이 물어본 적도 없이 '상관없음' 으로 넘어간다. + */ + 붙이기({ confident: false, matchedLevel: null, candidates: ["NO_PREFERENCE"] }); + expect(await 맵기물어보기("하나도 안 맵게")).toEqual({ 되물을것: ["상관없음"] }); + }); + + it("확신한 답은 예전처럼 그대로 확정한다", async () => { // 위 시험이 '언제나 되묻는다' 로 헛통과하지 않도록 지킨다. - 붙이기({ confident: false, matchedLevel: null, candidates: ["MEDIUM"] }); + 붙이기({ confident: true, matchedLevel: "MEDIUM", candidates: ["MEDIUM"] }); expect(await 맵기물어보기("얼큰한맛")).toEqual({ 고른값: "보통맛" }); }); }); diff --git a/frontend/src/api/spicy.ts b/frontend/src/api/spicy.ts index 9dc8792..ebf2c9f 100644 --- a/frontend/src/api/spicy.ts +++ b/frontend/src/api/spicy.ts @@ -50,18 +50,19 @@ export type 맵기결과 = | { 못함: true }; /** - * 서버가 준 enum 목록을 화면 칩 이름으로. + * 서버가 준 enum 목록을 화면 칩 이름으로. 화면에 없는 이름은 뺀다. * - * 모르는 값이 있었는지도 같이 돌려준다. 그걸 조용히 버리면 **서버의 '모르겠다' - * 가 우리 쪽에서 '확정' 으로 바뀐다** — 서버가 [MEDIUM, EXTRA_HOT] 을 놓고 - * 확신 못 한다고 했는데, 우리가 EXTRA_HOT 을 버리면 보통맛 하나만 남아서 - * 마치 정해진 것처럼 보인다. 서버가 망설인 것을 우리가 없앨 수는 없다. + * 몇 개가 빠졌는지는 이제 안 센다. 예전에는 셌다 — 후보가 하나만 남으면 그것을 + * 확정으로 삼았기 때문에, 우리가 버려서 하나가 된 것인지 원래 하나였는지를 + * 갈라야 했다. 지금은 **확신 못 한 답은 개수와 상관없이 늘 되묻는다**(아래). + * 그래서 그 구분이 필요 없어졌다. */ -const 이름으로 = (값들: unknown): { 이름들: string[]; 모르는것있나: boolean } => { - if (!Array.isArray(값들)) return { 이름들: [], 모르는것있나: false }; - const 글자만 = 값들.filter((v): v is string => typeof v === "string"); - const 이름들 = 글자만.map((v) => 칩이름[v]).filter(Boolean); - return { 이름들, 모르는것있나: 이름들.length !== 글자만.length }; +const 이름으로 = (값들: unknown): string[] => { + if (!Array.isArray(값들)) return []; + return 값들 + .filter((v): v is string => typeof v === "string") + .map((v) => 칩이름[v]) + .filter(Boolean); }; /** @@ -113,7 +114,7 @@ export const 맵기물어보기 = async (들은말: string): Promise<맵기결 { confident?: boolean; matchedLevel?: string; candidates?: unknown } | null; if (!본문) return { 못함: true }; - const { 이름들: 후보, 모르는것있나 } = 이름으로(본문.candidates); + const 후보 = 이름으로(본문.candidates); if (본문.confident === true && typeof 본문.matchedLevel === "string") { const 이름 = 칩이름[본문.matchedLevel]; @@ -121,7 +122,23 @@ export const 맵기물어보기 = async (들은말: string): Promise<맵기결 return 이름 ? { 고른값: 이름 } : { 못함: true }; } + /* + * 여기부터는 서버가 **확신하지 못한** 답이다. 하나만 남아도 우리가 고르지 + * 않는다. + * + * 예전에는 후보가 하나면 그것을 확정으로 삼았다. 그런데 서버는 그때 되물을 + * 문장까지 같이 보낸다 — 실측이다: + * + * "하나도 안 맵게" + * confident=false candidates=["NO_PREFERENCE"] + * clarificationQuestion="\"하나도 안 맵게\"은(는) 상관없음인가요?" + * + * 서버가 묻고 싶다고 한 것을 우리가 대신 골라 버리면, 맵기를 못 드셔서 그렇게 + * 말한 분의 주문이 '상관없음' 으로 넘어간다. 물어본 적도 없이. + * + * 부정어 방어를 걷어낸 지금은 이 겹이 더 중요하다 — 예전에는 우리 부정어 표에 + * 걸리면 되물음으로 내려가는 길이 하나 더 있었지만, 이제 없다. + */ if (후보.length === 0) return { 못함: true }; - if (후보.length === 1 && !모르는것있나) return { 고른값: 후보[0] }; return { 되물을것: 후보 }; }; From 2251ecbbbe276070c78e38e51817faaabae10f36 Mon Sep 17 00:00:00 2001 From: Yena07 <164753655+Yena07@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:01:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=A7=80=EC=96=B4=EB=82=B8=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=EC=9D=84=20=EC=8B=A4=EC=B8=A1=EC=9D=B4?= =?UTF-8?q?=EB=9D=BC=EA=B3=A0=20=EC=A0=81=EC=96=B4=20=EB=91=94=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EB=B0=94=EB=A1=9C=EC=9E=A1=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4=20(=EB=A6=AC=EB=B7=B0=201=EA=B1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드래빗 🟡 Minor 1건. 맞는 지적입니다. "확신 못 한 답은 후보가 하나여도 되묻는다" 시험의 본문을 실측이라고 적어 뒀는데, 그 측정은 #138 배포 **전** 것이었습니다. 오늘 배포가 나가서 지금 서버는 같은 말에 다르게 답합니다. 오늘 다시 쟀습니다. "안 매운 거" confident=false ["MILD","MEDIUM"] "하나도 안 맵게" confident=false ["MILD","MEDIUM"] "맵지 않게" confident=false ["MILD","MEDIUM"] "안매워요" confident=false ["MILD","MEDIUM"] "매운 거" confident=true HOT (회귀 없음) "불닭맛" confident=true HOT 부정어가 다 잡히고, MILD 가 후보에 올라옵니다 — #133 에 남긴 두 가지가 모두 고쳐졌습니다. ■ 시험은 그대로 둡니다 규칙이 후보 개수가 아니라 confident 에 걸려 있기 때문입니다. 계약상 서버는 확신 못 하면서 후보를 하나만 줄 수 있고, #138 전에는 실제로 그랬습니다("하나도 안 맵게" → ["NO_PREFERENCE"] 하나). 대신 주석을 사실대로 고쳤습니다 — 지어낸 본문이라고 밝히고, 옛 실측과 오늘 실측을 나란히 적었습니다. 그리고 오늘 실제로 오는 본문 (["MILD","MEDIUM"])으로 시험을 하나 더 붙였습니다. typecheck · 439 tests · build 통과. Co-Authored-By: Claude Opus 5 --- frontend/src/api/spicy.test.ts | 24 ++++++++++++++++++------ frontend/src/api/spicy.ts | 16 ++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/frontend/src/api/spicy.test.ts b/frontend/src/api/spicy.test.ts index c0dbf98..ddb4df1 100644 --- a/frontend/src/api/spicy.test.ts +++ b/frontend/src/api/spicy.test.ts @@ -56,19 +56,31 @@ describe("서버가 잡아 준 맵기를 화면 이름으로 옮긴다", () => { it("확신 못 한 답은 후보가 하나여도 되묻는다", async () => { /* - * 실서버 실측이다. 서버는 이때 되물을 문장까지 같이 보낸다: + * 아래 본문은 **지어낸 것이다.** 지금 서버로 같은 말을 보내면 후보를 둘 준다 + * (2026-08-15, #138 배포 뒤 실측): * - * "하나도 안 맵게" - * confident=false candidates=["NO_PREFERENCE"] - * clarificationQuestion="\"하나도 안 맵게\"은(는) 상관없음인가요?" + * "하나도 안 맵게" → confident=false, candidates=["MILD","MEDIUM"] * - * 서버가 묻고 싶다고 한 것을 우리가 대신 고르면, 맵기를 못 드셔서 그렇게 - * 말한 분의 주문이 물어본 적도 없이 '상관없음' 으로 넘어간다. + * 그래도 이 시험을 두는 이유는, 규칙이 **후보 개수가 아니라 confident**에 + * 걸려 있기 때문이다. 계약상 서버는 확신하지 못하면서 후보를 하나만 줄 수 + * 있고, #138 전에는 실제로 그랬다(그때 "하나도 안 맵게" 는 + * candidates=["NO_PREFERENCE"] 하나였다). 그때 우리는 그것을 자동으로 + * 골랐다 — 맵기를 못 드셔서 그렇게 말한 분의 주문이 물어본 적도 없이 + * '상관없음' 으로 넘어갔다. + * + * 서버가 되물을 문장(clarificationQuestion)까지 만들어 보낸다는 것은, + * 그쪽도 묻고 싶다는 뜻이다. 그걸 우리가 대신 고르지 않는다. */ 붙이기({ confident: false, matchedLevel: null, candidates: ["NO_PREFERENCE"] }); expect(await 맵기물어보기("하나도 안 맵게")).toEqual({ 되물을것: ["상관없음"] }); }); + it("지금 서버가 실제로 주는 부정문 응답도 되묻기로 간다", async () => { + // 위가 지어낸 것이라, 실측 본문 하나를 나란히 둔다(#138 배포 뒤). + 붙이기({ confident: false, matchedLevel: null, candidates: ["MILD", "MEDIUM"] }); + expect(await 맵기물어보기("안 매운 거")).toEqual({ 되물을것: ["순한맛", "보통맛"] }); + }); + it("확신한 답은 예전처럼 그대로 확정한다", async () => { // 위 시험이 '언제나 되묻는다' 로 헛통과하지 않도록 지킨다. 붙이기({ confident: true, matchedLevel: "MEDIUM", candidates: ["MEDIUM"] }); diff --git a/frontend/src/api/spicy.ts b/frontend/src/api/spicy.ts index ebf2c9f..e416e6f 100644 --- a/frontend/src/api/spicy.ts +++ b/frontend/src/api/spicy.ts @@ -126,15 +126,15 @@ export const 맵기물어보기 = async (들은말: string): Promise<맵기결 * 여기부터는 서버가 **확신하지 못한** 답이다. 하나만 남아도 우리가 고르지 * 않는다. * - * 예전에는 후보가 하나면 그것을 확정으로 삼았다. 그런데 서버는 그때 되물을 - * 문장까지 같이 보낸다 — 실측이다: + * 예전에는 후보가 하나면 그것을 확정으로 삼았다. 그런데 서버는 확신하지 + * 못할 때 되물을 문장(clarificationQuestion)까지 같이 보낸다 — 그쪽도 묻고 + * 싶다는 뜻이다. 그걸 우리가 대신 골라 버리면 안 된다. * - * "하나도 안 맵게" - * confident=false candidates=["NO_PREFERENCE"] - * clarificationQuestion="\"하나도 안 맵게\"은(는) 상관없음인가요?" - * - * 서버가 묻고 싶다고 한 것을 우리가 대신 골라 버리면, 맵기를 못 드셔서 그렇게 - * 말한 분의 주문이 '상관없음' 으로 넘어간다. 물어본 적도 없이. + * #138 전에 실제로 그랬다. "하나도 안 맵게" 에 confident=false 이면서 + * candidates 가 ["NO_PREFERENCE"] 하나뿐이었고, 우리는 그것을 자동으로 + * 골랐다 — 맵기를 못 드셔서 그렇게 말한 분의 주문이 물어본 적도 없이 + * '상관없음' 으로 넘어갔다. (지금 서버는 같은 말에 ["MILD","MEDIUM"] 을 + * 준다. 고쳐졌지만, 규칙은 후보 개수가 아니라 confident 에 걸어 둔다.) * * 부정어 방어를 걷어낸 지금은 이 겹이 더 중요하다 — 예전에는 우리 부정어 표에 * 걸리면 되물음으로 내려가는 길이 하나 더 있었지만, 이제 없다.