From 2cbe19feccfe2c17b9548db34f5b043689147334 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Mon, 15 Jun 2026 17:29:03 +0200 Subject: [PATCH] feat(processor_lmstudio): openai-compatible client with health-check and retry --- src/journal_bot/processor_lmstudio.py | 77 ++++++++++++++++++++++++ tests/test_processor_lmstudio.py | 87 +++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/journal_bot/processor_lmstudio.py create mode 100644 tests/test_processor_lmstudio.py diff --git a/src/journal_bot/processor_lmstudio.py b/src/journal_bot/processor_lmstudio.py new file mode 100644 index 0000000..f5bdcac --- /dev/null +++ b/src/journal_bot/processor_lmstudio.py @@ -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}") diff --git a/tests/test_processor_lmstudio.py b/tests/test_processor_lmstudio.py new file mode 100644 index 0000000..9b26fb7 --- /dev/null +++ b/tests/test_processor_lmstudio.py @@ -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())