archivartaunik commited on
Commit
cdf7718
Β·
verified Β·
1 Parent(s): 40497af

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +803 -638
app.py CHANGED
@@ -1,783 +1,948 @@
 
1
  from __future__ import annotations
 
 
2
  import os
 
 
3
  import json
4
- import datetime as _dt
5
- from typing import List, Tuple, Optional
6
- import time
7
 
8
- import requests
9
- import pandas as pd
10
- import numpy as np
11
  import gradio as gr
 
12
 
 
13
  try:
14
- # New Google Gemini client library
15
- from google import genai # type: ignore
16
- _HAS_GENAI = True
17
- except Exception:
18
- genai = None
19
- _HAS_GENAI = False
20
-
21
- # ─────────────────────────────────────────────────────────────────────────────
22
- # Config
23
- # ─────────────────────────────────────────────────────────────────────────────
24
- BASE_URL = os.environ.get("VOCHI_BASE_URL", "https://crm.vochi.by/api")
25
- CLIENT_ID = os.environ.get("VOCHI_CLIENT_ID")
26
-
27
- # If your API needs auth, fill it here (or via VOCHI_BEARER in Secrets)
28
- _AUTH_TOKEN = os.environ.get("VOCHI_BEARER", "").strip()
29
- AUTH_HEADERS = {
30
- "Accept": "audio/*,application/json;q=0.9,*/*;q=0.8",
31
- **({"Authorization": f"Bearer {_AUTH_TOKEN}"} if _AUTH_TOKEN else {}),
32
- }
33
-
34
- # πŸ”’ UI password from Space Secrets (set VOCHI_UI_PASSWORD there)
35
- _UI_PASSWORD = os.environ.get("VOCHI_UI_PASSWORD", "")
36
-
37
- # ─────────────────────────────────────────────────────────────────────────────
38
- # Vochi API helpers
39
- # ─────────────────────────────────────────────────────────────────────────────
40
- def fetch_calllogs(date_str: str):
41
- """Get list of calls for a given date (YYYY-MM-DD)."""
42
- r = requests.get(
43
- f"{BASE_URL}/calllogs",
44
- params={"start": date_str, "end": date_str, "clientId": CLIENT_ID},
45
- headers=AUTH_HEADERS,
46
- timeout=60,
47
  )
48
- r.raise_for_status()
49
- data = r.json()
50
- if isinstance(data, dict):
51
- return data.get("data", data)
52
- return data
53
-
54
-
55
- def fetch_mp3_by_unique_id(unique_id: str) -> Tuple[str, str]:
56
- """Fetch call recording by UniqueId and save to /tmp. Returns (filepath, url)."""
57
- url = f"{BASE_URL}/calllogs/{CLIENT_ID}/{unique_id}"
58
- r = requests.get(url, headers=AUTH_HEADERS, timeout=120)
59
- r.raise_for_status()
60
- path = f"/tmp/call_{unique_id}.mp3"
61
- with open(path, "wb") as f:
62
- f.write(r.content)
63
- return path, url
64
-
65
- # ─────────────────────────────────────────────────────────────────────────────
66
- # Prompt templates & model options
67
- # ─────────────────────────────────────────────────────────────────────────────
68
- PROMPT_TEMPLATES = {
69
- "simple": (
70
- """
71
- You are a call-center conversation analyst for a medical clinic. From the call recording, provide a brief summary:
72
- - Purpose of the call (appointment / results / complaint / billing / other).
73
- - Patient intent and expectations.
74
- - Outcome (booked / call-back / routed / unresolved).
75
- - Next steps (owner and when).
76
- - Patient emotion (1–5) and agent tone (1–5).
77
- - Alerts: urgency/risks/privacy.
78
-
79
- Keep it short (6–8 lines). End with a line: β€˜Service quality rating: X/5’ and one sentence explaining the rating.
80
- """
81
- ),
82
- "medium": (
83
- """
84
- Act as a senior service analyst. Analyze the call using this structure:
85
- 1) Quick overview: reason for the call, intent, key facts, urgency (low/medium/high).
86
- 2) Call flow (2–4 bullets): what was asked/answered, where friction occurred.
87
- 3) Outcomes & tasks: concrete next actions for clinic/patient with timeframes.
88
- 4) Emotions & empathy: patient mood; agent empathy (0–5).
89
- 5) Procedural compliance: identity verification, disclosure of recording (if stated), no off-protocol medical advice, data accuracy.
90
- 6) Quality rating (0–100) using rubric: greeting, verification, accuracy, empathy, issue resolution (each 0–20).
91
- """
92
- ),
93
- "detailed": (
94
- """
95
- You are a quality & operations analyst. Provide an in-depth analysis:
96
- A) Segmentation: split the call into stages with approximate timestamps (if available) and roles (Patient/Agent).
97
- B) Structured data for booking: full name (if stated), date of birth, phone, symptoms/complaints (list), onset/duration, possible pain level 0–10 (if mentioned), required specialist/service, preferred time windows, constraints.
98
- C) Triage & risks: class (routine/urgent/emergency), red flags, whether immediate escalation is needed.
99
- D) Compliance audit: identity/privacy checks, recording disclosure, consent to data processing, booking policies.
100
- E) Conversation metrics: talk ratio (agent/patient), interruptions, long pauses, notable keywords.
101
- F) Coaching for the agent: 3–5 concrete improvements with sample phrasing.
102
-
103
- Deliver: (1) A short patient-chart summary (2–3 sentences). (2) A task table with columns: priority, owner, due.
104
- """
105
- ),
106
- }
107
-
108
- TPL_OPTIONS = [
109
- ("Simple", "simple"),
110
- ("Medium", "medium"),
111
- ("Detailed", "detailed"),
112
- ("Custom", "custom"),
113
- ]
114
 
115
- LANG_OPTIONS = [
116
- ("Russian", "ru"),
117
- ("Auto", "default"),
118
- ("Belarusian", "be"),
119
- ("English", "en"),
120
- ]
121
 
122
- MODEL_OPTIONS = [
123
- ("flash", "models/gemini-2.5-flash"),
124
- ("pro", "models/gemini-2.5-pro"),
125
- ("flash-lite", "models/gemini-2.5-flash-lite"),
126
- ]
127
 
128
- # ─────────────────────────────────────────────────────────────────────────────
129
- # Utilities
130
- # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
131
 
132
- def label_row(row: dict) -> str:
133
- start = row.get("Start", "")
134
- src = row.get("CallerId", "")
135
- dst = row.get("Destination", "")
136
- dur = row.get("Duration", "")
137
- return f"{start} | {src} β†’ {dst} ({dur}s)"
138
 
 
139
 
140
- def _resolve_model(client: "genai.Client", preferred: str) -> str:
141
- name = preferred if preferred.startswith("models/") else f"models/{preferred}"
142
- try:
143
- models = list(client.models.list())
144
- desired_short = name.split("/", 1)[1]
145
- for m in models:
146
- mname = getattr(m, "name", "")
147
- short = mname.split("/", 1)[1] if mname.startswith("models/") else mname
148
- methods = set(getattr(m, "supported_generation_methods", []) or [])
149
- if short == desired_short and ("generateContent" in methods or not methods):
150
- return f"models/{short}"
151
- # Fallback to first available
152
- for title, candidate in MODEL_OPTIONS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  try:
154
- short = candidate.split("/", 1)[1]
155
- for m in models:
156
- mname = getattr(m, "name", "")
157
- sm = mname.split("/", 1)[1] if mname.startswith("models/") else mname
158
- methods = set(getattr(m, "supported_generation_methods", []) or [])
159
- if sm == short and ("generateContent" in methods or not methods):
160
- return candidate
161
- except Exception:
162
- pass
163
- except Exception:
164
- pass
165
- return name
166
 
 
167
 
168
- def _system_instruction(lang_code: str) -> str:
169
- if lang_code == "be":
170
- return "Reply in Belarusian."
171
- if lang_code == "ru":
172
- return "Reply in Russian."
173
- if lang_code == "en":
174
- return "Reply in English."
175
- return "Reply in the caller's language; if unclear, use concise professional English."
176
 
 
 
 
 
 
 
 
 
177
 
178
- def _prepare_prompt(template_key: str, custom_prompt: str) -> str:
179
- if template_key == "custom":
180
- return (custom_prompt or "").strip() or PROMPT_TEMPLATES["simple"]
181
- return PROMPT_TEMPLATES.get(template_key, PROMPT_TEMPLATES["simple"])
182
 
183
 
184
- def _today_str():
185
- return _dt.date.today().strftime("%Y-%m-%d")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
- # Time helpers (HH:MM β†’ seconds since midnight)
188
- _DEF_T_FROM = "00:00"
189
- _DEF_T_TO = "23:59"
 
 
 
 
 
 
190
 
191
 
192
- def _parse_hhmm(s: Optional[str]) -> Optional[int]:
193
- s = (s or "").strip()
194
- if not s:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  return None
 
 
 
 
196
  try:
197
- hh, mm = s.split(":", 1)
198
- hh = int(hh)
199
- mm = int(mm)
200
- if not (0 <= hh <= 23 and 0 <= mm <= 59):
201
- return None
202
- return hh * 3600 + mm * 60
203
- except Exception:
 
 
 
 
204
  return None
 
 
 
 
 
 
 
 
 
 
205
 
206
 
207
- def _infer_calltype_col(df: pd.DataFrame) -> Optional[str]:
208
- for c in df.columns:
209
- low = str(c).lower()
210
- if low in {"calltype", "call_type", "type"}:
211
- return c
212
- return None
213
 
214
 
215
- def _normalize_start_to_time(df: pd.DataFrame) -> pd.DataFrame:
216
- if "Start" not in df.columns:
217
- return df
218
- out = df.copy()
219
  try:
220
- out["_Start_dt"] = pd.to_datetime(out["Start"], errors="coerce")
221
- out["_Start_time_sec"] = out["_Start_dt"].dt.hour.fillna(0).astype(int) * 3600 + \
222
- out["_Start_dt"].dt.minute.fillna(0).astype(int) * 60 + \
223
- out["_Start_dt"].dt.second.fillna(0).astype(int)
224
- except Exception:
225
- out["_Start_time_sec"] = np.nan
226
- return out
 
 
227
 
228
 
229
- def _filter_calls(df: pd.DataFrame, t_from: Optional[str], t_to: Optional[str], calltype: Optional[str]) -> pd.DataFrame:
230
- if df is None or df.empty:
231
- return df
 
232
 
233
- tf = _parse_hhmm(t_from) or _parse_hhmm(_DEF_T_FROM) or 0
234
- tt = _parse_hhmm(t_to) or _parse_hhmm(_DEF_T_TO) or (24*3600-1)
235
- if tt < tf:
236
- # Swap if user inverted
237
- tf, tt = tt, tf
238
 
239
- work = _normalize_start_to_time(df)
240
- mask_time = (work["_Start_time_sec"].fillna(-1).astype(int) >= tf) & (work["_Start_time_sec"].fillna(-1).astype(int) <= tt)
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- mask = mask_time
243
 
244
- ct = (calltype or "").strip()
245
- if ct:
246
- col = _infer_calltype_col(work)
247
- if col:
248
- # exact match (case-insensitive)
249
- mask = mask & (work[col].astype(str).str.lower() == ct.lower())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- return work.loc[mask].drop(columns=[c for c in ["_Start_dt", "_Start_time_sec"] if c in work.columns])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
- def _validate_times(t_from: str, t_to: str) -> Tuple[bool, str, bool, bool]:
255
- """Return (ok, message_html, from_valid, to_valid)."""
256
- tf = _parse_hhmm(t_from)
257
- tt = _parse_hhmm(t_to)
258
- if tf is None and tt is None:
259
- return False, "<span style='color:#c00'>❌ ΠŸΠ°ΠΌΡ‹Π»ΠΊΠ°: Π½ΡΠΏΡ€Π°Π²Ρ–Π»ΡŒΠ½Ρ‹ Ρ„Π°Ρ€ΠΌΠ°Ρ‚ часу ў Π°Π±ΠΎΠ΄Π²ΡƒΡ… палях (выкарыстайцС HH:MM).</span>", False, False
260
- if tf is None:
261
- return False, "<span style='color:#c00'>❌ ΠŸΠ°ΠΌΡ‹Π»ΠΊΠ°: ΠΏΠΎΠ»Π΅ <b>Time from</b> ΠΏΠ°Π²Ρ–Π½Π½Π° Π±Ρ‹Ρ†ΡŒ Ρƒ Ρ„Π°Ρ€ΠΌΠ°Ρ†Π΅ HH:MM.</span>", False, True
262
- if tt is None:
263
- return False, "<span style='color:#c00'>❌ ΠŸΠ°ΠΌΡ‹Π»ΠΊΠ°: ΠΏΠΎΠ»Π΅ <b>Time to</b> ΠΏΠ°Π²Ρ–Π½Π½Π° Π±Ρ‹Ρ†ΡŒ Ρƒ Ρ„Π°Ρ€ΠΌΠ°Ρ†Π΅ HH:MM.</span>", True, False
264
- return True, "", True, True
265
 
 
 
266
 
267
- def _recording_url(unique_id: str) -> str:
268
- return f"{BASE_URL}/calllogs/{CLIENT_ID}/{unique_id}"
 
 
 
 
269
 
270
- # ─────────────────────────────────────────────────────────────────────────────
271
- # Gradio handlers
272
- # ─────────────────────────────────────────────────────────────────────────────
273
 
274
- def ui_fetch_calls(date_str: str):
275
- try:
276
- items = fetch_calllogs(date_str.strip())
277
- df = pd.DataFrame(items)
278
- opts = [(label_row(r), i) for i, r in df.iterrows()]
279
- msg = f"Calls found: {len(df)}"
280
- # Update dropdown choices and default value
281
- dd = gr.update(choices=[(lbl, idx) for lbl, idx in opts], value=(opts[0][1] if opts else None))
282
- return df, dd, msg
283
- except requests.HTTPError as e:
284
- body = ""
285
  try:
286
- body = e.response.text[:800]
287
- except Exception:
288
- pass
289
- # CORRECTED LINE: Use triple quotes for multi-line f-string
290
- return pd.DataFrame(), gr.update(choices=[], value=None), f"""HTTP error: {e}
291
- {body}"""
292
- except Exception as e:
293
- return pd.DataFrame(), gr.update(choices=[], value=None), f"Load error: {e}"
294
-
295
-
296
- def ui_play_audio(selected_idx: Optional[int], df: pd.DataFrame):
297
- if selected_idx is None or df is None or df.empty:
298
- return "<em>First fetch the list and select a row.</em>", None, None, ""
299
- try:
300
- row = df.iloc[int(selected_idx)]
301
- except Exception:
302
- return "<em>Invalid row selection.</em>", None, None, ""
303
- unique_id = str(row.get("UniqueId"))
304
  try:
305
- fpath = f"/tmp/call_{unique_id}.mp3"
306
- url_used = _recording_url(unique_id)
307
- # Download only if not exists (avoid re-fetch)
308
- if not os.path.exists(fpath) or os.path.getsize(fpath) == 0:
309
- fpath, url_used = fetch_mp3_by_unique_id(unique_id)
310
- html = f'<a href="{url_used}" target="_blank">Recording URL</a>'
311
- return html, fpath, fpath, "Ready βœ…"
312
- except requests.HTTPError as e:
313
- body = ""
314
- try:
315
- body = e.response.text[:800]
316
- except Exception:
317
- pass
318
- return f"HTTP error: {e}<br><pre>{body}</pre>", None, None, ""
319
- except Exception as e:
320
- return f"Playback failed: {e}", None, None, ""
321
 
 
 
 
 
 
322
 
323
- def ui_toggle_custom_prompt(template_key: str):
324
- return gr.update(visible=(template_key == "custom"))
 
325
 
326
 
327
- def ui_analyze(selected_idx: Optional[int], df: pd.DataFrame,
328
- template_key: str, custom_prompt: str, lang_code: str, model_pref: str):
329
- if df is None or df.empty or selected_idx is None:
330
- return "First fetch the list, choose a call, and (optionally) click β€˜πŸŽ§ Play’."
331
- if not _HAS_GENAI:
332
- return "❌ google-genai library not found. Make sure it's in requirements.txt."
333
 
334
- try:
335
- row = df.iloc[int(selected_idx)]
336
- except Exception:
337
- return "Invalid row selection."
338
 
339
- unique_id = str(row.get("UniqueId"))
340
- mp3_path = f"/tmp/call_{unique_id}.mp3"
 
 
 
 
 
 
 
 
 
 
 
341
 
342
- # Ensure audio file exists (download if needed)
343
- try:
344
- if not os.path.exists(mp3_path) or os.path.getsize(mp3_path) == 0:
345
- mp3_path, _ = fetch_mp3_by_unique_id(unique_id)
346
- except Exception as e:
347
- return f"Failed to obtain audio for analysis: {e}"
348
 
349
- api_key = os.environ.get("GOOGLE_API_KEY", "").strip()
350
- if not api_key:
351
- return "GOOGLE_API_KEY is not set in Space Secrets. Add it in Settings β†’ Secrets and restart the Space."
352
 
353
- try:
354
- client = genai.Client(api_key=api_key)
355
- except Exception as e:
356
- return f"Failed to initialize the client: {e}"
357
 
358
- # Upload file
359
- try:
360
- uploaded_file = client.files.upload(file=mp3_path)
361
- except Exception as e:
362
- return f"File upload error: {e}"
363
 
364
- # Prepare prompt
365
- if template_key == "custom":
366
- prompt = (custom_prompt or "").strip() or PROMPT_TEMPLATES["simple"]
367
- else:
368
- prompt = PROMPT_TEMPLATES.get(template_key, PROMPT_TEMPLATES["simple"])
 
 
 
369
 
370
- sys_inst = _system_instruction(lang_code)
371
- model_name = _resolve_model(client, model_pref)
 
 
 
 
 
372
 
373
- # Call model
 
 
 
 
 
 
 
 
374
  try:
375
- merged = f"""[SYSTEM INSTRUCTION: {sys_inst}]
376
-
377
- {prompt}"""
378
- resp = client.models.generate_content(model=model_name, contents=[uploaded_file, merged])
379
- text = getattr(resp, "text", None)
380
- if not text:
381
- return "Analysis finished but returned no text. Check model settings and file format."
382
- # Add a listening link at the end
383
- url = _recording_url(unique_id)
384
- return f"""### Analysis result
385
-
386
- {text}
387
-
388
- **Recording:** {url}"""
389
- except Exception as e:
390
- # Try to attach more error details
391
- msg = str(e)
392
- try:
393
- if hasattr(e, "args") and e.args:
394
- msg = msg + "\n\n" + str(e.args[0])
395
- except Exception:
396
- pass
397
- return f"Error during model call: {msg}"
398
- finally:
399
- # Best-effort cleanup of remote file
400
- try:
401
- if 'uploaded_file' in locals() and hasattr(uploaded_file, 'name'):
402
- client.files.delete(name=uploaded_file.name)
403
- except Exception:
404
- pass
405
 
406
- # ─────────────────────────────────────────────────────────────────────────────
407
- # NEW: Batch helpers for dynamic calltype choices and preview
408
- # ─────────────────────────────────────────────────────────────────────────────
 
 
 
 
409
 
410
- def ui_batch_load(date_str: str, authed: bool):
411
- """Fetch calls for the date, return DF, calltype choices, and status."""
412
- if not authed:
413
- return gr.update(), gr.update(), "πŸ”’ УвядзіцС ΠΏΠ°Ρ€ΠΎΠ»ΡŒ, ΠΊΠ°Π± Π°Ρ‚Ρ€Ρ‹ΠΌΠ°Ρ†ΡŒ Π·Π²Π°Π½ΠΊΡ–.", gr.update(visible=True)
414
 
415
- date_str = (date_str or "").strip() or _today_str()
416
- try:
417
- calls = fetch_calllogs(date_str)
418
- df = pd.DataFrame(calls)
419
- except Exception as e:
420
- return gr.update(), gr.update(choices=[""], value=""), f"НС ўдалося Π·Π°Π³Ρ€ΡƒΠ·Ρ–Ρ†ΡŒ спіс: {e}", gr.update(visible=False)
421
-
422
- if df.empty:
423
- return gr.update(value=pd.DataFrame()), gr.update(choices=[""], value=""), "Π—Π° гэты дзСнь званкоў Π½Π΅ Π·Π½ΠΎΠΉΠ΄Π·Π΅Π½Π°.", gr.update(visible=False)
424
-
425
- # Build calltype list automatically
426
- col = _infer_calltype_col(df)
427
- if col:
428
- types = sorted(set(str(x).strip() for x in df[col].dropna().unique() if str(x).strip()))
429
- choices = [""] + types # allow empty = no filter
430
- else:
431
- choices = [""]
432
 
433
- return (
434
- df, # store DF in state via gr.State
435
- gr.update(choices=choices, value=""),
436
- f"Π—Π°Π³Ρ€ΡƒΠΆΠ°Π½Π° званкоў: {len(df)}. АбярыцС Ρ„Ρ–Π»ΡŒΡ‚Ρ€ Ρ– націсніцС β€˜ΠŸΠ°ΠΏΡΡ€ΡΠ΄Π½Ρ– прагляд’.",
437
- gr.update(visible=False),
438
- )
439
 
 
 
 
 
 
 
 
440
 
441
- def ui_batch_preview(df: pd.DataFrame, t_from: str, t_to: str, calltype: str):
442
- ok, msg, from_ok, to_ok = _validate_times(t_from, t_to)
443
- if not ok:
444
- return pd.DataFrame(), f"{msg}", gr.update(info=("βœ“" if from_ok else "Format: HH:MM")), gr.update(info=("βœ“" if to_ok else "Format: HH:MM"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
 
446
- if df is None or len(df) == 0:
447
- return pd.DataFrame(), "Π‘ΠΏΠ°Ρ‡Π°Ρ‚ΠΊΡƒ Π·Π°Π³Ρ€ΡƒΠ·Ρ–Ρ†Π΅ спіс Π½Π° Π΄Π°Ρ‚Ρƒ.", gr.update(), gr.update()
 
 
 
 
 
448
 
449
- # Filter
450
- df_f = _filter_calls(df, t_from, t_to, calltype)
 
 
 
451
 
452
- # Add recording URL column for quick listening
453
- if not df_f.empty and "UniqueId" in df_f.columns:
454
- df_f = df_f.copy()
455
- df_f["RecordingURL"] = df_f["UniqueId"].astype(str).map(_recording_url)
 
 
 
456
 
457
- count = len(df_f)
458
- info = f"Π€Ρ–Π»ΡŒΡ‚Ρ€: {t_from}–{t_to}{' | calltype=' + calltype if calltype else ''}. Π—Π½ΠΎΠΉΠ΄Π·Π΅Π½Π°: <b>{count}</b>."
459
- return df_f, info, gr.update(info=""), gr.update(info="")
460
 
 
 
 
461
 
462
- # ─────────────────────────────────────────────────────────────────────────────
463
- # Batch analysis with time interval + calltype filter + URL in output
464
- # ─────────────────────────────────────────────────────────────────────────────
465
 
466
- def ui_analyze_calls_by_date(
467
- authed: bool,
468
- df_loaded: pd.DataFrame,
469
- date_str: str,
470
- t_from: str,
471
- t_to: str,
472
- calltype: str,
473
- template_key: str,
474
- custom_prompt: str,
475
- lang_code: str,
476
- model_pref: str,
477
- ):
478
- """
479
- Analyze filtered calls one-by-one. Uses the already loaded DF to allow
480
- dynamic calltype dropdown and preview step. Adds listening URL to each block.
481
- """
482
- # Password gate
483
- if not authed:
484
- return (
485
- "πŸ”’ УвядзіцС ΠΏΠ°Ρ€ΠΎΠ»ΡŒ, ΠΊΠ°Π± Π·Π°ΠΏΡƒΡΡ†Ρ–Ρ†ΡŒ Π°Π½Π°Π»Ρ–Π·.", # results_md
486
- None, # file
487
- "Доступ Π·Π°ΠΊΡ€Ρ‹Ρ‚Ρ‹.", # status
488
- gr.update(visible=True), # show pwd group
489
- )
490
 
491
- # Validate time inputs
492
- ok, msg, from_ok, to_ok = _validate_times(t_from, t_to)
493
- if not ok:
494
- return (msg, None, "", gr.update(visible=False))
 
495
 
496
- # Ensure DF is present; if not, fallback to fetch (safety)
497
- if df_loaded is None or len(df_loaded) == 0:
498
- try:
499
- calls = fetch_calllogs((date_str or _today_str()).strip())
500
- df_loaded = pd.DataFrame(calls)
501
- except Exception as e:
502
- return (f"НС ўдалося Π·Π°Π³Ρ€ΡƒΠ·Ρ–Ρ†ΡŒ спіс: {e}", None, "", gr.update())
503
 
504
- if df_loaded.empty:
505
- return ("Π—Π° гэты дзСнь званкоў Π½Π΅ Π·Π½ΠΎΠΉΠ΄Π·Π΅Π½Π°.", None, "", gr.update())
 
506
 
507
- # Filter by time + calltype
508
- df_f = _filter_calls(df_loaded, t_from, t_to, calltype)
509
- total = len(df_f)
 
 
 
 
510
 
511
- if total == 0:
512
- info = f"Па Ρ„Ρ–Π»ΡŒΡ‚Ρ€Ρƒ ({t_from}–{t_to}{' | ' + calltype if calltype else ''}) β€” 0 званкоў."
513
- return (info, None, info, gr.update())
514
 
515
- # Prepare model
516
- if not _HAS_GENAI:
517
- return ("❌ google-genai library not found.", None, "", gr.update())
518
 
519
- api_key = os.environ.get("GOOGLE_API_KEY", "").strip()
520
- if not api_key:
521
- return ("GOOGLE_API_KEY Π½Π΅ Π·Π°Π΄Π°Π΄Π·Π΅Π½Ρ‹ ў Secrets.", None, "", gr.update())
522
 
523
- try:
524
- client = genai.Client(api_key=api_key)
525
- model_name = _resolve_model(client, model_pref)
526
- except Exception as e:
527
- return (f"НС ўдалося Ρ–Π½Ρ–Ρ†Ρ‹ΡΠ»Ρ–Π·Π°Π²Π°Ρ†ΡŒ ΠΊΠ»Ρ–Π΅Π½Ρ‚Π°: {e}", None, "", gr.update())
 
 
 
528
 
529
- sys_inst = _system_instruction(lang_code)
530
- prompt = _prepare_prompt(template_key, custom_prompt)
531
 
532
- # Iterate filtered calls
533
- results_blocks: List[str] = [
534
- f"""## Π’Ρ‹Π½Ρ–ΠΊΡ–
535
- Π”Π°Ρ‚Π°: {(date_str or _today_str()).strip()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
 
537
- Π€Ρ–Π»ΡŒΡ‚Ρ€: {t_from}–{t_to}{' | calltype=' + calltype if calltype else ''}
 
 
 
 
 
 
538
 
539
- Π—Π½ΠΎΠΉΠ΄Π·Π΅Π½Π° званкоў: {total}
 
 
 
540
 
541
- ---"""
542
- ]
543
- analyzed = 0
 
544
 
545
- for i, row in df_f.reset_index(drop=True).iterrows():
546
- unique_id = str(row.get("UniqueId", ""))
547
- if not unique_id:
548
- continue
549
 
550
- # Download/cache mp3
551
- try:
552
- mp3_path = f"/tmp/call_{unique_id}.mp3"
553
- if not os.path.exists(mp3_path) or os.path.getsize(mp3_path) == 0:
554
- mp3_path, _ = fetch_mp3_by_unique_id(unique_id)
555
- except Exception as e:
556
- url = _recording_url(unique_id)
557
- results_blocks.append(
558
- f"""### Call {i+1}
559
- - UniqueId: {unique_id}
560
- - Start: {row.get('Start','')}
561
- - CallerId: {row.get('CallerId','')}
562
- - Destination: {row.get('Destination','')}
563
- - Duration: {row.get('Duration','')}
564
- - Recording: {url}
565
-
566
- **ΠŸΠ°ΠΌΡ‹Π»ΠΊΠ° Π·Π°Π³Ρ€ΡƒΠ·ΠΊΡ– Π°ΡžΠ΄Ρ‹Ρ‘:** {e}
567
- ---"""
568
- )
569
- continue
570
 
571
- # Upload + generate
572
- try:
573
- uploaded_file = client.files.upload(file=mp3_path)
574
- merged = f"""[SYSTEM INSTRUCTION: {sys_inst}]
575
 
576
- {prompt}"""
577
- resp = client.models.generate_content(
578
- model=model_name,
579
- contents=[uploaded_file, merged],
580
- )
581
- text = getattr(resp, "text", "") or "(пусты Π°Π΄ΠΊΠ°Π·)"
582
- except Exception as e:
583
- text = f"ΠŸΠ°ΠΌΡ‹Π»ΠΊΠ° Π°Π½Π°Π»Ρ–Π·Ρƒ: {e}"
584
- finally:
585
- try:
586
- if 'uploaded_file' in locals() and hasattr(uploaded_file, 'name'):
587
- client.files.delete(name=uploaded_file.name)
588
- except Exception:
589
- pass
590
-
591
- # Result block with listening URL
592
- url = _recording_url(unique_id)
593
- block = (
594
- f"""### Call {i+1}
595
- - UniqueId: {unique_id}
596
- - Start: {row.get('Start','')}
597
- - CallerId: {row.get('CallerId','')}
598
- - Destination: {row.get('Destination','')}
599
- - Duration: {row.get('Duration','')}
600
- - Recording: {url}
601
-
602
- **Analysis:**
603
-
604
- {text}
605
- ---"""
606
  )
607
- results_blocks.append(block)
608
- analyzed += 1
609
 
610
- if analyzed == 0:
611
- info = "НС атрымалася ΠΏΡ€Π°Π°Π½Π°Π»Ρ–Π·Π°Π²Π°Ρ†ΡŒ Π½Ρ–Π²ΠΎΠ΄Π½Π°Π³Π° Π·Π²Π°Π½ΠΊΠ°."
612
- return (info, None, info, gr.update())
613
 
614
- # Save to .txt and return
615
- results_text = "\n\n".join(results_blocks)
616
- fname = f"/tmp/batch_analysis_{(date_str or _today_str()).strip()}_{t_from.replace(':','')}-{t_to.replace(':','')}_{int(time.time())}.txt"
617
- with open(fname, "w", encoding="utf-8") as f:
618
- f.write(results_text)
619
 
620
- status = f"Π“Π°Ρ‚ΠΎΠ²Π° βœ…. Па Ρ„Ρ–Π»ΡŒΡ‚Ρ€Ρƒ β€” {total} званкоў. ΠŸΡ€Π°Π°Π½Π°Π»Ρ–Π·Π°Π²Π°Π½Π°: {analyzed}."
621
- return (results_text, fname, status, gr.update(visible=False))
622
 
623
- # ─────────────────────────────────────────────────────────────────────────────
624
- # Password / gating helpers
625
- # ─────────────────────────────────────────────────────────────────────────────
626
-
627
- def ui_check_password(pwd: str):
 
628
  """
629
- Check password against VOCHI_UI_PASSWORD.
630
- Returns: (authed_state, status_msg_md, pwd_group_visibility)
 
 
 
631
  """
632
- if not _UI_PASSWORD:
633
- # Admin hint if password not configured
634
- return False, (
635
- "⚠️ <b>VOCHI_UI_PASSWORD</b> Π½Π΅ Π½Π°Π»Π°Π΄ΠΆΠ°Π½Ρ‹ ў Secrets. "
636
- "Π”Π°Π΄Π°ΠΉΡ†Π΅ яго ў Settings β†’ Secrets Ρ– пСразапусціцС Space."
637
- ), gr.update(visible=True)
638
 
639
- if (pwd or "").strip() == _UI_PASSWORD:
640
- return True, "βœ… Доступ Π°Π΄ΠΊΡ€Ρ‹Ρ‚Ρ‹. ЦяпСр ΠΌΠΎΠΆΠ½Π° Π½Π°Ρ†Ρ–ΡΠΊΠ°Ρ†ΡŒ <b>Fetch list</b> Ρ– ΠΏΡ€Π°Ρ†Π°Π²Π°Ρ†ΡŒ.", gr.update(visible=False)
641
- else:
642
- return False, "❌ ΠΡΠΏΡ€Π°Π²Ρ–Π»ΡŒΠ½Ρ‹ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ. ΠŸΠ°ΡΠΏΡ€Π°Π±ΡƒΠΉΡ†Π΅ ΡΡˆΡ‡Ρ Ρ€Π°Π·.", gr.update(visible=True)
 
 
 
 
 
643
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
 
645
- def ui_fetch_or_auth(date_str: str, authed: bool):
646
- """
647
- If not authed, open password box instead of fetching.
648
- Otherwise, fetch calls.
649
- Returns: calls_df, row_dd, status_md, pwd_group_visibility
650
- """
651
- if not authed:
652
- return gr.update(), gr.update(), "πŸ”’ УвядзіцС ΠΏΠ°Ρ€ΠΎΠ»ΡŒ, ΠΊΠ°Π± Π°Ρ‚Ρ€Ρ‹ΠΌΠ°Ρ†ΡŒ Π·Π²Π°Π½ΠΊΡ–.", gr.update(visible=True)
653
- df, dd, msg = ui_fetch_calls(date_str)
654
- return df, dd, msg, gr.update(visible=False)
 
 
655
 
656
 
657
- # ─────────────────────────────────────────────────────────────────────────────
658
  # Build Gradio UI
659
- # ─────────────────────────────────────────────────────────────────────────────
 
 
 
660
 
661
  with gr.Blocks(title="Vochi CRM Call Logs (Gradio)") as demo:
662
  gr.Markdown(
663
- """
664
- # Vochi CRM β†’ MP3 β†’ AI analysis
665
- *Fetch daily calls, play/download MP3, and analyze the call with an AI model.*
666
- """
667
  )
668
 
669
- # Auth state (False by default)
670
  authed = gr.State(False)
 
 
671
 
672
- # States for batch tab
673
- batch_df_state = gr.State(pd.DataFrame())
674
-
675
- # Password "modal" (group shown on demand)
676
- with gr.Group(visible=False) as pwd_group:
677
- gr.Markdown("### πŸ” УвядзіцС ΠΏΠ°Ρ€ΠΎΠ»ΡŒ")
678
- pwd_tb = gr.Textbox(label="Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", lines=1)
679
- pwd_btn = gr.Button("ΠΠ΄ΠΊΡ€Ρ‹Ρ†ΡŒ доступ", variant="primary")
680
 
681
  with gr.Tabs() as tabs:
682
  with gr.Tab("Vochi CRM"):
683
  with gr.Row():
684
- date_inp = gr.Textbox(label="Date", value=_today_str(), scale=1)
685
- fetch_btn = gr.Button("Fetch list", variant="primary", scale=0)
686
- calls_df = gr.Dataframe(value=pd.DataFrame(), label="Call list", interactive=False)
687
- row_dd = gr.Dropdown(choices=[], label="Call", info="Select a row for playback/analysis")
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  with gr.Row():
689
- play_btn = gr.Button("🎧 Play")
690
- url_html = gr.HTML()
691
- audio_out = gr.Audio(label="Audio", type="filepath")
692
- file_out = gr.File(label="MP3 download")
693
  status_fetch = gr.Markdown()
 
694
 
695
- with gr.Tab("AI Analysis"):
696
- with gr.Row():
697
- tpl_dd = gr.Dropdown(choices=TPL_OPTIONS, value="simple", label="Template")
698
- lang_dd = gr.Dropdown(choices=LANG_OPTIONS, value="default", label="Language")
699
- model_dd = gr.Dropdown(choices=MODEL_OPTIONS, value="models/gemini-2.5-flash", label="Model")
700
- custom_prompt_tb = gr.Textbox(label="Custom prompt", lines=8, visible=False)
701
- analyze_btn = gr.Button("🧠 Analyze", variant="primary")
702
- analysis_md = gr.Markdown()
703
 
704
- # ΠŸΠ°ΠΊΠ΅Ρ‚Π½Ρ‹ Π°Π½Π°Π»Ρ–Π· Π· Ρ„Ρ–Π»ΡŒΡ‚Ρ€Π°ΠΌΡ– ΠΏΠ° часС Ρ– calltype + прэв'ю
705
- with gr.Tab("Аналіз званкоў Π·Π° дзСнь/час"):
706
- batch_date_inp = gr.Textbox(label="Date", value=_today_str(), scale=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
 
708
- with gr.Row():
709
- t_from_inp = gr.Textbox(label="Time from (HH:MM)", value=_DEF_T_FROM)
710
- t_to_inp = gr.Textbox(label="Time to (HH:MM)", value=_DEF_T_TO)
711
- calltype_dd = gr.Dropdown(label="calltype", choices=[""], value="", info="ΠΡžΡ‚Π°-значэнні пасля Π·Π°Π³Ρ€ΡƒΠ·ΠΊΡ–")
 
 
712
 
713
  with gr.Row():
714
- load_btn = gr.Button("ΠΡ‚Ρ€Ρ‹ΠΌΠ°Ρ†ΡŒ спіс (для Ρ„Ρ–Π»ΡŒΡ‚Ρ€Π°Ρž)", variant="secondary")
715
- preview_btn = gr.Button("ΠŸΠ°ΠΏΡΡ€ΡΠ΄Π½Ρ– прагляд", variant="secondary")
716
- batch_btn = gr.Button("ΠΏΡ€Π°Π°Π½Π°Π»Ρ–Π·Π°Π²Π°Ρ†ΡŒ", variant="primary")
717
 
718
- preview_info_md = gr.Markdown()
719
- preview_df = gr.Dataframe(value=pd.DataFrame(), label="ΠΠ΄Ρ„Ρ–Π»ΡŒΡ‚Ρ€Π°Π²Π°Π½Ρ‹Ρ Π·Π²Π°Π½ΠΊΡ–", interactive=False)
 
720
 
 
721
  with gr.Row():
722
- tpl_batch_dd = gr.Dropdown(choices=TPL_OPTIONS, value="simple", label="Template")
723
- lang_batch_dd = gr.Dropdown(choices=LANG_OPTIONS, value="default", label="Language")
724
- model_batch_dd = gr.Dropdown(choices=MODEL_OPTIONS, value="models/gemini-2.5-flash", label="Model")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
 
726
- custom_batch_tb = gr.Textbox(label="Custom prompt", lines=8, visible=False)
 
 
727
 
728
- batch_status_md = gr.Markdown()
729
- batch_results_md = gr.Markdown()
730
- batch_file_out = gr.File(label="Π’Ρ‹Π½Ρ–ΠΊΡ– (.txt)")
731
-
732
- # Wire events
733
- # 1) Fetch button: gate by password
734
- fetch_btn.click(
735
- ui_fetch_or_auth,
736
- inputs=[date_inp, authed],
737
- outputs=[calls_df, row_dd, status_fetch, pwd_group],
738
- )
739
 
740
- # 2) Password submit β†’ set authed state, show message, hide group on success
741
  pwd_btn.click(
742
  ui_check_password,
743
  inputs=[pwd_tb],
744
  outputs=[authed, status_fetch, pwd_group],
745
  )
746
 
747
- # 3) Other interactions (single analysis)
748
- play_btn.click(ui_play_audio, inputs=[row_dd, calls_df], outputs=[url_html, audio_out, file_out, status_fetch])
749
- tpl_dd.change(ui_toggle_custom_prompt, inputs=[tpl_dd], outputs=[custom_prompt_tb])
750
- analyze_btn.click(
751
- ui_analyze,
752
- inputs=[row_dd, calls_df, tpl_dd, custom_prompt_tb, lang_dd, model_dd],
753
- outputs=[analysis_md],
754
  )
755
 
756
- # 4) Batch tab interactions
757
- tpl_batch_dd.change(ui_toggle_custom_prompt, inputs=[tpl_batch_dd], outputs=[custom_batch_tb])
 
 
 
 
 
 
 
 
 
 
 
758
 
759
- # Load list for date β†’ populate state & calltype dropdown
760
- load_btn.click(
761
- ui_batch_load,
762
- inputs=[batch_date_inp, authed],
763
- outputs=[batch_df_state, calltype_dd, preview_info_md, pwd_group],
764
  )
765
 
766
- # Preview filtered table
767
- preview_btn.click(
768
- ui_batch_preview,
769
- inputs=[batch_df_state, t_from_inp, t_to_inp, calltype_dd],
770
- outputs=[preview_df, preview_info_md, t_from_inp, t_to_inp],
771
  )
772
 
773
- # Analyze filtered
774
- batch_btn.click(
775
- ui_analyze_calls_by_date,
776
- inputs=[authed, batch_df_state, batch_date_inp, t_from_inp, t_to_inp, calltype_dd, tpl_batch_dd, custom_batch_tb, lang_batch_dd, model_batch_dd],
777
- outputs=[batch_results_md, batch_file_out, batch_status_md, pwd_group],
778
  )
779
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
780
 
781
  if __name__ == "__main__":
782
- # On HF Spaces, just running this file is enough; launch() is fine for local dev, too.
783
- demo.launch()
 
 
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", "default")
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
+ html = f'URL: <a href="{listen_url}" target="_blank">{listen_url}</a>'
373
 
374
+ return html, handle.local_uri, "Ready βœ…"
375
+ except Exception as exc:
376
+ return f"Playback failed: {exc}", None, ""
377
 
378
 
379
+ def ui_toggle_custom_prompt(template_key):
380
+ """ΠŸΠ°ΠΊΠ°Π·Π°Ρ†ΡŒ/ΡΡ…Π°Π²Π°Ρ†ΡŒ ΠΏΠΎΠ»Π΅ Custom prompt."""
381
+ return gr.update(visible=(template_key == "custom"))
 
 
 
382
 
 
 
 
 
383
 
384
+ def ui_mass_analyze(
385
+ date_value,
386
+ time_from_value,
387
+ time_to_value,
388
+ call_type_value,
389
+ tenant_id,
390
+ authed,
391
+ ):
392
+ """
393
+ ΠœΠ°ΡΠ°Π²Ρ‹ Π°Π½Π°Π»Ρ–Π· (STREAMING).
394
+ Гэта Π³Π΅Π½Π΅Ρ€Π°Ρ‚Π°Ρ€ (yield), Gradio Π±ΡƒΠ΄Π·Π΅ Π°Π΄Π»ΡŽΡΡ‚Ρ€ΠΎΡžΠ²Π°Ρ†ΡŒ Π²Ρ‹Π½Ρ–ΠΊΡ– паступова.
395
+ ΠŸΠ°Π²Π΅Π΄Π°ΠΌΠ»Π΅Π½Π½Ρ– прагрэс-статусу Ρ– Π²Ρ‹Π½Ρ–ΠΊΠΎΠ²Π°Π΅ ΠΏΠ°Π²Π΅Π΄Π°ΠΌΠ»Π΅Π½Π½Π΅ Ρ–Π΄ΡƒΡ†ΡŒ Π±ΡƒΠΉΠ½Ρ‹ΠΌ ΡˆΡ€Ρ‹Ρ„Ρ‚Π°ΠΌ (Markdown ## / ###).
396
+ """
397
 
398
+ empty_df = pd.DataFrame()
399
+ hidden_df_update = gr.update(value=empty_df, visible=False)
400
+ hidden_file = gr.update(value=None, visible=False)
 
 
 
401
 
402
+ def h3(txt: str) -> str:
403
+ # сярэдні Π±ΡƒΠΉΠ½Ρ‹ ΡˆΡ€Ρ‹Ρ„Ρ‚
404
+ return f"### {txt}"
405
 
406
+ def h2_success(txt: str) -> str:
407
+ # вялікі тэкст для Ρ„Ρ–Π½Π°Π»ΡŒΠ½Π°Π³Π° Π²Ρ‹Π½Ρ–ΠΊΡƒ
408
+ return f"## {txt}"
 
409
 
410
+ def h2_error(txt: str) -> str:
411
+ return f"## {txt}"
 
 
 
412
 
413
+ # 1) ΠΏΡ€Π°Π²Π΅Ρ€ΠΊΡ– доступу Ρ– ΠΊΠ°Π½Ρ„Ρ–Π³Π°
414
+ if not authed:
415
+ yield (
416
+ hidden_df_update,
417
+ h2_error("πŸ” Enter the password to run batch analysis."),
418
+ hidden_file,
419
+ )
420
+ return
421
 
422
+ if not PROJECT_IMPORTS_AVAILABLE:
423
+ yield (
424
+ hidden_df_update,
425
+ h2_error("Project dependencies are not loaded."),
426
+ hidden_file,
427
+ )
428
+ return
429
 
430
+ if len(ai_registry) == 0 or not BATCH_MODEL_KEY:
431
+ yield (
432
+ hidden_df_update,
433
+ h2_error("❌ Batch analysis is unavailable: AI model is not configured."),
434
+ hidden_file,
435
+ )
436
+ return
437
+
438
+ # 2) асноўная Π»ΠΎΠ³Ρ–ΠΊΠ° Π·Π±ΠΎΡ€Ρƒ спісу званкоў
439
  try:
440
+ day = _parse_day(date_value)
441
+ time_from = _parse_time_value(time_from_value)
442
+ time_to = _parse_time_value(time_to_value)
443
+ _validate_time_range(time_from, time_to)
444
+ call_type = _resolve_call_type(call_type_value)
445
+
446
+ tenant = tenant_service.resolve(tenant_id or None)
447
+ entries = call_log_service.list_calls(
448
+ day,
449
+ tenant,
450
+ time_from=time_from,
451
+ time_to=time_to,
452
+ call_type=call_type,
453
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
+ if not entries:
456
+ yield (
457
+ hidden_df_update,
458
+ h3("ℹ️ No calls for the selected filter."),
459
+ hidden_file,
460
+ )
461
+ return
462
 
463
+ rows = []
464
+ total = len(entries)
 
 
465
 
466
+ # ΠΏΠ°Ρ‡Π°Ρ‚ΠΊΠΎΠ²Ρ‹ апдэйт
467
+ yield (
468
+ gr.update(value=pd.DataFrame(), visible=False),
469
+ h3(f"Starting batch analysis for {total} call(s)..."),
470
+ hidden_file,
471
+ )
 
 
 
 
 
 
 
 
 
 
 
472
 
473
+ # 3) Ρ†Ρ‹ΠΊΠ» Π°Π½Π°Π»Ρ–Π·Ρƒ
474
+ for i, entry in enumerate(entries, start=1):
475
+ pct = int((i / total) * 100)
 
 
 
476
 
477
+ row_data = {
478
+ "Start": entry.started_at.isoformat() if entry.started_at else "",
479
+ "Caller": entry.caller_id or "",
480
+ "Destination": entry.destination or "",
481
+ "Duration (s)": entry.duration_seconds,
482
+ "UniqueId": entry.unique_id,
483
+ }
484
 
485
+ try:
486
+ result = analysis_service.analyze_call(
487
+ unique_id=entry.unique_id,
488
+ tenant=tenant,
489
+ lang=BATCH_LANGUAGE,
490
+ options=AnalysisOptions(
491
+ model_key=BATCH_MODEL_KEY,
492
+ prompt_key=BATCH_PROMPT_KEY,
493
+ custom_prompt=BATCH_PROMPT_TEXT or None,
494
+ ),
495
+ )
496
+
497
+ link = (
498
+ f"{tenant.vochi_base_url.rstrip('/')}/calllogs/"
499
+ f"{tenant.vochi_client_id}/{entry.unique_id}"
500
+ )
501
+
502
+ # спроба structured JSON
503
+ try:
504
+ text = str(result.text or "").strip()
505
+ l, r = text.find("{"), text.rfind("}")
506
+ if l != -1 and r != -1 and r > l:
507
+ text = text[l : r + 1]
508
+ payload = json.loads(text)
509
+
510
+ row_data["Needs follow-up"] = (
511
+ "Yes" if payload.get("needs_follow_up") else "No"
512
+ )
513
+ row_data["Reason"] = str(payload.get("reason") or "")
514
+ except Exception:
515
+ row_data["Needs follow-up"] = ""
516
+ row_data["Reason"] = result.text
517
+
518
+ row_data["Link"] = f'<a href="{link}" target="_blank">Listen</a>'
519
+ row_data["Status"] = "βœ…"
520
+ except Exception as exc:
521
+ row_data["Needs follow-up"] = ""
522
+ row_data["Reason"] = f"❌ {exc}"
523
+ row_data["Link"] = ""
524
+ row_data["Status"] = "❌"
525
+
526
+ rows.append(row_data)
527
+
528
+ partial_df = pd.DataFrame(rows)
529
+ interim_msg = f"Analyzing {i}/{total} ({pct}%)… UID `{entry.unique_id}`"
530
+
531
+ # ΠΏΡ€Π°ΠΌΠ΅ΠΆΠΊΠ°Π²Ρ‹ yield (ΠΆΡ‹Π²ΠΎΠ΅ абнаўлСннС Ρ‚Π°Π±Π»Ρ–Ρ†Ρ‹ + статус)
532
+ yield (
533
+ gr.update(value=partial_df, visible=True),
534
+ h3(interim_msg),
535
+ hidden_file,
536
+ )
537
 
538
+ # 4) Ρ„Ρ–Π½Π°Π»
539
+ final_df = pd.DataFrame(rows)
540
+ ok_count = len(final_df[final_df["Status"] == "βœ…"])
541
+ final_msg = (
542
+ "βœ… Batch analysis completed. "
543
+ f"Found: {total}, processed successfully: {ok_count}"
544
+ )
545
 
546
+ yield (
547
+ gr.update(value=final_df, visible=True),
548
+ h2_success(final_msg),
549
+ hidden_file,
550
+ )
551
 
552
+ except Exception as exc:
553
+ yield (
554
+ hidden_df_update,
555
+ h2_error(f"❌ Analysis failed: {exc}"),
556
+ hidden_file,
557
+ )
558
+ return
559
 
 
 
 
560
 
561
+ def ui_hide_call_list():
562
+ """Π‘Ρ…Π°Π²Π°Ρ†ΡŒ Ρ€ΡƒΡ‡Π½Ρ‹ спіс Π²Ρ‹ΠΊΠ»Ρ–ΠΊΠ°Ρž пасля Π±Π°Ρ‚Ρ‡Π°, ΠΊΠ°Π± Π½Π΅ Π±Π»Ρ‹Ρ‚Π°Ρ†ΡŒ ΠΊΠ°Ρ€Ρ‹ΡΡ‚Π°Π»ΡŒΠ½Ρ–ΠΊΠ°."""
563
+ return gr.update(visible=False)
564
 
 
 
 
565
 
566
+ def ui_export_results(results_df):
567
+ """Π—Π°Ρ…Π°Π²Π°Ρ†ΡŒ Π±Π°Ρ‚Ρ‡-Π°Π½Π°Π»Ρ–Π· Ρƒ CSV Ρ– Π²ΡΡ€Π½ΡƒΡ†ΡŒ Ρ„Π°ΠΉΠ» Ρƒ UI."""
568
+ if results_df is None or results_df.empty:
569
+ return gr.update(value=None, visible=False), "❌ No data to export."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
 
571
+ with tempfile.NamedTemporaryFile(
572
+ "w", suffix=".csv", delete=False, encoding="utf-8"
573
+ ) as tmp:
574
+ results_df.to_csv(tmp.name, index=False)
575
+ return gr.update(value=tmp.name, visible=True), "βœ… File is ready to save."
576
 
 
 
 
 
 
 
 
577
 
578
+ def ui_check_password(pwd: str):
579
+ """ΠŸΡ€Π°Π²Π΅Ρ€ΠΊΠ° доступу ў UI."""
580
+ _UI_PASSWORD = os.environ.get("VOCHI_UI_PASSWORD", "")
581
 
582
+ if not _UI_PASSWORD:
583
+ # ΠΏΠ°Ρ€ΠΎΠ»ΡŒ Π½Π΅ настроСны -> усім ΠΌΠΎΠΆΠ½Π°
584
+ return (
585
+ False,
586
+ "⚠️ <b>VOCHI_UI_PASSWORD</b> is not configured. Access granted without password.",
587
+ gr.update(visible=False),
588
+ )
589
 
590
+ if (pwd or "").strip() == _UI_PASSWORD:
591
+ return True, "βœ… Access granted.", gr.update(visible=False)
 
592
 
593
+ return False, "❌ Incorrect password.", gr.update(visible=True)
 
 
594
 
 
 
 
595
 
596
+ def ui_show_current_uid(current_uid: str):
597
+ """ΠŸΠ°ΠΊΠ°Π·Π°Ρ†ΡŒ Π²Ρ‹Π±Ρ€Π°Π½Ρ‹ UID Ρƒ Ρ‚Π°Π±Π΅ AI Analysis."""
598
+ uid = (current_uid or "").strip()
599
+ return (
600
+ f"**Selected UniqueId:** `{uid}`"
601
+ if uid
602
+ else "No file selected for AI Analysis."
603
+ )
604
 
 
 
605
 
606
+ def ui_analyze_bridge(
607
+ selected_idx,
608
+ df,
609
+ template_key,
610
+ custom_prompt,
611
+ lang_code,
612
+ model_pref,
613
+ tenant_id,
614
+ current_uid,
615
+ ):
616
+ """
617
+ Аналіз Π°Π΄Π½ΠΎΠΉ Ρ€Π°Π·ΠΌΠΎΠ²Ρ‹ Π— ΠŸΠ ΠΠ“Π Π­Π‘ΠΠœ.
618
+ ВАЖНА:
619
+ - Гэта цяпСр Π³Π΅Π½Π΅Ρ€Π°Ρ‚Π°Ρ€ (yield), Π° Π½Π΅ звычайная функцыя.
620
+ - ΠœΡ‹ Π½Π΅ Π²Ρ‹ΠΊΠ°Ρ€Ρ‹ΡΡ‚ΠΎΡžΠ²Π°Π΅ΠΌ Π°Ρ€Π³ΡƒΠΌΠ΅Π½Ρ‚ progress=... (Ρ‘Π½ Π»Π°ΠΌΠ°Π΅Ρ†Ρ†Π° ў Gradio 5).
621
+ - ЗрабляСм Π½Π΅ΠΊΠ°Π»ΡŒΠΊΡ– ΠΊΡ€ΠΎΠΊΠ°Ρž:
622
+ 1) ΠΏΡ€Π°Π²Π΅Ρ€ΠΊΡ– Ρ– ΠΏΠ°Π΄Ρ€Ρ‹Ρ…Ρ‚ΠΎΡžΠΊΠ° -> yield статычны статус
623
+ 2) Π²Ρ‹ΠΊΠ»Ρ–ΠΊ Π°Π½Π°Π»Ρ–Π·Ρƒ -> пасля гэтага ΡΡˆΡ‡Ρ Π°Π΄Π·Ρ–Π½ yield Π· Π²Ρ‹Π½Ρ–ΠΊΠ°ΠΌ
624
+ - Gradio сам ΠΏΠ°ΠΊΠ°ΠΆΠ° built-in progress bar ΠΏΡ€Π°Π· show_progress="full".
625
+ """
626
 
627
+ # STEP 0. Π’Ρ‹Π·Π½Π°Ρ‡Π°Π΅ΠΌ, які UID трэба Π°Π½Π°Π»Ρ–Π·Π°Π²Π°Ρ†ΡŒ
628
+ uid_to_analyze = (current_uid or "").strip()
629
+ if not uid_to_analyze and selected_idx is not None and df is not None and not df.empty:
630
+ try:
631
+ uid_to_analyze = str(df.iloc[int(selected_idx)].get("UniqueId") or "").strip()
632
+ except (ValueError, IndexError):
633
+ uid_to_analyze = ""
634
 
635
+ # ΠšΠ°Π»Ρ– няма UID -> Π°Π΄Ρ€Π°Π·Ρƒ Π²Ρ‹Π½Ρ–ΠΊΠ°Π΅ΠΌ
636
+ if not uid_to_analyze:
637
+ yield "Select a call from the list or batch results first."
638
+ return
639
 
640
+ # STEP 1. ΠŸΡ€Π°Π²Π΅Ρ€ΠΊΡ– ΠΊΠ°Π½Ρ„Ρ–Π³ΡƒΡ€Π°Ρ†Ρ‹Ρ– ΠΏΠ΅Ρ€Π°Π΄ Π²Ρ‹ΠΊΠ»Ρ–ΠΊΠ°ΠΌ мадэлі
641
+ if not PROJECT_IMPORTS_AVAILABLE:
642
+ yield "Project dependencies are not loaded."
643
+ return
644
 
645
+ if len(ai_registry) == 0:
646
+ yield "❌ No AI models are configured."
647
+ return
 
648
 
649
+ if model_pref not in ai_registry:
650
+ yield "❌ Selected model is not available."
651
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
+ # ΠΏΠ°ΠΊΠ°Π·Π²Π°Π΅ΠΌ ΠΊΠ°Ρ€Ρ‹ΡΡ‚Π°Π»ΡŒΠ½Ρ–ΠΊΡƒ, ΡˆΡ‚ΠΎ ΠΏΠ°Ρ‡Ρ‹Π½Π°Π΅ΠΌ
654
+ yield f"### Preparing analysis...\n\n- UID: `{uid_to_analyze}`\n- Model: `{model_pref}`\n- Lang: `{lang_code}`\n\nPlease wait…"
 
 
655
 
656
+ # STEP 2. Π ΡΠ°Π»ΡŒΠ½Ρ‹ Π°Π½Π°Π»Ρ–Π·
657
+ try:
658
+ tenant = tenant_service.resolve(tenant_id or None)
659
+ lang = Language(lang_code)
660
+
661
+ result = analysis_service.analyze_call(
662
+ unique_id=uid_to_analyze,
663
+ tenant=tenant,
664
+ lang=lang,
665
+ options=AnalysisOptions(
666
+ model_key=model_pref,
667
+ prompt_key=template_key,
668
+ custom_prompt=custom_prompt,
669
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
  )
 
 
671
 
672
+ # STEP 3. Π“Π°Ρ‚ΠΎΠ²Π°, вяртаСм Π²Ρ‹Π½Ρ–ΠΊ
673
+ yield f"### Analysis result\n\n{result.text}"
 
674
 
675
+ except Exception as exc:
676
+ yield f"Analysis failed: {exc}"
 
 
 
677
 
 
 
678
 
679
+ def ui_on_batch_row_select(
680
+ displayed_df: pd.DataFrame,
681
+ full_df_state: pd.DataFrame,
682
+ tenant_id: str,
683
+ evt: gr.SelectData,
684
+ ):
685
  """
686
+ ΠΠΏΡ€Π°Ρ†ΠΎΡžΠ²Π°Π΅ Π²Ρ‹Π±Π°Ρ€ Ρ€Π°Π΄ΠΊΠ° Π· Ρ‚Π°Π±Π»Ρ–Ρ†Ρ‹ Π²Ρ‹Π½Ρ–ΠΊΠ°Ρž (Batch results).
687
+ ВАЖНА:
688
+ - evt.index Π΄Π°Π΅ індэкс Ρ€Π°Π΄ΠΊΠ° ў Π°Π΄Π»ΡŽΡΡ‚Ρ€Π°Π²Π°Π½Π°ΠΉ Ρ‚Π°Π±Π»Ρ–Ρ†Ρ‹ (пасля ΡΠ°Ρ€Ρ‚Ρ‹Ρ€ΠΎΡžΠΊΡ–/Ρ„Ρ–Π»ΡŒΡ‚Ρ€Π°Ρ†Ρ‹Ρ–),
689
+ Π° Π½Π΅ ў Π·Ρ‹Ρ…ΠΎΠ΄Π½Ρ‹Ρ… Π΄Π°Π΄Π·Π΅Π½Ρ‹Ρ….
690
+ - ΠœΡ‹ дастаём UniqueId Π· гэтага Ρ€Π°Π΄ΠΊΠ° Ρ– Π±ΡƒΠ΄ΡƒΠ΅ΠΌ Π°Π΄Π·Ρ–Π½ варыянт для Π²Ρ‹ΠΏΠ°Π΄Π°ΡŽΡ‡Π°Π³Π° спісу "Call".
691
  """
692
+ # Значэнні ΠΏΠ° Π·ΠΌΠ°ΡžΡ‡Π°Π½Π½Ρ–, ΠΊΠ°Π»Ρ– Π½Π΅ΡˆΡ‚Π° ΠΏΠΎΠΉΠ΄Π·Π΅ Π½Π΅ Ρ‚Π°ΠΊ
693
+ empty_return = (
694
+ gr.update(choices=[], value=None),
695
+ "",
696
+ "No file selected for AI Analysis.",
697
+ )
698
 
699
+ # ΠŸΡ€Π°Π²Π΅Ρ€ΠΊΠ°, Ρ†Ρ– Ρ‘ΡΡ†ΡŒ даныя для Π°ΠΏΡ€Π°Ρ†ΠΎΡžΠΊΡ–
700
+ if (
701
+ evt is None
702
+ or displayed_df is None
703
+ or displayed_df.empty
704
+ or full_df_state is None
705
+ or full_df_state.empty
706
+ ):
707
+ return empty_return
708
 
709
+ try:
710
+ # КРОК 1: АтрымліваСм індэкс Π²Ρ‹Π±Ρ€Π°Π½Π°Π³Π° Ρ€Π°Π΄ΠΊΠ° Π· Π°Π±'Π΅ΠΊΡ‚Π° ΠΏΠ°Π΄Π·Π΅Ρ– (evt)
711
+ # evt.index Ρ‚ΡƒΡ‚ успрымаСм як спіс Π²Ρ‹Π±Ρ€Π°Π½Ρ‹Ρ… Ρ€Π°Π΄ΠΊΠΎΡž, бярэм ΠΏΠ΅Ρ€ΡˆΡ‹
712
+ visual_row_index = evt.index[0]
713
+ clicked_row_from_view = displayed_df.iloc[visual_row_index]
714
+
715
+ # КРОК 2: Π—Π΄Π°Π±Ρ‹Π²Π°Π΅ΠΌ ΡƒΠ½Ρ–ΠΊΠ°Π»ΡŒΠ½Ρ‹ ідэнтыфікатар (UniqueId)
716
+ uid = str(clicked_row_from_view.get("UniqueId", "")).strip()
717
+ if not uid:
718
+ return empty_return
719
+
720
+ # Π¨ΡƒΠΊΠ°ΠΉ Π°Ρ€Ρ‹Π³Ρ–Π½Π°Π»ΡŒΠ½Ρ‹ Ρ€Π°Π΄ΠΎΠΊ Ρƒ ΠΏΠΎΡžΠ½Ρ‹ΠΌ Π½Π°Π±ΠΎΡ€Ρ‹ Π΄Π°Π½Ρ‹Ρ…
721
+ original_row_series = full_df_state[full_df_state["UniqueId"] == uid]
722
+ if original_row_series.empty:
723
+ return empty_return
724
+ original_row = original_row_series.iloc[0]
725
+ row_dict = original_row.to_dict()
726
+
727
+ # КРОК 3: Π§Π°Π»Π°Π²Π΅Ρ‡Ρ‹ лэйбл для Π²Ρ‹ΠΏΠ°Π΄Π°ΡŽΡ‡Π°Π³Π° спісу
728
+ label = (
729
+ f"{row_dict.get('Start','')} | "
730
+ f"{row_dict.get('Caller','')} β†’ "
731
+ f"{row_dict.get('Destination','')} "
732
+ f"({row_dict.get('Duration (s)','')}s)"
733
+ )
734
 
735
+ # КРОК 4: АбнаўлСннС для Dropdown "Call"
736
+ # choices = [("Π±Π°Ρ‡Π½Ρ‹ тэкст", value_for_component)]
737
+ dd_update = gr.update(choices=[(f"Batch: {label}", uid)], value=uid)
738
+
739
+ # КРОК 5: ВяртаСм:
740
+ # - абнаўлСннС row_dd
741
+ # - сам uid -> ΠΊΠ»Π°Π΄Π·Π΅Ρ†Ρ†Π° ў current_uid_state
742
+ # - Ρ„Π°Ρ€ΠΌΠ°Ρ‚Π°Π²Π°Π½Ρ‹ Markdown Π· UID Ρƒ Ρ‚Π°Π±Π΅ "AI Analysis"
743
+ return dd_update, uid, ui_show_current_uid(uid)
744
+
745
+ except (AttributeError, IndexError, KeyError):
746
+ return empty_return
747
 
748
 
749
+ # ----------------------------------------------------------------------------
750
  # Build Gradio UI
751
+ # ----------------------------------------------------------------------------
752
+ def _today_str():
753
+ return _dt.date.today().strftime("%Y-%m-%d")
754
+
755
 
756
  with gr.Blocks(title="Vochi CRM Call Logs (Gradio)") as demo:
757
  gr.Markdown(
758
+ "# Vochi CRM β†’ MP3 β†’ AI analysis\n"
759
+ "*Filter calls by date, time and type, listen to recordings and run batch AI analysis.*"
 
 
760
  )
761
 
 
762
  authed = gr.State(False)
763
+ batch_results_state = gr.State(pd.DataFrame())
764
+ current_uid_state = gr.State("")
765
 
766
+ with gr.Group(visible=os.environ.get("VOCHI_UI_PASSWORD", "") != "") as pwd_group:
767
+ gr.Markdown("### πŸ” Enter password")
768
+ pwd_tb = gr.Textbox(
769
+ label="Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", lines=1
770
+ )
771
+ pwd_btn = gr.Button("Unlock", variant="primary")
 
 
772
 
773
  with gr.Tabs() as tabs:
774
  with gr.Tab("Vochi CRM"):
775
  with gr.Row():
776
+ tenant_tb = gr.Textbox(
777
+ label="Tenant ID", value=DEFAULT_TENANT_ID, scale=1
778
+ )
779
+ date_inp = gr.Textbox(
780
+ label="Date", value=_today_str(), placeholder="YYYY-MM-DD", scale=1
781
+ )
782
+ time_from_inp = gr.Textbox(
783
+ label="Time from", placeholder="HH:MM", scale=1
784
+ )
785
+ time_to_inp = gr.Textbox(label="Time to", placeholder="HH:MM", scale=1)
786
+ call_type_dd = gr.Dropdown(
787
+ choices=CALL_TYPE_OPTIONS,
788
+ value="",
789
+ label="Call type",
790
+ type="value",
791
+ scale=1,
792
+ )
793
  with gr.Row():
794
+ filter_btn = gr.Button("Filter", variant="primary", scale=0)
795
+ batch_btn = gr.Button("Batch analyze", variant="secondary", scale=0)
796
+ save_btn = gr.Button("Save to file", scale=0)
797
+
798
  status_fetch = gr.Markdown()
799
+ batch_status_md = gr.Markdown()
800
 
801
+ calls_df = gr.DataFrame(
802
+ value=pd.DataFrame(),
803
+ label="Call list (manual filter)",
804
+ interactive=False,
805
+ )
 
 
 
806
 
807
+ batch_results_df = gr.DataFrame(
808
+ value=pd.DataFrame(),
809
+ label="Batch results",
810
+ interactive=True,
811
+ visible=False,
812
+ datatype=[
813
+ "str", # Start
814
+ "str", # Caller
815
+ "str", # Destination
816
+ "number", # Duration (s)
817
+ "str", # UniqueId
818
+ "str", # Needs follow-up
819
+ "str", # Reason
820
+ "markdown", # Link
821
+ "str", # Status
822
+ ],
823
+ )
824
 
825
+ row_dd = gr.Dropdown(
826
+ choices=[],
827
+ label="Call",
828
+ info="Choose a row to listen/analyze",
829
+ type="value",
830
+ )
831
 
832
  with gr.Row():
833
+ play_btn = gr.Button("🎧 Play")
 
 
834
 
835
+ url_html = gr.HTML()
836
+ audio_out = gr.Audio(label="Audio", type="filepath")
837
+ batch_file = gr.File(label="Export CSV", visible=False)
838
 
839
+ with gr.Tab("AI Analysis"):
840
  with gr.Row():
841
+ tpl_dd = gr.Dropdown(
842
+ choices=TPL_OPTIONS,
843
+ value="simple" if TPL_OPTIONS else "custom",
844
+ label="Template",
845
+ )
846
+ lang_dd = gr.Dropdown(
847
+ choices=LANG_OPTIONS,
848
+ value=Language.AUTO,
849
+ label="Language",
850
+ )
851
+ model_dd = gr.Dropdown(
852
+ choices=MODEL_CHOICES,
853
+ value=MODEL_DEFAULT,
854
+ label="Model",
855
+ interactive=bool(MODEL_OPTIONS),
856
+ info=MODEL_INFO,
857
+ )
858
+
859
+ custom_prompt_tb = gr.Textbox(
860
+ label="Custom prompt", lines=8, visible=False
861
+ )
862
 
863
+ current_uid_md = gr.Markdown(
864
+ value="No file selected for AI Analysis."
865
+ )
866
 
867
+ analyze_btn = gr.Button("🧠 Analyze", variant="primary")
868
+ analysis_md = gr.Markdown()
869
+
870
+ # --- wiring events ---
 
 
 
 
 
 
 
871
 
872
+ # ΠΏΠ°Ρ€ΠΎΠ»ΡŒ
873
  pwd_btn.click(
874
  ui_check_password,
875
  inputs=[pwd_tb],
876
  outputs=[authed, status_fetch, pwd_group],
877
  )
878
 
879
+ # ручная Ρ„Ρ–Π»ΡŒΡ‚Ρ€Π°Ρ†Ρ‹Ρ
880
+ filter_btn.click(
881
+ ui_filter_calls,
882
+ inputs=[date_inp, time_from_inp, time_to_inp, call_type_dd, authed, tenant_tb],
883
+ outputs=[calls_df, batch_results_df, row_dd, status_fetch, pwd_group],
 
 
884
  )
885
 
886
+ # масавы Π°Π½Π°Π»Ρ–Π· (stream Π· yield -> ΠΆΡ‹Π²ΠΎΠ΅ абнаўлСннС Ρ– "прагрэс-Π±Π°Ρ€" Ρƒ выглядзС статусу)
887
+ batch_btn.click(
888
+ fn=ui_mass_analyze,
889
+ inputs=[date_inp, time_from_inp, time_to_inp, call_type_dd, tenant_tb, authed],
890
+ outputs=[batch_results_df, batch_status_md, batch_file],
891
+ ).then(
892
+ fn=lambda df: df,
893
+ inputs=[batch_results_df],
894
+ outputs=[batch_results_state],
895
+ ).then(
896
+ fn=ui_hide_call_list,
897
+ outputs=[calls_df],
898
+ )
899
 
900
+ # Π²Ρ‹Π±Π°Ρ€ Ρ€Π°Π΄ΠΊΠ° Π· Π±Π°Ρ‚Ρ‡Ρƒ -> абнаўляСм ΠΏΠΎΠ»Π΅ Call + UID Ρƒ AI Analysis
901
+ batch_results_df.select(
902
+ fn=ui_on_batch_row_select,
903
+ inputs=[batch_results_df, batch_results_state, tenant_tb],
904
+ outputs=[row_dd, current_uid_state, current_uid_md],
905
  )
906
 
907
+ # ΠΏΡ€Π°ΠΉΠ³Ρ€Π°Π²Π°Π½Π½Π΅ Π°ΡžΠ΄Ρ‹Ρ‘
908
+ play_btn.click(
909
+ ui_play_audio,
910
+ inputs=[row_dd, calls_df, tenant_tb],
911
+ outputs=[url_html, audio_out, status_fetch],
912
  )
913
 
914
+ # экспарт CSV
915
+ save_btn.click(
916
+ ui_export_results,
917
+ inputs=[batch_results_state],
918
+ outputs=[batch_file, batch_status_md],
919
  )
920
 
921
+ # ΠΏΠ°ΠΊΠ°Π·Π°Ρ†ΡŒ ΠΏΠΎΠ»Π΅ для свайго prompt
922
+ tpl_dd.change(
923
+ ui_toggle_custom_prompt,
924
+ inputs=[tpl_dd],
925
+ outputs=[custom_prompt_tb],
926
+ )
927
+
928
+ # Π°Π½Π°Π»Ρ–Π· Π°Π΄Π½ΠΎΠΉ Ρ€Π°Π·ΠΌΠΎΠ²Ρ‹ Π· прагрэсам
929
+ analyze_btn.click(
930
+ fn=ui_analyze_bridge,
931
+ inputs=[
932
+ row_dd,
933
+ calls_df,
934
+ tpl_dd,
935
+ custom_prompt_tb,
936
+ lang_dd,
937
+ model_dd,
938
+ tenant_tb,
939
+ current_uid_state,
940
+ ],
941
+ outputs=[analysis_md],
942
+ show_progress="full", # Gradio Π±ΡƒΠ΄Π·Π΅ ΠΏΠ°ΠΊΠ°Π·Π²Π°Ρ†ΡŒ progress bar Π°ΡžΡ‚Π°ΠΌΠ°Ρ‚Ρ‹Ρ‡Π½Π°
943
+ )
944
 
945
  if __name__ == "__main__":
946
+ # УВАГА: Замяні "D:\\tmp" Π½Π° ΡˆΠ»ΡΡ…, Π΄Π·Π΅ Π»ΡΠΆΠ°Ρ†ΡŒ MP3-запісы,
947
+ # ΠΊΠ°Π± ΠΊΠ½ΠΎΠΏΠΊΠ° 🎧 Play ΠΌΠ°Π³Π»Π° Ρ–Ρ… ΠΏΡ€Π°ΠΉΠ³Ρ€Π°Π²Π°Ρ†ΡŒ лакальна.
948
+ demo.launch(allowed_paths=["D:\\tmp"])