From 59194d91269bb2a865db0a1f18f25c79009a04c0 Mon Sep 17 00:00:00 2001 From: idriz Date: Fri, 4 Sep 2026 19:55:36 +0200 Subject: [PATCH] =?UTF-8?q?Igra:=20trenutna=20potvrda=20izbora=20(prepozna?= =?UTF-8?q?vanje=20odgo=C4=91eno)=20+=20timeout=20web=20searcha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uzrok sporosti (iz loga): prepoznavanje lika, uključujući web search, radilo se UNUTAR superheroji_izbor prije potvrde. Za nepoznato ime web search je jednom trajao ~100s i blokirao potvrdu. - record_pick je sada BRZ (bez LLM-a): odmah bilježi izbor i vraća potvrdu; brza provjera zabrane/duplikata preko keša alias-a i izgovorenog imena. - Pravo prepoznavanje (LLM + web search) radi se pri presudi (_resolve_pending), paralelno, gdje ionako ima filler poruka. - Web search ograničen na 20s (+8s wiki fallback), da presuda ne visi. - Filler je sada periodičan (svakih ~9s dok traje presuda), ne samo jednom. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW --- idriz/assistant.py | 24 ++++++++----- idriz/game.py | 85 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 27 deletions(-) diff --git a/idriz/assistant.py b/idriz/assistant.py index e471b9a..76bb6ef 100644 --- a/idriz/assistant.py +++ b/idriz/assistant.py @@ -233,16 +233,24 @@ class Assistant: nxt = self.match.next_picker() return confirm + f" Zatim pitaj igrača {nxt} da kaže svog lika." + _FILLERS = [ + "Reci kratko i veselo, u jednoj rečenici, da još razmišljaš ko bi pobijedio. Ne otkrivaj pobjednika.", + "Reci u jednoj rečenici da je borba tijesna i da još vagaš ko je jači. Ne otkrivaj pobjednika.", + "Reci kratko da samo što nisi odlučio, još malo. Ne otkrivaj pobjednika.", + ] + async def _adjudicate_and_announce(self, match: "game.Match") -> None: - """Presudi rundu (može potrajati). Ako traje, ubaci filler; onda pošalji rezultat modelu.""" + """Presudi rundu (može potrajati zbog prepoznavanja/web searcha). Ubacuj filler dok traje.""" + adj = asyncio.create_task(match.resolve_round()) + i = 0 try: - adj = asyncio.create_task(match.resolve_round()) - try: - res = await asyncio.wait_for(asyncio.shield(adj), timeout=8) - except asyncio.TimeoutError: - await self._nudge("Reci kratko, veselo i u jednoj rečenici da još razmišljaš ko bi pobijedio " - "(npr. 'Hmm, još vagam ko je jači...'). Ne otkrivaj pobjednika.") - res = await adj + while True: + try: + res = await asyncio.wait_for(asyncio.shield(adj), timeout=9) + break + except asyncio.TimeoutError: + await self._nudge(self._FILLERS[min(i, len(self._FILLERS) - 1)]) + i += 1 except Exception: # noqa: BLE001 log.exception("presuda runde nije uspjela") await self.notify_model("Došlo je do greške pri presudi runde. Reci djeci da još jednom kažu svoje likove.") diff --git a/idriz/game.py b/idriz/game.py index c7da108..8187b3d 100644 --- a/idriz/game.py +++ b/idriz/game.py @@ -32,28 +32,33 @@ znatno slabijeg lika bez izuzetno jakog razloga. Poštuj ranije ishode da igra b Vrati JSON: {"pobjednik": "tačno ime pobjednika iz date liste", "razlog": "obrazloženje na bosanskom"}.""" +WEB_SEARCH_TIMEOUT = 20.0 # sekundi — da presuda ne visi predugo + + async def _web_search(client: AsyncOpenAI, model: str, name: str) -> str: - """Kratak opis lika sa weba — koristi se SAMO kad LLM ne zna lika.""" + """Kratak opis lika sa weba — koristi se SAMO kad LLM ne zna lika. Vremenski ograničeno.""" try: - resp = await client.responses.create( + resp = await asyncio.wait_for(client.responses.create( model=model, tools=[{"type": "web_search"}], input=(f"Ko je izmišljeni lik '{name}'? Iz kojeg je djela (film, crtani, strip, igra, knjiga)? " f"Ukratko opiši lika i njegove moći ili sposobnosti."), - ) + ), timeout=WEB_SEARCH_TIMEOUT) text = (resp.output_text or "").strip() if text: log.info("web search za %r: %d znakova", name, len(text)) return text + except asyncio.TimeoutError: + log.warning("web search za %r prekinut nakon %.0fs", name, WEB_SEARCH_TIMEOUT) except Exception: # noqa: BLE001 log.exception("web search (responses API) nije uspio za %r", name) - # rezervni izvor: engleska Wikipedia + # rezervni izvor: engleska Wikipedia (brzo) try: - art = await asyncio.to_thread(wiki.find_article, name) + art = await asyncio.wait_for(asyncio.to_thread(wiki.find_article, name), timeout=8.0) if art: return f"{art.title}: {art.text[:800]}" - except Exception: # noqa: BLE001 - log.exception("wiki fallback nije uspio za %r", name) + except (asyncio.TimeoutError, Exception): # noqa: BLE001 + log.warning("wiki fallback nije uspio/prekinut za %r", name) return "" @@ -237,28 +242,70 @@ class Match: } async def record_pick(self, player: str, hero: str) -> dict: + """BRZO bilježi izbor (bez poziva LLM-u), da potvrda bude trenutna. + + Pravo prepoznavanje lika (LLM + web search) radi se kasnije, pri presudi runde + (_resolve_pending), gdje ionako ima vremena i filler poruka. + """ player = (player or "").strip() - # upari igrača (tolerantno na sitne razlike) if player not in self.scores: match = next((p for p in self.players if p.lower() == player.lower()), None) player = match or (self.players[0] if len(self.players) == 1 else player) - info = await resolve_hero(self.client, self.model, self.store, hero) - if info is None: - return {"ok": False, "razlog": "nepoznat", - "poruka": f"Ne mogu prepoznati lika '{hero}'. Neka {player} izabere drugog."} - if info["key"] in self.banned: + spoken = (hero or "").strip() + if not spoken: + return {"ok": False, "razlog": "prazno", + "poruka": f"Nisam čuo ime lika. Neka {player} ponovi."} + + cached_key = self.store.resolve_alias(spoken) # samo ranije viđeni likovi + # brza provjera zabrane (pobjednici su prepoznati i keširani) + if cached_key and cached_key in self.banned: + name = (self.store.get(cached_key) or {}).get("name", spoken) return {"ok": False, "razlog": "iskorišten", - "poruka": f"{info['name']} je već pobijedio ranije u ovoj partiji i ne može se ponovo koristiti. " + "poruka": f"{name} je već pobijedio ranije u ovoj partiji i ne može se ponovo koristiti. " f"Neka {player} izabere drugog heroja."} - if any(pk["key"] == info["key"] for pl, pk in self.picks.items() if pl != player): - return {"ok": False, "razlog": "duplikat", - "poruka": f"{info['name']} je već izabran u ovoj rundi. Neka {player} izabere drugog."} - self.picks[player] = info - return {"ok": True, "igrac": player, "heroj": info["name"], "poruka": f"{info['name']} je zabilježen za {player}."} + # brza provjera duplikata u rundi + for pl, pk in self.picks.items(): + if pl == player: + continue + if cached_key and pk.get("key") == cached_key: + return {"ok": False, "razlog": "duplikat", + "poruka": f"{pk['name']} je već izabran u ovoj rundi. Neka {player} izabere drugog."} + if not cached_key and normalize_key(pk.get("spoken", "")) == normalize_key(spoken): + return {"ok": False, "razlog": "duplikat", + "poruka": f"Taj lik je već izabran u ovoj rundi. Neka {player} izabere drugog."} + + if cached_key: + h = self.store.get(cached_key) or {} + pick = {"spoken": spoken, "key": cached_key, "name": h.get("name", spoken), + "desc": h.get("desc", ""), "resolved": True} + else: + pick = {"spoken": spoken, "key": "", "name": spoken.strip().title() or spoken, + "desc": "", "resolved": False} + self.picks[player] = pick + return {"ok": True, "igrac": player, "heroj": pick["name"], + "poruka": f"{pick['name']} je zabilježen za {player}."} + + async def _resolve_pending(self) -> None: + """Prepoznaj sve još neprepoznate likove u rundi (LLM + web search) — paralelno.""" + pending = [(pl, pk) for pl, pk in self.picks.items() if not pk.get("resolved")] + if not pending: + return + async def _one(pl: str, pk: dict) -> None: + info = await resolve_hero(self.client, self.model, self.store, pk["spoken"]) + if info: + pk["key"], pk["name"], pk["desc"] = info["key"], info["name"], info["desc"] + else: + # ni nakon pretrage nije prepoznat — koristi izgovoreno ime kao lik + pk["key"] = normalize_key(pk["spoken"]) or "nepoznato" + self.store.upsert(pk["key"], pk["name"], "", "", "web") + pk["resolved"] = True + await asyncio.gather(*[_one(pl, pk) for pl, pk in pending]) async def resolve_round(self) -> dict: if not self.picks: return {"ok": False, "poruka": "Još niko nije izabrao heroja u ovoj rundi."} + # tek sada prepoznaj likove (LLM + web search); potvrde su već izgovorene + await self._resolve_pending() # heroji poredani po redoslijedu igranja order = self.current_order participants = [self.picks[p] for p in order if p in self.picks]