Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Thumbs.db

# Kehitysaikaiset artefaktit
status
loki.log

# Testierä-tilastot (generoituja)
testitulokset/
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
115 changes: 93 additions & 22 deletions arviointi/llmarviointi.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""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
from llm import kutsu, tiiviste, kehotteet, kurssimuoto, asetukset

_OLETUS_ERAKOKO = 5 # kursseja per LLM-kutsu; .env:n ARVIOINTI_ERAKOKO ohittaa

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


def _lue_jarjestelma_kehote() -> str:
return kehoteet.lue("arviointi_jarjestelma.txt")
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 _luokittele_virhe(e: Exception) -> str:
"""Eräkohtainen virhe → tilastoluokka (max_tokens / virhe_502 / tyhja / muoto).

max_tokens tunnistetaan viimeisimmän kutsun finish_reasonista ('length') —
vastaus tuli, mutta katkesi token-kattoon → JSON jää vajaaksi. Tarkistetaan
vasta muut luokat suljettua, jolloin finish_reason on tuoreesta pyynnöstä."""
teksti = str(e)
if "tyhjän vastauksen" in teksti or "tyhjän choices" in teksti:
return "tyhja"
if "502" in teksti or isinstance(e, RuntimeError):
return "virhe_502"
if kutsu.hae_viimeisin_kaytto().get("finish_reason") == "length":
return "max_tokens"
return "muoto"


def _lue_jarjestelmakehote() -> str:
return kehotteet.lue("arviointijarjestelma.txt")


def _erittele_json(teksti: str) -> list[dict]:
Expand All @@ -22,7 +62,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 @@ -128,7 +170,7 @@ def _selvita_tyo(tutkimus: dict) -> dict:
return {"kysymykset": [], "jarjestelma": "", "kys_tiiviste": {},
"arviointikehote": arviointikehote, "kurssi_kartta": {}, "olemassa": {}, "tyo": {}}

jarjestelma = _lue_jarjestelma_kehote()
jarjestelma = _lue_jarjestelmakehote()
kys_tiiviste = tiiviste.kysymystiivisteet(arviointikehote, jarjestelma, kysymykset)
kurssit = mallit.hae_valitut_kurssit(tid)
olemassa = mallit.hae_vastaus_tiivisteet(tid)
Expand Down Expand Up @@ -156,28 +198,39 @@ def _kysymystiivisteet(tutkimus: dict) -> dict:
if not kysymykset:
return {}
return tiiviste.kysymystiivisteet(
tutkimus["Arviointikehote"], _lue_jarjestelma_kehote(), kysymykset)
tutkimus["Arviointikehote"], _lue_jarjestelmakehote(), kysymykset)


def laske_tyomaara(tutkimus: dict, tieto: dict | None = None) -> tuple[int, int]:
"""(uudet, vanhentuneet) — montako mukana-kurssia arvioidaan: täysin uudet
(ei aiempia vastauksia) vs. osin vanhentuneet (kehote/kysymys muuttunut).
def laske_tyomaara(tutkimus: dict, tieto: dict | None = None) -> tuple[int, int, int]:
"""(uudet, täydennettävät, muuttuneet) — montako mukana-kurssia arvioidaan:
- uudet: ei aiempia vastauksia lainkaan
- täydennettävät: osa vastauksista on, puuttuvat kysymykset vain täytetään
(kehote/kysymys EI muuttunut) — esim. malli palautti erässä vajaat tulokset
- muuttuneet: jokin jo vastattu kysymys on vanhentunut (kehote/kysymys muuttui)

tieto: esilaskettu _selvita_tyo-tulos — annettuna vältetään kurssien ja
vastaustiivisteiden uudelleenhaku (merkitsevää etäpalvelimella)."""
if tieto is None:
tieto = _selvita_tyo(tutkimus)
uudet = vanhentuneet = 0
for kid in tieto["tyo"]:
olemassa = tieto["olemassa"]
kys_tiiviste = tieto["kys_tiiviste"]
uudet = taydennettavat = muuttuneet = 0
for kid, tarvittavat in tieto["tyo"].items():
oli_vastauksia = any(
(s := tieto["olemassa"].get((kid, k["KysID"]))) and s["vastattu"]
(s := olemassa.get((kid, k["KysID"]))) and s["vastattu"]
for k in tieto["kysymykset"]
)
if oli_vastauksia:
vanhentuneet += 1
else:
if not oli_vastauksia:
uudet += 1
return uudet, vanhentuneet
elif any(
(s := olemassa.get((kid, k["KysID"]))) and s["vastattu"]
and s["tiiviste"] != kys_tiiviste[k["KysID"]]
for k in tarvittavat
):
muuttuneet += 1
else:
taydennettavat += 1
return uudet, taydennettavat, muuttuneet


def rakenna_erat(tieto: dict, koko: int) -> list[tuple[list[dict], list[dict]]]:
Expand All @@ -200,7 +253,7 @@ def rakenna_erat(tieto: dict, koko: int) -> list[tuple[list[dict], list[dict]]]:


def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None,
tieto: dict | None = None) -> int:
tieto: dict | None = None, keskeyta_cb=None) -> int:
"""Arvioi mukaan otetut kurssit LLM:llä. Palauttaa arvioitujen kurssien määrän.

Idempotentti ja kehotetietoinen: kysyy LLM:ltä vain ne (kurssi, kysymys) -parit,
Expand All @@ -209,6 +262,10 @@ def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None,
max_erat: jos annettu, ajetaan korkeintaan tämän verran eräpyyntöjä (esim. 1 =
yksi LLM-pyyntö, kätevä testaukseen). Loput jäävät seuraavalle ajolle.
tieto: esilaskettu _selvita_tyo-tulos — annettuna vältetään uudelleenhaku.
keskeyta_cb: jos annettu ja palauttaa True erien välissä, ajo lopetetaan
siististi (loput jäävät seuraavalle ajolle).
edistyminen_cb(käsitelty, yhteensa, erä_nro, erien_maara, tilasto): tilasto on
elävä dict {onnistuneet, max_tokens, virhe_502, tyhja, muoto}.
"""
if tieto is None:
tieto = _selvita_tyo(tutkimus)
Expand All @@ -228,20 +285,34 @@ def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None,
yhteensa = sum(len(erä) for _, erä in erat)
arvioidut: set = set()
käsitelty = 0
tilasto = {"onnistuneet": 0, "max_tokens": 0, "virhe_502": 0, "tyhja": 0, "muoto": 0}

for erä_nro, (osa_kysymykset, erä) in enumerate(erat, 1):
if keskeyta_cb and keskeyta_cb():
break
if edistyminen_cb:
edistyminen_cb(käsitelty, yhteensa, erä_nro, len(erat))
edistyminen_cb(käsitelty, yhteensa, erä_nro, len(erat), tilasto)
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).
kategoria = _luokittele_virhe(e)
tilasto[kategoria] += len(erä)
_kirjaa(f"erä {erä_nro}/{len(erat)} epäonnistui ({type(e).__name__}: {e}); "
f"kurssit {sorted(odotetut)} jäävät seuraavalle ajolle [{kategoria}]")
tulokset = []
for t in tulokset:
arvioidut.add(t.get("id"))
arvioidut.add(t["id"])
tilasto["onnistuneet"] = len(arvioidut)
käsitelty += len(erä)
if edistyminen_cb:
edistyminen_cb(käsitelty, yhteensa, erä_nro, len(erat))
edistyminen_cb(käsitelty, yhteensa, erä_nro, len(erat), tilasto)

return len(arvioidut)
97 changes: 71 additions & 26 deletions cliui/arviointinaytto.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@
from tietokanta import mallit
from cliui.apurit import piirra_otsikko, nayta_viesti, valitse_listasta, lue_teksti

# Tilaston rivit: (avain aja()n tilastodictissä, näyttöteksti). Sama järjestys
# elävässä edistymisnäytössä ja loppuyhteenvedossa.
_TILASTORIVIT = [
("onnistuneet", "Onnistuneita arviointeja"),
("max_tokens", "Epäonnistuneita arviointeja (max tokens)"),
("virhe_502", "Epäonnistuneita arviointeja (502 virhe)"),
("tyhja", "Epäonnistuneita arviointeja (tyhjä vastaus)"),
("muoto", "Epäonnistuneita arviointeja (vastaus ei oikean muotoinen)"),
]
_TYHJA_TILASTO = {avain: 0 for avain, _ in _TILASTORIVIT}


def _piirra_tilasto(stdscr, tilasto: dict, alkurivi: int) -> None:
for i, (avain, nimi) in enumerate(_TILASTORIVIT):
stdscr.addstr(alkurivi + i, 0, f" * {tilasto.get(avain, 0):>3} {nimi}")


def _piirra_edistyminen(stdscr, otsikko: str, tila_rivi: str, tilasto: dict) -> None:
"""Tyhjentää ja piirtää koko edistymisnäytön (ei jää roikkumaan vanhaa tekstiä)."""
stdscr.erase()
piirra_otsikko(stdscr, otsikko)
stdscr.addstr(3, 0, "Yhdistetään LLM:ään...")
stdscr.addstr(4, 0, f" {tila_rivi}")
_piirra_tilasto(stdscr, tilasto, 6)
stdscr.addstr(6 + len(_TILASTORIVIT) + 1, 0, "paina q keskeyttääksesi")
stdscr.refresh()


def _nayta_yhteenveto(stdscr, otsikko: str, keskeytetty: bool, tilasto: dict, yhteensa: int) -> None:
stdscr.erase()
piirra_otsikko(stdscr, f"{otsikko} — {'keskeytetty' if keskeytetty else 'valmis'}")
_piirra_tilasto(stdscr, tilasto, 3)
rivi = 3 + len(_TILASTORIVIT) + 1
kesken = yhteensa - tilasto.get("onnistuneet", 0)
if kesken > 0:
stdscr.addstr(rivi, 0, f"Jäljellä vielä {kesken} kurssia (aja uudelleen jatkaaksesi).")
rivi += 1
nayta_viesti(stdscr, "", rivi + 1)


def nayta(stdscr) -> None:
tutkimukset = mallit.hae_tutkimukset()
Expand Down Expand Up @@ -70,19 +109,20 @@ def _aja_llm(stdscr, tutkimus: dict, vain_yksi_era: bool = False) -> None:
# työmäärälaskennalle ja ajolle — muuten _selvita_tyo (kaikki kurssit +
# vastaustiivisteet) ajettaisiin 2–3 kertaa peräkkäin etäpalvelinta vasten.
tieto = llmarviointi._selvita_tyo(tutkimus)
uudet, vanhentuneet = llmarviointi.laske_tyomaara(tutkimus, tieto=tieto)
if uudet + vanhentuneet == 0:
uudet, taydennettavat, muuttuneet = llmarviointi.laske_tyomaara(tutkimus, tieto=tieto)
yhteensa = uudet + taydennettavat + muuttuneet
if yhteensa == 0:
nayta_viesti(stdscr, "Kaikki mukana olevat kurssit on jo arvioitu nykyisellä kehotteella ja kysymyksillä.")
return

# Yhden erän testiajo: ohitetaan kustannusvaroitus (yksi pyyntö on tarkoituksellisen pieni).
# Täysajossa varoitetaan, jos kehote/kysymys on muuttunut (uudelleenajo maksaa).
if not vain_yksi_era and vanhentuneet > 0:
# Varoitetaan vain jos kehote/kysymys OIKEASTI muuttui (uudelleenajo maksaa).
# Pelkkä täydennys (puuttuvia vastauksia) ei ole muutos → ei varoitusta.
if not vain_yksi_era and muuttuneet > 0:
valinta = valitse_listasta(
stdscr,
"LLM-arviointi — kehote tai kysymys on muuttunut",
[
f"Aja LLM: {uudet} uutta + {vanhentuneet} uudelleen (muutos) — vain muuttuneet kysymykset",
f"Aja LLM: {uudet} uutta + {taydennettavat} täydennys + {muuttuneet} uudelleen (muutos)",
"Peruuta",
],
)
Expand All @@ -96,32 +136,37 @@ def _aja_llm(stdscr, tutkimus: dict, vain_yksi_era: bool = False) -> None:
nayta_viesti(stdscr, f"Mallia ei voi käyttää: {e}")
return

stdscr.addstr(3, 0, "Yhdistetään LLM:ään...")
stdscr.refresh()
otsikko_ajo = f"{otsikko} — {tutkimus['LuokittelunNimi']}"
saatu = [dict(_TYHJA_TILASTO)] # viimeisin tilasto talteen loppunäyttöä varten
keskeytetty = [False]

_odottaa = [True]
def edistyminen(n, yht, erä, erat, tilasto):
saatu[0] = tilasto
_piirra_edistyminen(stdscr, otsikko_ajo, f"Erä {erä}/{erat} — lähettää... ({n}/{yht} käsitelty)", tilasto)

def edistyminen(n, yht, erä, erat):
if _odottaa[0]:
stdscr.addstr(4, 0, f" Erä {erä}/{erat} — lähettää... ({n}/{yht} käsitelty) ")
else:
stdscr.addstr(4, 0, f" Erä {erä}/{erat} — {n}/{yht} käsitelty ")
_odottaa[0] = not _odottaa[0]
stdscr.refresh()
def keskeyta():
if stdscr.getch() in (ord("q"), ord("Q")):
keskeytetty[0] = True
return True
return False

# nodelay(True): getch ei blokkaa → 'q' luetaan erien välissä keskeyttämättä ajoa.
stdscr.nodelay(True)
virhe = None
try:
arvioitu = llmarviointi.aja(tutkimus, edistyminen,
max_erat=1 if vain_yksi_era else None, tieto=tieto)
piirra_otsikko(stdscr, "LLM-arviointi — valmis")
stdscr.addstr(3, 0, f"Arvioitu: {arvioitu}")
if vain_yksi_era and arvioitu < uudet + vanhentuneet:
jaljella = uudet + vanhentuneet - arvioitu
stdscr.addstr(4, 0, f"Jäljellä vielä {jaljella} kurssia (aja uudelleen jatkaaksesi).")
nayta_viesti(stdscr, "", 6)
llmarviointi.aja(tutkimus, edistyminen, max_erat=1 if vain_yksi_era else None,
tieto=tieto, keskeyta_cb=keskeyta)
except EnvironmentError as e:
nayta_viesti(stdscr, f"Virhe: {e}")
virhe = f"Virhe: {e}"
except Exception as e:
nayta_viesti(stdscr, f"LLM-virhe: {e}")
virhe = f"LLM-virhe: {e}"
finally:
stdscr.nodelay(False)

if virhe:
nayta_viesti(stdscr, virhe)
return
_nayta_yhteenveto(stdscr, otsikko, keskeytetty[0], saatu[0], yhteensa)


def _aja_testiera(stdscr, tutkimus: dict) -> None:
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
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion llm/ARVIOINTIPROTOKOLLA.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ LLM:lle lähetetään yksi pyyntö per erä kursseja. Viesti koostuu neljästä
### 1. Järjestelmäkehote

Vakioteksti, joka asettaa LLM:n rooliin ja määrittelee vastausformaatin (JSON).
Tallennetaan tiedostoon `kehoteet/jarjestelma.txt`.
Tallennetaan tiedostoon `kehotteet/jarjestelma.txt`.

### 2. Arviointikehote

Expand Down
4 changes: 2 additions & 2 deletions llm/kehoteet.py → llm/kehotteet.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Järjestelmäkehotteiden lukeminen kehoteet/-kansiosta.
"""Järjestelmäkehotteiden lukeminen kehotteet/-kansiosta.

Jaettu lukija, jotta sekä pipeline (luokittelu/arviointi) että WebUI voivat
laskea kehotetiivisteet samasta lähteestä ilman koodin kahdentamista.
"""
import os

_KANSIO = os.path.join(os.path.dirname(__file__), "..", "kehoteet")
_KANSIO = os.path.join(os.path.dirname(__file__), "..", "kehotteet")


def lue(tiedosto: str) -> str:
Expand Down
Loading