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..0b2bf36 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 @@ -11,6 +12,29 @@ def erakoko() -> int: return asetukset.lue_int("ARVIOINTI_ERAKOKO", _OLETUS_ERAKOKO) +def _kirjaa(viesti: str) -> None: + """Lisää aikaleimattu rivi loki.log:iin (eräkohtaiset virheet/varoitukset).""" + with open("loki.log", "a", encoding="utf-8") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [arviointi] {viesti}\n") + + +def _siivoa_tulokset(raaka: list[dict], odotetut: set) -> list[dict]: + """Suodattaa mallin tulokset vain erän kursseihin ja normalisoi id:t kokonaisluvuiksi. + + Torjuu hallusinoidut / väärät id:t ennen tallennusta (opas: 'IDs must exist') — + muuten aseta_vastaus INSERTtäisi rivin kurssille jota erässä ei ollut. + """ + siivotut = [] + for t in raaka: + try: + kid = int(t.get("id")) + except (TypeError, ValueError): + continue + if kid in odotetut: + siivotut.append({**t, "id": kid}) + return siivotut + + def _lue_jarjestelma_kehote() -> str: return kehoteet.lue("arviointi_jarjestelma.txt") @@ -22,7 +46,9 @@ def _erittele_json(teksti: str) -> list[dict]: if alku != -1: loppu = teksti.rfind("}") if loppu != -1: - data = json.loads(teksti[alku:loppu + 1]) + # strict=False sallii raa'at ohjausmerkit (\n, \t) merkkijonoissa — + # malli laittaa niitä perusteluihin, ei syytä hylätä muuten kelvollista JSONia. + data = json.loads(teksti[alku:loppu + 1], strict=False) for arvo in data.values(): if isinstance(arvo, list): return arvo @@ -232,11 +258,19 @@ def aja(tutkimus: dict, edistyminen_cb=None, max_erat: int | None = None, for erä_nro, (osa_kysymykset, erä) in enumerate(erat, 1): if edistyminen_cb: edistyminen_cb(käsitelty, yhteensa, erä_nro, len(erat)) + odotetut = {k["KID"] for k in erä} try: - tulokset = _arvioi_erä(erä, arviointikehote, osa_kysymykset, jarjestelma) + raaka = _arvioi_erä(erä, arviointikehote, osa_kysymykset, jarjestelma) + tulokset = _siivoa_tulokset(raaka, odotetut) _tallenna_tulokset(tulokset, osa_kysymykset, malli, kys_tiiviste) - except Exception: + puuttuvat = odotetut - {t["id"] for t in tulokset} + if puuttuvat: + _kirjaa(f"erä {erä_nro}/{len(erat)}: malli palautti {len(tulokset)}/{len(odotetut)} " + f"kurssia; puuttuvat {sorted(puuttuvat)} jäävät seuraavalle ajolle") + except Exception as e: # Viallinen erä ohitetaan; sen kurssit jäävät seuraavalle ajolle (idempotenssi). + _kirjaa(f"erä {erä_nro}/{len(erat)} epäonnistui ({type(e).__name__}: {e}); " + f"kurssit {sorted(odotetut)} jäävät seuraavalle ajolle") tulokset = [] for t in tulokset: arvioidut.add(t.get("id")) 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/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/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}) == [] 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