File size: 27,772 Bytes
218085c
 
 
 
 
 
95d5bed
 
 
 
e8003c4
95d5bed
e446b1b
218085c
f09038e
95d5bed
 
 
 
 
218085c
 
 
 
 
 
 
144bf42
218085c
95d5bed
 
 
 
 
e8003c4
f09038e
 
95d5bed
 
 
 
 
3d44974
 
 
 
 
 
619ed81
 
 
 
 
 
 
 
 
 
 
 
 
 
3d44974
 
 
f85abb8
 
 
e23c8c3
 
 
9fcf4ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e23c8c3
c743599
 
9fcf4ea
 
 
e23c8c3
9fcf4ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e23c8c3
 
 
9fcf4ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d44974
 
95d5bed
369d759
 
 
 
 
 
95d5bed
3d44974
e8003c4
 
 
 
 
 
3d44974
e8003c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f09038e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d44974
f09038e
 
 
 
 
 
 
3d44974
f09038e
 
 
95d5bed
f09038e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8003c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f856ebd
e8003c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b28aa53
 
 
95d5bed
 
b28aa53
3d44974
f856ebd
b28aa53
3d44974
b28aa53
3d44974
 
b28aa53
 
 
3d44974
b28aa53
95d5bed
 
 
3d44974
f09038e
 
 
 
3d44974
 
218085c
2a93301
218085c
 
 
2a93301
218085c
95d5bed
 
 
 
63a687d
95d5bed
218085c
63a687d
 
 
 
218085c
63a687d
 
 
e446b1b
63a687d
e446b1b
392dfe4
 
63a687d
e446b1b
95d5bed
 
 
63a687d
95d5bed
e446b1b
392dfe4
 
63a687d
e446b1b
95d5bed
 
 
 
63a687d
95d5bed
e446b1b
392dfe4
 
63a687d
e446b1b
95d5bed
 
 
 
63a687d
95d5bed
e446b1b
392dfe4
 
63a687d
 
 
95d5bed
 
 
 
 
 
 
 
 
63a687d
 
 
 
 
 
 
 
 
 
95d5bed
 
 
 
218085c
f09038e
 
 
95d5bed
 
 
218085c
95d5bed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218085c
 
95d5bed
 
 
 
 
 
 
 
 
 
 
b28aa53
e446b1b
95d5bed
 
e446b1b
95d5bed
e8003c4
3d44974
95d5bed
e8003c4
3d44974
e8003c4
 
 
 
 
 
 
 
 
 
 
e446b1b
3d44974
 
e8003c4
 
 
95d5bed
3d44974
95d5bed
 
63a687d
218085c
e446b1b
63a687d
95d5bed
 
 
 
b28aa53
392dfe4
 
 
 
 
 
 
95d5bed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e446b1b
95d5bed
e446b1b
 
 
63a687d
392dfe4
 
63a687d
e446b1b
 
95d5bed
63a687d
95d5bed
 
 
 
 
 
 
 
63a687d
 
392dfe4
 
63a687d
95d5bed
 
 
218085c
95d5bed
 
 
 
 
 
 
 
 
 
45b18ac
 
 
 
 
 
218085c
 
 
 
95d5bed
 
218085c
95d5bed
 
144bf42
95d5bed
 
144bf42
3d44974
 
95d5bed
 
 
 
 
 
218085c
f09038e
 
 
 
 
 
 
 
 
 
 
 
 
95d5bed
 
3d44974
 
 
bd1a487
3d44974
 
 
 
 
 
 
 
 
 
63a687d
3d44974
63a687d
3d44974
 
 
 
 
 
 
63a687d
 
 
 
3d44974
 
 
 
 
 
 
 
 
 
 
 
 
95d5bed
 
218085c
95d5bed
 
 
bd1a487
95d5bed
 
 
 
 
 
 
 
 
 
 
 
 
63a687d
95d5bed
 
 
 
 
 
 
 
144bf42
 
 
45b18ac
144bf42
ffee684
 
144bf42
 
 
ffee684
 
 
144bf42
45b18ac
ffee684
144bf42
 
 
 
45b18ac
 
 
 
95d5bed
218085c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
"""
Voice Processing API для обработки аудио и извлечения данных о расходах.

Основной файл приложения Flask.
"""

from __future__ import annotations

import json
import os
import subprocess
import tempfile
import time
from datetime import date
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional

from flask import Flask, jsonify, request

# Импорт экстракторов
from extractors import (
    ExpenseDateExtractor,
    ExpenseSupplierExtractor,
    ExpenseUserExtractor,
    ExpenseAmountExtractor,
)
from expense_predictor import predict_expenses


# HuggingFace Token (если нужен для моделей)
HF_TOKEN = os.getenv("HF_TOKEN")

_WHISPER_MODEL: Optional[Any] = None
_HF_ASR_CLIENT: Optional[Any] = None
_LOCAL_WHISPER_READY = False
_REMOTE_ASR_READY = False


app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024

TEST_USERS = [
    "Олечка",
    "Влад",
]

TEST_SUPPLIERS = [
   "alimentara", "andy's pizza", "atlantis alexandri", "avandion srl", "belsug toys s.r.l.",
    "beauty factory", "berăria costin", "casa curată", "chibox", "cleber", "coffee dealer",
    "crafti", "curtea macelar", "danjan lux srl", "davidan", "döner kebab", "elica",
    "energocom", "eurotelecom", "farmacia familia", "fast food", "felicia", "fidesco",
    "filetti", "franzeluța", "giganet", "global store", "granier", "herb", "hippocrates",
    "iarmareco", "iherb", "iute credit", "iutecredit", "jardi market", "joom", "katana sushi",
    "katshop", "kaufland", "kebab", "kiss beauty salon", "letz", "linella", "linella 115",
    "local", "maestro", "maestro delice", "maximum", "medical market", "megapolis", "merci",
    "metro", "mikof", "modus vivendi", "moldovagaz", "moldovapresa", "moldpresa", "mozza",
    "nanu market", "nr 1", "ocean fish", "oldcom", "ovatia", "pandashop", "peach girl",
    "peon farm", "piața centrală", "pizza9", "premier energy", "primul discounter", "rogob",
    "salamer", "samurai", "sancos", "startur", "stomatologia familiei", "supraten", "tagaer",
    "takumi", "telemarket cricova", "temix", "temu", "valconi", "vasconi", "vatsak",
    "velmart", "volta", "welness & spa termal", "яндекс подписка",
]

TEST_PHRASES = [
    "Олюня оплатил в Велмарт 455,90 лей",
    "В Велмарт Олюня оплатил  455,90 лей",
    "Оля оплатила в Velmart 455,90 лей",
    "Через 3 дня Влад был в Wellness & spa Thermal на 1200,70 лей",
    "вчера Олечка заплатил в вольта 425,40 лей",
    "Сегодня Олюня купил в velmart на 755,50 лей",
    "Вчера Оля платил в vatsak 185,80 лей",
    "Сегодня был в Vasconi на 455,30 lei",
    "Вчера платил в Valconi 325,90 lei",
    "Сегодня заказал в тему на 895,60 лей",
    "Вчера купил в temix на 185,50 лей",
    "Сегодня оплатил в телемаркет Крикова 655,80 лей",
    "Вчера был в такуме на 425,7 лей",
    "Сегодня купил в tagaer на 285,40 лей",
    "Вчера оплатил в Supraten 1200,50 лень",
    "Сегодня был в стоматологии о фамилии на 455,90 лень",
    "Я на следующей неделе заказал в стартур билеты на 855,60 лень",
    "Сегодня оплатил в Sankos 245,70 лей",
    "Вчера купил в Samurai на 325,40 лей",
    "Сегодня был в Salomer на 185,50 lei",
    "Вчера купил vragob na 655,80 lei",
    "Сегодня купил в primul discounter на 425,03 лей",
    "Вчера оплатил Premier Energy 985,90 lei",
    "Сегодня заказали в пицце 9 на 285,60 лей",
    "Сегодня заказали в пицце 91 на 285,60 лей",
    "Сегодня заказали в пицце 912 на 285,60 лей",
    "На прошлой неделе ходили в piața centrală, купили на 455,7 lei",
    "Сегодня купил в peon farm на 325,40 лей",
    "Вчера Wallach купила в Peach Girl на 755,50 лей",
    "Через 2 дня купил в Pandashop на 895,80 лей",
    "Pazavchora był vivația i kupil na 185,30 lei",
    "Сегодня оплатил в oldcom 655,90 лей",
    "Вчера купил рыбу в Ocean Fish на 280 lei",
    "Сегодня купил в номер 1 на 420 лей",
    "вчера воля купила в nanu market на 250 lei",
    "Сегодня купил в Mozza на 380 lei",
    "Вчера оплатил moldpressa 90 lei",
    "Сегодня заплатил в Moldova-Presa 180 lei",
    "Вчера платил MoldovaGaz 1250 lei",
    "Сегодня был в modus vivendi, я ставил 420 lei",
    "Вчера купил в Micov na 150 lei",
    "Сегодня оплатил в метрах 890,13 лей",
    "Вчера купил в Мерси на 210 lei",
    "Сегодня заплатил в Megapolis 680 lei",
    "Вчера Оля купила лекарство в Medical Market на 340 лей",
    "Сегодня оплатил в максимум 450 lei",
    "Вчера купил десерт в maestro delice на 120 lei",
    "Сегодня оплатил в maestro 750 lei",
    "вчера оля купила в local на 190 лей",
    "Сегодня был в Linelo 115 и купил на 280 лей",
    "Вчера купил продукты в Linel на 420,55 лей",
    "Сегодня оплатил vats 320 lei",
    "Вчера Олечка была в Kiss Beauty Salon на 450 lei",
    "Сегодня купил кебаб в кебаб на 150 лей",
    "Вчера Оля была в Кауфленд и потратила 890,15 лей",
    "Сегодня купил в cat shop на 650 lei",
    "Вчера вечером был выкатан суши на 300 восьятлей",
    "Оля вчера заказала в Joom на 1200 lei",
    "Сегодня купили рассаду в Ярди Маркет на 280 лей",
    "Вчера Влад оплатил в uiti credit 950 lei",
    "Сегодня оплатил в U.T. Credit очередной платеж 1800 лей",
    "Вчера заказал в iherb витамина на 420 лей",
    "На прошлой неделе покупали в Ярмареку на 950,13 лей",
    "Оля вчера была в Хипократис и оставила 650 lei",
    "Сегодня я купил витамины в herb на 180 лей",
    "Вчера купил хлеб в Граньер на 70 лей",
    "Сегодня ходил в Global Store за техникой на 2100 лей",
    "Вчера я оплатил интернет в Giganet 450,35 лей",
    "Сегодня Оля купила хлеб Франзелуца на 80 петлей",
    "вчера купил рыбу в эфилете на 420 лей",
    "На прошлой неделе заплатил в Fidesco 1300 lei",
    "Сегодня Влад был в Феличи и купил сыр на 95 лей",
    "Вчера вечером купили fast food на 180 lei",
    "Олечка вчера купила лекарство фармачия Familia на 240 лей",
    "Я сегодня утром оплатил Eurotelicom 310 lei",
    "Вчера Владислав оплатил энергоком 560 lei",
    "Сегодня оплатил в Елика 420 лей",
    "На следующей неделе в субботу хочу зайти в дёйнер-кебаб",
]


def env_flag(name: str, default: bool = False) -> bool:
    """Парсит bool-флаг из переменных окружения."""
    raw = os.getenv(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def get_whisper_backend() -> str:
    """Возвращает активный backend для STT."""
    backend = (os.getenv("WHISPER_BACKEND") or "auto").strip().lower()
    if backend not in {"auto", "hf-inference", "local"}:
        return "auto"
    return backend


def should_use_remote_asr() -> bool:
    """Определяет, можно ли использовать HF Inference для ASR."""
    backend = get_whisper_backend()
    return backend in {"auto", "hf-inference"} and bool(HF_TOKEN)


def should_use_local_asr() -> bool:
    """Определяет, можно ли использовать локальный ASR."""
    backend = get_whisper_backend()
    if backend == "hf-inference" and not HF_TOKEN:
        return True
    return backend in {"auto", "local"}


def get_hf_asr_client() -> Any:
    """Возвращает клиент HF Inference для ASR."""
    global _HF_ASR_CLIENT

    if _HF_ASR_CLIENT is None:
        from huggingface_hub import InferenceClient

        provider = os.getenv("WHISPER_REMOTE_PROVIDER", "hf-inference")
        timeout = float(os.getenv("WHISPER_REMOTE_TIMEOUT", "15"))
        _HF_ASR_CLIENT = InferenceClient(
            provider=provider,
            api_key=HF_TOKEN,
            timeout=timeout,
        )
        print(f"[INFO] hf inference client ready: provider={provider}, timeout={timeout}s")

    return _HF_ASR_CLIENT


def warmup_local_whisper_model() -> None:
    """Прогревает локальную faster-whisper модель один раз."""
    global _LOCAL_WHISPER_READY
    if _LOCAL_WHISPER_READY:
        return

    started = time.time()
    model = get_whisper_model()
    print(f"[TIMINGS] whisper_preload: {round(time.time() - started, 3)}s")

    import struct
    import wave

    warmup_path = "/tmp/_whisper_warmup.wav"
    try:
        with wave.open(warmup_path, "w") as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(16000)
            wf.writeframes(struct.pack("<" + "h" * 3200, *([0] * 3200)))

        wt0 = time.time()
        segments, _ = model.transcribe(
            warmup_path,
            language="ru",
            beam_size=1,
            condition_on_previous_text=False,
            vad_filter=False,
        )
        _ = list(segments)
        print(f"[TIMINGS] whisper_warmup: {round(time.time() - wt0, 3)}s")
        _LOCAL_WHISPER_READY = True
    finally:
        if os.path.exists(warmup_path):
            os.unlink(warmup_path)


def ensure_asr_ready(include_local: bool = False) -> dict[str, bool]:
    """Инициализирует доступные ASR backend-ы без повторного прогрева."""
    global _REMOTE_ASR_READY

    remote_ready = False
    local_ready = _LOCAL_WHISPER_READY

    if should_use_remote_asr():
        get_hf_asr_client()
        _REMOTE_ASR_READY = True
        remote_ready = True

    if include_local and should_use_local_asr() and env_flag("WHISPER_PRELOAD_ON_HEALTH", default=True):
        warmup_local_whisper_model()
        local_ready = _LOCAL_WHISPER_READY

    return {
        "remote_ready": remote_ready or _REMOTE_ASR_READY,
        "local_ready": local_ready,
    }


def preprocess_audio_for_asr(audio_path: str) -> tuple[str, Optional[str]]:
    """Приводит аудио к 16k mono wav для более стабильного и быстрого STT."""
    if not env_flag("WHISPER_PREPROCESS_AUDIO", default=True):
        return audio_path, None

    fd, prepared_path = tempfile.mkstemp(suffix=".wav")
    os.close(fd)

    command = [
        "ffmpeg",
        "-y",
        "-i",
        audio_path,
        "-ar",
        "16000",
        "-ac",
        "1",
        "-vn",
        prepared_path,
    ]

    try:
        subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        return prepared_path, prepared_path
    except Exception:
        if os.path.exists(prepared_path):
            os.unlink(prepared_path)
        return audio_path, None


def transcribe_audio_remote(audio_path: str) -> tuple[str, float]:
    """Транскрибирует аудио через HF Inference."""
    started = time.time()
    client = get_hf_asr_client()
    model_id = os.getenv("WHISPER_REMOTE_MODEL", "openai/whisper-large-v3")

    result = client.automatic_speech_recognition(audio=audio_path, model=model_id)
    text = (getattr(result, "text", None) or "").strip()
    elapsed = round(time.time() - started, 3)
    print(f"[TIMINGS] whisper_transcribe_remote: {elapsed}s")
    if text:
        return text, elapsed

    raise RuntimeError("HF Inference returned empty transcription.")


def transcribe_audio_local(audio_path: str) -> tuple[str, float]:
    """Транскрибирует аудио локально через faster-whisper."""
    started = time.time()
    model = get_whisper_model()
    beam_size = max(1, int(os.getenv("WHISPER_NUM_BEAMS", "1")))
    vad_filter = env_flag("WHISPER_VAD_FILTER", default=False)

    segments, _ = model.transcribe(
        audio_path,
        language="ru",
        beam_size=beam_size,
        condition_on_previous_text=False,
        vad_filter=vad_filter,
    )
    text = "".join(segment.text for segment in segments).strip()
    elapsed = round(time.time() - started, 3)
    print(f"[TIMINGS] whisper_transcribe_local: {elapsed}s")
    if text:
        return text, elapsed

    raise RuntimeError("Local faster-whisper returned empty transcription.")


def get_whisper_model() -> Any:
    """Возвращает faster-whisper WhisperModel (ленивая загрузка)."""
    global _WHISPER_MODEL

    if _WHISPER_MODEL is None:
        from faster_whisper import WhisperModel

        model_id = os.getenv("WHISPER_MODEL", "deepdml/faster-whisper-large-v3-ct2")
        cpu_threads = max(1, int(os.getenv("WHISPER_CPU_THREADS", "2")))

        _WHISPER_MODEL = WhisperModel(
            model_id,
            device="cpu",
            compute_type="int8",
            cpu_threads=cpu_threads,
            num_workers=1,
        )
        print(f"[INFO] faster-whisper loaded: model={model_id}, threads={cpu_threads}")

    return _WHISPER_MODEL


@lru_cache(maxsize=32)
def build_cached_pipeline(suppliers_key: tuple[str, ...], users_key: tuple[str, ...]) -> ExpenseTextExtractor:
    """Кэширует экстрактор для повторяющихся наборов suppliers/users."""
    return ExpenseTextExtractor(suppliers=list(suppliers_key), users=list(users_key))


class ExpenseTextExtractor:
    """
    Главный экстрактор данных о расходах.
    
    Комбинирует все экстракторы: даты, поставщики, пользователи, суммы.
    """
    
    def __init__(self, suppliers: list[str], users: list[str]) -> None:
        self.date_extractor = ExpenseDateExtractor()
        self.supplier_extractor = ExpenseSupplierExtractor(suppliers=suppliers)
        self.amount_extractor = ExpenseAmountExtractor(suppliers=suppliers)
        self.user_extractor = ExpenseUserExtractor(users=users, suppliers=suppliers)

    def extract(
        self,
        text: str,
        reference_date: str | date | None = None,
        debug: bool = False,
    ) -> dict[str, Any]:
        """Извлекает все данные из текста."""
        timings: dict[str, float] = {}

        t0 = time.time()
        date_info = self.date_extractor.extract(text, reference_date=reference_date, debug=debug)
        timings["date_extractor"] = round(time.time() - t0, 3)
        if debug:
            print(f"[DEBUG][DATE] {date_info}")

        t0 = time.time()
        supplier_info = self.supplier_extractor.extract(
            text,
            date_phrase=date_info.get("matched_date_phrase"),
            debug=debug,
        )
        timings["supplier_extractor"] = round(time.time() - t0, 3)
        if debug:
            print(f"[DEBUG][SUPPLIER] {supplier_info}")

        t0 = time.time()
        user_info = self.user_extractor.extract(
            text,
            supplier_phrase=supplier_info.get("matched_supplier_phrase"),
            date_phrase=date_info.get("matched_date_phrase"),
            debug=debug,
        )
        timings["user_extractor"] = round(time.time() - t0, 3)
        if debug:
            print(f"[DEBUG][USER] {user_info}")

        t0 = time.time()
        amount_info = self.amount_extractor.extract(
            text,
            matched_date_phrase=date_info["matched_date_phrase"],
            matched_supplier_phrase=supplier_info["matched_supplier_phrase"],
            debug=debug,
        )
        timings["amount_extractor"] = round(time.time() - t0, 3)
        if debug:
            print(f"[DEBUG][AMOUNT] {amount_info}")

        if debug:
            print(f"[TIMINGS] {timings}")

        result = {
            "text": text,
            "user": user_info["user"],
            "supplier": supplier_info["supplier"],
            "amount": amount_info["amount"],
            "date": date_info["date"],
            "date_iso": date_info["date_iso"],
        }

        if debug:
            result["debug"] = {
                "timings": timings,
                "date": date_info.get("date_debug"),
                "supplier": supplier_info.get("supplier_debug"),
                "user": user_info.get("user_debug"),
                "amount": amount_info.get("amount_debug"),
            }

        return result


def build_default_pipeline(suppliers: list[str], users: list[str]) -> ExpenseTextExtractor:
    """Создаёт пайплайн извлечения данных."""
    suppliers_key = tuple(item for item in suppliers if item)
    users_key = tuple(item for item in users if item)
    return build_cached_pipeline(suppliers_key, users_key)


def extract_names(items: Any) -> list[str]:
    """Извлекает имена из списка объектов или строк."""
    if not isinstance(items, list):
        return []

    names: list[str] = []
    for item in items:
        if isinstance(item, dict):
            name = item.get("name")
            if isinstance(name, str) and name.strip():
                names.append(name.strip())
            continue

        if isinstance(item, str) and item.strip():
            names.append(item.strip())

    return names


def polish_notes_text(text: str) -> str:
    """Форматирует текст заметки."""
    import re
    normalized = re.sub(r"\s+", " ", text).strip()
    if not normalized:
        return ""

    normalized = normalized[0].upper() + normalized[1:]
    if normalized[-1] not in ".!?":
        normalized += "."

    return normalized


def transcribe_audio_text(audio_path: str) -> tuple[str, float]:
    """Транскрибирует аудио в текст. Возвращает (текст, время в секундах)."""
    mock_text = os.getenv("EXPENSE_VOICE_MOCK_TEXT")
    if mock_text:
        return mock_text.strip(), 0.0

    prepared_audio_path, prepared_temp_path = preprocess_audio_for_asr(audio_path)

    try:
        if should_use_remote_asr():
            try:
                text, elapsed = transcribe_audio_remote(prepared_audio_path)
                print(f"[TIMINGS] whisper_backend: hf-inference")
                return text, elapsed
            except Exception as remote_error:
                print(f"[WARN] Remote ASR failed: {remote_error}")
                if not should_use_local_asr():
                    raise

        if should_use_local_asr():
            text, elapsed = transcribe_audio_local(prepared_audio_path)
            print(f"[TIMINGS] whisper_backend: local")
            return text, elapsed
    except Exception as e:
        print(f"[ERROR] Whisper transcribe failed: {e}")
    finally:
        if prepared_temp_path and os.path.exists(prepared_temp_path):
            os.unlink(prepared_temp_path)

    raise RuntimeError("Speech-to-text backend is unavailable.")


def process_voice_request(audio_path: str, mode: str, payload: dict[str, Any], debug: bool = False) -> dict[str, Any]:
    """Обрабатывает голосовой запрос."""
    total_start = time.time()

    context = payload.get("context", {}) if isinstance(payload, dict) else {}
    supplier_names = extract_names(context.get("suppliers"))
    user_names = extract_names(context.get("users"))

    transcript, whisper_time = transcribe_audio_text(audio_path)
    if debug:
        print(f"[DEBUG][TRANSCRIPT] {transcript}")
        print(
            f"[DEBUG][CONTEXT] suppliers_count={len(supplier_names)}, users_count={len(user_names)}"
        )
        print(f"[DEBUG][SUPPLIERS] {supplier_names}")
        print(f"[DEBUG][USERS] {user_names}")

    if mode == "notes":
        notes = polish_notes_text(transcript)
        return {
            "status": "ok",
            "text": transcript,
            "notes": notes,
            "supplier": None,
            "user": None,
            "date": None,
            "sum": None,
        }

    if not supplier_names:
        raise RuntimeError("No suppliers were provided by Laravel context.")

    if not user_names:
        raise RuntimeError("No users were provided by Laravel context.")

    t0 = time.time()
    extractor = build_default_pipeline(suppliers=supplier_names, users=user_names)
    pipeline_init_time = round(time.time() - t0, 3)
    print(f"[TIMINGS] pipeline_init: {pipeline_init_time}s")
    
    extracted = extractor.extract(transcript, reference_date=date.today().isoformat(), debug=debug)
    if debug:
        print(f"[DEBUG][EXTRACTED_RAW] {extracted}")

    total_time = round(time.time() - total_start, 3)
    print(f"[TIMINGS] TOTAL: {total_time}s (whisper: {whisper_time}s)")

    payload = {
        "status": "ok",
        "text": transcript,
        "notes": polish_notes_text(extracted.get("text") or transcript),
        "supplier": extracted.get("supplier"),
        "user": extracted.get("user"),
        "date": extracted.get("date_iso") or extracted.get("date"),
        "sum": extracted.get("amount"),
    }
    if debug and extracted.get("debug"):
        payload["debug"] = extracted.get("debug")
    if debug:
        print(f"[DEBUG][RESPONSE_PAYLOAD] {payload}")
    return payload


def parse_context(raw: str | None) -> dict[str, Any]:
    """Парсит JSON контекст."""
    if not raw:
        return {}

    try:
        payload = json.loads(raw)
        return payload if isinstance(payload, dict) else {}
    except json.JSONDecodeError:
        return {}


def parse_json_payload() -> dict[str, Any]:
    """Возвращает JSON payload из входящего запроса."""
    payload = request.get_json(silent=True)
    return payload if isinstance(payload, dict) else {}


# ============================================================================
# ENDPOINTS
# ============================================================================

@app.get("/")
def index():
    """Главная страница API."""
    return jsonify({
        "status": "ok",
        "message": "Expense Processing API is running",
        "endpoints": {
            "POST /process-audio": "Process audio file",
            "POST /predict-expenses": "Predict next 3 expenses based on history",
            "GET /health": "Health check",
            "GET /test-data": "Run text-only extraction tests"
        }
    })


@app.get("/health")
def health():
    """Проверка здоровья сервиса."""
    try:
        readiness = ensure_asr_ready(include_local=True)
        return jsonify({
            "status": "ok",
            "stt": {
                "backend": get_whisper_backend(),
                "remote_enabled": should_use_remote_asr(),
                "local_enabled": should_use_local_asr(),
                **readiness,
            },
        })
    except Exception as exception:
        return jsonify({"status": "error", "message": str(exception)}), 503


@app.get("/test-data")
def test_data():
    """Тестирует извлечение данных из текста без использования Whisper."""
    debug = (request.args.get("debug") or "").strip().lower() == "1"
    extractor = build_default_pipeline(suppliers=TEST_SUPPLIERS, users=TEST_USERS)

    started = time.time()
    results: list[dict[str, Any]] = []

    for phrase in TEST_PHRASES:
        item_started = time.time()
        extracted = extractor.extract(
            phrase,
            reference_date=date.today().isoformat(),
            debug=debug,
        )
        row = {
            "text": phrase,
            "user": extracted.get("user"),
            "supplier": extracted.get("supplier"),
            "amount": extracted.get("amount"),
            "date": extracted.get("date"),
            "date_iso": extracted.get("date_iso"),
            "processing_time": round(time.time() - item_started, 3),
        }
        if debug and extracted.get("debug"):
            row["debug"] = extracted.get("debug")
        results.append(row)

    return jsonify({
        "status": "ok",
        "mode": "text-only",
        "reference_date": date.today().isoformat(),
        "phrases_count": len(TEST_PHRASES),
        "suppliers_count": len(TEST_SUPPLIERS),
        "users_count": len(TEST_USERS),
        "total_processing_time": round(time.time() - started, 3),
        "results": results,
    })


@app.post("/process-audio")
def process_audio():
    """Обработка аудио файла."""

    audio = request.files.get("audio")
    mode = (request.form.get("mode") or "expense").strip()
    debug = (request.args.get("debug") or "") == "1"
    context = parse_context(request.form.get("context"))

    if audio is None:
        return jsonify({"status": "error", "message": "Audio file is required."}), 422

    suffix = Path(audio.filename or "voice.webm").suffix or ".webm"
    temp_path = None

    try:
        with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
            temp_path = temp_file.name
            audio.save(temp_file)

        result = process_voice_request(audio_path=temp_path, mode=mode, payload={"context": context}, debug=debug)
        return jsonify(result)
    except Exception as exception:
        return jsonify({"status": "error", "message": str(exception)}), 422
    finally:
        if temp_path and os.path.exists(temp_path):
            os.unlink(temp_path)


@app.post("/predict-expenses")
def predict_expenses_endpoint():
    """Predicts top 3 expenses user should add based on 6-month history."""
    payload = parse_json_payload()
    expenses = payload.get("expenses") or []
    user_id = payload.get("user_id")
    debug = (request.args.get("debug") or request.args.get("debut") or "").strip().lower() == ""
    
    if not isinstance(expenses, list):
        return jsonify({"status": "error", "message": "expenses must be a list"}), 422

    if user_id is None:
        return jsonify({"status": "error", "message": "user_id is required"}), 422
    
    try:
        predictions = predict_expenses(expenses, target_user_id=user_id, debug=debug)
        return jsonify({
            "status": "ok",
            "predictions": predictions
        })
    except Exception as exception:
        return jsonify({"status": "error", "message": str(exception)}), 422


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=int(os.getenv("PORT", "7860")))