diff --git a/ai_ocr/clova_layout.py b/ai_ocr/clova_layout.py index 4fba07e..8a3fbc2 100644 --- a/ai_ocr/clova_layout.py +++ b/ai_ocr/clova_layout.py @@ -12,6 +12,7 @@ from statistics import mean, median from normalizer import normalize_name_text, normalize_price, remove_serving_amount +from price_metrics import count_matched_price_anchors _FORMATTED_PRICE_RE = re.compile( @@ -36,6 +37,32 @@ "포장됩니다", ) _EXCLUDED_MENU_PARTS = ("사리추가",) +_SIZE_LABELS = { + "소": "소", + "중": "중", + "대": "대", + "小": "소", + "中": "중", + "大": "대", +} +_SIZE_ORDER = ("소", "중", "대") +# menu_005에서 실제로 확인된 CLOVA 오인식이다. +# 단독 문자열 교정에는 사용하지 않는다. +_OBSERVED_SIZE_LABEL_MISREADS = { + "★": "대", + "ㅊ": "대", +} + + +@dataclass(frozen=True) +class SpatialOption: + label: str + price: int + price_raw: str + confidence: float + label_token: dict | None + price_token: dict + inferred: bool = False @dataclass(frozen=True) @@ -45,6 +72,7 @@ class SpatialPair: price_raw: str confidence: float source_tokens: tuple[dict, ...] + options: tuple[SpatialOption, ...] = () def extract_clova_fields(payload: dict) -> list[dict]: @@ -106,7 +134,17 @@ def parse_spatial_pairs(tokens: list[dict]) -> list[SpatialPair]: text_tokens = [token for token in expanded if token.get("price") is None] pairs: list[SpatialPair] = [] + consumed_price_ids: set[int] = set() + for option_group in _find_size_option_groups(price_tokens, text_tokens): + pair = _pair_option_group_with_name(option_group, price_tokens, text_tokens) + if pair is None: + continue + pairs.append(pair) + consumed_price_ids.update(id(option.price_token) for option in pair.options) + for price_token in price_tokens: + if id(price_token) in consumed_price_ids: + continue pair = _pair_price_with_name(price_token, price_tokens, text_tokens) if pair is not None: pairs.append(pair) @@ -115,6 +153,287 @@ def parse_spatial_pairs(tokens: list[dict]) -> list[SpatialPair]: return _deduplicate_pairs(pairs) +def _find_size_option_groups( + prices: list[dict], texts: list[dict] +) -> list[list[SpatialOption]]: + """같은 열에 반복되는 크기 라벨과 가격을 옵션 그룹으로 묶는다.""" + used_price_ids: set[int] = set() + options: list[SpatialOption] = [] + + size_tokens = sorted( + ( + token + for token in texts + if _normalize_size_label(token.get("text", "")) is not None + ), + key=lambda token: (int(token.get("page") or 1), _centerline_y_at(token, _center_x(token))), + ) + + for label_token in size_tokens: + candidates = [] + for price_token in prices: + if id(price_token) in used_price_ids: + continue + match_score = _label_price_match_score(label_token, price_token) + if match_score is None: + continue + candidates.append((*match_score, price_token)) + + if not candidates: + continue + + _, _, price_token = min(candidates, key=lambda item: (item[0], item[1])) + used_price_ids.add(id(price_token)) + options.append( + SpatialOption( + label=_normalize_size_label(label_token["text"]), + price=int(price_token["price"]), + price_raw=str(price_token["priceRaw"]), + confidence=round( + mean( + ( + float(label_token.get("confidence") or 0.0), + float(price_token.get("confidence") or 0.0), + ) + ), + 3, + ), + label_token=label_token, + price_token=price_token, + ) + ) + + groups: list[list[SpatialOption]] = [] + current: list[SpatialOption] = [] + for option in options: + if current and ( + option.label in {item.label for item in current} + or not _is_same_option_column(current[-1], option) + ): + if len(current) >= 2: + groups.append(current) + current = [] + current.append(option) + + if len(current) >= 2: + groups.append(current) + return groups + + +def _normalize_size_label(text: str) -> str | None: + compact = re.sub(r"\s+", "", text) + return _SIZE_LABELS.get(compact) or _OBSERVED_SIZE_LABEL_MISREADS.get(compact) + + +def _label_price_match_score( + label_token: dict, price_token: dict +) -> tuple[float, float] | None: + if price_token.get("page") != label_token.get("page"): + return None + + horizontal_gap = float(price_token["x1"]) - float(label_token["x2"]) + if horizontal_gap < -2: + return None + max_gap = max(_height(label_token), _height(price_token)) * 3.0 + if horizontal_gap > max_gap: + return None + + baseline_error = _baseline_error(label_token, price_token) + tolerance = max(_height(label_token), _height(price_token)) * 0.7 + 4.0 + if baseline_error > tolerance: + return None + return baseline_error, max(horizontal_gap, 0.0) + + +def _is_same_option_column(first: SpatialOption, second: SpatialOption) -> bool: + if first.label_token.get("page") != second.label_token.get("page"): + return False + + row_gap = _centerline_y_at(second.label_token, _center_x(second.label_token)) - _centerline_y_at( + first.label_token, _center_x(first.label_token) + ) + typical_height = max( + _height(first.label_token), + _height(second.label_token), + _height(first.price_token), + _height(second.price_token), + ) + if row_gap <= 0 or row_gap > typical_height * 3.0: + return False + + label_x_gap = abs(_center_x(first.label_token) - _center_x(second.label_token)) + price_x_gap = abs(float(first.price_token["x1"]) - float(second.price_token["x1"])) + return label_x_gap <= typical_height and price_x_gap <= typical_height * 1.5 + + +def _pair_option_group_with_name( + options: list[SpatialOption], prices: list[dict], texts: list[dict] +) -> SpatialPair | None: + option_label_ids = {id(option.label_token) for option in options} + name_tokens = [token for token in texts if id(token) not in option_label_ids] + + anchor = _find_option_name_anchor(options, prices, name_tokens) + options = _infer_missing_size_option( + options, + prices, + texts, + menu_name=anchor.name if anchor is not None else None, + ) + if anchor is None: + anchor = _find_option_name_anchor(options, prices, name_tokens) + if anchor is None: + return None + + source_tokens = list(anchor.source_tokens[:-1]) + for option in options: + if option.label_token is not None: + source_tokens.append(option.label_token) + source_tokens.append(option.price_token) + + unique_tokens: list[dict] = [] + seen_ids = set() + for token in source_tokens: + if id(token) in seen_ids: + continue + seen_ids.add(id(token)) + unique_tokens.append(token) + + option_confidence = mean(option.confidence for option in options) + return SpatialPair( + name=anchor.name, + price=options[0].price, + price_raw=options[0].price_raw, + confidence=round((anchor.confidence * 0.75) + (option_confidence * 0.25), 3), + source_tokens=tuple(unique_tokens), + options=tuple(options), + ) + + +def _find_option_name_anchor( + options: list[SpatialOption], prices: list[dict], texts: list[dict] +) -> SpatialPair | None: + for option in options: + anchor = _pair_price_with_name(option.price_token, prices, texts) + if anchor is not None: + return anchor + return None + + +def _infer_missing_size_option( + options: list[SpatialOption], + prices: list[dict], + texts: list[dict], + menu_name: str | None, +) -> list[SpatialOption]: + """두 확정 옵션 사이 또는 끝의 단 하나의 누락 라벨만 추론.""" + if len(options) != 2 or len({option.label for option in options}) != 2: + return options + + ordered = sorted(options, key=lambda option: _centerline_y_at( + option.price_token, _center_x(option.price_token) + )) + first_rank = _SIZE_ORDER.index(ordered[0].label) + second_rank = _SIZE_ORDER.index(ordered[1].label) + direction = 1 if second_rank > first_rank else -1 + display_order = _SIZE_ORDER if direction > 0 else tuple(reversed(_SIZE_ORDER)) + missing_labels = set(_SIZE_ORDER) - {option.label for option in ordered} + if len(missing_labels) != 1: + return options + + missing_label = missing_labels.pop() + first_index = display_order.index(ordered[0].label) + second_index = display_order.index(ordered[1].label) + missing_index = display_order.index(missing_label) + if first_index == second_index: + return options + + first_y = _centerline_y_at( + ordered[0].price_token, _center_x(ordered[0].price_token) + ) + second_y = _centerline_y_at( + ordered[1].price_token, _center_x(ordered[1].price_token) + ) + row_step = (second_y - first_y) / (second_index - first_index) + typical_height = median(_height(option.price_token) for option in ordered) + if row_step < typical_height * 0.45 or row_step > typical_height * 2.5: + return options + + expected_y = first_y + (row_step * (missing_index - first_index)) + candidates = [] + used_price_ids = {id(option.price_token) for option in ordered} + reference_x = mean(float(option.price_token["x2"]) for option in ordered) + for price_token in prices: + if id(price_token) in used_price_ids: + continue + if price_token.get("page") != ordered[0].price_token.get("page"): + continue + if abs(float(price_token["x2"]) - reference_x) > typical_height * 1.5: + continue + + price_y = _centerline_y_at(price_token, _center_x(price_token)) + y_error = abs(price_y - expected_y) + if y_error > max(4.0, typical_height * 0.55, row_step * 0.35): + continue + if not _is_monotonic_size_price(missing_label, price_token, ordered): + continue + if any( + _normalize_size_label(token.get("text", "")) is not None + and _label_price_match_score(token, price_token) is not None + for token in texts + ): + continue + + candidate_name = _pair_price_with_name(price_token, prices, texts) + if ( + menu_name + and candidate_name is not None + and re.sub(r"\s+", "", candidate_name.name) + != re.sub(r"\s+", "", menu_name) + ): + continue + candidates.append((y_error, price_token)) + + if len(candidates) != 1: + return options + + _, price_token = candidates[0] + inferred = SpatialOption( + label=missing_label, + price=int(price_token["price"]), + price_raw=str(price_token["priceRaw"]), + confidence=round( + min( + float(price_token.get("confidence") or 0.0), + mean(option.confidence for option in ordered) * 0.75, + ), + 3, + ), + label_token=None, + price_token=price_token, + inferred=True, + ) + return sorted( + [*options, inferred], + key=lambda option: _centerline_y_at( + option.price_token, _center_x(option.price_token) + ), + ) + + +def _is_monotonic_size_price( + missing_label: str, price_token: dict, options: list[SpatialOption] +) -> bool: + missing_rank = _SIZE_ORDER.index(missing_label) + missing_price = int(price_token["price"]) + for option in options: + option_rank = _SIZE_ORDER.index(option.label) + if missing_rank > option_rank and missing_price <= option.price: + return False + if missing_rank < option_rank and missing_price >= option.price: + return False + return True + + def count_price_anchors(tokens: list[dict]) -> int: return sum( 1 @@ -128,7 +447,8 @@ def parse_attempt_score(tokens: list[dict], menus: list[dict]) -> float: """원본/전처리 OCR 결과 중 더 구조적으로 일관된 결과를 고른다.""" anchors = count_price_anchors(tokens) paired = len(menus) - coverage = min(paired / anchors, 1.0) if anchors else 0.0 + matched_prices = count_matched_price_anchors(menus) + coverage = min(matched_prices / anchors, 1.0) if anchors else 0.0 confidences = [float(menu.get("confidence") or 0.0) for menu in menus] confidence = mean(confidences) if confidences else 0.0 single_char_ratio = ( @@ -150,7 +470,8 @@ def should_retry_with_preprocessing(tokens: list[dict], menus: list[dict]) -> bo anchors = count_price_anchors(tokens) if not tokens or not menus or len(menus) < 3: return True - coverage = len(menus) / anchors if anchors else 0.0 + matched_prices = count_matched_price_anchors(menus) + coverage = matched_prices / anchors if anchors else 0.0 single_chars = sum( len(re.sub(r"\s+", "", menu.get("rawName", ""))) == 1 for menu in menus ) diff --git a/ai_ocr/normalizer.py b/ai_ocr/normalizer.py index 9688af8..141188d 100644 --- a/ai_ocr/normalizer.py +++ b/ai_ocr/normalizer.py @@ -11,6 +11,37 @@ "가재미탕": "가자미탕", } +# 음식명 뒤에 반복적으로 표기되는 주요 원산지만 명시적으로 분리. +ORIGIN_LABELS = ( + "뉴질랜드산", + "네덜란드산", + "노르웨이산", + "오스트리아산", + "국내산", + "캐나다산", + "브라질산", + "스페인산", + "이탈리아산", + "베트남산", + "러시아산", + "멕시코산", + "프랑스산", + "덴마크산", + "호주산", + "미국산", + "중국산", + "일본산", + "태국산", + "칠레산", + "독일산", + "국산", +) +_ORIGIN_QUALIFIERS = ("암소한우", "한우", "육우", "암소", "거세우") +_ORIGIN_SUFFIX_RE = re.compile( + rf"(?P{'|'.join(re.escape(label) for label in ORIGIN_LABELS)})" + rf"\s*(?P{'|'.join(re.escape(label) for label in _ORIGIN_QUALIFIERS)})?\s*$" +) + def normalize_price(text: str): if not text: @@ -95,8 +126,32 @@ def remove_price(text: str): return normalize_name_text(without_price) +def split_menu_name_and_origin(text: str) -> tuple[str, str | None]: + """메뉴명 뒤의 원산지 표기를 분리하되 원문 의미는 보존. + + CLOVA 공간 파서가 괄호를 공백으로 정규화할 수 있어 + `국내산(암소한우)`와 `국내산 암소한우`를 같은 형태로 처리. + """ + normalized = normalize_name_text(text) + if not normalized: + return "", None + + match = _ORIGIN_SUFFIX_RE.search(normalized) + if not match: + return normalized, None + + menu_name = normalized[: match.start()].strip() + if len(re.sub(r"\s+", "", menu_name)) < 2: + return normalized, None + + origin_parts = [match.group("origin")] + if match.group("qualifier"): + origin_parts.append(match.group("qualifier")) + return menu_name, " ".join(origin_parts) + + def normalize_menu_name(text: str): - text = normalize_name_text(text) + text, _ = split_menu_name_and_origin(text) text = clean_menu_name_artifacts(text) text = re.sub(r"\s+", "", text) @@ -118,7 +173,7 @@ def match_known_menu_name(text: str): if not text: return None - candidate = normalize_name_text(text) + candidate, _ = split_menu_name_and_origin(text) candidate = clean_menu_name_artifacts(candidate) candidate = re.sub(r"\s+", "", candidate) candidate = candidate.replace("찌게", "찌개") diff --git a/ai_ocr/parser.py b/ai_ocr/parser.py index f2d4f28..117a487 100644 --- a/ai_ocr/parser.py +++ b/ai_ocr/parser.py @@ -10,6 +10,7 @@ normalize_price, normalize_price_detail, remove_price, + split_menu_name_and_origin, ) NOISE_KEYWORDS = ("영업", "전화", "예약", "원산지", "포장", "배달", "OPEN", "CLOSE", "메뉴판") @@ -75,11 +76,22 @@ def parse_clova_menu_candidates(tokens): menus = [] for pair in parse_spatial_pairs(tokens): + options = [] + for option in pair.options: + parsed_option = { + "name": option.label, + "price": option.price, + "priceRaw": option.price_raw, + } + if option.inferred: + parsed_option["inferred"] = True + options.append(parsed_option) menu = build_menu_item( pair.name, pair.price, list(pair.source_tokens), price_raw=pair.price_raw, + options=options, ) menu["confidence"] = pair.confidence menu["source"]["provider"] = "clova" @@ -314,8 +326,9 @@ def extract_price_text(text): def build_menu_item(raw_name, price, source_lines, price_raw=None, options=None): - normalized_name = normalize_menu_name(raw_name) - matched = match_known_menu_name(raw_name) + menu_name, origin_text = split_menu_name_and_origin(raw_name) + normalized_name = normalize_menu_name(menu_name) + matched = match_known_menu_name(menu_name) price_detail = normalize_price_detail(" ".join(line["text"] for line in source_lines)) if price_raw: price_detail = normalize_price_detail(price_raw) @@ -323,6 +336,7 @@ def build_menu_item(raw_name, price, source_lines, price_raw=None, options=None) item = { "rawName": raw_name, "normalizedCandidate": normalized_name, + "originText": origin_text, "price": price, "priceRaw": price_raw or price_detail["priceRaw"], "priceCorrected": price_detail["priceCorrected"], @@ -352,7 +366,9 @@ def build_menu_item(raw_name, price, source_lines, price_raw=None, options=None) item["matchScore"] = None item["nameCorrected"] = normalized_name != re.sub(r"\s+", "", raw_name) - if item["nameCorrected"]: + if origin_text: + item["correctionReason"] = "origin_metadata_removed" + elif item["nameCorrected"]: item["correctionReason"] = "known_ocr_typo_or_dictionary_match" else: item["correctionReason"] = None diff --git a/ai_ocr/price_metrics.py b/ai_ocr/price_metrics.py new file mode 100644 index 0000000..072230a --- /dev/null +++ b/ai_ocr/price_metrics.py @@ -0,0 +1,13 @@ +def count_matched_price_anchors(menus: list[dict]) -> int: + """옵션 가격을 포함해 실제 메뉴에 연결된 가격 anchor 수를 센다.""" + matched = 0 + for menu in menus: + options = [ + option + for option in menu.get("options", []) + if option.get("priceRaw") or option.get("price") is not None + ] + matched += len(options) if options else int( + bool(menu.get("priceRaw") or menu.get("price") is not None) + ) + return matched diff --git a/ai_ocr/result_builder.py b/ai_ocr/result_builder.py index cd8dd0f..c26ff15 100644 --- a/ai_ocr/result_builder.py +++ b/ai_ocr/result_builder.py @@ -4,6 +4,7 @@ from spicy_detector import infer_is_spicy from image_quality import analyze_image_quality +from price_metrics import count_matched_price_anchors def build_final_result( @@ -85,6 +86,15 @@ def build_menu_analysis(menu, display_order: int): menu_name = menu.get("normalizedCandidate") or menu.get("rawName") or menu.get("matchedMenu") description = menu.get("description") or "" price_text = stringify_price(menu.get("price")) or menu.get("priceRaw") + price_options = [ + { + "label": option.get("name"), + "price": option.get("price"), + **({"inferred": True} if option.get("inferred") else {}), + } + for option in menu.get("options", []) + if option.get("name") and option.get("price") is not None + ] return { "menu_name_ko": menu_name, @@ -92,6 +102,8 @@ def build_menu_analysis(menu, display_order: int): "description_ko": description, "description_en": None, "price_text": price_text, + "price_options": price_options, + "origin_text": menu.get("originText"), "risk_level": None, "is_spicy": infer_is_spicy( { @@ -123,7 +135,9 @@ def build_scan_quality(image: Path, menus, raw_lines=None): else 0.0 ) price_match_ratio = pair_coverage if price_anchor_count else ( - round(price_match_count / menu_count, 2) if menu_count else 0.0 + round(min(price_match_count / menu_count, 1.0), 2) + if menu_count + else 0.0 ) ocr_confidences = [ float(line.get("confidence")) @@ -219,7 +233,7 @@ def build_scan_quality(image: Path, menus, raw_lines=None): def count_price_matches(menus): - return sum(1 for menu in menus if menu.get("priceRaw") or menu.get("price") is not None) + return count_matched_price_anchors(menus) def count_detected_price_anchors(raw_lines): diff --git a/tests/test_clova_ocr.py b/tests/test_clova_ocr.py index 8a7fca3..0c02338 100644 --- a/tests/test_clova_ocr.py +++ b/tests/test_clova_ocr.py @@ -13,10 +13,16 @@ import image_quality # noqa: E402 import ocr_client # noqa: E402 -from clova_layout import count_price_anchors, extract_clova_fields # noqa: E402 +from clova_layout import ( # noqa: E402 + count_price_anchors, + extract_clova_fields, + parse_spatial_pairs, + should_retry_with_preprocessing, +) from ocr_client import ClovaOCRClient, OCRConfigError # noqa: E402 -from parser import parse_menu_candidates # noqa: E402 -from result_builder import build_final_result # noqa: E402 +from normalizer import normalize_menu_name, split_menu_name_and_origin # noqa: E402 +from parser import build_menu_item, parse_menu_candidates # noqa: E402 +from result_builder import build_final_result, build_menu_analysis # noqa: E402 _OCR_MAIN_SPEC = importlib.util.spec_from_file_location( "ocr_main_under_test", OCR_DIR / "main.py" @@ -93,6 +99,236 @@ def test_clova_client_rejects_placeholder_or_non_https_endpoint( } +@pytest.mark.parametrize( + ("raw_name", "expected_menu", "expected_origin"), + [ + ("육회국내산 암소한우", "육회", "국내산 암소한우"), + ("육회비빔밥 국내산", "육회비빔밥", "국내산"), + ("소고기국밥호주산", "소고기국밥", "호주산"), + ("도가니탕 미국산", "도가니탕", "미국산"), + ("수제돈까스(국내산)", "수제돈까스", "국내산"), + ], +) +def test_origin_suffix_is_separated_from_menu_name( + raw_name, expected_menu, expected_origin +): + menu_name, origin_text = split_menu_name_and_origin(raw_name) + + assert normalize_menu_name(menu_name) == expected_menu + assert origin_text == expected_origin + + +@pytest.mark.parametrize( + "menu_name", + ["자연산광어회", "부산어묵", "산채비빔밥", "한우육회", "국내산 한우육회"], +) +def test_non_suffix_origin_like_text_is_not_removed(menu_name): + split_name, origin_text = split_menu_name_and_origin(menu_name) + + assert split_name == menu_name + assert origin_text is None + + +def test_origin_is_preserved_in_ocr_menu_response(): + source_lines = [ + {"text": "소고기국밥 호주산", "page": 1, "x1": 0, "y1": 0, "x2": 100, "y2": 20}, + {"text": "8,000", "page": 1, "x1": 110, "y1": 0, "x2": 160, "y2": 20}, + ] + menu = build_menu_item( + "소고기국밥 호주산", 8000, source_lines, price_raw="8,000" + ) + + assert menu["normalizedCandidate"] == "소고기국밥" + assert menu["originText"] == "호주산" + assert menu["correctionReason"] == "origin_metadata_removed" + + response = build_menu_analysis(menu, display_order=1) + assert response["menu_name_ko"] == "소고기국밥" + assert response["origin_text"] == "호주산" + + +def _clova_token(text, x1, y1, x2, y2): + return { + "text": text, + "page": 1, + "x1": x1, + "y1": y1, + "x2": x2, + "y2": y2, + "polygon": ((x1, y1), (x2, y1), (x2, y2), (x1, y2)), + "confidence": 0.99, + "source": "clova_field", + } + + +def test_korean_size_rows_are_grouped_into_single_menu_options(): + tokens = [ + _clova_token("소고기버섯전골", 100, 100, 240, 124), + _clova_token("소", 250, 100, 270, 124), + _clova_token("30,000", 290, 100, 350, 124), + _clova_token("대", 250, 132, 270, 156), + _clova_token("40,000", 290, 132, 350, 156), + _clova_token("매운갈비찜", 100, 172, 220, 196), + _clova_token("소", 250, 172, 270, 196), + _clova_token("22,000", 290, 172, 350, 196), + _clova_token("중", 250, 204, 270, 228), + _clova_token("33,000", 290, 204, 350, 228), + _clova_token("대", 250, 236, 270, 260), + _clova_token("45,000", 290, 236, 350, 260), + ] + + pairs = parse_spatial_pairs(tokens) + + assert [(pair.name, pair.price) for pair in pairs] == [ + ("소고기버섯전골", 30000), + ("매운갈비찜", 22000), + ] + assert [[(option.label, option.price) for option in pair.options] for pair in pairs] == [ + [("소", 30000), ("대", 40000)], + [("소", 22000), ("중", 33000), ("대", 45000)], + ] + + menus = parse_menu_candidates(tokens) + response = build_menu_analysis(menus[1], display_order=2) + assert response["menu_name_ko"] == "매운갈비찜" + assert response["price_text"] == "22000" + assert response["price_options"] == [ + {"label": "소", "price": 22000}, + {"label": "중", "price": 33000}, + {"label": "대", "price": 45000}, + ] + + +def test_single_korean_size_token_is_not_treated_as_option_group(): + tokens = [ + _clova_token("소고기", 100, 100, 180, 124), + _clova_token("소", 250, 100, 270, 124), + _clova_token("20,000", 290, 100, 350, 124), + ] + + pairs = parse_spatial_pairs(tokens) + + assert len(pairs) == 1 + assert pairs[0].name == "소고기 소" + assert pairs[0].options == () + + +def test_cjk_size_labels_are_normalized_to_korean_options(): + tokens = [ + _clova_token("감자탕", 100, 100, 180, 124), + _clova_token("大", 250, 100, 270, 124), + _clova_token("39,000", 290, 100, 350, 124), + _clova_token("中", 250, 132, 270, 156), + _clova_token("34,000", 290, 132, 350, 156), + _clova_token("小", 250, 164, 270, 188), + _clova_token("28,000", 290, 164, 350, 188), + ] + + pairs = parse_spatial_pairs(tokens) + + assert len(pairs) == 1 + assert pairs[0].name == "감자탕" + assert [(option.label, option.price) for option in pairs[0].options] == [ + ("대", 39000), + ("중", 34000), + ("소", 28000), + ] + + +def test_menu_005_recovers_cjk_and_observed_misread_size_labels(): + menus = parse_menu_candidates(_sample_tokens("menu_005")) + menus_by_name = {menu["normalizedCandidate"]: menu for menu in menus} + + assert menus_by_name["감자탕"]["options"] == [ + {"name": "대", "price": 39000, "priceRaw": "39,000"}, + {"name": "중", "price": 34000, "priceRaw": "34,000"}, + { + "name": "소", + "price": 28000, + "priceRaw": "28,000", + "inferred": True, + }, + ] + assert menus_by_name["등뼈찜"]["options"] == [ + {"name": "대", "price": 44000, "priceRaw": "44,000"}, + {"name": "중", "price": 38000, "priceRaw": "38,000"}, + ] + + response = build_menu_analysis(menus_by_name["감자탕"], display_order=1) + assert response["price_options"][-1] == { + "label": "소", + "price": 28000, + "inferred": True, + } + + +@pytest.mark.parametrize( + ("missing_label", "visible_rows", "missing_price"), + [ + ("대", [("중", 34000), ("소", 28000)], 39000), + ("중", [("대", 39000), ("소", 28000)], 34000), + ("소", [("대", 39000), ("중", 34000)], 28000), + ], +) +def test_missing_size_label_is_inferred_at_any_position( + missing_label, visible_rows, missing_price +): + rows = [("대", 39000), ("중", 34000), ("소", 28000)] + tokens = [_clova_token("감자탕", 100, 100, 180, 124)] + for index, (label, price) in enumerate(rows): + y1 = 100 + (index * 32) + if (label, price) in visible_rows: + tokens.append(_clova_token(label, 250, y1, 270, y1 + 24)) + tokens.append( + _clova_token(f"{price:,}", 290, y1, 350, y1 + 24) + ) + + pairs = parse_spatial_pairs(tokens) + + assert len(pairs) == 1 + inferred = next(option for option in pairs[0].options if option.inferred) + assert (inferred.label, inferred.price) == (missing_label, missing_price) + + +def test_missing_label_inference_does_not_absorb_next_menu_price(): + tokens = [ + _clova_token("감자탕", 100, 100, 180, 124), + _clova_token("대", 250, 100, 270, 124), + _clova_token("39,000", 290, 100, 350, 124), + _clova_token("중", 250, 132, 270, 156), + _clova_token("34,000", 290, 132, 350, 156), + _clova_token("해물부추전", 100, 164, 220, 188), + _clova_token("15,000", 290, 164, 350, 188), + ] + + pairs = parse_spatial_pairs(tokens) + pairs_by_name = {pair.name: pair for pair in pairs} + + assert {pair.name for pair in pairs} == {"감자탕", "해물부추전"} + actual_options = [ + (option.label, option.price) + for option in pairs_by_name["감자탕"].options + ] + assert actual_options == [ + ("대", 39000), + ("중", 34000), + ] + + +def test_observed_misread_symbol_is_not_corrected_without_repeated_option_rows(): + tokens = [ + _clova_token("감자탕", 100, 100, 180, 124), + _clova_token("★", 250, 100, 270, 124), + _clova_token("39,000", 290, 100, 350, 124), + ] + + pairs = parse_spatial_pairs(tokens) + + assert len(pairs) == 1 + assert pairs[0].name == "감자탕" + assert pairs[0].options == () + + @pytest.mark.parametrize("sample_name", ["menu_001", "menu_002", "menu_003"]) def test_clova_sample_extracts_exact_menu_price_pairs(sample_name): payload_path = ROOT / "sample_data" / f"clova_response_{sample_name}.json" @@ -146,6 +382,45 @@ def test_low_resolution_with_complete_pairs_is_reviewable_not_hard_failure(): assert quality["pair_coverage"] == 1.0 +def test_menu_005_quality_counts_all_option_price_anchors(): + tokens = _sample_tokens("menu_005") + menus = parse_menu_candidates(tokens) + + result = build_final_result( + str(ROOT / "images" / "menu_005.jpg"), + menus, + raw_lines=tokens, + enable_gpt_post_process=False, + enable_gpt_judgment=False, + ) + quality = result["scan_quality"] + + assert len(menus) == 18 + assert quality["price_anchor_count"] == 21 + assert quality["price_match_count"] == 21 + assert quality["pair_coverage"] == 1.0 + + +def test_option_prices_prevent_unnecessary_preprocessing_retry(): + tokens = [] + for menu_index, menu_name in enumerate(("감자탕", "전골", "갈비찜")): + y1 = 100 + (menu_index * 80) + tokens.extend( + [ + _clova_token(menu_name, 100, y1, 180, y1 + 24), + _clova_token("소", 250, y1, 270, y1 + 24), + _clova_token("20,000", 290, y1, 350, y1 + 24), + _clova_token("대", 250, y1 + 32, 270, y1 + 56), + _clova_token("30,000", 290, y1 + 32, 350, y1 + 56), + ] + ) + + menus = parse_menu_candidates(tokens) + + assert len(menus) == 3 + assert should_retry_with_preprocessing(tokens, menus) is False + + def test_clova_client_sends_v2_multipart_request(tmp_path, monkeypatch): monkeypatch.setenv("CLOVA_OCR_URL", "https://example.test/ocr") monkeypatch.setenv("CLOVA_OCR_SECRET", "test-secret")