| """Interface backend LLM + tiện ích parse JSON bền bỉ từ output model. |
| |
| Mọi backend (transformers Qwen3-Omni, llama.cpp fallback) phải hiện thực |
| `LLMBackend`. Pipeline chỉ phụ thuộc vào interface này, không vào model cụ thể. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import abc |
| import json |
| import re |
| from dataclasses import dataclass, field |
| from typing import Any, Dict, List, Optional, Sequence |
|
|
|
|
| @dataclass |
| class ChatMessage: |
| """Một message đa phương thức gửi tới model. |
| |
| - `text`: nội dung văn bản. |
| - `images`: đường dẫn ảnh/frame (đưa TRƯỚC text khi build prompt). |
| - `audio`: đường dẫn audio (đưa SAU text — theo best practice Qwen multimodal). |
| """ |
|
|
| role: str = "user" |
| text: str = "" |
| images: List[str] = field(default_factory=list) |
| audio: List[str] = field(default_factory=list) |
|
|
|
|
| class LLMBackend(abc.ABC): |
| """Giao diện tối thiểu cho mọi backend.""" |
|
|
| @abc.abstractmethod |
| def chat( |
| self, |
| messages: Sequence[ChatMessage], |
| *, |
| system: Optional[str] = None, |
| max_new_tokens: int = 1024, |
| temperature: float = 0.7, |
| top_p: float = 0.95, |
| top_k: int = 64, |
| **kwargs: Any, |
| ) -> str: |
| """Sinh văn bản từ chuỗi message. Trả về raw text.""" |
| raise NotImplementedError |
|
|
| def chat_json( |
| self, |
| messages: Sequence[ChatMessage], |
| *, |
| system: Optional[str] = None, |
| max_new_tokens: int = 1024, |
| **kwargs: Any, |
| ) -> Any: |
| """Gọi `chat` rồi cố trích JSON từ output. Trả về dict/list đã parse.""" |
| raw = self.chat( |
| messages, system=system, max_new_tokens=max_new_tokens, **kwargs |
| ) |
| return extract_json(raw) |
|
|
| |
| @staticmethod |
| def _join_text(messages: Sequence[ChatMessage]) -> str: |
| return "\n".join(m.text for m in messages if m.text) |
|
|
|
|
| _FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL | re.IGNORECASE) |
|
|
|
|
| def extract_json(raw: str) -> Any: |
| """Trích object/array JSON đầu tiên từ output model. |
| |
| Thử theo thứ tự: parse trực tiếp → bóc khối ```json``` → quét cặp ngoặc |
| cân bằng đầu tiên. Ném `ValueError` nếu không tìm được JSON hợp lệ. |
| """ |
| if raw is None: |
| raise ValueError("Output rỗng, không có JSON.") |
| text = raw.strip() |
|
|
| |
| try: |
| return json.loads(text) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| m = _FENCE_RE.search(text) |
| if m: |
| candidate = m.group(1).strip() |
| try: |
| return json.loads(candidate) |
| except json.JSONDecodeError: |
| text = candidate |
|
|
| |
| for opener, closer in (("{", "}"), ("[", "]")): |
| snippet = _extract_balanced(text, opener, closer) |
| if snippet: |
| try: |
| return json.loads(snippet) |
| except json.JSONDecodeError: |
| continue |
|
|
| raise ValueError(f"Không trích được JSON từ output:\n{raw[:500]}") |
|
|
|
|
| def _extract_balanced(text: str, opener: str, closer: str) -> Optional[str]: |
| start = text.find(opener) |
| if start == -1: |
| return None |
| depth = 0 |
| in_str = False |
| escape = False |
| for i in range(start, len(text)): |
| ch = text[i] |
| if in_str: |
| if escape: |
| escape = False |
| elif ch == "\\": |
| escape = True |
| elif ch == '"': |
| in_str = False |
| continue |
| if ch == '"': |
| in_str = True |
| elif ch == opener: |
| depth += 1 |
| elif ch == closer: |
| depth -= 1 |
| if depth == 0: |
| return text[start : i + 1] |
| return None |
|
|