WINTER4000's picture
Bulletproof type coercion + full traceback logging in chat_fn
181770f verified
Raw
History Blame Contribute Delete
6.24 kB
"""Gradio entrypoint for the TuringDNA assistant Space.
Two surfaces:
1. Gradio ChatInterface at "/" — for humans visiting
huggingface.co/spaces/winter4000/turingdna-assistant directly to
sanity-check the model.
2. Auto-generated Gradio API at "/api/predict" (or /run/predict on
older Gradio) — what the Flask app's /api/chat proxy calls via
gradio_client.Client(...).predict(...).
The Flask side uses `api_name="/chat"`, so the ChatInterface event is
registered with that api_name.
ZeroGPU note: the actual inference is in llm.generate() which carries
the @spaces.GPU(duration=60) decorator. From here we just hand control
to ChatInterface — Gradio handles the request queueing and ZeroGPU
acquires the A100 when generate() is called.
"""
import os
import sys
import gradio as gr
import fastapi
import starlette
import jinja2
# Print the installed versions of the web stack so the next failure
# log tells us exactly what we're running against. The Gradio 4.44 +
# Starlette + jinja2 interaction has been the root cause of every
# build issue so far; this single line saves a lot of debugging if
# the constellation changes underneath us.
print(
f"[turingdna.assistant] gradio={gr.__version__} fastapi={fastapi.__version__} "
f"starlette={starlette.__version__} jinja2={jinja2.__version__} python={sys.version.split()[0]}",
flush=True,
)
from llm import chat, MODEL_ID_IN_USE, SYSTEM_PROMPT
def chat_fn(message, history=None) -> str:
"""Gradio ChatInterface callback.
Deliberately IGNORES Gradio's session `history` — the Flask client
embeds conversation context into the message string itself. Single
source of truth = the message string.
Bulletproof type coercion: Gradio 4.44 has inconsistent type
handling depending on call path (UI vs API), version, and theme.
We've seen message arrive as: a str, a {'role','content'} dict, a
list-of-dicts, and an empty string. Coerce everything to a plain
str BEFORE anything else touches it.
Also: this entire function is wrapped in a top-level exception
handler that logs the traceback to stderr so the next mystery
'list' object has no attribute 'get' tells us which LINE
blew up (the previous error format was useless for debugging
because it stripped the traceback).
"""
import sys
import traceback
# Logging payload (truncated for log readability).
print(
f"[chat_fn] in message_type={type(message).__name__} "
f"history_type={type(history).__name__} "
f"history_len={len(history) if hasattr(history, '__len__') else 'n/a'}",
flush=True,
)
try:
# ── Coerce message to a plain string, no matter what shape ──
if message is None:
text = ""
elif isinstance(message, str):
text = message
elif isinstance(message, dict):
# OpenAI-style {role, content} — take content.
text = str(message.get("content") or "")
elif isinstance(message, (list, tuple)):
# A list of messages — take the last one's content.
if not message:
text = ""
else:
last = message[-1]
if isinstance(last, dict):
text = str(last.get("content") or "")
else:
text = str(last)
else:
text = str(message)
text = text.strip()
if not text:
return "Ask me a question about your sequence, mutations, or library."
# history=[] always — Flask owns conversation context.
return chat(text, history=[])
except Exception as exc: # noqa: BLE001
# FULL traceback to stderr so the next failure log is debuggable.
# Returning a clean message to the user but the operator sees
# exactly which line raised what.
tb = traceback.format_exc()
print(
f"[chat_fn] EXCEPTION {type(exc).__name__}: {exc}\n{tb}",
file=sys.stderr,
flush=True,
)
return f"Generation error: {type(exc).__name__}: {exc}"
# Modest demo prompts shown above the composer — give the visiting
# researcher (or me, when sanity-checking) a one-click way to see what
# the assistant is good at without having to compose a question from
# scratch.
DEMO_PROMPTS = [
"Explain the ESM-2 ΔLL score in one paragraph for someone with a biochem background.",
"I have a 280-aa enzyme and the engine is suggesting M1L, V8I, and K42R. Which of these is most likely to affect activity, and why?",
"What's the difference between site-saturation mutagenesis and the combinatorial library approach this engine uses?",
"Why does codon optimization for E. coli sometimes hurt expression in yeast?",
]
# Note: we used to wrap this ChatInterface in a multi-tab gr.Blocks (Chat
# tab + System-prompt-viewer tab). That pattern is shaky in Gradio 4.x —
# .render() on a ChatInterface inside another Blocks context didn't
# initialise the queue cleanly and produced 500s on every submit. Dropped
# the wrapper; the ChatInterface launches directly. System prompt is
# documented in README.md instead.
demo = gr.ChatInterface(
fn=chat_fn,
type="messages",
title="TuringDNA Assistant",
description=(
f"In-app biology assistant powered by **{MODEL_ID_IN_USE}** on ZeroGPU. "
"Ask about protein sequences, ΔLL scores, codon optimization, or cloning strategy. "
"First call after the Space wakes up takes 10–30 s while the GPU is acquired."
),
examples=DEMO_PROMPTS,
cache_examples=False,
# NOTE: api_name kwarg is a Gradio 5.x feature; we're pinned to 4.44.
# In 4.x the ChatInterface submit endpoint auto-registers as "/chat"
# via the function name fallback. gradio_client.predict(api_name="/chat")
# on the Flask side matches.
)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=1).launch(
server_name="0.0.0.0",
# On HF Spaces the platform sets GRADIO_SERVER_PORT; locally we
# default to 7860.
server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
show_api=True,
)