"""
thai filler word classifier for ingfah.ai voice bot.
picks the right acknowledgment filler to play instantly while llm thinks.
uses e5-small embeddings with centroid-based classification.
"""

__version__ = "0.5.0"

import re
import time
import numpy as np
from sentence_transformers import SentenceTransformer

try:
    from pythainlp.tokenize import word_tokenize as _thai_tokenize
    def _tokenize(text: str) -> list[str]:
        return _thai_tokenize(text)
except ImportError:
    # fallback: naive regex split on thai char boundaries
    def _tokenize(text: str) -> list[str]:
        return [t for t in re.findall(r'\S+', text) if t]


# anchor phrases - customer/caller pov (what they say to us)
# e5 requires "query: " prefix for encoding queries
CATEGORY_ANCHORS = {
    "complaint": [
        # profanity / cursing
        "พ่อมึงตายหรอไอ้สัส",
        "ไอ้เหี้ย",
        "เอไอหีหมา",
        "แม่เย็ด",
        "เอากับกูเปล่า",
        "ไอ้สัตว์",
        "ไอ้ควาย",
        "กูโกรธมากเลยวะ",
        "มึงทำอะไรอยู่วะ",
        "ห่าอะไรวะ",
        "เฮงซวยมาก",
        "บ้าเหี้ย",
        "สัส",
        "ไอ้บ้า",
        "ควย ไอ้เหี้ย",
        "หัวควย",
        "ลูกกระหรี่",
        "ควยเอ๊ย บริการห่วยแตก",
        "โง่ชิบหาย",
        "หมาไม่แดก",
        "ไอ้ระยำ",
        "ปากหมา",
        "ไปตายซะ",
        "ไอ้สัตว์นรก",
        "โง่เป็นควาย",
        "เชี่ยเอ๊ย",
        "ไอ้ชาติหมา",
        "ไอ้เปรต",
        "ไอ้สถุน",
        "ไอ้กระจอก",
        "ไอ้ขี้กาก",
        "ชิบหาย",
        "เสือก",
        "อย่ามาเสือก",
        "ไม่ต้องมาเสือก",
        "เสือกอะไรวะ",
        "อย่าเสือก",
        "กระหรี่",
        "ไอ้หน้าด้าน",
        "ไอ้หน้าโง่",
        # angry / frustrated (no profanity)
        "ใช้งานไม่ได้เลย",
        "มีปัญหาครับ",
        "เสียแล้ว",
        "ไม่พอใจเลย",
        "ทำไมเป็นแบบนี้",
        "แย่มากเลยครับ",
        "หงุดหงิดมาก",
        "โทรมาหลายรอบแล้ว",
        "ยังไม่ได้แก้เลย",
        "รอนานมากครับ",
        "เน็ตหลุดตลอด",
        "ใช้ไม่ได้มาสามวันแล้ว",
        "เบื่อมากเลยครับ",
        "บริการแย่มาก",
        "ผิดหวังมากครับ",
        "จะฟ้องสคบ",
        "ขอเรื่องร้องเรียน",
        "จะยกเลิกเลย",
        "ไม่มีใครช่วยเลย",
        "โทรมาทั้งวัน",
        # threats / escalation (no profanity)
        "ฟังฉันให้ดีนะ",
        "ฉันจะเอาเรื่องนี้ให้ถึงที่สุด",
        "คุณทำให้ฉันรู้สึกเหมือนถูกดูถูก",
        "จะแจ้งความเลยนะ",
        "จะโพสต์ลงโซเชียลเลย",
        "ขอคุยกับหัวหน้า",
        "ขอพูดกับผู้จัดการ",
        "ไม่ยอมรับได้เลย",
        "นี่มันเรื่องอะไรกัน",
        "คุณทำงานกันยังไงวะ",
        "นี่มันการบริการลูกค้าเหรอวะ",
        "ทำไมถึงแก้ไม่ได้สักที",
        "จะร้องเรียนไปกสทช",
        "พูดแล้วไม่ทำตาม",
        "สัญญาไว้แล้วไม่ทำ",
        "เสียเวลาเปล่า",
        "ทำไมปล่อยให้ลูกค้ารอ",
        "รับผิดชอบหน่อยสิ",
        # rhetorical questions (angry, not real questions)
        "คุณทำงานกันยังไงวะ",
        "นี่เรียกบริการเหรอ",
        "จะให้ทนอีกนานแค่ไหน",
        "ทำไมถึงทำกับลูกค้าแบบนี้",
        # passive-aggressive / condescending complaints
        "ผมว่าคุณคุยไม่ค่อยรู้เรื่องนะครับ",
        "คุณเข้าใจที่ผมพูดไหม",
        "พูดไปก็เหมือนพูดกับกำแพง",
        "คุณฟังผมพูดอยู่หรือเปล่า",
        "คุณช่วยอะไรได้จริงไหม",
        "คุณรู้เรื่องที่พูดไหมครับ",
    ],
    "question": [
        "อยากถามว่า",
        "คือว่า",
        "ช่วยอธิบายได้ไหม",
        "สอบถามหน่อยครับ",
        "อยากรู้ว่า",
        "มีโปรอะไรบ้างครับ",
        "ราคาเท่าไหร่ครับ",
        "ทำยังไงครับ",
        "ต้องทำอะไรบ้าง",
        "เงื่อนไขเป็นยังไงครับ",
        "คิดค่าบริการยังไง",
        "ขอถามเรื่องบิลหน่อย",
        "มีวิธีไหนบ้าง",
        "แพ็กเกจไหนดีครับ",
        "ช่วยเช็คให้หน่อยได้ไหม",
        "ป้ายสาขาดูได้จากตรงไหนครับ",
        "มองไปดูตรงไหนได้ครับ",
        "ดูยังไงครับ",
        "หาเจอตรงไหนครับ",
        # problem + question pattern (describing issue then asking)
        "อินเทอร์เน็ตช้ามาก มีวิธีแก้ไขไหมคะ",
        "ทำไมสัญญาณโทรศัพท์ถึงไม่ค่อยมีคะ",
        "ซิมการ์ดหาย ต้องทำยังไงคะ",
        "ทำไมเน็ตช้าจังเลยคะ",
        "ทำไมสัญญาณอ่อนจังเลยคะ",
        "ใช้เน็ตหมดเร็วมากเลยค่ะ",
        "สัญญาณโทรศัพท์ไม่ค่อยดีเลยค่ะ",
        "มีปัญหาเรื่องอินเทอร์เน็ตค่ะ",
        "เน็ตบ้านใช้ไม่ได้มาหลายวันแล้วค่ะ",
        "สินค้าที่สั่งไปนานแล้ว ทำไมยังไม่ได้รับคะ",
        # service inquiry questions
        "ต้องการระงับการใช้งานชั่วคราวได้ไหมคะ",
        "ถ้าไม่ใช้บัตร จะยกเลิกได้ไหมครับ",
        "ฉันอยากจะยกเลิกบริการ ต้องติดต่อที่ไหนคะ",
        "บริการเสริมนี้เสียค่าใช้จ่ายเพิ่มไหมคะ",
        "ฉันลืมรหัสผ่านเข้าระบบ ต้องกู้คืนยังไงคะ",
        "สัญญาหมดเมื่อไหร่คะ",
        "ถ้าไม่ต่อสัญญา จะเป็นอะไรไหมคะ",
        "ต้องรอนานไหมคะ",
        "ถ้าจ่ายช้าจะเป็นอะไรไหมคะ",
        "ถ้าจะยกเลิกต้องเสียค่าอะไรไหมคะ",
        "เปลี่ยนจาก AIS ไป TRUE ต้องทำยังไง",
        "โปรนี้ใช้เล่น TikTok ได้ไหมคะ",
    ],
    "no_filler": [
        # incomplete / trailing-off speech -- customer hasn't finished their thought yet
        # NOTE: เอ่อ = unambiguous hesitation. อืม alone excluded (doubles as agreement).
        # standalone เอ่อ (pure hesitation, nothing else said)
        "เอ่อ",
        "เอ่อ...",
        "เอ่อ เอ่อ",
        # polite particle + เอ่อ hesitation (clearly mid-thought, NOT followed by completion)
        "ครับ เอ่อ",
        "ค่ะ เอ่อ",
        "นะครับ เอ่อ",
        "นะคะ เอ่อ",
        # agreement + เอ่อ hesitation (said yes then trailed off with hesitation sound)
        "ได้ครับ เอ่อ",
        "ได้ค่ะ เอ่อ",
        "โอเคครับ เอ่อ",
        "โอเคค่ะ เอ่อ",
        "ตกลงครับ เอ่อ",
        "ใช่ครับ เอ่อ",
        "ใช่ค่ะ เอ่อ",
        # "wait a sec" markers (customer explicitly requesting pause)
        "แปปนะครับ เอ่อ",
        "แปปนะคะ เอ่อ",
        "แปปนะครับ",
        "แปปนะคะ",
        "สักครู่นะครับ เอ่อ",
        "สักครู่นะคะ เอ่อ",
        "รอแปปนะครับ",
        "รอแปปนะคะ",
        "รอสักครู่นะครับ",
        # "thinking" markers (explicit think request + trailing off)
        "คิดแปป เอ่อ",
        "คิดก่อนนะครับ เอ่อ",
        "คิดก่อนนะ เอ่อ",
        "ขอคิดก่อนนะครับ",
        "ขอคิดแปปนะครับ",
        "ขอนึกก่อนนะครับ",
        "นึกก่อนนะครับ เอ่อ",
        "ขอคิดดูก่อนนะครับ เอ่อ",
        # topic intro without completing (sentence clearly unfinished)
        "คือว่า เอ่อ",
        "ก็คือ เอ่อ",
        "นั่นคือ เอ่อ",
        "คือ เอ่อ",
        "พอดีว่า เอ่อ",
        "พอดี เอ่อ",
        "อยากถามว่า เอ่อ",
        "อยากถาม เอ่อ",
        "สอบถาม เอ่อ",
        "ต้องการ เอ่อ",
        "ต้องการจะ เอ่อ",
        "มีเรื่อง เอ่อ",
    ],
    "default": [
        # acknowledgment, agreement, greeting, decline, info-giving all go here
        "รับทราบครับ",
        "โอเค รับทราบ",
        "ทราบแล้วครับ",
        "เข้าใจแล้วครับ",
        "ได้ครับ",
        "ตกลงครับ",
        "โอเคครับ",
        "ได้เลยครับ",
        "สวัสดีครับ",
        "หวัดดีครับ",
        "ไม่เอาครับ",
        "ไม่ต้องครับ",
        "สาขา 182 ครับ",
        "100245 ครับ",
        "หมายเลข 5678 ครับ",
        "ชื่อ สมชาย ครับ",
        "ถูกต้องครับ",
        "ครับ ติดต่อเบอร์นี้ได้เลย",
        "เอาครับ",
        # polite requests (contain question words but are service requests)
        "ช่วยเปลี่ยนแพ็กเกจให้หน่อยได้ไหมคะ",
        "รบกวนเช็คให้หน่อยค่ะ",
        "ช่วยแนะนำหน่อยได้ไหมคะ",
        "ขอเปลี่ยนเบอร์ได้ไหมครับ",
        "รบกวนช่วยดูให้หน่อยนะคะ",
        "ช่วยโอนสายให้หน่อยได้ไหม",
        "ขอยกเลิกบริการเสริมได้ไหมคะ",
        "รบกวนส่งรายละเอียดมาให้หน่อยค่ะ",
        "ช่วยตรวจสอบยอดค้างชำระหน่อยได้ไหม",
        "ขอสมัครโปรใหม่ได้ไหมครับ",
        "รบกวนแก้ไขข้อมูลให้หน่อยค่ะ",
        "ช่วยเช็คสถานะให้หน่อยได้ไหมคะ",
        "ช่วยแนะนำหน่อยได้ไหมคะ",
        "ช่วยแนะนำหน่อยครับ",
        "แนะนำให้หน่อยได้ไหม",
        "ช่วยแนะนำโปรหน่อยค่ะ",
        # polite declines
        "ไม่เอาค่ะ",
        "ไม่เอาครับ",
        "ไม่เอาแล้วค่ะ",
        "ไม่เอาแล้วครับ",
        "ไม่สนใจค่ะ",
        "ไม่สนใจครับ",
        "ไม่เป็นไรค่ะ",
        "ไม่เป็นไรครับ",
        "ยังไม่เอาค่ะ",
        "ไม่ล่ะค่ะ",
        # statements (not questions, not complaints)
        "ขอโทษนะคะ เมื่อกี้พูดว่าอะไรนะคะ",
        "เน็ตหลุดบ่อยมากเลยค่ะ",
        "ที่บ้านไม่มีสัญญาณเลยค่ะ",
        "อยากได้ความเร็วเน็ตที่เสถียรกว่านี้",
        "พอดีไม่ค่อยเข้าใจค่ะ",
        "ผมชื่ออะไรนะครับ",
        "ชื่ออะไรนะคะ",
        "ผมชื่ออะไรครับ",
        # polite declines (extended)
        "ไม่เอาดีกว่าครับ",
        "ไม่เอาดีกว่าค่ะ",
        "เอาไว้ก่อนดีกว่าครับ",
        "ไว้คราวหน้าดีกว่าค่ะ",
        "ยังไม่เอาดีกว่า",
        "ขอคิดดูก่อนครับ",
        "ขอคิดดูก่อนค่ะ",
        # rescheduling / alternative requests
        "เป็นวันพรุ่งนี้แทนได้มั้ย",
        "เลื่อนเป็นวันอื่นได้ไหมครับ",
        "เปลี่ยนเวลาได้มั้ยคะ",
        "ขอเลื่อนนัดหน่อยครับ",
        "พรุ่งนี้ได้ไหมครับ",
        "เอาเป็นอาทิตย์หน้าได้มั้ย",
        "นัดวันอื่นได้มั้ยครับ",
        "เปลี่ยนวันได้มั้ยคะ",
        "เป็นวันพรุ่งนี้แทนได้มั้ยครับ",
        "เป็นวันพรุ่งนี้แทนได้มั้ยคะ",
        # realization / remembering
        "อ่อออ ผมลืมไปเลย",
        "อ๋อ ลืมไปเลยค่ะ",
        "อ่อ จริงด้วย",
        "อ๋อ นึกออกแล้วครับ",
        "เพิ่งนึกได้ค่ะ",
        "อ่อ ใช่ๆ ลืมไปเลย",
        # complete agreements with follow-up word (not trailing off)
        "ได้ครับ ตกลงครับ",
        "ได้ค่ะ ตกลงค่ะ",
        "ได้ครับ ตกลง",
        "ได้ ตกลงครับ",
        "โอเค ตกลงครับ",
        "โอเค ตกลงค่ะ",
        "ใช่ครับ ตกลงครับ",
        "ตกลงเลยครับ",
        "ตกลงเลยค่ะ",
        "ได้เลยครับ ตกลง",
        # อืม as complete acknowledgment (not trailing off)
        "อืม ค่ะ",
        "อืม ครับ",
        "อืมค่ะ",
        "อืมครับ",
        "อืมมม ค่ะ",
        "อืม เข้าใจแล้วค่ะ",
        "อืม เข้าใจแล้วครับ",
        # เอ่อ followed by polite completion particle (utterance ended, not trailing off)
        "เอ่อ ค่ะ",
        "เอ่อ ครับ",
        "เอ่อ... ค่ะ",
        "เอ่อ... ครับ",
        # เออ (soft agreement, complete utterance)
        "เออ",
        "เออ ได้ครับ",
        "เออ ได้ค่ะ",
        "เออ...ผมว่า",
        # short complete agreements (no hesitation marker -- fully done speaking)
        "ใช่ค่ะ",
        "ใช่ครับ",
        "ใช่ค่ะ ค่ะ",
        "ใช่ครับ ครับ",
        "ใช่ค่ะ/ใช่ครับ",
        "ใช่ครับ/ค่ะ",
        "แน่นอนค่ะ",
        "แน่นอนครับ",
        "แน่นอนค่ะ/แน่นอนครับ",
        "เข้าใจค่ะ",
        "เข้าใจครับ",
        "ถูกต้องค่ะ",
        "ถูกต้องครับ",
        "ตกลงค่ะ",
        "ตกลงครับ",
        # ค่ะ + clear completion (not trailing off)
        "ค่ะ โอเคค่ะ",
        "ค่ะ ตามนั้นค่ะ",
        "ค่ะ ตามนั้นเเล้วกันค่ะ",
        "ค่ะ ใช่ค่ะ ถูกต้องค่ะ",
        "ค่ะ แค่นี้นะคะ",
        "ครับ แค่นี้นะครับ",
        "ใช่ค่ะ ตามนั้นเลย",
        "ใช่ค่ะ เข้าใจแล้วค่ะ",
        "ใช่เเล้วค่ะอันนั้นถูกต้อง ค่ะ",
        # wait-a-moment with completion particle (complete polite request)
        "แป๊บนึงนะคะ",
        "แป๊บนึงนะครับ",
        "รอแป๊บนึงนะคะ",
    ],
}

# filler phrases mapped to each category (bot response fillers)
# no_filler is intentionally empty -- caller should NOT play any audio
CATEGORY_FILLERS = {
    "complaint": ["ขออภัยด้วยน่ะคะ"],
    "question": ["สักครู่นะคะ", "สักครู่ค่ะ", "ตรวจสอบให้นะคะ"],
    "default": ["รับทราบค่ะ", "ค่ะ เข้าใจค่ะ", "ค่ะ รับทราบค่ะ", "ค่ะ คุณลูกค้า"],
    "no_filler": [],
}


# tokens that are ambiguous on their own -- could be a complete answer or
# just an acknowledgment before the real answer.  needs bot context (cog_level)
# to decide correctly.
AMBIGUOUS_TOKENS = {"ครับ", "ค่ะ", "เอ่อ", "อืม", "เอ้อ", "อ่า", "ฮะ", "ฮ่ะ", "เออ", "อ้า"}


class FillerClassifier:
    """
    embed-based filler classifier.
    computes category centroids from anchor phrases, then classifies
    new input by cosine similarity to centroids.
    """

    def __init__(self, model: SentenceTransformer | None = None, model_name: str = "intfloat/multilingual-e5-small"):
        if model is None:
            model = SentenceTransformer(model_name)
        self.model = model
        self.categories = list(CATEGORY_ANCHORS.keys())
        self.centroids = self._compute_centroids()
        # round-robin counters per category
        self._rr_counters = {cat: 0 for cat in self.categories}

    def _compute_centroids(self) -> np.ndarray:
        """embed all anchors per category, average to get centroids."""
        centroids = []
        for cat in self.categories:
            # e5 models need "query: " prefix
            phrases = [f"query: {p}" for p in CATEGORY_ANCHORS[cat]]
            embeddings = self.model.encode(phrases, normalize_embeddings=True)
            centroid = np.mean(embeddings, axis=0)
            # normalize the centroid
            centroid = centroid / np.linalg.norm(centroid)
            centroids.append(centroid)
        return np.array(centroids)

    # completion particles: when text ends with one of these, the speaker FINISHED their thought
    _COMPLETION_PARTICLES = ("ค่ะ", "ครับ", "ครับผม", "ค่ะๆ", "ครับๆ")
    # thinking/waiting markers: if present, keep no_filler even when text ends with completion particle
    _THINKING_MARKERS = ("คิด", "นึก", "รอ", "แปป", "สักครู่", "จำ", "เดี๋ยว")

    def classify(self, text: str, cog_level: str | None = None) -> tuple[str, float, str | None]:
        """
        classify input text.
        returns (category, confidence, filler_phrase).
        filler_phrase is None when category is 'no_filler' -- caller should skip playback.

        cog_level: cognitive load of the bot's preceding question.
          - "high" = question requiring thought (preference, decision, data recall).
                     bare ambiguous tokens like "ครับ" -> no_filler (user still thinking).
          - "low"  = simple yes/no or confirmation question.
                     bare ambiguous tokens like "ครับ" -> default filler (complete answer).
          - None   = no context available. bare ambiguous tokens -> no_filler (safe default).

        the cog_level tag is expected to come from the upstream llm response,
        stripped by the livekit agent before tts, and passed here.
        """
        stripped = text.strip()

        # short-circuit: bare ambiguous tokens need cog_level to decide
        if stripped in AMBIGUOUS_TOKENS:
            if cog_level == "high":
                return "no_filler", 1.0, None       # user is thinking, wait
            if cog_level == "low":
                # complete answer to a simple question, play default filler
                fillers = CATEGORY_FILLERS["default"]
                filler = self._pick_filler("default", fillers, stripped)
                return "default", 0.9, filler
            # no context: safe default is no_filler (don't interrupt)
            return "no_filler", 0.8, None

        embedding = self.model.encode(
            [f"query: {text}"], normalize_embeddings=True
        )[0]
        # cosine sim (vectors alr normalized)
        sims = self.centroids @ embedding
        best_idx = int(np.argmax(sims))
        category = self.categories[best_idx]
        confidence = float(sims[best_idx])

        # rule: if predicted no_filler but text ends with a completion particle
        # AND has no thinking/waiting marker → speaker finished their thought, use 2nd-best
        if category == "no_filler":
            stripped = text.strip()
            ends_with_completion = any(stripped.endswith(p) for p in self._COMPLETION_PARTICLES)
            has_thinking = any(m in stripped for m in self._THINKING_MARKERS)
            if ends_with_completion and not has_thinking:
                sims_copy = sims.copy()
                sims_copy[best_idx] = -1.0
                second_idx = int(np.argmax(sims_copy))
                category = self.categories[second_idx]
                confidence = float(sims[second_idx])

        # no_filler = incomplete speech, caller must NOT play anything
        if category == "no_filler":
            return category, confidence, None
        # round-robin filler selection with skip logic
        fillers = CATEGORY_FILLERS[category]
        filler = self._pick_filler(category, fillers, text)
        return category, confidence, filler

    def _pick_filler(self, category: str, fillers: list[str], input_text: str) -> str:
        """pick next filler with skip rules to avoid echo-back."""
        skip_rules = self._get_skip_rules(category, input_text)
        num_fillers = len(fillers)

        for _ in range(num_fillers):
            idx = self._rr_counters[category] % num_fillers
            candidate = fillers[idx]
            self._rr_counters[category] = idx + 1
            if not any(skip in candidate for skip in skip_rules):
                return candidate

        # all fillers matched skip rules, reset and return first anyway
        idx = self._rr_counters[category] % num_fillers
        self._rr_counters[category] = idx + 1
        return fillers[idx]

    def _get_skip_rules(self, category: str, input_text: str) -> list[str]:
        """return list of substrings to skip in fillers based on input text."""
        skip = []
        text = input_text.strip()
        if category == "default":
            # tokenize and check if last 4 words contain "ได้"
            tokens = _tokenize(text)
            tail_tokens = tokens[-4:] if len(tokens) > 4 else tokens
            if "ได้" in tail_tokens:
                skip.append("ได้")
        return skip

    def get_filler(self, text: str, cog_level: str | None = None) -> str | None:
        """returns the filler phrase, or None if no_filler (customer still talking)."""
        _, _, filler = self.classify(text, cog_level=cog_level)
        return filler


if __name__ == "__main__":
    print("loading model...")
    model = SentenceTransformer("intfloat/multilingual-e5-small")

    print("initializing classifier...")
    t0 = time.time()
    clf = FillerClassifier(model)
    print(f"centroid computation: {(time.time() - t0) * 1000:.0f}ms\n")

    # test cases: (input, expected_category)
    test_cases = [
        # core tests
        ("ได้ครับ ตกลง", "agreement"),
        ("ใช้งานไม่ได้เลยครับ", "empathy"),
        ("อยากถามเรื่องบิลครับ", "question"),
        ("สวัสดีครับ", "greeting"),
        ("ไม่เอาครับ", "decline"),
        ("โอเค รับทราบครับ", "acknowledgment"),
        # edge cases - informal/colloquial
        ("ดีครับ", "greeting"),
        ("เอาเลยจ้า", "agreement"),
        ("ไม่เอาแล้วค่ะ", "decline"),
        ("เน็ตไม่เร็วเลยครับ", "empathy"),
        ("ค่าบริการเดือนนี้เท่าไหร่", "question"),
        ("ครับ ทราบแล้ว", "acknowledgment"),
        # more edge cases
        ("จัดให้เลยครับ", "agreement"),
        ("โทรมาร้องเรียนครับ", "empathy"),
        ("มีโปรโมชั่นอะไรบ้าง", "question"),
        ("ไม่สนใจค่ะ", "decline"),
        ("หวัดดี", "greeting"),
        ("อ๋อ ได้ครับ เข้าใจแล้ว", "acknowledgment"),
    ]

    passed = 0
    total = len(test_cases)

    for text, expected in test_cases:
        t0 = time.time()
        category, confidence, filler = clf.classify(text)
        elapsed_ms = (time.time() - t0) * 1000
        ok = "pass" if category == expected else "FAIL"
        if category == expected:
            passed += 1
        print(
            f"[{ok}] \"{text}\" -> {category} ({confidence:.3f}) "
            f"filler=\"{filler}\" [{elapsed_ms:.1f}ms]"
            + (f"  (expected: {expected})" if category != expected else "")
        )

    print(f"\n{passed}/{total} passed")
