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>
This commit is contained in:
Oskar Kapala 2026-06-09 15:03:50 +02:00
parent ad397ae1bd
commit 691c8ea360
12 changed files with 537 additions and 1 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
venv/
__pycache__/
*.pyc
*.wav
*.onnx
*.onnx.json
.pytest_cache/
dist/
*.egg-info/

View file

@ -17,5 +17,56 @@ Poprawność fleksji jest nienegocjowalna. Wszystkie formy słów pochodzą wył
z ręcznie zweryfikowanego leksykonu; model językowy (gdy włączony) wybiera tylko z ręcznie zweryfikowanego leksykonu; model językowy (gdy włączony) wybiera tylko
identyfikatory elementów, a nie generuje polskiego tekstu. identyfikatory elementów, a nie generuje polskiego tekstu.
## Szybki start (bez audio)
```bash
python -m ipin_vr --level 3 --count 6 --no-audio
```
Przykładowy wynik:
```
1. Połóż zieloną książkę na półce i umieść klucz na biurku.
2. Umieść żółtą piłkę na parapecie i połóż kubek na stole.
...
```
Dostępne opcje CLI:
| Flaga | Domyślnie | Opis |
|---|---|---|
| `--level {1,2,3}` | 1 | poziom trudności |
| `--count N` | 5 | liczba poleceń |
| `--delay S` | 4.0 | pauza między poleceniami (s) |
| `--voice PATH` | — | ścieżka do modelu Piper `.onnx` |
| `--no-audio` | — | wyłącza TTS |
| `--llm` | — | użyj LLM do wyboru poleceń |
| `--llm-endpoint URL` | localhost:8080 | endpoint llama.cpp |
| `--llm-model NAME` | `local` | nazwa modelu |
| `--seed N` | — | deterministyczny generator |
## Audio (Piper TTS)
```bash
pip install piper-tts
# pobierz głos polski, np. ze https://huggingface.co/rhasspy/piper-voices
python -m ipin_vr --level 2 --count 5 --voice pl_PL-gosia-medium.onnx
```
## LLM (llama.cpp, opcjonalny)
```bash
# uruchom lokalnie serwer llama.cpp na porcie 8080
python -m ipin_vr --level 3 --count 5 --no-audio --llm
```
LLM zwraca wyłącznie identyfikatory elementów; gramatykę buduje zawsze leksykon.
Przy błędzie (timeout, zły JSON, nieznany identyfikator) — fallback na generator losowy.
## Testy
```bash
pytest
```
## Status ## Status
Etap 1 — w budowie. Implementacja powstaje ściśle według `docs/SPEC.md`. Etap 1 — zrealizowany zgodnie z `docs/SPEC.md`.

0
ipin_vr/__init__.py Normal file
View file

3
ipin_vr/__main__.py Normal file
View file

@ -0,0 +1,3 @@
from .cli import main
main()

53
ipin_vr/cli.py Normal file
View file

@ -0,0 +1,53 @@
import argparse
import random
import time
from .generator import random_command, render
from .llm import llm_command
def main() -> None:
parser = argparse.ArgumentParser(
description="ipin-vr: generator poleceń do terapii afazji"
)
parser.add_argument("--level", type=int, choices=[1, 2, 3], default=1,
metavar="{1,2,3}", help="poziom trudności (domyślnie: 1)")
parser.add_argument("--count", type=int, default=5, metavar="N",
help="liczba poleceń (domyślnie: 5)")
parser.add_argument("--delay", type=float, default=4.0, metavar="S",
help="pauza między poleceniami w sekundach (domyślnie: 4.0)")
parser.add_argument("--voice", metavar="PATH",
help="ścieżka do modelu Piper .onnx")
parser.add_argument("--no-audio", action="store_true",
help="wyłącza TTS")
parser.add_argument("--llm", action="store_true",
help="użyj LLM do wyboru poleceń")
parser.add_argument("--llm-endpoint",
default="http://localhost:8080/v1/chat/completions",
metavar="URL", help="endpoint llama.cpp")
parser.add_argument("--llm-model", default="local", metavar="NAME",
help="nazwa modelu")
parser.add_argument("--seed", type=int, metavar="N",
help="deterministyczny generator losowy")
args = parser.parse_args()
if args.seed is not None:
random.seed(args.seed)
use_audio = (not args.no_audio) and (args.voice is not None)
for i in range(1, args.count + 1):
if args.llm:
cmd = llm_command(args.level, args.llm_endpoint, args.llm_model)
else:
cmd = random_command(args.level)
text = render(cmd)
print(f"{i}. {text}")
if use_audio:
from .tts import speak
speak(text, args.voice)
if i < args.count:
time.sleep(args.delay)

73
ipin_vr/generator.py Normal file
View file

@ -0,0 +1,73 @@
import random
from dataclasses import dataclass
from typing import Optional
from .lexicon import COLORS, LOCATIONS, OBJECTS, VERBS
@dataclass
class Clause:
verb_id: str
object_id: str
location_id: str
color_id: Optional[str]
@dataclass
class Command:
clauses: list[Clause]
level: int
def _render_clause(clause: Clause, first: bool) -> str:
verb = VERBS[clause.verb_id]
if not first:
verb = verb.lower()
obj = OBJECTS[clause.object_id]
parts = [verb]
if clause.color_id is not None:
parts.append(COLORS[clause.color_id][obj["gender"]])
parts.append(obj["acc"])
parts.append(LOCATIONS[clause.location_id])
return " ".join(parts)
def render(command: Command) -> str:
rendered = [
_render_clause(clause, i == 0)
for i, clause in enumerate(command.clauses)
]
return " i ".join(rendered) + "."
def random_command(level: int) -> Command:
verb_ids = list(VERBS)
object_ids = list(OBJECTS)
location_ids = list(LOCATIONS)
color_ids = list(COLORS)
def _clause(object_id: str, use_color: bool) -> Clause:
return Clause(
verb_id=random.choice(verb_ids),
object_id=object_id,
location_id=random.choice(location_ids),
color_id=random.choice(color_ids) if use_color else None,
)
if level == 1:
return Command(clauses=[_clause(random.choice(object_ids), False)], level=1)
if level == 2:
return Command(clauses=[_clause(random.choice(object_ids), True)], level=2)
if level == 3:
obj1 = random.choice(object_ids)
obj2 = random.choice([o for o in object_ids if o != obj1])
clauses = [
_clause(obj1, random.choice([True, False])),
_clause(obj2, random.choice([True, False])),
]
return Command(clauses=clauses, level=3)
raise ValueError(f"Nieznany poziom: {level}")

35
ipin_vr/lexicon.py Normal file
View file

@ -0,0 +1,35 @@
OBJECTS = {
"jablko": {"nom": "jabłko", "acc": "jabłko", "gender": "n"},
"kubek": {"nom": "kubek", "acc": "kubek", "gender": "m"},
"lyzka": {"nom": "łyżka", "acc": "łyżkę", "gender": "f"},
"ksiazka": {"nom": "książka", "acc": "książkę", "gender": "f"},
"dlugopis": {"nom": "długopis", "acc": "długopis", "gender": "m"},
"klucz": {"nom": "klucz", "acc": "klucz", "gender": "m"},
"pilka": {"nom": "piłka", "acc": "piłkę", "gender": "f"},
"butelka": {"nom": "butelka", "acc": "butelkę", "gender": "f"},
"talerz": {"nom": "talerz", "acc": "talerz", "gender": "m"},
"banan": {"nom": "banan", "acc": "banan", "gender": "m"},
}
COLORS = {
"czerwony": {"m": "czerwony", "f": "czerwoną", "n": "czerwone"},
"zielony": {"m": "zielony", "f": "zieloną", "n": "zielone"},
"niebieski": {"m": "niebieski", "f": "niebieską", "n": "niebieskie"},
"zolty": {"m": "żółty", "f": "żółtą", "n": "żółte"},
"bialy": {"m": "biały", "f": "białą", "n": "białe"},
"czarny": {"m": "czarny", "f": "czarną", "n": "czarne"},
}
LOCATIONS = {
"stol": "na stole",
"krzeslo": "na krześle",
"polka": "na półce",
"podloga": "na podłodze",
"biurko": "na biurku",
"parapet": "na parapecie",
}
VERBS = {
"poloz": "Połóż",
"umiesc": "Umieść",
}

80
ipin_vr/llm.py Normal file
View file

@ -0,0 +1,80 @@
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)

57
ipin_vr/tts.py Normal file
View file

@ -0,0 +1,57 @@
import os
import subprocess
import sys
import tempfile
def speak(text: str, voice_path: str) -> None:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
wav_path = tf.name
try:
proc = subprocess.run(
["piper", "--model", voice_path, "--output_file", wav_path],
input=text.encode(),
capture_output=True,
timeout=15,
)
if proc.returncode != 0:
stderr = proc.stderr.decode(errors="replace").strip()
print(f"[TTS off: piper błąd {proc.returncode}: {stderr}]")
return
_play(wav_path)
except FileNotFoundError:
print("[TTS off: piper nie znaleziony — zainstaluj piper-tts]")
except subprocess.TimeoutExpired:
print("[TTS off: piper timeout]")
except Exception as exc:
print(f"[TTS off: {exc}]")
finally:
try:
os.unlink(wav_path)
except OSError:
pass
def _play(wav_path: str) -> None:
if sys.platform.startswith("linux"):
_run_player("aplay", wav_path)
elif sys.platform == "darwin":
_run_player("afplay", wav_path)
elif sys.platform == "win32":
try:
import winsound
winsound.PlaySound(wav_path, winsound.SND_FILENAME)
except Exception as exc:
print(f"[TTS off: winsound: {exc}]")
else:
print(f"[TTS off: nieobsługiwana platforma {sys.platform}]")
def _run_player(player: str, wav_path: str) -> None:
try:
subprocess.run([player, wav_path], capture_output=True, timeout=30)
except FileNotFoundError:
print(f"[TTS off: {player} nie znaleziony]")
except Exception as exc:
print(f"[TTS off: {exc}]")

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
# Rdzeń nie wymaga żadnych zewnętrznych zależności (tylko stdlib).
# TTS jest opcjonalne — zainstaluj piper-tts i pobierz głos polski:
# pip install piper-tts
# # głos: https://huggingface.co/rhasspy/piper-voices (np. pl_PL-gosia-medium.onnx)
# piper-tts

0
tests/__init__.py Normal file
View file

170
tests/test_generator.py Normal file
View file

@ -0,0 +1,170 @@
import pytest
from ipin_vr.generator import Clause, Command, render
def cmd1(verb, obj, loc, color=None):
return Command(clauses=[Clause(verb, obj, loc, color)], level=1)
def cmd2(verb, obj, loc, color):
return Command(clauses=[Clause(verb, obj, loc, color)], level=2)
# --- kolor + rzeczownik: rodzaj nijaki (n) ---
def test_color_neuter():
# jablko: acc=jabłko, gender=n → czerwone
assert render(cmd2("poloz", "jablko", "stol", "czerwony")) == "Połóż czerwone jabłko na stole."
def test_color_neuter_zolty():
assert render(cmd2("umiesc", "jablko", "biurko", "zolty")) == "Umieść żółte jabłko na biurku."
# --- kolor + rzeczownik: rodzaj męski (m) ---
def test_color_masculine():
# kubek: acc=kubek, gender=m → zielony
assert render(cmd2("poloz", "kubek", "biurko", "zielony")) == "Połóż zielony kubek na biurku."
def test_color_masculine_niebieski():
# klucz: acc=klucz, gender=m → niebieski
assert render(cmd2("umiesc", "klucz", "polka", "niebieski")) == "Umieść niebieski klucz na półce."
# --- kolor + rzeczownik: rodzaj żeński (f) ---
def test_color_feminine():
# lyzka: acc=łyżkę, gender=f → niebieską
assert render(cmd2("poloz", "lyzka", "polka", "niebieski")) == "Połóż niebieską łyżkę na półce."
def test_color_feminine_zolty():
# pilka: acc=piłkę, gender=f → żółtą
assert render(cmd2("poloz", "pilka", "parapet", "zolty")) == "Połóż żółtą piłkę na parapecie."
def test_color_feminine_zielony():
# ksiazka: acc=książkę, gender=f → zieloną
assert render(cmd2("umiesc", "ksiazka", "stol", "zielony")) == "Umieść zieloną książkę na stole."
# --- poprawny miejscownik miejsca ---
def test_location_stol():
assert render(cmd1("poloz", "jablko", "stol")) == "Połóż jabłko na stole."
def test_location_krzeslo():
assert render(cmd1("poloz", "kubek", "krzeslo")) == "Połóż kubek na krześle."
def test_location_polka():
assert render(cmd1("poloz", "klucz", "polka")) == "Połóż klucz na półce."
def test_location_podloga():
assert render(cmd1("poloz", "banan", "podloga")) == "Połóż banan na podłodze."
def test_location_biurko():
assert render(cmd1("poloz", "dlugopis", "biurko")) == "Połóż długopis na biurku."
def test_location_parapet():
assert render(cmd1("poloz", "talerz", "parapet")) == "Połóż talerz na parapecie."
# --- łączenie dwóch klauzul (poziom 3) ---
def test_level3_two_clauses():
cmd = Command(
clauses=[
Clause("poloz", "ksiazka", "polka", "zielony"),
Clause("umiesc", "klucz", "biurko", None),
],
level=3,
)
assert render(cmd) == "Połóż zieloną książkę na półce i umieść klucz na biurku."
def test_level3_both_with_color():
cmd = Command(
clauses=[
Clause("poloz", "jablko", "stol", "czerwony"),
Clause("umiesc", "pilka", "parapet", "zolty"),
],
level=3,
)
assert render(cmd) == "Połóż czerwone jabłko na stole i umieść żółtą piłkę na parapecie."
def test_level3_no_colors():
cmd = Command(
clauses=[
Clause("poloz", "kubek", "biurko", None),
Clause("poloz", "banan", "podloga", None),
],
level=3,
)
assert render(cmd) == "Połóż kubek na biurku i połóż banan na podłodze."
# --- kapitalizacja: pierwsza klauzula wielką, kolejna małą literą ---
def test_capitalization_first_uppercase():
result = render(cmd1("poloz", "jablko", "stol"))
assert result[0].isupper()
def test_capitalization_second_lowercase():
cmd = Command(
clauses=[
Clause("poloz", "jablko", "stol", None),
Clause("poloz", "kubek", "biurko", None),
],
level=3,
)
result = render(cmd)
# "Połóż jabłko na stole i połóż kubek na biurku."
assert result.startswith("Połóż")
assert " i połóż" in result
def test_capitalization_umiesc_second():
cmd = Command(
clauses=[
Clause("umiesc", "talerz", "stol", None),
Clause("umiesc", "butelka", "polka", None),
],
level=3,
)
result = render(cmd)
assert result.startswith("Umieść")
assert " i umieść" in result
# --- przykłady ze SPEC ---
def test_spec_example_poloz_jablko():
assert render(cmd1("poloz", "jablko", "stol")) == "Połóż jabłko na stole."
def test_spec_example_zolta_pilka():
assert render(cmd2("poloz", "pilka", "parapet", "zolty")) == "Połóż żółtą piłkę na parapecie."
def test_spec_example_niebieskie_jablko():
assert render(cmd2("umiesc", "jablko", "stol", "niebieski")) == "Umieść niebieskie jabłko na stole."
def test_spec_example_level3():
cmd = Command(
clauses=[
Clause("poloz", "ksiazka", "polka", "zielony"),
Clause("umiesc", "klucz", "biurko", None),
],
level=3,
)
assert render(cmd) == "Połóż zieloną książkę na półce i umieść klucz na biurku."