"""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 = ( '' '' '' ) SITE_BAR_HTML = f"""
{_LOGO_SVG} Fugee Safe guidance for people on the move
""" def phase_header(tag: str, desc: str) -> str: """The 'PHASE N — TITLE' tag + description shown above a phase screen.""" return f'
{tag}{desc}
' 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 = ( '
' '
' 'Preparing your document package…
' '

Fugee is drafting your ' 'personal statement and building your Word + PDF files. This takes a few moments.

' '
' ) 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()