feat(processor_lmstudio): openai-compatible client with health-check and retry
This commit is contained in:
parent
3f61204444
commit
2cbe19fecc
|
|
@ -0,0 +1,77 @@
|
|||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import httpx
|
||||
from pydantic import ValidationError
|
||||
from .processor_protocol import ProcessorInput, ProcessorOutput
|
||||
|
||||
|
||||
class LMStudioProcessor:
|
||||
"""OpenAI-compatible processor against LM Studio local server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
max_retries: int = 2,
|
||||
timeout: float = 120.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.system_prompt = system_prompt
|
||||
self.max_retries = max_retries
|
||||
self._http = httpx.Client(timeout=timeout)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
try:
|
||||
r = self._http.get(f"{self.base_url}/models", timeout=2.0)
|
||||
return r.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
def _user_message(self, payload: ProcessorInput) -> dict:
|
||||
ctx = {
|
||||
"today": payload.today,
|
||||
"weekday": payload.weekday,
|
||||
"received_time": payload.received_time,
|
||||
"persons": payload.persons,
|
||||
"projects": payload.projects,
|
||||
"text": payload.text,
|
||||
"image_embed": payload.image_embed,
|
||||
"image_caption": payload.image_caption,
|
||||
}
|
||||
content: list[dict] = [{"type": "text", "text": json.dumps(ctx, ensure_ascii=False)}]
|
||||
if payload.image_local_path:
|
||||
p = Path(payload.image_local_path)
|
||||
if p.exists():
|
||||
b64 = base64.b64encode(p.read_bytes()).decode("ascii")
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||
})
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
def process(self, payload: ProcessorInput) -> ProcessorOutput:
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
self._user_message(payload),
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.2,
|
||||
}
|
||||
last_error: Optional[Exception] = None
|
||||
for _ in range(self.max_retries + 1):
|
||||
r = self._http.post(f"{self.base_url}/chat/completions", json=body)
|
||||
r.raise_for_status()
|
||||
content = r.json()["choices"][0]["message"]["content"]
|
||||
try:
|
||||
data = json.loads(content)
|
||||
return ProcessorOutput.model_validate(data)
|
||||
except (json.JSONDecodeError, ValidationError) as e:
|
||||
last_error = e
|
||||
continue
|
||||
raise RuntimeError(f"LM Studio produced invalid output after retries: {last_error}")
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import json
|
||||
import httpx
|
||||
import respx
|
||||
import pytest
|
||||
from journal_bot.processor_lmstudio import LMStudioProcessor
|
||||
from journal_bot.processor_protocol import ProcessorInput
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor():
|
||||
return LMStudioProcessor(
|
||||
base_url="http://localhost:1234/v1",
|
||||
model="qwen/qwen3-vl-8b",
|
||||
system_prompt="SYS",
|
||||
)
|
||||
|
||||
|
||||
def make_input(text="Hallo Welt"):
|
||||
return ProcessorInput(
|
||||
today="2026-06-14",
|
||||
weekday="Sonntag",
|
||||
received_time="14:32",
|
||||
persons=[],
|
||||
projects=[],
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_health_check_ok(processor):
|
||||
respx.get("http://localhost:1234/v1/models").mock(
|
||||
return_value=httpx.Response(200, json={"data": [{"id": "qwen/qwen3-vl-8b"}]})
|
||||
)
|
||||
assert processor.health_check() is True
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_health_check_down(processor):
|
||||
respx.get("http://localhost:1234/v1/models").mock(
|
||||
side_effect=httpx.ConnectError("no")
|
||||
)
|
||||
assert processor.health_check() is False
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_process_returns_validated_output(processor):
|
||||
payload = {
|
||||
"target_date": "2026-06-14",
|
||||
"target_path": "05 Daily Notes/2026-06-14.md",
|
||||
"entry_markdown": "## 14:32\nHallo Welt",
|
||||
"clarifications": [],
|
||||
"raw_excluded": [],
|
||||
}
|
||||
respx.post("http://localhost:1234/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": json.dumps(payload)}}],
|
||||
})
|
||||
)
|
||||
out = processor.process(make_input())
|
||||
assert out.target_date == "2026-06-14"
|
||||
assert out.entry_markdown.startswith("## 14:32")
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_process_retries_on_schema_mismatch(processor):
|
||||
bad = {"choices": [{"message": {"content": json.dumps({"target_date": "bad"})}}]}
|
||||
good = {"choices": [{"message": {"content": json.dumps({
|
||||
"target_date": "2026-06-14",
|
||||
"target_path": "05 Daily Notes/2026-06-14.md",
|
||||
"entry_markdown": "## 14:32\nHallo",
|
||||
"clarifications": [], "raw_excluded": [],
|
||||
})}}]}
|
||||
respx.post("http://localhost:1234/v1/chat/completions").mock(
|
||||
side_effect=[httpx.Response(200, json=bad), httpx.Response(200, json=good)]
|
||||
)
|
||||
out = processor.process(make_input())
|
||||
assert out.target_date == "2026-06-14"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_process_raises_after_max_retries(processor):
|
||||
bad = {"choices": [{"message": {"content": "not json"}}]}
|
||||
respx.post("http://localhost:1234/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json=bad)
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
processor.process(make_input())
|
||||
Loading…
Reference in New Issue