Skip to content
Closed
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
53 changes: 29 additions & 24 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 @@ -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({ 고른값: "매운맛" });
});
});

Expand Down
53 changes: 17 additions & 36 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 에 적어 뒀다).
*
* ── 실패하면 조용히 물러난다 ─────────────────────────────────────────────────
*
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 { 되물을것: 후보 };
};
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