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:
parent
ad397ae1bd
commit
691c8ea360
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.wav
|
||||||
|
*.onnx
|
||||||
|
*.onnx.json
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
53
README.md
53
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
|
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
0
ipin_vr/__init__.py
Normal file
3
ipin_vr/__main__.py
Normal file
3
ipin_vr/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
53
ipin_vr/cli.py
Normal file
53
ipin_vr/cli.py
Normal 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
73
ipin_vr/generator.py
Normal 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
35
ipin_vr/lexicon.py
Normal 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
80
ipin_vr/llm.py
Normal 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
57
ipin_vr/tts.py
Normal 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
5
requirements.txt
Normal 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
0
tests/__init__.py
Normal file
170
tests/test_generator.py
Normal file
170
tests/test_generator.py
Normal 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."
|
||||||
Loading…
Reference in a new issue