File size: 7,846 Bytes
6f4d8d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""
Phrase-library-based conversational response system.
Matches transcribed text against curated Bambara/Fula phrase libraries.

No ML required — the knowledge base is plain JSON files that anyone can edit.
Library files: data/phrases/{lang}.json
User additions: data/phrases/{lang}_additions.json (saved to HF Hub for persistence)

Each phrase entry:
  {
    "patterns":  ["i ni ce", "ini ce", ...],   <- what the user might say (incl. Whisper variants)
    "response":  "I ni ce. N bɛ i dɛmɛ.",     <- what the app says back
    "english":   "Hello! I am here to help.",  <- English translation shown in UI
    "tags":      ["greeting"]
  }
"""
from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# Resolve project root relative to this file:
# src/conversation/phrase_matcher.py  → ../../data/phrases/
DATA_DIR = Path(__file__).parent.parent.parent / "data" / "phrases"

try:
    from rapidfuzz import fuzz as _fuzz, process as _process
    _HAS_RAPIDFUZZ = True
except ImportError:
    _HAS_RAPIDFUZZ = False
    logger.warning("rapidfuzz not installed — using substring matching only (install it for better matching)")


class PhraseMatcher:
    """
    Fuzzy-matches a transcription against a per-language phrase library.
    Falls back to substring matching if rapidfuzz is not installed.
    """

    def __init__(self) -> None:
        self._cache: dict[str, list[dict]] = {}

    # ── Library loading ───────────────────────────────────────────────────────

    def _load(self, lang: str) -> list[dict]:
        """Load and cache all phrases for a language (base + user additions)."""
        if lang not in self._cache:
            phrases: list[dict] = []

            # Base library (git-tracked, always present)
            base_path = DATA_DIR / f"{lang}.json"
            if base_path.exists():
                with open(base_path, encoding="utf-8") as f:
                    phrases = json.load(f)

            # User additions (written locally, uploaded to HF Hub for persistence)
            additions_path = DATA_DIR / f"{lang}_additions.json"
            if additions_path.exists():
                try:
                    with open(additions_path, encoding="utf-8") as f:
                        additions = json.load(f)
                    phrases.extend(additions)
                except Exception as exc:
                    logger.warning("Could not load phrase additions for %s: %s", lang, exc)

            self._cache[lang] = phrases
            logger.info("PhraseMatcher: loaded %d phrases for lang=%s", len(phrases), lang)
        return self._cache[lang]

    def reload(self, lang: str) -> None:
        """Force reload from disk (call after adding new phrases)."""
        self._cache.pop(lang, None)

    def reload_from_hub_data(self, lang: str, additions_json: str) -> None:
        """
        Called at startup with JSON downloaded from HF Hub.
        Writes to the local additions file so _load() picks it up.
        """
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        additions_path = DATA_DIR / f"{lang}_additions.json"
        with open(additions_path, "w", encoding="utf-8") as f:
            f.write(additions_json)
        self.reload(lang)

    # ── Matching ──────────────────────────────────────────────────────────────

    def match(self, text: str, lang: str, threshold: int = 60) -> Optional[dict]:
        """
        Find the best-matching phrase entry for the transcribed text.
        Returns the entry dict (with 'response' and 'english' keys) or None.

        Strategy:
          1. Exact substring: any pattern is a substring of text (or vice-versa)
          2. Fuzzy partial_ratio via rapidfuzz (handles Whisper spelling variants)
        """
        phrases = self._load(lang)
        if not phrases:
            return None

        text_norm = text.lower().strip()
        if not text_norm:
            return None

        # Build flat candidate list: (normalised_pattern, entry)
        candidates: list[tuple[str, dict]] = [
            (pat.lower(), entry)
            for entry in phrases
            for pat in entry.get("patterns", [])
        ]
        if not candidates:
            return None

        # 1. Exact substring check (fast path)
        for pattern, entry in candidates:
            if pattern in text_norm or text_norm in pattern:
                logger.debug("Exact phrase match: %r", pattern)
                return entry

        # 2. Fuzzy match (handles Whisper mis-transcriptions)
        if _HAS_RAPIDFUZZ:
            pattern_strings = [c[0] for c in candidates]
            result = _process.extractOne(
                text_norm,
                pattern_strings,
                scorer=_fuzz.partial_ratio,
                score_cutoff=threshold,
            )
            if result:
                _, score, idx = result
                logger.debug("Fuzzy phrase match score=%d: %r", score, candidates[idx][0])
                return candidates[idx][1]

        return None

    # ── Phrase addition ───────────────────────────────────────────────────────

    def add_phrase(
        self,
        lang: str,
        patterns: list[str],
        response: str,
        english: str,
        tags: list[str] | None = None,
    ) -> None:
        """Add a single phrase to the local additions file."""
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        additions_path = DATA_DIR / f"{lang}_additions.json"

        additions: list[dict] = []
        if additions_path.exists():
            try:
                with open(additions_path, encoding="utf-8") as f:
                    additions = json.load(f)
            except Exception:
                additions = []

        additions.append({
            "patterns": [p.strip() for p in patterns if p.strip()],
            "response": response.strip(),
            "english": english.strip(),
            "tags": tags or ["user_added"],
        })

        with open(additions_path, "w", encoding="utf-8") as f:
            json.dump(additions, f, ensure_ascii=False, indent=2)

        self.reload(lang)

    def import_pairs(self, lang: str, text: str) -> int:
        """
        Bulk-import from pasted text.
        Format: one entry per line → native_phrase | english_translation
        Lines starting with # are ignored.
        Returns number of entries successfully added.
        """
        count = 0
        for line in text.strip().splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if "|" not in line:
                continue
            parts = line.split("|", 1)
            if len(parts) != 2:
                continue
            native, english = parts[0].strip(), parts[1].strip()
            if not native or not english:
                continue
            self.add_phrase(lang, [native], native, english, tags=["imported"])
            count += 1
        return count

    def get_additions_json(self, lang: str) -> str:
        """Return the raw JSON of user additions (for HF Hub upload)."""
        additions_path = DATA_DIR / f"{lang}_additions.json"
        if not additions_path.exists():
            return "[]"
        with open(additions_path, encoding="utf-8") as f:
            return f.read()

    def phrase_count(self, lang: str) -> int:
        return len(self._load(lang))