Harden multilingual customer capture (recover from raw message)
Browse filesA customer name is a proper noun that survives translation, so recover it
directly from the raw message as a belt-and-suspenders fill, independent of
language — fixes Shona/Swahili sales losing the customer (and thus credit
tracking / "who owes me").
- _recover_customer_name: recipient markers to|for, kuna|kune|kuye|kunaye (Shona),
kwa (Swahili/Chewa) + a Capitalised name (so "kwa ajili", "for cash" don't match);
supports multi-word names ("Mai Chisomo").
- _fill_missing_customer runs on every sale path (deterministic, LLM, vision) over
both raw and translated text; only fills when customer is empty, never overrides,
never sets the item as the customer.
- Translate prompt: keep names/numbers verbatim, preserve "kuna <Name>" -> "to <Name>".
- ADR 0012.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docs/decisions/0012-language-agnostic-customer-recovery.md +42 -0
- docs/decisions/README.md +1 -0
- main.py +4 -0
- utility.py +72 -0
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 12. Language-agnostic customer-name recovery from the raw message
|
| 2 |
+
|
| 3 |
+
Date: 2026-06-25
|
| 4 |
+
Status: Accepted
|
| 5 |
+
|
| 6 |
+
## Context
|
| 7 |
+
Multilingual / code-switched input is a core selling point, but customer capture leaned on
|
| 8 |
+
the English deterministic parser or the LLM, both running on the *translated* text. When
|
| 9 |
+
translation reworded or dropped the recipient — Shona "ndatengesa … kuna Chipo akandipa 2
|
| 10 |
+
dollars", "sold … to Audry and she paid …" — the customer was lost, so credit sales never
|
| 11 |
+
landed on a party balance and "who owes me" came back empty. Relying on translation quality
|
| 12 |
+
or the model remembering to fill `details.customer` was too fragile for the headline feature.
|
| 13 |
+
|
| 14 |
+
## Decision
|
| 15 |
+
A customer name is a **proper noun that survives translation**, so recover it directly from
|
| 16 |
+
the **raw** message as a belt-and-suspenders fill, independent of language.
|
| 17 |
+
- `_recover_customer_name(text)` scans for a recipient marker followed by a **Capitalised**
|
| 18 |
+
name: English `to|for`, Shona `kuna|kune|kuye|kunaye`, Swahili/Chewa `kwa`. Markers are
|
| 19 |
+
case-insensitive; the name must be capitalised so "kwa ajili" / "for cash" / "to me" don't
|
| 20 |
+
match. Bare `ku` is excluded as too ambiguous. Names up to 3 words ("Mai Chisomo").
|
| 21 |
+
- `_fill_missing_customer(txns, *texts)` runs on every sale path (deterministic, LLM, vision)
|
| 22 |
+
and only fills `details.customer` when it's **empty** — it never overrides the parser/LLM,
|
| 23 |
+
and never sets the customer to the item being sold.
|
| 24 |
+
- It runs against both the raw `message_text` and the translated `english_text`, so a name
|
| 25 |
+
is caught whether it survived translation or only exists in the original.
|
| 26 |
+
- The translate prompt now also instructs the model to keep names/numbers verbatim and
|
| 27 |
+
preserve "kuna <Name>" → "to <Name>".
|
| 28 |
+
|
| 29 |
+
## Consequences
|
| 30 |
+
- Credit sales, repayments, and "who owes me" work for Shona/Swahili/English phrasings, not
|
| 31 |
+
just clean English — the multilingual promise holds end to end.
|
| 32 |
+
- Three independent layers now capture the customer (deterministic parser, LLM with examples,
|
| 33 |
+
raw-text recovery); a miss in one is caught by another.
|
| 34 |
+
- Marker/name heuristics are conservative (capitalised names only) to avoid false positives;
|
| 35 |
+
a lower-case native name in casual text still relies on the parser/LLM layers.
|
| 36 |
+
|
| 37 |
+
## Rejected alternatives
|
| 38 |
+
- **Trust translation + LLM only** — the original failure; one reword drops the name.
|
| 39 |
+
- **Run NER / a name model** — heavyweight and another dependency for what a marker + capital
|
| 40 |
+
heuristic handles for the languages we serve.
|
| 41 |
+
- **Match any word after a marker (no capitalisation)** — false-positives on common phrases
|
| 42 |
+
like Swahili "kwa ajili ya" ("for the sake of").
|
|
@@ -32,3 +32,4 @@ Context · Decision · Consequences · Rejected alternatives.
|
|
| 32 |
| [0009](0009-preserve-english-text-and-pretranslation-crud.md) | Preserve raw English input; parse CRUD-by-id before translation | Accepted |
|
| 33 |
| [0010](0010-capture-and-remember-stock-cost-price.md) | Capture the stock-in cost price once, then remember it | Superseded by [0011](0011-two-price-model-cost-and-selling.md) |
|
| 34 |
| [0011](0011-two-price-model-cost-and-selling.md) | Two-price model: cost from the purchase, a prompt for the selling price | Accepted |
|
|
|
|
|
|
| 32 |
| [0009](0009-preserve-english-text-and-pretranslation-crud.md) | Preserve raw English input; parse CRUD-by-id before translation | Accepted |
|
| 33 |
| [0010](0010-capture-and-remember-stock-cost-price.md) | Capture the stock-in cost price once, then remember it | Superseded by [0011](0011-two-price-model-cost-and-selling.md) |
|
| 34 |
| [0011](0011-two-price-model-cost-and-selling.md) | Two-price model: cost from the purchase, a prompt for the selling price | Accepted |
|
| 35 |
+
| [0012](0012-language-agnostic-customer-recovery.md) | Language-agnostic customer-name recovery from the raw message | Accepted |
|
|
@@ -40,6 +40,7 @@ from utility import (
|
|
| 40 |
apply_price_override,
|
| 41 |
resolve_sale_prices,
|
| 42 |
resolve_stock_costs,
|
|
|
|
| 43 |
check_sale_stock,
|
| 44 |
_infer_prices,
|
| 45 |
_money_to_float,
|
|
@@ -1384,6 +1385,7 @@ def _handle_vision_result(mobile: str, result: str, caption: Optional[str]) -> N
|
|
| 1384 |
sale_txns = parse_vision_sale_transactions(result, currency=currency)
|
| 1385 |
|
| 1386 |
if sale_txns:
|
|
|
|
| 1387 |
resolve_sale_prices(db, mobile, sale_txns)
|
| 1388 |
sale_txns = _enforce_stock(mobile, sale_txns)
|
| 1389 |
if not sale_txns:
|
|
@@ -1595,6 +1597,7 @@ def process_text_message(message_text: str, mobile: str,
|
|
| 1595 |
direct_txns = parse_direct_sale_text(english_text, currency=user_currency) or \
|
| 1596 |
parse_stock_in_text(english_text, currency=user_currency)
|
| 1597 |
if direct_txns:
|
|
|
|
| 1598 |
resolve_sale_prices(db, mobile, direct_txns)
|
| 1599 |
resolve_stock_costs(db, mobile, direct_txns)
|
| 1600 |
direct_txns = _enforce_stock(mobile, direct_txns)
|
|
@@ -1633,6 +1636,7 @@ def process_text_message(message_text: str, mobile: str,
|
|
| 1633 |
llm_response_str = generateResponse(english_text, currency=user_currency)
|
| 1634 |
parsed_trans_data = parse_multiple_transactions(llm_response_str)
|
| 1635 |
parsed_trans_data = _correct_sale_stock_mix(parsed_trans_data, english_text)
|
|
|
|
| 1636 |
|
| 1637 |
response_msg = ""
|
| 1638 |
send_image = False
|
|
|
|
| 40 |
apply_price_override,
|
| 41 |
resolve_sale_prices,
|
| 42 |
resolve_stock_costs,
|
| 43 |
+
_fill_missing_customer,
|
| 44 |
check_sale_stock,
|
| 45 |
_infer_prices,
|
| 46 |
_money_to_float,
|
|
|
|
| 1385 |
sale_txns = parse_vision_sale_transactions(result, currency=currency)
|
| 1386 |
|
| 1387 |
if sale_txns:
|
| 1388 |
+
_fill_missing_customer(sale_txns, caption or "")
|
| 1389 |
resolve_sale_prices(db, mobile, sale_txns)
|
| 1390 |
sale_txns = _enforce_stock(mobile, sale_txns)
|
| 1391 |
if not sale_txns:
|
|
|
|
| 1597 |
direct_txns = parse_direct_sale_text(english_text, currency=user_currency) or \
|
| 1598 |
parse_stock_in_text(english_text, currency=user_currency)
|
| 1599 |
if direct_txns:
|
| 1600 |
+
_fill_missing_customer(direct_txns, message_text, english_text)
|
| 1601 |
resolve_sale_prices(db, mobile, direct_txns)
|
| 1602 |
resolve_stock_costs(db, mobile, direct_txns)
|
| 1603 |
direct_txns = _enforce_stock(mobile, direct_txns)
|
|
|
|
| 1636 |
llm_response_str = generateResponse(english_text, currency=user_currency)
|
| 1637 |
parsed_trans_data = parse_multiple_transactions(llm_response_str)
|
| 1638 |
parsed_trans_data = _correct_sale_stock_mix(parsed_trans_data, english_text)
|
| 1639 |
+
_fill_missing_customer(parsed_trans_data, message_text, english_text)
|
| 1640 |
|
| 1641 |
response_msg = ""
|
| 1642 |
send_image = False
|
|
@@ -184,6 +184,9 @@ def detect_and_translate_input(text: str) -> Dict[str, str]:
|
|
| 184 |
prompt = (
|
| 185 |
'Detect the language of the following text and translate it to English.\n'
|
| 186 |
'If the text is already in English, set detected_lang to "English".\n'
|
|
|
|
|
|
|
|
|
|
| 187 |
'Return ONLY a JSON object: {"detected_lang": "...", "english_text": "..."}\n'
|
| 188 |
'No explanation, no markdown.\n\n'
|
| 189 |
f'Text: {stripped}'
|
|
@@ -550,6 +553,75 @@ def _extract_customer(work: str) -> Tuple[str, Optional[str]]:
|
|
| 550 |
return cleaned, cand
|
| 551 |
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
# Verb groups used to detect mixed/multi-type messages. The deterministic
|
| 554 |
# single-type parsers must defer to the LLM when more than one group is present,
|
| 555 |
# otherwise they grab one fragment and silently drop the rest of the message.
|
|
|
|
| 184 |
prompt = (
|
| 185 |
'Detect the language of the following text and translate it to English.\n'
|
| 186 |
'If the text is already in English, set detected_lang to "English".\n'
|
| 187 |
+
'Keep every personal name, item name, number, and currency EXACTLY as written — '
|
| 188 |
+
'never translate, drop, or alter a name. Preserve "to <Name>" relationships '
|
| 189 |
+
'(e.g. Shona "kuna Chipo" -> "to Chipo").\n'
|
| 190 |
'Return ONLY a JSON object: {"detected_lang": "...", "english_text": "..."}\n'
|
| 191 |
'No explanation, no markdown.\n\n'
|
| 192 |
f'Text: {stripped}'
|
|
|
|
| 553 |
return cleaned, cand
|
| 554 |
|
| 555 |
|
| 556 |
+
# A customer name is a proper noun that survives translation, so we can recover it from
|
| 557 |
+
# the RAW message using recipient markers in several languages — even when translation or
|
| 558 |
+
# the LLM drops it. Shona/Ndebele/Swahili/Chewa "to <person>" markers + English to/for.
|
| 559 |
+
_NAME_TAIL = r"(?:\s+[A-Z][a-zA-Z.'\-]*){0,2}"
|
| 560 |
+
# Markers are case-insensitive (inline (?i:...)) but the NAME must be Capitalised (a proper
|
| 561 |
+
# noun) so "kwa ajili"/"for cash"/"to me" don't match. Bare "ku" is excluded — too ambiguous.
|
| 562 |
+
# Shona kuna/kune/kuye, Swahili/Chewa kwa; English to/for.
|
| 563 |
+
_NATIVE_MARKER_RE = re.compile(r"\b(?i:kuna|kune|kuye|kunaye|kwa)\s+([A-Z][a-zA-Z.'\-]*" + _NAME_TAIL + r")")
|
| 564 |
+
_EN_MARKER_RE = re.compile(r"\b(?:to|for)\s+([A-Z][a-zA-Z.'\-]*" + _NAME_TAIL + r")")
|
| 565 |
+
_NOT_A_NAME = {"cash", "credit", "free", "change", "nothing", "stock", "resale", "today",
|
| 566 |
+
"tomorrow", "school", "church", "home", "work"}
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def _recover_customer_name(text: str) -> Optional[str]:
|
| 570 |
+
"""Find a recipient/customer name after a 'to <person>' marker in any supported
|
| 571 |
+
language. Returns a Title-cased name or None. Language-agnostic on purpose."""
|
| 572 |
+
if not text:
|
| 573 |
+
return None
|
| 574 |
+
for rx in (_NATIVE_MARKER_RE, _EN_MARKER_RE):
|
| 575 |
+
for m in rx.finditer(text):
|
| 576 |
+
words: List[str] = []
|
| 577 |
+
for w in m.group(1).split():
|
| 578 |
+
lw = w.lower()
|
| 579 |
+
if lw in _CUSTOMER_STOP or lw in _NON_CUSTOMER_TOKENS or lw in _CURRENCY_TOKENS or lw in _NOT_A_NAME:
|
| 580 |
+
break
|
| 581 |
+
if any(ch.isdigit() for ch in w):
|
| 582 |
+
break
|
| 583 |
+
words.append(w)
|
| 584 |
+
if len(words) >= 2:
|
| 585 |
+
break
|
| 586 |
+
if words:
|
| 587 |
+
cand = " ".join(words)
|
| 588 |
+
if cand.lower() not in _NOT_A_NAME:
|
| 589 |
+
return cand.title()
|
| 590 |
+
return None
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def _fill_missing_customer(transactions: List[Dict], *texts: str) -> List[Dict]:
|
| 594 |
+
"""If a sale names a recipient (incl. non-English markers like Shona 'kuna') but the
|
| 595 |
+
parser/LLM left details.customer empty, recover it from the raw/translated text."""
|
| 596 |
+
if not transactions:
|
| 597 |
+
return transactions
|
| 598 |
+
recovered = None
|
| 599 |
+
computed = False
|
| 600 |
+
for txn in transactions:
|
| 601 |
+
if (txn.get("transaction_type") or "").lower() != "sale":
|
| 602 |
+
continue
|
| 603 |
+
det = txn.get("details") or {}
|
| 604 |
+
if det.get("customer"):
|
| 605 |
+
continue
|
| 606 |
+
if not computed:
|
| 607 |
+
computed = True
|
| 608 |
+
for t in texts:
|
| 609 |
+
nm = _recover_customer_name(t)
|
| 610 |
+
if nm:
|
| 611 |
+
recovered = nm
|
| 612 |
+
break
|
| 613 |
+
if not recovered:
|
| 614 |
+
continue
|
| 615 |
+
# Never mistake the item being sold for the customer.
|
| 616 |
+
item_names = {normalise_item_name(str(it.get("item") or it.get("name") or ""))
|
| 617 |
+
for it in (det.get("items") or [])}
|
| 618 |
+
if normalise_item_name(recovered) in item_names:
|
| 619 |
+
continue
|
| 620 |
+
det["customer"] = recovered
|
| 621 |
+
txn["details"] = det
|
| 622 |
+
return transactions
|
| 623 |
+
|
| 624 |
+
|
| 625 |
# Verb groups used to detect mixed/multi-type messages. The deterministic
|
| 626 |
# single-type parsers must defer to the LLM when more than one group is present,
|
| 627 |
# otherwise they grab one fragment and silently drop the rest of the message.
|