Spaces:
Running on Zero
Running on Zero
File size: 7,459 Bytes
2173baa cc04911 2173baa 456f775 cc04911 456f775 199fcc8 cc04911 2173baa 7d530f8 456f775 cc04911 456f775 2173baa 456f775 b532259 2173baa b532259 456f775 c4e38ea cc04911 456f775 cc04911 456f775 cc04911 456f775 2173baa cc04911 456f775 d2a52e1 cc04911 456f775 d2a52e1 7d530f8 456f775 7d530f8 cc04911 7d530f8 cc04911 d2a52e1 7d530f8 cc04911 7d530f8 cc04911 1ff61c0 cc04911 456f775 1ff61c0 cc04911 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | """Cola por lotes para Gemini (moderacion por API, generateContent).
Un mensaje = una llamada Gemini secuencial. La cola acumula mensajes (ventana)
y los modera en lote; cada fallo individual se marca fail-closed
({"_error"} -> uncertain + escala) sin tumbar el resto. Solo si TODO el
lote falla (red/API caida) se reencola hasta 3 veces.
Sin GPU local: Space y local usan el mismo camino (solo cambia la key).
El registro al recibir garantiza que nada se pierde aunque la API caiga.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Callable
import gemini_client
import store
from space_client import Item
log = logging.getLogger("kmbot.ojqueue")
@dataclass
class _Pending:
item: Item
retries: int = 0
first_seen: float = field(default_factory=time.time)
def moderate_batch(items: list[Item]) -> dict[str, dict[str, Any]]:
"""{item.key: veredicto parseado}. Llamadas secuenciales (volumen bajo)."""
out: dict[str, dict[str, Any]] = {}
for it in items:
try:
hist = []
for h in (it.history or []):
if isinstance(h, dict):
a = str(h.get("author") or "?")
t = str(h.get("text") or "")
if t:
hist.append(f"{a}: {t}")
elif h:
hist.append(str(h))
out[it.key] = gemini_client.moderate(
it.text, flags="", reply_to=it.reply_to or "", history=hist,
)
except Exception as exc: # noqa: BLE001
out[it.key] = {"unsafe": True, "categories": [], "raw": "",
"_error": f"{type(exc).__name__}: {exc}"}
return out
class OJBatchQueue:
def __init__(
self,
client: Any | None,
on_results: Callable[[list[Item], dict[str, dict[str, Any]]], None],
*,
max_messages: int = 16,
window: float = 8.0,
max_retries: int = 3,
loop: asyncio.AbstractEventLoop | None = None,
predict_fn: Callable[[list[Item]], dict[str, dict]] | None = None,
) -> None:
"""`predict_fn` (default: moderate_batch) recibe items y devuelve
{key: veredicto}. `client` se ignora (compat): ya no hay Space GPU."""
self.client = client
self.predict_fn = predict_fn or moderate_batch
# Diagnostico visible en /status (sin esto vamos ciegos).
self.last_error: str | None = None
self.processed: int = 0
self.dropped: int = 0
self.on_results = on_results
self.max_messages = max_messages
self.window = window
self.max_retries = max_retries
self._pending: list[_Pending] = []
self._task: asyncio.Task | None = None
self._wake = asyncio.Event()
self._loop = loop
def submit(self, item: Item) -> None:
self._pending.append(_Pending(item=item))
self._kick()
def _kick(self) -> None:
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
return
if self._task is None or self._task.done():
self._task = self._loop.create_task(self._run())
def start(self) -> None:
self._kick()
self._anchor = self._task
async def _run(self) -> None:
while True:
if not self._pending:
self._wake.clear()
try:
await asyncio.wait_for(self._wake.wait(), timeout=self.window)
except asyncio.TimeoutError:
return
continue
oldest = self._pending[0].first_seen
wait = self.window - (time.time() - oldest)
if len(self._pending) < self.max_messages and wait > 0:
try:
await asyncio.wait_for(self._wake.wait(), timeout=wait)
except asyncio.TimeoutError:
pass
continue
await self._flush()
async def _flush(self) -> None:
batch = self._pending[: self.max_messages]
self._pending = self._pending[self.max_messages :]
items = [p.item for p in batch]
try:
loop = asyncio.get_running_loop()
merged = await loop.run_in_executor(None, self.predict_fn, items)
self.last_error = None
self.processed += len(items)
except Exception as exc: # noqa: BLE001
# UN solo camino de reintento para cualquier fallo del lote.
self.last_error = f"{type(exc).__name__}: {exc}"
log.warning(
"Fallo el lote (%s: %s). Reintentare hasta %d veces; %d mensajes en cola.",
type(exc).__name__, exc, self.max_retries, len(items),
)
requeue: list[_Pending] = []
exhausted: list[Item] = []
for p in batch:
if p.retries < self.max_retries:
p.retries += 1
p.first_seen = time.time() # reintentar en el proximo ciclo
requeue.append(p)
else:
self.dropped += 1
exhausted.append(p.item)
log.error("Descartando %s tras %d intentos.", p.item.key, p.retries)
self._pending = requeue + self._pending
if exhausted:
# Marca la fila para que no se confunda con "aun sin puntuar".
for it in exhausted:
try:
store.apply_decision(
chat_id=it.chat_id,
message_id=it.message_id,
flags=[],
probabilities={},
summary=f"modelo no respondio: {self.last_error}",
max_severity=0,
escalate=False,
is_violation=False,
# scored=False a proposito: no hay veredicto. Si
# se marcara como puntuada aqui, la fila dejaria de
# contar en `sin puntuar` y la caida del modelo
# quedaria escondida en el contador de /status.
scored=False,
)
except Exception: # noqa: BLE001
log.exception("No se pudo marcar el descarte de %s.", it.key)
if requeue:
self._kick()
return
by_msg: dict[str, Any] = {}
for it in items:
got = merged.get(it.key)
# OJO: `got` NO tiene por que ser un dict. El backend Decision-2.0
# devuelve un `rules.ModerationDecision` ya evaluado, y coercionarlo
# a dict tiraba el veredicto (medido 2026-10-08 en produccion: el
# Space juzgaba mensajes reales del grupo y la bitacora se quedaba
# en "sin respuesta", con dec2_rows = 0). Solo se rellena el
# marcador cuando no hay NADA para esa clave.
by_msg[it.key] = got if got is not None else {"_error": "sin respuesta"}
try:
self.on_results(items, by_msg)
except Exception: # noqa: BLE001
log.exception("Fallo el manejo de resultados.")
|