Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions cliui/luokittelunaytto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -314,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']}")
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
125 changes: 83 additions & 42 deletions luokittelu/llmluokittelu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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)
14 changes: 11 additions & 3 deletions testit/savutesti.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
# Aja: ./testit/savutesti.sh
# Oletus: kontit ovat jo käynnissä (docker compose up -d).

set -euo pipefail

WEBUI="http://localhost:12121"
# 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

# Projektin juuri = lähin hakemisto (nykyhakemistosta ylöspäin), jossa on
# docker-compose.yml. Riippumaton siitä miten skripti käynnistetään (suoraan
Expand All @@ -19,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=()

Expand Down
2 changes: 1 addition & 1 deletion testit/test_cliui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading