call-to-text/ingestor/novofon_api.py
ed0ss 7f03949593 Первая версия: транскрипция звонков из Novofon DATA API в Directus
- Клиент Novofon DATA API (JSON-RPC, get.calls_report, чанки по 90 дней)
- faster-whisper (модель turbo) на CUDA с разделением стереоканалов
- Маркировка спикеров: левый канал = caller, правый = callee
- Directus: коллекции calls, calls_74951284933, contacts
- CLI: process, process-file, export, daemon (polling 5 мин)
- Docker Compose для Directus + PostgreSQL
- Архив email-парсинга в old/
2026-06-26 12:44:50 +03:00

185 lines
6.1 KiB
Python

import datetime
from dataclasses import dataclass
from typing import Optional
import httpx
@dataclass
class CallMetadata:
call_id: Optional[str] = None
caller_number: Optional[str] = None
callee_number: Optional[str] = None
employee: Optional[str] = None
direction: Optional[str] = None
duration_sec: Optional[int] = None
datetime: Optional[str] = None
audio_url: Optional[str] = None
class NovofonApiClient:
def __init__(self, access_token: str = None, base_url: str = None):
from config import config
self.access_token = access_token or config.NOVOFON_ACCESS_TOKEN
self.base_url = (base_url or config.NOVOFON_API_BASE_URL).rstrip("/")
self._client = httpx.Client(
headers={"Content-Type": "application/json; charset=UTF-8"},
timeout=60,
)
def _request(self, method: str, params: dict) -> list:
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
}
resp = self._client.post(self.base_url, json=payload)
resp.raise_for_status()
result = resp.json()
if "error" in result:
err = result["error"]
msg = err.get("message", "")
mnemonic = err.get("data", {}).get("mnemonic", "")
raise RuntimeError(f"Novofon API error [{mnemonic}]: {msg}")
return result.get("result", {}).get("data", [])
CALL_FIELDS = [
"communication_id", "direction", "contact_phone_number",
"virtual_phone_number", "start_time", "talk_duration",
"total_duration", "call_records", "wav_call_records",
"full_record_file_link", "employees",
]
def get_calls_report(
self,
date_from: str,
date_till: str,
offset: int = 0,
limit: int = 10000,
sort: Optional[list] = None,
) -> list[dict]:
params = {
"access_token": self.access_token,
"date_from": date_from,
"date_till": date_till,
"offset": offset,
"limit": limit,
"fields": self.CALL_FIELDS,
}
if sort:
params["sort"] = sort
return self._request("get.calls_report", params)
def get_all_calls(
self,
date_from: str,
date_till: Optional[str] = None,
) -> list[CallMetadata]:
if " " not in date_from:
date_from = f"{date_from} 00:00:00"
start = datetime.datetime.strptime(date_from, "%Y-%m-%d %H:%M:%S")
if date_till:
if " " not in date_till:
date_till = f"{date_till} 23:59:59"
end = datetime.datetime.strptime(date_till, "%Y-%m-%d %H:%M:%S")
else:
end = datetime.datetime.utcnow()
# Build chunks from oldest to newest
chunks: list[tuple[str, str]] = []
chunk_start = start
while chunk_start < end:
chunk_end = min(chunk_start + datetime.timedelta(days=90), end)
chunks.append((
chunk_start.strftime("%Y-%m-%d %H:%M:%S"),
chunk_end.strftime("%Y-%m-%d %H:%M:%S"),
))
chunk_start = chunk_end
# Iterate chunks newest-first so --limit gets recent calls
sort_desc = [{"field": "start_time", "order": "desc"}]
all_calls: list[CallMetadata] = []
for from_str, till_str in reversed(chunks):
offset = 0
page_size = 10000
while True:
batch = self.get_calls_report(
from_str, till_str,
offset=offset, limit=page_size,
sort=sort_desc,
)
if not batch:
break
for item in batch:
meta = self._to_metadata(item)
if meta:
all_calls.append(meta)
if len(batch) < page_size:
break
offset += page_size
return all_calls
@staticmethod
def extract_audio_url(data: dict) -> Optional[str]:
full = data.get("full_record_file_link")
if full:
return full
comm_id = data.get("communication_id")
records = data.get("call_records", [])
if records and comm_id:
return f"https://app.novofon.ru/system/media/talk/{comm_id}/{records[0]}/"
wav_records = data.get("wav_call_records", [])
if wav_records and comm_id:
return f"https://app.novofon.ru/system/media/wav/{comm_id}/{wav_records[0]}/"
return None
@staticmethod
def _to_metadata(data: dict) -> Optional[CallMetadata]:
comm_id = data.get("communication_id")
if not comm_id:
return None
direction = data.get("direction")
is_inbound = direction == "in"
contact = data.get("contact_phone_number", "") or ""
virtual = data.get("virtual_phone_number", "") or ""
if is_inbound:
caller_number = contact
callee_number = virtual
else:
caller_number = virtual
callee_number = contact
employees = data.get("employees") or []
employee = employees[0].get("employee_full_name") if employees else None
raw_time = data.get("start_time")
iso_time = None
if raw_time and " " in raw_time:
try:
dt = datetime.datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
iso_time = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
except ValueError:
pass
duration = data.get("talk_duration") or data.get("total_duration")
return CallMetadata(
call_id=str(comm_id),
caller_number=caller_number if caller_number else None,
callee_number=callee_number if callee_number else None,
employee=employee,
direction="inbound" if is_inbound else "outbound",
duration_sec=duration,
datetime=iso_time,
audio_url=NovofonApiClient.extract_audio_url(data),
)
def close(self):
self._client.close()