From a8d6cc142b1a4dc872005daa6a852477bc401765 Mon Sep 17 00:00:00 2001 From: kjp0411 <98109773+kjp0411@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:01:26 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[FIX]=20=EB=B6=80=EC=A0=95=EC=96=B4("?= =?UTF-8?q?=EC=95=88=20=EB=A7=A4=EC=9A=B4",=20"=EB=A7=B5=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C")=20=EC=98=A4=EB=B6=84=EB=A5=98=20=EB=B0=8F?= =?UTF-8?q?=20MEDIUM=20=EB=9D=BC=EB=B2=A8=20=ED=91=9C=EA=B8=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 부정어 감지 필터 추가로 안 매운/맵지 않게 등 오분류 방지, MEDIUM 표기를 보통맛으로 수정 * fix: 영문 부정어를 단어 경계로 매칭하도록 수정, 부정어 분기 유닛 테스트 추가 * fix: 한글 부정어 매칭 확대(안/않/말고/빼고), 영문 부정어 회귀 테스트 추가 * fix: 부정어 판정을 구체적 조합으로 좁혀 안녕/안내 등 오탐 방지, 부정 입력 시 외부 호출 없음 검증 추가 * fix: 빼주세요/빼줘/빼줄래 부정 표현 추가로 매운 거 빼주세요 오분류 방지 --- .../service/SpicyLevelMatchingService.java | 22 +++- .../SpicyLevelMatchingServiceUnitTest.java | 116 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingServiceUnitTest.java diff --git a/backend/src/main/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingService.java b/backend/src/main/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingService.java index a860376..664d69d 100644 --- a/backend/src/main/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingService.java +++ b/backend/src/main/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import java.util.stream.Collectors; @Service @@ -28,6 +29,12 @@ public class SpicyLevelMatchingService { "싫", "말고", "아니", "보다", "이상", "이하" ); + private static final Set NEGATION_MARKERS_KO = Set.of( + "안 매", "안매", "안 맵", "안맵", "안 돼", "안돼", + "않", "말고", "빼주세요", "빼줘", "빼고", "빼줄래" + ); + private static final Pattern NEGATION_PATTERN_EN = Pattern.compile("\\b(no|not)\\b"); + private final EmbeddingService embeddingService; private final SpicyLevelAnchorRepository repository; @@ -37,6 +44,12 @@ public SpicyLevelMatchingService(EmbeddingService embeddingService, SpicyLevelAn } public SpicyLevelMatchResult match(String text) { + if (isNegated(text)) { + List candidates = List.of("MILD", "MEDIUM"); + String question = buildClarificationQuestion(text, candidates); + return new SpicyLevelMatchResult(null, false, Map.of(), candidates, text, question); + } + if (matchesKeyword(text, NO_PREFERENCE_PHRASES)) { return new SpicyLevelMatchResult( "NO_PREFERENCE", true, Map.of(), List.of("NO_PREFERENCE"), text, null @@ -73,6 +86,13 @@ public SpicyLevelMatchResult match(String text) { return new SpicyLevelMatchResult(null, false, counts, topLabels, text, question); } + private boolean isNegated(String text) { + if (NEGATION_MARKERS_KO.stream().anyMatch(text::contains)) { + return true; + } + return NEGATION_PATTERN_EN.matcher(text.toLowerCase()).find(); + } + private boolean matchesKeyword(String text, Set phrases) { if (EXCLUSION_MARKERS.stream().anyMatch(text::contains)) { return false; @@ -90,7 +110,7 @@ private String buildClarificationQuestion(String text, List candidates) private String toKorean(String level) { return switch (level) { case "HOT" -> "매운맛"; - case "MEDIUM" -> "중간맛"; + case "MEDIUM" -> "보통맛"; case "MILD" -> "순한맛"; case "NO_PREFERENCE" -> "상관없음"; default -> level; diff --git a/backend/src/test/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingServiceUnitTest.java b/backend/src/test/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingServiceUnitTest.java new file mode 100644 index 0000000..f41dbd7 --- /dev/null +++ b/backend/src/test/java/com/kiobridge/kiobridge/modules/spicylevel/service/SpicyLevelMatchingServiceUnitTest.java @@ -0,0 +1,116 @@ +package com.kiobridge.kiobridge.modules.spicylevel.service; + +import com.kiobridge.kiobridge.modules.spicylevel.repository.SpicyLevelAnchorRepository; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class SpicyLevelMatchingServiceUnitTest { + + private final EmbeddingService embeddingService = mock(EmbeddingService.class); + private final SpicyLevelAnchorRepository repository = mock(SpicyLevelAnchorRepository.class); + private final SpicyLevelMatchingService service = + new SpicyLevelMatchingService(embeddingService, repository); + + @Test + void 안_매운_거는_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("안 매운 거"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + verifyNoInteractions(embeddingService, repository); + } + + @Test + void 맵지_않게는_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("맵지 않게"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + verifyNoInteractions(embeddingService, repository); + } + + @Test + void 하나도_안_맵게는_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("하나도 안 맵게"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + verifyNoInteractions(embeddingService, repository); + } + + @Test + void 매운_건_안돼도_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("매운 건 안돼"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + verifyNoInteractions(embeddingService, repository); + } + + @Test + void no_spicy는_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("no spicy"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + assertThat(result.clarificationQuestion()).contains("보통맛"); + verifyNoInteractions(embeddingService, repository); + } + + @Test + void not_spicy는_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("not spicy"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + assertThat(result.clarificationQuestion()).contains("보통맛"); + verifyNoInteractions(embeddingService, repository); + } + + @Test + void normal이라는_단어는_부정어로_오탐되지_않는다() { + when(embeddingService.embed("normal")).thenReturn(new float[1536]); + when(repository.findNearestSpicyLevels("[" + "0.0,".repeat(1535) + "0.0]", 5)) + .thenReturn(java.util.List.of("MEDIUM", "MEDIUM", "MEDIUM", "HOT", "MILD")); + + SpicyLevelMatchResult result = service.match("normal"); + + assertThat(result.confident()).isTrue(); + assertThat(result.matchedLevel()).isEqualTo("MEDIUM"); + } + + @Test + void 안녕하세요는_부정어로_오탐되지_않는다() { + when(embeddingService.embed("안녕하세요")).thenReturn(new float[1536]); + when(repository.findNearestSpicyLevels("[" + "0.0,".repeat(1535) + "0.0]", 5)) + .thenReturn(java.util.List.of("MEDIUM", "MEDIUM", "MEDIUM", "HOT", "MILD")); + + SpicyLevelMatchResult result = service.match("안녕하세요"); + + assertThat(result.confident()).isTrue(); + } + + @Test + void 안내라는_단어는_부정어로_오탐되지_않는다() { + when(embeddingService.embed("안내")).thenReturn(new float[1536]); + when(repository.findNearestSpicyLevels("[" + "0.0,".repeat(1535) + "0.0]", 5)) + .thenReturn(java.util.List.of("MEDIUM", "MEDIUM", "MEDIUM", "HOT", "MILD")); + + SpicyLevelMatchResult result = service.match("안내"); + + assertThat(result.confident()).isTrue(); + } + + @Test + void 매운_거_빼주세요는_되묻기로_처리된다() { + SpicyLevelMatchResult result = service.match("매운 거 빼주세요"); + + assertThat(result.confident()).isFalse(); + assertThat(result.candidates()).containsExactly("MILD", "MEDIUM"); + verifyNoInteractions(embeddingService, repository); + } +} \ No newline at end of file From 488634404be116bb9f83278609de74e019f17d9f Mon Sep 17 00:00:00 2001 From: Gganii Date: Sat, 15 Aug 2026 15:01:04 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FIX]=20recommendation=20=EC=A0=90=EC=88=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Chickenstore 환경의 CompatibilityRule errorcode 사용자 친화 메시지 * feat: CandidateFilterResult requiresReconfirmation 필드 map으로 변경 * feat: Map 변경에 따른 서비스 수정 * feat: RuleEvaluator: 1단계 값 추출 로직 * feat: RecommendationEngineService STEP5~7 추천 로직 초기 구현 * fix: STEP2 파트 passesByCandidateId 추가 전달 * fix: SKIPPED, PASS 점수 오류 수정 * fix: WARN 없는 항목의 confidence 임계값 크로스 테스트 픽스처 보정 * fix: 뼈 타입 추천 점수에 추가 * fix: STAFF_ASSISTANCE_REASON 멘트 추가 * fix: 병합 오류 수정 * fix: v5.1.6 RC5 변경에 맞추어 뼈타입.컵옵션 추가 * feat: 혼잡 시간대(요일+시간대) 기반 포장 메뉴 가산점 추가 * fix: 뼈 타입 점수, 확신도 점수 수정. --------- Co-authored-by: parkseyoung --- .../recommendation/RecommendationEngineService.java | 6 +++--- .../RecommendationEngineServiceTest.java | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineService.java b/backend/src/main/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineService.java index 659721b..ad3243e 100644 --- a/backend/src/main/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineService.java +++ b/backend/src/main/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineService.java @@ -69,8 +69,8 @@ public RecommendationEngineService() { private static final double PREFERENCE_MISMATCH_PENALTY = -0.3; private static final double PREFERENCE_PARTIAL_MISMATCH_PENALTY = -0.15; private static final double PREFERENCE_UNKNOWN_SCORE = 0.0; - private static final double BONE_TYPE_MATCH_BONUS = 0.5; - private static final double BONE_TYPE_MISMATCH_PENALTY = -0.15; + private static final double BONE_TYPE_MATCH_BONUS = 1.0; + private static final double BONE_TYPE_MISMATCH_PENALTY = -0.3; private static final double CUP_OPTION_MATCH_BONUS = 0.5; private static final double CUP_OPTION_MISMATCH_PENALTY = -0.15; private static final double CROWDING_TAKE_OUT_BONUS = 0.1; @@ -86,7 +86,7 @@ public RecommendationEngineService() { private static final double CONFIDENCE_BASE_FLOOR = 0.5; private static final double CONFIDENCE_GAP_SCALE = 0.3; private static final double RECONFIRMATION_CONFIDENCE_CAP = 0.5; - private static final double LOW_CONFIDENCE_THRESHOLD = 0.6; + private static final double LOW_CONFIDENCE_THRESHOLD = 0.7; private static final double MIN_CONFIDENCE = 0.0; private static final double MAX_CONFIDENCE = 1.0; diff --git a/backend/src/test/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineServiceTest.java b/backend/src/test/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineServiceTest.java index adbc8f6..66cb1df 100644 --- a/backend/src/test/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineServiceTest.java +++ b/backend/src/test/java/com/kiobridge/kiobridge/modules/recommendation/RecommendationEngineServiceTest.java @@ -175,7 +175,10 @@ private static RecommendationEngineService serviceAt(Instant instant) { Recommendation recommendation = service.recommend(filterResult, sessionContext(null), profile()); assertThat(recommendation.confidence()).isCloseTo(0.65, offset(0.0001)); - assertThat(recommendation.requiresReconfirmation()).isFalse(); + // LOW_CONFIDENCE_THRESHOLD를 프론트(api/backend.ts LOW_CONFIDENCE)와 같은 0.7로 맞췄다. + // 남은 후보가 하나뿐이어도 사용자가 밝힌 조건과 실제로 어긋난 게(WARN) 있으면 + // 0.65는 재확인이 필요한 게 맞다 — 이제 그 판단을 백엔드도 정직하게 응답에 싣는다. + assertThat(recommendation.requiresReconfirmation()).isTrue(); } @Test @@ -196,6 +199,7 @@ private static RecommendationEngineService serviceAt(Instant instant) { void 뼈타입_선호와_일치하면_점수_보너스와_일치_사유를_받는다() { // v5.1.6 RC5부터 CHICKEN_BONE_TYPE_PREFERENCE가 CANDIDATE-scope WARN 규칙으로 추가됐다. // serviceType/spicyLevel과 동일하게 PASS가 실제로 나야 "일치" 보너스를 준다. + // 형태는 접근성과 직결된 축이라 이용방식/맵기와 같은 무게(1.0/-0.3)를 쓴다 — 컵(0.5/-0.15)과는 다르다. Candidate boneless = candidate("CHICKEN-BONELESS", 6000.0, List.of("HOT")); Map> passes = @@ -206,7 +210,7 @@ private static RecommendationEngineService serviceAt(Instant instant) { Recommendation recommendation = service.recommend(filterResult, sessionContext(null), profile()); - assertThat(recommendation.scoreBreakdown().get("boneTypeMatch")).isEqualTo(0.5); + assertThat(recommendation.scoreBreakdown().get("boneTypeMatch")).isEqualTo(1.0); assertThat(recommendation.recommendationReasons()).contains("선호하신 뼈/순살과 일치하는 메뉴라 우선 추천드립니다."); } @@ -222,7 +226,7 @@ private static RecommendationEngineService serviceAt(Instant instant) { Recommendation recommendation = service.recommend(filterResult, sessionContext(null), profile()); - assertThat(recommendation.scoreBreakdown().get("boneTypeMatch")).isEqualTo(-0.15); + assertThat(recommendation.scoreBreakdown().get("boneTypeMatch")).isEqualTo(-0.3); assertThat(recommendation.unmetConditions()).contains("선호하신 뼈/순살과 다릅니다."); } From 469d497732b647756a1f181347a18cbbd38ec109 Mon Sep 17 00:00:00 2001 From: Yena07 <164753655+Yena07@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:16:39 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[FIX/FEAT]=20=ED=99=95=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=9C=A0=EC=8B=A4=20?= =?UTF-8?q?+=20=EB=B3=B4=EA=B8=B0=EC=97=90=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EB=A7=90=EB=8F=84=20=EC=95=8C=EC=95=84=EB=93=A3=EA=B8=B0=20(#1?= =?UTF-8?q?36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포커스 결함 3건(원인이 각각 다름), 큰 글씨 모드 가로 넘침, 맵기 매칭(서버 임베딩) 붙이기, 사용자 피드백 5건, 여자 목소리 우선 선택, 안내 언어 위치 조정, QR 페어링 확인 도구(check:pairing) 추가. 코드래빗 지적 4건 반영. --- frontend/api/bff.ts | 5 + frontend/package.json | 6 +- frontend/src/api/speech.ts | 58 +++++++ frontend/src/api/spicy.test.ts | 132 ++++++++++++++++ frontend/src/api/spicy.ts | 146 ++++++++++++++++++ frontend/src/api/voice.ts | 21 +++ frontend/src/app/App.tsx | 253 ++++++++++++++++++++++++++++--- frontend/src/i18n/en.ts | 19 ++- frontend/tools/check-pairing.mjs | 201 ++++++++++++++++++++++++ 9 files changed, 814 insertions(+), 27 deletions(-) create mode 100644 frontend/src/api/spicy.test.ts create mode 100644 frontend/src/api/spicy.ts create mode 100644 frontend/tools/check-pairing.mjs diff --git a/frontend/api/bff.ts b/frontend/api/bff.ts index 8c9aaa3..61e8ce9 100644 --- a/frontend/api/bff.ts +++ b/frontend/api/bff.ts @@ -46,6 +46,11 @@ const 허용경로 = [ * 뒀다. 그 셋이 각각 오디오를 막는다. 그래서 이 경로만 아래에서 따로 다룬다. */ /^api\/v1\/voice\/transcribe$/, + /* + * 말한 맵기를 골라 주는 경로(팀 #133). 화면 보기에 없는 말("불닭맛")을 + * 여기서 받는다 — 자세한 사연은 src/api/spicy.ts 주석에 있다. + */ + /^internal\/spicy-level\/match$/, // 승인 한 번으로 조립·제출·검증·실행까지. 프론트가 실제로 쓰는 경로다. /^internal\/orchestrator\/approve$/, // 추천 계열 (RecommendationController) diff --git a/frontend/package.json b/frontend/package.json index ab370c7..64098d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.1", "type": "module", + "engines": { + "node": ">=18" + }, "scripts": { "build": "vite build", "build:team": "vite build --mode team", @@ -10,7 +13,8 @@ "dev:team": "vite --mode team --port 5199 --strictPort", "typecheck": "tsc --noEmit", "test": "vitest run", - "check:backend": "node tools/check-backend.mjs" + "check:backend": "node tools/check-backend.mjs", + "check:pairing": "node tools/check-pairing.mjs" }, "dependencies": { "lucide-react": "0.487.0", diff --git a/frontend/src/api/speech.ts b/frontend/src/api/speech.ts index 4d152ca..4c32d3a 100644 --- a/frontend/src/api/speech.ts +++ b/frontend/src/api/speech.ts @@ -27,6 +27,59 @@ * 한다고 말하지 않는다. */ +/** + * 이 기기에 있는 목소리 중 더 자연스러운 것을 고른다. 없으면 null. + * + * 여태 아무것도 안 골라서 브라우저 기본이 나왔다. 윈도우·안드로이드의 한국어 + * 기본은 대개 오래된 합성음이라 말투가 딱딱하다 — 어르신께 읽어 드리는 + * 앱에서 그 소리로 안내가 나가면 듣기 힘들다는 말을 들었다. + * + * 이름으로 고른다. 표준이 정한 규칙은 아니지만, 실제 기기에서 신경망 목소리는 + * 이름에 Google · Natural · Neural · Siri 같은 말이 붙는다. 없으면 고르지 + * 않고 두어 브라우저 기본으로 간다 — 지금과 같아질 뿐 나빠지지 않는다. + * + * getVoices() 는 처음 몇 번 빈 배열을 준다(목록을 늦게 채우는 브라우저가 있다). + * 그때도 그냥 null 이라 기본 목소리로 읽고, 다음 문장부터 좋은 목소리가 잡힌다. + */ +const 좋은이름 = /google|natural|neural|premium|enhanced|siri/i; +/* + * 여자 목소리로 알려진 이름들. + * + * "하이톤 여자 목소리가 듣기 좋다" 는 말을 듣고 그쪽으로 맞춘다. 표준이 성별을 + * 알려 주지 않아서 이름으로 가릴 수밖에 없다 — 아래는 실제 기기에서 쓰이는 + * 한국어·영어 목소리 이름이다. + * + * Heami 윈도우 한국어 기본 (이 프로젝트에서 실제로 확인한 목소리) + * Yuna 애플 한국어 + * Sun-Hi Azure 한국어 신경망 + * Seoyeon AWS Polly 한국어 + * Sora 삼성 한국어 + * + * 이름으로 가리는 것이라 완벽하지 않다. 못 찾으면 아래에서 그냥 null 로 두고 + * 브라우저 기본으로 간다 — 지금과 같아질 뿐 나빠지지 않는다. + */ +const 여자이름 = /heami|yuna|sun-?hi|seoyeon|sora|jiwon|female|여성|zira|samantha|aria|jenny/i; + +const 나은목소리 = (언어: string): SpeechSynthesisVoice | null => { + try { + const 목록 = globalThis.speechSynthesis?.getVoices?.() ?? []; + // 언어가 먼저다. 한국어 문장을 영어 목소리로 읽으면 알아들을 수 없다. + const 같은언어 = 목록.filter((v) => v.lang?.replace("_", "-").toLowerCase().startsWith(언어.slice(0, 2).toLowerCase())); + if (같은언어.length === 0) return null; + /* + * 고르는 차례 — 여자 목소리를 성능보다 앞에 둔다. + * + * 사용자가 좋다고 한 것이 그 목소리라서다. 자연스러움은 그다음이다. + */ + return 같은언어.find((v) => 여자이름.test(v.name) && 좋은이름.test(v.name)) + ?? 같은언어.find((v) => 여자이름.test(v.name)) + ?? 같은언어.find((v) => 좋은이름.test(v.name)) + ?? null; + } catch { + return null; + } +}; + /** 이 브라우저에서 소리 안내가 되는가. 화면이 스위치를 내밀지 말지 이걸로 정한다. */ export const 소리를낼수있나 = (): boolean => { try { @@ -75,8 +128,13 @@ export const 읽어주기 = ( * 그래서 부르는 쪽이 '지금 화면의 언어' 를 준다(a11y 의 language). */ u.lang = 언어; + // 이 기기에 더 자연스러운 목소리가 있으면 그걸 쓴다. 없으면 기본 그대로다. + const 고른목소리 = 나은목소리(언어); + if (고른목소리) u.voice = 고른목소리; // 기본 속도는 이 앱을 쓰는 분들에게 빠르다. 조금 늦춘다. u.rate = 0.95; + // 기본 높이는 또박또박하지만 사무적으로 들린다. 아주 조금 올려 부드럽게. + u.pitch = 1.05; globalThis.speechSynthesis.speak(u); } catch { /* 목소리가 없거나 브라우저가 막았다. 화면 글은 그대로 있다. */ diff --git a/frontend/src/api/spicy.test.ts b/frontend/src/api/spicy.test.ts new file mode 100644 index 0000000..ab02688 --- /dev/null +++ b/frontend/src/api/spicy.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { 맵기물어보기 } from "./spicy"; + +/* + * 이 파일이 지키는 것. + * + * ① 서버가 잡아 준 말을 화면 보기 이름으로 옮긴다 — enum 이 사용자에게 안 보인다. + * ② **부정을 서버보다 우리가 더 믿는다.** 서버는 임베딩 유사도로 고르는데 그 + * 방식은 부정을 못 읽는다. 실서버로 재 봤다 — + * "안 매운 거" → confident=true, HOT + * 매운 것을 못 드시는 분이 그렇게 말하면 매운맛이 들어간다. ②가 그걸 막는다. + * ③ 실패하면 조용히 물러난다. 이 경로가 없어도 앱은 손으로 고르기로 돌아간다. + */ + +const 응답 = (본문: unknown, status = 200) => + ({ ok: status >= 200 && status < 300, status, json: async () => 본문 }) as unknown as Response; + +afterEach(() => { vi.unstubAllGlobals(); }); + +const 붙이기 = (본문: unknown, status = 200) => { + // 인자를 선언해 둬야 아래에서 mock.calls[0][1] 로 본문을 꺼내 볼 수 있다. + const f = vi.fn(async (_url: string, _init?: RequestInit) => 응답(본문, status)); + vi.stubGlobal("fetch", f); + return f; +}; + +describe("서버가 잡아 준 맵기를 화면 이름으로 옮긴다", () => { + it("확정이면 화면 보기 이름을 돌려준다", async () => { + const f = 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT"] }); + // enum(HOT)이 아니라 화면에 떠 있는 칩 이름이어야 한다. 사용자는 HOT 을 모른다. + expect(await 맵기물어보기("불닭맛")).toEqual({ 고른값: "매운맛" }); + expect(f.mock.calls[0][0]).toBe("/api/bff/internal/spicy-level/match"); + expect(JSON.parse(String((f.mock.calls[0][1] as RequestInit).body))).toEqual({ text: "불닭맛" }); + }); + + it("애매하면 되물을 값들을 돌려준다 — 우리가 고르지 않는다", async () => { + 붙이기({ confident: false, matchedLevel: null, candidates: ["MEDIUM", "MILD"] }); + expect(await 맵기물어보기("얼큰한맛")).toEqual({ 되물을것: ["보통맛", "순한맛"] }); + }); + + it("상관없음도 받는다", async () => { + // 맵기 축은 네 칸이다. "아무거나" 라고 말하는 분이 실제로 있다. + 붙이기({ confident: true, matchedLevel: "NO_PREFERENCE", candidates: ["NO_PREFERENCE"] }); + expect(await 맵기물어보기("아무거나")).toEqual({ 고른값: "상관없음" }); + }); + + it("모르는 enum 은 화면에 안 내보내되, 서버의 '모르겠다' 를 지우지는 않는다", async () => { + /* + * 화면에 없는 이름을 사용자에게 보여 주면 안 되니 EXTRA_HOT 은 뺀다. + * 그런데 빼고 나면 보통맛 하나만 남는다 — 그걸 확정으로 삼으면 **서버가 + * 망설인 것을 우리가 없애는 셈**이다. 서버는 둘 사이에서 확신을 못 했다. + */ + 붙이기({ confident: false, matchedLevel: null, candidates: ["MEDIUM", "EXTRA_HOT"] }); + expect(await 맵기물어보기("얼큰한맛")).toEqual({ 되물을것: ["보통맛"] }); + }); + + it("모르는 값이 없으면 하나 남은 후보는 그대로 쓴다", async () => { + // 위 시험이 '언제나 되묻는다' 로 헛통과하지 않도록 지킨다. + 붙이기({ confident: false, matchedLevel: null, candidates: ["MEDIUM"] }); + expect(await 맵기물어보기("얼큰한맛")).toEqual({ 고른값: "보통맛" }); + }); +}); + +describe("개인정보처럼 보이는 말은 보내지 않는다", () => { + /* + * 여기 오는 것은 보기와도 예/아니오와도 안 맞은 말이라, 사람이 무슨 말을 + * 했는지 알 수 없다. 그대로 보내면 그 말이 BFF 를 지나 백엔드를 거쳐 + * OpenAI 까지 간다. 이 앱은 실제 개인정보를 받지도 저장하지도 않는다고 + * 화면에서 약속하고 있다. + */ + it("전화번호·주민번호·주소는 네트워크로 안 나간다", async () => { + const f = 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT"] }); + for (const 말 of ["010-1234-5678", "901010-1234567", "행복로 12"]) { + expect(await 맵기물어보기(말)).toEqual({ 못함: true }); + } + expect(f).not.toHaveBeenCalled(); + }); +}); + +describe("부정은 서버보다 우리 표를 믿는다", () => { + it("확정이어도 아니라고 말한 값은 안 받는다", async () => { + /* + * 실서버 실측이다. "안 매운 거" 를 보내면 confident=true 로 HOT 이 온다 — + * "안 매운" 안에 "매운" 이 들어 있어서 매운맛 앵커와 가깝기 때문이다. + * + * 그대로 넣으면 매운 것을 못 드시는 분의 주문에 매운맛이 들어간다. + * 되묻지도 않는다(confident=true). 우리 표는 이 문제를 이미 알고 있다. + */ + 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT", "MILD"] }); + const r = await 맵기물어보기("안 매운 거"); + expect(r).not.toEqual({ 고른값: "매운맛" }); + // 남은 후보가 있으면 그것으로 되묻는다. 무엇을 원하는지는 아직 모르기 때문이다. + expect(r).toEqual({ 되물을것: ["순한맛"] }); + }); + + it("후보에서도 아니라고 한 값을 빼고 되묻는다", async () => { + 붙이기({ confident: false, matchedLevel: null, candidates: ["HOT", "MEDIUM", "MILD"] }); + expect(await 맵기물어보기("안 맵게")).toEqual({ 되물을것: ["보통맛", "순한맛"] }); + }); + + it("아니라고 한 것만 남으면 못 고른 것으로 둔다", async () => { + // 짐작해서 넣지 않는다. 화면이 "못 골랐어요" 로 가고 사람이 손으로 짚는다. + 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT"] }); + expect(await 맵기물어보기("안 매운 걸로")).toEqual({ 못함: true }); + }); +}); + +describe("실패하면 조용히 물러난다", () => { + it("서버가 없으면(404) 못함", async () => { + 붙이기({ code: "NOT_ALLOWED" }, 404); + expect(await 맵기물어보기("불닭맛")).toEqual({ 못함: true }); + }); + + it("네트워크가 끊겨도 던지지 않는다", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("Failed to fetch"); })); + await expect(맵기물어보기("불닭맛")).resolves.toEqual({ 못함: true }); + }); + + it("빈 말과 너무 긴 말은 아예 안 보낸다", async () => { + // 서버가 @NotBlank · @Size(max = 100) 을 건다. 보내 봐야 400 이고, + // 이 경로는 호출당 비용이 나가는 임베딩을 부른다. + const f = 붙이기({ confident: true, matchedLevel: "HOT", candidates: ["HOT"] }); + expect(await 맵기물어보기(" ")).toEqual({ 못함: true }); + expect(await 맵기물어보기("가".repeat(101))).toEqual({ 못함: true }); + expect(f).not.toHaveBeenCalled(); + }); + + it("본문이 이상해도 던지지 않는다", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, json: async () => { throw new Error("bad"); } }) as unknown as Response)); + await expect(맵기물어보기("불닭맛")).resolves.toEqual({ 못함: true }); + }); +}); diff --git a/frontend/src/api/spicy.ts b/frontend/src/api/spicy.ts new file mode 100644 index 0000000..dc358ae --- /dev/null +++ b/frontend/src/api/spicy.ts @@ -0,0 +1,146 @@ +import { 아니라고했나 } from "@/api/voice"; +import { 개인정보같은글 } from "@/api/account"; + +/** + * 말한 맵기를 서버가 골라 준다(팀 #133). + * + * ── 왜 서버가 필요한가 ─────────────────────────────────────────────────────── + * + * 지금 말 맞추기(voice.ts)는 **화면에 떠 있는 보기와 글자를 대조**한다. 그래서 + * "매운맛"·"안 맵게" 처럼 표에 적어 둔 말은 잡지만, "불닭맛"·"얼큰한맛" 처럼 + * 표에 없는 말은 못 잡는다. 사람이 실제로 쓰는 말은 훨씬 많고, 그걸 전부 + * 앞에 적어 두는 것은 끝이 없다. + * + * 서버는 앵커 표현과의 임베딩 유사도로 고른다. 표에 없던 말도 잡는다. + * + * ── 그런데 서버 답을 그대로 믿지 않는다 ────────────────────────────────────── + * + * 유사도는 **부정을 못 읽는다.** "안 매운 거" 안에는 "매운" 이 들어 있어서 + * 매운맛 앵커와 가깝다. 실서버로 재 봤다: + * + * "안 매운 거" confident=true HOT ← 정반대인데 되묻지도 않는다 + * "안매워요" confident=false [HOT, NO_PREFERENCE] + * "하나도 안 맵게" confident=false [NO_PREFERENCE] + * + * 매운 것을 못 드시는 분이 "안 매운 거" 라고 말하면 매운맛이 들어간다. + * 그래서 우리 쪽 부정 표(voice.ts 의 아니라는말)로 한 번 거른다 — 그 표는 + * 이 문제를 이미 알고 만든 것이다. + * + * ── 실패하면 조용히 물러난다 ───────────────────────────────────────────────── + * + * 이 경로가 없어도 앱은 지금처럼 동작한다(손으로 고르기). 서버가 없거나 + * 느리거나 프로필이 안 켜져 있으면 못함 을 돌려주고, 화면은 원래 하던 대로 + * "못 골랐어요" 로 간다. 음성은 편의지 이 앱이 하는 일이 아니다. + */ + +/** 서버가 쓰는 enum 과 화면 칩 이름. 화면에 없는 말을 사용자에게 들려주지 않는다. */ +const 칩이름: Record = { + HOT: "매운맛", + MEDIUM: "보통맛", + MILD: "순한맛", + NO_PREFERENCE: "상관없음", +}; + +export type 맵기결과 = + /** 확정. 화면 칩 이름이다. */ + | { 고른값: string } + /** 애매하다. 이 중에서 되물어야 한다. 화면 칩 이름들이다. */ + | { 되물을것: string[] } + /** 못 골랐다(서버 없음·오류·부정 걸림). 화면은 원래 하던 대로 간다. */ + | { 못함: true }; + +/** + * 서버가 준 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 }; +}; + +/** + * 서버가 이만큼 안에 답하지 않으면 포기한다. + * + * 없으면 화면이 '인식 중…' 에 머문 채 말하기 단추가 계속 잠겨 있다. 사용자는 + * 무엇을 기다리는지도 모르고 손으로 고를 수도 없다 — 안 되는 것보다 나쁘다. + * 임베딩 한 번은 보통 1초 안쪽이고, BFF 도 15초에 끊는다. + */ +const 기다릴시간 = 8000; + +export const 맵기물어보기 = async (들은말: string, 영어인가 = false): Promise<맵기결과> => { + const 글 = 들은말.replace(/\s+/g, " ").trim(); + // 서버가 @Size(max = 100) 을 걸어 두었다. 넘겨 봐야 400 이라 여기서 접는다. + if (글 === "" || 글.length > 100) return { 못함: true }; + + /* + * 개인정보처럼 보이는 말은 **보내지 않는다.** + * + * 여기 오는 것은 보기와도 예/아니오와도 안 맞은 말이라, 사람이 무슨 말을 + * 했는지 알 수 없다. 전화번호나 주소를 말했으면 그 말이 우리 BFF 를 지나 + * 백엔드를 거쳐 OpenAI 까지 간다. 이 앱은 실제 개인정보를 받지도 저장하지도 + * 않는다고 화면에서 약속하고 있다. + * + * 메모·메뉴 이름에 쓰는 것과 같은 가드를 쓴다(account.ts). 말로 들어오는 + * 길만 예외로 두면 막아 둔 뜻이 없다. + */ + if (개인정보같은글(글)) return { 못함: true }; + + const 시계 = new AbortController(); + const 타이머 = setTimeout(() => 시계.abort(), 기다릴시간); + let res: Response; + try { + res = await fetch("/api/bff/internal/spicy-level/match", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: 글 }), + signal: 시계.signal, + }); + } catch { + // 끊겼든 시간이 다 됐든 사용자가 할 일은 같다 — 손으로 고른다. + return { 못함: true }; + } finally { + clearTimeout(타이머); + } + if (!res.ok) return { 못함: true }; + + const 본문 = await res.json().catch(() => null) as + { confident?: boolean; matchedLevel?: string; candidates?: unknown } | null; + if (!본문) return { 못함: true }; + + /* + * 부정으로 거른다. 서버가 뭐라고 했든, 사용자가 "그건 아니다" 라고 말한 + * 값은 안 받는다. + * + * 확정이 걸리면 되물음으로 내린다 — 버리지 않는 이유는, 부정했다는 것은 + * 무엇을 원하는지가 아니라 무엇을 원하지 않는지만 말한 것이라 여전히 + * 물어봐야 하기 때문이다. + */ + const { 이름들: 원래후보, 모르는것있나 } = 이름으로(본문.candidates); + const 남은후보 = 원래후보.filter((이름) => !아니라고했나(글, 이름, 영어인가)); + const 부정걸림 = 남은후보.length !== 원래후보.length; + + if (본문.confident === true && typeof 본문.matchedLevel === "string") { + const 이름 = 칩이름[본문.matchedLevel]; + if (이름 && !아니라고했나(글, 이름, 영어인가)) return { 고른값: 이름 }; + // 확정인데 우리 표가 아니라고 한다. 남은 것으로 되묻는다. + const 나머지 = 남은후보.filter((n) => n !== 이름); + return 나머지.length > 0 ? { 되물을것: 나머지 } : { 못함: true }; + } + + 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 2b3eb95..4564263 100644 --- a/frontend/src/api/voice.ts +++ b/frontend/src/api/voice.ts @@ -113,6 +113,27 @@ const 낱말로있나 = (글: string, 말: string): boolean => { return new RegExp(`(^|[^a-z0-9])${그대로}([^a-z0-9]|$)`).test(글); }; +/** + * 이 값이 **아니라고** 말했나. + * + * 위 두 표(아니라는말·영어로아니라는말)를 밖에서도 쓸 수 있게 연다. 서버가 + * 골라 준 값을 받을 때 필요하다 — 서버는 임베딩 유사도로 고르는데, 그 방식은 + * 부정을 못 읽는다. "안 매운 거" 안에 "매운" 이 들어 있어서 매운맛과 가깝다고 + * 본다(실측: confident=true, HOT). 우리 쪽 표는 그걸 이미 알고 있으므로, + * 서버 답이 여기 걸리면 안 받는다. + * + * 넓히는 것이 아니라 **반대로 읽는 것을 막는 것**이라, 짐작하지 않는다는 + * 규칙과 어긋나지 않는다. + */ +export const 아니라고했나 = (들은말: string, 값: string, 영어인가 = false): boolean => { + const 글 = 들은말.replace(/\s+/g, " ").trim(); + if (영어인가) { + const 소문자 = 글.toLowerCase(); + return (영어로아니라는말[값] ?? []).some((말) => 낱말로있나(소문자, 말)); + } + return (아니라는말[값] ?? []).some((말) => 글.includes(말)); +}; + /** 이 값을 말한 것으로 볼 수 있나. 값 자체나, 달리 부르는 말이 글에 있으면 그렇다. */ const 말했나 = (글: string, 값: string, 영어인가: boolean): boolean => { if (영어인가) { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index a4e637b..c47435e 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -23,6 +23,7 @@ import { 접근성설정, 언어목록, type 도움설정, type 언어코드 } f import { 소리를낼수있나, 읽어주기, 그만읽기, 화면글 } from "@/api/speech"; import { 들을수있나, 들어보기, type 못들은이유 } from "@/api/listen"; import { 말에서고르기, 예아니오 } from "@/api/voice"; +import { 맵기물어보기 } from "@/api/spicy"; import { 입력출처 } from "@/api/inputsource"; import { 가격한도 } from "@/api/budget"; import { 접근토큰 } from "@/api/token"; @@ -109,6 +110,26 @@ function ProgressBar({ step, total = 3 }: { step: number; total?: number }) { */ const 동의없이볼수있는화면 = new Set(["welcome", "login", "signup", "privacy"]); +/** + * 소리로 주고받을 수 있나 — 스위치 하나가 듣기와 말하기를 함께 정한다. + * + * 예전에는 둘이 따로였다. '소리로 읽어 주기' 는 스위치였고, 말하기 단추는 + * 기기가 되기만 하면 늘 떠 있었다. 그래서 소리를 원하지 않는 사람에게도 + * 말하기 단추가 보였고, 반대로 읽어 주기만 켠 사람은 왜 말하기가 되는지 + * 몰랐다 — 한 가지 일을 두 군데서 정하고 있었던 셈이다. + * + * 하나로 묶는다. 켜면 읽어 주고 말도 받는다. 끄면 둘 다 안 한다. + * + * 기기가 마이크를 못 주면 켜도 말하기 단추는 안 나온다. 켰는데 아무 일도 + * 안 일어나는 단추를 두지 않는다는 규칙은 그대로다. + * + * 훅이 아니라 그냥 읽는다. 조기 반환(`if (!소리로주고받나()) return null`)과 + * 섞이면 훅 순서가 깨지기 때문이다. App 이 설정을 구독하고 있어서, 스위치를 + * 바꾸면 App 이 다시 그려지고 이 값도 따라 바뀐다 — 이 파일이 simpleSteps · + * staffAssistancePreferred 를 읽는 방식과 같다. + */ +const 소리로주고받나 = (): boolean => 접근성설정.읽기().voiceGuide && 들을수있나(); + function ConsentCheck({ 동의함, on바꾸기, onDetail }: { 동의함: boolean; on바꾸기: (v: boolean) => void; @@ -149,9 +170,17 @@ function ConsentCheck({ 동의함, on바꾸기, onDetail }: { 한 줄로 줄였다. 무엇을 모으는지는 옆의 '자세히' 가 여는 개인정보 화면이 말한다 — 체크 옆에 두 줄을 붙여 두면 읽지 않고 넘기게 되고, 정작 자세한 설명은 그 화면에 또 있다. + + 예전에는 "개인정보 수집 동의서" 였다. 그런데 이 앱은 실제 개인정보를 + **받지 않는다** — 이름·전화번호·주소는 묻지도 저장하지도 않고, 적으려 + 하면 막는다(account.ts 의 개인정보같은글). 모으는 것은 메뉴 조건과 + 도움 설정뿐이다. + + 안 받는 것을 받는다고 적어 두면 사용자는 실제보다 더 많이 내주는 줄 + 알고 동의한다. 겁먹고 그만두는 사람도 생긴다. 하는 일을 그대로 적는다. */} - 개인정보 수집 동의서 + 서비스 이용 및 주문표 저장 안내를 확인했어요 + + {/* + 안내 언어 — 화면 맨 아래, 왼쪽이 한국어 오른쪽이 영어. + + 제목도 설명도 안 붙인다. 무엇을 고르는지는 글자 자체가 말한다 — + "한국어" 와 "English" 는 그 언어를 읽는 사람에게 각각 자기 말이다. + 설명을 한국어로 붙이면 영어만 읽는 사람에게는 읽을 수 없는 안내가 + 하나 더 늘 뿐이다. + + 맨 아래에 두는 이유는 이것이 이 화면에서 **하는 일이 아니라 되돌리는 + 길**이기 때문이다. 대부분은 안 건드리고 지나가고, 필요한 사람만 + 끝에서 찾는다. + */} +
+ {언어목록.map(({ code, label }) => ( + + ))} +
); @@ -593,7 +663,7 @@ function LoginScreen({ onDone, onBack, onGoSignup, 동의함, on동의, onPrivac {!채워짐 && (

- {동의함 ? "아이디와 비밀번호를 적으면 로그인할 수 있어요" : "동의하셔야 로그인할 수 있어요"} + {동의함 ? "아이디와 비밀번호를 적으면 로그인할 수 있어요" : "확인하셔야 로그인할 수 있어요"}

)} @@ -745,7 +815,7 @@ function SignupScreen({ onDone, onBack, onGoLogin, 동의함, on동의, onPrivac {!보낼수있나 && (

{아이디문제 ?? 비번문제 ?? 다시문제 - ?? (동의함 ? "아이디와 비밀번호를 적으면 가입할 수 있어요" : "동의하셔야 가입할 수 있어요")} + ?? (동의함 ? "아이디와 비밀번호를 적으면 가입할 수 있어요" : "확인하셔야 가입할 수 있어요")}

)} @@ -1027,6 +1097,13 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { */ const [상태, set상태] = useState<"쉬는중" | "듣는중" | "처리중">("쉬는중"); const [못들음, set못들음] = useState<못들은이유 | "못골랐어요" | null>(null); + /* + * 서버가 "이 둘 중 하나 같은데 확실치 않다" 고 할 때 되물을 값들(팀 #133). + * + * 우리가 고르지 않는다. 화면이 그 값들만 보여 주고 사람이 짚는다 — + * 짐작해서 넣으면 고르지 않은 것이 골라진다는 규칙은 여기서도 같다. + */ + const [되물을것, set되물을것] = useState(null); const 듣던것 = useRef<{ 그만두기: (보내기?: boolean) => void } | null>(null); const 회차 = useRef(0); @@ -1057,20 +1134,26 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { set칸(0); set상태("쉬는중"); set못들음(null); + set되물을것(null); }, [place]); - if (!들을수있나() || 축들.length === 0) return null; + if (!소리로주고받나() || 축들.length === 0) return null; const 지금축 = 축들[Math.min(칸, 축들.length - 1)]; const 마지막인가 = 칸 >= 축들.length - 1; const 다음으로 = () => { set못들음(null); + // 앞 축에 대한 되물음이 다음 축까지 따라오면 안 된다. + set되물을것(null); if (마지막인가) { onDone(); return; } set칸((n) => n + 1); }; // 고른 값을 넣고 다음 칸으로. 여러 개 고르는 칸은 이어 붙이고 그 자리에 머문다. const 넣기 = (고른것: string, 말로: boolean) => { + // 값이 정해졌으면 되물을 것도 없다. 여러 개 고르는 칸은 다음으로() 를 + // 안 지나가므로 여기서도 지운다. + set되물을것(null); const 이전 = 값[지금축.label] ?? []; on고르기(지금축.label, 지금축.multi ? [...new Set([...이전, 고른것])] : [고른것], 말로); if (지금축.multi) set못들음(null); @@ -1087,6 +1170,7 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { */ 그만읽기(); set못들음(null); + set되물을것(null); set상태("듣는중"); 회차.current += 1; // 단추를 누른 바로 이 자리에서 듣기 시작한다 — 브라우저는 사람이 누른 자리가 @@ -1114,6 +1198,34 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { const 답 = 예아니오(r.들은말, 언어 === "en-US"); if (답 === true) { 넣기(이축.choices[0], true); return; } if (답 === false) { 이축.choices.length === 2 ? 넣기(이축.choices[1], true) : 다음으로(); return; } + + /* + * 셋째 길 — 보기에 없는 말이면 서버에 물어본다(팀 #133). + * + * 여기까지 온 것은 화면 보기와도, 예/아니오와도 안 맞았다는 뜻이다. + * "불닭맛" 같은 말이 그렇다. 서버는 앵커 표현과의 유사도로 고르므로 + * 우리 표에 없는 말도 잡는다. + * + * 맵기만 물어본다. 다른 축은 아직 서버에 그 경로가 없다. + * + * 늦게 오는 답을 회차로 막는다 — 서버를 한 번 오가는 사이에 사용자가 + * 그만두거나 다음 축으로 갔을 수 있고, 그때 도착한 답을 넣으면 사용자가 + * 보고 있지도 않은 축이 채워진다. + */ + if (이축.label === "맵기") { + set상태("처리중"); + void 맵기물어보기(r.들은말, 언어 === "en-US").then((결과) => { + if (내회차 !== 회차.current) return; + set상태("쉬는중"); + if ("고른값" in 결과 && 이축.choices.includes(결과.고른값)) { 넣기(결과.고른값, true); return; } + if ("되물을것" in 결과) { + const 있는것 = 결과.되물을것.filter((v) => 이축.choices.includes(v)); + if (있는것.length > 0) { set되물을것(있는것); return; } + } + set못들음("못골랐어요"); + }); + return; + } set못들음("못골랐어요"); }); }; @@ -1171,7 +1283,14 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { const 고른것 = 값[지금축.label] ?? []; return (
-

+ {/* + 순번은 읽지 않는다(data-소리조용). + + 읽어 주면 "1번째 질문 전체 7개" 로 시작해서, 정작 무엇을 묻는지는 그 + 뒤에 온다. 듣는 사람에게 먼저 필요한 것은 몇 번째인지가 아니라 무엇을 + 묻는지다. 눈으로는 순번이 도움이 되니 화면에는 그대로 둔다. + */} +

{tf("{n}번째 질문 (전체 {전체}개)", { n: 칸 + 1, 전체: 축들.length })}

{t(지금축.label)}

@@ -1180,7 +1299,15 @@ function 한칸씩말하기({ place, 언어, 값, on고르기, onDone }: { {tf("{보기} — 이것으로 할까요? 그렇게 말씀하셔도 되고, 다른 것을 말씀하셔도 돼요.", { 보기: t(지금축.choices[0]) })}

-
+ {/* + 보기 칩도 읽지 않는다(data-소리조용). + + 바로 위 안내가 이미 무엇을 답하면 되는지 말하고 있다. 칩까지 읽으면 + "…말씀해 주세요. 켜기. 끄기." 처럼 같은 말을 두 번 듣게 되고, 답할 + 차례에 소리가 아직 안 끝나 있다. 손으로 고르는 사람에게는 화면에 + 그대로 보인다. + */} +
{지금축.choices.map((c) => (
- {들을수있나() ? ( + {소리로주고받나() ? ( <한칸씩말하기 place={place} 언어={언어} @@ -2093,7 +2238,7 @@ function SavedSheetsScreen({ + 새 주문표 추가 {/* 말로 만드는 길. 터치 화면에 음성 카드를 끼우지 않고 문에서 가른다. */} - {들을수있나() && ( + {소리로주고받나() && ( 말로 주문표 만들기 )} @@ -2773,7 +2918,10 @@ function SubScreenHeader({ title, kicker, spot, onBack }: { function ToggleRow({ label, sub, on, onToggle, }: { - label: string; sub: string; on: boolean; onToggle: () => void; + label: string; + /** 없으면 이름만 보여 준다. 첫 화면처럼 이름으로 이미 뜻이 통하는 자리에서 쓴다. */ + sub?: string; + on: boolean; onToggle: () => void; }) { return (