fitandsleekAIchat / services /reverse_text.py
Reach99999's picture
push again
c8cda56
Raw
History Blame Contribute Delete
11.9 kB
"""Decode reversed letters / word-order (EN + Khmer) for messy chatbot input.
Never scolds the user — silently restores intent when a reverse form matches
known shopping vocabulary (products, colors, greetings, seller phrases).
"""
from __future__ import annotations
import re
from functools import lru_cache
# Extra shopping / chat words beyond taxonomy (EN + KM)
_SEED_WORDS = (
"hello",
"hi",
"hey",
"welcome",
"thanks",
"thank",
"please",
"yes",
"no",
"ok",
"okay",
"love",
"like",
"want",
"need",
"have",
"got",
"buy",
"order",
"help",
"find",
"looking",
"size",
"budget",
"price",
"prices",
"cheap",
"sale",
"new",
"stock",
"available",
"beautiful",
"very",
"this",
"that",
"my",
"your",
"online",
"shop",
"store",
"seller",
"product",
"products",
"customer",
"fashion",
"clothing",
"clothes",
"quality",
"premium",
"special",
"offer",
"arrival",
"best",
"artificial",
"intelligence",
"computer",
"database",
"men",
"women",
"kids",
"under",
"affordable",
"high",
"provide",
"we",
"at",
"is",
"to",
"for",
"bestseller",
"jean",
"jeans",
"pants",
"shirt",
"blue",
"black",
"navy",
"សួស្តី",
"អរគុណ",
"សូម",
"ស្វាគមន៍",
"មក",
"កាន់",
"ហាង",
"របស់",
"យើង",
"ខ្ញុំ",
"ចូលចិត្ត",
"ផលិតផល",
"នេះ",
"អាវ",
"ស្អាត",
"ណាស់",
"អ្នកលក់",
"អតិថិជន",
"ទំហំ",
"តម្លៃ",
"ថវិកា",
"ពណ៌",
"មាន",
"អត់",
"ទេ",
"ចង់",
"រក",
"ទិញ",
"ខោ",
"ស្បែកជើង",
"កាបូប",
"មួក",
"បុរស",
"ស្ត្រី",
"កុមារ",
"គុណភាព",
"ខ្ពស់",
"សមរម្យ",
"ក្នុង",
"ផ្តល់",
"កុំព្យូទ័រ",
"បច្ចេកវិទ្យា",
"កសិករ",
)
# Manual reverse aliases (user-typed / OCR / playful reverses that ≠ codepoint reverse)
_REVERSE_ALIASES = {
# EN letter reverses
"olleh": "hello",
"pohs": "shop",
"relles": "seller",
"tcudorp": "product",
"remotsuc": "customer",
"nohsaf": "fashion",
"gnihtolc": "clothing",
"retupmoc": "computer",
"esabatad": "database",
"naej": "jean",
"snaej": "jeans",
"eulb": "blue",
"kcalb": "black",
"yvan": "navy",
"trihs": "shirt",
"stnap": "pants",
# Marketing (letter reverse + often word-order reverse)
"relles tseb": "best seller",
"tseb relles": "best seller",
"lavirra wen": "new arrival",
"wen lavirra": "new arrival",
"ytilauq muimerp": "premium quality",
"muimerp ytilauq": "premium quality",
"reffo laiceps": "special offer",
"laiceps reffo": "special offer",
# Khmer — playful / mistyped reverses from common examples
"ីត្តសួស": "សួស្តី",
"់កល្នអ": "អ្នកលក់",
"លផិតលផ": "ផលិតផល",
"នជិថិតអ": "អតិថិជន",
"រកសិក": "កសិករ",
# Khmer reverse word-order (no spaces) — common test phrases
"នេះផលិតផលចូលចិត្តខ្ញុំ": "ខ្ញុំចូលចិត្តផលិតផលនេះ",
"ណាស់ស្អាតនេះអាវ": "អាវនេះស្អាតណាស់",
"យើងរបស់ហាងកាន់មកមន៍ស្វាគមន៍សូម": "សូមស្វាគមន៍មកកាន់ហាងរបស់យើង",
"សមរម្យតម្លៃក្នុងខ្ពស់គុណភាពផលិតផលផ្តល់យើង": "យើងផ្តល់ផលិតផលគុណភាពខ្ពស់ក្នុងតម្លៃសមរម្យ",
}
_PHRASE_BONUS = (
"best seller",
"new arrival",
"premium quality",
"special offer",
"i love",
"welcome to",
"this shirt",
"ខ្ញុំចូលចិត្ត",
"សូមស្វាគមន៍",
"អាវនេះ",
)
_STARTERS = frozenset(
{
"i",
"we",
"welcome",
"this",
"hello",
"hi",
"hey",
"please",
"best",
"new",
"premium",
"special",
"ខ្ញុំ",
"សូម",
"សួស្តី",
"អាវ",
"យើង",
}
)
_MIN_FLIP_LEN = 3
def _is_khmer(text: str) -> bool:
return bool(re.search(r"[\u1780-\u17FF]", text or ""))
def _norm_key(token: str) -> str:
if _is_khmer(token):
return token
return token.lower()
@lru_cache(maxsize=1)
def shopping_vocab() -> frozenset[str]:
"""Canonical tokens the bot recognizes (lowercase Latin / exact Khmer)."""
words: set[str] = {_norm_key(w) for w in _SEED_WORDS if w}
words.update(_REVERSE_ALIASES.values())
try:
from services.taxonomy import load_reference, load_taxonomy
tax = load_taxonomy()
for bad, good in (tax.get("typo_aliases") or {}).items():
if bad:
words.add(_norm_key(str(bad)))
if good:
words.add(_norm_key(str(good)))
for product in tax.get("products") or []:
for field in ("aliases", "search_terms"):
for a in product.get(field) or []:
if a:
words.add(_norm_key(str(a)))
for part in re.split(r"[\s/]+", product.get("name_en") or ""):
if len(part) >= 3:
words.add(part.lower())
for group in (tax.get("groups") or {}).values():
for t in group.get("search_terms") or []:
if t:
words.add(_norm_key(str(t)))
ref = load_reference()
for color in ref.get("colors") or []:
cid = color.get("id")
if cid:
words.add(str(cid).lower().replace("_", " "))
for a in color.get("aliases") or []:
if a:
words.add(_norm_key(str(a)))
for brand in ref.get("brands") or []:
name = brand.get("name")
if name:
words.add(str(name).lower())
for cat in ref.get("categories") or []:
for a in (cat.get("aliases") or []) + (cat.get("search_terms") or []):
if a:
words.add(_norm_key(str(a)))
except Exception:
pass
return frozenset(w for w in words if w and len(w) >= 2)
def _in_vocab(token: str, vocab: frozenset[str]) -> bool:
return bool(token) and _norm_key(token) in vocab
def _apply_alias_map(text: str) -> str:
"""Exact / phrase reverse aliases (longest first)."""
out = text or ""
low = out.lower()
# Phrase aliases on lowercase latin; Khmer exact
items = sorted(_REVERSE_ALIASES.items(), key=lambda kv: len(kv[0]), reverse=True)
for bad, good in items:
if _is_khmer(bad):
if bad in out:
out = out.replace(bad, good)
continue
# whole-string or word-boundary for latin
pattern = re.compile(rf"(?<!\w){re.escape(bad)}(?!\w)", re.IGNORECASE)
out = pattern.sub(good, out)
return out
def _unreverse_token(token: str, vocab: frozenset[str]) -> str:
"""Flip letters only when reversed form is known and original is not."""
if len(token) < _MIN_FLIP_LEN:
return token
key = _norm_key(token)
if key in _REVERSE_ALIASES:
return _REVERSE_ALIASES[key]
if _in_vocab(token, vocab):
return token
flipped = token[::-1]
flip_key = _norm_key(flipped)
if flip_key in _REVERSE_ALIASES:
return _REVERSE_ALIASES[flip_key]
if _in_vocab(flipped, vocab):
return _norm_key(flipped) if not _is_khmer(flipped) else flipped
return token
def _iter_word_spans(text: str):
for m in re.finditer(r"[\u1780-\u17FF]+|[A-Za-z][A-Za-z']*", text or ""):
yield m.start(), m.end(), m.group(0)
def _flip_tokens_in_text(text: str, vocab: frozenset[str]) -> str:
parts: list[str] = []
last = 0
for start, end, tok in _iter_word_spans(text):
parts.append(text[last:start])
parts.append(_unreverse_token(tok, vocab))
last = end
parts.append((text or "")[last:])
return "".join(parts)
def _flip_word_order(text: str) -> str:
bits = (text or "").split()
if len(bits) < 2:
return text or ""
# Keep trailing sentence punctuation on the new last word
trail = ""
core = []
for b in bits:
m = re.match(r"^(.*?)([.,!?;:។]+)$", b)
if m and m.group(1):
core.append(m.group(1))
trail = m.group(2) # last wins
else:
core.append(b)
out = " ".join(reversed(core))
if trail and not out.endswith(trail):
out = out + trail
# Capitalize first Latin word lightly if original looked like a sentence
parts = out.split(" ", 1)
if parts and parts[0] and parts[0][0].isalpha() and not _is_khmer(parts[0]):
parts[0] = parts[0][:1].upper() + parts[0][1:]
out = " ".join(parts)
return out
def _strip_edges(tok: str) -> str:
return tok.strip(".,!?;:\"'`“”។")
def _vocab_score(text: str, vocab: frozenset[str]) -> int:
score = 0
words = []
for _, _, raw in _iter_word_spans(text or ""):
words.append(_strip_edges(raw))
if _in_vocab(raw, vocab) or _in_vocab(_strip_edges(raw), vocab):
score += 2 if len(raw) >= 4 else 1
elif len(raw) >= _MIN_FLIP_LEN and _in_vocab(raw[::-1], vocab):
score -= 1
low = (text or "").lower()
for phrase in _PHRASE_BONUS:
if phrase in low or phrase in (text or ""):
score += 4
if words:
first = _norm_key(words[0])
last = _norm_key(words[-1])
if first in _STARTERS:
score += 3
if last in _STARTERS and first not in _STARTERS:
score -= 2
return score
def decode_reversed_text(text: str) -> str:
"""
Restore reverse-letter and reverse-word-order input when it clearly
matches shopping vocabulary. Leaves normal text unchanged.
"""
if not text or not str(text).strip():
return text or ""
original = str(text)
vocab = shopping_vocab()
aliased = _apply_alias_map(original)
letter_fixed = _flip_tokens_in_text(aliased, vocab)
order_flip = _flip_word_order(aliased)
order_then_letters = _flip_tokens_in_text(order_flip, vocab)
letters_then_order = _flip_word_order(letter_fixed)
stripped = aliased.strip()
whole = stripped[::-1]
whole_letters = _flip_tokens_in_text(whole, vocab)
# Marketing: letter-fix each token then reverse order
marketing = _flip_word_order(_flip_tokens_in_text(aliased, vocab))
candidates = [
original,
aliased,
letter_fixed,
order_flip,
order_then_letters,
letters_then_order,
marketing,
whole,
whole_letters,
]
best = original
best_score = _vocab_score(original, vocab)
for cand in candidates:
sc = _vocab_score(cand, vocab)
if sc > best_score:
best = cand
best_score = sc
best = re.sub(r"[ \t]+", " ", best).strip()
return best if best else original
def clear_reverse_caches() -> None:
shopping_vocab.cache_clear()