feat(processor_lmstudio): openai-compatible client with health-check and retry

This commit is contained in:
beo3000 2026-06-15 17:29:03 +02:00
parent 3f61204444
commit 2cbe19fecc
2 changed files with 164 additions and 0 deletions

View File

@ -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}")

View File

@ -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())