EurekaPotato commited on
Commit
1bc8c61
·
verified ·
1 Parent(s): 5fb1b5c

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -1
  2. handler.py +29 -175
  3. text_features.py +431 -0
Dockerfile CHANGED
@@ -6,7 +6,7 @@ COPY requirements.txt .
6
  RUN pip install --no-cache-dir torch==2.1.0 --index-url https://download.pytorch.org/whl/cpu
7
  RUN pip install -r requirements.txt
8
 
9
- COPY handler.py .
10
 
11
  EXPOSE 7860
12
 
 
6
  RUN pip install --no-cache-dir torch==2.1.0 --index-url https://download.pytorch.org/whl/cpu
7
  RUN pip install -r requirements.txt
8
 
9
+ COPY . .
10
 
11
  EXPOSE 7860
12
 
handler.py CHANGED
@@ -9,178 +9,19 @@ Extracts all 9 text features from conversation transcript:
9
  Derived from: src/text_features.py
10
  """
11
 
12
- import re
13
- import numpy as np
14
- from typing import List, Dict
15
- from transformers import pipeline
16
- from sentence_transformers import SentenceTransformer
17
-
18
-
19
  # ──────────────────────────────────────────────────────────────────────── #
20
- # TextFeatureExtractorEndpoint (mirrors src/text_features.py)
21
  # ──────────────────────────────────────────────────────────────────────── #
 
 
 
 
 
 
22
 
23
- class TextFeatureExtractorEndpoint:
24
- """Stateless text feature extraction for HF endpoint."""
25
-
26
- # Keywords from src/text_features.py
27
- BUSY_KEYWORDS = [
28
- "busy", "driving", "can't talk", "in a meeting", "call me later",
29
- "call back", "not now", "not a good time", "occupied", "running late",
30
- "in the middle of", "hold on", "give me a minute", "let me call you back",
31
- "gotta go", "heading out", "right now", "on the road", "at work",
32
- "hung up", "hang up", "rushing",
33
- ]
34
- FREE_KEYWORDS = [
35
- "free", "available", "go ahead", "i have time", "i'm listening",
36
- "sure", "yes", "yeah", "okay", "what's up", "tell me",
37
- "i can talk", "go on", "fire away",
38
- ]
39
- FILLER_WORDS = [
40
- "um", "uh", "hmm", "like", "you know", "sort of",
41
- "kind of", "i mean", "well", "so", "right", "actually",
42
- ]
43
- URGENCY_MARKERS = [
44
- "hurry", "quick", "fast", "rush", "soon", "asap",
45
- "right now", "immediately", "no time",
46
- ]
47
- DEFLECTION_PHRASES = [
48
- "later", "not now", "another time", "busy", "can't",
49
- "don't have time", "gotta go", "let me", "call me back",
50
- ]
51
-
52
- def __init__(self):
53
- print("Loading NLP models for text features...")
54
-
55
- # Sentiment — RoBERTa-based
56
- try:
57
- self.sentiment_model = pipeline(
58
- "sentiment-analysis",
59
- model="cardiffnlp/twitter-roberta-base-sentiment-latest",
60
- truncation=True,
61
- max_length=512,
62
- )
63
- print("✓ Sentiment model loaded")
64
- except Exception as e:
65
- print(f"⚠ Sentiment model fallback: {e}")
66
- self.sentiment_model = None
67
-
68
- # Coherence — Sentence Transformer
69
- try:
70
- self.coherence_model = SentenceTransformer("all-MiniLM-L6-v2")
71
- print("✓ Coherence model loaded")
72
- except Exception as e:
73
- print(f"⚠ Coherence model fallback: {e}")
74
- self.coherence_model = None
75
-
76
- print("✓ Text feature extractor ready")
77
-
78
- # --- T0: Explicit Free ---
79
- def extract_explicit_free(self, transcript: str) -> float:
80
- text = transcript.lower()
81
- for kw in self.FREE_KEYWORDS:
82
- if kw in text:
83
- return 1.0
84
- return 0.0
85
-
86
- # --- T1: Explicit Busy ---
87
- def extract_explicit_busy(self, transcript: str) -> float:
88
- text = transcript.lower()
89
- for kw in self.BUSY_KEYWORDS:
90
- if kw in text:
91
- return 1.0
92
- return 0.0
93
-
94
- # --- T2-T3: Response patterns ---
95
- def extract_response_patterns(self, transcript_list: List[str]) -> Dict[str, float]:
96
- if not transcript_list:
97
- return {"t2_avg_resp_len": 0.0, "t3_short_ratio": 0.0}
98
- lengths = [len(r.split()) for r in transcript_list]
99
- avg_len = float(np.mean(lengths))
100
- short_ratio = sum(1 for l in lengths if l <= 3) / len(lengths)
101
- return {"t2_avg_resp_len": avg_len, "t3_short_ratio": float(short_ratio)}
102
-
103
- # --- T4-T6: Marker counts ---
104
- def extract_marker_counts(self, transcript: str) -> Dict[str, float]:
105
- text = transcript.lower()
106
- words = text.split()
107
- total = max(len(words), 1)
108
-
109
- filler_count = sum(1 for w in words if w in self.FILLER_WORDS)
110
- urgency_count = sum(1 for phrase in self.URGENCY_MARKERS if phrase in text)
111
- deflection_count = sum(1 for phrase in self.DEFLECTION_PHRASES if phrase in text)
112
-
113
- return {
114
- "t4_cognitive_load": float(filler_count / total),
115
- "t5_time_pressure": float(urgency_count / total),
116
- "t6_deflection": float(deflection_count / total),
117
- }
118
-
119
- # --- T7: Sentiment ---
120
- def extract_sentiment(self, transcript: str) -> float:
121
- if self.sentiment_model is None or not transcript.strip():
122
- return 0.0
123
- try:
124
- result = self.sentiment_model(transcript[:512])[0]
125
- label = result["label"].lower()
126
- score = result["score"]
127
- if "positive" in label:
128
- return float(score)
129
- elif "negative" in label:
130
- return float(-score)
131
- else:
132
- return 0.0
133
- except Exception:
134
- return 0.0
135
-
136
- # --- T8: Coherence ---
137
- def extract_coherence(self, question: str, responses: List[str]) -> float:
138
- if self.coherence_model is None or not question or not responses:
139
- return 0.5
140
- try:
141
- q_emb = self.coherence_model.encode(question)
142
- r_embs = self.coherence_model.encode(responses)
143
- from sklearn.metrics.pairwise import cosine_similarity as cos_sim
144
- similarities = cos_sim([q_emb], r_embs)[0]
145
- return float(np.mean(similarities))
146
- except Exception:
147
- return 0.5
148
-
149
- # --- T9: Latency ---
150
- def extract_latency(self, events: List[Dict]) -> float:
151
- if not events or len(events) < 2:
152
- return 0.0
153
- latencies = []
154
- for i in range(1, len(events)):
155
- if events[i].get("speaker") != events[i - 1].get("speaker"):
156
- t1 = events[i - 1].get("timestamp", 0)
157
- t2 = events[i].get("timestamp", 0)
158
- if t2 > t1:
159
- latencies.append(t2 - t1)
160
- return float(np.mean(latencies)) if latencies else 0.0
161
-
162
- # --- Extract all ---
163
- def extract_all(
164
- self,
165
- transcript_list: List[str],
166
- full_transcript: str = "",
167
- question: str = "",
168
- events: List[Dict] = None,
169
- ) -> Dict[str, float]:
170
- if not full_transcript and transcript_list:
171
- full_transcript = " ".join(transcript_list)
172
-
173
- features = {}
174
- features["t0_explicit_free"] = self.extract_explicit_free(full_transcript)
175
- features["t1_explicit_busy"] = self.extract_explicit_busy(full_transcript)
176
- patterns = self.extract_response_patterns(transcript_list)
177
- features.update(patterns)
178
- markers = self.extract_marker_counts(full_transcript)
179
- features.update(markers)
180
- features["t7_sentiment"] = self.extract_sentiment(full_transcript)
181
- features["t8_coherence"] = self.extract_coherence(question, transcript_list)
182
- features["t9_latency"] = self.extract_latency(events or [])
183
- return features
184
 
185
 
186
  # ──────────────────────────────────────────────────────────────────────── #
@@ -223,11 +64,9 @@ async def global_exception_handler(request: Request, exc: Exception):
223
  content={**DEFAULT_TEXT_FEATURES, "_error": str(exc), "_handler": "global"},
224
  )
225
 
226
- extractor = TextFeatureExtractorEndpoint()
227
-
228
-
229
  class TextRequest(BaseModel):
230
  transcript: str = ""
 
231
  utterances: List[str] = []
232
  question: str = ""
233
  events: Optional[List[Dict]] = None
@@ -246,22 +85,37 @@ async def root():
246
  async def health():
247
  return {
248
  "status": "healthy",
 
249
  "sentiment_loaded": extractor.sentiment_model is not None,
250
- "coherence_loaded": extractor.coherence_model is not None,
251
  }
252
 
253
 
254
  @app.post("/extract-text-features")
255
  async def extract_text_features(data: TextRequest):
256
  """Extract all 9 text features from transcript."""
257
- transcript_list = data.utterances if data.utterances else [data.transcript]
 
 
 
 
 
 
258
  features = extractor.extract_all(
259
  transcript_list=transcript_list,
260
  full_transcript=data.transcript,
261
  question=data.question,
262
  events=data.events,
263
  )
264
- return features
 
 
 
 
 
 
 
 
 
265
 
266
 
267
  if __name__ == "__main__":
 
9
  Derived from: src/text_features.py
10
  """
11
 
 
 
 
 
 
 
 
12
  # ──────────────────────────────────────────────────────────────────────── #
13
+ # Imports from standardized modules
14
  # ──────────────────────────────────────────────────────────────────────── #
15
+ try:
16
+ from text_features import TextFeatureExtractor
17
+ except ImportError:
18
+ import sys
19
+ sys.path.append('.')
20
+ from text_features import TextFeatureExtractor
21
 
22
+ # Initialize global extractor
23
+ print("[INFO] Initializing Global TextFeatureExtractor...")
24
+ extractor = TextFeatureExtractor(use_intent_model=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
  # ──────────────────────────────────────────────────────────────────────── #
 
64
  content={**DEFAULT_TEXT_FEATURES, "_error": str(exc), "_handler": "global"},
65
  )
66
 
 
 
 
67
  class TextRequest(BaseModel):
68
  transcript: str = ""
69
+ # Optional list of extra utterances if available
70
  utterances: List[str] = []
71
  question: str = ""
72
  events: Optional[List[Dict]] = None
 
85
  async def health():
86
  return {
87
  "status": "healthy",
88
+ "intent_model_loaded": extractor.use_intent_model,
89
  "sentiment_loaded": extractor.sentiment_model is not None,
 
90
  }
91
 
92
 
93
  @app.post("/extract-text-features")
94
  async def extract_text_features(data: TextRequest):
95
  """Extract all 9 text features from transcript."""
96
+ # Prepare inputs for TextFeatureExtractor.extract_all
97
+ # It expects: transcript_list, full_transcript, question, events
98
+
99
+ transcript_list = data.utterances
100
+ if not transcript_list and data.transcript:
101
+ transcript_list = [data.transcript]
102
+
103
  features = extractor.extract_all(
104
  transcript_list=transcript_list,
105
  full_transcript=data.transcript,
106
  question=data.question,
107
  events=data.events,
108
  )
109
+
110
+ # Sanitize inputs to ensure floats
111
+ sanitized = {}
112
+ for k, v in features.items():
113
+ if isinstance(v, float):
114
+ sanitized[k] = 0.0 if np.isnan(v) or np.isinf(v) else v
115
+ else:
116
+ sanitized[k] = v
117
+
118
+ return sanitized
119
 
120
 
121
  if __name__ == "__main__":
text_features.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Text Feature Extractor - IMPROVED VERSION
3
+ Extracts 9 text features from conversation transcripts to detect busy/distracted states.
4
+
5
+ KEY IMPROVEMENTS:
6
+ 1. Uses NLI model for intent classification (understands "not busy" properly)
7
+ 2. Handles negation, context, and sarcasm
8
+ 3. Removes useless t9_latency for single-side audio
9
+ """
10
+
11
+ import numpy as np
12
+ from typing import List, Dict, Tuple
13
+ from transformers import pipeline
14
+ from sentence_transformers import SentenceTransformer
15
+ import re
16
+
17
+
18
+ class TextFeatureExtractor:
19
+ """Extract 9 text features for busy detection"""
20
+
21
+ def __init__(self, use_intent_model: bool = True):
22
+ """
23
+ Initialize NLP models
24
+
25
+ Args:
26
+ use_intent_model: If True, use BART-MNLI for intent classification
27
+ If False, fall back to pattern matching
28
+ """
29
+ self.use_intent_model = use_intent_model
30
+
31
+ print("Loading NLP models...")
32
+
33
+ # Sentiment model
34
+ model_name = "cardiffnlp/twitter-roberta-base-sentiment-latest"
35
+ self.sentiment_model = pipeline(
36
+ "sentiment-analysis",
37
+ model=model_name,
38
+ device=-1
39
+ )
40
+ print("[OK] Sentiment model loaded")
41
+
42
+ # Coherence model
43
+ self.coherence_model = SentenceTransformer('all-MiniLM-L6-v2')
44
+ print("[OK] Coherence model loaded")
45
+
46
+ # Intent classification model (NEW - understands context!)
47
+ if self.use_intent_model:
48
+ try:
49
+ self.intent_classifier = pipeline(
50
+ "zero-shot-classification",
51
+ model="facebook/bart-large-mnli",
52
+ device=-1
53
+ )
54
+ print("[OK] Intent classifier loaded (BART-MNLI)")
55
+ except Exception as e:
56
+ print(f"[WARN] Intent classifier failed to load: {e}")
57
+ print(" Falling back to pattern matching")
58
+ self.use_intent_model = False
59
+ self._setup_patterns()
60
+ else:
61
+ self._setup_patterns()
62
+
63
+ def _setup_patterns(self):
64
+ """Setup pattern-based matching as fallback"""
65
+ # Negation pattern
66
+ self.negation_pattern = re.compile(
67
+ r'\b(not|no|never|neither|n\'t|dont|don\'t|cannot|can\'t|wont|won\'t)\s+\w*\s*(busy|free|available|talk|rush)',
68
+ re.IGNORECASE
69
+ )
70
+
71
+ # Busy patterns (positive assertions)
72
+ self.busy_patterns = [
73
+ r'\b(i\'m|i am|im)\s+(busy|driving|working|cooking|rushing)\b',
74
+ r'\bin a (meeting|call|hurry)\b',
75
+ r'\bcan\'t talk\b',
76
+ r'\bcall (you|me) back\b',
77
+ r'\bnot a good time\b',
78
+ r'\bbad time\b'
79
+ ]
80
+
81
+ # Free patterns (positive assertions)
82
+ self.free_patterns = [
83
+ r'\b(i\'m|i am|im)\s+(free|available)\b',
84
+ r'\bcan talk\b',
85
+ r'\bhave time\b',
86
+ r'\bnot busy\b',
87
+ r'\bgood time\b',
88
+ r'\bnow works\b'
89
+ ]
90
+
91
+ # Compile patterns
92
+ self.busy_patterns = [re.compile(p, re.IGNORECASE) for p in self.busy_patterns]
93
+ self.free_patterns = [re.compile(p, re.IGNORECASE) for p in self.free_patterns]
94
+
95
+ # Legacy keywords for other features
96
+ self.busy_keywords = {
97
+ 'cognitive_load': [
98
+ 'um', 'uh', 'like', 'you know', 'i mean', 'kind of',
99
+ 'sort of', 'basically', 'actually'
100
+ ],
101
+ 'time_pressure': [
102
+ 'quickly', 'hurry', 'fast', 'urgent', 'asap', 'right now',
103
+ 'immediately', 'short', 'brief'
104
+ ],
105
+ 'deflection': [
106
+ 'later', 'another time', 'not now', 'maybe', 'i don\'t know',
107
+ 'whatever', 'sure sure', 'yeah yeah'
108
+ ]
109
+ }
110
+
111
+ def extract_explicit_busy(self, transcript: str) -> float:
112
+ """
113
+ T1: Explicit Busy Indicators (binary: 0 or 1)
114
+
115
+ IMPROVED: Uses NLI model to understand context and negation
116
+ - "I'm busy" → 1.0
117
+ - "I'm not busy" → 0.0
118
+ - "Can't talk right now" → 1.0
119
+ - "I can talk" → 0.0
120
+ """
121
+ if not transcript or len(transcript.strip()) < 3:
122
+ return 0.0
123
+
124
+ # Method 1: Use intent classification model (best)
125
+ if self.use_intent_model:
126
+ try:
127
+ result = self.intent_classifier(
128
+ transcript,
129
+ candidate_labels=["person is busy or occupied",
130
+ "person is free and available",
131
+ "unclear or neutral"],
132
+ hypothesis_template="This {}."
133
+ )
134
+
135
+ top_label = result['labels'][0]
136
+ top_score = result['scores'][0]
137
+
138
+ # Require high confidence (>0.6) to avoid false positives
139
+ if top_score > 0.6:
140
+ if "busy" in top_label:
141
+ return 1.0
142
+ elif "free" in top_label:
143
+ return 0.0
144
+
145
+ return 0.0 # Neutral or low confidence
146
+
147
+ except Exception as e:
148
+ print(f"Intent classification failed: {e}")
149
+ # Fall through to pattern matching
150
+
151
+ # Method 2: Pattern-based with negation handling (fallback)
152
+ return self._extract_busy_patterns(transcript)
153
+
154
+ def _extract_busy_patterns(self, transcript: str) -> float:
155
+ """Pattern-based busy detection with negation handling"""
156
+ transcript_lower = transcript.lower()
157
+
158
+ # Check for negated busy/free statements
159
+ negation_match = self.negation_pattern.search(transcript_lower)
160
+ if negation_match:
161
+ matched_text = negation_match.group(0)
162
+ # "not busy" or "can't be free" etc.
163
+ if any(word in matched_text for word in ['busy', 'rush']):
164
+ return 0.0 # "not busy" = available
165
+ elif any(word in matched_text for word in ['free', 'available', 'talk']):
166
+ return 1.0 # "can't talk" or "not free" = busy
167
+
168
+ # Check free patterns first (higher priority)
169
+ for pattern in self.free_patterns:
170
+ if pattern.search(transcript_lower):
171
+ return 0.0
172
+
173
+ # Then check busy patterns
174
+ for pattern in self.busy_patterns:
175
+ if pattern.search(transcript_lower):
176
+ return 1.0
177
+
178
+ return 0.0
179
+
180
+ def extract_explicit_free(self, transcript: str) -> float:
181
+ """
182
+ T0: Explicit Free Indicators (binary: 0 or 1)
183
+
184
+ IMPROVED: Uses same context-aware approach as busy detection
185
+ """
186
+ if not transcript or len(transcript.strip()) < 3:
187
+ return 0.0
188
+
189
+ # Use intent model
190
+ if self.use_intent_model:
191
+ try:
192
+ result = self.intent_classifier(
193
+ transcript,
194
+ candidate_labels=["person is free and available",
195
+ "person is busy or occupied",
196
+ "unclear or neutral"],
197
+ hypothesis_template="This {}."
198
+ )
199
+
200
+ top_label = result['labels'][0]
201
+ top_score = result['scores'][0]
202
+
203
+ if top_score > 0.6 and "free" in top_label:
204
+ return 1.0
205
+
206
+ return 0.0
207
+
208
+ except Exception as e:
209
+ print(f"Intent classification failed: {e}")
210
+
211
+ # Fallback to patterns
212
+ transcript_lower = transcript.lower()
213
+
214
+ for pattern in self.free_patterns:
215
+ if pattern.search(transcript_lower):
216
+ return 1.0
217
+
218
+ return 0.0
219
+
220
+ def extract_response_patterns(self, transcript_list: List[str]) -> Tuple[float, float]:
221
+ """
222
+ T2-T3: Average Response Length and Short Response Ratio
223
+
224
+ Returns:
225
+ - avg_response_len: Average words per response
226
+ - short_ratio: Fraction of responses with ≤3 words
227
+ """
228
+ if not transcript_list:
229
+ return 0.0, 0.0
230
+
231
+ word_counts = [len(response.split()) for response in transcript_list]
232
+
233
+ avg_response_len = np.mean(word_counts)
234
+ short_count = sum(1 for wc in word_counts if wc <= 3)
235
+ short_ratio = short_count / len(word_counts)
236
+
237
+ return float(avg_response_len), float(short_ratio)
238
+
239
+ def extract_marker_counts(self, transcript: str) -> Tuple[float, float, float]:
240
+ """
241
+ T4-T6: Cognitive Load, Time Pressure, Deflection markers
242
+
243
+ Returns:
244
+ - cognitive_load: Count of filler words / total words
245
+ - time_pressure: Count of urgency markers / total words
246
+ - deflection: Count of deflection phrases / total words
247
+ """
248
+ transcript_lower = transcript.lower()
249
+ words = transcript.split()
250
+ total_words = len(words)
251
+
252
+ if total_words == 0:
253
+ return 0.0, 0.0, 0.0
254
+
255
+ # Count markers
256
+ cognitive_load_count = sum(
257
+ 1 for keyword in self.busy_keywords['cognitive_load']
258
+ if keyword in transcript_lower
259
+ )
260
+
261
+ time_pressure_count = sum(
262
+ 1 for keyword in self.busy_keywords['time_pressure']
263
+ if keyword in transcript_lower
264
+ )
265
+
266
+ deflection_count = sum(
267
+ 1 for keyword in self.busy_keywords['deflection']
268
+ if keyword in transcript_lower
269
+ )
270
+
271
+ # Normalize by total words
272
+ cognitive_load = cognitive_load_count / total_words
273
+ time_pressure = time_pressure_count / total_words
274
+ deflection = deflection_count / total_words
275
+
276
+ return float(cognitive_load), float(time_pressure), float(deflection)
277
+
278
+ def extract_sentiment(self, transcript: str) -> float:
279
+ """
280
+ T7: Sentiment Polarity (-1 to +1)
281
+ Negative sentiment often indicates stress/frustration
282
+ """
283
+ if not transcript or len(transcript.strip()) == 0:
284
+ return 0.0
285
+
286
+ try:
287
+ result = self.sentiment_model(transcript[:512])[0]
288
+ label = result['label'].lower()
289
+ score = result['score']
290
+
291
+ if 'positive' in label:
292
+ return float(score)
293
+ elif 'negative' in label:
294
+ return float(-score)
295
+ else:
296
+ return 0.0
297
+
298
+ except Exception as e:
299
+ print(f"Sentiment extraction error: {e}")
300
+ return 0.0
301
+
302
+ def extract_coherence(self, question: str, responses: List[str]) -> float:
303
+ """
304
+ T8: Coherence Score (0 to 1)
305
+ Measures how relevant responses are to the question
306
+ Low coherence = distracted/not paying attention
307
+ """
308
+ if not question or not responses:
309
+ return 0.5 # Neutral if no data (changed from 1.0 to be more conservative)
310
+
311
+ try:
312
+ # Encode question and responses
313
+ question_embedding = self.coherence_model.encode(question, convert_to_tensor=True)
314
+ response_embeddings = self.coherence_model.encode(responses, convert_to_tensor=True)
315
+
316
+ # Calculate cosine similarity
317
+ from sentence_transformers import util
318
+ similarities = util.cos_sim(question_embedding, response_embeddings)[0]
319
+
320
+ # Average similarity as coherence score
321
+ coherence = float(np.mean(similarities.cpu().numpy()))
322
+
323
+ return max(0.0, min(1.0, coherence)) # Clamp to [0, 1]
324
+ except Exception as e:
325
+ print(f"Coherence extraction error: {e}")
326
+ return 0.5
327
+
328
+ def extract_latency(self, events: List[Dict]) -> float:
329
+ """
330
+ T9: Average Response Latency (seconds)
331
+
332
+ ⚠️ WARNING: This feature is USELESS for single-side audio!
333
+ Always returns 0.0 since we don't have agent questions.
334
+ Kept for compatibility with existing models.
335
+
336
+ events: List of dicts with 'timestamp' and 'speaker' keys
337
+ """
338
+ # Always return 0 for single-side audio
339
+ return 0.0
340
+
341
+ def extract_all(
342
+ self,
343
+ transcript_list: List[str],
344
+ full_transcript: str = "",
345
+ question: str = "",
346
+ events: List[Dict] = None
347
+ ) -> Dict[str, float]:
348
+ """
349
+ Extract all 9 text features
350
+
351
+ Args:
352
+ transcript_list: List of individual responses (can be single item for one-turn)
353
+ full_transcript: Complete conversation text
354
+ question: The question/prompt from agent (for coherence)
355
+ events: List of timestamped events (unused for single-side audio)
356
+
357
+ Returns:
358
+ Dict with keys: t0_explicit_free, t1_explicit_busy,
359
+ t2_avg_resp_len, t3_short_ratio,
360
+ t4_cognitive_load, t5_time_pressure, t6_deflection,
361
+ t7_sentiment, t8_coherence, t9_latency
362
+ """
363
+ features = {}
364
+
365
+ # Use full transcript if not provided separately
366
+ if not full_transcript:
367
+ full_transcript = " ".join(transcript_list)
368
+
369
+ # T0-T1: Explicit indicators (IMPROVED with NLI)
370
+ features['t0_explicit_free'] = self.extract_explicit_free(full_transcript)
371
+ features['t1_explicit_busy'] = self.extract_explicit_busy(full_transcript)
372
+
373
+ # T2-T3: Response patterns
374
+ avg_len, short_ratio = self.extract_response_patterns(transcript_list)
375
+ features['t2_avg_resp_len'] = avg_len
376
+ features['t3_short_ratio'] = short_ratio
377
+
378
+ # T4-T6: Markers
379
+ cog_load, time_press, deflect = self.extract_marker_counts(full_transcript)
380
+ features['t4_cognitive_load'] = cog_load
381
+ features['t5_time_pressure'] = time_press
382
+ features['t6_deflection'] = deflect
383
+
384
+ # T7: Sentiment
385
+ features['t7_sentiment'] = self.extract_sentiment(full_transcript)
386
+
387
+ # T8: Coherence (default to 0.5 if no question provided)
388
+ if question:
389
+ features['t8_coherence'] = self.extract_coherence(question, transcript_list)
390
+ else:
391
+ features['t8_coherence'] = 0.5 # Neutral
392
+
393
+ # T9: Latency (ALWAYS 0 for single-side audio)
394
+ features['t9_latency'] = 0.0
395
+
396
+ return features
397
+
398
+
399
+ if __name__ == "__main__":
400
+ # Test the extractor
401
+ print("Initializing Text Feature Extractor...")
402
+ extractor = TextFeatureExtractor(use_intent_model=True)
403
+
404
+ # Test cases for intent classification
405
+ test_cases = [
406
+ "I'm driving right now",
407
+ "I'm not busy at all",
408
+ "Can't talk, in a meeting",
409
+ "I can talk now",
410
+ "Not a good time",
411
+ "I have time to chat"
412
+ ]
413
+
414
+ print("\nTesting intent classification:")
415
+ for test in test_cases:
416
+ busy_score = extractor.extract_explicit_busy(test)
417
+ free_score = extractor.extract_explicit_free(test)
418
+ print(f" '{test}'")
419
+ print(f" → Busy: {busy_score:.1f}, Free: {free_score:.1f}")
420
+
421
+ # Full feature extraction
422
+ print("\nFull feature extraction:")
423
+ features = extractor.extract_all(
424
+ transcript_list=["I'm not busy", "I can talk now"],
425
+ full_transcript="I'm not busy. I can talk now.",
426
+ question="How are you doing today?"
427
+ )
428
+
429
+ print("\nExtracted features:")
430
+ for key, value in features.items():
431
+ print(f" {key}: {value:.3f}")