archivartaunik commited on
Commit
b1d4fb8
·
verified ·
1 Parent(s): 543c607

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +975 -0
app.py ADDED
@@ -0,0 +1,975 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI wired to hexagonal architecture services."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as _dt
5
+ import os
6
+ import tempfile
7
+ from typing import Optional, List, Tuple
8
+ import json
9
+
10
+ import gradio as gr
11
+ import pandas as pd
12
+
13
+ # --- Пачатак блока, які можа патрабаваць ўстаноўкі залежнасцяў ---
14
+ try:
15
+ from calls_analyser.adapters.ai.gemini import GeminiAIAdapter
16
+ from calls_analyser.adapters.secrets.env import EnvSecretsAdapter
17
+ from calls_analyser.adapters.storage.local import LocalStorageAdapter
18
+ from calls_analyser.adapters.telephony.vochi import VochiTelephonyAdapter
19
+ from calls_analyser.domain.exceptions import CallsAnalyserError
20
+ from calls_analyser.domain.models import Language
21
+ from calls_analyser.ports.ai import AIModelPort
22
+ from calls_analyser.services.analysis import AnalysisOptions, AnalysisService
23
+ from calls_analyser.services.call_log import CallLogService
24
+ from calls_analyser.services.prompt import PromptService
25
+ from calls_analyser.services.registry import ProviderRegistry
26
+ from calls_analyser.services.tenant import TenantService
27
+ from calls_analyser.config import (
28
+ PROMPTS as CFG_PROMPTS,
29
+ MODEL_CANDIDATES as CFG_MODEL_CANDIDATES,
30
+ BATCH_MODEL_KEY as CFG_BATCH_MODEL_KEY,
31
+ BATCH_PROMPT_KEY as CFG_BATCH_PROMPT_KEY,
32
+ BATCH_PROMPT_TEXT as CFG_BATCH_PROMPT_TEXT,
33
+ BATCH_LANGUAGE_CODE as CFG_BATCH_LANGUAGE_CODE,
34
+ )
35
+ PROJECT_IMPORTS_AVAILABLE = True
36
+ except ImportError:
37
+ PROJECT_IMPORTS_AVAILABLE = False
38
+
39
+ class CallsAnalyserError(Exception):
40
+ pass
41
+
42
+ class Language:
43
+ RUSSIAN = "ru"
44
+ BELARUSIAN = "be"
45
+ ENGLISH = "en"
46
+ AUTO = "auto"
47
+
48
+ CFG_PROMPTS = {}
49
+ CFG_MODEL_CANDIDATES = []
50
+ CFG_BATCH_MODEL_KEY = ""
51
+ CFG_BATCH_PROMPT_KEY = ""
52
+ CFG_BATCH_PROMPT_TEXT = ""
53
+ CFG_BATCH_LANGUAGE_CODE = "auto"
54
+ # --- Канец блока ---
55
+
56
+
57
+ PROMPTS = CFG_PROMPTS if PROJECT_IMPORTS_AVAILABLE else {}
58
+
59
+ TPL_OPTIONS = [(tpl.title, tpl.key) for tpl in PROMPTS.values()] + [("Custom", "custom")]
60
+ LANG_OPTIONS = [
61
+ ("Russian", Language.RUSSIAN),
62
+ ("Auto", Language.AUTO),
63
+ ("Belarusian", Language.BELARUSIAN),
64
+ ("English", Language.ENGLISH),
65
+ ]
66
+ CALL_TYPE_OPTIONS = [
67
+ ("All types", ""),
68
+ ("Inbound", "0"),
69
+ ("Outbound", "1"),
70
+ ("Internal", "2"),
71
+ ]
72
+ MODEL_CANDIDATES = CFG_MODEL_CANDIDATES if PROJECT_IMPORTS_AVAILABLE else []
73
+
74
+
75
+ # ----------------------------------------------------------------------------
76
+ # Dependency wiring
77
+ # ----------------------------------------------------------------------------
78
+ DEFAULT_TENANT_ID = os.environ.get("DEFAULT_TENANT_ID", "Amedis")
79
+ DEFAULT_BASE_URL = os.environ.get("VOCHI_BASE_URL", "https://crm.vochi.by/api")
80
+
81
+ if not PROJECT_IMPORTS_AVAILABLE:
82
+ # заглушкі
83
+ class MockAdapter:
84
+ def get_optional_secret(self, _):
85
+ return os.environ.get("GOOGLE_API_KEY")
86
+
87
+ secrets_adapter = MockAdapter()
88
+ storage_adapter = None
89
+ prompt_service = None
90
+ ai_registry = {}
91
+ tenant_service = None
92
+ call_log_service = None
93
+ analysis_service = None
94
+ else:
95
+ secrets_adapter = EnvSecretsAdapter()
96
+ storage_adapter = LocalStorageAdapter()
97
+ prompt_service = PromptService(PROMPTS)
98
+ ai_registry: ProviderRegistry[AIModelPort] = ProviderRegistry()
99
+
100
+ def _register_gemini_models() -> None:
101
+ api_key = secrets_adapter.get_optional_secret("GOOGLE_API_KEY")
102
+ if not api_key:
103
+ return
104
+ for _title, model in MODEL_CANDIDATES:
105
+ try:
106
+ ai_registry.register(model, GeminiAIAdapter(api_key=api_key, model=model))
107
+ except CallsAnalyserError:
108
+ continue
109
+
110
+ _register_gemini_models()
111
+
112
+ def _build_tenant_service() -> TenantService:
113
+ return TenantService(
114
+ secrets_adapter,
115
+ default_tenant=DEFAULT_TENANT_ID,
116
+ default_base_url=DEFAULT_BASE_URL,
117
+ )
118
+
119
+ def _build_call_log_service(tenant_service: TenantService) -> CallLogService:
120
+ config = tenant_service.resolve()
121
+ telephony_adapter = VochiTelephonyAdapter(
122
+ base_url=config.vochi_base_url,
123
+ client_id=config.vochi_client_id,
124
+ bearer_token=config.bearer_token,
125
+ )
126
+ return CallLogService(telephony_adapter, storage_adapter)
127
+
128
+ tenant_service = _build_tenant_service()
129
+ call_log_service = _build_call_log_service(tenant_service)
130
+ analysis_service = AnalysisService(call_log_service, ai_registry, prompt_service)
131
+
132
+
133
+ def _build_model_options() -> list[tuple[str, str]]:
134
+ """Збіраем опцыі мадэлі для выпадаючага спісу."""
135
+ if not PROJECT_IMPORTS_AVAILABLE:
136
+ return []
137
+ options: list[tuple[str, str]] = []
138
+ for title, model_key in MODEL_CANDIDATES:
139
+ if model_key not in ai_registry:
140
+ continue
141
+ provider = ai_registry.get(model_key)
142
+ provider_label = getattr(provider, "provider_name", model_key)
143
+ options.append((f"{provider_label} • {title}", model_key))
144
+ return options
145
+
146
+
147
+ MODEL_OPTIONS = _build_model_options()
148
+ MODEL_PLACEHOLDER_CHOICE = ("Configure GOOGLE_API_KEY to enable Gemini models", "")
149
+ MODEL_CHOICES = MODEL_OPTIONS or [MODEL_PLACEHOLDER_CHOICE]
150
+ MODEL_DEFAULT = MODEL_OPTIONS[0][1] if MODEL_OPTIONS else MODEL_PLACEHOLDER_CHOICE[1]
151
+ MODEL_INFO = (
152
+ "Select an AI model for call analysis"
153
+ if MODEL_OPTIONS
154
+ else "Add GOOGLE_API_KEY to secrets and reload to enable models"
155
+ )
156
+
157
+ BATCH_PROMPT_KEY = CFG_BATCH_PROMPT_KEY
158
+ BATCH_PROMPT_TEXT = (CFG_BATCH_PROMPT_TEXT or "").strip()
159
+ BATCH_MODEL_KEY = CFG_BATCH_MODEL_KEY or MODEL_DEFAULT or ""
160
+ BATCH_LANGUAGE_CODE = CFG_BATCH_LANGUAGE_CODE
161
+ try:
162
+ BATCH_LANGUAGE = Language(BATCH_LANGUAGE_CODE)
163
+ except ValueError:
164
+ BATCH_LANGUAGE = Language.AUTO
165
+
166
+
167
+ # ----------------------------------------------------------------------------
168
+ # UI utilities
169
+ # ----------------------------------------------------------------------------
170
+ def _label_row(row: dict) -> str:
171
+ start = row.get("Start", "")
172
+ src = row.get("CallerId", "")
173
+ dst = row.get("Destination", "")
174
+ dur = row.get("Duration", "")
175
+ return f"{start} | {src} → {dst} ({dur}s)"
176
+
177
+
178
+ def _parse_day(day_value) -> _dt.date:
179
+ if isinstance(day_value, _dt.datetime):
180
+ return day_value.date()
181
+ if isinstance(day_value, _dt.date):
182
+ return day_value
183
+ if not day_value:
184
+ raise ValueError("Date not specified.")
185
+ try:
186
+ timestamp = float(str(day_value).strip())
187
+ if timestamp > 1e9:
188
+ return _dt.datetime.fromtimestamp(timestamp, tz=_dt.timezone.utc).date()
189
+ except (ValueError, TypeError):
190
+ pass
191
+ try:
192
+ return _dt.date.fromisoformat(str(day_value).strip())
193
+ except ValueError as exc:
194
+ raise ValueError(f"Invalid date format: {day_value}") from exc
195
+
196
+
197
+ def _parse_time_value(time_value) -> Optional[_dt.time]:
198
+ if time_value in (None, ""):
199
+ return None
200
+ if isinstance(time_value, _dt.datetime):
201
+ return time_value.time().replace(microsecond=0)
202
+ if isinstance(time_value, _dt.time):
203
+ return time_value.replace(microsecond=0)
204
+ try:
205
+ timestamp = float(str(time_value).strip())
206
+ if timestamp > 1e9:
207
+ return (
208
+ _dt.datetime.fromtimestamp(timestamp, tz=_dt.timezone.utc)
209
+ .time()
210
+ .replace(microsecond=0)
211
+ )
212
+ except (ValueError, TypeError):
213
+ pass
214
+ value = str(time_value).strip()
215
+ if not value:
216
+ return None
217
+ try:
218
+ if value.count(":") == 1 and len(value.split(":")[0]) == 1:
219
+ value = f"0{value}"
220
+ parsed = _dt.time.fromisoformat(value)
221
+ except ValueError as exc:
222
+ if len(value) == 5 and value.count(":") == 1:
223
+ parsed = _dt.time.fromisoformat(f"{value}:00")
224
+ else:
225
+ raise ValueError(f"Invalid time format: {value}") from exc
226
+ return parsed.replace(microsecond=0)
227
+
228
+
229
+ def _validate_time_range(time_from: Optional[_dt.time], time_to: Optional[_dt.time]) -> None:
230
+ if time_from and time_to and time_from > time_to:
231
+ raise ValueError("Time 'from' must be less than or equal to time 'to'.")
232
+
233
+
234
+ def _resolve_call_type(value: object) -> Optional[int]:
235
+ s = str(value).strip()
236
+ if s == "":
237
+ return None
238
+ try:
239
+ return int(s)
240
+ except ValueError:
241
+ pass
242
+ label_to_value = {label: v for (label, v) in CALL_TYPE_OPTIONS}
243
+ mapped = label_to_value.get(s, "")
244
+ try:
245
+ return int(mapped) if mapped != "" else None
246
+ except ValueError:
247
+ return None
248
+
249
+
250
+ def _build_dropdown(df: pd.DataFrame):
251
+ opts = [(_label_row(row), idx) for idx, row in df.iterrows()]
252
+ value = opts[0][1] if opts else None
253
+ return gr.update(choices=[(label, idx) for label, idx in opts], value=value)
254
+
255
+
256
+ def _build_batch_dropdown(df: pd.DataFrame):
257
+ if df is None or df.empty:
258
+ return gr.update(choices=[], value=None)
259
+ opts: List[Tuple[str, str]] = []
260
+ for _idx, row in df.iterrows():
261
+ label = (
262
+ f"{row.get('Start','')} | {row.get('Caller','')} -> "
263
+ f"{row.get('Destination','')} ({row.get('Duration (s)','')}s)"
264
+ )
265
+ uid = str(row.get("UniqueId", ""))
266
+ if uid:
267
+ opts.append((label, uid))
268
+ value = opts[0][1] if opts else None
269
+ return gr.update(choices=opts, value=value)
270
+
271
+
272
+ # ----------------------------------------------------------------------------
273
+ # Gradio handlers
274
+ # ----------------------------------------------------------------------------
275
+ def ui_filter_calls(
276
+ date_value,
277
+ time_from_value,
278
+ time_to_value,
279
+ call_type_value,
280
+ authed,
281
+ tenant_id,
282
+ ):
283
+ """Фільтруе званкі і вяртае табліцу."""
284
+ if not authed:
285
+ return (
286
+ gr.update(value=pd.DataFrame(), visible=False),
287
+ gr.update(visible=False),
288
+ gr.update(choices=[], value=None),
289
+ "🔐 Enter the password to apply the filter.",
290
+ gr.update(visible=True),
291
+ )
292
+
293
+ if not PROJECT_IMPORTS_AVAILABLE:
294
+ return (
295
+ pd.DataFrame(),
296
+ gr.update(visible=False),
297
+ [],
298
+ "Project dependencies are not loaded.",
299
+ gr.update(visible=False),
300
+ )
301
+
302
+ try:
303
+ day = _parse_day(date_value)
304
+ time_from = _parse_time_value(time_from_value)
305
+ time_to = _parse_time_value(time_to_value)
306
+ _validate_time_range(time_from, time_to)
307
+ call_type = _resolve_call_type(call_type_value)
308
+
309
+ tenant = tenant_service.resolve(tenant_id or None)
310
+ entries = call_log_service.list_calls(
311
+ day,
312
+ tenant,
313
+ time_from=time_from,
314
+ time_to=time_to,
315
+ call_type=call_type,
316
+ )
317
+ df = pd.DataFrame([entry.raw for entry in entries])
318
+ dd = _build_dropdown(df)
319
+ msg = f"Calls found: {len(df)}"
320
+
321
+ return (
322
+ gr.update(value=df, visible=True),
323
+ gr.update(visible=False),
324
+ dd,
325
+ msg,
326
+ gr.update(visible=False),
327
+ )
328
+ except Exception as exc:
329
+ return (
330
+ gr.update(value=pd.DataFrame(), visible=True),
331
+ gr.update(visible=False),
332
+ gr.update(choices=[], value=None),
333
+ f"Load error: {exc}",
334
+ gr.update(visible=False),
335
+ )
336
+
337
+
338
+ def ui_play_audio(selected_idx, df, tenant_id):
339
+ """Прайграць аўдыё па выбраным радку.
340
+
341
+ Лагіка:
342
+ - калі selected_idx выглядае як UID (не лічба) -> гуляем яго;
343
+ - калі гэта індэкс радка -> шукаем у df і бярэм UniqueId.
344
+ """
345
+ if not PROJECT_IMPORTS_AVAILABLE:
346
+ return "Project dependencies are not loaded.", None, ""
347
+
348
+ unique_id = None
349
+
350
+ if selected_idx is not None:
351
+ try:
352
+ # калі дропдаўн ужо захоўвае UID напрамую
353
+ if not str(selected_idx).isdigit():
354
+ unique_id = str(selected_idx)
355
+ elif df is not None and not df.empty:
356
+ row = df.iloc[int(selected_idx)]
357
+ unique_id = str(row.get("UniqueId"))
358
+ except (ValueError, IndexError):
359
+ return "<em>Invalid selection.</em>", None, ""
360
+
361
+ if not unique_id:
362
+ return "<em>Select a call to play.</em>", None, ""
363
+
364
+ try:
365
+ tenant = tenant_service.resolve(tenant_id or None)
366
+ handle = call_log_service.ensure_recording(unique_id, tenant)
367
+
368
+ listen_url = (
369
+ f"{tenant.vochi_base_url.rstrip('/')}/calllogs/"
370
+ f"{tenant.vochi_client_id}/{unique_id}"
371
+ )
372
+ # ВЫПРАЎЛЕНА: Дадаем унікальны ID для JavaScript
373
+ html = f'URL: <a id="audio-listen-link" href="{listen_url}">{listen_url}</a>'
374
+
375
+ return html, handle.local_uri, "Ready ✅"
376
+ except Exception as exc:
377
+ return f"Playback failed: {exc}", None, ""
378
+
379
+
380
+ def ui_toggle_custom_prompt(template_key):
381
+ """Паказаць/схаваць поле Custom prompt."""
382
+ return gr.update(visible=(template_key == "custom"))
383
+
384
+
385
+ def ui_mass_analyze(
386
+ date_value,
387
+ time_from_value,
388
+ time_to_value,
389
+ call_type_value,
390
+ tenant_id,
391
+ authed,
392
+ ):
393
+ """
394
+ Масавы аналіз (STREAMING).
395
+ Гэта генератар (yield), Gradio будзе адлюстроўваць вынікі паступова.
396
+ Паведамленні прагрэс-статусу і выніковае паведамленне ідуць буйным шрыфтам (Markdown ## / ###).
397
+ """
398
+
399
+ empty_df = pd.DataFrame()
400
+ hidden_df_update = gr.update(value=empty_df, visible=False)
401
+ hidden_file = gr.update(value=None, visible=False)
402
+
403
+ def h3(txt: str) -> str:
404
+ # сярэдні буйны шрыфт
405
+ return f"### {txt}"
406
+
407
+ def h2_success(txt: str) -> str:
408
+ # вялікі тэкст для фінальнага выніку
409
+ return f"## {txt}"
410
+
411
+ def h2_error(txt: str) -> str:
412
+ return f"## {txt}"
413
+
414
+ # 1) праверкі доступу і канфіга
415
+ if not authed:
416
+ yield (
417
+ hidden_df_update,
418
+ h2_error("🔐 Enter the password to run batch analysis."),
419
+ hidden_file,
420
+ )
421
+ return
422
+
423
+ if not PROJECT_IMPORTS_AVAILABLE:
424
+ yield (
425
+ hidden_df_update,
426
+ h2_error("Project dependencies are not loaded."),
427
+ hidden_file,
428
+ )
429
+ return
430
+
431
+ if len(ai_registry) == 0 or not BATCH_MODEL_KEY:
432
+ yield (
433
+ hidden_df_update,
434
+ h2_error("❌ Batch analysis is unavailable: AI model is not configured."),
435
+ hidden_file,
436
+ )
437
+ return
438
+
439
+ # 2) асноўная логіка збору спісу званкоў
440
+ try:
441
+ day = _parse_day(date_value)
442
+ time_from = _parse_time_value(time_from_value)
443
+ time_to = _parse_time_value(time_to_value)
444
+ _validate_time_range(time_from, time_to)
445
+ call_type = _resolve_call_type(call_type_value)
446
+
447
+ tenant = tenant_service.resolve(tenant_id or None)
448
+ entries = call_log_service.list_calls(
449
+ day,
450
+ tenant,
451
+ time_from=time_from,
452
+ time_to=time_to,
453
+ call_type=call_type,
454
+ )
455
+
456
+ if not entries:
457
+ yield (
458
+ hidden_df_update,
459
+ h3("ℹ️ No calls for the selected filter."),
460
+ hidden_file,
461
+ )
462
+ return
463
+
464
+ rows = []
465
+ total = len(entries)
466
+
467
+ # пачатковы апдэйт
468
+ yield (
469
+ gr.update(value=pd.DataFrame(), visible=False),
470
+ h3(f"Starting batch analysis for {total} call(s)..."),
471
+ hidden_file,
472
+ )
473
+
474
+ # 3) цыкл аналізу
475
+ for i, entry in enumerate(entries, start=1):
476
+ pct = int((i / total) * 100)
477
+
478
+ row_data = {
479
+ "Start": entry.started_at.isoformat() if entry.started_at else "",
480
+ "Caller": entry.caller_id or "",
481
+ "Destination": entry.destination or "",
482
+ "Duration (s)": entry.duration_seconds,
483
+ "UniqueId": entry.unique_id,
484
+ }
485
+
486
+ try:
487
+ result = analysis_service.analyze_call(
488
+ unique_id=entry.unique_id,
489
+ tenant=tenant,
490
+ lang=BATCH_LANGUAGE,
491
+ options=AnalysisOptions(
492
+ model_key=BATCH_MODEL_KEY,
493
+ prompt_key=BATCH_PROMPT_KEY,
494
+ custom_prompt=BATCH_PROMPT_TEXT or None,
495
+ ),
496
+ )
497
+
498
+ link = (
499
+ f"{tenant.vochi_base_url.rstrip('/')}/calllogs/"
500
+ f"{tenant.vochi_client_id}/{entry.unique_id}"
501
+ )
502
+
503
+ # спроба structured JSON
504
+ try:
505
+ text = str(result.text or "").strip()
506
+ l, r = text.find("{"), text.rfind("}")
507
+ if l != -1 and r != -1 and r > l:
508
+ text = text[l : r + 1]
509
+ payload = json.loads(text)
510
+
511
+ row_data["Needs follow-up"] = (
512
+ "Yes" if payload.get("needs_follow_up") else "No"
513
+ )
514
+ row_data["Reason"] = str(payload.get("reason") or "")
515
+ except Exception:
516
+ row_data["Needs follow-up"] = ""
517
+ row_data["Reason"] = result.text
518
+
519
+ # ВЫПРАЎЛЕНА: Дадаем клас для JavaScript, каб зрабіць спасылку ў новай укладцы
520
+ row_data["Link"] = f'<a href="{link}" class="new-tab-link">Listen</a>'
521
+ row_data["Status"] = "✅"
522
+ except Exception as exc:
523
+ row_data["Needs follow-up"] = ""
524
+ row_data["Reason"] = f"❌ {exc}"
525
+ row_data["Link"] = ""
526
+ row_data["Status"] = "❌"
527
+
528
+ rows.append(row_data)
529
+
530
+ partial_df = pd.DataFrame(rows)
531
+ interim_msg = f"Analyzing {i}/{total} ({pct}%)… UID `{entry.unique_id}`"
532
+
533
+ # прамежкавы yield (жывое абнаўленне табліцы + статус)
534
+ yield (
535
+ gr.update(value=partial_df, visible=True),
536
+ h3(interim_msg),
537
+ hidden_file,
538
+ )
539
+
540
+ # 4) фінал
541
+ final_df = pd.DataFrame(rows)
542
+ ok_count = len(final_df[final_df["Status"] == "✅"])
543
+ final_msg = (
544
+ "✅ Batch analysis completed. "
545
+ f"Found: {total}, processed successfully: {ok_count}"
546
+ )
547
+
548
+ yield (
549
+ gr.update(value=final_df, visible=True),
550
+ h2_success(final_msg),
551
+ hidden_file,
552
+ )
553
+
554
+ except Exception as exc:
555
+ yield (
556
+ hidden_df_update,
557
+ h2_error(f"❌ Analysis failed: {exc}"),
558
+ hidden_file,
559
+ )
560
+ return
561
+
562
+
563
+ def ui_hide_call_list():
564
+ """Схаваць ручны спіс выклікаў пасля батча, каб не блытаць карыстальніка."""
565
+ return gr.update(visible=False)
566
+
567
+
568
+ def ui_export_results(results_df):
569
+ """Захаваць батч-аналіз у CSV і вярнуць файл у UI."""
570
+ if results_df is None or results_df.empty:
571
+ return gr.update(value=None, visible=False), "❌ No data to export."
572
+
573
+ with tempfile.NamedTemporaryFile(
574
+ "w", suffix=".csv", delete=False, encoding="utf-8"
575
+ ) as tmp:
576
+ results_df.to_csv(tmp.name, index=False)
577
+ return gr.update(value=tmp.name, visible=True), "✅ File is ready to save."
578
+
579
+
580
+ def ui_check_password(pwd: str):
581
+ """Праверка доступу ў UI."""
582
+ _UI_PASSWORD = os.environ.get("VOCHI_UI_PASSWORD", "")
583
+
584
+ if not _UI_PASSWORD:
585
+ # пароль не настроены -> усім можна
586
+ return (
587
+ False,
588
+ "⚠️ <b>VOCHI_UI_PASSWORD</b> is not configured. Access granted without password.",
589
+ gr.update(visible=False),
590
+ )
591
+
592
+ if (pwd or "").strip() == _UI_PASSWORD:
593
+ return True, "✅ Access granted.", gr.update(visible=False)
594
+
595
+ return False, "❌ Incorrect password.", gr.update(visible=True)
596
+
597
+
598
+ def ui_show_current_uid(current_uid: str):
599
+ """Паказаць выбраны UID у табе AI Analysis."""
600
+ uid = (current_uid or "").strip()
601
+ return (
602
+ f"**Selected UniqueId:** `{uid}`"
603
+ if uid
604
+ else "No file selected for AI Analysis."
605
+ )
606
+
607
+
608
+ def ui_analyze_bridge(
609
+ selected_idx,
610
+ df,
611
+ template_key,
612
+ custom_prompt,
613
+ lang_code,
614
+ model_pref,
615
+ tenant_id,
616
+ current_uid,
617
+ ):
618
+ """
619
+ Аналіз адной размовы З ПРАГРЭСАМ.
620
+ ВАЖНА:
621
+ - Гэта цяпер генератар (yield), а не звычайная функцыя.
622
+ - Мы не выкарыстоўваем аргумент progress=... (ён ламаецца ў Gradio 5).
623
+ - Зрабляем некалькі крокаў:
624
+ 1) праверкі і падрыхтоўка -> yield статычны статус
625
+ 2) выклік аналізу -> пасля гэтага яшчэ адзін yield з вынікам
626
+ - Gradio сам пакажа built-in progress bar праз show_progress="full".
627
+ """
628
+
629
+ # STEP 0. Вызначаем, які UID трэба аналізаваць
630
+ uid_to_analyze = (current_uid or "").strip()
631
+ if not uid_to_analyze and selected_idx is not None and df is not None and not df.empty:
632
+ try:
633
+ uid_to_analyze = str(df.iloc[int(selected_idx)].get("UniqueId") or "").strip()
634
+ except (ValueError, IndexError):
635
+ uid_to_analyze = ""
636
+
637
+ # Калі няма UID -> адразу вынікаем
638
+ if not uid_to_analyze:
639
+ yield "Select a call from the list or batch results first."
640
+ return
641
+
642
+ # STEP 1. Праверкі канфігурацыі перад выклікам мадэлі
643
+ if not PROJECT_IMPORTS_AVAILABLE:
644
+ yield "Project dependencies are not loaded."
645
+ return
646
+
647
+ if len(ai_registry) == 0:
648
+ yield "❌ No AI models are configured."
649
+ return
650
+
651
+ if model_pref not in ai_registry:
652
+ yield "❌ Selected model is not available."
653
+ return
654
+
655
+ # паказваем карыстальніку, што пачынаем
656
+ yield f"### Preparing analysis...\n\n- UID: `{uid_to_analyze}`\n- Model: `{model_pref}`\n- Lang: `{lang_code}`\n\nPlease wait…"
657
+
658
+ # STEP 2. Рэальны аналіз
659
+ try:
660
+ tenant = tenant_service.resolve(tenant_id or None)
661
+ lang = Language(lang_code)
662
+
663
+ result = analysis_service.analyze_call(
664
+ unique_id=uid_to_analyze,
665
+ tenant=tenant,
666
+ lang=lang,
667
+ options=AnalysisOptions(
668
+ model_key=model_pref,
669
+ prompt_key=template_key,
670
+ custom_prompt=custom_prompt,
671
+ ),
672
+ )
673
+
674
+ # STEP 3. Гатова, вяртаем вынік
675
+ yield f"### Analysis result\n\n{result.text}"
676
+
677
+ except Exception as exc:
678
+ yield f"Analysis failed: {exc}"
679
+
680
+
681
+ def ui_on_batch_row_select(
682
+ displayed_df: pd.DataFrame,
683
+ full_df_state: pd.DataFrame,
684
+ tenant_id: str,
685
+ evt: gr.SelectData,
686
+ ):
687
+ """
688
+ Апрацоўвае выбар радка з табліцы вынікаў (Batch results).
689
+ ВЫПРАЎЛЕНА: Цяпер гэтая функцыя таксама адразу абнаўляе плэер.
690
+ """
691
+ # Значэнні па змаўчанні, калі нешта пойдзе не так
692
+ empty_return = (
693
+ gr.update(choices=[], value=None), # row_dd
694
+ "", # current_uid_state
695
+ "No file selected for AI Analysis.", # current_uid_md
696
+ "", # url_html
697
+ None, # audio_out
698
+ "Selection error.", # status_fetch
699
+ )
700
+
701
+ if (
702
+ evt is None
703
+ or displayed_df is None
704
+ or displayed_df.empty
705
+ or full_df_state is None
706
+ or full_df_state.empty
707
+ ):
708
+ return empty_return
709
+
710
+ try:
711
+ visual_row_index = evt.index[0]
712
+ clicked_row_from_view = displayed_df.iloc[visual_row_index]
713
+ uid = str(clicked_row_from_view.get("UniqueId", "")).strip()
714
+ if not uid:
715
+ return empty_return
716
+
717
+ original_row_series = full_df_state[full_df_state["UniqueId"] == uid]
718
+ if original_row_series.empty:
719
+ return empty_return
720
+ original_row = original_row_series.iloc[0]
721
+ row_dict = original_row.to_dict()
722
+
723
+ label = (
724
+ f"{row_dict.get('Start','')} | "
725
+ f"{row_dict.get('Caller','')} → "
726
+ f"{row_dict.get('Destination','')} "
727
+ f"({row_dict.get('Duration (s)','')}s)"
728
+ )
729
+ dd_update = gr.update(choices=[(f"Batch: {label}", uid)], value=uid)
730
+ uid_md_update = ui_show_current_uid(uid)
731
+
732
+ # ВЫПРАЎЛЕНА: Дадаем логіку прайгравання аўдыё прама сюды
733
+ try:
734
+ tenant = tenant_service.resolve(tenant_id or None)
735
+ handle = call_log_service.ensure_recording(uid, tenant)
736
+ listen_url = (
737
+ f"{tenant.vochi_base_url.rstrip('/')}/calllogs/"
738
+ f"{tenant.vochi_client_id}/{uid}"
739
+ )
740
+ html = f'URL: <a id="audio-listen-link" href="{listen_url}">{listen_url}</a>'
741
+ audio_uri = handle.local_uri
742
+ status_msg = "Ready ✅"
743
+ except Exception as exc:
744
+ html, audio_uri, status_msg = f"Playback failed: {exc}", None, ""
745
+
746
+ return dd_update, uid, uid_md_update, html, audio_uri, status_msg
747
+
748
+ except (AttributeError, IndexError, KeyError):
749
+ return empty_return
750
+
751
+
752
+ # ----------------------------------------------------------------------------
753
+ # Build Gradio UI
754
+ # ----------------------------------------------------------------------------
755
+ def _today_str():
756
+ return _dt.date.today().strftime("%Y-%m-%d")
757
+
758
+
759
+ # ВЫПРАЎЛЕНА: JavaScript-код для выпраўлення спасылак
760
+ # Ён знаходзіць усе спасылкі з класам 'new-tab-link' (у табліцы) і ID 'audio-listen-link' (у плэеры)
761
+ # і дадае ім target='_blank'.
762
+ JS_FIX_LINKS = """
763
+ () => {
764
+ setTimeout(() => {
765
+ const links = document.querySelectorAll('a.new-tab-link, a#audio-listen-link');
766
+ links.forEach(link => {
767
+ if (link) {
768
+ link.target = '_blank';
769
+ link.rel = 'noopener noreferrer';
770
+ }
771
+ });
772
+ }, 100);
773
+ }
774
+ """
775
+
776
+
777
+ with gr.Blocks(title="Vochi CRM Call Logs (Gradio)") as demo:
778
+ gr.Markdown(
779
+ "# Vochi CRM → MP3 → AI analysis\n"
780
+ "*Filter calls by date, time and type, listen to recordings and run batch AI analysis.*"
781
+ )
782
+
783
+ authed = gr.State(False)
784
+ batch_results_state = gr.State(pd.DataFrame())
785
+ current_uid_state = gr.State("")
786
+
787
+ with gr.Group(visible=os.environ.get("VOCHI_UI_PASSWORD", "") != "") as pwd_group:
788
+ gr.Markdown("### 🔐 Enter password")
789
+ pwd_tb = gr.Textbox(
790
+ label="Password", type="password", placeholder="••••••••", lines=1
791
+ )
792
+ pwd_btn = gr.Button("Unlock", variant="primary")
793
+
794
+ with gr.Tabs() as tabs:
795
+ with gr.Tab("Vochi CRM"):
796
+ with gr.Row():
797
+ tenant_tb = gr.Textbox(
798
+ label="Tenant ID", value=DEFAULT_TENANT_ID, scale=1
799
+ )
800
+ date_inp = gr.Textbox(
801
+ label="Date", value=_today_str(), placeholder="YYYY-MM-DD", scale=1
802
+ )
803
+ time_from_inp = gr.Textbox(
804
+ label="Time from", placeholder="HH:MM", scale=1
805
+ )
806
+ time_to_inp = gr.Textbox(label="Time to", placeholder="HH:MM", scale=1)
807
+ call_type_dd = gr.Dropdown(
808
+ choices=CALL_TYPE_OPTIONS,
809
+ value="",
810
+ label="Call type",
811
+ type="value",
812
+ scale=1,
813
+ )
814
+ with gr.Row():
815
+ filter_btn = gr.Button("Filter", variant="primary", scale=0)
816
+ batch_btn = gr.Button("Batch analyze", variant="secondary", scale=0)
817
+ save_btn = gr.Button("Save to file", scale=0)
818
+
819
+ status_fetch = gr.Markdown()
820
+ batch_status_md = gr.Markdown()
821
+
822
+ calls_df = gr.DataFrame(
823
+ value=pd.DataFrame(),
824
+ label="Call list (manual filter)",
825
+ interactive=False,
826
+ )
827
+
828
+ batch_results_df = gr.DataFrame(
829
+ value=pd.DataFrame(),
830
+ label="Batch results",
831
+ interactive=True,
832
+ visible=False,
833
+ datatype=[
834
+ "str", # Start
835
+ "str", # Caller
836
+ "str", # Destination
837
+ "number", # Duration (s)
838
+ "str", # UniqueId
839
+ "str", # Needs follow-up
840
+ "str", # Reason
841
+ "markdown", # Link
842
+ "str", # Status
843
+ ],
844
+ )
845
+
846
+ row_dd = gr.Dropdown(
847
+ choices=[],
848
+ label="Call",
849
+ info="Choose a row to listen/analyze",
850
+ type="value",
851
+ )
852
+
853
+ with gr.Row():
854
+ play_btn = gr.Button("🎧 Play")
855
+
856
+ url_html = gr.HTML()
857
+ audio_out = gr.Audio(label="Audio", type="filepath")
858
+ batch_file = gr.File(label="Export CSV", visible=False)
859
+
860
+ with gr.Tab("AI Analysis"):
861
+ with gr.Row():
862
+ tpl_dd = gr.Dropdown(
863
+ choices=TPL_OPTIONS,
864
+ value="simple" if TPL_OPTIONS else "custom",
865
+ label="Template",
866
+ )
867
+ lang_dd = gr.Dropdown(
868
+ choices=LANG_OPTIONS,
869
+ value=Language.AUTO,
870
+ label="Language",
871
+ )
872
+ model_dd = gr.Dropdown(
873
+ choices=MODEL_CHOICES,
874
+ value=MODEL_DEFAULT,
875
+ label="Model",
876
+ interactive=bool(MODEL_OPTIONS),
877
+ info=MODEL_INFO,
878
+ )
879
+
880
+ custom_prompt_tb = gr.Textbox(
881
+ label="Custom prompt", lines=8, visible=False
882
+ )
883
+
884
+ current_uid_md = gr.Markdown(
885
+ value="No file selected for AI Analysis."
886
+ )
887
+
888
+ analyze_btn = gr.Button("🧠 Analyze", variant="primary")
889
+ analysis_md = gr.Markdown()
890
+
891
+ # --- wiring events ---
892
+
893
+ # пароль
894
+ pwd_btn.click(
895
+ ui_check_password,
896
+ inputs=[pwd_tb],
897
+ outputs=[authed, status_fetch, pwd_group],
898
+ )
899
+
900
+ # ручная фільтрацыя
901
+ filter_btn.click(
902
+ ui_filter_calls,
903
+ inputs=[date_inp, time_from_inp, time_to_inp, call_type_dd, authed, tenant_tb],
904
+ outputs=[calls_df, batch_results_df, row_dd, status_fetch, pwd_group],
905
+ )
906
+
907
+ # масавы аналіз
908
+ batch_btn.click(
909
+ fn=ui_mass_analyze,
910
+ inputs=[date_inp, time_from_inp, time_to_inp, call_type_dd, tenant_tb, authed],
911
+ outputs=[batch_results_df, batch_status_md, batch_file],
912
+ ).then(
913
+ fn=lambda df: df,
914
+ inputs=[batch_results_df],
915
+ outputs=[batch_results_state],
916
+ ).then(
917
+ fn=ui_hide_call_list,
918
+ outputs=[calls_df],
919
+ ).then( # ВЫПРАЎЛЕНА: запускаем JS для выпраўлення спасылак у табліцы
920
+ fn=None, js=JS_FIX_LINKS
921
+ )
922
+
923
+ # ВЫПРАЎЛЕНА: выбар радка з батчу -> абнаўляем усё, уключаючы плэер
924
+ batch_results_df.select(
925
+ fn=ui_on_batch_row_select,
926
+ inputs=[batch_results_df, batch_results_state, tenant_tb],
927
+ outputs=[row_dd, current_uid_state, current_uid_md, url_html, audio_out, status_fetch],
928
+ ).then( # ВЫПРАЎЛЕНА: запускаем JS для выпраўлення спасылкі ў плэеры
929
+ fn=None, js=JS_FIX_LINKS
930
+ )
931
+
932
+ # прайграванне аўдыё
933
+ play_btn.click(
934
+ ui_play_audio,
935
+ inputs=[row_dd, calls_df, tenant_tb],
936
+ outputs=[url_html, audio_out, status_fetch],
937
+ ).then( # ВЫПРАЎЛЕНА: запускаем JS для выпраўлення спасылкі ў плэеры
938
+ fn=None, js=JS_FIX_LINKS
939
+ )
940
+
941
+ # экспарт CSV
942
+ save_btn.click(
943
+ ui_export_results,
944
+ inputs=[batch_results_state],
945
+ outputs=[batch_file, batch_status_md],
946
+ )
947
+
948
+ # паказаць поле для свайго prompt
949
+ tpl_dd.change(
950
+ ui_toggle_custom_prompt,
951
+ inputs=[tpl_dd],
952
+ outputs=[custom_prompt_tb],
953
+ )
954
+
955
+ # аналіз адной размовы з прагрэсам
956
+ analyze_btn.click(
957
+ fn=ui_analyze_bridge,
958
+ inputs=[
959
+ row_dd,
960
+ calls_df,
961
+ tpl_dd,
962
+ custom_prompt_tb,
963
+ lang_dd,
964
+ model_dd,
965
+ tenant_tb,
966
+ current_uid_state,
967
+ ],
968
+ outputs=[analysis_md],
969
+ show_progress="full",
970
+ )
971
+
972
+ if __name__ == "__main__":
973
+ # УВАГА: Замяні "D:\\tmp" на шлях, дзе ляжаць MP3-запісы,
974
+ # каб кнопка 🎧 Play магла іх прайграваць лакальна.
975
+ demo.launch(allowed_paths=["D:\\tmp"])