| |
| """Rule-based question router. |
| |
| The reader network never sees the question. This module turns a free-form |
| Korean or English question into a field selector, then indexes the record the |
| network produced. It is dependency-free and is shipped alongside the model, |
| because the ONNX graph alone cannot answer a question. |
| |
| The model reads exactly two things: the phone number's digits and the street |
| number's digits. Anything else must return an empty answer. A router that |
| guesses is worse than one that declines, because a plausible wrong number is |
| indistinguishable from a right one downstream. |
| |
| Divergence from `tiny_receipt_vqa/train.py` |
| ------------------------------------------- |
| The regex bodies started as a verbatim copy of that file's |
| `route_family_from_question` and `phone_op_from_question`. They have since been |
| corrected, because in the baseline the router only picked an adapter โ a wrong |
| route still produced an answer from the network โ whereas here the router |
| *decides the answer*. The same code is far more dangerous in this position. |
| |
| Four fixes, each covered by `test_question_router.py`: |
| |
| 1. Item, price, and store-name questions are matched *before* address. The |
| Korean particle `๋ก` is a substring of ordinary words (`ํฉ๊ณ๋ก`, `์ ํ์ผ๋ก`, |
| `์ธ๋ก`), so an address test that runs first swallows them. |
| 2. An address question only yields the street number when it actually asks for |
| a number. `๊ฐ๊ฒ ์ฃผ์๊ฐ ๋ฌด์์
๋๊น?` now returns nothing instead of the |
| street number. |
| 3. Digit indices are range-checked. `digit 0` used to index `phone[-1]` and |
| return the last digit. |
| 4. `last digit` / `๋์๋ฆฌ` with no explicit ordinal now resolve to `back_1` |
| instead of falling through to an empty answer. |
| """ |
| from __future__ import annotations |
|
|
| import re |
| import unicodedata |
|
|
| __all__ = [ |
| "clean_text", |
| "digits_only", |
| "normalize_address_text", |
| "street_no_from_address", |
| "ordinal_en", |
| "route_family_from_question", |
| "phone_op_from_question", |
| "address_op_from_question", |
| "answer_from_record", |
| "SUPPORTED_FAMILIES", |
| ] |
|
|
| SUPPORTED_FAMILIES = ("phone", "address") |
|
|
| _ORDINALS = {"first": 1, "second": 2, "third": 3, "fourth": 4, |
| "fifth": 5, "sixth": 6, "seventh": 7, "eighth": 8, |
| "ninth": 9, "tenth": 10} |
|
|
|
|
| def clean_text(s: object) -> str: |
| s = str(s if s is not None else "") |
| return unicodedata.normalize("NFC", re.sub(r"\s+", " ", s.replace("\n", " ")).strip()) |
|
|
|
|
| def digits_only(s: object) -> str: |
| return "".join(re.findall(r"\d", str(s if s is not None else ""))) |
|
|
|
|
| def normalize_address_text(s: object) -> str: |
| text = clean_text(s) |
| return re.sub(r"(\d+(?:\s+\d+)+)$", lambda m: re.sub(r"\s+", "", m.group(1)), text) |
|
|
|
|
| def street_no_from_address(address: object) -> str: |
| text = normalize_address_text(address) |
| match = re.search(r"(\d+(?:\s+\d+)*)\s*$", text) |
| return digits_only(match.group(1)) if match else "" |
|
|
|
|
| def ordinal_en(n: int) -> str: |
| names = {v: k for k, v in _ORDINALS.items()} |
| return names.get(n, f"{n}th") |
|
|
|
|
| def _is_phone(q: str, lower: str) -> bool: |
| return bool(re.search(r"\b(?:phone|telephone|tel)\b", lower)) or \ |
| any(k in q for k in ("์ ํ", "ํฐ๋ฒํธ", "์ฐ๋ฝ์ฒ")) |
|
|
|
|
| def _is_item(q: str, lower: str) -> bool: |
| return bool(re.search(r"\b(?:item|items|product|products|price|prices|qty|quantity|" |
| r"total|totals|subtotal|amount|cost|purchased|bought)\b", lower)) or \ |
| any(k in q for k in ("ํ๋ชฉ", "์ํ", "์ ํ", "๊ฐ๊ฒฉ", "๋จ๊ฐ", "์๋", |
| "๊ตฌ๋งค", "๊ตฌ์
", "์ด์ก", "๊ธ์ก", "ํฉ๊ณ", "๊ฐ์")) |
|
|
|
|
| def _is_store_name(q: str, lower: str) -> bool: |
| return bool(re.search(r"\b(?:store name|shop name|merchant|business name|" |
| r"name of the (?:store|shop))\b", lower)) or \ |
| any(k in q for k in ("์ํธ", "๊ฐ๊ฒ ์ด๋ฆ", "๋งค์ฅ ์ด๋ฆ", "์ ํฌ ์ด๋ฆ", "๊ฐ๊ฒ๋ช
", "๋งค์ฅ๋ช
")) |
|
|
|
|
| def _is_address(q: str, lower: str) -> bool: |
| return bool(re.search(r"\b(?:address|street|road|location)\b", lower)) or \ |
| bool(re.search(r"\b(?:st|rd)\.", lower)) or \ |
| any(k in q for k in ("์ฃผ์", "๋๋ก๋ช
", "์์น", "๋ฒ์ง")) or \ |
| bool(re.search(r"[๊ฐ-ํฃ]{1,10}(?:๊ธธ|๋ก|๋๋ก)\s*\[?\?", q)) |
|
|
|
|
| def route_family_from_question(question: object) -> str: |
| """'phone', 'address', 'item', 'store', or 'other'. |
| |
| Only 'phone' and 'address' are answerable; the rest exist so that an |
| unsupported question is recognised rather than mistaken for a supported one. |
| Order matters โ see the module docstring. |
| """ |
| q = clean_text(question) |
| lower = q.lower() |
| if _is_phone(q, lower): |
| return "phone" |
| if _is_item(q, lower): |
| return "item" |
| if _is_store_name(q, lower): |
| return "store" |
| if _is_address(q, lower): |
| return "address" |
| return "other" |
|
|
|
|
| def phone_op_from_question(question: str) -> str: |
| """'front_N', 'back_N', or 'phone_digit' when no index can be recovered.""" |
| q = clean_text(question).lower() |
| op = "phone_digit" |
| m = re.search(r"\b(" + "|".join(_ORDINALS) + r")\b", q) |
| if m: |
| op = f"front_{_ORDINALS[m.group(1)]}" |
| m = re.search(r"(?:front of|from the front|digit)\D*(\d+)", q) |
| if m: |
| op = f"front_{m.group(1)}" |
| m = re.search(r"(?:from the end|from the back|from the right|last)\D*(\d+)", q) |
| if m: |
| op = f"back_{m.group(1)}" |
| m = re.search(r"์์์\s*(\d+)\s*๋ฒ์งธ", q) |
| if m: |
| op = f"front_{m.group(1)}" |
| m = re.search(r"(?:์|์์๋ฆฌ)\s*(\d+)\s*(?:๋ฒ์งธ|๋ฒ)?", q) |
| if m: |
| op = f"front_{m.group(1)}" |
| m = re.search(r"๋ค์์\s*(\d+)\s*๋ฒ์งธ", q) |
| if m: |
| op = f"back_{m.group(1)}" |
| m = re.search(r"(?:๋ค|๋ท์๋ฆฌ|๋์๋ฆฌ)\s*(\d+)\s*(?:๋ฒ์งธ|๋ฒ)?", q) |
| if m: |
| op = f"back_{m.group(1)}" |
| if any(k in q for k in ("from the back", "from the end", "from last")): |
| m = re.search(r"\b(" + "|".join(_ORDINALS) + r")\b", q) |
| if m: |
| op = f"back_{_ORDINALS[m.group(1)]}" |
| if op == "phone_digit": |
| |
| if re.search(r"\blast\b", q) or any(k in q for k in ("๋์๋ฆฌ", "๋ง์ง๋ง")): |
| op = "back_1" |
| elif re.search(r"\bfirst\b", q) or "์ฒซ์๋ฆฌ" in q or "์ฒซ ๋ฒ์งธ" in q: |
| op = "front_1" |
| return op |
|
|
|
|
| def address_op_from_question(question: object) -> str: |
| """'street_no' when the question asks for the street number, else 'unsupported'. |
| |
| The model cannot transcribe an address, so a question about the address text |
| has to decline rather than hand back the number it happens to hold. |
| """ |
| q = clean_text(question) |
| lower = q.lower() |
| if "[?]" in q or "?]" in q: |
| return "street_no" |
| if any(k in lower for k in ("fill the blank", "fill in the blank")): |
| return "street_no" |
| if any(k in q for k in ("๋น ์นธ", "๋น์นธ")): |
| return "street_no" |
| if re.search(r"\b(?:street|road|building|house|block)\s*(?:number|no\.?|num)\b", lower): |
| return "street_no" |
| if re.search(r"\bnumber\b.*\b(?:address|street|road)\b", lower) or \ |
| re.search(r"\b(?:address|street|road)\b.*\bnumber\b", lower): |
| return "street_no" |
| if "๋ฒ์ง" in q: |
| return "street_no" |
| if re.search(r"(?:๋๋ก๋ช
|์ฃผ์|์์น|๊ธธ|๋ก)\s*(?:๋ค|๋ค์|๋|๋ง์ง๋ง)?\s*(?:์)?\s*์ซ์", q): |
| return "street_no" |
| if re.search(r"์ซ์", q) and any(k in q for k in ("์ฃผ์", "๋๋ก๋ช
", "์์น")): |
| return "street_no" |
| return "unsupported" |
|
|
|
|
| def answer_from_record(question: str, phone: str, street: str) -> str: |
| """Index an already-read record. Returns '' when the question cannot be served.""" |
| family = route_family_from_question(question) |
| if family == "address": |
| return street if address_op_from_question(question) == "street_no" else "" |
| if family == "phone": |
| m = re.match(r"(front|back)_(\d+)$", phone_op_from_question(question)) |
| if not m or not phone: |
| return "" |
| i = int(m.group(2)) |
| if not 1 <= i <= len(phone): |
| return "" |
| return phone[i - 1] if m.group(1) == "front" else phone[-i] |
| return "" |
|
|