EurekaPotato commited on
Commit
3469c65
·
verified ·
1 Parent(s): 96f6db5

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. Dockerfile +2 -14
  2. README.md +10 -25
  3. handler.py +63 -147
  4. requirements.txt +6 -17
  5. text_features.py +431 -0
Dockerfile CHANGED
@@ -2,24 +2,12 @@ FROM python:3.10-slim
2
 
3
  WORKDIR /app
4
 
5
- # System dependencies for audio processing + git for torch.hub
6
- RUN apt-get update && apt-get install -y \
7
- libsndfile1 \
8
- ffmpeg \
9
- git \
10
- && rm -rf /var/lib/apt/lists/*
11
-
12
  COPY requirements.txt .
13
-
14
- # Install CPU-only torch first (prevents CUDA downloads)
15
- RUN pip install --no-cache-dir torch==2.1.0+cpu torchvision==0.16.0+cpu torchaudio==2.1.0+cpu \
16
- --extra-index-url https://download.pytorch.org/whl/cpu
17
-
18
- # Install other dependencies
19
  RUN pip install -r requirements.txt
20
 
21
  COPY . .
22
 
23
  EXPOSE 7860
24
 
25
- CMD ["uvicorn", "handler:app", "--host", "0.0.0.0", "--port", "7860"]
 
2
 
3
  WORKDIR /app
4
 
 
 
 
 
 
 
 
5
  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 . .
10
 
11
  EXPOSE 7860
12
 
13
+ CMD ["python", "handler.py"]
README.md CHANGED
@@ -1,41 +1,26 @@
1
  ---
2
- title: Busy Module Audio Features
3
- emoji: 🎤
4
- colorFrom: indigo
5
- colorTo: purple
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Audio Feature Extraction API
12
 
13
- Extracts 17 voice features from audio: SNR, noise classification, speech rate, pitch, energy, pause analysis, and emotion features.
14
 
15
  ## API
16
 
17
- **POST** `/extract-audio-features-base64`
18
  ```json
19
  {
20
- "audio_base64": "<base64-encoded-wav>",
21
- "transcript": "I'm driving right now"
 
22
  }
23
  ```
24
 
25
- **POST** `/extract-audio-features` (multipart form)
26
- - `audio`: audio file upload
27
- - `transcript`: text transcript
28
-
29
- **POST** `/extract-audio-features` (multipart form)
30
- - `audio`: audio file upload
31
- - `transcript`: text transcript
32
-
33
  **GET** `/health`
34
-
35
- ## Authentication
36
-
37
- This Space requires access to private models. You must add your Hugging Face token as a secret:
38
- 1. Go to **Settings** -> **Variables and secrets**.
39
- 2. Click **New secret**.
40
- 3. Name: `HF_TOKEN`
41
- 4. Value: Your Hugging Face Access Token (with read permissions).
 
1
  ---
2
+ title: Busy Module Text Features
3
+ emoji: 💬
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Text Feature Extraction API
12
 
13
+ Extracts 9 text features from conversation transcripts: explicit intent, response patterns, cognitive load, time pressure, deflection, sentiment (RoBERTa), coherence (Sentence Transformer), and latency.
14
 
15
  ## API
16
 
17
+ **POST** `/extract-text-features`
18
  ```json
19
  {
20
+ "transcript": "I'm driving right now, can't talk",
21
+ "utterances": ["I'm driving right now", "can't talk"],
22
+ "question": "How are you doing?"
23
  }
24
  ```
25
 
 
 
 
 
 
 
 
 
26
  **GET** `/health`
 
 
 
 
 
 
 
 
handler.py CHANGED
@@ -1,91 +1,53 @@
1
  """
2
- Audio Feature Extraction — Hugging Face Inference Endpoint Handler
3
 
4
- Extracts all 17 voice features from uploaded audio:
5
- v1_snr, v2_noise_* (5), v3_speech_rate, v4/v5_pitch, v6/v7_energy,
6
- v8/v9/v10_pause, v11/v12/v13_emotion
 
7
 
8
- Derived from: src/audio_features.py, src/emotion_features.py
9
  """
10
 
11
- import io
12
- import numpy as np
13
- import librosa
14
- from scipy import signal as scipy_signal
15
- from typing import Dict
16
- import torch
17
- import torch.nn as nn
18
- from torchvision import models
19
- import warnings
20
-
21
- warnings.filterwarnings("ignore")
22
-
23
-
24
  # ──────────────────────────────────────────────────────────────────────── #
25
  # Imports from standardized modules
26
  # ──────────────────────────────────────────────────────────────────────── #
27
  try:
28
- from audio_features import AudioFeatureExtractor
29
  except ImportError:
30
- # Fallback if running from a different context
31
  import sys
32
  sys.path.append('.')
33
- from audio_features import AudioFeatureExtractor
34
 
35
  # Initialize global extractor
36
- # We use a global instance to cache models (VAD, Emotion)
37
- print("[INFO] Initializing Global AudioFeatureExtractor...")
38
- extractor = AudioFeatureExtractor(
39
- sample_rate=16000,
40
- use_emotion=True,
41
- emotion_models_dir="/app/models" # Absolute path in Docker container
42
- )
43
-
44
- # Ensure models are downloaded/ready
45
- if extractor.use_emotion and extractor.emotion_extractor:
46
- print("[INFO] Checking for emotion models...")
47
- # Trigger download if needed/possible
48
- try:
49
- if len(extractor.emotion_extractor.models) == 0:
50
- print("[INFO] Models not found, attempting download...")
51
- extractor.emotion_extractor.download_models()
52
- # Re-init manually to load them
53
- extractor.emotion_extractor.__init__(models_dir=extractor.emotion_extractor.models_dir)
54
- except Exception as e:
55
- print(f"[WARN] Failed to download emotion models: {e}")
56
-
57
- # ──────────────────────────────────────────────────────────────────────── #
58
- # Helper to handle NaN/Inf for JSON
59
- # ──────────────────────────────────────────────────────────────────────── #
60
- def sanitize_features(features: Dict[str, float]) -> Dict[str, float]:
61
- sanitized = {}
62
- for key, val in features.items():
63
- if isinstance(val, (float, np.floating)):
64
- if np.isnan(val) or np.isinf(val):
65
- sanitized[key] = 0.0
66
- else:
67
- sanitized[key] = float(val)
68
- elif isinstance(val, (int, np.integer)):
69
- sanitized[key] = int(val)
70
- else:
71
- sanitized[key] = val # keep string/other as is
72
- return sanitized
73
-
74
 
75
 
76
  # ──────────────────────────────────────────────────────────────────────── #
77
- # FastAPI handler for deployment (HF Spaces / Cloud Run / Lambda)
78
  # ──────────────────────────────────────────────────────────────────────── #
79
 
80
- from fastapi import FastAPI, File, UploadFile, Form, Request
81
  from fastapi.middleware.cors import CORSMiddleware
82
  from fastapi.responses import JSONResponse
83
  from pydantic import BaseModel
84
- from typing import Optional
85
- import base64
86
  import traceback
87
 
88
- app = FastAPI(title="Audio Feature Extraction API", version="1.0.0")
 
 
 
 
 
 
 
 
 
 
 
 
89
  app.add_middleware(
90
  CORSMiddleware,
91
  allow_origins=["*"], allow_credentials=True,
@@ -95,110 +57,65 @@ app.add_middleware(
95
 
96
  @app.exception_handler(Exception)
97
  async def global_exception_handler(request: Request, exc: Exception):
98
- """Catch any unhandled exceptions and return defaults instead of 500."""
99
  print(f"[GLOBAL ERROR] {request.url}: {exc}")
100
  traceback.print_exc()
101
  return JSONResponse(
102
  status_code=200,
103
- content={**DEFAULT_AUDIO_FEATURES, "_error": str(exc), "_handler": "global"},
104
  )
105
 
106
- # Extractor is already initialized globally above
107
-
108
- # ──────────────────────────────────────────────────────────────────────── #
109
- # Constants & Defaults
110
- # ──────────────────────────────────────────────────────────────────────── #
111
-
112
- class AudioBase64Request(BaseModel):
113
- audio_base64: str = ""
114
  transcript: str = ""
 
 
 
 
115
 
116
 
117
  @app.get("/")
118
  async def root():
119
  return {
120
- "service": "Audio Feature Extraction API",
121
  "version": "1.0.0",
122
- "endpoints": ["/health", "/extract-audio-features", "/extract-audio-features-base64"],
123
  }
124
 
125
 
126
  @app.get("/health")
127
  async def health():
128
- vad_status = extractor.vad_model is not None
129
- emotion_status = extractor.emotion_extractor is not None if extractor.use_emotion else False
130
  return {
131
- "status": "healthy",
132
- "vad_loaded": vad_status,
133
- "emotion_loaded": emotion_status
134
  }
135
 
136
 
137
- @app.post("/extract-audio-features")
138
- async def extract_audio_features(audio: UploadFile = File(...), transcript: str = Form("")):
139
- """Extract all 17 voice features from uploaded audio file."""
140
- try:
141
- audio_bytes = await audio.read()
142
- # librosa.load returns (audio, sr)
143
- y, sr = librosa.load(io.BytesIO(audio_bytes), sr=16000, mono=True)
144
-
145
- # AudioFeatureExtractor.extract_all expects numpy array and optional transcript
146
- features = extractor.extract_all(y, transcript)
147
-
148
- return sanitize_features(features)
149
- except Exception as e:
150
- print(f"[ERROR] extract_audio_features: {e}")
151
- traceback.print_exc()
152
- return {**DEFAULT_AUDIO_FEATURES, "_error": str(e)}
153
-
154
-
155
- @app.post("/extract-audio-features-base64")
156
- async def extract_audio_features_base64(data: AudioBase64Request):
157
- """Extract features from base64-encoded audio (for Vercel serverless calls)."""
158
- import soundfile as sf
159
-
160
- audio_b64 = data.audio_base64
161
- transcript = data.transcript
162
-
163
- # Handle empty / missing audio — return default features
164
- if not audio_b64 or len(audio_b64) < 100:
165
- print("[INFO] Empty or too-short audio_base64, returning defaults")
166
- return {**DEFAULT_AUDIO_FEATURES}
167
-
168
- try:
169
- # Strip data URL prefix if present (e.g. "data:audio/wav;base64,...")
170
- if "," in audio_b64[:80]:
171
- audio_b64 = audio_b64.split(",", 1)[1]
172
-
173
- audio_bytes = base64.b64decode(audio_b64)
174
- print(f"[INFO] Decoded {len(audio_bytes)} bytes of audio")
175
-
176
- # Try soundfile first, fall back to librosa
177
- try:
178
- y, sr = sf.read(io.BytesIO(audio_bytes))
179
- except Exception as sf_err:
180
- print(f"[WARN] soundfile failed ({sf_err}), trying librosa...")
181
- y, sr = librosa.load(io.BytesIO(audio_bytes), sr=16000, mono=True)
182
-
183
- if hasattr(y, 'shape') and len(y.shape) > 1:
184
- y = np.mean(y, axis=1)
185
- y = np.asarray(y, dtype=np.float32)
186
- if sr != 16000:
187
- y = librosa.resample(y, orig_sr=sr, target_sr=16000)
188
- y = y.astype(np.float32)
189
-
190
- if len(y) < 100:
191
- print("[WARN] Audio too short after decode, returning defaults")
192
- return {**DEFAULT_AUDIO_FEATURES}
193
-
194
- features = extractor.extract_all(y, transcript)
195
- print(f"[OK] Extracted {len(features)} audio features")
196
- return sanitize_features(features)
197
- except Exception as e:
198
- print(f"[ERROR] extract_audio_features_base64: {e}")
199
- traceback.print_exc()
200
- # Return defaults rather than 500
201
- return {**DEFAULT_AUDIO_FEATURES, "_error": str(e)}
202
 
203
 
204
  if __name__ == "__main__":
@@ -206,4 +123,3 @@ if __name__ == "__main__":
206
  import os
207
  port = int(os.environ.get("PORT", 7860))
208
  uvicorn.run(app, host="0.0.0.0", port=port)
209
-
 
1
  """
2
+ Text Feature Extraction — Hugging Face Inference Endpoint Handler
3
 
4
+ Extracts all 9 text features from conversation transcript:
5
+ t0_explicit_free, t1_explicit_busy, t2_avg_resp_len, t3_short_ratio,
6
+ t4_cognitive_load, t5_time_pressure, t6_deflection, t7_sentiment,
7
+ t8_coherence, t9_latency
8
 
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
  # ──────────────────────────────────────────────────────────────────────── #
28
+ # FastAPI handler for deployment
29
  # ──────────────────────────────────────────────────────────────────────── #
30
 
31
+ from fastapi import FastAPI, Request
32
  from fastapi.middleware.cors import CORSMiddleware
33
  from fastapi.responses import JSONResponse
34
  from pydantic import BaseModel
35
+ from typing import Optional, List, Dict
 
36
  import traceback
37
 
38
+ # ──────────────────────────────────────────────────────────────────────── #
39
+ # Constants & Defaults
40
+ # ──────────────────────────────────────────────────────────────────────── #
41
+
42
+ DEFAULT_TEXT_FEATURES = {
43
+ "t0_explicit_free": 0.0, "t1_explicit_busy": 0.0,
44
+ "t2_avg_resp_len": 0.0, "t3_short_ratio": 0.0,
45
+ "t4_cognitive_load": 0.0, "t5_time_pressure": 0.0,
46
+ "t6_deflection": 0.0, "t7_sentiment": 0.0,
47
+ "t8_coherence": 0.5, "t9_latency": 0.0,
48
+ }
49
+
50
+ app = FastAPI(title="Text Feature Extraction API", version="1.0.0")
51
  app.add_middleware(
52
  CORSMiddleware,
53
  allow_origins=["*"], allow_credentials=True,
 
57
 
58
  @app.exception_handler(Exception)
59
  async def global_exception_handler(request: Request, exc: Exception):
 
60
  print(f"[GLOBAL ERROR] {request.url}: {exc}")
61
  traceback.print_exc()
62
  return JSONResponse(
63
  status_code=200,
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
73
 
74
 
75
  @app.get("/")
76
  async def root():
77
  return {
78
+ "service": "Text Feature Extraction API",
79
  "version": "1.0.0",
80
+ "endpoints": ["/health", "/extract-text-features"],
81
  }
82
 
83
 
84
  @app.get("/health")
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__":
 
123
  import os
124
  port = int(os.environ.get("PORT", 7860))
125
  uvicorn.run(app, host="0.0.0.0", port=port)
 
requirements.txt CHANGED
@@ -1,22 +1,11 @@
1
- # Core audio processing
2
- librosa==0.10.1
3
- soundfile==0.12.1
4
- numpy==1.24.3
5
- scipy==1.11.2
6
-
7
- # ML - CPU-only versions (HF Spaces friendly)
8
- # Torch for Silero VAD
9
- --extra-index-url https://download.pytorch.org/whl/cpu
10
- torch==2.1.0+cpu
11
- torchaudio==2.1.0+cpu
12
 
13
- # TensorFlow for Emotion Models
14
- tensorflow-cpu==2.15.0
15
 
16
  # API
17
  fastapi==0.95.2
18
  uvicorn==0.22.0
19
- python-multipart==0.0.6
20
- huggingface_hub>=0.19.0
21
- noisereduce>=3.0.0
22
- scikit-image>=0.21.0
 
1
+ # NLP
2
+ transformers==4.35.0
3
+ sentence-transformers==2.2.2
 
 
 
 
 
 
 
 
4
 
5
+ numpy==1.24.3
6
+ scikit-learn==1.3.2
7
 
8
  # API
9
  fastapi==0.95.2
10
  uvicorn==0.22.0
11
+ pydantic==1.10.13
 
 
 
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
+ # Always setup patterns — busy_keywords is needed by extract_marker_counts()
47
+ self._setup_patterns()
48
+
49
+ # Intent classification model (NEW - understands context!)
50
+ if self.use_intent_model:
51
+ try:
52
+ self.intent_classifier = pipeline(
53
+ "zero-shot-classification",
54
+ model="facebook/bart-large-mnli",
55
+ device=-1
56
+ )
57
+ print("[OK] Intent classifier loaded (BART-MNLI)")
58
+ except Exception as e:
59
+ print(f"[WARN] Intent classifier failed to load: {e}")
60
+ print(" Falling back to pattern matching")
61
+ self.use_intent_model = False
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}")