Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Automaattinen pipeline suomalaisten yliopistojen opinto-oppaiden läpikäymiseen
## Tietovirrat

- **Syöte:** opinto-oppaiden URL:t + suodatusasetukset (tiedekunta, taso, aihe)
- **Tallennus:** MySQL Docker-kontti (portti 21212) — kurssitiedot, LLM-vastaukset, mukaan/pois-päätökset
- **Tallennus:** MySQL (portti 21212; nyk. etäpalvelin geopalvelin1 / Tailscale — ks. Kehityskäytännöt) — kurssitiedot, LLM-vastaukset, mukaan/pois-päätökset
- **LLM-kutsut:** seulontakysymyssarja (vaihe 2), arviointikysymyssarja (vaihe 3)
- **Tuloste:** jäsennelty raportti + tilastot oppaiden laadusta

Expand All @@ -60,7 +60,8 @@ WebUI:n esittely yleisölle (seminaari-lähiverkko ja etäkokous-Tailscale Funne
## Kehityskäytännöt

- Python-projekti; pidä riippuvuudet minimissä ja kirjaa ne `requirements.txt`-tiedostoon
- MySQL Docker-kontissa (portti 21212) kaikelle pysyvyydelle; WebUI Docker-kontissa (portti 12121)
- MySQL kaikelle pysyvyydelle portissa 21212; WebUI Docker-kontissa (portti 12121)
- **Huom:** jaettu/tuotanto-MySQL on migroitu etäpalvelin **geopalvelin1**:lle (Tailscale-osoite `100.123.32.101:21212`, korkea/piikikäs latenssi) — **ei enää paikallinen Docker-kontti**. Yhteysasetukset `.env`:stä (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`); `docker-compose.yml`:n mysql-palvelu jää paikalliskehitykseen. Kirjoita tietokantakoodi etälatenssia varten: yhteyspooli (`tietokanta/yhteys.py`), `COUNT`/aggregaatit rivinouton sijaan, älä toista raskaita hakuja.
- LLM-kutsut kulkevat yhden ohuen kääreen kautta (`llm/kutsu.py`), jotta malli/palveluntarjoaja voidaan vaihtaa — OpenAI-yhteensopiva rajapinta, konfiguraatio `.env`:ssä (`LLM_PROVIDER` = perus-URL, `LLM_API_KEY`, `LLM_MODEL`)
- Promptit sijaitsevat omissa tiedostoissaan (ei koodin sisällä), jotta niitä voi iteroida koskematta logiikkaan
- Hakurobottien täytyy olla kohteliaita: noudata `robots.txt`:ää, lisää viiveet, älä kuormita palvelimia
Expand Down
40 changes: 37 additions & 3 deletions arviointi/llmarviointi.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""LLM-arviointi: lähettää mukaan otetut kurssit LLM:lle kysymysvastauksiin."""
import json
import time
from tietokanta import mallit
from llm import kutsu, tiiviste, kehoteet, kurssimuoto, asetukset

Expand All @@ -11,6 +12,29 @@ def erakoko() -> int:
return asetukset.lue_int("ARVIOINTI_ERAKOKO", _OLETUS_ERAKOKO)


def _kirjaa(viesti: str) -> None:
"""Lisää aikaleimattu rivi loki.log:iin (eräkohtaiset virheet/varoitukset)."""
with open("loki.log", "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [arviointi] {viesti}\n")


def _siivoa_tulokset(raaka: list[dict], odotetut: set) -> list[dict]:
"""Suodattaa mallin tulokset vain erän kursseihin ja normalisoi id:t kokonaisluvuiksi.

Torjuu hallusinoidut / väärät id:t ennen tallennusta (opas: 'IDs must exist') —
muuten aseta_vastaus INSERTtäisi rivin kurssille jota erässä ei ollut.
"""
siivotut = []
for t in raaka:
try:
kid = int(t.get("id"))
except (TypeError, ValueError):
continue
if kid in odotetut:
siivotut.append({**t, "id": kid})
return siivotut


def _lue_jarjestelma_kehote() -> str:
return kehoteet.lue("arviointi_jarjestelma.txt")

Expand All @@ -22,7 +46,9 @@ def _erittele_json(teksti: str) -> list[dict]:
if alku != -1:
loppu = teksti.rfind("}")
if loppu != -1:
data = json.loads(teksti[alku:loppu + 1])
# strict=False sallii raa'at ohjausmerkit (\n, \t) merkkijonoissa —
# malli laittaa niitä perusteluihin, ei syytä hylätä muuten kelvollista JSONia.
data = json.loads(teksti[alku:loppu + 1], strict=False)
for arvo in data.values():
if isinstance(arvo, list):
return arvo
Expand Down Expand Up @@ -232,11 +258,19 @@ def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None,
for erä_nro, (osa_kysymykset, erä) in enumerate(erat, 1):
if edistyminen_cb:
edistyminen_cb(käsitelty, yhteensa, erä_nro, len(erat))
odotetut = {k["KID"] for k in erä}
try:
tulokset = _arvioi_erä(erä, arviointikehote, osa_kysymykset, jarjestelma)
raaka = _arvioi_erä(erä, arviointikehote, osa_kysymykset, jarjestelma)
tulokset = _siivoa_tulokset(raaka, odotetut)
_tallenna_tulokset(tulokset, osa_kysymykset, malli, kys_tiiviste)
except Exception:
puuttuvat = odotetut - {t["id"] for t in tulokset}
if puuttuvat:
_kirjaa(f"erä {erä_nro}/{len(erat)}: malli palautti {len(tulokset)}/{len(odotetut)} "
f"kurssia; puuttuvat {sorted(puuttuvat)} jäävät seuraavalle ajolle")
except Exception as e:
# Viallinen erä ohitetaan; sen kurssit jäävät seuraavalle ajolle (idempotenssi).
_kirjaa(f"erä {erä_nro}/{len(erat)} epäonnistui ({type(e).__name__}: {e}); "
f"kurssit {sorted(odotetut)} jäävät seuraavalle ajolle")
tulokset = []
for t in tulokset:
arvioidut.add(t.get("id"))
Expand Down
9 changes: 8 additions & 1 deletion cliui/asetuksetnaytto.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,16 @@ def _tarkista(stdscr) -> None:
stdscr.refresh()
try:
mallitiedot.tarkista_saatavuus()
nayta_viesti(stdscr, f"Malli '{kutsu.hae_malli()}' on saatavilla.", 4)
except Exception as e:
nayta_viesti(stdscr, f"Ei saatavilla: {e}", 4)
return
rivit = [f"Malli '{kutsu.hae_malli()}' on saatavilla."]
varoitus = mallitiedot.muototuki_varoitus()
if varoitus:
rivit += ["", f"VAROITUS: {varoitus}"]
for i, teksti in enumerate(rivit):
stdscr.addstr(4 + i, 0, teksti)
nayta_viesti(stdscr, "", 4 + len(rivit) + 1)


def _paivita_lista(stdscr) -> None:
Expand Down
4 changes: 4 additions & 0 deletions llm/kutsu.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ def kysy(viesti: str, jarjestelma: str = "", json_muoto: bool = False,
}
if json_muoto:
runko["response_format"] = {"type": "json_object"}
# Reititä vain tarjoajille jotka oikeasti tukevat response_formatia —
# muuten OpenRouter voi valita tarjoajan joka ohittaa sen ja palauttaa
# roskaa/tyhjää (ks. lokin "OpenInference"-502:t). OpenRouter-kohtainen.
runko["provider"] = {"require_parameters": True}
vastaus = requests.post(
f"{perus_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {api_avain}"},
Expand Down
51 changes: 43 additions & 8 deletions llm/mallitiedot.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,39 @@ def tukee_valimuistia(malli_tiedot: dict) -> bool:
return "input_cache_read" in (malli_tiedot.get("pricing") or {})


def muoto_kentta(malli_tiedot: dict) -> str:
"""Lyhyt merkintä mallin rakenteisen ulostulon tuesta (OpenRouterin
supported_parameters) — vahvin ensin. Tyhjä kenttä → '?' (ei tietoa)."""
tuetut = malli_tiedot.get("supported_parameters")
if not tuetut:
return "?"
tuetut = set(tuetut)
if "structured_outputs" in tuetut:
return "skeema"
if "response_format" in tuetut:
return "json"
if "tools" in tuetut:
return "tools"
return "—"


def muototuki_varoitus(malli: str | None = None) -> str | None:
"""Varoitusteksti jos malli EI ilmoita tukevansa response_formatia, muuten None.

Perustuu jo haetun /models-listan supported_parameters-kenttään — ei uutta
pyyntöä. Palauttaa None jos mallia ei löydy tai kenttää ei ole (ei voida
päätellä; saatavuus tarkistetaan erikseen)."""
malli = malli or os.environ.get("LLM_MODEL", "")
tiedot = hae_malli_tiedot(malli)
tuetut = tiedot.get("supported_parameters") if tiedot else None
if not tuetut:
return None
if "response_format" not in set(tuetut):
return ("Malli ei ilmoita tukevansa response_formatia — JSON-tila ei "
"välttämättä toimi. Valitse malli jonka 'muoto?' on json tai skeema.")
return None


def _hinta_per_milj(arvo: str | None) -> float:
"""OpenRouterin per-token-hinta ($/token) → $/miljoona tokenia."""
try:
Expand All @@ -75,20 +108,21 @@ def _hinta_per_milj(arvo: str | None) -> float:
return 0.0


_SARAKEOTSIKOT = ("Mallin nimi", "Hinta", "konteksti", "välimuisti?")
_SARAKEOTSIKOT = ("Mallin nimi", "Hinta", "konteksti", "muoto?", "välimuisti?")
_EROTIN = " | "


def _mallin_kentat(malli_tiedot: dict) -> tuple[str, str, str, str]:
"""Yhden mallin sarakearvot: (id, hinta, konteksti, välimuisti)."""
def _mallin_kentat(malli_tiedot: dict) -> tuple[str, str, str, str, str]:
"""Yhden mallin sarakearvot: (id, hinta, konteksti, muoto, välimuisti)."""
pricing = malli_tiedot.get("pricing") or {}
sisaan = _hinta_per_milj(pricing.get("prompt"))
ulos = _hinta_per_milj(pricing.get("completion"))
hinta = "ilmainen" if sisaan == 0 and ulos == 0 else f"${sisaan:.2f}/${ulos:.2f} per Mtok"
ctx = malli_tiedot.get("context_length") or 0
konteksti = f"{ctx // 1000}k" if ctx else "?"
muoto = muoto_kentta(malli_tiedot)
valimuisti = "kyllä" if tukee_valimuistia(malli_tiedot) else "—"
return malli_tiedot.get("id", "?"), hinta, konteksti, valimuisti
return malli_tiedot.get("id", "?"), hinta, konteksti, muoto, valimuisti


def muotoile_taulukko(mallit: list[dict]) -> tuple[list[str], list[str]]:
Expand All @@ -99,14 +133,15 @@ def muotoile_taulukko(mallit: list[dict]) -> tuple[list[str], list[str]]:
keskelle, välimuisti vasemmalle (viimeinen sarake). Kaikki rivit ovat samanlevyisiä.
"""
kentat = [_mallin_kentat(m) for m in mallit]
lev = [len(_SARAKEOTSIKOT[s]) for s in range(4)]
n = len(_SARAKEOTSIKOT)
lev = [len(_SARAKEOTSIKOT[s]) for s in range(n)]
for rivi in kentat:
for s in range(4):
for s in range(n):
lev[s] = max(lev[s], len(rivi[s]))

def koosta(nimi, hinta, ktx, valimuisti):
def koosta(nimi, hinta, ktx, muoto, valimuisti):
return _EROTIN.join([nimi.ljust(lev[0]), hinta.ljust(lev[1]),
ktx.center(lev[2]), valimuisti.ljust(lev[3])])
ktx.center(lev[2]), muoto.ljust(lev[3]), valimuisti.ljust(lev[4])])

otsikkorivi = koosta(*_SARAKEOTSIKOT)
viiva = "-" * len(otsikkorivi)
Expand Down
16 changes: 16 additions & 0 deletions testit/test_arviointi.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,19 @@ def test_useita_kursseja_ja_kysymyksia(self):
]
llmarviointi._tallenna_tulokset(tulokset, ks, "m")
assert mock_aseta.call_count == 4


class TestSiivoaTulokset:
"""Kohta 6: torju hallusinoidut/väärät id:t, säilytä vain erän kurssit."""

def test_suodattaa_vieraan_idn(self):
raaka = [{"id": 1, "vastaukset": []}, {"id": 999, "vastaukset": []}]
assert llmarviointi._siivoa_tulokset(raaka, {1, 2}) == [{"id": 1, "vastaukset": []}]

def test_normalisoi_merkkijono_idn(self):
tulos = llmarviointi._siivoa_tulokset([{"id": "2", "vastaukset": []}], {1, 2})
assert tulos == [{"id": 2, "vastaukset": []}]

def test_ohittaa_puuttuvan_tai_kelvottoman_idn(self):
raaka = [{"vastaukset": []}, {"id": None, "vastaukset": []}, {"id": "abc"}]
assert llmarviointi._siivoa_tulokset(raaka, {1, 2}) == []
31 changes: 30 additions & 1 deletion testit/test_mallitiedot.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,41 @@ def test_tunnistaa_cache_kentan(self):
assert mallitiedot.tukee_valimuistia(_DATA[0]) is False


class TestMuoto:
def test_muoto_kentta_vahvin_ensin(self):
assert mallitiedot.muoto_kentta({"supported_parameters": ["response_format", "structured_outputs"]}) == "skeema"
assert mallitiedot.muoto_kentta({"supported_parameters": ["response_format"]}) == "json"
assert mallitiedot.muoto_kentta({"supported_parameters": ["tools", "tool_choice"]}) == "tools"
assert mallitiedot.muoto_kentta({"supported_parameters": ["temperature"]}) == "—"

def test_muoto_kentta_tuntematon_kun_kentta_puuttuu(self):
assert mallitiedot.muoto_kentta({}) == "?"

def test_varoitus_kun_ei_response_formatia(self):
data = [{"id": "m/x", "supported_parameters": ["tools"]}]
with patch.dict(os.environ, {**_ENV, "LLM_MODEL": "m/x"}), \
patch("llm.mallitiedot.requests.get", return_value=_mock_get(data)):
assert mallitiedot.muototuki_varoitus() is not None

def test_ei_varoitusta_kun_response_format_tuettu(self):
data = [{"id": "m/x", "supported_parameters": ["response_format"]}]
with patch.dict(os.environ, {**_ENV, "LLM_MODEL": "m/x"}), \
patch("llm.mallitiedot.requests.get", return_value=_mock_get(data)):
assert mallitiedot.muototuki_varoitus() is None

def test_ei_varoitusta_kun_tietoa_ei_ole(self):
data = [{"id": "m/x"}] # ei supported_parameters -kenttää
with patch.dict(os.environ, {**_ENV, "LLM_MODEL": "m/x"}), \
patch("llm.mallitiedot.requests.get", return_value=_mock_get(data)):
assert mallitiedot.muototuki_varoitus() is None


class TestTaulukko:
def test_otsikkorivit_sisaltavat_sarakeotsikot_ja_viivan(self):
otsikkorivit, _ = mallitiedot.muotoile_taulukko(_DATA)
assert len(otsikkorivit) == 2
otsikko, viiva = otsikkorivit
for sarake in ("Mallin nimi", "Hinta", "konteksti", "välimuisti?"):
for sarake in ("Mallin nimi", "Hinta", "konteksti", "muoto?", "välimuisti?"):
assert sarake in otsikko
assert set(viiva) == {"-"} # erotinviiva pelkkiä viivoja
assert len(viiva) == len(otsikko) # yhtä leveä kuin otsikkorivi
Expand Down