case-forge / app.py
nextmarte's picture
Add deterministic numeric-consistency checker (flags prose number slips)
f725a35 verified
Raw
History Blame Contribute Delete
22.8 kB
"""Case Forge β€” forge a Harvard-style teaching case + teaching note (HF Space).
For instructors authoring cases: pick domain + topic + level + language β†’ the
fine-tuned ≀4B student expands the short request into the full contract
(data/schema.py), rendered classroom-ready with quality badges. The author can
then edit the Markdown live, regenerate, and export. Runtime: ZeroGPU in-Space.
UI identity: "forge & ember" β€” slate + amber, Space Grotesk display font, an
animated book+flame logo, and subtle entrance/loading/result animations. Polish
is a direct judging criterion; all the polish hooks (theme/css/head/js/favicon)
are passed to launch() (Gradio 6 moved them off Blocks()).
"""
import os
import sys
from pathlib import Path
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
_ROOT = Path(__file__).resolve().parent
_MONOREPO = _ROOT.parent
for _p in (str(_ROOT), str(_MONOREPO)):
if _p not in sys.path:
sys.path.insert(0, _p)
import gradio as gr # noqa: E402
from shared import i18n # noqa: E402 (visuals are local; only i18n is shared)
from core import export, infer, numcheck, render # noqa: E402
LOGO_PATH = _ROOT / "assets" / "logo.svg"
_LOGO_SVG = LOGO_PATH.read_text(encoding="utf-8")
# Audience levels: UI label per language β†’ value sent to the model (matches the
# training distribution, which is PT-leaning).
LEVELS = {
"en": [("MBA", "MBA"), ("Undergraduate", "graduaΓ§Γ£o"), ("Executive", "executivo")],
"pt": [("MBA", "MBA"), ("GraduaΓ§Γ£o", "graduaΓ§Γ£o"), ("Executivo", "executivo")],
}
EXAMPLE = {
"domain": "estratΓ©gia",
"topic": "uma rede de cafeterias decidindo adotar preΓ§o dinΓ’mico por horΓ‘rio",
"theory": "elasticidade-preΓ§o, percepΓ§Γ£o de justiΓ§a de preΓ§o",
}
# ---------------------------------------------------------------------------
# Look & feel β€” theme + injected <head> + CSS skin (all passed to launch()).
# NOTE: kept as plain strings (no f-strings / .format) so CSS/JS braces and any
# backslashes are safe under the Space's Python 3.10.
# ---------------------------------------------------------------------------
def _forge_theme():
"""A distinct ember theme for Case Forge (does not touch shared/ui)."""
return gr.themes.Soft(
primary_hue=gr.themes.colors.orange,
secondary_hue=gr.themes.colors.amber,
neutral_hue=gr.themes.colors.slate,
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
).set(
body_background_fill="linear-gradient(160deg, #14131a 0%, #1c1822 100%)",
body_background_fill_dark="linear-gradient(160deg, #14131a 0%, #1c1822 100%)",
block_background_fill="rgba(255,255,255,0.045)",
block_border_width="1px",
block_border_color="rgba(255,255,255,0.09)",
block_radius="16px",
button_primary_background_fill="linear-gradient(92deg, #ff8a3d 0%, #ff6a1f 100%)",
button_primary_background_fill_hover="linear-gradient(92deg, #ffa05a 0%, #ff7a2f 100%)",
button_primary_text_color="#1c1206",
# Tone down component labels (default renders them as solid ember pills).
# We force dark mode, so the *_dark variants are the ones that apply.
block_title_background_fill="transparent",
block_title_background_fill_dark="transparent",
block_title_text_color="#e7b890",
block_title_text_color_dark="#e7b890",
block_label_background_fill="rgba(255,138,61,0.14)",
block_label_background_fill_dark="rgba(255,138,61,0.14)",
block_label_text_color="#ffc89c",
block_label_text_color_dark="#ffc89c",
input_background_fill="rgba(0,0,0,0.22)",
input_background_fill_dark="rgba(0,0,0,0.22)",
input_background_fill_focus="rgba(0,0,0,0.30)",
input_background_fill_focus_dark="rgba(0,0,0,0.30)",
)
_HEAD = """
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--cf-ember:#ff8a3d; --cf-ember2:#ffb066;
--cf-ink:#ece7df; --cf-muted:rgba(236,231,223,.60);
--cf-surface:rgba(255,255,255,.045); --cf-border:rgba(255,255,255,.09);
--cf-ember-soft:rgba(255,138,61,.14);
}
@keyframes cfFadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
@keyframes cfPop{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
@keyframes cfShimmer{to{background-position:200% center}}
@keyframes cfFlicker{0%,100%{opacity:1;transform:scaleY(1)}50%{opacity:.82;transform:scaleY(1.07)}}
@keyframes cfFlickerFast{0%,100%{opacity:1;transform:scaleY(1)}50%{opacity:.65;transform:scaleY(1.14)}}
@keyframes cfGlow{0%,100%{opacity:.5}50%{opacity:.92}}
@keyframes cfPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.07)}}
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
</style>
"""
_CSS = """
.gradio-container{ color:var(--cf-ink); }
footer{ display:none !important; }
/* ---------- branded hero (animated ember canvas behind) ---------- */
#cf-hero{ position:relative; overflow:hidden; border-radius:20px; margin:2px 0 6px;
padding:18px 20px; border:1px solid rgba(255,138,61,.14);
background:linear-gradient(180deg, rgba(255,138,61,.05), rgba(255,255,255,.012)); }
#cf-hero::before{ content:''; position:absolute; inset:0; pointer-events:none;
background:radial-gradient(120% 150% at 16% -10%, rgba(255,138,61,.20), rgba(255,138,61,0) 55%); }
#cf-embers{ position:absolute; inset:0; width:100%; height:100%; pointer-events:none; opacity:.85; }
#cf-header{ position:relative; z-index:1; display:flex; align-items:center; gap:18px; }
#cf-header .cf-logo{ width:64px; height:64px; flex:0 0 auto;
filter:drop-shadow(0 6px 18px rgba(255,138,61,.42)); }
#cf-header .cf-logo svg{ width:100%; height:100%; display:block; }
#cf-header .cf-flame{ transform-origin:32px 18px; animation:cfFlicker 2.8s ease-in-out infinite; }
#cf-header .cf-flame-core{ transform-origin:32px 18px; animation:cfFlicker 2.2s ease-in-out infinite; }
#cf-header .cf-glow{ animation:cfGlow 2.8s ease-in-out infinite; }
#cf-header h1{ font-family:'Space Grotesk',sans-serif; font-weight:700; font-size:2.45rem;
margin:0; line-height:1.02; letter-spacing:.5px;
background:linear-gradient(92deg,#ffe7bd,#ff9d4d 55%,#ff6a1f);
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
#cf-header p{ margin:4px 0 0; color:var(--cf-muted); font-size:1rem; }
/* ---------- panels / cards (depth + ember edge) ---------- */
.cf-panel{ position:relative; border-radius:18px; padding:18px 16px 14px !important;
background:linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.018));
border:1px solid rgba(255,138,61,.16);
box-shadow:0 16px 46px rgba(0,0,0,.40), inset 0 1px 0 rgba(255,255,255,.05); }
.cf-panel::before{ content:''; position:absolute; top:0; left:22px; right:22px; height:1px;
background:linear-gradient(90deg, transparent, rgba(255,138,61,.55), transparent); }
/* ---------- buttons ---------- */
.gradio-container button.primary{ font-weight:600;
transition:transform .15s ease, box-shadow .15s ease, filter .15s ease; }
.gradio-container button.primary:hover{ transform:translateY(-1px);
box-shadow:0 10px 24px rgba(255,138,61,.32); filter:brightness(1.04); }
.gradio-container button.secondary{ transition:transform .15s ease, border-color .15s ease; }
.gradio-container button.secondary:hover{ transform:translateY(-1px);
border-color:rgba(255,138,61,.5); }
/* ---------- status (shimmers while forging) ---------- */
#cf-status{ font-weight:600; min-height:1.2em; }
body.cf-forging #cf-status, body.cf-forging #cf-status *{
background:linear-gradient(90deg,#ff8a3d,#ffe0ad,#ff8a3d); background-size:200% auto;
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
animation:cfShimmer 1.25s linear infinite; }
body.cf-forging #cf-header .cf-flame{ animation:cfFlickerFast .55s ease-in-out infinite; }
body.cf-forging #cf-header .cf-logo{ filter:drop-shadow(0 6px 22px rgba(255,138,61,.6)); }
/* ---------- quality chips (staggered pop-in) ---------- */
.cf-chips{ display:flex; flex-wrap:wrap; gap:8px; margin:2px 0 6px; }
.cf-chip{ padding:6px 13px; border-radius:999px; font-size:.84rem; font-weight:500;
border:1px solid transparent; opacity:0; animation:cfPop .42s ease forwards; }
.cf-pass{ background:rgba(74,222,128,.14); border-color:rgba(74,222,128,.5); color:#bbf7d0; }
.cf-fail{ background:rgba(248,113,113,.13); border-color:rgba(248,113,113,.45); color:#fecaca; }
.cf-demo{ font-style:italic; opacity:.85; margin-top:6px; font-size:.85rem; }
.cf-numwarn{ margin-top:8px; padding:8px 12px; border-radius:10px; font-size:.84rem;
background:rgba(255,193,7,.10); border:1px solid rgba(255,193,7,.40); color:#ffe2a6; }
.cf-numwarn b{ color:#ffd479; }
.cf-numwarn ul{ margin:4px 0 0; padding-left:18px; }
.cf-numwarn li{ margin:2px 0; }
.cf-hint{ font-size:.82rem; opacity:.65; margin:4px 2px; }
/* ---------- empty state (illustrated) ---------- */
.cf-empty{ text-align:center; padding:48px 22px; color:var(--cf-muted);
border:1px dashed rgba(255,138,61,.28); border-radius:16px;
background:radial-gradient(80% 130% at 50% -10%, rgba(255,138,61,.08), rgba(255,138,61,0)); }
.cf-empty .cf-empty-logo{ width:58px; height:58px; margin:0 auto 12px;
filter:drop-shadow(0 5px 16px rgba(255,138,61,.42)); animation:cfPulse 3s ease-in-out infinite; }
.cf-empty .cf-empty-logo svg{ width:100%; height:100%; display:block; }
.cf-empty h3{ font-family:'Space Grotesk',sans-serif; color:#ffd9b0; margin:0 0 4px;
font-size:1.08rem; letter-spacing:.3px; }
.cf-empty p{ margin:0; font-size:.9rem; }
/* ---------- rendered case/note (readable on dark; ember accents) ---------- */
.cf-doc, .cf-doc *{ color:var(--cf-ink) !important; }
.cf-doc{ max-height:64vh; overflow-y:auto; padding-right:8px; line-height:1.66;
animation:cfFadeUp .5s ease; }
.cf-doc h1{ font-family:'Space Grotesk',sans-serif; font-size:1.5rem; color:#ffd9b0 !important;
border-bottom:1px solid var(--cf-ember-soft); padding-bottom:6px; }
.cf-doc h2{ font-family:'Space Grotesk',sans-serif; font-size:1.12rem;
color:var(--cf-ember2) !important; margin-top:1.4em; }
.cf-doc strong{ color:#fff3e6 !important; }
.cf-doc blockquote{ border-left:3px solid var(--cf-ember); background:var(--cf-ember-soft);
padding:.5em .9em; border-radius:8px; margin:1em 0; }
.cf-doc table{ border-collapse:collapse; margin:.5em 0; }
.cf-doc th, .cf-doc td{ border:1px solid var(--cf-border); padding:5px 9px; }
.cf-doc li{ margin:.25em 0; }
.cf-chips{ animation:cfFadeUp .5s ease; }
/* ---------- entrance ---------- */
body.cf-ready #cf-header{ animation:cfFadeUp .6s ease; }
body.cf-ready .cf-panel{ animation:cfFadeUp .6s ease .08s backwards; }
"""
# Small client-side hooks (plain JS strings; executed by Gradio β€” NOT via head
# innerHTML, which doesn't run <script>). The ember field is a real canvas anim.
_JS_EMBERS = """
() => {
document.body.classList.add('cf-ready');
const c = document.getElementById('cf-embers');
if(!c) return;
if(window.__cfRAF) cancelAnimationFrame(window.__cfRAF);
const ctx = c.getContext('2d');
const resize = () => { c.width = c.offsetWidth; c.height = c.offsetHeight; };
resize(); window.addEventListener('resize', resize);
const N = 30, ps = [];
for(let i=0;i<N;i++){ ps.push({x:Math.random(), y:Math.random(),
r:Math.random()*1.7+0.5, s:Math.random()*0.0008+0.00025,
a:Math.random()*0.5+0.25, t:Math.random()*6.3}); }
const tick = () => {
ctx.clearRect(0,0,c.width,c.height);
for(const p of ps){
p.y -= p.s; p.t += 0.02;
if(p.y < -0.06){ p.y = 1.06; p.x = Math.random(); }
const x = (p.x + Math.sin(p.t)*0.012) * c.width, y = p.y * c.height, rad = p.r*4.5;
const g = ctx.createRadialGradient(x,y,0,x,y,rad);
g.addColorStop(0,'rgba(255,170,95,'+p.a+')');
g.addColorStop(1,'rgba(255,140,60,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x,y,rad,0,6.283); ctx.fill();
}
window.__cfRAF = requestAnimationFrame(tick);
};
tick();
}
"""
# Drive the "forging" animation straight off the status text the generator sets:
# the hourglass means in-progress. Self-syncing, no event races.
_JS_STATUS = "(s) => { document.body.classList.toggle('cf-forging', !!s && s.indexOf('\\u23f3') >= 0); }"
# ---------------------------------------------------------------------------
# Rendering helpers
# ---------------------------------------------------------------------------
def _header_html(lang: str) -> str:
return ('<div id="cf-hero">'
'<canvas id="cf-embers"></canvas>'
'<div id="cf-header">'
'<div class="cf-logo">' + _LOGO_SVG + '</div>'
'<div class="cf-brand">'
'<h1>' + i18n.t("cf.title", lang) + '</h1>'
'<p>' + i18n.t("cf.tagline", lang) + '</p>'
'</div></div></div>')
def _empty_state(lang: str) -> str:
return ('<div class="cf-empty"><div class="cf-empty-logo">' + _LOGO_SVG + '</div>'
'<h3>Case Forge</h3>'
'<p>' + i18n.t("cf.result_empty", lang) + '</p></div>')
def _chips_html(flags: dict, lang: str, demo: bool, reason: str | None = None,
num_warns: list | None = None) -> str:
labels = {
"schema": i18n.t("cf.q_schema", lang),
"noleak": i18n.t("cf.q_noleak", lang),
"objectives": i18n.t("cf.q_objectives", lang),
"sourced": i18n.t("cf.q_sourced", lang),
}
chips = []
for i, key in enumerate(("schema", "noleak", "objectives", "sourced")):
ok = flags.get(key)
cls = "cf-pass" if ok else "cf-fail"
mark = "βœ“" if ok else "βœ—"
delay = "%.2fs" % (i * 0.06)
chips.append('<span class="cf-chip ' + cls + '" style="animation-delay:' + delay
+ '">' + mark + ' ' + labels[key] + '</span>')
head = '<div class="cf-chips">' + "".join(chips) + '</div>'
head += '<div class="cf-demo">⚠ ' + i18n.t("cf.disclaimer", lang) + '</div>'
if num_warns:
items = "".join("<li>" + w + "</li>" for w in num_warns)
head += ('<div class="cf-numwarn"><b>⚠ ' + i18n.t("cf.numcheck_title", lang)
+ '</b><ul>' + items + '</ul></div>')
if demo:
note = "cf.busy_note" if reason == "busy" else "cf.demo_note"
head += '<div class="cf-demo">' + i18n.t(note, lang) + '</div>'
return head
def _ui_lang(label: str) -> str:
return i18n.code_for(label)
# ---------------------------------------------------------------------------
# Generation flow
# ---------------------------------------------------------------------------
def _loading(status_value):
"""A forge() yield that only updates the status line (rest unchanged)."""
n = gr.update()
return (gr.update(value=status_value, visible=True), n, n, n, n, n, n, n,
gr.update(visible=False))
def forge(domain, topic, level, case_lang, theory, ui_lang_label):
"""Generate β†’ validate β†’ render into the editable authoring surface."""
lang = _ui_lang(ui_lang_label)
if not (topic or "").strip():
yield _loading(i18n.t("cf.empty_topic", lang))
return
yield _loading("⏳ " + i18n.t("cf.status_forging", lang))
res = infer.generate(domain=domain, topic=topic, level=level,
language=case_lang, theory=theory)
obj = res.get("obj")
if not obj:
yield _loading("⚠️ " + i18n.t("cf.status_invalid", lang))
return
flags = render.quality_flags(obj, res.get("errors", []), res.get("warnings", []))
clang_obj = obj.get("language", "pt")
num_warns = [] if res.get("demo") else numcheck.check(obj, clang_obj)
chips = _chips_html(flags, lang, res.get("demo", False), res.get("reason"), num_warns)
case_md = render.render_case(obj)
note_md = render.render_note(obj)
title = obj.get("title", "case")
clang = obj.get("language", "pt")
status_key = "cf.status_done" if res.get("valid") else "cf.status_invalid"
icon = "βœ…" if res.get("valid") else "⚠️"
yield (
gr.update(value=icon + " " + i18n.t(status_key, lang), visible=True),
chips,
case_md, case_md, # case source + preview
note_md, note_md, # note source + preview
title, clang, # states
gr.update(visible=True), # actions row (regen + downloads)
)
def fill_example():
return EXAMPLE["domain"], EXAMPLE["topic"], EXAMPLE["theory"]
def relabel(ui_lang_label):
"""Re-render all UI chrome when the interface language changes."""
lang = _ui_lang(ui_lang_label)
return (
_header_html(lang),
gr.update(label=i18n.t("cf.domain", lang)),
gr.update(label=i18n.t("cf.topic", lang)),
gr.update(label=i18n.t("cf.level", lang), choices=LEVELS[lang], value="MBA"),
gr.update(label=i18n.t("cf.content_lang", lang)),
gr.update(label=i18n.t("cf.theory", lang)),
gr.update(value=i18n.t("cf.btn_forge", lang)),
gr.update(value=i18n.t("cf.btn_example", lang)),
gr.update(label=i18n.t("cf.tab_case", lang)),
gr.update(label=i18n.t("cf.tab_note", lang)),
gr.update(label=i18n.t("cf.edit_source", lang)), # case_src
gr.update(label=i18n.t("cf.edit_source", lang)), # note_src
gr.update(value="πŸ”„ " + i18n.t("cf.btn_regen", lang)), # regen
gr.update(label=i18n.t("cf.download_md", lang)),
gr.update(label=i18n.t("cf.download_html", lang)),
)
def build():
d = i18n.DEFAULT
with gr.Blocks(title="Case Forge") as demo:
with gr.Row():
header = gr.HTML(_header_html(d))
ui_lang = gr.Dropdown(
choices=[n for n, _ in i18n.LANGS], value="English",
label=i18n.t("common.language", d), scale=0, min_width=150,
)
title_state = gr.State("case")
lang_state = gr.State("pt")
with gr.Row(equal_height=False):
# ---- input column ----
with gr.Column(scale=2, elem_classes="cf-panel"):
domain = gr.Textbox(label=i18n.t("cf.domain", d),
placeholder=i18n.t("cf.domain_ph", d))
topic = gr.Textbox(label=i18n.t("cf.topic", d), lines=2,
placeholder=i18n.t("cf.topic_ph", d))
with gr.Row():
level = gr.Dropdown(choices=LEVELS[d], value="MBA",
label=i18n.t("cf.level", d))
case_lang = gr.Radio(choices=[("PT", "pt"), ("EN", "en")],
value="pt", label=i18n.t("cf.content_lang", d))
theory = gr.Textbox(label=i18n.t("cf.theory", d),
placeholder=i18n.t("cf.theory_ph", d))
with gr.Row():
example_btn = gr.Button(i18n.t("cf.btn_example", d), variant="secondary")
forge_btn = gr.Button(i18n.t("cf.btn_forge", d), variant="primary")
status = gr.Markdown(visible=False, elem_id="cf-status")
# ---- output / authoring column ----
with gr.Column(scale=3, elem_classes="cf-panel"):
chips = gr.HTML(_empty_state(d))
with gr.Tab(i18n.t("cf.tab_case", d)) as case_tab:
with gr.Row():
case_src = gr.Textbox(label=i18n.t("cf.edit_source", d), lines=22,
scale=1)
case_prev = gr.Markdown(elem_classes="cf-doc")
with gr.Tab(i18n.t("cf.tab_note", d)) as note_tab:
with gr.Row():
note_src = gr.Textbox(label=i18n.t("cf.edit_source", d), lines=22,
scale=1)
note_prev = gr.Markdown(elem_classes="cf-doc")
gr.Markdown(i18n.t("cf.edit_hint", d), elem_classes="cf-hint")
# Toggle the row's visibility as a unit β€” toggling sibling
# DownloadButtons individually drops the 2nd one in Gradio.
with gr.Row(visible=False) as actions_row:
regen_btn = gr.Button("πŸ”„ " + i18n.t("cf.btn_regen", d),
variant="secondary")
md_dl = gr.DownloadButton(i18n.t("cf.download_md", d))
html_dl = gr.DownloadButton(i18n.t("cf.download_html", d))
forge_out = [status, chips, case_src, case_prev, note_src, note_prev,
title_state, lang_state, actions_row]
forge_in = [domain, topic, level, case_lang, theory, ui_lang]
# Core generation (direct wiring).
forge_btn.click(forge, inputs=forge_in, outputs=forge_out)
regen_btn.click(forge, inputs=forge_in, outputs=forge_out)
example_btn.click(fill_example, outputs=[domain, topic, theory])
# The "forging" animation is driven by the status text the generator sets
# (hourglass = in progress) β€” synced to the yields, self-healing, no races.
status.change(None, inputs=[status], js=_JS_STATUS)
# Live preview: editing the Markdown source updates the rendered preview.
case_src.change(lambda s: s, case_src, case_prev)
note_src.change(lambda s: s, note_src, note_prev)
# Downloads are built from the CURRENT (possibly edited) source at click time.
md_dl.click(export.to_markdown_file,
inputs=[case_src, note_src, title_state], outputs=md_dl)
html_dl.click(export.to_html_file,
inputs=[case_src, note_src, title_state, lang_state], outputs=html_dl)
ui_lang.change(
relabel, inputs=[ui_lang],
outputs=[header, domain, topic, level, case_lang, theory,
forge_btn, example_btn, case_tab, note_tab,
case_src, note_src, regen_btn, md_dl, html_dl],
).then(None, js=_JS_EMBERS) # re-init the ember canvas after the hero re-renders
demo.load(None, js=_JS_EMBERS) # entrance animation + start the ember field
return demo
if __name__ == "__main__":
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", 7860)))
build().launch(server_name="0.0.0.0", server_port=port, show_error=True,
theme=_forge_theme(), css=_CSS, head=_HEAD,
favicon_path=str(LOGO_PATH))