From 8470e1e087a1fd98d3efc2891cfb4bd9d51841cf Mon Sep 17 00:00:00 2001 From: Klaude Date: Sun, 12 Jul 2026 18:42:54 +0300 Subject: [PATCH 1/6] CLAUDE.md: kirjaa MySQL:n migraatio geopalvelin1:lle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL ei ole enää paikallinen Docker-kontti vaan etäpalvelin geopalvelin1 (Tailscale, korkea latenssi); yhteysasetukset .env:stä. Muistutus kirjoittaa DB-koodi etälatenssia varten (pooli, COUNT/aggregaatit). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From 5cd5b990e9090cc158aa6301f6b1a4cba09ae102 Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 13 Jul 2026 06:47:47 +0300 Subject: [PATCH 2/6] =?UTF-8?q?arviointi:=20kirjaa=20ep=C3=A4onnistuneet?= =?UTF-8?q?=20er=C3=A4t=20loki.log-tiedostoon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiljainen `except Exception` nielaisi eräkohtaiset virheet, joten epäonnistuneiden arviointien syytä ei voinut diagnosoida. Kirjataan nyt aikaleima, poikkeustyyppi/-viesti ja erän kurssien KID:t loki.log:iin. Co-Authored-By: Claude Opus 4.8 --- arviointi/llmarviointi.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/arviointi/llmarviointi.py b/arviointi/llmarviointi.py index f24c66b..b5ff69b 100644 --- a/arviointi/llmarviointi.py +++ b/arviointi/llmarviointi.py @@ -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 @@ -235,8 +236,13 @@ def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None, try: tulokset = _arvioi_erä(erä, arviointikehote, osa_kysymykset, jarjestelma) _tallenna_tulokset(tulokset, osa_kysymykset, malli, kys_tiiviste) - except Exception: + except Exception as e: # Viallinen erä ohitetaan; sen kurssit jäävät seuraavalle ajolle (idempotenssi). + kidt = [k.get("KID") for k in erä] + with open("loki.log", "a", encoding="utf-8") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [arviointi] " + f"erä {erä_nro}/{len(erat)} epäonnistui ({type(e).__name__}: {e}); " + f"kurssit {kidt} jäävät seuraavalle ajolle\n") tulokset = [] for t in tulokset: arvioidut.add(t.get("id")) From 102ce9b30f4cb25d3171cb7b07db6ab49c9dbc0a Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 13 Jul 2026 11:56:20 +0300 Subject: [PATCH 3/6] llm/arviointi: pakota JSON-tarjoaja ja salli ohjausmerkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arviointierät kaatuivat massoittain JSON-virheisiin. Kaksi syytä korjattu: - kutsu.py: provider.require_parameters=true → OpenRouter reitittää vain tarjoajille jotka kunnioittavat response_formatia (poistaa "OpenInference"- tyyliset 502-roskavastaukset ja tyhjät vastaukset). - llmarviointi.py: json.loads(strict=False) → raa'at ohjausmerkit (\n, \t) perusteluissa eivät enää hylkää muuten kelvollista JSONia. Katkeamiset (Unterminated string) hoituvat pienemmällä ARVIOINTI_ERAKOKO:lla. Co-Authored-By: Claude Opus 4.8 --- arviointi/llmarviointi.py | 4 +++- llm/kutsu.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/arviointi/llmarviointi.py b/arviointi/llmarviointi.py index b5ff69b..ecfa67a 100644 --- a/arviointi/llmarviointi.py +++ b/arviointi/llmarviointi.py @@ -23,7 +23,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 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}"}, From 12d07c9c6c341a544241e8ce6d95ee5d52b9d190 Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 13 Jul 2026 12:15:51 +0300 Subject: [PATCH 4/6] =?UTF-8?q?arviointi:=20torju=20v=C3=A4=C3=A4r=C3=A4t?= =?UTF-8?q?=20id:t=20ja=20kirjaa=20puuttuvat=20tulokset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured-output-oppaan kohta 6: aseta_vastaus on suora INSERT (KysID, KID), joten mallin hallusinoima id kirjoittaisi orpo-/väärän rivin. _siivoa_tulokset suodattaa tulokset vain erän kursseihin ja normalisoi id:t kokonaisluvuiksi. Lisäksi loki.log:iin kirjataan kun malli palauttaa vähemmän kursseja kuin pyydettiin (hiljainen osittaisvaje näkyviin). Loki-kirjoitus DRY:ttiin _kirjaa-apufunktioon. Co-Authored-By: Claude Opus 4.8 --- arviointi/llmarviointi.py | 38 ++++++++++++++++++++++++++++++++------ testit/test_arviointi.py | 16 ++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/arviointi/llmarviointi.py b/arviointi/llmarviointi.py index ecfa67a..0b2bf36 100644 --- a/arviointi/llmarviointi.py +++ b/arviointi/llmarviointi.py @@ -12,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") @@ -235,16 +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) + 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). - kidt = [k.get("KID") for k in erä] - with open("loki.log", "a", encoding="utf-8") as f: - f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [arviointi] " - f"erä {erä_nro}/{len(erat)} epäonnistui ({type(e).__name__}: {e}); " - f"kurssit {kidt} jäävät seuraavalle ajolle\n") + _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")) diff --git a/testit/test_arviointi.py b/testit/test_arviointi.py index 65133ef..f5d3bfb 100644 --- a/testit/test_arviointi.py +++ b/testit/test_arviointi.py @@ -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}) == [] From fd23d6625139d330aa984cbfad484a448b81b90b Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 13 Jul 2026 12:33:09 +0300 Subject: [PATCH 5/6] =?UTF-8?q?LLM-asetukset:=20n=C3=A4yt=C3=A4=20ja=20var?= =?UTF-8?q?oita=20mallin=20muototuesta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saatavuustarkistus varmisti vain että mallin id löytyy /models-listalta, ei sitä tukeeko malli response_formatia. OpenRouterin /models-vastauksessa on jo per-malli supported_parameters — luetaan se ilman lisäpyyntöä: - muoto_kentta() + uusi "muoto?"-sarake mallitaulukkoon (skeema/json/tools/—/?) - muototuki_varoitus() + "Tarkista saatavuus" varoittaa jos nykyinen malli ei ilmoita response_format-tukea (silloin require_parameters ei voi auttaa) Ei suodata malleja pois — vain näyttää tuen, jotta mallinvalinta on itsedokumentoiva sen sijaan että vika näkyy vasta loki.log:ssa. Co-Authored-By: Claude Opus 4.8 --- cliui/asetuksetnaytto.py | 9 ++++++- llm/mallitiedot.py | 51 ++++++++++++++++++++++++++++++++------ testit/test_mallitiedot.py | 31 ++++++++++++++++++++++- 3 files changed, 81 insertions(+), 10 deletions(-) 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/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/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 From efd2f38e6572f4af611417c1d279f4ea47c11397 Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 13 Jul 2026 16:41:10 +0300 Subject: [PATCH 6/6] =?UTF-8?q?Arviointin=C3=A4ytt=C3=B6:=20erottele=20t?= =?UTF-8?q?=C3=A4ydennys/muutos,=20n=C3=A4yt=C3=A4=20tilastot,=20keskeytys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neljä parannusta arvioinnin ajonäkymään: 1. laske_tyomaara palauttaa (uudet, täydennettävät, muuttuneet) — pelkkä puuttuva vastaus ei enää valehtele "kehote muuttunut" -varoituksena; varoitetaan vain kun tiiviste oikeasti eroaa. 2. Näyttö tyhjennetään joka ruudulla (stdscr.erase) — ei vanhaa valikkotekstiä roikkumaan "Yhdistetään LLM:ään..." perään. 3. aja() luokittelee epäonnistuneet erät (max_tokens/502/tyhjä/muoto) ja pitää elävää tilastoa; näyttö listaa onnistuneet + epäonnistuneet syittäin. 4. keskeyta_cb + stdscr.nodelay: 'q' keskeyttää ajon erien välissä ilman Ctrl-C:tä. Co-Authored-By: Claude Opus 4.8 --- arviointi/llmarviointi.py | 67 +++++++++++++++++++++------ cliui/arviointinaytto.py | 97 ++++++++++++++++++++++++++++----------- testit/test_arviointi.py | 76 ++++++++++++++++++++++++++---- 3 files changed, 191 insertions(+), 49 deletions(-) diff --git a/arviointi/llmarviointi.py b/arviointi/llmarviointi.py index 0b2bf36..c4f1b59 100644 --- a/arviointi/llmarviointi.py +++ b/arviointi/llmarviointi.py @@ -35,6 +35,22 @@ def _siivoa_tulokset(raaka: list[dict], odotetut: set) -> list[dict]: 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_jarjestelma_kehote() -> str: return kehoteet.lue("arviointi_jarjestelma.txt") @@ -185,25 +201,36 @@ def _kysymystiivisteet(tutkimus: dict) -> dict: tutkimus["Arviointikehote"], _lue_jarjestelma_kehote(), 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]]]: @@ -226,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, @@ -235,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) @@ -254,10 +285,13 @@ 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: raaka = _arvioi_erä(erä, arviointikehote, osa_kysymykset, jarjestelma) @@ -269,13 +303,16 @@ def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None, 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") + 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/testit/test_arviointi.py b/testit/test_arviointi.py index f5d3bfb..a762487 100644 --- a/testit/test_arviointi.py +++ b/testit/test_arviointi.py @@ -142,7 +142,7 @@ def test_aja_kutsuu_kysy_json_muodolla(self): 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), \ @@ -173,7 +173,7 @@ def test_aja_max_erat_rajaa_yhteen_pyyntoon(self): 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) + 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 @@ -204,9 +204,10 @@ def test_laske_tyomaara_erottaa_uudet_ja_vanhentuneet(self): 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 + 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 @@ -224,12 +225,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.""" @@ -253,6 +256,63 @@ def test_aja_esilasketulla_tiedolla_ei_hae_kursseja(self): 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_jarjestelma_kehote", 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},