From cc5716897af9e640bfb559dd0138aba8309efdaa Mon Sep 17 00:00:00 2001 From: Klaude Date: Sat, 4 Jul 2026 15:30:32 +0300 Subject: [PATCH 1/5] WebUI: perustelu-tooltip aukeaa vasemmalle (ei mene ikkunan oikean reunan yli) Co-Authored-By: Claude Opus 4.8 --- webui/staattinen/index.html | 2 +- webui/staattinen/tyyli.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/staattinen/index.html b/webui/staattinen/index.html index 48d57aa..8682e61 100644 --- a/webui/staattinen/index.html +++ b/webui/staattinen/index.html @@ -5,7 +5,7 @@ Opserver - +
diff --git a/webui/staattinen/tyyli.css b/webui/staattinen/tyyli.css index 8e080de..04baaa0 100644 --- a/webui/staattinen/tyyli.css +++ b/webui/staattinen/tyyli.css @@ -324,7 +324,7 @@ td.perustelu { .perustelu-detalji { display: none; position: absolute; - left: 0; + right: 0; top: 100%; z-index: 20; margin-top: 0.25rem; From 8e0a1808b7b9c2e274ed46084f338743a3924656 Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 6 Jul 2026 20:00:56 +0300 Subject: [PATCH 2/5] =?UTF-8?q?Luokittelu:=20passiluuppi,=20ep=C3=A4onnist?= =?UTF-8?q?umis-erittely=20ja=20ctrl-s-keskeytys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aja luokittelee passeina kunnes kaikki ehdokkaat käsitelty tai passi ei edisty. Statusnäyttö erittelee epäonnistumiset (täysin menetetyt erät vs. kurssit ilman vastausta) ja ctrl-s keskeyttää ajon siististi menettämättä jo tallennettuja luokituksia. Co-Authored-By: Claude Opus 4.8 --- cliui/luokittelunaytto.py | 74 +++++++++++++++++---- luokittelu/llmluokittelu.py | 125 ++++++++++++++++++++++++------------ testit/test_luokittelu.py | 82 ++++++++++++++++++++--- 3 files changed, 217 insertions(+), 64 deletions(-) diff --git a/cliui/luokittelunaytto.py b/cliui/luokittelunaytto.py index 0bc30d6..a7f7bac 100644 --- a/cliui/luokittelunaytto.py +++ b/cliui/luokittelunaytto.py @@ -140,25 +140,75 @@ def _aja_llm(stdscr, tutkimus: dict) -> None: stdscr.addstr(3, 0, "Yhdistetään LLM:ään...") stdscr.refresh() - def edistyminen(n, yht, erä, erat, mukana, hylätty, epaonnistunut): - osuus = f" (mukaan {mukana/(mukana + hylätty):.0%})" if (mukana + hylätty) else "" - stdscr.addstr(4, 0, f" Erä {erä}/{erat} — {n}/{yht} kurssia käsitelty") - stdscr.addstr(5, 0, f" Mukaan: {mukana} Hylätty: {hylätty} Epäonnistunut: {epaonnistunut}{osuus}") + keskeytetty = [False] + ctrl_s_kaytossa = [False] + + def rivi(nro: int, teksti: str) -> None: + stdscr.addstr(nro, 0, teksti) stdscr.clrtoeol() - stdscr.refresh() + def edistyminen(n, yht, erä, erat, mukana, hylätty, epaonnistunut, tilasto): + osuus = f" (mukaan {mukana/(mukana + hylätty):.0%})" if (mukana + hylätty) else "" + rivi(4, f" Erä {erä}/{erat} — {n}/{yht} kurssia käsitelty") + rivi(5, f" Mukaan: {mukana} Hylätty: {hylätty} Epäonnistunut: {epaonnistunut}{osuus}") + me, mk, iv = tilasto["menetetyt_erat"], tilasto["menetetyt_kurssit"], tilasto["ilman_vastausta"] + if me or iv: + rivi(6, " Epäonnistuneista:") + rivi(7, f" - täysin menetettyjä eriä: {me} erää, {mk} kurssia (harkitse eräkoon pienentämistä)" if me else "") + rivi(8, f" - kursseja ilman vastausta: {iv} kpl" if iv else "") + else: + rivi(6, ""); rivi(7, ""); rivi(8, "") + if ctrl_s_kaytossa[0]: + rivi(10, " Paina ctrl-s keskeyttääksesi ajon (jo tehtyjä luokitteluja ei menetetä!) ja muokataksesi asetuksia") + stdscr.refresh() + if ctrl_s_kaytossa[0] and stdscr.getch() == 19: # ctrl-s + keskeytetty[0] = True + return True + return False + + # ctrl-s pitää saada ajon aikana getch:lle: nodelay tekee pollauksesta + # estämättömän, ja IXON pois estää päätettä nielemästä ctrl-s:ää virtaus- + # kontrollina. Molemmat palautetaan finally-lohkossa. Jos stdin ei ole aito + # pääte (esim. testit), keskeytys jää pois käytöstä mutta ajo etenee normaalisti. + import sys, termios + fd = vanha_termios = None try: - mukana, hylätty, virheet = llmluokittelu.aja(tutkimus, edistyminen) - piirra_otsikko(stdscr, "LLM-luokittelu — valmis") - stdscr.addstr(3, 0, f"Mukaan otettu: {mukana}") - stdscr.addstr(4, 0, f"Hylätty: {hylätty}") - if virheet: - stdscr.addstr(5, 0, f"Ohitettuja viallisia eriä: {virheet} (kurssit tulevat seuraavalla ajolla)") - nayta_viesti(stdscr, "", 7) + fd = sys.stdin.fileno() + vanha_termios = termios.tcgetattr(fd) + muokattu = termios.tcgetattr(fd) + muokattu[0] &= ~termios.IXON + termios.tcsetattr(fd, termios.TCSANOW, muokattu) + stdscr.nodelay(True) + ctrl_s_kaytossa[0] = True + except Exception: + fd = vanha_termios = None # ctrl-s pois päältä; getch ei kysele + try: + mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus, edistyminen) except EnvironmentError as e: nayta_viesti(stdscr, f"Virhe: {e}") + return except Exception as e: nayta_viesti(stdscr, f"LLM-virhe: {e}") + return + finally: + if vanha_termios is not None: + stdscr.nodelay(False) + termios.tcsetattr(fd, termios.TCSANOW, vanha_termios) + + if keskeytetty[0]: + piirra_otsikko(stdscr, "LLM-luokittelu — keskeytetty") + stdscr.addstr(3, 0, f"Mukaan otettu: {mukana} Hylätty: {hylätty}") + stdscr.addstr(4, 0, "Jo tehdyt luokitukset on tallennettu — työ jatkuu mihin jäi.") + stdscr.addstr(5, 0, "Muokkaa asetuksia (esim. eräkoko) ja aja uudelleen.") + nayta_viesti(stdscr, "", 7) + return + + piirra_otsikko(stdscr, "LLM-luokittelu — valmis") + stdscr.addstr(3, 0, f"Mukaan otettu: {mukana}") + stdscr.addstr(4, 0, f"Hylätty: {hylätty}") + if luokittelematta: + stdscr.addstr(5, 0, f"Luokittelematta jäi: {luokittelematta} (LLM ei antanut päätöstä — pienennä LUOKITTELU_ERAKOKO)") + nayta_viesti(stdscr, "", 7) def _kysy_luvut(stdscr, otsikko: str) -> tuple[int, int] | None: diff --git a/luokittelu/llmluokittelu.py b/luokittelu/llmluokittelu.py index 45097ae..160df58 100644 --- a/luokittelu/llmluokittelu.py +++ b/luokittelu/llmluokittelu.py @@ -58,9 +58,16 @@ def laske_tiiviste(tutkimus: dict) -> str: def aja(tutkimus: dict, edistyminen_cb=None) -> tuple[int, int, int]: """Luokittelee meta-suodatuksen läpäisseet kurssit LLM:llä. - Palauttaa (mukana, hylätty, virhe_erat). Idempotentti. Yksittäisen erän - epäonnistuminen (esim. katkennut/viallinen LLM-JSON) ei kaada koko ajoa: - erä ohitetaan ja sen kurssit tulevat mukaan seuraavalla ajolla. + Palauttaa (mukana, hylätty, luokittelematta). Idempotentti. Ajaa passeja + kunnes kaikki ehdokkaat on luokiteltu tai passi ei enää edisty — silloin + loput jäävät luokittelematta (esim. erä katkeaa token-kattoon joka kerta → + pienennä LUOKITTELU_ERAKOKO). Yksittäisen erän epäonnistuminen (katkennut/ + viallinen LLM-JSON) ei kaada ajoa: sen kurssit tulevat seuraavalla passilla. + + edistyminen_cb saa 8. argumenttina tilasto-dictin nykyisen passin + epäonnistumisten erittelystä (menetetyt_erat, menetetyt_kurssit, + ilman_vastausta). Jos callback palauttaa truthy-arvon, ajo keskeytyy siististi + seuraavan valmiin erän kohdalla — jo tallennetut luokitukset säilyvät. """ tid = tutkimus["TID"] luokittelukehote = tutkimus["Luokittelukehote"] @@ -86,45 +93,79 @@ def aja(tutkimus: dict, edistyminen_cb=None) -> tuple[int, int, int]: malli = kutsu.hae_malli() koko = erakoko() - erat = [kandidaatit[i : i + koko] for i in range(0, len(kandidaatit), koko)] + yhteensa = len(kandidaatit) mukana = 0 hylätty = 0 - virhe_erat = 0 - käsitelty = 0 - valmiit = 0 - - # LLM-kutsut ajetaan rinnakkain (säikeissä), mutta tietokantakirjoitukset - # tehdään pääsäikeessä erien valmistuessa — yhteyttä ei jaeta säikeiden - # kesken. kutsu.py tahdistaa ja backoffaa säieturvallisesti yli kutsujen. - with ThreadPoolExecutor(max_workers=rinnakkaisuus()) as suoritin: - tulevat = { - suoritin.submit(_luokittele_erä, erä, luokittelukehote, jarjestelma): erä - for erä in erat - } - for tuleva in as_completed(tulevat): - erä = tulevat[tuleva] - try: - tulokset = tuleva.result() - except Exception: - virhe_erat += 1 # viallinen erä ohitetaan; kurssit jäävät seuraavalle ajolle - tulokset = [] - for tulos in tulokset: - kid = tulos.get("id") - if kid is None: - continue - on_mukana = bool(tulos.get("mukana")) - perustelu = tulos.get("perustelu", "") - mallit.aseta_luokitus(tid, kid, on_mukana, perustelu, malli, tiiviste=tiiv) - if on_mukana: - mukana += 1 - else: - hylätty += 1 - käsitelty += len(erä) - valmiit += 1 - if edistyminen_cb: - # epäonnistuneet = käsitellyt, jotka eivät saaneet päätöstä - # (viallisten erien kurssit + mahdolliset null-id-tulokset) - edistyminen_cb(käsitelty, len(kandidaatit), valmiit, len(erat), - mukana, hylätty, käsitelty - mukana - hylätty) - return mukana, hylätty, virhe_erat + # Passi = yksi kierros yli kaikkien vielä-luokittelemattomien. Toistetaan + # kunnes ei jää ehdokkaita tai passi ei tuottanut yhtään uutta päätöstä. + # ponytail: kattona "ei edistystä"; systemaattisesti katkeavaa erää tämä ei + # pelasta (sama koko → sama katkeaminen) — sen ratkaisu on pienempi eräkoko. + keskeytetty = False + while True: + erat = [kandidaatit[i : i + koko] for i in range(0, len(kandidaatit), koko)] + passin_paatokset = 0 + valmiit = 0 + # Epäonnistumisten erittely tälle passille: kokonaan menetetyt erät + # (LLM-JSON katkesi/viallinen → koko erä ilman tulosta) vs. yksittäiset + # kurssit, jotka LLM jätti pois muuten kelvollisesta vastauksesta. + menetetyt_erat = 0 + menetetyt_kurssit = 0 + ilman_vastausta = 0 + + # LLM-kutsut ajetaan rinnakkain (säikeissä), mutta tietokantakirjoitukset + # tehdään pääsäikeessä erien valmistuessa — yhteyttä ei jaeta säikeiden + # kesken. kutsu.py tahdistaa ja backoffaa säieturvallisesti yli kutsujen. + with ThreadPoolExecutor(max_workers=rinnakkaisuus()) as suoritin: + tulevat = { + suoritin.submit(_luokittele_erä, erä, luokittelukehote, jarjestelma): erä + for erä in erat + } + for tuleva in as_completed(tulevat): + erä = tulevat[tuleva] + try: + tulokset = tuleva.result() + except Exception: + tulokset = [] # viallinen erä; kurssit jäävät seuraavalle passille + menetetyt_erat += 1 + menetetyt_kurssit += len(erä) + saadut = set() + for tulos in tulokset: + kid = tulos.get("id") + if kid is None: + continue + saadut.add(kid) + on_mukana = bool(tulos.get("mukana")) + perustelu = tulos.get("perustelu", "") + mallit.aseta_luokitus(tid, kid, on_mukana, perustelu, malli, tiiviste=tiiv) + if on_mukana: + mukana += 1 + else: + hylätty += 1 + passin_paatokset += 1 + # Muuten kelvollinen erä, josta LLM jätti kursseja pois (ei menetetty erä). + if tulokset: + ilman_vastausta += sum(1 for k in erä if k["KID"] not in saadut) + valmiit += 1 + if edistyminen_cb: + # epäonnistunut = vielä ilman päätöstä olevat (kutistuu passeittain) + tilasto = {"menetetyt_erat": menetetyt_erat, + "menetetyt_kurssit": menetetyt_kurssit, + "ilman_vastausta": ilman_vastausta} + if edistyminen_cb(mukana + hylätty, yhteensa, valmiit, len(erat), + mukana, hylätty, yhteensa - mukana - hylätty, tilasto): + keskeytetty = True + if keskeytetty: + for t in tulevat: + t.cancel() # aloittamattomat perutaan; käynnissä olevat valmistuvat, tulos hylätään + break + + if keskeytetty: + break # jo tallennetut säilyvät; loput luokitellaan seuraavalla ajolla + if passin_paatokset == 0: + break # ei edistystä → loput epäonnistuvat pysyvästi (kandidaatit jää jäljelle) + kandidaatit = mallit.hae_luokittelemattomat(tid, tiiv) + if not kandidaatit: + break + + return mukana, hylätty, len(kandidaatit) diff --git a/testit/test_luokittelu.py b/testit/test_luokittelu.py index 71c0118..4619564 100644 --- a/testit/test_luokittelu.py +++ b/testit/test_luokittelu.py @@ -142,14 +142,16 @@ def test_aja_kirjoittaa_luokitukset(self): "Oppiaine": "FY", "Opintopisteet": 3, "Opetusvuosi": "2025-2026", "OpsKuvaus": None}, ] tutkimus = {"TID": 1, "Luokittelukehote": "Arvioi kyberturvallisuusrelevanssi."} - with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit) as mock_hae, \ + # Ensin ehdokkaat, sitten tyhjä → simuloi tietokannan tyhjenemistä passien välissä + with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", + 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"): - mukana, hylätty, virheet = llmluokittelu.aja(tutkimus) + mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus) assert mukana == 1 assert hylätty == 1 - assert virheet == 0 + assert luokittelematta == 0 assert mock_aseta.call_count == 2 # Kandidaatit haetaan tiivisteellä, jotta vanhentunut kehote ajetaan uudelleen tiiv = mock_hae.call_args.args[1] @@ -169,14 +171,14 @@ def test_aja_tyhja_kehote_hyvaksyy_meta_ilman_llm(self): 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"): - mukana, hylätty, virheet = llmluokittelu.aja(tutkimus) - assert (mukana, hylätty, virheet) == (2, 0, 0) + mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus) + assert (mukana, hylätty, luokittelematta) == (2, 0, 0) assert mock_kysy.call_count == 0 # ei LLM-kutsuja assert mock_aseta.call_count == 2 assert all(c.args[2] is True for c in mock_aseta.call_args_list) # kaikki mukaan def test_aja_viallinen_era_ei_kaada_ajoa(self): - """Katkennut/viallinen LLM-JSON ohitetaan: virhe lasketaan, ajo jatkuu.""" + """Katkennut/viallinen LLM-JSON ohitetaan: kurssi jää luokittelematta, ajo ei kaadu.""" kandidaatit = [ {"KID": 1, "KurssiNimi": "A", "Koodi": "X1", "Taso": "aine", "Oppiaine": "IT", "Opetusvuosi": "2025-2026", "OpsKuvaus": None}, @@ -186,9 +188,9 @@ def test_aja_viallinen_era_ei_kaada_ajoa(self): 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"): - mukana, hylätty, virheet = llmluokittelu.aja(tutkimus) + mukana, hylätty, luokittelematta = llmluokittelu.aja(tutkimus) assert (mukana, hylätty) == (0, 0) - assert virheet == 1 + assert luokittelematta == 1 # ainoa kurssi jäi ilman päätöstä assert mock_aseta.call_count == 0 def test_aja_tiiviste_muuttuu_kehotteesta(self): @@ -208,7 +210,8 @@ def test_aja_edistyminen_saa_juoksevat_mukana_ja_hylatty(self): ] tutkimus = {"TID": 1, "Luokittelukehote": "Arvioi."} kutsut = [] - with patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ + with 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"): @@ -236,6 +239,65 @@ def test_aja_edistyminen_laskee_epaonnistuneet_kurssit(self): viimeinen = kutsut[-1] assert (viimeinen[4], viimeinen[5], viimeinen[6]) == (0, 0, 1) # mukaan, hylätty, epäonnistunut + def test_aja_tilasto_erottaa_menetetyn_eran(self): + """Kokonaan katkennut LLM-JSON kirjataan tilastoon menetettynä eränä, + ei yksittäisinä ilman-vastausta-kursseina (ohjaa oikeaan korjaukseen).""" + kandidaatit = [ + {"KID": 1, "KurssiNimi": "A", "Koodi": "X1", "Taso": "aine", + "Oppiaine": "IT", "Opetusvuosi": "2025-2026", "OpsKuvaus": None}, + ] + tutkimus = {"TID": 1, "Luokittelukehote": "Arvioi."} + kutsut = [] + 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"): + 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} + + def test_aja_tilasto_laskee_ilman_vastausta_kurssit(self): + """Kelvollinen erä, josta LLM jätti kurssin pois → ilman_vastausta, ei + menetetty erä. (Vastaus kattaa vain id 1:n; id 2 jää ilman päätöstä.)""" + kandidaatit = [ + {"KID": 1, "KurssiNimi": "A", "Koodi": "X1", "Taso": "aine", + "Oppiaine": "IT", "Opetusvuosi": "2025-2026", "OpsKuvaus": None}, + {"KID": 2, "KurssiNimi": "B", "Koodi": "X2", "Taso": "aine", + "Oppiaine": "IT", "Opetusvuosi": "2025-2026", "OpsKuvaus": None}, + ] + tutkimus = {"TID": 1, "Luokittelukehote": "Arvioi."} + vain_id1 = '[{"id": 1, "mukana": true, "perustelu": "ok"}]' + kutsut = [] + # koko=2 → yksi erä; toinen passi saa tyhjän hausta → silmukka päättyy + with patch("luokittelu.llmluokittelu.erakoko", return_value=2), \ + 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"): + 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} + + def test_aja_keskeytys_lopettaa_ja_sailyttaa_tehdyt(self, monkeypatch): + """Callbackin truthy-paluu keskeyttää ajon: loppuja eriä ei enää + käsitellä, mutta ensimmäisen erän luokitukset on jo tallennettu.""" + monkeypatch.setenv("LLM_RINNAKKAISUUS", "1") # deterministinen: yksi erä kerrallaan + kandidaatit = [ + {"KID": i, "KurssiNimi": "A", "Koodi": f"X{i}", "Taso": "aine", + "Oppiaine": "IT", "Opetusvuosi": "2025-2026", "OpsKuvaus": None} + for i in (1, 2, 3) + ] + tutkimus = {"TID": 1, "Luokittelukehote": "Arvioi."} + yksi = '[{"id": 1, "mukana": true, "perustelu": "ok"}]' + with patch("luokittelu.llmluokittelu.erakoko", return_value=1), \ + 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"): + # 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 + def test_rinnakkaisuus_oletus_ja_ymparisto(self, monkeypatch): monkeypatch.delenv("LLM_RINNAKKAISUUS", raising=False) assert llmluokittelu.rinnakkaisuus() == 5 # oletus @@ -260,7 +322,7 @@ def vakooja(*a, max_workers=None, **k): return oikea(*a, max_workers=max_workers, **k) with patch("luokittelu.llmluokittelu.ThreadPoolExecutor", side_effect=vakooja), \ - patch("luokittelu.llmluokittelu.mallit.hae_luokittelemattomat", return_value=kandidaatit), \ + 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"): From a98b13be0293c7611595a52fed2a516b4e184200 Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 6 Jul 2026 20:01:13 +0300 Subject: [PATCH 3/5] =?UTF-8?q?Tietokanta:=20nopeuta=20et=C3=A4yhteytt?= =?UTF-8?q?=C3=A4=20(pooli,=20timeout,=20funnel-aggregaatit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB migroitiin etäpalvelimelle (korkea Tailscale-latenssi), mikä paljasti poolaamattoman yhteys/kutsu -mallin: tilannesivu avasi 7 tuoretta yhteyttä ja veti ~12000 riviä Pythoniin. - yhteys(): MySQLConnectionPool + connection_timeout (ei loputonta roikkumista) - hae_tutkimuksen_tilanne: suppilo SQL-aggregaateilla yhdessä yhteydessä - laske_arvioimattomat: COUNT(*) ei vedä hyväksyttyjen rivejä pelkkään len():iin - docker-compose: mysql --skip-name-resolve (poistaa per-yhteys reverse-DNS-viiveen) Co-Authored-By: Claude Opus 4.8 --- cliui/luokittelunaytto.py | 2 +- docker-compose.yml | 4 ++ testit/test_cliui.py | 2 +- testit/test_tietokanta.py | 81 ++++++++++++++++-------- tietokanta/mallit.py | 130 ++++++++++++++++++++++++++------------ tietokanta/yhteys.py | 53 +++++++++++++++- 6 files changed, 199 insertions(+), 73 deletions(-) diff --git a/cliui/luokittelunaytto.py b/cliui/luokittelunaytto.py index a7f7bac..8ae6cf0 100644 --- a/cliui/luokittelunaytto.py +++ b/cliui/luokittelunaytto.py @@ -364,7 +364,7 @@ def _poista_testiajo(stdscr, tutkimus: dict) -> None: def _nayta_tilanne(stdscr, tutkimus: dict) -> None: tid = tutkimus["TID"] t = mallit.hae_tutkimuksen_tilanne(tid) - arvioimattomat = len(mallit.hae_arvioimattomat(tid)) + arvioimattomat = mallit.laske_arvioimattomat(tid) arvioitu = max(0, t["hyvaksytty"] - arvioimattomat) piirra_otsikko(stdscr, f"Tilanne — {tutkimus['LuokittelunNimi']}") diff --git a/docker-compose.yml b/docker-compose.yml index 60d689e..a171658 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,10 @@ name: opserver services: mysql: image: mysql:8.4 + # skip-name-resolve: älä tee käänteistä DNS-hakua joka yhteydelle. + # Ilman tätä jokainen tuore yhteys (isännältä porttiin 21212) jumittaa + # sekunnin+ macOS/Docker-gatewayn IP:n reverse-DNS:ssä — hidastaa jokaista näkymän avausta. + command: ["--skip-name-resolve"] ports: - "21212:3306" environment: diff --git a/testit/test_cliui.py b/testit/test_cliui.py index 0b78e79..5e166c8 100644 --- a/testit/test_cliui.py +++ b/testit/test_cliui.py @@ -122,7 +122,7 @@ def test_nayta_tilanne_yhdenmukainen_suppilo(): } scr = _FakeScr(korkeus=24, leveys=120, nappaimet=[ord("q")]) with patch.object(ln.mallit, "hae_tutkimuksen_tilanne", return_value=tilanne), \ - patch.object(ln.mallit, "hae_arvioimattomat", return_value=[{}] * 162): + patch.object(ln.mallit, "laske_arvioimattomat", return_value=162): ln._nayta_tilanne(scr, {"TID": 1, "LuokittelunNimi": "Testi"}) teksti = "\n".join(t for _, t in scr.piirretyt) diff --git a/testit/test_tietokanta.py b/testit/test_tietokanta.py index 307d2c3..7a3035b 100644 --- a/testit/test_tietokanta.py +++ b/testit/test_tietokanta.py @@ -165,6 +165,48 @@ def test_ilman_rajausta_ei_lisaa_scope_ehtoa(self, mock_yhteys): assert "KKID" not in sql assert list(params) == [1, 1, 1] + def test_laske_arvioimattomat_laskee_ei_hae_riveja(self, mock_yhteys): + """Tilannesivu tarvitsee vain lukumäärän → COUNT(*), ei SELECT k.* (ei vedä + OpsKuvaus-tekstejä verkon yli). Sama WHERE-ehto kuin rivihaussa.""" + yht, kursori = mock_yhteys + kursori.fetchone.return_value = (7,) + with patch("tietokanta.mallit._tutkimus_kurssi_scope", + return_value=("k.KKID IN (%s) AND vuosirajaus", [4, 2024, 2025])): + tulos = mallit.laske_arvioimattomat(1) + assert tulos == 7 + sql, params = kursori.execute.call_args[0] + assert "SELECT COUNT(*)" in sql and "SELECT k.*" not in sql + assert list(params) == [1, 1, 1, 4, 2024, 2025] + + +class TestHaeTutkimuksenTilanne: + """Suppilo lasketaan SQL-aggregaateilla yhdessä yhteydessä (ei vedä kaikkia + kursseja/luokituksia Pythoniin) — etäpalvelimella siirtomäärä ratkaisee.""" + + def test_kokoaa_suppilon_aggregaateista(self, mock_yhteys): + yht, kursori = mock_yhteys + # järjestys: COUNT(*) Kurssi, Lukuvuosi, (KKID via fetchall), kurssi-agg, luokitus-agg + kursori.fetchone.side_effect = [ + (100,), ("2025-2026",), (80, 65), (10, 5, 30, 15, 60), + ] + kursori.fetchall.return_value = [(1,), (2,)] + t = mallit.hae_tutkimuksen_tilanne(1) + assert t == { + "kursseja_yht": 100, + "vuosi_lapi": 80, "vuosi_hyl": 20, + "oppilaitos_lapi": 65, "oppilaitos_hyl": 15, + "odottaa_meta": 5, # in_scope 65 − luok 60 + "hyl_meta": 30, "odottaa_llm": 5, "hyl_llm": 15, "hyvaksytty": 10, + } + + def test_ilman_lukuvuotta_kaikki_vuosihylattyja(self, mock_yhteys): + yht, kursori = mock_yhteys + kursori.fetchone.side_effect = [(100,), (None,)] # kursseja_yht, lukuvuosi=NULL + kursori.fetchall.return_value = [] + t = mallit.hae_tutkimuksen_tilanne(1) + assert t["kursseja_yht"] == 100 and t["vuosi_hyl"] == 100 + assert t["vuosi_lapi"] == 0 and t["hyvaksytty"] == 0 + class TestHaeLuokittelemattomat: """LLM-luokittelun ehdokasjoukon täytyy noudattaa tutkimuksen rajausta @@ -388,36 +430,23 @@ def test_tyhja_kun_ei_rajausta(self, mock_yhteys): class TestTutkimuksenTilanne: - def test_funnel_jakaa_kurssit_vaiheittain(self): - kurssit = [ - {"KID": 1, "KKID": 1, "Opetusvuosi": "2025-2026"}, # in-scope → hyväksytty - {"KID": 2, "KKID": 1, "Opetusvuosi": "2025-2026"}, # in-scope → odottaa LLM - {"KID": 3, "KKID": 1, "Opetusvuosi": "2025-2026"}, # in-scope → hyl meta - {"KID": 4, "KKID": 1, "Opetusvuosi": "2025-2026"}, # in-scope → hyl LLM - {"KID": 5, "KKID": 1, "Opetusvuosi": "2025-2026"}, # in-scope → odottaa meta (ei riviä) - {"KID": 6, "KKID": 1, "Opetusvuosi": "2024-2025"}, # väärä vuosi - {"KID": 7, "KKID": 2, "Opetusvuosi": "2025-2026"}, # väärä oppilaitos - ] - luok = [ - {"KID": 1, "Mukana": 1, "Luokitteluperuste": "LLM: relevantti"}, - {"KID": 2, "Mukana": None, "Luokitteluperuste": "meta: odottaa LLM-seulontaa"}, - {"KID": 3, "Mukana": 0, "Luokitteluperuste": "meta: oppiaine ei täsmää"}, - {"KID": 4, "Mukana": 0, "Luokitteluperuste": "LLM: ei liity aiheeseen"}, - {"KID": 99, "Mukana": 1, "Luokitteluperuste": "x"}, # ulkopuolinen → ohitetaan + def test_funnel_jakaa_kurssit_vaiheittain(self, mock_yhteys): + """7 kurssin skenaario: 6 vuosirajaus läpi, 5 in-scope, näistä + hyväksytty/odottaa/meta-hyl/LLM-hyl/odottaa-meta yksi kutakin. Aggregaatti- + SQL:n TUOTTAMAT luvut on syötetty kursorille; testaa Python-kokoonpanon. + (SQL:n semantiikka varmennettu erikseen MySQL:ää vasten.)""" + yht, kursori = mock_yhteys + # järjestys: COUNT(*) Kurssi, Lukuvuosi, (KKID fetchall), kurssi-agg, luokitus-agg + kursori.fetchone.side_effect = [ + (7,), ("2025-2026",), (6, 5), (1, 1, 1, 1, 4), ] - with patch.object(mallit, "hae_tutkimus", return_value={"TID": 1, "Lukuvuosi": "2025-2026"}), \ - patch.object(mallit, "hae_tutkimuksen_korkeakoulut", return_value=[1]), \ - patch.object(mallit, "hae_kurssit", return_value=kurssit), \ - patch.object(mallit, "hae_luokitukset", return_value=luok): - t = mallit.hae_tutkimuksen_tilanne(1) + kursori.fetchall.return_value = [(1,)] + t = mallit.hae_tutkimuksen_tilanne(1) assert t["kursseja_yht"] == 7 assert (t["vuosi_lapi"], t["vuosi_hyl"]) == (6, 1) assert (t["oppilaitos_lapi"], t["oppilaitos_hyl"]) == (5, 1) - assert t["odottaa_meta"] == 1 - assert t["hyl_meta"] == 1 - assert t["odottaa_llm"] == 1 - assert t["hyl_llm"] == 1 - assert t["hyvaksytty"] == 1 + assert t["odottaa_meta"] == 1 # in_scope 5 − luok 4 + assert (t["hyl_meta"], t["odottaa_llm"], t["hyl_llm"], t["hyvaksytty"]) == (1, 1, 1, 1) # in-scope-summa täsmää assert (t["odottaa_meta"] + t["hyl_meta"] + t["odottaa_llm"] + t["hyl_llm"] + t["hyvaksytty"]) == t["oppilaitos_lapi"] diff --git a/tietokanta/mallit.py b/tietokanta/mallit.py index 2a8420e..46b8bf3 100644 --- a/tietokanta/mallit.py +++ b/tietokanta/mallit.py @@ -700,37 +700,66 @@ def hae_tutkimuksen_tilanne(tid: int) -> dict: LLM-luokitus jakavat in-scope-kurssit odottaviin/hylättyihin/hyväksyttyihin. Meta- ja LLM-hylkäys erotetaan Luokitteluperusteen "meta:"-etuliitteestä. """ - tutkimus = hae_tutkimus(tid) - lukuvuosi = (tutkimus or {}).get("Lukuvuosi") - korkeakoulut = set(hae_tutkimuksen_korkeakoulut(tid)) - kurssit = hae_kurssit() - kursseja_yht = len(kurssit) - - vuosi_lapi = [k for k in kurssit - if lukuvuosi and _kattaa_turvallinen(k.get("Opetusvuosi"), lukuvuosi)] - in_scope = [k for k in vuosi_lapi if k["KKID"] in korkeakoulut] - in_scope_kid = {k["KID"] for k in in_scope} - - luok = {l["KID"]: l for l in hae_luokitukset(tid) if l["KID"] in in_scope_kid} - hyl_meta = hyl_llm = odottaa_llm = hyvaksytty = 0 - for l in luok.values(): - mukana = l.get("Mukana") - if mukana in (1, True): - hyvaksytty += 1 - elif mukana is None: - odottaa_llm += 1 - elif (l.get("Luokitteluperuste") or "").startswith("meta:"): - hyl_meta += 1 - else: - hyl_llm += 1 + # Suppilo lasketaan SQL-aggregaateilla yhdessä yhteydessä. Aiemmin tämä veti + # kaikki kurssit ja luokitukset Pythoniin (4 yhteyttä + ~12000 riviä) — halpaa + # localhostilla mutta hidasta etäpalvelimella (geopalvelin1). Nyt: 1 yhteys, + # muutama COUNT-rivi. + with yhteys() as yht: + with yht.cursor() as kursori: + kursori.execute("SELECT COUNT(*) FROM Kurssi") + kursseja_yht = int(kursori.fetchone()[0]) + + kursori.execute("SELECT Lukuvuosi FROM Tutkimus WHERE TID = %s", (tid,)) + rivi = kursori.fetchone() + lukuvuosi = rivi[0] if rivi else None + + kursori.execute("SELECT KKID FROM TutkimusKorkeakoulu WHERE TID = %s", (tid,)) + korkeakoulut = [r[0] for r in kursori.fetchall()] + + # Ilman lukuvuotta koko in-scope-joukko on tyhjä (kuten Python-versiossa). + if not lukuvuosi: + return {"kursseja_yht": kursseja_yht, "vuosi_lapi": 0, + "vuosi_hyl": kursseja_yht, "oppilaitos_lapi": 0, + "oppilaitos_hyl": 0, "odottaa_meta": 0, "hyl_meta": 0, + "odottaa_llm": 0, "hyl_llm": 0, "hyvaksytty": 0} + + vuosi_sql, vp = _vuosi_kattaa_sql("k.Opetusvuosi", lukuvuosi) + kk = ",".join(["%s"] * len(korkeakoulut)) if korkeakoulut else "NULL" + + # Kurssipuoli: vuosirajaus läpäisseet + niistä valittuihin korkeakouluihin osuvat. + kursori.execute( + f"""SELECT COALESCE(SUM({vuosi_sql}), 0), + COALESCE(SUM(CASE WHEN ({vuosi_sql}) AND k.KKID IN ({kk}) + THEN 1 ELSE 0 END), 0) + FROM Kurssi k""", + (*vp, *vp, *korkeakoulut), + ) + vuosi_lapi, in_scope = (int(x) for x in kursori.fetchone()) + + # Luokituspuoli in-scope-kursseille: mukaan / odottaa / meta-hylätty / LLM-hylätty. + # Meta- ja LLM-hylkäys erotetaan Luokitteluperusteen "meta:"-etuliitteestä. + kursori.execute( + f"""SELECT COALESCE(SUM(kl.Mukana = 1), 0), + COALESCE(SUM(kl.Mukana IS NULL), 0), + COALESCE(SUM(kl.Mukana = 0 AND kl.Luokitteluperuste LIKE 'meta:%%'), 0), + COALESCE(SUM(kl.Mukana = 0 AND (kl.Luokitteluperuste NOT LIKE 'meta:%%' + OR kl.Luokitteluperuste IS NULL)), 0), + COUNT(*) + FROM Kurssiluokitus kl + JOIN Kurssi k ON k.KID = kl.KID + WHERE kl.TID = %s AND ({vuosi_sql}) AND k.KKID IN ({kk})""", + (tid, *vp, *korkeakoulut), + ) + hyvaksytty, odottaa_llm, hyl_meta, hyl_llm, luok_maara = ( + int(x) for x in kursori.fetchone()) return { "kursseja_yht": kursseja_yht, - "vuosi_lapi": len(vuosi_lapi), - "vuosi_hyl": kursseja_yht - len(vuosi_lapi), - "oppilaitos_lapi": len(in_scope), - "oppilaitos_hyl": len(vuosi_lapi) - len(in_scope), - "odottaa_meta": len(in_scope) - len(luok), + "vuosi_lapi": vuosi_lapi, + "vuosi_hyl": kursseja_yht - vuosi_lapi, + "oppilaitos_lapi": in_scope, + "oppilaitos_hyl": vuosi_lapi - in_scope, + "odottaa_meta": in_scope - luok_maara, "hyl_meta": hyl_meta, "odottaa_llm": odottaa_llm, "hyl_llm": hyl_llm, @@ -738,6 +767,24 @@ def hae_tutkimuksen_tilanne(tid: int) -> dict: } +def _arvioimattomat_ehto(tid: int) -> tuple[str, tuple]: + """FROM+WHERE (ja parametrit) mukaan otetuille kursseille, joilta puuttuu + vielä ei-tyhjiä vastauksia. Jaettu rivihaun ja lukumäärän kesken (DRY).""" + scope_where, scope_params = _tutkimus_kurssi_scope(tid) + scope_sql = f" AND {scope_where}" if scope_where else "" + sp = scope_params or [] + ehto = f""" + FROM Kurssi k + JOIN Kurssiluokitus kl ON k.KID = kl.KID AND kl.TID = %s AND kl.Mukana = 1 + WHERE ( + SELECT COUNT(*) FROM Vastaukset v + JOIN Kysymykset ky ON v.KysID = ky.KysID + WHERE ky.TID = %s AND v.KID = k.KID AND v.Vastaus != '' + ) < (SELECT COUNT(*) FROM Kysymykset WHERE TID = %s){scope_sql} + """ + return ehto, (tid, tid, tid, *sp) + + def hae_arvioimattomat(tid: int) -> list[dict]: """Mukaan otetut kurssit, joille ei vielä ole kaikkia ei-tyhjiä vastauksia tässä tutkimuksessa. @@ -745,25 +792,24 @@ def hae_arvioimattomat(tid: int) -> list[dict]: ja tilastopaneeli — muuten vanhojen ajojen rajauksen ulkopuoliset hyväksynnät (väärä vuosi / korkeakoulu) menisivät turhaan LLM-arviointiin. """ - scope_where, scope_params = _tutkimus_kurssi_scope(tid) - scope_sql = f" AND {scope_where}" if scope_where else "" - sp = scope_params or [] + ehto, params = _arvioimattomat_ehto(tid) with yhteys() as yht: with yht.cursor() as kursori: - kursori.execute(f""" - SELECT k.* - FROM Kurssi k - JOIN Kurssiluokitus kl ON k.KID = kl.KID AND kl.TID = %s AND kl.Mukana = 1 - WHERE ( - SELECT COUNT(*) FROM Vastaukset v - JOIN Kysymykset ky ON v.KysID = ky.KysID - WHERE ky.TID = %s AND v.KID = k.KID AND v.Vastaus != '' - ) < (SELECT COUNT(*) FROM Kysymykset WHERE TID = %s){scope_sql} - ORDER BY k.KurssiNimi - """, (tid, tid, tid, *sp)) + kursori.execute(f"SELECT k.* {ehto} ORDER BY k.KurssiNimi", params) return _rivit_dikteina(kursori) +def laske_arvioimattomat(tid: int) -> int: + """Arvioimattomien kurssien lukumäärä. Erillinen hae_arvioimattomat'sta, jotta + tilannesivu ei vedä kaikkien hyväksyttyjen kurssien täysiä rivejä (OpsKuvaus- + tekstit) verkon yli pelkkää len()-laskentaa varten — merkitsevää etäpalvelimella.""" + ehto, params = _arvioimattomat_ehto(tid) + with yhteys() as yht: + with yht.cursor() as kursori: + kursori.execute(f"SELECT COUNT(*) {ehto}", params) + return int(kursori.fetchone()[0]) + + # --- HITL-korjaukset --- def tallenna_hitl_korjaus(tid: int, kid: int, uusi_tila: bool, perustelu: str, diff --git a/tietokanta/yhteys.py b/tietokanta/yhteys.py index ce35bed..ceb8e80 100644 --- a/tietokanta/yhteys.py +++ b/tietokanta/yhteys.py @@ -1,6 +1,8 @@ import os +import threading from contextlib import contextmanager import mysql.connector +from mysql.connector import pooling, errors from dotenv import load_dotenv load_dotenv() @@ -14,20 +16,65 @@ def _asetukset() -> dict: "password": os.getenv("DB_PASSWORD", ""), "database": os.getenv("DB_NAME", "opserverdb"), "charset": "utf8mb4", + # Etäpalvelin (geopalvelin1, Tailscale) voi olla tavoittamattomissa → + # ilman katkaisua connect roikkuu loputtomiin (näyttää "ei tulosta mitään"). + # Katkaisulla kutsu epäonnistuu selkeästi muutamassa sekunnissa. + "connection_timeout": int(os.getenv("DB_YHTEYS_AIKAKATKAISU_S", "8")), } +# Yhteyspooli: etäpalvelimella (geopalvelin1, Tailscale) jokainen TCP+auth- +# kättely maksaa satoja millisekunteja, ja poolaamaton yhteys/kutsu teki sivuista +# tuskaisia. Pooli maksaa kättelyn ~kerran ja lainaa yhteyden uudelleen. +# pool_reset_session=False: käyttö on tilaton (ei sessiomuuttujia/temp-tauluja) ja +# yhteydenmanageri commit/rollbackaa aina ennen palautusta → nollausta ei tarvita +# (säästää yhden verkkomatkan palautuksessa). +_pooli = None +_pooli_lukko = threading.Lock() + + +def _pooli_koko() -> int: + """Poolin koko (.env: DB_POOLI_KOKO). Oltava ≥ LLM_RINNAKKAISUUS + varaa UI:lle.""" + return int(os.getenv("DB_POOLI_KOKO", "8")) + + +def _hae_pooli(): + global _pooli + if _pooli is None: + with _pooli_lukko: + if _pooli is None: + _pooli = pooling.MySQLConnectionPool( + pool_name="opserver", + pool_size=_pooli_koko(), + pool_reset_session=False, + **_asetukset(), + ) + return _pooli + + @contextmanager def yhteys(): - yht = mysql.connector.connect(**_asetukset()) + try: + yht = _hae_pooli().get_connection() + except errors.PoolError: + # Pooli täynnä (esim. rinnakkaisuus > poolin koko) → tuore yhteys kuten + # ennen. Ei kaadu ruuhkaan; hidas mutta toimii. + yht = mysql.connector.connect(**_asetukset()) + # ponytail: ei per-lainaus-ping()iä — se lisäisi verkkomatkan joka kyselyyn + # (luokittelun tuhannet kirjoitukset × Tailscale-RTT). Aktiivikäytössä yhteydet + # eivät ehdi vanhentua; harvinainen idle-katkennut yhteys → kysely virhe kerran + # (luokittelun passiluuppi yrittää erän uusiksi, UI-kysely toistetaan käsin). try: yield yht yht.commit() except Exception: - yht.rollback() + try: + yht.rollback() + except Exception: + pass raise finally: - yht.close() + yht.close() # pooliyhteys palautuu pooliin; tuore sulkeutuu def alusta_tietokanta(): From 11f24e860279077612467ca0456991e13a56d343 Mon Sep 17 00:00:00 2001 From: Klaude Date: Mon, 6 Jul 2026 20:01:30 +0300 Subject: [PATCH 4/5] =?UTF-8?q?savutesti:=20=C3=A4l=C3=A4=20abortoi=20hilj?= =?UTF-8?q?aa=20=E2=80=94=20poista=20set=20-e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skripti kerää virheet itse (VIRHEET-laskuri) ja raportoi lopussa. set -e abortoi ensimmäisen kaatuvan komennon kohdalla ennen yhteenvetoa — ja sourcattuna (esim. ./status) vei koko interaktiivisen shellin hiljaa alas. Ilman -e jokainen tarkistus ajetaan ja [FAIL]it näkyvät. Co-Authored-By: Claude Opus 4.8 --- testit/savutesti.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testit/savutesti.sh b/testit/savutesti.sh index 8b971a4..8e6f4c5 100755 --- a/testit/savutesti.sh +++ b/testit/savutesti.sh @@ -3,7 +3,11 @@ # Aja: ./testit/savutesti.sh # Oletus: kontit ovat jo käynnissä (docker compose up -d). -set -euo pipefail +# EI set -e: savutesti kerää virheet itse (VIRHEET-laskuri) ja raportoi lopussa. +# set -e abortoisi ensimmäisen kaatuvan komennon kohdalla ENNEN yhteenvetoa — +# ja sourcattuna (esim. ./status) veisi koko interaktiivisen shellin hiljaa alas, +# jolloin "ei tulosta mitään". Ilman -e jokainen tarkistus ajetaan ja [FAIL]it näkyvät. +set -uo pipefail WEBUI="http://localhost:12121" From 49ec89a09fc7bd7bfc3c5b2474c86d521b502b3d Mon Sep 17 00:00:00 2001 From: Klaude Date: Tue, 7 Jul 2026 17:52:04 +0300 Subject: [PATCH 5/5] savutesti: lue WebUI-osoite WEBUI_OSOITE:sta, ei kovakoodia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sama WEBUI_OSOITE-muuttuja kuin cliui käyttää (cliui/tutkimusnaytto.py). Etusija: ympäristö → .env → oletus http://localhost:12121. Co-Authored-By: Claude Opus 4.8 --- testit/savutesti.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/testit/savutesti.sh b/testit/savutesti.sh index 8e6f4c5..0ffdd44 100755 --- a/testit/savutesti.sh +++ b/testit/savutesti.sh @@ -9,8 +9,6 @@ # jolloin "ei tulosta mitään". Ilman -e jokainen tarkistus ajetaan ja [FAIL]it näkyvät. set -uo pipefail -WEBUI="http://localhost:12121" - # Projektin juuri = lähin hakemisto (nykyhakemistosta ylöspäin), jossa on # docker-compose.yml. Riippumaton siitä miten skripti käynnistetään (suoraan # testit/-alta, repo-juuresta tai status-wräpperin kautta sourcaten). @@ -23,6 +21,12 @@ done exit 1 } +# WebUI-osoite: sama WEBUI_OSOITE-muuttuja kuin cliui (cliui/tutkimusnaytto.py). +# Ympäristö ohittaa .env:n, .env ohittaa oletuksen; loppukauttaviiva pois. +WEBUI="${WEBUI_OSOITE:-$(grep -E '^WEBUI_OSOITE=' "$PROJO_JUURI/.env" 2>/dev/null | tail -1 | cut -d= -f2-)}" +WEBUI="${WEBUI:-http://localhost:12121}" +WEBUI="${WEBUI%/}" + VIRHEET=0 AUTH_ARGS=()