jevasio / app.py
Jarbas's picture
JEVASIO_MODELS picks which models the app serves
b881291 verified
Raw History Blame Contribute Delete
25.2 kB
"""Jevásio on Hugging Face Spaces (Gradio, ZeroGPU).
A state and a set of typed questions in, a probability per option out, in one forward pass. The model is loaded through
Kev's own loader (kev.checkpoint), the same path `python -m kev.serve --run TigreGotico/jevasio-0.8b` takes, and
requests go through kev.api and kev.model.admit exactly as /v1/systemone does. Both sizes load in bf16 at start-up, and
each forward pass runs inside @spaces.GPU, so a request holds a GPU only for that pass.
"""
import html
import json
import os
import time
from dataclasses import replace
import gradio as gr
import spaces
import torch
from pydantic import ValidationError
from kev.api import SystemOneRequest, output_tokens, render, to_answers, to_record
from kev.checkpoint import Checkpoint, LoadOptions
from kev.model import admit
MODELS = {"Jevásio-4B-V2": "TigreGotico/jevasio-4b-v2", "Jevásio-0.8B-V2": "TigreGotico/jevasio-0.8b-v2",
"Jevásio-4B": "TigreGotico/jevasio-4b", "Jevásio-0.8B": "TigreGotico/jevasio-0.8b"}
# JEVASIO_MODELS="Jevásio-4B-V2,Jevásio-0.8B-V2" serves only the named models.
if os.environ.get("JEVASIO_MODELS"):
MODELS = {name: MODELS[name] for name in os.environ["JEVASIO_MODELS"].split(",")}
DEFAULT = "Jevásio-4B-V2"
LINK_08B = "https://huggingface.co/TigreGotico/jevasio-0.8b"
LINK_4B = "https://huggingface.co/TigreGotico/jevasio-4b"
LINK_08B_V2 = "https://huggingface.co/TigreGotico/jevasio-0.8b-v2"
LINK_4B_V2 = "https://huggingface.co/TigreGotico/jevasio-4b-v2"
LINK_DATA_V2 = "https://huggingface.co/datasets/TigreGotico/jevasio-decisions-v2"
LINK_DATA = "https://huggingface.co/datasets/TigreGotico/jevasio-decisions"
LOADED = {}
for name, repo in MODELS.items():
t0 = time.perf_counter()
checkpoint = Checkpoint(repo)
opts = LoadOptions.from_env()
if opts.dtype is None:
opts = replace(opts, dtype=torch.bfloat16)
# ZeroGPU has no GPU at start-up: load on the CPU, then move. ZeroGPU defers the move until a @spaces.GPU call, and
# Kev builds its input tensors on model.device. Off ZeroGPU, JEVASIO_DEVICE_<SIZE> places a model, e.g.
# JEVASIO_DEVICE_4B=cpu keeps the 4B in RAM on a laptop GPU too small for it (JEVASIO_DEVICE_4B_V2 for the 4B V2).
key = name.split("-", 1)[1].replace(".", "").replace("-", "_").upper()
device = "cuda" if os.environ.get("SPACES_ZERO_GPU") else os.environ.get(
f"JEVASIO_DEVICE_{key}", "cuda" if torch.cuda.is_available() else "cpu")
tok, model = checkpoint.load("cpu", opts)
model.to(device)
model.device = torch.device(device)
LOADED[name] = tok, model
print(f"[jevasio] {repo}: base={checkpoint.meta.base}@{checkpoint.meta.base_revision} dtype={model.dtype} "
f"temperature={model.head.temperature:.2f} loaded in {time.perf_counter() - t0:.0f} s", flush=True)
@spaces.GPU(duration=30)
def forward(name, enc):
return [p.float().cpu() for p in LOADED[name][1].probs(enc)]
class RequestError(ValueError):
"""A request the model cannot answer: bad JSON, a bad question, or a state that is too long."""
def parse_request(payload):
try:
return SystemOneRequest.model_validate(payload)
except ValidationError as e:
first = e.errors()[0]
where = ".".join(str(x) for x in first.get("loc", ()))
raise RequestError(f"Pedido inválido em `{where}`: {first.get('msg')}") from None
def systemone(req, name=DEFAULT):
"""The /v1/systemone response body kev.serve returns for this request, plus the latency of the forward pass."""
if name not in LOADED:
raise RequestError(f"Modelo desconhecido: {name}. Opções: {', '.join(MODELS)}.")
tokenizer, model = LOADED[name]
rec, meta = to_record(req)
try:
enc = admit(model, tokenizer, rec)
except ValueError as e:
raise RequestError(str(e)) from None
start = time.perf_counter()
probs = forward(name, enc)
ms = (time.perf_counter() - start) * 1000
answers = to_answers([p.tolist() for p in probs], meta)
return {"model": MODELS[name], "answers": answers,
"usage": {"input_tokens": len(enc["ids"]), "output_tokens": output_tokens(tokenizer, answers)},
"latency_ms": round(ms, 1)}
# ------------------------------------------------------------------ rendering
CARD = ("border:1px solid var(--border-color-primary);border-radius:10px;padding:12px 14px;margin-bottom:10px;"
"background:var(--background-fill-secondary);")
TRACK = "position:relative;display:block;height:8px;border-radius:999px;background:var(--border-color-primary);overflow:hidden;"
TYPE_LABEL = {"choice": "escolha", "noul": "sim/não", "score": "escala"}
def bar(label, p, top):
fill = "var(--color-accent)" if top else "var(--body-text-color-subdued)"
weight = "600" if top else "400"
width = max(0.0, min(1.0, p)) * 100
return ('<div style="display:grid;grid-template-columns:minmax(0,12rem) minmax(0,1fr) 3.2rem;align-items:center;'
'gap:0 12px;font-size:13px;line-height:24px">'
f'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:{weight}" '
f'title="{html.escape(label)}">{html.escape(label)}</span>'
f'<span style="{TRACK}"><span style="position:absolute;top:0;bottom:0;left:0;border-radius:999px;'
f'background:{fill};width:{width:.1f}%"></span></span>'
f'<span style="text-align:right;font-variant-numeric:tabular-nums;font-weight:{weight}">{p:.2f}</span></div>')
def render_answers(req, resp):
usage = resp["usage"]
parts = ['<div style="font-size:12px;color:var(--body-text-color-subdued);margin:6px 0 10px">'
f'{len(resp["answers"])} pergunta(s) · {usage["input_tokens"]} tokens de entrada · '
f'{resp["model"].split("/")[1]} · {resp["latency_ms"]:.0f} ms · nenhum texto gerado</div>']
for qid, a in resp["answers"].items():
instructions = render(req.questions[qid].instructions)
if a["type"] == "noul":
p = a["noul"]
headline, detail = ("sim" if p >= 0.5 else "não"), f"p(sim) {p:.2f}"
rows = bar("sim", p, p >= 0.5) + bar("não", 1 - p, p < 0.5)
elif a["type"] == "choice":
headline, detail = a["choice"], f"confiança {a['confidence']:.2f}"
ranked = sorted(a["probabilities"].items(), key=lambda kv: -kv[1])
rows = "".join(bar(k, v, k == a["choice"]) for k, v in ranked)
else:
legend, dist = a["legend"], a["probabilities"]
top = max(dist, key=dist.get)
headline = f"{a['score']:.2f} de {len(legend) - 1}"
detail = f"confiança {a['confidence']:.2f}"
rows = "".join(bar(f"{i}. {legend[str(i)]}", dist[str(i)], str(i) == top) for i in range(len(legend)))
parts.append(f'<div style="{CARD}"><div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">'
f'<span style="font-family:var(--font-mono);font-size:13px;font-weight:600">{html.escape(qid)}</span>'
f'<span style="font-size:11px;color:var(--body-text-color-subdued)">· {TYPE_LABEL[a["type"]]}</span></div>'
f'<div style="font-size:13px;margin:2px 0 6px">{html.escape(instructions)}</div>'
'<div style="display:flex;align-items:baseline;gap:10px;margin-bottom:8px">'
f'<span style="font-size:17px;font-weight:600">{html.escape(str(headline))}</span>'
f'<span style="font-size:12px;color:var(--body-text-color-subdued)">{detail}</span></div>{rows}</div>')
return "".join(parts)
def error_html(message):
return (f'<div style="{CARD}border-color:var(--error-border-color, #d33);">'
f'<b>Erro:</b> {html.escape(message)}</div>')
# ------------------------------------------------------------------ question builder
def parse_state(text):
"""Plain text, or a JSON object or array."""
t = (text or "").strip()
if t[:1] in "{[":
try:
return json.loads(t)
except json.JSONDecodeError:
pass
return t
def parse_options(text):
"""One option per line, `name: description` or just `name`."""
criteria = {}
for line in (text or "").splitlines():
name, _, desc = line.partition(":")
name, desc = name.strip(), desc.strip()
if name:
criteria[name] = desc or None
return criteria
def build_request(state, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels):
"""The System One request the builder describes. A question with an empty id is left out."""
ids = [i.strip() for i in (choice_id, noul_id, score_id) if i and i.strip()]
if len(ids) != len(set(ids)):
raise RequestError("Cada pergunta precisa de um id diferente.")
questions = {}
if choice_id.strip():
questions[choice_id.strip()] = {"type": "choice", "instructions": choice_instr, "criteria": parse_options(choice_opts)}
if noul_id.strip():
questions[noul_id.strip()] = {"type": "noul", "instructions": noul_instr}
if score_id.strip():
levels = [line.strip() for line in (score_levels or "").splitlines() if line.strip()]
questions[score_id.strip()] = {"type": "score", "instructions": score_instr, "criteria": levels}
if not (state or "").strip():
raise RequestError("Escreva um estado: o texto ou o objeto JSON que o modelo lê.")
if not questions:
raise RequestError("Defina pelo menos uma pergunta (um id não vazio).")
return {"state": parse_state(state), "model": "kev-latest", "questions": questions}
def decide(name, state, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels):
"""Answer the builder's questions about the state. Returns the rendered answers, the request and the response."""
try:
payload = build_request(state, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels)
req = parse_request(payload)
resp = systemone(req, name)
except RequestError as e:
return error_html(str(e)), {}, {"error": str(e)}
return render_answers(req, resp), payload, resp
def decide_raw(name, request_json):
"""Answer a raw System One request (JSON text). Returns a status line and the response or an error object."""
try:
payload = json.loads(request_json or "")
except json.JSONDecodeError as e:
message = f"JSON inválido: {e}"
return f"**Erro:** {message}", {"error": message}
if not isinstance(payload, dict):
message = 'O pedido tem de ser um objeto JSON com "state" e "questions".'
return f"**Erro:** {message}", {"error": message}
try:
resp = systemone(parse_request(payload), name)
except RequestError as e:
return f"**Erro:** {e}", {"error": str(e)}
return f"Respondido em {resp['latency_ms']:.0f} ms.", resp
# ------------------------------------------------------------------ examples
EXAMPLES = [
{
"name": "Apoio ao cliente",
"state": "Bom dia. Fui cobrado duas vezes pela encomenda 1182 e o dinheiro já saiu da minha conta. "
"Preciso que devolvam uma das cobranças ainda hoje, porque tenho a renda para pagar amanhã.",
"choice": ("team", "Que equipa deve tratar deste pedido?",
"billing: Cobranças, faturas e reembolsos\nshipping: Entregas e atrasos\n"
"returns: Trocas e devoluções de artigos\ntechnical: Problemas no site ou na aplicação"),
"noul": ("urgent", "Este pedido precisa de resposta hoje?"),
"score": ("frustration", "Quão frustrado está o cliente?", "Calmo\nIncomodado\nMuito zangado"),
},
{
"name": "Sentimento",
"state": "Fomos jantar ao restaurante novo da Baixa. A comida chegou fria, o empregado esqueceu-se das bebidas "
"e no fim ainda nos cobraram o couvert que não pedimos. Não voltamos.",
"choice": ("sentiment", "Qual é o sentimento geral do comentário?",
"positive: Positivo\nneutral: Neutro ou misto\nnegative: Negativo"),
"noul": ("recommends", "O autor recomenda o restaurante?"),
"score": ("intensity", "Com que intensidade o autor exprime a opinião?", "Fraca\nModerada\nForte"),
},
{
"name": "Variedade do português",
"state": "Estou a escrever-te do autocarro porque o meu telemóvel ficou sem rede no comboio. "
"Logo à noite passo aí para irmos tomar um pequeno-almoço tardio, está bem?",
"choice": ("variety", "Em que variedade do português está escrito este texto?",
"pt_pt: Português europeu\npt_br: Português do Brasil"),
"noul": ("informal", "O texto usa um tom informal?"),
"score": ("register", "Qual é o registo do texto?", "Muito informal\nInformal\nNeutro\nFormal\nMuito formal"),
},
{
"name": "Registo",
"state": "Exmo. Senhor Diretor, venho por este meio solicitar a V. Ex.ª a marcação de uma reunião, "
"com a maior brevidade possível, para tratar da renovação do contrato de prestação de serviços.",
"choice": ("text_type", "Que tipo de texto é este?",
"letter: Carta ou correio formal\nchat: Mensagem entre amigos\nnews: Notícia\nreview: Crítica ou opinião"),
"noul": ("asks_action", "O texto pede ao destinatário que faça alguma coisa?"),
"score": ("register", "Qual é o registo do texto?", "Muito informal\nInformal\nNeutro\nFormal\nMuito formal"),
},
{
"name": "Mensagem a um serviço (V2)",
"state": '{"message": "Boa tarde. Recebi a fatura de março com um valor de 89 euros, quando o meu plano é de 35. '
'Já liguei duas vezes e ninguém resolve. Se não corrigirem até amanhã, cancelo o contrato."}',
"choice": ("department", "Que departamento deve tratar desta mensagem?",
"delivery: Entregas e envios\ncancellation: Cancelamentos e rescisões\nsales: Vendas e novos serviços\n"
"technical: Assistência técnica\ncomplaints: Reclamações\nbilling: Faturação e pagamentos"),
"noul": ("", ""),
"score": ("urgency", "Quão urgente é esta mensagem?",
"Pode esperar dias\nResponder nos próximos dias\nResponder hoje\nImediato: há risco ou dano"),
},
{
"name": "Comando de voz (V2)",
"state": '{"request": "acorda-me amanhã às sete e meia que tenho de apanhar o comboio"}',
"choice": ("scenario", "A que domínio pertence este comando?",
"alarm: Alarmes\ncalendar: Agenda\ntransport: Transportes\nweather: Meteorologia\n"
"music: Preferências de música\niot: Casa inteligente"),
"noul": ("", ""),
"score": ("", "", ""),
},
{
"name": "Secção do jornal (V2)",
"state": '{"title": "Câmara do Porto aprova novas regras para o alojamento local na Baixa", '
'"text": "A Câmara Municipal do Porto aprovou esta terça-feira um regulamento que suspende novos registos de '
'alojamento local em seis freguesias do centro histórico durante dois anos."}',
"choice": ("section", "Em que secção do jornal saiu este artigo?",
"sports: Desporto\nlocal: Notícias locais\ntechnology: Tecnologia\neconomy: Economia\n"
"culture: Cultura\npolitics: Política nacional\ntravel: Viagens e lazer\nsociety: Sociedade"),
"noul": ("", ""),
"score": ("", "", ""),
},
{
"name": "Área do direito (V2)",
"state": '{"text": "Acordam no Supremo Tribunal de Justiça: a autora pede a condenação da ré no pagamento das '
'retribuições em dívida e da indemnização pelo despedimento, que considera ilícito por falta de processo '
'disciplinar."}',
"choice": ("legal_area", "Em que área do direito se insere este acórdão?",
"labour: Direito do trabalho\ndisputes: Contencioso\ncivil: Direito civil\ncriminal: Direito penal"),
"noul": ("", ""),
"score": ("", "", ""),
},
{
"name": "Relação entre frases (V2)",
"state": '{"premise": "O concerto da banda no Coliseu de Lisboa esgotou em menos de uma hora.", '
'"hypothesis": "Os bilhetes para o concerto no Coliseu acabaram muito depressa."}',
"choice": ("relation", "Que relação há entre as duas frases?",
"entailment: A primeira implica a segunda\nnone: Nenhuma: a segunda não decorre da primeira\n"
"paraphrase: As duas dizem o mesmo"),
"noul": ("", ""),
"score": ("similarity", "Quão parecido é o sentido das duas frases?",
"sem relação\npouca relação\nalguma relação\nmuito parecidas\npraticamente iguais"),
},
{
"name": "Tradução com um facto trocado (V2)",
"state": '{"original": "The museum opens at 9 a.m. on weekdays and is closed on Mondays.", '
'"translation": "O museu abre às 10 horas nos dias úteis e está fechado às segundas-feiras."}',
"choice": ("", "", ""),
"noul": ("faithful", "As duas versões dizem o mesmo?"),
"score": ("", "", ""),
},
{
"name": "Linguagem ofensiva (V2)",
"state": "Este árbitro é um palhaço, devia ter vergonha de aparecer em campo. Corja de incompetentes!",
"choice": ("", "", ""),
"noul": ("offensive", "A mensagem contém insultos ou linguagem ofensiva?"),
"score": ("", "", ""),
},
{
"name": "Trocadilho (V2)",
"state": "Qual é o animal que anda com uma pata? O pato.",
"choice": ("", "", ""),
"noul": ("pun", "A piada joga com um duplo sentido ou com o som das palavras?"),
"score": ("", "", ""),
},
]
def example_row(ex):
return [ex["state"], *ex["choice"], *ex["noul"], *ex["score"]]
RAW_EXAMPLE = json.dumps({
"state": {"request": "A encomenda 4471 chegou com o ecrã partido. Quero trocar o artigo, não quero o dinheiro de volta.",
"channel": "email"},
"model": "kev-latest",
"questions": {
"team": {"type": "choice", "instructions": "Que equipa deve tratar deste pedido?",
"criteria": {"billing": "Cobranças e reembolsos", "shipping": "Entregas", "returns": "Trocas"}},
"refund_requested": {"type": "noul", "instructions": "O cliente pede o reembolso do dinheiro?"},
"frustration": {"type": "score", "instructions": "Quão frustrado está o cliente?",
"criteria": ["Calmo", "Incomodado", "Muito zangado"]},
},
}, ensure_ascii=False, indent=2)
# ------------------------------------------------------------------ UI
INTRO = f"""# Jevásio
Modelo de decisão para português europeu. Recebe um **estado** (um texto ou um objeto JSON) e perguntas tipadas, e
devolve uma probabilidade para cada opção de cada pergunta, numa só passagem pelo modelo. Não gera texto.
Escolha o modelo: os 4B são mais exatos, os 0.8B são mais pequenos e rápidos. O [Jevásio-4B-V2]({LINK_4B_V2}) e o
[Jevásio-0.8B-V2]({LINK_08B_V2}) são o [Jevásio-4B]({LINK_4B}) e o [Jevásio-0.8B]({LINK_08B}) treinados com mais tarefas:
variedade do português por domínio, comandos de voz, secções de jornal, áreas do direito, relação entre frases,
linguagem ofensiva, trocadilhos e traduções ou resumos com um facto trocado. Os exemplos marcados com (V2) são dessas
tarefas.
"""
TEMPERATURES = ", ".join(f"T = {m.head.temperature:.2f} no {n}" for n, (_, m) in LOADED.items())
ABOUT = f"""Há três tipos de pergunta: `choice` (escolher uma opção com nome), `noul` (sim ou não) e `score` (um nível numa
escala ordenada). Os nomes das chaves (campos do estado, ids das perguntas, nomes das opções) estão em inglês e os
valores em português, como nos dados de treino.
O Jevásio é o [Kev](https://github.com/jaredpalmer/kev) afinado com o conjunto
[TigreGotico/jevasio-decisions]({LINK_DATA}), e o V2 com [TigreGotico/jevasio-decisions-v2]({LINK_DATA_V2}). A
probabilidade usa a temperatura de calibração guardada no modelo ({TEMPERATURES}).
Antes de definir um limiar, meça o modelo com os seus próprios dados.
Licença CC BY-NC-SA 4.0: só para investigação não comercial. Não use o modelo em decisões com consequências legais,
médicas, financeiras ou laborais sem revisão humana.
"""
PLACEHOLDER = ('<div style="border:1px dashed var(--border-color-primary);border-radius:10px;padding:28px 16px;'
'text-align:center;color:var(--body-text-color-subdued);font-size:13px">'
'As respostas aparecem aqui: um cartão por pergunta, com uma barra por opção.</div>')
CSS = """
#col-container { max-width: 1180px; margin: 0 auto; }
#decide-btn { min-height: 44px; }
"""
first = EXAMPLES[0]
with gr.Blocks(title="Jevásio") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(INTRO)
model_choice = gr.Radio(list(MODELS), value=DEFAULT, label="Modelo")
with gr.Tabs():
with gr.Tab("Perguntas"):
with gr.Row():
with gr.Column(scale=1):
state_box = gr.Textbox(label="Estado (o texto que o modelo lê)", lines=5, max_lines=14,
value=first["state"],
placeholder="Uma mensagem, uma crítica, um artigo ou um objeto JSON.")
with gr.Group():
gr.Markdown("**Pergunta de escolha** (`choice`)")
with gr.Row():
choice_id = gr.Textbox(label="Id", value=first["choice"][0], scale=1)
choice_instr = gr.Textbox(label="Instrução", value=first["choice"][1], scale=3)
choice_opts = gr.Textbox(label="Opções (uma por linha, nome: descrição)", lines=4,
value=first["choice"][2])
with gr.Group():
gr.Markdown("**Pergunta de sim/não** (`noul`)")
with gr.Row():
noul_id = gr.Textbox(label="Id", value=first["noul"][0], scale=1)
noul_instr = gr.Textbox(label="Instrução", value=first["noul"][1], scale=3)
with gr.Group():
gr.Markdown("**Pergunta de escala** (`score`)")
with gr.Row():
score_id = gr.Textbox(label="Id", value=first["score"][0], scale=1)
score_instr = gr.Textbox(label="Instrução", value=first["score"][1], scale=3)
score_levels = gr.Textbox(label="Níveis, do mais baixo ao mais alto (um por linha)", lines=3,
value=first["score"][2])
gr.Markdown("Deixe o id vazio para omitir uma pergunta.")
run_btn = gr.Button("Decidir", variant="primary", elem_id="decide-btn")
with gr.Column(scale=1):
answers_html = gr.HTML(value=PLACEHOLDER)
with gr.Accordion("Pedido enviado (formato System One)", open=False):
request_json = gr.JSON()
with gr.Accordion("Resposta em bruto", open=False):
response_json = gr.JSON()
inputs = [state_box, choice_id, choice_instr, choice_opts, noul_id, noul_instr, score_id, score_instr, score_levels]
gr.Examples(examples=[example_row(ex) for ex in EXAMPLES], example_labels=[ex["name"] for ex in EXAMPLES],
inputs=inputs, label="Exemplos")
run_btn.click(fn=decide, inputs=[model_choice, *inputs], outputs=[answers_html, request_json, response_json], api_name="decide")
with gr.Tab("Avançado"):
gr.Markdown("Cole um pedido em JSON no formato System One (`POST /v1/systemone`) e veja a resposta em JSON.")
with gr.Row():
with gr.Column(scale=1):
raw_in = gr.Code(label="Pedido (JSON)", language="json", lines=18, value=RAW_EXAMPLE)
raw_btn = gr.Button("Enviar", variant="primary")
with gr.Column(scale=1):
raw_status = gr.Markdown()
raw_out = gr.JSON(label="Resposta (JSON)")
raw_btn.click(fn=decide_raw, inputs=[model_choice, raw_in], outputs=[raw_status, raw_out], api_name="systemone")
with gr.Tab("Sobre"):
gr.Markdown(ABOUT)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1).launch(css=CSS)