initiumzim Claude Opus 4.8 commited on
Commit
8a4b591
·
1 Parent(s): 5e46ecf

Harden multilingual customer capture (recover from raw message)

Browse files

A 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 ADDED
@@ -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").
docs/decisions/README.md CHANGED
@@ -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 |
main.py CHANGED
@@ -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
utility.py CHANGED
@@ -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.