fugee / app /app.py
helmo's picture
[ux] Move credit to the intake screen only; LinkedIn/HF as logo-icons
ebc2550
Raw
History Blame Contribute Delete
13.6 kB
"""app/app.py — Fugee Gradio entrypoint.
Run locally with::
python app/app.py
Mounts the phase screens onto a single Gradio shell themed entirely from
DESIGN.md tokens (via ``app.design_tokens``) and mockup.html. The intake screen
(Phase 1) shows first; choosing a language and pressing "Begin" hides it and
streams the interview (Phase 2).
All colours, fonts, radii, and spacing come from DESIGN.md — never hardcoded
here (CLAUDE.md Design Rule 7).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Allow ``python app/app.py`` (script run) as well as ``python -m app.app``.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gradio as gr # noqa: E402
from agent.loop import create_loop # noqa: E402
from app.config import load_env # noqa: E402
from app.design_tokens import root_css # noqa: E402
from app.phases import assessment as assessment_phase # noqa: E402
from app.phases import documents as documents_phase # noqa: E402
from app.phases import intake as intake_phase # noqa: E402
from app.phases import interview as interview_phase # noqa: E402
from app.phases import recommendations as reco_phase # noqa: E402
from app.state.session import SessionState, State # noqa: E402
# Load .env (OLLAMA_HOST, MODEL_ID, …) before anything reads the environment.
load_env()
# One model for the whole flow (hackathon: single LLM, no fallback). Read from
# MODEL_ID via the loop factory.
# Web fonts: Fraunces (display serif) + Inter (UI sans) per DESIGN.md §3.
FONT_IMPORT = (
"@import url('https://fonts.googleapis.com/css2?"
"family=Fraunces:opsz,wght@9..144,500;9..144,600&"
"family=Inter:wght@400;450;600;700&display=swap');"
)
# Global shell + chrome overrides so Gradio's default theme doesn't bleed
# through. Flatten default component chrome; our phase screens (#intake-screen,
# #iv-screen) re-assert their own surface/border with higher specificity.
GLOBAL_CSS = """
footer { display: none !important; }
/* Always reserve the scrollbar gutter so a tall screen (with a scrollbar) and a
short one (without) keep the SAME viewport width — otherwise the centered
container, and the nav inside it, jump sideways on every screen transition. */
html { scrollbar-gutter: stable; overflow-y: scroll; }
body, .gradio-container { background: var(--bg) !important; color: var(--text);
font-family: var(--font-ui); }
/* Wide shell like the mockup (main = 1180px); narrow screens cap themselves.
width:100% pins the shell to the full available width so it never shrink-wraps
to the current phase's content — that shrink-wrap is what made the nav (which
spans this container) change size and re-center between screens. */
.gradio-container { width: 100% !important; max-width: 1180px !important; margin: 0 auto !important;
padding: 0 clamp(16px,4vw,32px) var(--s-xxl) !important; }
.gradio-container button, .gradio-container input,
.gradio-container textarea, .gradio-container select { font-family: var(--font-ui); }
/* neutralise Gradio's default boxes so our custom screens read cleanly */
.gradio-container .block { background: transparent !important; border: none !important;
box-shadow: none !important; }
.gradio-container .form { background: transparent !important; border: none !important;
box-shadow: none !important; }
.gradio-container .gap { gap: var(--s-sm); }
/* Every phase wrapper fills the shell so all five screens render at the SAME
width. Without this, the Gradio column shrink-wraps to its content and each
card collapses to a different width (intake ~680, interview ~560, etc.). */
.screen-wrap { width: 100% !important; align-self: stretch !important; }
.screen-wrap > .block, .screen-wrap > div { width: 100% !important; }
.gradio-container :focus-visible { outline: 3px solid var(--accent) !important;
outline-offset: 2px; border-radius: var(--r-sm); }
/* Site bar (mockup chrome) */
#site-bar { position:sticky; top:0; z-index:50; background:rgba(247,245,240,.86);
backdrop-filter:saturate(140%) blur(10px); border-bottom:1px solid var(--line);
margin:0 calc(-1 * clamp(16px,4vw,32px)) var(--s-xl); padding:14px clamp(16px,4vw,32px); }
#site-bar .inner { display:flex; align-items:center; gap:12px; max-width:1180px; margin:0 auto; }
#site-bar .name { font-family:var(--font-display); font-weight:600; font-size:21px;
letter-spacing:-.01em; color:var(--primary-deep); }
#site-bar .tag { font-size:12px; color:var(--text-muted); letter-spacing:.04em;
text-transform:uppercase; padding-left:10px; margin-left:2px; border-left:1px solid var(--line-strong); }
#site-bar .nav { margin-left:auto; display:flex; gap:2px; }
#site-bar .nav span { font-size:13px; font-weight:500; color:var(--text-secondary);
padding:7px 12px; border-radius:var(--r-full); white-space:nowrap; }
@media(max-width:860px){ #site-bar .nav, #site-bar .tag { display:none; } }
/* Per-phase header (tag + description), like the mockup */
.phase-head { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; margin-bottom:var(--s-lg); }
.phase-head .ptag { font-size:11.5px; font-weight:600; letter-spacing:.13em; text-transform:uppercase;
color:var(--primary); background:var(--primary-tint); padding:5px 11px; border-radius:var(--r-full); white-space:nowrap; }
.phase-head .pdesc { font-size:14px; color:var(--text-muted); max-width:52ch; }
"""
APP_CSS = (
FONT_IMPORT + "\n"
+ root_css() + "\n"
+ GLOBAL_CSS + "\n"
+ intake_phase.INTAKE_CSS + "\n"
+ interview_phase.INTERVIEW_CSS + "\n"
+ assessment_phase.ASSESSMENT_CSS + "\n"
+ reco_phase.RECO_CSS + "\n"
+ documents_phase.DOCUMENTS_CSS
)
_LOGO_SVG = (
'<svg width="28" height="28" viewBox="0 0 32 32" fill="none"><circle cx="16" cy="16" r="15" fill="#0E6A58"/>'
'<path d="M16 7l7 6.5V25h-4.6v-6.2h-4.8V25H9V13.5L16 7z" fill="#fff"/>'
'<circle cx="16" cy="13.6" r="1.7" fill="#E07B39"/></svg>'
)
SITE_BAR_HTML = f"""
<header id="site-bar"><div class="inner">
{_LOGO_SVG}
<span class="name">Fugee</span>
<span class="tag">Safe guidance for people on the move</span>
</div></header>
"""
def phase_header(tag: str, desc: str) -> str:
"""The 'PHASE N — TITLE' tag + description shown above a phase screen."""
return f'<div class="phase-head"><span class="ptag">{tag}</span><span class="pdesc">{desc}</span></div>'
def _ready_for_assessment(session) -> bool:
"""The interview advances state to ASSESSMENT once the review is confirmed."""
return (
session is not None
and session.state == State.ASSESSMENT
and not session.assessment.recommended_countries
)
def build_app() -> gr.Blocks:
with gr.Blocks(title="Fugee", analytics_enabled=False) as demo:
gr.HTML(SITE_BAR_HTML)
# Session + loop state shared across phases so each screen sees the same
# session object.
session_st = gr.State(None)
loop_st = gr.State(None)
intake_ui = intake_phase.build(visible=True)
interview_ui = interview_phase.build(visible=False, session_st=session_st, loop_st=loop_st)
assess_ui = assessment_phase.build(visible=False, session_st=session_st, loop_st=loop_st)
reco_ui = reco_phase.build(visible=False, session_st=session_st)
docs_ui = documents_phase.build(visible=False, session_st=session_st)
async def begin(lang, session, loop):
if not lang:
return # Begin is disabled until a language is chosen
session = SessionState()
intake_phase.select_language(session, lang)
intake_phase.begin_interview(session) # LANGUAGE_SELECT -> INTAKE
loop = create_loop()
# Hide intake, reveal + stream the interview's first question.
async for out in interview_ui.start_fn(session, loop):
yield (gr.update(visible=False), gr.update(visible=True), *out)
# Two round-trips: stream the first question (controls hidden), then
# reveal its control on its own so the CheckboxGroup isn't fighting the
# chat re-render for its paint (see interview.py).
intake_ui.begin.click(
begin,
inputs=[intake_ui.selected_lang, interview_ui.session, interview_ui.loop],
outputs=[intake_ui.column, interview_ui.column, *interview_ui.stream_outputs],
show_progress="hidden",
).then(
interview_ui.reveal_fn,
inputs=interview_ui.reveal_inputs,
outputs=interview_ui.reveal_outputs,
show_progress="hidden",
)
async def maybe_assess(session, loop):
# Triggered after each interview turn; runs only once the review is
# confirmed (interview set state -> ASSESSMENT), then streams the assessment.
if not _ready_for_assessment(session):
return
# Fresh loop for the assessment turn (same model, its own steering/abort).
assess_loop = create_loop()
async for facts_html, reason_html, progress_html, proceed_u, sess in assess_ui.start_fn(session, assess_loop):
yield (gr.update(visible=False), gr.update(visible=True),
facts_html, reason_html, progress_html, proceed_u, sess)
# After each interview turn completes, check whether the review was
# confirmed and, if so, hand off to the assessment screen.
interview_ui.continue_event.then(
maybe_assess,
inputs=[session_st, loop_st],
outputs=[interview_ui.column, assess_ui.column, *assess_ui.outputs],
)
reco_outputs = [assess_ui.column, reco_ui.column, *reco_ui.render_outputs, session_st]
def show_recommendations(session):
# Reveal the recommendations once the assessment has run (the person
# clicks "See your recommendations" so they can read the assessment
# first). We deliberately do NOT require recommended_countries to be
# non-empty: an unclear case has no recommendations, and the screen
# must still open to show the "needs more info" panel.
ready = session is not None and session.state >= State.RECOMMENDATIONS
if not ready:
return [gr.update()] * len(reco_outputs)
updates = reco_ui.populate(session)
return [gr.update(visible=False), gr.update(visible=True), *updates, session]
# The person reads the assessment, then clicks to see recommendations.
# show_progress="hidden": this just renders cards (pure Python); the generic
# overlay would paint stray "processing…" spinners on the card components.
assess_ui.proceed.click(show_recommendations, inputs=[session_st], outputs=reco_outputs,
show_progress="hidden")
# Recommendations -> Documents: generate the package for the chosen country.
docs_outputs = [reco_ui.column, docs_ui.column, *docs_ui.render_outputs]
_docs_loading = (
'<article class="doc" style="text-align:center;padding:44px 20px;">'
'<div style="font-size:15px;font-weight:700;color:var(--primary-deep)">'
'Preparing your document package…</div>'
'<p style="margin-top:8px;color:var(--text-secondary)">Fugee is drafting your '
'personal statement and building your Word&nbsp;+&nbsp;PDF files. This takes a few moments.</p>'
'</article>'
)
async def show_documents(session):
# Generator: reveal the documents screen with a status message FIRST,
# then run the LLM draft + file generation, so the click never looks
# like nothing is happening.
if session is None or not session.selected_country:
yield [gr.update()] * len(docs_outputs)
return
if session.state < State.DOCUMENTS:
from app.phases.interview import advance_to
advance_to(session, State.DOCUMENTS)
# 1) switch to the docs screen immediately, showing a "preparing…" note
yield [gr.update(visible=False), gr.update(visible=True),
gr.update(value=_docs_loading), gr.update(), gr.update(), gr.update()]
# 2) draft (LLM) + generate Word/PDF, then render the finished package
updates = await docs_ui.populate(session)
yield [gr.update(visible=False), gr.update(visible=True), *updates]
reco_ui.proceed.click(show_documents, inputs=[session_st], outputs=docs_outputs,
show_progress="hidden")
# Start over: return to the intake screen for a fresh session.
def start_over():
return (
gr.update(visible=True), gr.update(visible=False),
gr.update(visible=False), gr.update(visible=False),
gr.update(visible=False), None, None,
)
docs_ui.start_over.click(
start_over,
inputs=[],
outputs=[intake_ui.column, interview_ui.column, assess_ui.column,
reco_ui.column, docs_ui.column, session_st, loop_st],
)
return demo
def main() -> None:
import tempfile
demo = build_app()
# allowed_paths lets Gradio serve the generated PDFs (written to the system
# temp dir) for download.
demo.launch(
server_name="0.0.0.0", css=APP_CSS, theme=gr.themes.Base(),
allowed_paths=[tempfile.gettempdir()],
)
if __name__ == "__main__":
main()