- 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>
81 lines
2.5 KiB
Python
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)
|