diff --git a/frontend/src/api/spicy.test.ts b/frontend/src/api/spicy.test.ts index ab02688..ddb4df1 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 이 서버에서 고쳤고, 배포본으로 확인한 뒤 그 겹을 걷었다. + * 아래 "부정은 이제 서버가 읽는다" 가 서버가 계속 그 일을 하는지 붙잡는다. * ③ 실패하면 조용히 물러난다. 이 경로가 없어도 앱은 손으로 고르기로 돌아간다. */ @@ -54,9 +54,36 @@ describe("서버가 잡아 준 맵기를 화면 이름으로 옮긴다", () => { expect(await 맵기물어보기("얼큰한맛")).toEqual({ 되물을것: ["보통맛"] }); }); - it("모르는 값이 없으면 하나 남은 후보는 그대로 쓴다", async () => { + it("확신 못 한 답은 후보가 하나여도 되묻는다", async () => { + /* + * 아래 본문은 **지어낸 것이다.** 지금 서버로 같은 말을 보내면 후보를 둘 준다 + * (2026-08-15, #138 배포 뒤 실측): + * + * "하나도 안 맵게" → 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: false, matchedLevel: null, candidates: ["MEDIUM"] }); + 붙이기({ confident: true, matchedLevel: "MEDIUM", candidates: ["MEDIUM"] }); expect(await 맵기물어보기("얼큰한맛")).toEqual({ 고른값: "보통맛" }); }); }); @@ -77,31 +104,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..e416e6f 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 에 적어 뒀다). * * ── 실패하면 조용히 물러난다 ───────────────────────────────────────────────── * @@ -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); }; /** @@ -73,7 +74,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 +114,31 @@ 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 }; /* - * 부정이 걸렸으면 **하나만 남아도 묻는다.** + * 여기부터는 서버가 **확신하지 못한** 답이다. 하나만 남아도 우리가 고르지 + * 않는다. + * + * 예전에는 후보가 하나면 그것을 확정으로 삼았다. 그런데 서버는 확신하지 + * 못할 때 되물을 문장(clarificationQuestion)까지 같이 보낸다 — 그쪽도 묻고 + * 싶다는 뜻이다. 그걸 우리가 대신 골라 버리면 안 된다. + * + * #138 전에 실제로 그랬다. "하나도 안 맵게" 에 confident=false 이면서 + * candidates 가 ["NO_PREFERENCE"] 하나뿐이었고, 우리는 그것을 자동으로 + * 골랐다 — 맵기를 못 드셔서 그렇게 말한 분의 주문이 물어본 적도 없이 + * '상관없음' 으로 넘어갔다. (지금 서버는 같은 말에 ["MILD","MEDIUM"] 을 + * 준다. 고쳐졌지만, 규칙은 후보 개수가 아니라 confident 에 걸어 둔다.) * - * 사용자는 원하지 않는 것만 말했다("안 매운 거"). 남은 하나가 그 사람이 - * 원하는 것이라는 보장은 없다 — 순한맛일 수도 보통맛일 수도 있고, 서버가 - * 그 둘 중 하나만 후보로 올렸을 수도 있다. 우리가 고르면 그건 짐작이다. + * 부정어 방어를 걷어낸 지금은 이 겹이 더 중요하다 — 예전에는 우리 부정어 표에 + * 걸리면 되물음으로 내려가는 길이 하나 더 있었지만, 이제 없다. */ - if (남은후보.length === 1 && !부정걸림 && !모르는것있나) return { 고른값: 남은후보[0] }; - return { 되물을것: 남은후보 }; + if (후보.length === 0) return { 못함: true }; + 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; }