feat(Ф2): llm_client.py — DI LLM (HttpxLLMClient + FakeLLMClient)
This commit is contained in:
+19
-18
@@ -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_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||||
LLM_MODEL = "gpt-oss-120b"
|
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 filter (Stage 1: filename regex) ────────────────────
|
||||||
_GARBAGE_FILENAME_RE = re.compile(
|
_GARBAGE_FILENAME_RE = re.compile(
|
||||||
r'(сч[её]т|акт|плат[её]ж|УПД|сверк|инвойс|invoice|payment|act)',
|
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)
|
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 для классификации ОДНОГО документа.
|
Прямой вызов LLM для классификации ОДНОГО документа.
|
||||||
Возвращает (parsed_dict, raw_text, needed_fix).
|
Возвращает (parsed_dict, raw_text, needed_fix).
|
||||||
|
llm_client: LLMClient (optional). Default — HttpxLLMClient.
|
||||||
"""
|
"""
|
||||||
prompt, _ = build_classify_prompt(header_text)
|
if llm_client is None:
|
||||||
payload = {
|
llm_client = _get_classify_client()
|
||||||
"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()
|
|
||||||
|
|
||||||
raw = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
prompt, _ = build_classify_prompt(header_text)
|
||||||
parsed, needed_fix = _safe_json_parse(raw)
|
raw_text = llm_client.complete(prompt)
|
||||||
return parsed, raw, needed_fix
|
parsed, needed_fix = _safe_json_parse(raw_text)
|
||||||
|
return parsed, raw_text, needed_fix
|
||||||
|
|
||||||
|
|
||||||
def classify_batch(batch_id):
|
def classify_batch(batch_id):
|
||||||
|
|||||||
+20
-22
@@ -1,34 +1,32 @@
|
|||||||
"""LLM service — call LLM API."""
|
"""LLM service — call LLM API with optional DI."""
|
||||||
import os, json
|
import os, json
|
||||||
import httpx
|
|
||||||
|
|
||||||
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
|
LLM_URL = "https://api.aillm.ru/v1/chat/completions"
|
||||||
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
LLM_KEY = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
|
||||||
LLM_MODEL = "gpt-oss-120b"
|
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)
|
prompt, prompt_id = build_prompt_fn(current_spec, doc_text)
|
||||||
payload = {
|
raw_text = llm_client.complete(prompt)
|
||||||
"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 = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
||||||
json_text = raw_text
|
json_text = raw_text
|
||||||
if "```json" in json_text:
|
if "```json" in json_text:
|
||||||
json_text = json_text.split("```json")[1].split("```")[0]
|
json_text = json_text.split("```json")[1].split("```")[0]
|
||||||
|
|||||||
@@ -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"}')
|
||||||
Reference in New Issue
Block a user