Spaces:
Sleeping
Sleeping
| """Text Data Analytics for Fit & Sleek knowledge / user messages. | |
| Pillars: | |
| 1) Data Cleaning β normalize messy Khmer/English shopping text | |
| 2) Data Quality β check FAQ / knowledge completeness & conflicts | |
| 3) Data Analytics β coverage stats to improve AskβAnswer matching | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| from typing import Any | |
| import config | |
| from services.store import apply_typo_corrections, load_store_info | |
| from services.reverse_text import decode_reversed_text | |
| # --------------------------------------------------------------------------- | |
| # 1) Data Cleaning | |
| # --------------------------------------------------------------------------- | |
| _KHMER_DIGITS = str.maketrans("α α‘α’α£α€α₯α¦α§α¨α©", "0123456789") | |
| def clean_text(text: str, store_info: dict | None = None) -> str: | |
| """Clean user / FAQ text for matching and analytics (does not scold the user).""" | |
| if text is None: | |
| return "" | |
| out = str(text) | |
| out = out.replace("\u200b", "").replace("\ufeff", "") # zero-width / BOM | |
| out = out.translate(_KHMER_DIGITS) | |
| out = out.replace("ααα", "ααα").replace("ααα", "ααα") | |
| out = re.sub(r"[ \t]+", " ", out) | |
| out = re.sub(r"\n{3,}", "\n\n", out) | |
| out = out.strip().strip("\"'`ββ") | |
| # Collapse repeated punctuation: ??? β ?, !!! β ! | |
| out = re.sub(r"([!?α\.])\1{2,}", r"\1\1", out) | |
| info = store_info if store_info is not None else load_store_info() | |
| out = apply_typo_corrections(out, info) | |
| # Reverse-letter / reverse-word-order (ollehβhello, product this love Iββ¦) | |
| decoded = decode_reversed_text(out) | |
| if decoded != out: | |
| out = decoded | |
| return out.strip() | |
| def clean_batch(texts: list[str], store_info: dict | None = None) -> list[str]: | |
| info = store_info if store_info is not None else load_store_info() | |
| return [clean_text(t, info) for t in texts] | |
| # --------------------------------------------------------------------------- | |
| # 2) Data Quality | |
| # --------------------------------------------------------------------------- | |
| REQUIRED_STORE_FIELDS = ( | |
| "store_name", | |
| "assistant_name", | |
| "brand_summary", | |
| "payment_methods", | |
| "delivery_note", | |
| "how_to_order", | |
| "return_policy", | |
| "faq", | |
| ) | |
| def quality_check_store(store_info: dict | None = None) -> dict[str, Any]: | |
| """Score and list quality issues in store_info.json (FAQ + knowledge).""" | |
| info = store_info if store_info is not None else load_store_info() | |
| issues: list[dict[str, str]] = [] | |
| faq = list(info.get("faq") or []) | |
| chunks = list(info.get("knowledge_chunks") or []) | |
| for field in REQUIRED_STORE_FIELDS: | |
| val = info.get(field) | |
| if val in (None, "", [], {}): | |
| issues.append( | |
| { | |
| "severity": "high", | |
| "type": "missing_field", | |
| "message": f"Required field empty: {field}", | |
| } | |
| ) | |
| seen_ids: set[str] = set() | |
| keyword_owners: dict[str, list[str]] = defaultdict(list) | |
| missing_en = 0 | |
| empty_kw = 0 | |
| short_answers = 0 | |
| for item in faq: | |
| faq_id = str(item.get("id") or "").strip() or "(no-id)" | |
| if faq_id in seen_ids: | |
| issues.append( | |
| { | |
| "severity": "high", | |
| "type": "duplicate_faq_id", | |
| "message": f"Duplicate FAQ id: {faq_id}", | |
| } | |
| ) | |
| seen_ids.add(faq_id) | |
| kws = [str(k).strip() for k in (item.get("keywords") or []) if str(k).strip()] | |
| if not kws: | |
| empty_kw += 1 | |
| issues.append( | |
| { | |
| "severity": "high", | |
| "type": "empty_keywords", | |
| "message": f"FAQ '{faq_id}' has no keywords", | |
| } | |
| ) | |
| for kw in kws: | |
| keyword_owners[kw.lower()].append(faq_id) | |
| ans = str(item.get("answer") or "").strip() | |
| ans_en = str(item.get("answer_en") or "").strip() | |
| if not ans: | |
| issues.append( | |
| { | |
| "severity": "high", | |
| "type": "missing_answer", | |
| "message": f"FAQ '{faq_id}' missing Khmer answer", | |
| } | |
| ) | |
| elif len(ans) < 20: | |
| short_answers += 1 | |
| issues.append( | |
| { | |
| "severity": "low", | |
| "type": "short_answer", | |
| "message": f"FAQ '{faq_id}' Khmer answer is very short", | |
| } | |
| ) | |
| if not ans_en: | |
| missing_en += 1 | |
| issues.append( | |
| { | |
| "severity": "medium", | |
| "type": "missing_answer_en", | |
| "message": f"FAQ '{faq_id}' missing English answer", | |
| } | |
| ) | |
| # Broken template placeholders that won't fill | |
| for field_name, template in (("answer", ans), ("answer_en", ans_en)): | |
| for token in re.findall(r"\{([a-zA-Z0-9_]+)\}", template): | |
| # only flag obvious unknowns later via analytics if needed | |
| if token.startswith(" "): | |
| issues.append( | |
| { | |
| "severity": "medium", | |
| "type": "bad_placeholder", | |
| "message": f"FAQ '{faq_id}.{field_name}' bad placeholder {{{token}}}", | |
| } | |
| ) | |
| # Keyword collisions (same keyword owned by many FAQs) β matching becomes noisy | |
| collisions = { | |
| kw: ids for kw, ids in keyword_owners.items() if len(set(ids)) >= 3 and len(kw) >= 3 | |
| } | |
| for kw, ids in sorted(collisions.items(), key=lambda x: -len(x[1]))[:25]: | |
| issues.append( | |
| { | |
| "severity": "medium", | |
| "type": "keyword_collision", | |
| "message": f"Keyword '{kw}' shared by {len(set(ids))} FAQs: {', '.join(sorted(set(ids))[:6])}", | |
| } | |
| ) | |
| for chunk in chunks: | |
| cid = str(chunk.get("id") or "").strip() or "(chunk)" | |
| if not (chunk.get("topics") or []): | |
| issues.append( | |
| { | |
| "severity": "medium", | |
| "type": "chunk_no_topics", | |
| "message": f"knowledge_chunk '{cid}' has no topics", | |
| } | |
| ) | |
| if not str(chunk.get("text") or "").strip(): | |
| issues.append( | |
| { | |
| "severity": "high", | |
| "type": "chunk_empty_text", | |
| "message": f"knowledge_chunk '{cid}' has empty text", | |
| } | |
| ) | |
| high = sum(1 for i in issues if i["severity"] == "high") | |
| medium = sum(1 for i in issues if i["severity"] == "medium") | |
| low = sum(1 for i in issues if i["severity"] == "low") | |
| # Simple score: start 100, subtract | |
| score = max(0, 100 - high * 8 - medium * 2 - low * 1) | |
| return { | |
| "score": score, | |
| "grade": ( | |
| "A" | |
| if score >= 90 | |
| else "B" | |
| if score >= 75 | |
| else "C" | |
| if score >= 60 | |
| else "D" | |
| if score >= 40 | |
| else "F" | |
| ), | |
| "counts": { | |
| "faq": len(faq), | |
| "knowledge_chunks": len(chunks), | |
| "typo_corrections": len(info.get("typo_corrections") or {}), | |
| "missing_answer_en": missing_en, | |
| "empty_keywords": empty_kw, | |
| "short_answers": short_answers, | |
| "keyword_collisions": len(collisions), | |
| "issues_high": high, | |
| "issues_medium": medium, | |
| "issues_low": low, | |
| "issues_total": len(issues), | |
| }, | |
| "issues": issues[:200], | |
| } | |
| # --------------------------------------------------------------------------- | |
| # 3) Data Analytics (text / FAQ coverage) | |
| # --------------------------------------------------------------------------- | |
| INTENT_BUCKETS = { | |
| "payment": ("ααα", "payment", "khqr", "visa", "ααΌααΆαα"), | |
| "delivery": ("ααΉα", "delivery", "shipping", "ααααΎ"), | |
| "return": ("ααααΌα", "αααα‘αα", "return", "refund", "exchange"), | |
| "sizing": ("ααα α", "size", "αααααα", "weight"), | |
| "products": ("α’αΆα", "αα", "αααααααΎα", "hoodie", "product", "αααα·α"), | |
| "budget": ("ααα·ααΆ", "budget", "ααααΉα", "ααα", "sale", "cheap"), | |
| "color": ("ααα", "color", "colour"), | |
| "order_track": ("ααΆαααΆα", "track", "order", "αααααα"), | |
| "account": ("login", "ααααΈ", "password", "account"), | |
| "greeting": ("αα½ααααΈ", "hello", "hi", "good morning"), | |
| } | |
| def analyze_store_text(store_info: dict | None = None) -> dict[str, Any]: | |
| """Analytics over FAQ/knowledge text for improving AskβAnswer coverage.""" | |
| info = store_info if store_info is not None else load_store_info() | |
| faq = list(info.get("faq") or []) | |
| chunks = list(info.get("knowledge_chunks") or []) | |
| all_keywords: list[str] = [] | |
| km_chars = 0 | |
| en_chars = 0 | |
| for item in faq: | |
| kws = [str(k) for k in (item.get("keywords") or [])] | |
| all_keywords.extend(kws) | |
| km_chars += len(str(item.get("answer") or "")) | |
| en_chars += len(str(item.get("answer_en") or "")) | |
| kw_counter = Counter(k.lower() for k in all_keywords) | |
| bilingual = sum( | |
| 1 | |
| for item in faq | |
| if str(item.get("answer") or "").strip() | |
| and str(item.get("answer_en") or "").strip() | |
| ) | |
| coverage = {} | |
| blob = " ".join(all_keywords).lower() | |
| for name, keys in INTENT_BUCKETS.items(): | |
| hits = sum(1 for k in keys if k.lower() in blob) | |
| coverage[name] = { | |
| "keyword_hits": hits, | |
| "of": len(keys), | |
| "ok": hits > 0, | |
| } | |
| return { | |
| "faq_count": len(faq), | |
| "chunk_count": len(chunks), | |
| "keyword_total": len(all_keywords), | |
| "keyword_unique": len(kw_counter), | |
| "bilingual_faq_pct": round(100.0 * bilingual / max(1, len(faq)), 1), | |
| "answer_chars_km": km_chars, | |
| "answer_chars_en": en_chars, | |
| "top_keywords": kw_counter.most_common(20), | |
| "intent_coverage": coverage, | |
| "typo_map_size": len(info.get("typo_corrections") or {}), | |
| } | |
| def analyze_user_texts(texts: list[str], store_info: dict | None = None) -> dict[str, Any]: | |
| """Light analytics on a batch of user messages (after cleaning).""" | |
| info = store_info if store_info is not None else load_store_info() | |
| cleaned = clean_batch(texts, info) | |
| lengths = [len(t) for t in cleaned if t] | |
| has_khmer = sum(1 for t in cleaned if re.search(r"[\u1780-\u17FF]", t)) | |
| has_dollar = sum(1 for t in cleaned if "$" in t or "α" in t) | |
| return { | |
| "messages": len(texts), | |
| "non_empty": len(lengths), | |
| "avg_len": round(sum(lengths) / max(1, len(lengths)), 1), | |
| "khmer_messages": has_khmer, | |
| "budget_like": has_dollar, | |
| "samples_cleaned": cleaned[:10], | |
| } | |
| def full_report(store_info: dict | None = None) -> dict[str, Any]: | |
| info = store_info if store_info is not None else load_store_info() | |
| return { | |
| "cleaning": { | |
| "description": "normalize digits, typos, whitespace, odd punctuation", | |
| "typo_rules": len(info.get("typo_corrections") or {}), | |
| }, | |
| "quality": quality_check_store(info), | |
| "analytics": analyze_store_text(info), | |
| } | |
| def print_report(report: dict[str, Any] | None = None) -> None: | |
| report = report or full_report() | |
| q = report["quality"] | |
| a = report["analytics"] | |
| print("=== Fit & Sleek Text Data Report ===") | |
| print(f"Quality score: {q['score']} ({q['grade']})") | |
| print( | |
| f"FAQ={a['faq_count']} chunks={a['chunk_count']} " | |
| f"keywords={a['keyword_unique']} unique / {a['keyword_total']} total " | |
| f"bilingual={a['bilingual_faq_pct']}%" | |
| ) | |
| print("Intent coverage:") | |
| for name, meta in a["intent_coverage"].items(): | |
| mark = "OK" if meta["ok"] else "GAP" | |
| print(f" [{mark}] {name}: {meta['keyword_hits']}/{meta['of']}") | |
| print( | |
| f"Issues: high={q['counts']['issues_high']} " | |
| f"medium={q['counts']['issues_medium']} " | |
| f"low={q['counts']['issues_low']}" | |
| ) | |
| print("--- Top issues ---") | |
| for issue in q["issues"][:15]: | |
| print(f" ({issue['severity']}) {issue['type']}: {issue['message']}") | |
| def save_report(path: str | Path | None = None) -> Path: | |
| out = Path(path) if path else config.BASE_DIR / "data" / "text_quality_report.json" | |
| report = full_report() | |
| out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") | |
| return out | |
| if __name__ == "__main__": | |
| print_report() | |
| saved = save_report() | |
| print(f"\nSaved: {saved}") | |