diff --git a/REPORTPLAN.md b/REPORTPLAN.md index ea9ef9e..6f4b83b 100644 --- a/REPORTPLAN.md +++ b/REPORTPLAN.md @@ -2,7 +2,7 @@ Tämä dokumentti kuvaa, miten pipelinen vaihe 4 ("Raportti") on toteutettu: mitkä ominaisuudet on rakennettu, missä tiedostoissa, mitä ne tekevät ja miten. Tarkoitus on toimia lähtökohtana toteutuksen tarkastukselle CLAUDE.md:n valmistumiskriteerejä vasten. -*Tiedostoviittaukset ja väitteet varmennettu koodikannasta 2026-07-15. Tämä versio kuvaa tilan **PR #5:n jälkeen** (HITL-juurisyy + laatumittarit + tuoreusseuranta). Aiemmin dokumentoitu avoin puute ("%-osuus riittämättömiksi merkityistä oppaista") on nyt toteutettu — ks. kohta "Toteutettu: virhetaksonomia ja laatumittarit".* +*Tiedostoviittaukset ja väitteet varmennettu koodikannasta 2026-07-15. Tämä versio kuvaa tilan **PR #6:n jälkeen** (HITL-juurisyy + laatumittarit + tuoreusseuranta, jonka raskas laskenta siirretty taustalle). Aiemmin dokumentoitu avoin puute ("%-osuus riittämättömiksi merkityistä oppaista") on nyt toteutettu — ks. kohta "Toteutettu: virhetaksonomia ja laatumittarit". Huom: osa `mallit.py`-rivinumeroista voi olla siirtynyt PR #6:n lisäysten jälkeen.* ## Yleiskuva @@ -10,7 +10,7 @@ Raportointi jakautuu kolmeen puoliskoon: 1. **Generointi** (`raportti/llmraportti.py`) — rakentaa tietokannan tilasta kolme LLM-promptia (johdanto, kurssit, arvioinnit), lähettää ne `llm/kutsu.py`:n kautta ja kirjoittaa vastaukset `RaporttiOsio`-tauluun (avaimena `TID` + `OsioAvain`). Ajo on idempotentti. Kurssit-osion promptiin injektoidaan **HITL-laatumittarit** (käsin-muutos-% + juurisyyjakauma). Generoinnin hetkellä tallennetaan myös **laskentatiiviste** — hash lähdeaineistosta tuoreusseurantaa varten. 2. **Esitys ja muokkaus** — Web-UI lukee `RaporttiOsio`-taulun sisällön, laskee lisäksi rakenteelliset tilastot lennossa (per-kysymys-jakaumat + HITL-laatumittarit) ja tarjoaa muokattavan, usean käyttäjän yhteismuokattavan HTML-raportin, tulostus/PDF-napin sekä **tuoreuspalkin**. Curses-UI laukaisee generoinnin ja näyttää **"Näytä tilanne"** -sivun (tekoaika, tuoreus, generoinnin jälkeiset muutokset). -3. **Tuoreusseuranta** (A+B) — sekä CLIUI:n "Näytä tilanne" että Web-UI:n tuoreuspalkki kertovat: milloin raportti generoitiin, onko lähdeaineisto muuttunut sen jälkeen (laskentatiivistevertailu), ja montako HITL-korjausta/kommenttia on tehty generoinnin jälkeen. +3. **Tuoreusseuranta** (A+B) — sekä CLIUI:n "Näytä tilanne" että Web-UI:n tuoreuspalkki kertovat: milloin raportti generoitiin, onko lähdeaineisto muuttunut sen jälkeen (tiivistevertailu), **milloin tuoreus viimeksi tarkistettiin** ja montako HITL-korjausta/kommenttia on tehty generoinnin jälkeen. Raskas tiivistelaskenta ajetaan **taustalla** (ei status-katselun kriittisellä polulla) — ks. "Toteutettu: raportin tuoreusseuranta". ## Tiedostot ja niiden roolit @@ -24,8 +24,9 @@ Raportointi jakautuu kolmeen puoliskoon: - `_hitl_yhteenveto_teksti(m)` (r. 127) — muotoilee mittarit raporttikehotteen tekstilohkoksi. - `_rakenna_*_viesti` — rakentavat kolme käyttäjäviestiä `mallit.hae_tilastot_yliopistoittain(tid)`, `mallit.hae_kysymykset(tid)`, `mallit.hae_arviokommentit_kaikki(tid)` ja tutkimuksen kehote-/rajauskenttien pohjalta. Kurssit-viesti sisältää laatumittarit ja ohjeistaa LLM:ää raportoimaan riittämättömien oppaiden osuuden eksplisiittisesti. - `raporttitiiviste(tutkimus, tilastot=None, kysymykset=None)` (r. 10) — SHA-256-tiiviste lähdeaineistosta, josta raportti koottiin: per-yliopisto-tilastot (luokitukset + HITL), kysymykset, arviointien tila (`hae_vastaus_tiivisteet`), kommentit ja tutkimuksen kehotteet/rajaukset. Muuttuu, jos jokin raporttiin vaikuttava tieto muuttuu — kattaa myös aikaleimattomat taulut (`Kurssiluokitus`, `Vastaukset`), joten seulonnan/haun uudelleenajo näkyy. - - `koosta_tilanne(tutkimus)` (r. 49) — kokoaa raportin tuoreustiedot status-näkymiin (**curses- ja HTTP-riippumaton**, jaettu CLIUI + WebUI). Palauttaa `{"generoitu": False}` tai osioiden tilan + aikaleimat, tuoreuden (`ajan_tasalla` / `vanhentunut` / `tuntematon`, tiivistevertailu) ja generoinnin jälkeen tehtyjen HITL-korjausten/kommenttien määrän. - - `aja(tutkimus, edistyminen_cb=None) -> int` — pääfunktio: laskee laskentatiivisteen, käy läpi kolme osiota, kutsuu `llm.kutsu.kysy`:tä ja tallentaa tuloksen `mallit.aseta_raportti_osio(tid, avain, teksti, laskentatiiviste=...)`:llä. + - `koosta_tilanne(tutkimus)` — kokoaa raportin tuoreustiedot status-näkymiin (**curses- ja HTTP-riippumaton**, jaettu CLIUI + WebUI). **KEVYT:** ei laske `raporttitiiviste`ä, vaan lukee viimeksi lasketun tuoreuden tallennettuna (`mallit.hae_raportti_tuoreus`). Palauttaa `{"generoitu": False}` tai osioiden tilan + aikaleimat, tuoreuden (`ajan_tasalla` / `vanhentunut` / `tuntematon`, vertailu generoinnin tiivisteeseen), **`tarkistettu`**-aikaleiman ja generoinnin jälkeen tehtyjen HITL-korjausten/kommenttien määrän. Tuoreuslogiikka on erotettu apufunktioon `_tuoreus(...)`. + - `paivita_tuoreus(tutkimus) -> str` — **RASKAS:** laskee `raporttitiiviste`n (per-yliopisto-tilastot + kaikki vastaukset + kommentit etäkannasta) ja tallentaa sen aikaleimoineen (`mallit.tallenna_raportti_tuoreus`). Ajetaan **taustalla** tai käyttäjän pyynnöstä — ei status-katselun kriittisellä polulla. + - `aja(tutkimus, edistyminen_cb=None) -> int` — pääfunktio: laskee laskentatiivisteen, käy läpi kolme osiota, tallentaa tuloksen `mallit.aseta_raportti_osio(...)`:llä ja **siemenee tuoreuden** (`mallit.tallenna_raportti_tuoreus(tid, laskenta)`) → tuore raportti näyttää heti "ajan tasalla". - **`raportti/_kehotteet.py`** — debug-CLI (`./kehoteraportti`-wrapper). Tulostaa tutkimuksen järjestelmäkehotteen ja kolme käyttäjäviestiä **kutsumatta LLM:ää**. Ei testikattavuutta. @@ -39,18 +40,20 @@ Raportointi jakautuu kolmeen puoliskoon: - `hae_tilastot_yliopistoittain(tid)` (r. 999) — per-yliopisto `KurssiYhteensa`, `LLMKasitelty`, `Mukana`, `Hylatty`, `HitlLkm`, sekä **`HitlKursseja`** (käsin muutetut kurssit) ja **juurisyyjakauma** (`RiittamatonOpas` / `LlmVirhe` / `TuntematonSyy`, laskettu kunkin kurssin *viimeisimmästä* korjauksesta). - `aseta_raportti_osio(tid, avain, teksti, laskentatiiviste=None)` (r. 924) — upsert; `laskentatiiviste` tallennetaan generoinnissa, säilytetään WebUI-tekstimuokkauksessa (`COALESCE`). - `hae_raportti_tila(tid)` (r. 950) — per-osio aikaleima + laskentatiiviste (ei hae Teksti-kenttää). - - `laske_hitl_korjaukset_jalkeen(tid, aika)` (r. 963), `laske_arviokommentit_jalkeen(tid, aika)` (r. 974) — COUNT annetun ajan jälkeen (tuoreussivun "generoinnin jälkeen" -signaalit). + - `laske_hitl_korjaukset_jalkeen(tid, aika)`, `laske_arviokommentit_jalkeen(tid, aika)` — COUNT annetun ajan jälkeen (tuoreussivun "generoinnin jälkeen" -signaalit). + - `hae_raportti_tuoreus(tid)` / `tallenna_raportti_tuoreus(tid, signatuuri)` — viimeksi laskettu tuoreussignatuuri + laskenta-aika (`RaporttiTuoreus`, upsert); kevyt luku, raskas laskenta erikseen (`paivita_tuoreus`). - CRUD `RaporttiOsio`-tauluun: `hae_raportti_osiot`, `hae_raportti_osio`. - **`tietokanta/migraatio_007.sql`** — `Tutkimus.Raportointikehote` + `RaporttiOsio`-taulu (`Aikaleima` `ON UPDATE CURRENT_TIMESTAMP`). - **`tietokanta/migraatio_016.sql`** — `HitlKorjaus.Juurisyy VARCHAR(32) NULL` (virhetaksonomia). -- **`tietokanta/migraatio_017.sql`** — `RaporttiOsio.Laskentatiiviste VARCHAR(64) NULL` (tuoreustarkistus). - - *Molemmat additiivisia (nullable); sovellettu geopalvelin1-kantaan kehityksen aikana.* +- **`tietokanta/migraatio_017.sql`** — `RaporttiOsio.Laskentatiiviste VARCHAR(64) NULL` (tuoreustarkistus, generoinnin signatuuri). +- **`tietokanta/migraatio_018.sql`** — `RaporttiTuoreus`-taulu (`TID` PK, `Signatuuri`, `Tarkistettu`) — viimeksi laskettu lähdeaineiston tuoreussignatuuri + laskenta-aika; raskas tiivistelaskenta ajetaan taustalla eikä joka status-katselulla. + - *Additiivisia; sovellettu geopalvelin1-kantaan kehityksen aikana.* ### Curses-UI - **`cliui/valikko.py`** — päävalikon `"6) Tee raportti"` → `raporttinaytto.nayta`. -- **`cliui/raporttinaytto.py`** — "Generoi raportti LLM:llä" (kutsuu `llmraportti.aja`) tai **"Näytä tilanne"** (`_nayta_tilanne` → `llmraportti.koosta_tilanne`): näyttää generointiajan, per-osio aikaleimat, tuoreuden (`_TUOREUS_TEKSTI`: ajan tasalla / vanhentunut / tuntematon) ja generoinnin jälkeen tehdyt korjaukset/kommentit. `_aika_str` muotoilee aikaleiman. +- **`cliui/raporttinaytto.py`** — kolme toimintoa: "Generoi raportti LLM:llä" (`llmraportti.aja`); **"Näytä tilanne"** (`_nayta_tilanne` → `koosta_tilanne`): näyttää generointiajan, per-osio aikaleimat, tuoreuden (`_TUOREUS_TEKSTI`), **tuoreuden tarkistusajan** ja generoinnin jälkeiset korjaukset/kommentit — sekä käynnistää raskaan tuoreuslaskennan **taustasäikeessä** (`_kaynnista_taustatuoreus`, daemon + lippu ettei päällekkäisiä); **"Tarkista tuoreus nyt"** (`_tarkista_tuoreus` → `paivita_tuoreus`) laskee synkronisesti pyynnöstä. `_piirra_tilanne` jaettu näyttölohko, `_aika_str` muotoilee aikaleiman. ### Web-UI @@ -58,7 +61,7 @@ Raportointi jakautuu kolmeen puoliskoon: - `POST .../kurssit/{kid}/hitl` (r. 525) — `HitlPyynto` (r. 516) sisältää valinnaisen `juurisyy`-kentän; reitti validoi sen `JUURISYYT`:ia vasten (400 tuntemattomalle). - `GET .../raportti` (r. ~536) → `{tid, osiot}`. - `GET .../raportti/tilastot` (r. 548) → per-kysymys-jakaumat **+ `hitl`-lohko** (`llmraportti.hitl_mittarit`: käsin-muutos-% + juurisyyjakauma, auktoritatiivinen rakenteellinen luku). - - `GET .../raportti/tilanne` (r. 623) → `koosta_tilanne`-tuoreustiedot, `_raportti_tilanne_valimuistissa` (r. 332, TTL-välimuisti ettei 15 s -päivitys kuormita etäkantaa). + - `GET .../raportti/tilanne` → `koosta_tilanne`-tuoreustiedot (sis. `tarkistettu`-aikaleiman), `_raportti_tilanne_valimuistissa` (TTL-välimuisti ettei 15 s -päivitys kuormita etäkantaa). Välimuistin ohittuessa laukaisee raskaan tuoreuslaskennan **taustasäikeessä** (`_kaynnista_taustatuoreus`, harvennettu `WEBUI_TUOREUS_VALI_S`, yksi per tutkimus kerrallaan). - WebSocket-pohjainen yhteismuokkauskerros (`raportti-liity`/`-teksti`/`-tallenna`/`-poistu`). - **`webui/staattinen/sovellus.js`** — `renderTutkimusRaportti` hakee raportin, tilastot ja tuoreuden; `_renderTuoreusPalkki` (tuoreuspalkki: generointiaika + ajan tasalla/vanhentunut/tuntematon + muutokset), `_renderHitlMittarit` (laatumittarit kurssit-osioon), `_renderTilastotTaulukko` (per-kysymys-jakaumat arvioinnit-osioon), `avaaRaporttiTulostus` (tulosta-PDF, sisältää myös HITL-mittarit). @@ -69,7 +72,7 @@ Raportointi jakautuu kolmeen puoliskoon: ### Testit -- **`testit/test_raportti.py`** — `llmraportti`: taulukko, viestirakentajat, `hitl_mittarit`, `raporttitiiviste` (`TestRaporttiTiiviste`: determinismi + herkkyys), `koosta_tilanne` (`TestKoostaTilanne`: neljä tuoreustilaa), `aja` (osiot, edistyminen, idempotenssi, laskentatiivisteen kirjoitus). +- **`testit/test_raportti.py`** — `llmraportti`: taulukko, viestirakentajat, `hitl_mittarit`, `raporttitiiviste` (`TestRaporttiTiiviste`: determinismi + herkkyys), `koosta_tilanne` (`TestKoostaTilanne`: lukee tallennetun tuoreuden, EI laske tiivistettä — vartija kaataa jos laskee; tuoreustilat + `tarkistettu`), `paivita_tuoreus` (`TestPaivitaTuoreus`), `aja` (osiot, edistyminen, idempotenssi, laskentatiivisteen kirjoitus, tuoreuden siemennys). - **`testit/test_tietokanta.py`** — `TestHitlKorjaus` (juurisyy), `TestRaporttiTila` (aseta_raportti_osio-tiiviste, hae_raportti_tila, laske_*_jalkeen). - **`testit/test_webui.py`** — HITL-reitti (juurisyy: välitys, valinnaisuus, 400 tuntemattomalle), `/raportti`, `/raportti/tilastot` (ml. `hitl`-lohko), `/raportti/tilanne` (tuoreus + 404). - **`testit/test_cliui.py`** — curses-savutestit. @@ -90,7 +93,8 @@ CLAUDE.md:n vaiheen 4 valmistumiskriteeri "sisältää %-osuuden riittämättöm Aiemmin CLIUI:n "Näytä tilanne" näytti vain ✓/— per osio, eikä kertonut milloin raportti tehtiin tai onko aineisto muuttunut. **Nyt:** - **A** (aikaleimapohjainen): generointiaika (`RaporttiOsio.Aikaleima`) + "generoinnin jälkeen N HITL-korjausta, M kommenttia" (`HitlKorjaus`/`ArvioKommentti`-aikaleimoista). -- **B** (tiivistepohjainen, kattaa aikaleimattomat taulut): `RaporttiOsio.Laskentatiiviste` (migraatio 017) tallentaa lähdeaineiston hashin generoinnin hetkellä; status vertaa tallennettua nykyiseen → yksiselitteinen *ajan tasalla / vanhentunut / tuntematon*. +- **B** (tiivistepohjainen, kattaa aikaleimattomat taulut): `RaporttiOsio.Laskentatiiviste` (migraatio 017) tallentaa lähdeaineiston hashin generoinnin hetkellä; status vertaa tallennettua nykysignatuuriin → yksiselitteinen *ajan tasalla / vanhentunut / tuntematon*. +- **Tuoreuslaskenta taustalla (PR #6, 2026-07-15):** nykysignatuurin laskenta (`raporttitiiviste`) on raskas (per-yliopisto-tilastot + kaikki vastaukset + kommentit etäkannasta, ~22 s), eikä sitä lasketa synkronisesti joka status-katselulla. Viimeksi laskettu signatuuri + laskenta-aika tallennetaan `RaporttiTuoreus`-tauluun (migraatio 018); `koosta_tilanne` lukee sen halvalla ja palauttaa `tarkistettu`-aikaleiman. Raskas laskenta (`paivita_tuoreus`) ajetaan taustasäikeessä (CLIUI: "Näytä tilanne" käynnistää + "Tarkista tuoreus nyt" synkroninen; WebUI: endpoint laukaisee, harvennettu). Generointi siemenee tuoreuden heti. *Käyttäjän ohje: tuoreus ei tarvitse olla "juuri nyt" — riittää näyttää viimeksi laskettu tulos + aikaleima.* - Sama `koosta_tilanne`-logiikka molemmissa käyttöliittymissä (CLIUI "Näytä tilanne" + WebUI tuoreuspalkki). ## Avoimet kohdat / mahdollinen jatkokehitys diff --git a/testit/savutesti.sh b/testit/savutesti.sh index 0ffdd44..056f15e 100755 --- a/testit/savutesti.sh +++ b/testit/savutesti.sh @@ -30,9 +30,17 @@ WEBUI="${WEBUI%/}" VIRHEET=0 AUTH_ARGS=() -ok() { printf "[OK] %s\n" "$1"; } -fail() { printf "[FAIL] %s\n" "$1"; VIRHEET=$((VIRHEET + 1)); } -info() { printf "[INFO] %s\n" "$1"; } +# Värit: epäonnistumiset punaisella erottuvat heti silmämääräisesti. Pois päältä +# jos stdout ei ole pääte (putki/tiedosto) tai NO_COLOR on asetettu. +if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then + VARI_PUN=$'\033[31m'; VARI_VIH=$'\033[32m'; VARI_KEL=$'\033[33m'; VARI_L=$'\033[0m' +else + VARI_PUN=''; VARI_VIH=''; VARI_KEL=''; VARI_L='' +fi + +ok() { printf "${VARI_VIH}[OK]${VARI_L} %s\n" "$1"; } +fail() { printf "${VARI_PUN}[FAIL] %s${VARI_L}\n" "$1"; VIRHEET=$((VIRHEET + 1)); } +info() { printf "${VARI_KEL}[INFO]${VARI_L} %s\n" "$1"; } # curl-kääre: lisää Basic Auth -tunnukset, jos autentikointi todettiin käytössä. hae() { @@ -64,16 +72,16 @@ maarita_autentikointi() { } tarkista_http() { - local kuvaus=$1 + local nimi=$1 local url=$2 local odotettu_sisalto=${3:-} - vastaus=$(hae "$url" 2>/dev/null) || { fail "$kuvaus — ei vastausta ($url)"; return; } + vastaus=$(hae "$url" 2>/dev/null) || { fail "$nimi ei vastaa ($url)"; return; } if [[ -n "$odotettu_sisalto" ]] && ! echo "$vastaus" | grep -q "$odotettu_sisalto"; then - fail "$kuvaus — vastaus ei sisällä '$odotettu_sisalto'" + fail "$nimi — vastaus ei sisällä '$odotettu_sisalto'" else - ok "$kuvaus" + ok "$nimi" fi } @@ -129,14 +137,20 @@ echo "" # 2. WebUI HTTP tarkista_http "WebUI etusivu" "$WEBUI/" "Opserver" -tarkista_http "API /korkeakoulut vastaa" "$WEBUI/api/korkeakoulut" "" -tarkista_http "API /kurssit vastaa" "$WEBUI/api/kurssit" "" -tarkista_http "API /tutkimukset vastaa" "$WEBUI/api/tutkimukset" "" - -# Raportti-endpoint: tarkistetaan jokaiselle tutkimukselle (jos niitä on) -tutkimukset_json=$(hae "$WEBUI/api/tutkimukset" 2>/dev/null) || tutkimukset_json="[]" -slugit=$(printf '%s' "$tutkimukset_json" | python3 -c "import sys,json; [print(t['Slug']) for t in json.load(sys.stdin)]" 2>/dev/null) || slugit="" -if [[ -z "$slugit" ]]; then +tarkista_http "API /korkeakoulut" "$WEBUI/api/korkeakoulut" "" +tarkista_http "API /kurssit" "$WEBUI/api/kurssit" "" +tarkista_http "API /tutkimukset" "$WEBUI/api/tutkimukset" "" + +# Raportti-endpoint: tarkistetaan jokaiselle tutkimukselle (jos niitä on). +# Erottele kolme tilaa: (a) /api/tutkimukset ei vastaa → FAIL (ei saa väittää +# ettei tutkimuksia ole, kun tosiasiassa endpoint on alhaalla); (b) vastaa mutta +# ei kelvollista tutkimuslistaa → FAIL; (c) kelvollinen mutta tyhjä lista → OK. +if ! tutkimukset_json=$(hae "$WEBUI/api/tutkimukset" 2>/dev/null); then + fail "API /raportti — /api/tutkimukset ei vastaa, ei voi tarkistaa" +elif ! slugit=$(printf '%s' "$tutkimukset_json" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); assert isinstance(d,list); [print(t['Slug']) for t in d]" 2>/dev/null); then + fail "API /raportti — /api/tutkimukset ei palauttanut kelvollista tutkimuslistaa" +elif [[ -z "$slugit" ]]; then ok "API /raportti — ei tutkimuksia tarkistettavana" else raportti_ok=true @@ -170,9 +184,9 @@ fi echo "" if [[ $VIRHEET -eq 0 ]]; then - echo "Kaikki tarkistukset lapi. Kontit toimivat oikein." + printf "${VARI_VIH}Kaikki tarkistukset lapi. Kontit toimivat oikein.${VARI_L}\n" exit 0 else - echo "$VIRHEET tarkistus epaonnistui." + printf "${VARI_PUN}%s tarkistus epaonnistui.${VARI_L}\n" "$VIRHEET" exit 1 fi diff --git a/testit/test_yhteys.py b/testit/test_yhteys.py index a4527e3..c79e437 100644 --- a/testit/test_yhteys.py +++ b/testit/test_yhteys.py @@ -7,6 +7,7 @@ """ from unittest.mock import patch, MagicMock import threading +from mysql.connector import errors from tietokanta import yhteys as y @@ -16,8 +17,19 @@ def _tee_yhteys(): return m -def _laiska_pooli(koko=8): - return y._LaiskaPooli(koko, {"host": "x"}) +class _Kello: + """Ohjattava kello testeihin (ei oikeaa time.monotonicia).""" + + def __init__(self): + self.t = 0.0 + + def __call__(self): + return self.t + + +def _laiska_pooli(koko=8, idle_validointi_s=30, kello=None): + return y._LaiskaPooli(koko, {"host": "x"}, idle_validointi_s=idle_validointi_s, + aika=kello or _Kello()) def test_ensimmainen_haku_luo_vain_yhden_yhteyden(): @@ -112,3 +124,81 @@ def lainaa(): s.join() assert sum(1 for p in lainat if p) == 4 # tasan koko pooloitua assert sum(1 for p in lainat if not p) == 6 # loput väliaikaisia + + +# --- Kestävyys: pooli toipuu MySQL:n uudelleenkäynnistyksestä --- + +def test_tuoretta_yhteytta_ei_validoida(): + """Hot-path: äsken palautettu yhteys uudelleenkäytetään ILMAN ping-verkkomatkaa + (luokittelun tuhannet kirjoitukset eivät saa maksaa pingiä per kysely).""" + kello = _Kello() + with patch("tietokanta.yhteys.mysql.connector.connect", side_effect=lambda **k: _tee_yhteys()): + pooli = _laiska_pooli(koko=1, idle_validointi_s=30, kello=kello) + yht, p = pooli.hae() + pooli.palauta(yht, p) + kello.t = 5 # 5 s < 30 s → tuore + yht2, _ = pooli.hae() + yht2.ping.assert_not_called() # ei pingiä hot-pathilla + assert yht2 is yht + + +def test_idle_yhteys_validoidaan_ennen_uudelleenkayttoa(): + """Kauan poolissa maannut yhteys pingataan (reconnect) ennen luovutusta → + MySQL:n uudelleenkäynnistyksen jälkeen kuollut yhteys ei päädy kutsujalle.""" + kello = _Kello() + with patch("tietokanta.yhteys.mysql.connector.connect", side_effect=lambda **k: _tee_yhteys()): + pooli = _laiska_pooli(koko=1, idle_validointi_s=30, kello=kello) + yht, p = pooli.hae() + pooli.palauta(yht, p) + kello.t = 100 # 100 s > 30 s → idle + yht2, _ = pooli.hae() + yht2.ping.assert_called_once() + assert yht2.ping.call_args.kwargs.get("reconnect") is True + assert yht2 is yht + + +def test_kuollut_idle_yhteys_korvataan_tuoreella(): + """Jos idle-yhteyden ping epäonnistuu (palvelin katkaisi), yhteys suljetaan ja + korvataan tuoreella — kutsuja saa elävän yhteyden, ei virhettä.""" + kello = _Kello() + elava, korvaaja = _tee_yhteys(), _tee_yhteys() + luodut = [elava, korvaaja] + with patch("tietokanta.yhteys.mysql.connector.connect", side_effect=lambda **k: luodut.pop(0)): + pooli = _laiska_pooli(koko=1, idle_validointi_s=30, kello=kello) + yht, p = pooli.hae() # elava + pooli.palauta(yht, p) + elava.ping.side_effect = errors.OperationalError("palvelin katkaisi") + kello.t = 100 + yht2, p2 = pooli.hae() # ping kaatuu → korvataan + elava.close.assert_called_once() + assert yht2 is korvaaja + assert p2 is True # yhä pooloitu (slotti säilyi) + + +def test_rikki_yhteys_ei_palaudu_pooliin(): + """Yhteystason virheen jälkeen rikki yhteys EI palaudu pooliin (muuten se + jaettaisiin uudelleen → 'MySQL Connection not available' -kaskadi).""" + with patch("tietokanta.yhteys.mysql.connector.connect", side_effect=lambda **k: _tee_yhteys()) as mc: + pooli = _laiska_pooli(koko=2) + yht, p = pooli.hae() # connect #1 + pooli.palauta(yht, p, rikki=True) + yht.close.assert_called_once() # rikki suljetaan, ei poolata + yht2, p2 = pooli.hae() # slotti vapautui → uusi kättely + assert mc.call_count == 2 + assert yht2 is not yht + + +def test_kontekstimanageri_merkitsee_yhteysvirheen_rikkinaiseksi(): + """OperationalError (kuollut yhteys) → palauta(rikki=True), EI rollbackia + (kaatuisi kuolleella yhteydellä).""" + yht = _tee_yhteys() + pooli = MagicMock(hae=lambda: (yht, True)) + with patch.object(y, "_hae_pooli", return_value=pooli): + try: + with y.yhteys(): + raise errors.OperationalError("MySQL Connection not available.") + except errors.OperationalError: + pass + yht.rollback.assert_not_called() + yht.commit.assert_not_called() + pooli.palauta.assert_called_once_with(yht, True, rikki=True) diff --git a/tietokanta/yhteys.py b/tietokanta/yhteys.py index 15bd0d8..5150d0e 100644 --- a/tietokanta/yhteys.py +++ b/tietokanta/yhteys.py @@ -1,7 +1,9 @@ import os +import time import threading from contextlib import contextmanager import mysql.connector +from mysql.connector import errors from dotenv import load_dotenv load_dotenv() @@ -40,49 +42,88 @@ def _pooli_koko() -> int: return int(os.getenv("DB_POOLI_KOKO", "8")) +def _idle_validointi_s() -> int: + """Kynnys (s), jonka yli poolissa maannut yhteys validoidaan (ping+reconnect) + ennen luovutusta. Hot-path (peräkkäiset kyselyt) alittaa kynnyksen → ei pingiä. + (.env: DB_IDLE_VALIDOINTI_S; oletus 30 s.)""" + return int(os.getenv("DB_IDLE_VALIDOINTI_S", "30")) + + class _LaiskaPooli: """Luo yhteydet tarvittaessa kattoon (koko) asti ja käyttää palautetut uudelleen. Katon täyttyessä (rinnakkaisuuspiikki) antaa väliaikaisen yhteyden, - joka suljetaan palautuksessa — ei tukita ruuhkaa eikä kasvateta poolia yli.""" + joka suljetaan palautuksessa — ei tukita ruuhkaa eikä kasvateta poolia yli. - def __init__(self, koko: int, asetukset: dict): + Kestävyys MySQL:n uudelleenkäynnistykselle: (1) idle_validointi_s-kynnyksen yli + maannut yhteys pingataan (reconnect) ennen luovutusta ja korvataan jos kuollut; + (2) yhteystason virheen saanut yhteys suljetaan palautuksessa (rikki=True) eikä + palaudu pooliin. Hot-pathilla (idle < kynnys) ei pingiä → ei ylimääräistä + verkkomatkaa luokittelun tuhansiin kyselyihin.""" + + def __init__(self, koko: int, asetukset: dict, idle_validointi_s: int = 30, + aika=time.monotonic): self._koko = koko self._asetukset = asetukset - self._vapaat: list = [] # käytettävissä olevat pooliyhteydet (LIFO) + self._idle_validointi_s = idle_validointi_s + self._aika = aika + self._vapaat: list = [] # (yhteys, palautus_hetki), LIFO self._luotu = 0 # luotujen pooliyhteyksien määrä (vapaat + lainassa) self._lukko = threading.Lock() def _yhdista(self): return mysql.connector.connect(**self._asetukset) + def _luo_pooliyhteys(self): + """Uusi pooliyhteys; slotti (_luotu) vapautetaan jos kättely epäonnistuu.""" + try: + return self._yhdista() + except Exception: + with self._lukko: + self._luotu -= 1 + raise + def hae(self): """Palauttaa (yhteys, pooloitu). pooloitu=False → väliaikainen yhteys, joka suljetaan palautuksessa (katto oli täynnä).""" with self._lukko: if self._vapaat: - return self._vapaat.pop(), True - luo_pooliin = self._luotu < self._koko - if luo_pooliin: - self._luotu += 1 - # Kättely lukon ULKOPUOLELLA — verkkoviive ei saa tukkia muita säikeitä. - if luo_pooliin: + yht, hetki = self._vapaat.pop() + tuore = (self._aika() - hetki) < self._idle_validointi_s + else: + yht = None + luo_pooliin = self._luotu < self._koko + if luo_pooliin: + self._luotu += 1 + # Kättely/ping lukon ULKOPUOLELLA — verkkoviive ei saa tukkia muita säikeitä. + if yht is not None: + if tuore: + return yht, True # hot-path: ei pingiä + # Idle liian kauan → varmista elossa; kuollut korvataan tuoreella. try: - return self._yhdista(), True + yht.ping(reconnect=True, attempts=1, delay=0) + return yht, True except Exception: - with self._lukko: - self._luotu -= 1 # vapauta slotti, ettei pooli jää vajaaksi - raise + try: + yht.close() + except Exception: + pass + return self._luo_pooliyhteys(), True # slotti säilyi varattuna + if luo_pooliin: + return self._luo_pooliyhteys(), True return self._yhdista(), False - def palauta(self, yht, pooloitu: bool) -> None: - if pooloitu: + def palauta(self, yht, pooloitu: bool, rikki: bool = False) -> None: + if pooloitu and not rikki: with self._lukko: - self._vapaat.append(yht) # takaisin pooliin ilman ping-verkkomatkaa - else: - try: - yht.close() - except Exception: - pass + self._vapaat.append((yht, self._aika())) # pooliin ilman ping-verkkomatkaa + return + try: + yht.close() + except Exception: + pass + if pooloitu: # rikki pooliyhteys → vapauta slotti uudelleenluontia varten + with self._lukko: + self._luotu -= 1 def _hae_pooli() -> _LaiskaPooli: @@ -90,7 +131,7 @@ def _hae_pooli() -> _LaiskaPooli: if _pooli is None: with _pooli_lukko: if _pooli is None: - _pooli = _LaiskaPooli(_pooli_koko(), _asetukset()) + _pooli = _LaiskaPooli(_pooli_koko(), _asetukset(), _idle_validointi_s()) return _pooli @@ -98,9 +139,15 @@ def _hae_pooli() -> _LaiskaPooli: def yhteys(): pooli = _hae_pooli() yht, pooloitu = pooli.hae() + rikki = False try: yield yht yht.commit() + except (errors.OperationalError, errors.InterfaceError): + # Yhteystason virhe (palvelin katkaisi, "Connection not available", ...) → + # yhteys on rikki: ei rollbackia (kaatuisi) eikä paluuta pooliin. + rikki = True + raise except Exception: try: yht.rollback() @@ -108,7 +155,7 @@ def yhteys(): pass raise finally: - pooli.palauta(yht, pooloitu) + pooli.palauta(yht, pooloitu, rikki=rikki) def alusta_tietokanta(): diff --git a/webui/Dockerfile b/webui/Dockerfile index f3a4221..e70578f 100644 --- a/webui/Dockerfile +++ b/webui/Dockerfile @@ -8,6 +8,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY tietokanta/ tietokanta/ COPY luokittelu/ luokittelu/ COPY llm/ llm/ +COPY raportti/ raportti/ COPY kehotteet/ kehotteet/ COPY webui/ webui/ diff --git a/webui/staattinen/index.html b/webui/staattinen/index.html index 9ab649d..b1020f6 100644 --- a/webui/staattinen/index.html +++ b/webui/staattinen/index.html @@ -5,7 +5,7 @@
