import spaces # must be imported before torch / transformers
import html
import time
import gradio as gr
import torch
from huggingface_hub import snapshot_download
from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
from docision._runtime.hf import HFDocisionEngine
from docision.engine import DocisionEngine
from docision.io import DEFAULT_CONTEXT
MODEL_ID = "SoMarkAI/Docision-1.0"
# Reference defaults from the official CLI (docision/cli.py)
MIN_PIXELS = 3136
MAX_PIXELS = 501760
MAX_INPUT_TOKENS = 4000
model_path = snapshot_download(MODEL_ID, allow_patterns=["*.json", "*.jinja", "*.safetensors"])
processor = AutoProcessor.from_pretrained(model_path)
model, loading_info = Qwen3VLForConditionalGeneration.from_pretrained(
model_path, dtype=torch.bfloat16, attn_implementation="sdpa", output_loading_info=True
)
# Same strictness as the official runtime: refuse incomplete checkpoint loads.
if any(loading_info.get(k) for k in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")):
raise RuntimeError(f"Incomplete Docision checkpoint load: {loading_info}")
print("[docision] checkpoint loaded cleanly")
model = model.to("cuda")
model.eval()
# Build the official Docision engine around the already-loaded model
# (DocisionEngine.__init__ would load with device_map="auto", which bypasses ZeroGPU).
engine = DocisionEngine.__new__(DocisionEngine)
HFDocisionEngine.__init__(
engine, model_path, model=model, processor=processor,
numerics="native", max_input_tokens=MAX_INPUT_TOKENS,
)
engine.builder.processor.image_processor.size = {
"shortest_edge": MIN_PIXELS,
"longest_edge": MAX_PIXELS,
}
def parse_questions(text: str) -> list[dict]:
"""Parse blocks separated by blank lines: first line = question, other lines = candidates."""
blocks, current = [], []
for raw in (text or "").splitlines():
line = raw.strip()
if not line:
if current:
blocks.append(current)
current = []
continue
current.append(line)
if current:
blocks.append(current)
if not blocks:
raise gr.Error("Add at least one question followed by two or more candidate lines.")
questions = []
for i, block in enumerate(blocks, 1):
question = block[0]
for prefix in ("Q:", "Q:"):
if question.startswith(prefix):
question = question[len(prefix):].strip()
candidates = []
for line in block[1:]:
if line[:2] in ("- ", "* ", "• "):
line = line[2:].strip()
if line:
candidates.append(line)
if len(candidates) < 2:
raise gr.Error(f"Question {i} (“{question}”) needs at least two candidate lines.")
if len(set(candidates)) != len(candidates):
raise gr.Error(f"Question {i} has duplicate candidates.")
questions.append({"id": f"q{i}", "question": question, "candidates": candidates})
return questions
def render_html(questions: list[dict], results: list[dict], seconds: float) -> str:
parts = ['
']
for q, r in zip(questions, results):
decision = r["decision"]
parts.append('
')
parts.append(f'
{html.escape(q["question"])}
')
parts.append(
f'
Decision: [{html.escape(decision["label"])}] '
f'{html.escape(decision["text"])} · {decision["probability"]*100:.1f}%
'
)
for c in r["candidates"]:
pct = c["probability"] * 100
win = " dc-win" if c["label"] == decision["label"] else ""
parts.append(
f'
'
f'
[{html.escape(c["label"])}] {html.escape(c["text"])}
'
f'
'
f'
{pct:.2f}%
'
)
parts.append("
")
parts.append(f'
')
return "".join(parts)
def _gpu_duration(image, questions_text, *args, **kwargs):
# Measured ~0.5-0.7s per question (incl. cold start); keep a small fixed margin.
n = sum(1 for block in (questions_text or "").split("\n\n") if block.strip())
return min(90, 10 + 2 * max(n, 1))
@spaces.GPU(duration=_gpu_duration)
def decide(
image: str,
questions_text: str,
context: str = DEFAULT_CONTEXT,
mode: str = "causal",
) -> tuple[str, list]:
"""Score candidate answers to one or more questions about a document image with Docision-1.0.
Args:
image: path to the document image (PNG/JPEG/WebP).
questions_text: question blocks separated by blank lines; the first line of each block is the
question, every following line is one candidate answer.
context: instruction prefix shared by all questions.
mode: "causal" (default; candidates see each other) or "isolated" (each candidate attends only to the shared prefix).
Returns:
An HTML visualization and the raw per-question results (candidate probabilities + decision).
"""
if not image:
raise gr.Error("Upload a document image first.")
questions = parse_questions(questions_text)
document = {
"image": image,
"context": context if context and context.strip() else DEFAULT_CONTEXT,
"questions": questions,
}
t0 = time.perf_counter()
try:
results = engine.score_document(document, mode=mode, prefix_cache=False, batch_size=1)
except ValueError as e:
raise gr.Error(str(e))
seconds = time.perf_counter() - t0
print(f"[docision] scored {len(questions)} question(s) in {seconds:.2f}s")
raw = [
{
"id": q["id"],
"question": q["question"],
"decision": {k: r["decision"][k] for k in ("label", "text", "probability", "index")},
"candidates": r["candidates"],
"prompt_tokens": r["prompt_tokens"],
}
for q, r in zip(questions, results)
]
return render_html(questions, results, seconds), raw
EX_EN = """What is the RR for severely malnourished in the table?
3.4
4.8
2.1
What is used as the reference in the Stage at Diagnosis row?
Stages I and II
Newly diagnosed
Well-nourished
Are the 95% CIs for moderately malnourished and stage at diagnosis the same?
Different
The same
Which items in the table have P-values marked “< 0.001”?
Moderately malnourished and severely malnourished
Severely malnourished and treatment history
Stage at diagnosis and treatment history
Moderately malnourished and stage at diagnosis"""
EX_ZH = """表中重度营养不良(Severely malnourished)的RR是多少?
3.4
4.8
2.1
诊断时分期(Stage at Diagnosis)这一行采用什么作为参照?
I期和II期
新诊断
营养良好
中度营养不良与诊断时分期的95% CI是否相同?
不同
相同"""
EX_CLS = """What type of document is this?
A statistical results table from a research paper
An invoice
A bank statement
A handwritten letter
Does the table report relative risks with confidence intervals?
Yes
No"""
CSS = """
#col-container { max-width: 1200px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
.dc-wrap { display: flex; flex-direction: column; gap: 12px; }
.dc-card { border: 1px solid var(--border-color-primary); border-radius: 10px; padding: 12px 14px; background: var(--block-background-fill); }
.dc-q { font-weight: 600; margin-bottom: 4px; color: var(--body-text-color); }
.dc-dec { font-size: 0.9em; margin-bottom: 8px; color: var(--body-text-color-subdued); }
.dc-row { display: grid; grid-template-columns: minmax(140px, 2fr) 3fr 70px; gap: 8px; align-items: center; padding: 3px 0; font-size: 0.92em; color: var(--body-text-color); }
.dc-barbox { background: var(--background-fill-secondary); border-radius: 6px; height: 14px; overflow: hidden; }
.dc-bar { height: 100%; background: var(--neutral-400); border-radius: 6px; }
.dc-win .dc-bar { background: var(--color-accent); }
.dc-win .dc-label { font-weight: 600; }
.dc-pct { text-align: right; font-variant-numeric: tabular-nums; }
.dc-foot { font-size: 0.8em; color: var(--body-text-color-subdued); }
"""
with gr.Blocks(title="Docision-1.0") as demo:
with gr.Column(elem_id="col-container"):
gr.Markdown(
"# 📑 Docision-1.0\n"
"Document decisions with candidate scoring: upload a document image, write one or more questions, "
"each with its own candidate answers, and get the chosen candidate plus a probability for every option. "
"Fine-tuned from Qwen3-VL-4B-Instruct. \n"
"[Model](https://huggingface.co/SoMarkAI/Docision-1.0) · "
"[GitHub](https://github.com/SoMarkAI/docision) · "
"[Benchmark](https://huggingface.co/datasets/SoMarkAI/doc-decision-bench)"
)
with gr.Row():
with gr.Column(scale=1):
image = gr.Image(label="Document image", type="filepath", format="png", height=320)
questions_text = gr.Textbox(
label="Questions & candidates",
info="One block per question, separated by a blank line. First line = question, "
"each following line = one candidate (2–128 per question).",
lines=12,
placeholder="Which category does this document belong to?\nInvoice\nReceipt\nContract\n\n"
"Is the document signed?\nYes\nNo",
)
run = gr.Button("Decide", variant="primary")
with gr.Accordion("Advanced options", open=False):
context = gr.Textbox(label="Context / instructions (shared by all questions)",
value=DEFAULT_CONTEXT, lines=2)
mode = gr.Radio(["causal", "isolated"], value="causal", label="Attention mode",
info="isolated: each candidate only sees the shared prompt, not the other candidates.")
with gr.Column(scale=1):
result_html = gr.HTML(label="Decisions")
result_json = gr.JSON(label="Raw output")
gr.Examples(
examples=[
["examples/validation-table.jpg", EX_EN],
["examples/validation-table.jpg", EX_ZH],
["examples/validation-table.jpg", EX_CLS],
],
inputs=[image, questions_text],
outputs=[result_html, result_json],
fn=decide,
cache_examples=True,
cache_mode="lazy",
)
gr.Markdown(
"Example image: Table 3 from Gupta et al., *Can subjective global assessment of nutritional "
"status predict survival in ovarian cancer?*, Journal of Ovarian Research 1, 5 (2008), "
"[CC BY 2.0](https://creativecommons.org/licenses/by/2.0/). Probabilities are relative scores over the "
"candidates, not calibrated confidences."
)
run.click(decide, inputs=[image, questions_text, context, mode],
outputs=[result_html, result_json], api_name="decide")
if __name__ == "__main__":
demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)