ipin-vr/ipin_vr/llm.py
Oskar Kapala 691c8ea360 Implement Stage 1: command generator, CLI, TTS and LLM clients
- lexicon.py: exact forms from docs/LEXICON.md (Polish diacritics preserved)
- generator.py: Clause/Command dataclasses, render(), random_command(level 1-3)
- llm.py: OpenAI-compatible client via urllib, JSON-only contract, fallback to random
- tts.py: Piper via subprocess, defensive (prints [TTS off: reason] on any failure)
- cli.py: argparse interface per SPEC §9
- tests/test_generator.py: 23 tests covering all genders, locations, level-3 joining,
  capitalisation; all pass

DoD verified: pytest 23/23, python -m ipin_vr --level 3 --count 6 --no-audio OK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:50 +02:00

81 lines
2.5 KiB
Python

import json
import urllib.error
import urllib.request
from typing import Optional
from .generator import Clause, Command, random_command
from .lexicon import COLORS, LOCATIONS, OBJECTS, VERBS
_SYSTEM_PROMPT = (
"Return ONLY a JSON object — no prose, no markdown, no code fences.\n"
"Schema: {{\"clauses\":[{{\"verb\":\"<id>\",\"object\":\"<id>\","
"\"location\":\"<id>\",\"color\":\"<id or null>\"}}],\"level\":<int>}}\n"
"Allowed verb IDs: {verbs}\n"
"Allowed object IDs: {objects}\n"
"Allowed location IDs: {locations}\n"
"Allowed color IDs: {colors}"
)
def _system_prompt() -> str:
return _SYSTEM_PROMPT.format(
verbs=", ".join(VERBS),
objects=", ".join(OBJECTS),
locations=", ".join(LOCATIONS),
colors=", ".join(COLORS),
)
def _validate(data: dict, level: int) -> Optional[Command]:
clauses_raw = data.get("clauses")
if not isinstance(clauses_raw, list) or not clauses_raw:
return None
clauses = []
for c in clauses_raw:
v = c.get("verb")
o = c.get("object")
loc = c.get("location")
col = c.get("color")
if v not in VERBS or o not in OBJECTS or loc not in LOCATIONS:
return None
if col is not None and col not in COLORS:
return None
clauses.append(Clause(verb_id=v, object_id=o, location_id=loc, color_id=col))
return Command(clauses=clauses, level=level)
def llm_command(level: int, endpoint: str, model: str) -> Command:
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": _system_prompt()},
{"role": "user", "content": f"Generate a level {level} aphasia therapy command."},
],
"temperature": 0.8,
}).encode()
try:
req = urllib.request.Request(
endpoint,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read())
content = body["choices"][0]["message"]["content"]
start = content.find("{")
end = content.rfind("}") + 1
if start == -1 or end == 0:
raise ValueError("brak JSON w odpowiedzi")
data = json.loads(content[start:end])
cmd = _validate(data, level)
if cmd is None:
raise ValueError("nieznane identyfikatory w odpowiedzi LLM")
return cmd
except Exception:
return random_command(level)