Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 58 additions & 26 deletions frontend/src/api/spicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { 맵기물어보기 } from "./spicy";
* 이 파일이 지키는 것.
*
* ① 서버가 잡아 준 말을 화면 보기 이름으로 옮긴다 — enum 이 사용자에게 안 보인다.
* ② **부정을 서버보다 우리가 더 믿는다.** 서버는 임베딩 유사도로 고르는데 그
* 방식은 부정을 못 읽는다. 실서버로 재 봤다 —
* "안 매운 거" → confident=true, HOT
* 매운 것을 못 드시는 분이 그렇게 말하면 매운맛이 들어간다. ②가 그걸 막는다.
* ② **부정은 서버가 읽는다.** 예전에는 우리 부정어 표로 서버 답을 되거르는
* 겹이 있었다(서버가 "안 매운 거" 를 confident=true HOT 으로 주던 때).
* 팀 #138 이 서버에서 고쳤고, 배포본으로 확인한 뒤 그 겹을 걷었다.
* 아래 "부정은 이제 서버가 읽는다" 가 서버가 계속 그 일을 하는지 붙잡는다.
* ③ 실패하면 조용히 물러난다. 이 경로가 없어도 앱은 손으로 고르기로 돌아간다.
*/

Expand Down Expand Up @@ -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({ 되물을것: ["상관없음"] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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({ 고른값: "보통맛" });
});
});
Expand All @@ -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({ 고른값: "매운맛" });
});
});

Expand Down
84 changes: 41 additions & 43 deletions frontend/src/api/spicy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { 아니라고했나 } from "@/api/voice";
import { 개인정보같은글 } from "@/api/account";

/**
Expand All @@ -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 에 적어 뒀다).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*
* ── 실패하면 조용히 물러난다 ─────────────────────────────────────────────────
*
Expand All @@ -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);
};

/**
Expand All @@ -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 };
Expand Down Expand Up @@ -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 { 되물을것: 후보 };
};
24 changes: 5 additions & 19 deletions frontend/src/api/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down