diff --git a/deploy/services/classify.py b/deploy/services/classify.py index 137e6ee..227c3be 100644 --- a/deploy/services/classify.py +++ b/deploy/services/classify.py @@ -27,6 +27,17 @@ LLM_URL = "https://api.aillm.ru/v1/chat/completions" LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "") LLM_MODEL = "gpt-oss-120b" +# Ленивый singleton — обратная совместимость +_classify_llm = None + + +def _get_classify_client(): + global _classify_llm + if _classify_llm is None: + from .llm_client import HttpxLLMClient + _classify_llm = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL, max_tokens=1000, timeout=60) + return _classify_llm + # ── Garbage filter (Stage 1: filename regex) ──────────────────── _GARBAGE_FILENAME_RE = re.compile( r'(сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|payment|act)', @@ -53,29 +64,19 @@ def _is_garbage_by_header(text: str) -> bool: return any(marker in header for marker in _GARBAGE_HEADER_MARKERS) -def _call_llm_classify(header_text): +def _call_llm_classify(header_text, llm_client=None): """ Прямой вызов LLM для классификации ОДНОГО документа. Возвращает (parsed_dict, raw_text, needed_fix). + llm_client: LLMClient (optional). Default — HttpxLLMClient. """ - prompt, _ = build_classify_prompt(header_text) - payload = { - "model": LLM_MODEL, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": 1000, - "temperature": 0.1, - } - with httpx.Client(http2=True, timeout=60) as client: - resp = client.post( - LLM_URL, json=payload, - headers={"Authorization": f"Bearer {LLM_KEY}", "Content-Type": "application/json"}, - ) - resp.raise_for_status() - data = resp.json() + if llm_client is None: + llm_client = _get_classify_client() - raw = data.get("choices", [{}])[0].get("message", {}).get("content", "") - parsed, needed_fix = _safe_json_parse(raw) - return parsed, raw, needed_fix + prompt, _ = build_classify_prompt(header_text) + raw_text = llm_client.complete(prompt) + parsed, needed_fix = _safe_json_parse(raw_text) + return parsed, raw_text, needed_fix def classify_batch(batch_id): diff --git a/deploy/services/llm.py b/deploy/services/llm.py index abf73c1..f061398 100644 --- a/deploy/services/llm.py +++ b/deploy/services/llm.py @@ -1,34 +1,32 @@ -"""LLM service — call LLM API.""" +"""LLM service — call LLM API with optional DI.""" import os, json -import httpx LLM_URL = "https://api.aillm.ru/v1/chat/completions" LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "") LLM_MODEL = "gpt-oss-120b" +# Ленивый singleton — обратная совместимость +_llm_client = None + + +def _get_default_client(): + global _llm_client + if _llm_client is None: + from .llm_client import HttpxLLMClient + _llm_client = HttpxLLMClient(url=LLM_URL, key=LLM_KEY, model=LLM_MODEL) + return _llm_client + + +def call_llm(current_spec, doc_text, build_prompt_fn, llm_client=None): + """Call LLM. Returns (parsed_result, prompt_id). + llm_client: LLMClient (optional). Default — HttpxLLMClient (prod). + """ + if llm_client is None: + llm_client = _get_default_client() -def call_llm(current_spec, doc_text, build_prompt_fn): - """Call LLM. Returns (parsed_result, prompt_id).""" prompt, prompt_id = build_prompt_fn(current_spec, doc_text) - payload = { - "model": LLM_MODEL, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": 8000, - "temperature": 0.1, - } - with httpx.Client(http2=True, timeout=120, verify=True) as client: - resp = client.post( - LLM_URL, - json=payload, - headers={ - "Authorization": f"Bearer {LLM_KEY}", - "Content-Type": "application/json", - }, - ) - resp.raise_for_status() - data = resp.json() + raw_text = llm_client.complete(prompt) - raw_text = data.get("choices", [{}])[0].get("message", {}).get("content", "") json_text = raw_text if "```json" in json_text: json_text = json_text.split("```json")[1].split("```")[0] diff --git a/deploy/services/llm_client.py b/deploy/services/llm_client.py new file mode 100644 index 0000000..b8a4e53 --- /dev/null +++ b/deploy/services/llm_client.py @@ -0,0 +1,74 @@ +"""LLM Client — протокол + httpx-реализация + fake для тестов. + +План decoupling Ф2: +- LLMClient.complete(prompt) -> str — единственный метод +- Парсинг JSON остаётся у вызывающего (call_llm / _call_llm_classify) +- HttpxLLMClient — продакшен +- FakeLLMClient — тесты (возвращает сохранённые ответы) +""" +from typing import Protocol + + +class LLMClient(Protocol): + """Протокол LLM-клиента. Один метод — сырой вызов.""" + def complete(self, prompt: str) -> str: + """Отправить prompt в LLM, вернуть raw_text.""" + ... + + +class HttpxLLMClient: + """Продакшен-клиент через httpx → api.aillm.ru.""" + + def __init__(self, url: str, key: str, model: str = "gpt-oss-120b", + max_tokens: int = 8000, temperature: float = 0.1, timeout: int = 120): + self.url = url + self.key = key + self.model = model + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + + def complete(self, prompt: str) -> str: + import httpx + payload = { + "model": self.model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": self.max_tokens, + "temperature": self.temperature, + } + with httpx.Client(http2=True, timeout=self.timeout, verify=True) as client: + resp = client.post( + self.url, + json=payload, + headers={ + "Authorization": f"Bearer {self.key}", + "Content-Type": "application/json", + }, + ) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"] + + +class FakeLLMClient: + """Тестовый клиент — возвращает сохранённые ответы из словаря.""" + + def __init__(self, responses: dict = None): + self.responses = responses or {} + self.calls: list[str] = [] # история вызовов для проверок + + def add(self, prompt_key: str, response: str): + """Зарегистрировать ответ для конкретного prompt_key.""" + self.responses[prompt_key] = response + + def complete(self, prompt: str) -> str: + self.calls.append(prompt) + # Ищем точное совпадение + if prompt in self.responses: + return self.responses[prompt] + # Ищем по частичному ключу + for key, resp in self.responses.items(): + if key in prompt: + return resp + # Fallback + return self.responses.get("default", '{"error": "no response registered"}')