diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..110a262 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +venv/ +__pycache__/ +*.pyc +*.wav +*.onnx +*.onnx.json +.pytest_cache/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md index 097e0ae..e3dfb2a 100644 --- a/README.md +++ b/README.md @@ -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 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 -Etap 1 — w budowie. Implementacja powstaje ściśle według `docs/SPEC.md`. +Etap 1 — zrealizowany zgodnie z `docs/SPEC.md`. diff --git a/ipin_vr/__init__.py b/ipin_vr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ipin_vr/__main__.py b/ipin_vr/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/ipin_vr/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/ipin_vr/cli.py b/ipin_vr/cli.py new file mode 100644 index 0000000..9706cd7 --- /dev/null +++ b/ipin_vr/cli.py @@ -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) diff --git a/ipin_vr/generator.py b/ipin_vr/generator.py new file mode 100644 index 0000000..1389d6d --- /dev/null +++ b/ipin_vr/generator.py @@ -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}") diff --git a/ipin_vr/lexicon.py b/ipin_vr/lexicon.py new file mode 100644 index 0000000..60dff91 --- /dev/null +++ b/ipin_vr/lexicon.py @@ -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ść", +} diff --git a/ipin_vr/llm.py b/ipin_vr/llm.py new file mode 100644 index 0000000..3fe9952 --- /dev/null +++ b/ipin_vr/llm.py @@ -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\":\"\",\"object\":\"\"," + "\"location\":\"\",\"color\":\"\"}}],\"level\":}}\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) diff --git a/ipin_vr/tts.py b/ipin_vr/tts.py new file mode 100644 index 0000000..89f41f6 --- /dev/null +++ b/ipin_vr/tts.py @@ -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}]") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78f3aa5 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..9ed2972 --- /dev/null +++ b/tests/test_generator.py @@ -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."