diff --git a/.gitignore b/.gitignore index 32fb050..c9fe015 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ Thumbs.db # Kehitysaikaiset artefaktit status +loki.log # Testierä-tilastot (generoituja) testitulokset/ diff --git a/CLAUDE.md b/CLAUDE.md index e64caee..8bc5f45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/arviointi/llmarviointi.py b/arviointi/llmarviointi.py index f24c66b..730fa0a 100644 --- a/arviointi/llmarviointi.py +++ b/arviointi/llmarviointi.py @@ -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 @@ -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]: @@ -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 @@ -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) @@ -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]]]: @@ -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, @@ -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) @@ -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) diff --git a/cliui/arviointinaytto.py b/cliui/arviointinaytto.py index 812d851..7c76eb2 100644 --- a/cliui/arviointinaytto.py +++ b/cliui/arviointinaytto.py @@ -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() @@ -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", ], ) @@ -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: diff --git a/cliui/asetuksetnaytto.py b/cliui/asetuksetnaytto.py index 56464b5..5936d70 100644 --- a/cliui/asetuksetnaytto.py +++ b/cliui/asetuksetnaytto.py @@ -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: diff --git a/kehotteet b/kehoteraportti similarity index 100% rename from kehotteet rename to kehoteraportti diff --git a/kehoteet/arviointi_jarjestelma.txt b/kehotteet/arviointijarjestelma.txt similarity index 100% rename from kehoteet/arviointi_jarjestelma.txt rename to kehotteet/arviointijarjestelma.txt diff --git a/kehoteet/luokittelu_jarjestelma.txt b/kehotteet/luokittelujarjestelma.txt similarity index 100% rename from kehoteet/luokittelu_jarjestelma.txt rename to kehotteet/luokittelujarjestelma.txt diff --git a/kehoteet/raportti_jarjestelma.txt b/kehotteet/raporttijarjestelma.txt similarity index 100% rename from kehoteet/raportti_jarjestelma.txt rename to kehotteet/raporttijarjestelma.txt diff --git a/llm/ARVIOINTIPROTOKOLLA.md b/llm/ARVIOINTIPROTOKOLLA.md index 3568ff2..ffc1d42 100644 --- a/llm/ARVIOINTIPROTOKOLLA.md +++ b/llm/ARVIOINTIPROTOKOLLA.md @@ -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 diff --git a/llm/kehoteet.py b/llm/kehotteet.py similarity index 74% rename from llm/kehoteet.py rename to llm/kehotteet.py index 3688c1d..d4ed712 100644 --- a/llm/kehoteet.py +++ b/llm/kehotteet.py @@ -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: diff --git a/llm/kutsu.py b/llm/kutsu.py index 835f29a..959d7d2 100644 --- a/llm/kutsu.py +++ b/llm/kutsu.py @@ -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}"}, diff --git a/llm/mallitiedot.py b/llm/mallitiedot.py index 2448eb0..bea3496 100644 --- a/llm/mallitiedot.py +++ b/llm/mallitiedot.py @@ -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: @@ -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]]: @@ -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) diff --git a/luokittelu/llmluokittelu.py b/luokittelu/llmluokittelu.py index 160df58..db4ea56 100644 --- a/luokittelu/llmluokittelu.py +++ b/luokittelu/llmluokittelu.py @@ -2,7 +2,7 @@ import json from concurrent.futures import ThreadPoolExecutor, as_completed from tietokanta import mallit -from llm import kutsu, tiiviste, kehoteet, kurssimuoto, asetukset +from llm import kutsu, tiiviste, kehotteet, kurssimuoto, asetukset _OLETUS_ERAKOKO = 20 # kursseja per LLM-kutsu; .env:n LUOKITTELU_ERAKOKO ohittaa _OLETUS_RINNAKKAISUUS = 5 # rinnakkaisia LLM-kutsuja; .env:n LLM_RINNAKKAISUUS ohittaa @@ -18,8 +18,8 @@ def rinnakkaisuus() -> int: return max(1, asetukset.lue_int("LLM_RINNAKKAISUUS", _OLETUS_RINNAKKAISUUS)) -def _lue_jarjestelma_kehote() -> str: - return kehoteet.lue("luokittelu_jarjestelma.txt") +def _lue_jarjestelmakehote() -> str: + return kehotteet.lue("luokittelujarjestelma.txt") def _erittele_json(teksti: str) -> list[dict]: @@ -52,7 +52,7 @@ def _luokittele_erä(erä: list[dict], luokittelukehote: str, jarjestelma: str) def laske_tiiviste(tutkimus: dict) -> str: """Nykyisen luokittelukehotteen tiiviste (kehote + järjestelmäkehote).""" - return tiiviste.luokittelu(tutkimus["Luokittelukehote"], _lue_jarjestelma_kehote()) + return tiiviste.luokittelu(tutkimus["Luokittelukehote"], _lue_jarjestelmakehote()) def aja(tutkimus: dict, edistyminen_cb=None) -> tuple[int, int, int]: @@ -71,7 +71,7 @@ def aja(tutkimus: dict, edistyminen_cb=None) -> tuple[int, int, int]: """ tid = tutkimus["TID"] luokittelukehote = tutkimus["Luokittelukehote"] - jarjestelma = _lue_jarjestelma_kehote() + jarjestelma = _lue_jarjestelmakehote() tiiv = tiiviste.luokittelu(luokittelukehote, jarjestelma) # Tiiviste mukana → ajaa myös vanhentuneen kehotteen tulokset uudelleen, diff --git a/luokittelu/testierat.py b/luokittelu/testierat.py index eff7f31..cd21f14 100644 --- a/luokittelu/testierat.py +++ b/luokittelu/testierat.py @@ -88,7 +88,7 @@ def aja_testierat(tutkimus: dict, erakoko: int, montako_era: int, polku = tilastopolku or TILASTOPOLKU tid = tutkimus["TID"] luokittelukehote = tutkimus["Luokittelukehote"] - jarjestelma = llmluokittelu._lue_jarjestelma_kehote() + jarjestelma = llmluokittelu._lue_jarjestelmakehote() tiiv = tiiviste.luokittelu(luokittelukehote, jarjestelma) malli = kutsu.hae_malli() diff --git a/raportti/_kehotteet.py b/raportti/_kehotteet.py index a1dfd39..785ad2e 100644 --- a/raportti/_kehotteet.py +++ b/raportti/_kehotteet.py @@ -30,7 +30,7 @@ tid = tutkimus["TID"] tilastot = mallit.hae_tilastot_yliopistoittain(tid) kysymykset = mallit.hae_kysymykset(tid) -jarjestelma = llmraportti._lue_jarjestelma_kehote() +jarjestelma = llmraportti._lue_jarjestelmakehote() viestirakentajat = { "johdanto": lambda: llmraportti._rakenna_johdanto_viesti(tutkimus, tilastot), diff --git a/raportti/llmraportti.py b/raportti/llmraportti.py index cafdccd..7b14426 100644 --- a/raportti/llmraportti.py +++ b/raportti/llmraportti.py @@ -6,8 +6,8 @@ OSIOT = ["johdanto", "kurssit", "arvioinnit"] -def _lue_jarjestelma_kehote() -> str: - polku = os.path.join(os.path.dirname(__file__), "..", "kehoteet", "raportti_jarjestelma.txt") +def _lue_jarjestelmakehote() -> str: + polku = os.path.join(os.path.dirname(__file__), "..", "kehotteet", "raporttijarjestelma.txt") with open(polku, encoding="utf-8") as f: return f.read().strip() @@ -104,7 +104,7 @@ def aja(tutkimus: dict, edistyminen_cb=None) -> int: Palauttaa generoitujen osioiden määrän. Idempotentti — korvaa olemassa olevat. """ tid = tutkimus["TID"] - jarjestelma = _lue_jarjestelma_kehote() + jarjestelma = _lue_jarjestelmakehote() tilastot = mallit.hae_tilastot_yliopistoittain(tid) kysymykset = mallit.hae_kysymykset(tid) diff --git a/testit/test_arviointi.py b/testit/test_arviointi.py index 65133ef..61ef39c 100644 --- a/testit/test_arviointi.py +++ b/testit/test_arviointi.py @@ -23,6 +23,13 @@ TUTKIMUS = {"TID": 1, "LuokittelunNimi": "Testi", "Arviointikehote": "Arvioi kyberturvallisuusrelevanssi."} +@pytest.fixture(autouse=True) +def _ei_kirjoita_lokia(): + """Estää testejä kirjoittamasta loki.log:ia työhakemistoon (aja() kirjaa siihen).""" + with patch("arviointi.llmarviointi._kirjaa"): + yield + + class TestErittelleJson: def test_erittele_json_puhdas(self): tulos = llmarviointi._erittele_json(LLM_VASTAUS) @@ -57,7 +64,7 @@ def test_aja_kirjoittaa_vastaukset(self): patch("arviointi.llmarviointi.kutsu.kysy", return_value=LLM_VASTAUS), \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="testimalli"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus") as mock_aseta, \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): arvioitu = llmarviointi.aja(TUTKIMUS) assert arvioitu == 2 @@ -81,7 +88,7 @@ def test_aja_ohittaa_jo_arvioidut_samalla_tiivisteella(self): patch("arviointi.llmarviointi.mallit.hae_vastaus_tiivisteet", return_value=olemassa), \ patch("arviointi.llmarviointi.kutsu.kysy") as mock_kysy, \ patch("arviointi.llmarviointi.mallit.aseta_vastaus") as mock_aseta, \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value=jarj): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value=jarj): arvioitu = llmarviointi.aja(TUTKIMUS) assert arvioitu == 0 mock_kysy.assert_not_called() @@ -103,7 +110,7 @@ def test_aja_kysyy_vain_muuttuneen_kysymyksen(self): patch("arviointi.llmarviointi.kutsu.kysy", return_value=vastaus) as mock_kysy, \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus") as mock_aseta, \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value=jarj): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value=jarj): llmarviointi.aja(TUTKIMUS) viesti = mock_kysy.call_args.args[0] assert "Soveltuuko" in viesti # kysymys 11 mukana @@ -122,7 +129,7 @@ def test_aja_ilman_valittuja_kursseja_palauttaa_nollan(self): with patch("arviointi.llmarviointi.mallit.hae_kysymykset", return_value=KYSYMYKSET), \ patch("arviointi.llmarviointi.mallit.hae_valitut_kurssit", return_value=[]), \ patch("arviointi.llmarviointi.mallit.hae_vastaus_tiivisteet", return_value={}), \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"), \ + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"), \ patch("arviointi.llmarviointi.kutsu.kysy") as mock_kysy: arvioitu = llmarviointi.aja(TUTKIMUS) assert arvioitu == 0 @@ -135,14 +142,14 @@ def test_aja_kutsuu_kysy_json_muodolla(self): patch("arviointi.llmarviointi.kutsu.kysy", return_value=LLM_VASTAUS) as mock_kysy, \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus"), \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): llmarviointi.aja(TUTKIMUS) _, kwargs = mock_kysy.call_args assert kwargs.get("json_muoto") is True def test_aja_kutsuu_edistyminen_cb(self): tapahtumat = [] - def edistyminen(n, yht, erä, erat): + def edistyminen(n, yht, erä, erat, tilasto): tapahtumat.append((n, yht, erä, erat)) with patch("arviointi.llmarviointi.mallit.hae_kysymykset", return_value=KYSYMYKSET), \ @@ -151,7 +158,7 @@ def edistyminen(n, yht, erä, erat): patch("arviointi.llmarviointi.kutsu.kysy", return_value=LLM_VASTAUS), \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus"), \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): llmarviointi.aja(TUTKIMUS, edistyminen) # Pre-batch (0 käsitelty) ja post-batch (2 käsitelty) — yhteensä 2 tapahtumaa @@ -172,8 +179,8 @@ def test_aja_max_erat_rajaa_yhteen_pyyntoon(self): patch("arviointi.llmarviointi.kutsu.kysy", return_value=vastaus) as mock_kysy, \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus"), \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"): - llmarviointi.aja(TUTKIMUS, lambda n, y, e, et: tapahtumat.append(et), max_erat=1) + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): + llmarviointi.aja(TUTKIMUS, lambda n, y, e, et, ti: tapahtumat.append(et), max_erat=1) assert mock_kysy.call_count == 1 assert all(et == 1 for et in tapahtumat) # eräkokonaismäärä näkyy rajattuna @@ -188,7 +195,7 @@ def test_aja_ilman_max_erat_ajaa_kaikki_erat(self): patch("arviointi.llmarviointi.kutsu.kysy", return_value=vastaus) as mock_kysy, \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus"), \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): llmarviointi.aja(TUTKIMUS) assert mock_kysy.call_count == 2 @@ -203,10 +210,11 @@ def test_laske_tyomaara_erottaa_uudet_ja_vanhentuneet(self): with patch("arviointi.llmarviointi.mallit.hae_kysymykset", return_value=KYSYMYKSET), \ patch("arviointi.llmarviointi.mallit.hae_valitut_kurssit", return_value=KURSSIT), \ patch("arviointi.llmarviointi.mallit.hae_vastaus_tiivisteet", return_value=olemassa), \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value=jarj): - uudet, vanhentuneet = llmarviointi.laske_tyomaara(TUTKIMUS) - assert uudet == 1 - assert vanhentuneet == 1 + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value=jarj): + uudet, taydennettavat, muuttuneet = llmarviointi.laske_tyomaara(TUTKIMUS) + assert uudet == 1 # kurssi 2: ei vastauksia + assert taydennettavat == 0 + assert muuttuneet == 1 # kurssi 1: kysymys 11 vanhentunut (tiiviste eroaa) def test_kysymystiivisteet_ei_hae_kursseja_eika_vastauksia(self): """Kevyt tiivistehaku käyttää vain hae_kysymykset — ei kurssien/vastausten @@ -214,7 +222,7 @@ def test_kysymystiivisteet_ei_hae_kursseja_eika_vastauksia(self): with patch("arviointi.llmarviointi.mallit.hae_kysymykset", return_value=KYSYMYKSET), \ patch("arviointi.llmarviointi.mallit.hae_valitut_kurssit") as mock_kurssit, \ patch("arviointi.llmarviointi.mallit.hae_vastaus_tiivisteet") as mock_vast, \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value="system"): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): tiivisteet = llmarviointi._kysymystiivisteet(TUTKIMUS) assert set(tiivisteet) == {10, 11} # KysID:t assert all(len(t) == 64 for t in tiivisteet.values()) @@ -224,12 +232,14 @@ def test_kysymystiivisteet_ei_hae_kursseja_eika_vastauksia(self): def test_laske_tyomaara_esilasketulla_tiedolla_ei_hae_uudelleen(self): """Annettu tieto → ei uutta _selvita_tyo-hakua (ei kurssien/vastausten latausta).""" tieto = {"kysymykset": KYSYMYKSET, - "olemassa": {(1, 10): {"tiiviste": "x", "vastattu": True}}, + "kys_tiiviste": {10: "x10", 11: "x11"}, + "olemassa": {(1, 10): {"tiiviste": "x10", "vastattu": True}}, "tyo": {1: [KYSYMYKSET[1]], 2: list(KYSYMYKSET)}} with patch("arviointi.llmarviointi._selvita_tyo") as mock_selvita: - uudet, vanhentuneet = llmarviointi.laske_tyomaara(TUTKIMUS, tieto=tieto) + uudet, taydennettavat, muuttuneet = llmarviointi.laske_tyomaara(TUTKIMUS, tieto=tieto) mock_selvita.assert_not_called() - assert (uudet, vanhentuneet) == (1, 1) # kurssi 1: oli vastauksia; kurssi 2: uusi + # kurssi 1: kysymys 11 vain puuttuu (10 ajan tasalla) → täydennys; kurssi 2: uusi + assert (uudet, taydennettavat, muuttuneet) == (1, 1, 0) def test_aja_esilasketulla_tiedolla_ei_hae_kursseja(self): """aja(tieto=...) ei kutsu _selvita_tyo:tä → ei hae_valitut_kurssit-latausta.""" @@ -246,13 +256,70 @@ def test_aja_esilasketulla_tiedolla_ei_hae_kursseja(self): patch("arviointi.llmarviointi.kutsu.kysy", return_value=LLM_VASTAUS), \ patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ patch("arviointi.llmarviointi.mallit.aseta_vastaus") as mock_aseta, \ - patch("arviointi.llmarviointi._lue_jarjestelma_kehote", return_value=jarj): + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value=jarj): arvioitu = llmarviointi.aja(TUTKIMUS, tieto=tieto) assert arvioitu == 2 assert mock_aseta.call_count == 4 # 2 kurssia × 2 kysymystä mk.assert_not_called(); mvk.assert_not_called(); mvt.assert_not_called() +class TestLuokitteleVirhe: + def test_tyhja_vastaus(self): + e = ValueError("LLM palautti tyhjän vastauksen (finish_reason: stop)") + assert llmarviointi._luokittele_virhe(e) == "tyhja" + + def test_502(self): + e = RuntimeError("OpenRouter-virhe (502): Upstream error") + assert llmarviointi._luokittele_virhe(e) == "virhe_502" + + def test_max_tokens_finish_reasonista(self): + import json as _json + e = _json.JSONDecodeError("Unterminated string", "doc", 5) + with patch("arviointi.llmarviointi.kutsu.hae_viimeisin_kaytto", + return_value={"finish_reason": "length"}): + assert llmarviointi._luokittele_virhe(e) == "max_tokens" + + def test_muoto_kun_ei_length(self): + e = ValueError("Ei JSON-objektia vastauksessa: xyz") + with patch("arviointi.llmarviointi.kutsu.hae_viimeisin_kaytto", + return_value={"finish_reason": "stop"}): + assert llmarviointi._luokittele_virhe(e) == "muoto" + + +@pytest.fixture +def aja_ymparisto(): + """Mockaa aja():n DB- ja kehote-riippuvuudet; testi asettaa vain kutsu.kysy:n.""" + with patch("arviointi.llmarviointi.mallit.hae_kysymykset", return_value=KYSYMYKSET), \ + patch("arviointi.llmarviointi.mallit.hae_valitut_kurssit", return_value=KURSSIT), \ + patch("arviointi.llmarviointi.mallit.hae_vastaus_tiivisteet", return_value={}), \ + patch("arviointi.llmarviointi.kutsu.hae_malli", return_value="m"), \ + patch("arviointi.llmarviointi.mallit.aseta_vastaus"), \ + patch("arviointi.llmarviointi._lue_jarjestelmakehote", return_value="system"): + yield + + +class TestAjaTilastoJaKeskeytys: + def test_tilasto_kirjaa_onnistuneet(self, aja_ymparisto): + tilastot = [] + with patch("arviointi.llmarviointi.kutsu.kysy", return_value=LLM_VASTAUS): + llmarviointi.aja(TUTKIMUS, lambda n, y, e, et, ti: tilastot.append(dict(ti))) + assert tilastot[-1]["onnistuneet"] == 2 + + def test_tilasto_luokittelee_epaonnistuneen_eran(self, aja_ymparisto): + tilastot = [] + with patch("arviointi.llmarviointi.kutsu.kysy", + side_effect=RuntimeError("OpenRouter-virhe (502): Upstream")): + llmarviointi.aja(TUTKIMUS, lambda n, y, e, et, ti: tilastot.append(dict(ti))) + assert tilastot[-1]["virhe_502"] == 2 # 2 kurssia yhdessä erässä + assert tilastot[-1]["onnistuneet"] == 0 + + def test_keskeyta_cb_lopettaa_ennen_eria(self, aja_ymparisto): + with patch("arviointi.llmarviointi.kutsu.kysy", return_value=LLM_VASTAUS) as mock_kysy: + arvioitu = llmarviointi.aja(TUTKIMUS, keskeyta_cb=lambda: True) + assert arvioitu == 0 + mock_kysy.assert_not_called() + + KYSYMYKSET_MONITYYPPI = [ {"KysID": 10, "TID": 1, "Kysymys": "Kuvaile kurssia.", "Luokittelu": "vapaa_teksti", "LuokitteluMaarittely": None}, @@ -369,3 +436,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}) == [] diff --git a/testit/test_luokittelu.py b/testit/test_luokittelu.py index 4619564..e2b0454 100644 --- a/testit/test_luokittelu.py +++ b/testit/test_luokittelu.py @@ -147,7 +147,7 @@ def test_aja_kirjoittaa_luokitukset(self): side_effect=[kandidaatit, []]) as mock_hae, \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value=self.LLM_VASTAUS), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus") as mock_aseta, \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus) assert mukana == 1 assert hylätty == 1 @@ -170,7 +170,7 @@ def test_aja_tyhja_kehote_hyvaksyy_meta_ilman_llm(self): with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ patch("luokittelu.llmluokittelu.kutsu.kysy") as mock_kysy, \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus") as mock_aseta, \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus) assert (mukana, hylätty, luokittelematta) == (2, 0, 0) assert mock_kysy.call_count == 0 # ei LLM-kutsuja @@ -187,7 +187,7 @@ def test_aja_viallinen_era_ei_kaada_ajoa(self): with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value="ei kelvollista jsonia"), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus") as mock_aseta, \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus) assert (mukana, hylätty) == (0, 0) assert luokittelematta == 1 # ainoa kurssi jäi ilman päätöstä @@ -214,7 +214,7 @@ def test_aja_edistyminen_saa_juoksevat_mukana_ja_hylatty(self): side_effect=[kandidaatit, []]), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value=self.LLM_VASTAUS), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus"), \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): llmluokittelu.aja(tutkimus, edistyminen_cb=lambda *a: kutsut.append(a)) # (käsitelty, yhteensä, valmiit, erät, mukana, hylätty, epäonnistunut) viimeinen = kutsut[-1] @@ -234,7 +234,7 @@ def test_aja_edistyminen_laskee_epaonnistuneet_kurssit(self): with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value="ei kelvollista jsonia"), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus"), \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): llmluokittelu.aja(tutkimus, edistyminen_cb=lambda *a: kutsut.append(a)) viimeinen = kutsut[-1] assert (viimeinen[4], viimeinen[5], viimeinen[6]) == (0, 0, 1) # mukaan, hylätty, epäonnistunut @@ -251,7 +251,7 @@ def test_aja_tilasto_erottaa_menetetyn_eran(self): with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value="ei kelvollista jsonia"), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus"), \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): llmluokittelu.aja(tutkimus, edistyminen_cb=lambda *a: kutsut.append(a)) tilasto = kutsut[-1][7] assert tilasto == {"menetetyt_erat": 1, "menetetyt_kurssit": 1, "ilman_vastausta": 0} @@ -273,7 +273,7 @@ def test_aja_tilasto_laskee_ilman_vastausta_kurssit(self): patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", side_effect=[kandidaatit, []]), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value=vain_id1), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus"), \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): llmluokittelu.aja(tutkimus, edistyminen_cb=lambda *a: kutsut.append(a)) tilasto = kutsut[-1][7] assert tilasto == {"menetetyt_erat": 0, "menetetyt_kurssit": 0, "ilman_vastausta": 1} @@ -293,7 +293,7 @@ def test_aja_keskeytys_lopettaa_ja_sailyttaa_tehdyt(self, monkeypatch): patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value=yksi), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus") as mock_aseta, \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): # Callback pyytää heti keskeytystä (palauttaa True ensimmäisellä kutsulla) mukana, hylätty, _ = llmluokittelu.aja(tutkimus, edistyminen_cb=lambda *a: True) assert mock_aseta.call_count == 1 # vain ensimmäinen erä ehti tallentua @@ -325,6 +325,6 @@ def vakooja(*a, max_workers=None, **k): patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", side_effect=[kandidaatit, []]), \ patch("luokittelu.llmluokittelu.kutsu.kysy", return_value=self.LLM_VASTAUS), \ patch("luokittelu.llmluokittelu.mallit.aseta_luokitus"), \ - patch("luokittelu.llmluokittelu._lue_jarjestelma_kehote", return_value="system"): + patch("luokittelu.llmluokittelu._lue_jarjestelmakehote", return_value="system"): llmluokittelu.aja(tutkimus) assert nahdyt["max_workers"] == 7 diff --git a/testit/test_mallitiedot.py b/testit/test_mallitiedot.py index 0a15b56..87c15ad 100644 --- a/testit/test_mallitiedot.py +++ b/testit/test_mallitiedot.py @@ -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 diff --git a/testit/test_raportti.py b/testit/test_raportti.py index 29c6a39..0aa6315 100644 --- a/testit/test_raportti.py +++ b/testit/test_raportti.py @@ -111,7 +111,7 @@ def test_aja_generoi_kaikki_osiot(self): patch("raportti.llmraportti.mallit.hae_arviokommentit_kaikki", return_value=[]), \ patch("raportti.llmraportti.mallit.aseta_raportti_osio") as mock_aseta, \ patch("raportti.llmraportti.kutsu.kysy", return_value="LLM-teksti") as mock_kysy, \ - patch("raportti.llmraportti._lue_jarjestelma_kehote", return_value="jarj"): + patch("raportti.llmraportti._lue_jarjestelmakehote", return_value="jarj"): lkm = llmraportti.aja(TUTKIMUS) assert lkm == 3 @@ -127,7 +127,7 @@ def test_aja_tallentaa_llm_tekstin(self): patch("raportti.llmraportti.mallit.hae_arviokommentit_kaikki", return_value=[]), \ patch("raportti.llmraportti.mallit.aseta_raportti_osio") as mock_aseta, \ patch("raportti.llmraportti.kutsu.kysy", return_value="Generoitu teksti"), \ - patch("raportti.llmraportti._lue_jarjestelma_kehote", return_value="jarj"): + patch("raportti.llmraportti._lue_jarjestelmakehote", return_value="jarj"): llmraportti.aja(TUTKIMUS) mock_aseta.assert_any_call(1, "johdanto", "Generoitu teksti") @@ -142,7 +142,7 @@ def edistyminen(n, yht, avain): patch("raportti.llmraportti.mallit.hae_arviokommentit_kaikki", return_value=[]), \ patch("raportti.llmraportti.mallit.aseta_raportti_osio"), \ patch("raportti.llmraportti.kutsu.kysy", return_value="teksti"), \ - patch("raportti.llmraportti._lue_jarjestelma_kehote", return_value="jarj"): + patch("raportti.llmraportti._lue_jarjestelmakehote", return_value="jarj"): llmraportti.aja(TUTKIMUS, edistyminen) # 3 osio-callbackia + 1 "valmis" @@ -157,7 +157,7 @@ def test_aja_on_idempotentti(self): patch("raportti.llmraportti.mallit.hae_arviokommentit_kaikki", return_value=[]), \ patch("raportti.llmraportti.mallit.aseta_raportti_osio") as mock_aseta, \ patch("raportti.llmraportti.kutsu.kysy", return_value="teksti"), \ - patch("raportti.llmraportti._lue_jarjestelma_kehote", return_value="jarj"): + patch("raportti.llmraportti._lue_jarjestelmakehote", return_value="jarj"): llmraportti.aja(TUTKIMUS) llmraportti.aja(TUTKIMUS) diff --git a/testit/test_testierat.py b/testit/test_testierat.py index 77ff01f..9502c03 100644 --- a/testit/test_testierat.py +++ b/testit/test_testierat.py @@ -37,7 +37,7 @@ def mockit(): """Mockaa kanta, LLM-kutsu ja kehotteet — ei verkkoa, ei tiedostoja.""" with patch("luokittelu.testierat.mallit.hae_luokittelemattomat", return_value=_kurssit(4)), \ patch("luokittelu.testierat.testimallit.aseta_testiluokitus") as aseta, \ - patch("luokittelu.testierat.llmluokittelu._lue_jarjestelma_kehote", return_value="JARJ"), \ + patch("luokittelu.testierat.llmluokittelu._lue_jarjestelmakehote", return_value="JARJ"), \ patch("luokittelu.testierat.tiiviste.luokittelu", return_value="TIIV"), \ patch("luokittelu.testierat.kutsu.hae_malli", return_value="openai/gpt-oss-120b:free"), \ patch("luokittelu.testierat.kutsu.hae_viimeisin_kaytto", @@ -90,7 +90,7 @@ def test_sama_kurssi_ei_osu_kahteen_eraan(tmp_path): """Satunnaisotos ilman takaisinpanoa: kukin kurssi enintään yhdessä erässä.""" with patch("luokittelu.testierat.mallit.hae_luokittelemattomat", return_value=_kurssit(20)), \ patch("luokittelu.testierat.testimallit.aseta_testiluokitus") as aseta, \ - patch("luokittelu.testierat.llmluokittelu._lue_jarjestelma_kehote", return_value="JARJ"), \ + patch("luokittelu.testierat.llmluokittelu._lue_jarjestelmakehote", return_value="JARJ"), \ patch("luokittelu.testierat.tiiviste.luokittelu", return_value="TIIV"), \ patch("luokittelu.testierat.kutsu.hae_malli", return_value="m"), \ patch("luokittelu.testierat.kutsu.hae_viimeisin_kaytto", return_value={"finish_reason": "stop"}), \ @@ -105,7 +105,7 @@ def test_pudonneet_kurssit_lasketaan(tmp_path): polku = tmp_path / "tilastot.jsonl" with patch("luokittelu.testierat.mallit.hae_luokittelemattomat", return_value=_kurssit(2)), \ patch("luokittelu.testierat.testimallit.aseta_testiluokitus"), \ - patch("luokittelu.testierat.llmluokittelu._lue_jarjestelma_kehote", return_value="JARJ"), \ + patch("luokittelu.testierat.llmluokittelu._lue_jarjestelmakehote", return_value="JARJ"), \ patch("luokittelu.testierat.tiiviste.luokittelu", return_value="TIIV"), \ patch("luokittelu.testierat.kutsu.hae_malli", return_value="m"), \ patch("luokittelu.testierat.kutsu.hae_viimeisin_kaytto", return_value={"finish_reason": "stop"}), \ @@ -121,7 +121,7 @@ def test_jasennys_epaonnistuu_merkitaan(tmp_path): polku = tmp_path / "tilastot.jsonl" with patch("luokittelu.testierat.mallit.hae_luokittelemattomat", return_value=_kurssit(2)), \ patch("luokittelu.testierat.testimallit.aseta_testiluokitus"), \ - patch("luokittelu.testierat.llmluokittelu._lue_jarjestelma_kehote", return_value="JARJ"), \ + patch("luokittelu.testierat.llmluokittelu._lue_jarjestelmakehote", return_value="JARJ"), \ patch("luokittelu.testierat.tiiviste.luokittelu", return_value="TIIV"), \ patch("luokittelu.testierat.kutsu.hae_malli", return_value="m"), \ patch("luokittelu.testierat.kutsu.hae_viimeisin_kaytto", return_value={"finish_reason": "length"}), \ diff --git a/webui/Dockerfile b/webui/Dockerfile index a62b4da..f3a4221 100644 --- a/webui/Dockerfile +++ b/webui/Dockerfile @@ -8,7 +8,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY tietokanta/ tietokanta/ COPY luokittelu/ luokittelu/ COPY llm/ llm/ -COPY kehoteet/ kehoteet/ +COPY kehotteet/ kehotteet/ COPY webui/ webui/ EXPOSE 12121 diff --git a/webui/palvelin.py b/webui/palvelin.py index d5edc97..d8407ff 100644 --- a/webui/palvelin.py +++ b/webui/palvelin.py @@ -15,7 +15,7 @@ from tietokanta import mallit from tietokanta.valimuisti import ttl_valimuisti -from llm import tiiviste, kehoteet +from llm import tiiviste, kehotteet _AUTH_EVASTE = "opserver_auth" @@ -441,7 +441,7 @@ def api_tutkimus_arvioinnit(slug: str) -> dict: # Nykyiset kysymystiivisteet: tunnistavat vastaukset jotka on generoitu # vanhentuneeseen kysymykseen/kehotteeseen (ennen seuraavaa LLM-ajoa). - jarjestelma = kehoteet.lue("arviointi_jarjestelma.txt") + jarjestelma = kehotteet.lue("arviointijarjestelma.txt") nyky_tiiviste = tiiviste.kysymystiivisteet( tutkimus.get("Arviointikehote") or "", jarjestelma, kysymykset )