archivartaunik commited on
Commit
c89d877
·
verified ·
1 Parent(s): 9059bff

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +105 -947
app.py CHANGED
@@ -1,975 +1,133 @@
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"])
 
1
  """Gradio UI wired to hexagonal architecture services."""
2
  from __future__ import annotations
3
 
 
4
  import os
 
 
 
5
 
6
+ from calls_analyser.ui import config as ui_config
7
+ from calls_analyser.ui.dependencies import build_dependencies
8
+ from calls_analyser.ui.handlers import UIHandlers
9
+ from calls_analyser.ui.layout import build_demo
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ deps = build_dependencies()
13
+ handlers = UIHandlers(deps)
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ def _build_app():
17
+ return build_demo(deps, handlers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ demo = _build_app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ # Expose configuration for tests
23
+ PROJECT_IMPORTS_AVAILABLE = deps.project_imports_available
24
+ tenant_service = deps.tenant_service
25
+ call_log_service = deps.call_log_service
26
+ ai_registry = deps.ai_registry
27
+ analysis_service = deps.analysis_service
28
+ BATCH_MODEL_KEY = deps.batch_model_key
29
+ BATCH_PROMPT_KEY = deps.batch_prompt_key
30
+ BATCH_PROMPT_TEXT = deps.batch_prompt_text
31
+ BATCH_LANGUAGE = deps.batch_language
32
+ Language = ui_config.Language
33
 
 
 
 
 
 
 
 
34
 
35
+ def _sync_test_overrides() -> None:
36
+ """Update handler dependencies with any monkeypatched globals (used in tests)."""
 
 
 
 
 
 
 
 
 
37
 
38
+ handlers.deps.project_imports_available = PROJECT_IMPORTS_AVAILABLE
39
+ handlers.deps.tenant_service = tenant_service
40
+ handlers.deps.call_log_service = call_log_service
41
+ handlers.deps.ai_registry = ai_registry
42
+ handlers.deps.analysis_service = analysis_service
43
+ handlers.deps.batch_model_key = BATCH_MODEL_KEY
44
+ handlers.deps.batch_prompt_key = BATCH_PROMPT_KEY
45
+ handlers.deps.batch_prompt_text = BATCH_PROMPT_TEXT
46
+ handlers.deps.batch_language = BATCH_LANGUAGE
47
 
 
 
 
 
 
 
 
48
 
49
+ def ui_mass_analyze(date_value, time_from_value, time_to_value, call_type_value, tenant_id, authed):
50
+ """Thin wrapper used in tests to run the batch pipeline."""
 
 
 
 
 
51
 
52
+ _sync_test_overrides()
 
 
 
 
 
 
 
53
 
54
+ return handlers._run_mass_analyze( # noqa: SLF001
55
+ date_value,
56
+ time_from_value,
57
+ time_to_value,
58
+ call_type_value,
59
+ tenant_id,
60
+ authed,
61
+ custom_prompt_override=None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  )
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  # ----------------------------------------------------------------------------
66
+ # Scheduler for automated daily batch (runs on Hugging Face Spaces / Servers)
67
  # ----------------------------------------------------------------------------
68
+ try:
69
+ from apscheduler.schedulers.background import BackgroundScheduler
70
+ import run_daily_batch
71
+ import datetime
72
+
73
+ def run_scheduled_job():
74
+ """Wrapper to run the batch job for 'yesterday'."""
75
+ print("⏰ [Scheduler] Starting daily batch analysis...")
76
+ # Calculate yesterday
77
+ target_date = datetime.date.today() - datetime.timedelta(days=1)
78
+
79
+ # Run the batch process using the same dependencies
80
+ # Note: We create new dependencies inside the job to ensure clean state if needed,
81
+ # but here reusing 'deps' is also fine if 'deps' is thread-safe.
82
+ # For safety/updates, we might want to re-build deps or just use the global 'deps'.
83
+ # Using global 'deps' for now as it holds the loaded secrets/config.
84
+ bp = deps.batch_params
85
+ run_daily_batch.run_batch_process(
86
+ deps,
87
+ day=target_date,
88
+ time_from_str=bp.filter_time_from,
89
+ time_to_str=bp.filter_time_to,
90
+ call_type_str=bp.filter_call_type,
91
+ tenant_id_arg=None
92
+ )
93
+ print(" [Scheduler] Daily batch finished.")
94
+
95
+ # Create and configure scheduler
96
+ scheduler = BackgroundScheduler()
97
+
98
+ # Read settings from batch_params
99
+ bp = deps.batch_params
100
+
101
+ # Define update schedule job based on params
102
+ # We always start the scheduler, but condition valid jobs.
103
+ if bp.scheduler_enabled:
104
+ hour, minute = 1, 0
105
+ try:
106
+ # expect "HH:MM"
107
+ parts = bp.scheduler_cron_time.split(":")
108
+ hour = int(parts[0])
109
+ minute = int(parts[1])
110
+ except Exception:
111
+ print("⚠️ [Scheduler] Invalid cron_time format. Using default 01:00.")
112
+
113
+ if bp.scheduler_mode == "interval":
114
+ interval_mins = max(1, bp.scheduler_interval_minutes)
115
+ print(f"ℹ️ [Scheduler] Mode: INTERVAL (every {interval_mins} mins). Filters: {bp.filter_time_from}-{bp.filter_time_to}, Type: {bp.filter_call_type}")
116
+ scheduler.add_job(run_scheduled_job, "interval", minutes=interval_mins, next_run_time=datetime.datetime.now() + datetime.timedelta(seconds=10))
117
+ else:
118
+ print(f"ℹ️ [Scheduler] Mode: CRON (at {hour:02d}:{minute:02d}). Filters: {bp.filter_time_from}-{bp.filter_time_to}, Type: {bp.filter_call_type}")
119
+ scheduler.add_job(run_scheduled_job, "cron", hour=hour, minute=minute)
120
+
121
+ scheduler.start()
122
+ print("ℹ️ [Scheduler] Background scheduler started.")
123
+ else:
124
+ print("ℹ️ [Scheduler] Scheduler is disabled in batch_params.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ except ImportError:
127
+ print("⚠️ [Scheduler] APScheduler not installed. Background jobs disabled.")
128
+ except Exception as e:
129
+ print(f"⚠️ [Scheduler] Failed to start scheduler: {e}")
 
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  if __name__ == "__main__":
133
+ demo.launch(allowed_paths=[os.environ.get("VOCHI_ALLOWED_PATH", "D:\\tmp")])