File size: 6,713 Bytes
160245d
7e54ab9
 
160245d
 
 
f7d6a8a
160245d
80cecae
 
160245d
 
 
7e54ab9
 
 
 
 
 
 
160245d
 
 
f7d6a8a
7e54ab9
160245d
 
9cd90d7
7e54ab9
 
9cd90d7
7e54ab9
 
 
9cd90d7
 
f7d6a8a
 
7e54ab9
f7d6a8a
 
7e54ab9
 
 
 
 
 
 
 
 
 
 
f7d6a8a
160245d
 
 
f7d6a8a
9cd90d7
 
 
 
f62bf0e
 
160245d
 
 
f7d6a8a
80cecae
f62bf0e
9cd90d7
 
 
 
 
 
 
f62bf0e
9cd90d7
 
f62bf0e
 
160245d
 
 
7e54ab9
f62bf0e
160245d
 
 
 
7e54ab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160245d
7e54ab9
 
 
 
 
 
 
f7d6a8a
160245d
7e54ab9
 
 
f7d6a8a
7e54ab9
 
160245d
7e54ab9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160245d
f7d6a8a
7e54ab9
f7d6a8a
7e54ab9
 
 
 
 
 
 
 
160245d
f7d6a8a
7e54ab9
 
 
 
 
 
 
 
160245d
 
7e54ab9
 
 
 
 
 
 
 
 
 
 
 
 
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
# caller_info_extractor.py
# Extracts caller fields from conversation based on optional per-session field spec.
# Uses regex for immediate hot-path extraction when fields are configured.

import re
import logging
from typing import List, Dict, Any, Optional

from language_config import normalize_language

logger = logging.getLogger("caller_info_extractor")

PHONE_PATTERNS = [
    r'\b(?:\+92|0092|92)[\s\-]?3\d{2}[\s\-]?\d{7}\b(?!\d)',
    r'\b(?:\+966|00966|966)[\s\-]?5\d{8}\b(?!\d)',
    r'\b(?:\+971|00971|971)[\s\-]?5[024568]\d{7}\b(?!\d)',
    r'\b(?:\+91|0091|91)[\s\-]?[6789]\d{9}\b(?!\d)',
    r'\b(?:\+1|1)[\s\-]?\(?[2-9]\d{2}\)?[\s\-]?[2-9]\d{2}[\s\-]?\d{4}\b(?!\d)',
    r'\b0\d{2,3}[\s\-]?\d{7,8}\b(?!\d)',
    r'\b\d{10,13}\b(?!\d)',
]

NAME_PATTERNS_EN = [
    r"(?:my name is|i am|i'm|this is|call me)\s+([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)?)",
    r"([A-Za-z][a-z]+(?:\s+[A-Za-z][a-z]+)?)\s+(?:here|speaking|calling)",
]

NAME_PATTERNS_ROMAN_UR = [
    r"(?:mera\s+na(?:am|me?)|apna\s+naam?)\s+([A-Za-z][a-z]{2,}(?:\s+[A-Za-z][a-z]+)?)\s+(?:hai|he|hun|hoon|ho)\b",
    r"(?:mera\s+na(?:am|me?)|apna\s+naam?)\s+([A-Za-z][a-z]{2,}(?:\s+[A-Za-z][a-z]+)?)",
    r"([A-Za-z][a-z]+)\s+(?:ye|yeh|ye\s+mera|mera)\s+na(?:am|me?)",
    r"\bna(?:am|me?)\s+(?:and\s+)?([A-Za-z][a-z]{2,})",
    r"(?:main|mein)\s+([A-Za-z][a-z]{2,})\s+(?:hoon|hun|ho)",
    r"(?:mujhe|humein)\s+([A-Za-z][a-z]{2,})\s+(?:kehte|bolte|bulao|kahte)",
]

NAME_PATTERNS_UR = [
    r"(?:میرا نام|میں ہوں)\s+([\u0600-\u06FF\s]+)",
    r"([\u0600-\u06FF\s]+)\s+(?:بات کر رہا ہوں|بات کر رہی ہوں|بول رہا ہوں)",
]

ID_PATTERNS = [
    r'\b\d{5}[\s\-]?\d{7}[\s\-]?\d\b',  # CNIC Pakistan
    r'\b[A-Z]{0,3}\d{4,12}\b',           # Generic student/employee ID
]

NAME_STOP_WORDS = {
    "he", "hai", "hun", "hoon", "ho", "and", "ye", "yeh", "mera", "name",
    "naam", "number", "num", "the", "my", "is", "am", "me", "id",
}


def extract_phone_from_text(text: str) -> Optional[str]:
    for pattern in PHONE_PATTERNS:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            phone = re.sub(r'[\s\-\(\)\+]', '', match.group())
            digit_count = sum(c.isdigit() for c in phone)
            if digit_count < 7 or digit_count > 15:
                logger.warning(f"Rejected implausible phone number ({digit_count} digits): {phone}")
                continue
            logger.info(f"Regex pipeline intercepted telephone credentials: {phone}")
            return phone
    return None


def extract_name_from_text(text: str, language: str = "en") -> Optional[str]:
    lang = normalize_language(language)

    latin_chars = sum(1 for c in text if c.isascii() and c.isalpha())
    total_chars = sum(1 for c in text if c.isalpha())
    is_roman_script = total_chars > 0 and (latin_chars / total_chars) > 0.5

    if lang == "ur" and is_roman_script:
        target_patterns = NAME_PATTERNS_ROMAN_UR + NAME_PATTERNS_EN
    elif lang == "ur":
        target_patterns = NAME_PATTERNS_UR
    else:
        target_patterns = NAME_PATTERNS_EN + NAME_PATTERNS_ROMAN_UR

    for pattern in target_patterns:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            name = match.group(1).strip()
            if 2 <= len(name) <= 40 and name.lower() not in NAME_STOP_WORDS:
                logger.info(f"Regex pipeline intercepted identity credentials: {name} [Lang: {lang}]")
                return name
    return None


def extract_id_from_text(text: str) -> Optional[str]:
    for pattern in ID_PATTERNS:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            value = match.group().strip()
            if len(value) >= 4:
                logger.info(f"Regex pipeline intercepted ID credentials: {value}")
                return value
    return None


def extract_generic_text_field(text: str) -> Optional[str]:
    match = re.search(r'\b([A-Za-z0-9][A-Za-z0-9\-]{2,20})\b', text)
    if match:
        return match.group(1)
    return None


class CallerInfoCollector:
    """Tracks caller profile fields defined by per-session collection spec."""

    def __init__(self, fields_spec: Optional[List[Dict[str, Any]]] = None):
        self.fields_spec: List[Dict[str, Any]] = fields_spec or []
        self.collected: Dict[str, Optional[str]] = {
            f["key"]: None for f in self.fields_spec if f.get("key")
        }
        self.language: Optional[str] = "en"

    def is_enabled(self) -> bool:
        return bool(self.fields_spec)

    def process_message(self, text: str, language: str = "en") -> None:
        if not self.is_enabled():
            return
        self.language = language
        for field in self.fields_spec:
            key = field.get("key")
            if not key or self.collected.get(key):
                continue
            field_type = (field.get("type") or "text").lower()
            value = None
            if field_type == "name":
                value = extract_name_from_text(text, language=language)
            elif field_type == "phone":
                value = extract_phone_from_text(text)
            elif field_type == "id":
                value = extract_id_from_text(text)
            else:
                value = extract_generic_text_field(text)
            if value:
                self.collected[key] = value

    def update_from_llm_extraction(self, extracted: Dict[str, Any]) -> None:
        if not extracted or not self.is_enabled():
            return
        for field in self.fields_spec:
            key = field.get("key")
            if not key or self.collected.get(key):
                continue
            if extracted.get(key):
                clean = str(extracted[key]).strip()
                if clean and clean.lower() != "unknown":
                    self.collected[key] = clean

    def to_dict(self) -> Dict[str, Any]:
        if not self.is_enabled():
            return {}
        return {k: v for k, v in self.collected.items()}

    def missing_fields(self) -> List[str]:
        if not self.is_enabled():
            return []
        return [f["key"] for f in self.fields_spec if f.get("key") and not self.collected.get(f["key"])]

    def is_complete(self) -> bool:
        return self.is_enabled() and len(self.missing_fields()) == 0

    def get_field_labels(self) -> List[Dict[str, str]]:
        return [
            {
                "key": f["key"],
                "label": f.get("label", f["key"]),
                "label_ur": f.get("label_ur", f.get("label", f["key"])),
                "type": f.get("type", "text"),
            }
            for f in self.fields_spec
            if f.get("key")
        ]