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.")