Spaces:
Sleeping
Sleeping
Upload app.py
Browse files
app.py
CHANGED
|
@@ -7,7 +7,9 @@ Qué hace
|
|
| 7 |
- `milo_core.py` debe estar junto a este app.py en el Space (el export del Colab 3 lo incluye). Si no está,
|
| 8 |
se descarga del repo del modelo.
|
| 9 |
- Usa la plantilla de chat con tokens especiales (<|system|> <|user|> <|assistant|> <|end|>), la MISMA del
|
| 10 |
-
fine-tuning, y el motor de generación de milo_core (KV-cache, min-p,
|
|
|
|
|
|
|
| 11 |
- Endpoints: /chat, /chat/stream (SSE), /web_chat (búsqueda web + respuesta), /health, /v1/model_info,
|
| 12 |
/web_search_debug. La UI de Gradio queda en "/".
|
| 13 |
|
|
@@ -49,7 +51,11 @@ MAX_NEW_TOKENS_HARD_CAP = int(os.environ.get("MILO_MAX_NEW_TOKENS_CAP", "400"))
|
|
| 49 |
NUM_THREADS = int(os.environ.get("MILO_NUM_THREADS", str(os.cpu_count() or 2)))
|
| 50 |
USE_CONTEXT = os.environ.get("MILO_USE_CONTEXT", "0") == "1" # 1 = usa el historial de la conversación
|
| 51 |
NUM_CANDIDATES = int(os.environ.get("MILO_NUM_CANDIDATES", "1")) # >1 = best-of-N (en lote), N veces más costoso
|
| 52 |
-
SYSTEM_PROMPT_ENV = os.environ.get("MILO_SYSTEM_PROMPT")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY", "").strip()
|
| 55 |
SEARCH_TIMEOUT = float(os.environ.get("MILO_SEARCH_TIMEOUT", "8"))
|
|
@@ -77,6 +83,7 @@ _blocked: Optional[torch.Tensor] = None
|
|
| 77 |
_load_error: Optional[str] = None
|
| 78 |
_tok_info: dict = {}
|
| 79 |
_stage: Optional[str] = None
|
|
|
|
| 80 |
_GEN_LOCK = threading.Lock() # una generación a la vez: en CPU, varias en paralelo solo se estorban
|
| 81 |
|
| 82 |
|
|
@@ -84,7 +91,7 @@ _GEN_LOCK = threading.Lock() # una generación a la vez: en CPU, varias en par
|
|
| 84 |
# Carga del modelo
|
| 85 |
# ==========================================================================================
|
| 86 |
def load_model():
|
| 87 |
-
global _model, _tok, _cfg, _system, _blocked, _load_error, _tok_info, _stage
|
| 88 |
print(f"[Milo X1] Descargando modelo desde {MODEL_REPO_ID} (revision={MODEL_REVISION}) ...")
|
| 89 |
try:
|
| 90 |
from safetensors.torch import load_file
|
|
@@ -130,11 +137,12 @@ def load_model():
|
|
| 130 |
_system = cfg_json.get("system_prompt", mc.DEFAULT_SYSTEM)
|
| 131 |
_blocked = mc.make_blocked_ids(tok, cfg.vocab_size, "cpu", allow=tok.stop_ids)
|
| 132 |
_model, _tok, _cfg, _stage = model, tok, cfg, cfg_json.get("stage")
|
|
|
|
| 133 |
_tok_info = {"vocab_size": tok.vocab_size, "unicode_roundtrip": mc.check_tokenizer_unicode(tok, verbose=False)}
|
| 134 |
_load_error = None
|
| 135 |
print(f"[Milo X1] Modelo cargado (etapa: {_stage}). {model.num_params()/1e6:.1f}M parámetros | d_model={cfg.d_model} "
|
| 136 |
f"| capas={cfg.n_layers} | heads={cfg.n_heads} (kv={cfg.n_kv_heads}) | contexto={cfg.context_length} "
|
| 137 |
-
f"| vocab={tok.vocab_size} | unicode/emoji={'OK' if _tok_info['unicode_roundtrip'] else 'CON PÉRDIDAS'}")
|
| 138 |
|
| 139 |
|
| 140 |
# ==========================================================================================
|
|
@@ -185,31 +193,35 @@ def make_gen(params: dict, seed=None) -> mc.GenConfig:
|
|
| 185 |
max_new_tokens=int(params["max_new_tokens"]), temperature=float(params["temperature"]),
|
| 186 |
top_k=int(params["top_k"] or 0), top_p=float(params["top_p"] if params["top_p"] else 1.0),
|
| 187 |
min_p=float(params.get("min_p", 0.0)), repetition_penalty=float(params["repetition_penalty"]),
|
| 188 |
-
no_repeat_ngram=int(params.get("no_repeat_ngram", 0)), seed=seed)
|
| 189 |
|
| 190 |
|
| 191 |
-
def
|
| 192 |
-
"""Generador SÍNCRONO de
|
| 193 |
-
deja de leer (cliente desconectado)
|
| 194 |
cancel = cancel or threading.Event()
|
| 195 |
q: "queue.Queue" = queue.Queue()
|
| 196 |
-
|
| 197 |
|
| 198 |
-
def
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
|
| 205 |
def work():
|
| 206 |
try:
|
| 207 |
with _GEN_LOCK:
|
| 208 |
if not cancel.is_set():
|
| 209 |
-
mc.
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
| 213 |
except _Cancel:
|
| 214 |
pass
|
| 215 |
except Exception as e: # noqa
|
|
@@ -243,54 +255,51 @@ def score_candidate(text: str) -> float:
|
|
| 243 |
return (len(set(bigrams)) / max(len(bigrams), 1)) * 0.5 + (len(set(words)) / len(words)) * 0.3 + min(len(words), 40) / 40 * 0.2
|
| 244 |
|
| 245 |
|
| 246 |
-
def
|
| 247 |
-
"""Respuesta completa. Con n>1 genera n candidatas EN LOTE
|
| 248 |
-
|
| 249 |
with _GEN_LOCK:
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
|
| 255 |
# ==========================================================================================
|
| 256 |
-
# Armado
|
| 257 |
# ==========================================================================================
|
| 258 |
-
def
|
| 259 |
ctx = _cfg.context_length
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
|
| 263 |
-
def
|
| 264 |
"""Si no cabe: descarta turnos viejos y luego recorta el INICIO del mensaje (se conserva el final)."""
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
ids = _tok.encode_chat(hist + [{"role": "user", "content": text}], system=_system, add_generation_prompt=True)
|
| 272 |
-
if len(ids) <= budget:
|
| 273 |
-
return ids
|
| 274 |
-
if hist:
|
| 275 |
-
hist = hist[2:]
|
| 276 |
-
continue
|
| 277 |
-
if len(text) <= 1:
|
| 278 |
-
break
|
| 279 |
-
text = text[-max(1, int(len(text) * 0.85)):]
|
| 280 |
-
return ids[:1] + ids[-(budget - 1):]
|
| 281 |
|
| 282 |
|
| 283 |
-
def
|
| 284 |
"""Pregunta + resultados web en un solo mensaje de usuario (igual que el SFT fusiona instruction + input).
|
| 285 |
Si no cabe, se recortan los RESULTADOS, nunca la pregunta."""
|
| 286 |
-
budget =
|
| 287 |
-
ent
|
| 288 |
for _ in range(60):
|
| 289 |
-
|
| 290 |
-
if len(
|
| 291 |
-
return
|
| 292 |
ent = ent[: int(len(ent) * 0.9)].rstrip()
|
| 293 |
-
return
|
| 294 |
|
| 295 |
|
| 296 |
_LEGACY_RE = re.compile(r"###\s*Instrucci[oó]n:\s*\n(.*?)(?:\n\s*###\s*Entrada:\s*\n(.*?))?\s*\n\s*###\s*Respuesta:\s*$", re.S)
|
|
@@ -520,10 +529,6 @@ def rate_allow(key: str, limit: int = SEARCH_RATE_PER_MIN, window: float = 60.0)
|
|
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
-
def build_search_user_text(question: str, entrada: str) -> str:
|
| 524 |
-
return f"{question}\n\n{entrada}"
|
| 525 |
-
|
| 526 |
-
|
| 527 |
# ==========================================================================================
|
| 528 |
# API FastAPI
|
| 529 |
# ==========================================================================================
|
|
@@ -548,6 +553,9 @@ class ChatRequest(BaseModel):
|
|
| 548 |
repetition_penalty: float = Field(CHAT_DEFAULTS["repetition_penalty"], ge=1.0, le=2.0)
|
| 549 |
no_repeat_ngram: int = Field(0, ge=0, le=8)
|
| 550 |
seed: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
|
| 553 |
def _history_msgs(messages: List[ChatMessage]) -> Tuple[List[dict], str]:
|
|
@@ -566,10 +574,13 @@ def _history_msgs(messages: List[ChatMessage]) -> Tuple[List[dict], str]:
|
|
| 566 |
|
| 567 |
|
| 568 |
def _resolve_request(req: ChatRequest):
|
| 569 |
-
"""Devuelve (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
legacy = parse_legacy_prompt(req.prompt) or (
|
| 571 |
parse_legacy_prompt(req.messages[-1].content) if (not req.prompt and req.messages and req.messages[-1].role == "user") else None)
|
| 572 |
-
given = req.model_fields_set
|
| 573 |
if legacy is not None and legacy[1]: # con "Entrada" = modo búsqueda web
|
| 574 |
question, entrada = legacy
|
| 575 |
p = dict(SEARCH_DEFAULTS)
|
|
@@ -577,7 +588,7 @@ def _resolve_request(req: ChatRequest):
|
|
| 577 |
if k in given:
|
| 578 |
p[k] = getattr(req, k)
|
| 579 |
p["max_new_tokens"] = min(p["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
|
| 580 |
-
return
|
| 581 |
if legacy is not None:
|
| 582 |
history, user_text = [], legacy[0]
|
| 583 |
else:
|
|
@@ -586,7 +597,7 @@ def _resolve_request(req: ChatRequest):
|
|
| 586 |
history, user_text = _history_msgs(req.messages)
|
| 587 |
p = dict(max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_k=req.top_k, top_p=req.top_p,
|
| 588 |
min_p=req.min_p, repetition_penalty=req.repetition_penalty, no_repeat_ngram=req.no_repeat_ngram)
|
| 589 |
-
return
|
| 590 |
|
| 591 |
|
| 592 |
@app.on_event("startup")
|
|
@@ -597,7 +608,8 @@ def _startup():
|
|
| 597 |
@app.get("/health")
|
| 598 |
def health():
|
| 599 |
return {"status": "ok" if _model is not None else "model_not_loaded", "model_repo": MODEL_REPO_ID,
|
| 600 |
-
"quantized": QUANTIZE, "error": _load_error, "stage": _stage,
|
|
|
|
| 601 |
"web_search": {"tavily_key_set": bool(TAVILY_API_KEY), "fallback": "wikipedia"},
|
| 602 |
"tokenizer": _tok_info or None}
|
| 603 |
|
|
@@ -610,6 +622,7 @@ def model_info():
|
|
| 610 |
"d_model": _cfg.d_model, "n_layers": _cfg.n_layers, "n_heads": _cfg.n_heads, "n_kv_heads": _cfg.n_kv_heads,
|
| 611 |
"context_length": _cfg.context_length, "vocab_size": _cfg.vocab_size, "quantized": QUANTIZE,
|
| 612 |
"stage": _stage, "use_context": USE_CONTEXT, "num_candidates": NUM_CANDIDATES,
|
|
|
|
| 613 |
"system_prompt": _system, "tokenizer": _tok_info}
|
| 614 |
|
| 615 |
|
|
@@ -617,37 +630,47 @@ def model_info():
|
|
| 617 |
def chat(req: ChatRequest):
|
| 618 |
if _model is None:
|
| 619 |
raise HTTPException(503, _load_error or "Modelo no cargado.")
|
| 620 |
-
|
| 621 |
t0 = time.time()
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
|
|
|
|
|
|
| 625 |
|
| 626 |
|
| 627 |
@app.post("/chat/stream")
|
| 628 |
async def chat_stream(req: ChatRequest, request: Request):
|
| 629 |
if _model is None:
|
| 630 |
raise HTTPException(503, _load_error or "Modelo no cargado.")
|
| 631 |
-
|
| 632 |
|
| 633 |
async def event_stream():
|
| 634 |
loop = asyncio.get_running_loop()
|
| 635 |
cancel = threading.Event()
|
| 636 |
try:
|
| 637 |
-
if search or NUM_CANDIDATES <= 1:
|
| 638 |
-
it =
|
| 639 |
sentinel = object()
|
|
|
|
| 640 |
while True:
|
| 641 |
-
|
| 642 |
-
if
|
| 643 |
break
|
| 644 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
if await request.is_disconnected():
|
| 646 |
break
|
| 647 |
else:
|
| 648 |
-
|
| 649 |
-
for i in range(0, len(
|
| 650 |
-
yield f"data: {json.dumps({'delta':
|
| 651 |
await asyncio.sleep(0.01)
|
| 652 |
except Exception as e: # noqa
|
| 653 |
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
|
@@ -688,11 +711,12 @@ async def web_chat(req: WebChatRequest, request: Request):
|
|
| 688 |
items = pick_results(raw, dedupe_domains=(provider != "wikipedia"))
|
| 689 |
entrada, items = build_entrada(items)
|
| 690 |
max_new = min(SEARCH_DEFAULTS["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
|
| 691 |
-
|
|
|
|
| 692 |
gen = make_gen(dict(SEARCH_DEFAULTS, max_new_tokens=max_new))
|
| 693 |
t0 = time.time()
|
| 694 |
try:
|
| 695 |
-
reply = await loop.run_in_executor(None,
|
| 696 |
except Exception as e: # noqa
|
| 697 |
print(f"[Milo X1] web_chat: generación falló: {e}")
|
| 698 |
return {"fallback": True, "notice": "No pude generar la respuesta con la búsqueda web ahora", "debug": str(e)[:300]}
|
|
@@ -744,20 +768,27 @@ def gradio_respond(message: str, history):
|
|
| 744 |
yield _load_error or "Modelo no cargado. Revisa MODEL_REPO_ID del Space."
|
| 745 |
return
|
| 746 |
hist = _history_to_msgs(history)[-8:] if USE_CONTEXT else []
|
| 747 |
-
|
| 748 |
gen = make_gen(CHAT_DEFAULTS)
|
| 749 |
-
if NUM_CANDIDATES > 1:
|
| 750 |
-
best =
|
| 751 |
for i in range(0, len(best), 4):
|
| 752 |
yield best[: i + 4]
|
| 753 |
if not best.strip():
|
| 754 |
yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
|
| 755 |
return
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
|
| 762 |
|
| 763 |
|
|
|
|
| 7 |
- `milo_core.py` debe estar junto a este app.py en el Space (el export del Colab 3 lo incluye). Si no está,
|
| 8 |
se descarga del repo del modelo.
|
| 9 |
- Usa la plantilla de chat con tokens especiales (<|system|> <|user|> <|assistant|> <|end|>), la MISMA del
|
| 10 |
+
fine-tuning, y el motor de generación de milo_core (KV-cache, min-p, decodificación especulativa, etc.).
|
| 11 |
+
- Si el modelo se entrenó con razonamiento (config.json: "thinking": true), PRIMERO piensa (<think>...</think>) y
|
| 12 |
+
DESPUÉS responde. El razonamiento no se muestra salvo que se pida (show_thinking) o MILO_SHOW_THINKING=1.
|
| 13 |
- Endpoints: /chat, /chat/stream (SSE), /web_chat (búsqueda web + respuesta), /health, /v1/model_info,
|
| 14 |
/web_search_debug. La UI de Gradio queda en "/".
|
| 15 |
|
|
|
|
| 51 |
NUM_THREADS = int(os.environ.get("MILO_NUM_THREADS", str(os.cpu_count() or 2)))
|
| 52 |
USE_CONTEXT = os.environ.get("MILO_USE_CONTEXT", "0") == "1" # 1 = usa el historial de la conversación
|
| 53 |
NUM_CANDIDATES = int(os.environ.get("MILO_NUM_CANDIDATES", "1")) # >1 = best-of-N (en lote), N veces más costoso
|
| 54 |
+
SYSTEM_PROMPT_ENV = os.environ.get("MILO_SYSTEM_PROMPT")
|
| 55 |
+
THINK_DEFAULT = os.environ.get("MILO_THINK", "1") == "1" # 1 = piensa antes de responder (si el modelo lo soporta)
|
| 56 |
+
MAX_THINK_TOKENS = int(os.environ.get("MILO_MAX_THINK", "200"))
|
| 57 |
+
SHOW_THINKING = os.environ.get("MILO_SHOW_THINKING", "0") == "1"
|
| 58 |
+
SPEC_TOKENS = int(os.environ.get("MILO_SPEC", "4")) # 0 = apaga la decodificación especulativa # "none" para desactivar; si no, el de config.json
|
| 59 |
|
| 60 |
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY", "").strip()
|
| 61 |
SEARCH_TIMEOUT = float(os.environ.get("MILO_SEARCH_TIMEOUT", "8"))
|
|
|
|
| 83 |
_load_error: Optional[str] = None
|
| 84 |
_tok_info: dict = {}
|
| 85 |
_stage: Optional[str] = None
|
| 86 |
+
_tf: bool = False # el modelo usa bloque <think>
|
| 87 |
_GEN_LOCK = threading.Lock() # una generación a la vez: en CPU, varias en paralelo solo se estorban
|
| 88 |
|
| 89 |
|
|
|
|
| 91 |
# Carga del modelo
|
| 92 |
# ==========================================================================================
|
| 93 |
def load_model():
|
| 94 |
+
global _model, _tok, _cfg, _system, _blocked, _load_error, _tok_info, _stage, _tf
|
| 95 |
print(f"[Milo X1] Descargando modelo desde {MODEL_REPO_ID} (revision={MODEL_REVISION}) ...")
|
| 96 |
try:
|
| 97 |
from safetensors.torch import load_file
|
|
|
|
| 137 |
_system = cfg_json.get("system_prompt", mc.DEFAULT_SYSTEM)
|
| 138 |
_blocked = mc.make_blocked_ids(tok, cfg.vocab_size, "cpu", allow=tok.stop_ids)
|
| 139 |
_model, _tok, _cfg, _stage = model, tok, cfg, cfg_json.get("stage")
|
| 140 |
+
_tf = bool(cfg_json.get("thinking", False))
|
| 141 |
_tok_info = {"vocab_size": tok.vocab_size, "unicode_roundtrip": mc.check_tokenizer_unicode(tok, verbose=False)}
|
| 142 |
_load_error = None
|
| 143 |
print(f"[Milo X1] Modelo cargado (etapa: {_stage}). {model.num_params()/1e6:.1f}M parámetros | d_model={cfg.d_model} "
|
| 144 |
f"| capas={cfg.n_layers} | heads={cfg.n_heads} (kv={cfg.n_kv_heads}) | contexto={cfg.context_length} "
|
| 145 |
+
f"| pensamiento={'sí' if _tf else 'no'} | vocab={tok.vocab_size} | unicode/emoji={'OK' if _tok_info['unicode_roundtrip'] else 'CON PÉRDIDAS'}")
|
| 146 |
|
| 147 |
|
| 148 |
# ==========================================================================================
|
|
|
|
| 193 |
max_new_tokens=int(params["max_new_tokens"]), temperature=float(params["temperature"]),
|
| 194 |
top_k=int(params["top_k"] or 0), top_p=float(params["top_p"] if params["top_p"] else 1.0),
|
| 195 |
min_p=float(params.get("min_p", 0.0)), repetition_penalty=float(params["repetition_penalty"]),
|
| 196 |
+
no_repeat_ngram=int(params.get("no_repeat_ngram", 0)), speculative=SPEC_TOKENS, seed=seed)
|
| 197 |
|
| 198 |
|
| 199 |
+
def iter_events(msgs: List[dict], gen: mc.GenConfig, think: bool, max_think: int, cancel: Optional[threading.Event] = None):
|
| 200 |
+
"""Generador SÍNCRONO de eventos en vivo: ("think", texto) mientras razona y ("answer", texto) al responder.
|
| 201 |
+
La generación corre en un hilo; si el consumidor deja de leer (cliente desconectado) se cancela al siguiente token."""
|
| 202 |
cancel = cancel or threading.Event()
|
| 203 |
q: "queue.Queue" = queue.Queue()
|
| 204 |
+
d_think, d_ans = IncrementalDecoder(_tok), IncrementalDecoder(_tok)
|
| 205 |
|
| 206 |
+
def cb(kind, dec):
|
| 207 |
+
def on_token(t):
|
| 208 |
+
if cancel.is_set():
|
| 209 |
+
raise _Cancel()
|
| 210 |
+
d = dec.push(t)
|
| 211 |
+
if d:
|
| 212 |
+
q.put((kind, d))
|
| 213 |
+
return on_token
|
| 214 |
|
| 215 |
def work():
|
| 216 |
try:
|
| 217 |
with _GEN_LOCK:
|
| 218 |
if not cancel.is_set():
|
| 219 |
+
mc.chat_generate(_model, _tok, msgs, system=_system, gen=gen, thinking_format=_tf, think=think,
|
| 220 |
+
max_think=max_think, on_think=cb("think", d_think), on_answer=cb("answer", d_ans))
|
| 221 |
+
for kind, dec in (("think", d_think), ("answer", d_ans)):
|
| 222 |
+
tail = dec.flush()
|
| 223 |
+
if tail:
|
| 224 |
+
q.put((kind, tail))
|
| 225 |
except _Cancel:
|
| 226 |
pass
|
| 227 |
except Exception as e: # noqa
|
|
|
|
| 255 |
return (len(set(bigrams)) / max(len(bigrams), 1)) * 0.5 + (len(set(words)) / len(words)) * 0.3 + min(len(words), 40) / 40 * 0.2
|
| 256 |
|
| 257 |
|
| 258 |
+
def run_chat(msgs: List[dict], gen: mc.GenConfig, think: bool, max_think: int, n: int = 1) -> mc.ChatResult:
|
| 259 |
+
"""Respuesta completa. Con n>1 y SIN razonamiento, genera n candidatas EN LOTE y elige la más coherente."""
|
| 260 |
+
active = _tf and think
|
| 261 |
with _GEN_LOCK:
|
| 262 |
+
if n > 1 and not active:
|
| 263 |
+
ids = _tok.encode_chat(msgs, system=_system, add_generation_prompt=True, thinking=_tf, think_prefix="closed")
|
| 264 |
+
blocked = mc.make_blocked_ids(_tok, _cfg.vocab_size, "cpu", allow=_tok.stop_ids)
|
| 265 |
+
outs = mc.generate(_model, [ids] * n, gen, stop_ids=_tok.stop_ids, blocked_ids=blocked)
|
| 266 |
+
texts = [_tok.decode(o).strip() for o in outs]
|
| 267 |
+
return mc.ChatResult("", max(texts, key=score_candidate))
|
| 268 |
+
return mc.chat_generate(_model, _tok, msgs, system=_system, gen=gen, thinking_format=_tf, think=think, max_think=max_think)
|
| 269 |
|
| 270 |
|
| 271 |
# ==========================================================================================
|
| 272 |
+
# Armado de mensajes (se ajustan al contexto; la plantilla de chat la aplica milo_core)
|
| 273 |
# ==========================================================================================
|
| 274 |
+
def _reserve(max_new: int, think: bool, max_think: int) -> int:
|
| 275 |
ctx = _cfg.context_length
|
| 276 |
+
r = min(max_new, ctx // 2)
|
| 277 |
+
if _tf and think:
|
| 278 |
+
r += min(max_think, ctx // 2 - 8)
|
| 279 |
+
return r
|
| 280 |
|
| 281 |
|
| 282 |
+
def fit_msgs(history: List[dict], user_text: str, max_new: int, think: bool, max_think: int) -> List[dict]:
|
| 283 |
"""Si no cabe: descarta turnos viejos y luego recorta el INICIO del mensaje (se conserva el final)."""
|
| 284 |
+
sess = mc.ChatSession(_model, _tok, system=_system, gen=mc.GenConfig(max_new_tokens=max_new),
|
| 285 |
+
thinking_format=_tf, think=think, max_think=max_think)
|
| 286 |
+
sess.history = list(history)
|
| 287 |
+
if len(user_text) > 16000:
|
| 288 |
+
user_text = user_text[-16000:]
|
| 289 |
+
return sess._fit(user_text, think)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
|
| 292 |
+
def fit_search_msgs(question: str, entrada: str, max_new: int, think: bool, max_think: int) -> List[dict]:
|
| 293 |
"""Pregunta + resultados web en un solo mensaje de usuario (igual que el SFT fusiona instruction + input).
|
| 294 |
Si no cabe, se recortan los RESULTADOS, nunca la pregunta."""
|
| 295 |
+
budget = max(32, _cfg.context_length - _reserve(max_new, think, max_think) - 4)
|
| 296 |
+
ent = entrada
|
| 297 |
for _ in range(60):
|
| 298 |
+
msgs = [{"role": "user", "content": f"{question}\n\n{ent}"}]
|
| 299 |
+
if len(_tok.encode_chat(msgs, system=_system, thinking=_tf, think_prefix="open")) <= budget:
|
| 300 |
+
return msgs
|
| 301 |
ent = ent[: int(len(ent) * 0.9)].rstrip()
|
| 302 |
+
return [{"role": "user", "content": question[-2000:]}]
|
| 303 |
|
| 304 |
|
| 305 |
_LEGACY_RE = re.compile(r"###\s*Instrucci[oó]n:\s*\n(.*?)(?:\n\s*###\s*Entrada:\s*\n(.*?))?\s*\n\s*###\s*Respuesta:\s*$", re.S)
|
|
|
|
| 529 |
|
| 530 |
|
| 531 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
# ==========================================================================================
|
| 533 |
# API FastAPI
|
| 534 |
# ==========================================================================================
|
|
|
|
| 553 |
repetition_penalty: float = Field(CHAT_DEFAULTS["repetition_penalty"], ge=1.0, le=2.0)
|
| 554 |
no_repeat_ngram: int = Field(0, ge=0, le=8)
|
| 555 |
seed: Optional[int] = None
|
| 556 |
+
think: Optional[bool] = Field(None, description="Pensar antes de responder (por defecto MILO_THINK; solo si el modelo lo soporta)")
|
| 557 |
+
show_thinking: Optional[bool] = Field(None, description="Incluir el razonamiento en la respuesta (por defecto MILO_SHOW_THINKING)")
|
| 558 |
+
max_think_tokens: Optional[int] = Field(None, ge=8, le=1000)
|
| 559 |
|
| 560 |
|
| 561 |
def _history_msgs(messages: List[ChatMessage]) -> Tuple[List[dict], str]:
|
|
|
|
| 574 |
|
| 575 |
|
| 576 |
def _resolve_request(req: ChatRequest):
|
| 577 |
+
"""Devuelve (mensajes, GenConfig, search_mode, think, max_think, show_thinking)."""
|
| 578 |
+
given = req.model_fields_set
|
| 579 |
+
think = THINK_DEFAULT if req.think is None else bool(req.think)
|
| 580 |
+
max_think = req.max_think_tokens or MAX_THINK_TOKENS
|
| 581 |
+
show = SHOW_THINKING if req.show_thinking is None else bool(req.show_thinking)
|
| 582 |
legacy = parse_legacy_prompt(req.prompt) or (
|
| 583 |
parse_legacy_prompt(req.messages[-1].content) if (not req.prompt and req.messages and req.messages[-1].role == "user") else None)
|
|
|
|
| 584 |
if legacy is not None and legacy[1]: # con "Entrada" = modo búsqueda web
|
| 585 |
question, entrada = legacy
|
| 586 |
p = dict(SEARCH_DEFAULTS)
|
|
|
|
| 588 |
if k in given:
|
| 589 |
p[k] = getattr(req, k)
|
| 590 |
p["max_new_tokens"] = min(p["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
|
| 591 |
+
return fit_search_msgs(question, entrada, p["max_new_tokens"], think, max_think), make_gen(p, req.seed), True, think, max_think, show
|
| 592 |
if legacy is not None:
|
| 593 |
history, user_text = [], legacy[0]
|
| 594 |
else:
|
|
|
|
| 597 |
history, user_text = _history_msgs(req.messages)
|
| 598 |
p = dict(max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_k=req.top_k, top_p=req.top_p,
|
| 599 |
min_p=req.min_p, repetition_penalty=req.repetition_penalty, no_repeat_ngram=req.no_repeat_ngram)
|
| 600 |
+
return fit_msgs(history, user_text, req.max_new_tokens, think, max_think), make_gen(p, req.seed), False, think, max_think, show
|
| 601 |
|
| 602 |
|
| 603 |
@app.on_event("startup")
|
|
|
|
| 608 |
@app.get("/health")
|
| 609 |
def health():
|
| 610 |
return {"status": "ok" if _model is not None else "model_not_loaded", "model_repo": MODEL_REPO_ID,
|
| 611 |
+
"quantized": QUANTIZE, "error": _load_error, "stage": _stage, "thinking_model": _tf,
|
| 612 |
+
"speculative": {"tokens": SPEC_TOKENS, **mc.SPEC_STATS},
|
| 613 |
"web_search": {"tavily_key_set": bool(TAVILY_API_KEY), "fallback": "wikipedia"},
|
| 614 |
"tokenizer": _tok_info or None}
|
| 615 |
|
|
|
|
| 622 |
"d_model": _cfg.d_model, "n_layers": _cfg.n_layers, "n_heads": _cfg.n_heads, "n_kv_heads": _cfg.n_kv_heads,
|
| 623 |
"context_length": _cfg.context_length, "vocab_size": _cfg.vocab_size, "quantized": QUANTIZE,
|
| 624 |
"stage": _stage, "use_context": USE_CONTEXT, "num_candidates": NUM_CANDIDATES,
|
| 625 |
+
"thinking_model": _tf, "think_default": THINK_DEFAULT and _tf, "max_think_tokens": MAX_THINK_TOKENS,
|
| 626 |
"system_prompt": _system, "tokenizer": _tok_info}
|
| 627 |
|
| 628 |
|
|
|
|
| 630 |
def chat(req: ChatRequest):
|
| 631 |
if _model is None:
|
| 632 |
raise HTTPException(503, _load_error or "Modelo no cargado.")
|
| 633 |
+
msgs, gen, search, think, max_think, show = _resolve_request(req)
|
| 634 |
t0 = time.time()
|
| 635 |
+
r = run_chat(msgs, gen, think, max_think, n=1 if search else NUM_CANDIDATES)
|
| 636 |
+
out = {"response": r.answer, "elapsed_seconds": round(time.time() - t0, 2), "mode": "web_search" if search else "chat"}
|
| 637 |
+
if show and _tf:
|
| 638 |
+
out["thinking"] = r.thinking
|
| 639 |
+
return out
|
| 640 |
|
| 641 |
|
| 642 |
@app.post("/chat/stream")
|
| 643 |
async def chat_stream(req: ChatRequest, request: Request):
|
| 644 |
if _model is None:
|
| 645 |
raise HTTPException(503, _load_error or "Modelo no cargado.")
|
| 646 |
+
msgs, gen, search, think, max_think, show = _resolve_request(req)
|
| 647 |
|
| 648 |
async def event_stream():
|
| 649 |
loop = asyncio.get_running_loop()
|
| 650 |
cancel = threading.Event()
|
| 651 |
try:
|
| 652 |
+
if search or NUM_CANDIDATES <= 1 or (_tf and think):
|
| 653 |
+
it = iter_events(msgs, gen, think, max_think, cancel)
|
| 654 |
sentinel = object()
|
| 655 |
+
announced = False
|
| 656 |
while True:
|
| 657 |
+
ev = await loop.run_in_executor(None, next, it, sentinel)
|
| 658 |
+
if ev is sentinel:
|
| 659 |
break
|
| 660 |
+
kind, piece = ev
|
| 661 |
+
if kind == "answer":
|
| 662 |
+
yield f"data: {json.dumps({'delta': piece}, ensure_ascii=False)}\n\n"
|
| 663 |
+
elif show:
|
| 664 |
+
yield f"data: {json.dumps({'thinking_delta': piece}, ensure_ascii=False)}\n\n"
|
| 665 |
+
elif not announced:
|
| 666 |
+
announced = True
|
| 667 |
+
yield f"data: {json.dumps({'status': 'thinking'})}\n\n"
|
| 668 |
if await request.is_disconnected():
|
| 669 |
break
|
| 670 |
else:
|
| 671 |
+
r = await loop.run_in_executor(None, run_chat, msgs, gen, think, max_think, NUM_CANDIDATES)
|
| 672 |
+
for i in range(0, len(r.answer), 4):
|
| 673 |
+
yield f"data: {json.dumps({'delta': r.answer[i:i + 4]}, ensure_ascii=False)}\n\n"
|
| 674 |
await asyncio.sleep(0.01)
|
| 675 |
except Exception as e: # noqa
|
| 676 |
yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
|
|
|
| 711 |
items = pick_results(raw, dedupe_domains=(provider != "wikipedia"))
|
| 712 |
entrada, items = build_entrada(items)
|
| 713 |
max_new = min(SEARCH_DEFAULTS["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
|
| 714 |
+
max_think = min(MAX_THINK_TOKENS, 160)
|
| 715 |
+
msgs = fit_search_msgs(question, entrada, max_new, THINK_DEFAULT, max_think)
|
| 716 |
gen = make_gen(dict(SEARCH_DEFAULTS, max_new_tokens=max_new))
|
| 717 |
t0 = time.time()
|
| 718 |
try:
|
| 719 |
+
reply = (await loop.run_in_executor(None, run_chat, msgs, gen, THINK_DEFAULT, max_think, 1)).answer
|
| 720 |
except Exception as e: # noqa
|
| 721 |
print(f"[Milo X1] web_chat: generación falló: {e}")
|
| 722 |
return {"fallback": True, "notice": "No pude generar la respuesta con la búsqueda web ahora", "debug": str(e)[:300]}
|
|
|
|
| 768 |
yield _load_error or "Modelo no cargado. Revisa MODEL_REPO_ID del Space."
|
| 769 |
return
|
| 770 |
hist = _history_to_msgs(history)[-8:] if USE_CONTEXT else []
|
| 771 |
+
msgs = fit_msgs(hist, message, CHAT_DEFAULTS["max_new_tokens"], THINK_DEFAULT, MAX_THINK_TOKENS)
|
| 772 |
gen = make_gen(CHAT_DEFAULTS)
|
| 773 |
+
if NUM_CANDIDATES > 1 and not (_tf and THINK_DEFAULT):
|
| 774 |
+
best = run_chat(msgs, gen, False, MAX_THINK_TOKENS, n=NUM_CANDIDATES).answer
|
| 775 |
for i in range(0, len(best), 4):
|
| 776 |
yield best[: i + 4]
|
| 777 |
if not best.strip():
|
| 778 |
yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
|
| 779 |
return
|
| 780 |
+
thinking, answer = "", ""
|
| 781 |
+
if _tf and THINK_DEFAULT:
|
| 782 |
+
yield "💭 Pensando…"
|
| 783 |
+
for kind, piece in iter_events(msgs, gen, THINK_DEFAULT, MAX_THINK_TOKENS):
|
| 784 |
+
if kind == "think":
|
| 785 |
+
thinking += piece
|
| 786 |
+
if SHOW_THINKING:
|
| 787 |
+
yield f"💭 {thinking}"
|
| 788 |
+
else:
|
| 789 |
+
answer += piece
|
| 790 |
+
yield (f"💭 {thinking}\n\n{answer}" if (SHOW_THINKING and thinking) else answer)
|
| 791 |
+
if not answer.strip():
|
| 792 |
yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
|
| 793 |
|
| 794 |
|