call-to-text/old/main.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

424 lines
14 KiB
Python

import os
import sys
import click
from typing import Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
sys.path.insert(0, os.path.dirname(__file__))
from config import config
from ingestor.email_fetcher import EmailFetcher
from ingestor.parsers.novofon import get_parser
from ingestor.downloader import download_audio
from audio.preprocessor import convert_to_wav, split_stereo_channels
from audio.stt import transcribe
from aligner.aligner import align
from storage.contacts import resolve_contacts, resolve_contact_name
from storage.exporter import export_to_json
def _is_mono_duplicate(wav_path: str, threshold: float = 0.98) -> bool:
import wave
import struct
try:
with wave.open(wav_path, "rb") as wf:
if wf.getnchannels() != 2:
return False
sampwidth = wf.getsampwidth()
framerate = wf.getframerate()
nframes = wf.getnframes()
total = min(nframes, framerate * 3)
raw = wf.readframes(total)
diffs = 0
non_silent = 0
for i in range(total):
offset = i * 2 * sampwidth
if sampwidth == 2:
left = struct.unpack_from("<h", raw, offset)[0]
right = struct.unpack_from("<h", raw, offset + 2)[0]
else:
left = struct.unpack_from("<b", raw, offset)[0]
right = struct.unpack_from("<b", raw, offset + 1)[0]
if abs(left) < 50 and abs(right) < 50:
continue
non_silent += 1
if abs(left - right) > 100:
diffs += 1
if non_silent == 0:
return True
return diffs / non_silent < (1 - threshold)
except Exception:
return False
def _transcribe_stereo(wav_path: str, meta: dict) -> dict:
channels = _get_wav_channels(wav_path)
if channels == 1:
click.echo(f" Mono file detected, single transcription.")
left_segments, left_text = transcribe(
wav_path,
model_size=config.WHISPER_MODEL_SIZE,
device=config.WHISPER_DEVICE,
compute_type=config.WHISPER_COMPUTE_TYPE,
)
right_segments = []
final_segments = []
for seg in left_segments:
final_segments.append({
"start": seg["start"],
"end": seg["end"],
"speaker": "unknown",
"text": seg["text"].strip(),
})
is_stereo = False
elif _is_mono_duplicate(wav_path):
click.echo(f" Stereo with duplicate channels, treating as mono.")
left_segments, left_text = transcribe(
wav_path,
model_size=config.WHISPER_MODEL_SIZE,
device=config.WHISPER_DEVICE,
compute_type=config.WHISPER_COMPUTE_TYPE,
)
right_segments = []
final_segments = []
for seg in left_segments:
final_segments.append({
"start": seg["start"],
"end": seg["end"],
"speaker": "unknown",
"text": seg["text"].strip(),
})
is_stereo = False
else:
click.echo(f" Real stereo, splitting channels...")
left_wav, right_wav = split_stereo_channels(wav_path, config.TEMP_DIR)
click.echo(f" Transcribing left channel (callee)...")
left_segments, left_text = transcribe(
left_wav,
model_size=config.WHISPER_MODEL_SIZE,
device=config.WHISPER_DEVICE,
compute_type=config.WHISPER_COMPUTE_TYPE,
)
click.echo(f" Transcribing right channel (caller)...")
right_segments, right_text = transcribe(
right_wav,
model_size=config.WHISPER_MODEL_SIZE,
device=config.WHISPER_DEVICE,
compute_type=config.WHISPER_COMPUTE_TYPE,
)
click.echo(f" Aligning speakers & text...")
final_segments = align(left_segments, right_segments)
is_stereo = True
caller_name = resolve_contact_name(meta.get("caller_number"))
callee_name = resolve_contact_name(meta.get("callee_number"))
employee_name = meta.get("employee")
full_text = ""
for seg in final_segments:
spk = seg["speaker"]
if spk == "caller":
label = caller_name or "Звонящий"
elif spk == "callee":
label = callee_name or (employee_name or "Принимающий")
else:
label = "?"
full_text += f"[{label}]: {seg['text']}\n"
contact_info = resolve_contacts(
meta.get("caller_number"),
meta.get("callee_number"),
)
call_data = {
**meta,
**contact_info,
"transcript": full_text,
"segments": final_segments,
"status": "processed",
"raw_metadata": meta,
}
try:
from storage.directus import DirectusClient
dc = DirectusClient()
if dc.is_available():
collection = _choose_collection(meta)
dc.create_call(call_data, collection=collection)
click.echo(f" Saved to Directus ({collection}).")
dc.close()
except Exception as e:
click.echo(f" Directus save error: {e}")
to_remove = [wav_path]
if is_stereo:
to_remove.extend([left_wav, right_wav])
for f in to_remove:
if f and os.path.exists(f):
os.remove(f)
return call_data
def _process_call(audio_path: str, meta: dict) -> dict:
click.echo(f" Converting audio...")
wav_path, channels = convert_to_wav(audio_path, config.TEMP_DIR)
return _transcribe_stereo(wav_path, meta)
def _get_audio_file(meta, temp_dir: str) -> Optional[str]:
if meta.audio_data:
fname = meta.audio_filename or f"call_{meta.call_id}.mp3"
local_path = os.path.join(temp_dir, fname)
with open(local_path, "wb") as f:
f.write(meta.audio_data)
return local_path
if meta.audio_url:
return download_audio(meta.audio_url, temp_dir)
return None
def _is_already_processed(call_id: str) -> bool:
from storage.directus import DirectusClient
dc = DirectusClient()
try:
for col in ["calls", "calls_74951284933"]:
resp = dc._client.get(f"/items/{col}", params={
"filter[call_id][_eq]": call_id,
"limit": 1, "fields": "id",
})
if resp.status_code == 200 and resp.json().get("data"):
return True
except Exception:
pass
finally:
dc.close()
return False
@click.group()
def cli():
pass
@cli.command()
@click.option("--limit", default=None, type=int, help="Max emails to process")
@click.option("--parallel", default=False, is_flag=True, help="Parallel download + convert")
def process(limit, parallel):
cnt = _run_email_batch(limit=limit, parallel=parallel)
click.echo(f"\nProcessed {cnt} calls.")
def _run_email_batch(limit=None, parallel=False):
fetcher = EmailFetcher(
config.IMAP_SERVER,
config.IMAP_PORT,
config.IMAP_USER,
config.IMAP_PASSWORD,
config.IMAP_FOLDER,
)
fetcher.connect()
messages = fetcher.fetch_unseen()
click.echo(f"Found {len(messages)} unseen messages.")
parser = get_parser("novofon")
tasks = []
seen_call_ids = set()
for msg in messages[:limit] if limit else messages:
click.echo(f"\n--- Message: {msg.subject} ---")
meta = parser.parse_email(msg.subject, msg.body_text, msg.body_html)
# Fallback: check for audio attachment
if not meta or not meta.call_id:
for att in msg._attachments:
att_meta = parser.parse_attachment(msg.subject, att.filename)
if att_meta and att_meta.call_id:
meta = att_meta
meta.audio_data = att.data
meta.audio_filename = att.filename
click.echo(f" Using audio attachment: {att.filename}")
break
if not meta or not meta.call_id:
click.echo(" No Novofon content found, skipping.")
continue
meta.source_email_id = msg.message_id
if not meta.datetime and msg.date_parsed:
meta.datetime = msg.date_parsed
if meta.call_id:
if meta.call_id in seen_call_ids:
click.echo(f" Duplicate call_id {meta.call_id} in batch, skipping.")
continue
if _is_already_processed(meta.call_id):
click.echo(f" Call {meta.call_id} already processed, skipping.")
fetcher.mark_seen(msg.uid)
continue
seen_call_ids.add(meta.call_id)
tasks.append((msg, meta))
if not tasks:
click.echo("No new calls to process.")
fetcher.disconnect()
return 0
click.echo(f"\n=== Phase 1: Download + convert ({len(tasks)} files) ===")
prepared = []
if parallel:
def prep(msg_meta):
msg, meta = msg_meta
try:
audio_path = _get_audio_file(meta, config.TEMP_DIR)
if not audio_path:
return (msg, meta, None, "download failed")
wav_path, _ = convert_to_wav(audio_path, config.TEMP_DIR)
if audio_path and os.path.exists(audio_path):
os.remove(audio_path)
return (msg, meta, wav_path, None)
except Exception as e:
return (msg, meta, None, str(e))
with ThreadPoolExecutor(max_workers=config.MAX_WORKERS) as pool:
futures = [pool.submit(prep, t) for t in tasks]
for f in as_completed(futures):
msg, meta, wav_path, err = f.result()
if err:
click.echo(f" ! Prep failed: {msg.subject} - {err}")
else:
prepared.append((msg, meta, wav_path))
click.echo(f" Prepared: {meta.call_id}")
else:
for msg, meta in tasks:
try:
click.echo(f" Preparing {meta.call_id}...")
audio_path = _get_audio_file(meta, config.TEMP_DIR)
if not audio_path:
click.echo(" Prep failed, skipping.")
continue
wav_path, _ = convert_to_wav(audio_path, config.TEMP_DIR)
if audio_path and os.path.exists(audio_path):
os.remove(audio_path)
prepared.append((msg, meta, wav_path))
except Exception as e:
click.echo(f" Error: {e}")
if not prepared:
click.echo("No files prepared for transcription.")
fetcher.disconnect()
return 0
click.echo(f"\n=== Phase 2: Transcribe + save ({len(prepared)} files, sequential on GPU) ===")
processed = 0
for msg, meta, wav_path in prepared:
try:
call_data = _transcribe_stereo(wav_path, {
"call_id": meta.call_id,
"datetime": meta.datetime,
"direction": meta.direction,
"caller_number": meta.caller_number,
"callee_number": meta.callee_number,
"employee": meta.employee,
"duration_sec": meta.duration_sec,
"audio_url": meta.audio_url,
"source_email_id": meta.source_email_id,
})
fetcher.mark_seen(msg.uid)
processed += 1
click.echo(f" Done! Call ID: {call_data.get('call_id')}")
except Exception as e:
click.echo(f" Error processing {meta.call_id}: {e}")
if wav_path and os.path.exists(wav_path):
os.remove(wav_path)
fetcher.disconnect()
return processed
@cli.command()
@click.option("--file", required=True, help="Path to audio file")
@click.option("--caller", default=None, help="Caller number")
@click.option("--callee", default=None, help="Callee number")
@click.option("--duration", default=None, type=int, help="Duration in seconds")
def process_file(file, caller, callee, duration):
click.echo(f"Processing file: {file}")
meta = {
"call_id": f"manual_{os.path.splitext(os.path.basename(file))[0]}",
"caller_number": caller,
"callee_number": callee,
"direction": "inbound" if caller else None,
"duration_sec": duration,
"audio_url": file,
}
call_data = _process_call(file, meta)
click.echo(f"Done! Call ID: {call_data.get('call_id')}")
@cli.command()
@click.option("--output", default="export.json", help="Output file path")
def export(output):
export_to_json(output)
click.echo(f"Exported to {output}")
@cli.command()
@click.option("--max", "max_per_cycle", default=None, type=int, help="Max calls per cycle")
def daemon(max_per_cycle):
import time
import schedule
max_calls = max_per_cycle or config.MAX_PER_CYCLE
def job():
click.echo("Checking for new emails...")
try:
cnt = _run_email_batch(limit=max_calls)
click.echo(f"Processed {cnt} calls this cycle.")
except Exception as e:
click.echo(f"Error: {e}")
schedule.every(5).minutes.do(job)
click.echo(f"Daemon started. Checking every 5 minutes, max {max_calls} calls/cycle.")
while True:
schedule.run_pending()
time.sleep(30)
def _get_wav_channels(wav_path: str) -> int:
import wave
try:
with wave.open(wav_path, "rb") as wf:
return wf.getnchannels()
except Exception:
return 2
def _choose_collection(meta: dict) -> str:
caller = meta.get("caller_number") or ""
callee = meta.get("callee_number") or ""
for num in [caller, callee]:
cleaned = num.replace("+", "").replace("-", "").replace(" ", "")
if "74951284933" in cleaned:
return "calls_74951284933"
return "calls"
if __name__ == "__main__":
cli()