Spaces:
Running
A newer version of the Gradio SDK is available: 6.20.0
ARCHITECTURE.md β Fugee
System architecture, data flow, and component contracts. Read
CLAUDE.mdfirst, then this file, thenPLAN.md.
System Overview
Fugee is a single-process Python application: a Gradio frontend and a pure-Python agent loop running in the same process. There is no subprocess, no Node.js, no NDJSON bridge.
The agent loop is ported from the structural patterns of pi-agent-core
(the loop, event stream, tool lifecycle, steering queue) but implemented
entirely in Python. LLM calls go directly to Ollama or any OpenAI-compatible
endpoint via the ollama or litellm Python SDK.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HF Space / Browser β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Gradio App (Python) β β
β β β β
β β Phase 1 Phase 2 Phase 3 Phase 4 Phase 5 β β
β β Intake Interview Assessment Recs Docs β β
β β β β β β β β β
β β ββββββββββββ΄βββββββββββ΄ββββββββββ΄ββββββββ β β
β β β β β
β β AgentLoop (agent/loop.py) β β
β β async generator β yields AgentEvent β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ β
β β Python Agent Loop β β
β β agent/loop.py β β
β β β β
β β βββββββββββββββ ββββββββββββββββ β β
β β β LLM client β β AgentTools β β β
β β β (ollama / β β web_search β β β
β β β litellm) β β country_lookupβ β β
β β ββββββββ¬βββββββ ββββββββ¬ββββββββ β β
β βββββββββββΌββββββββββββββββββΌββββββββββββββββββββββββββ β
β β β β
β βββββββββΌβββββββ βββββββββΌβββββββββββ β
β β LLM endpointβ β External APIs β β
β β (Ollama / β β (UNHCR, asylum β β
β β Modal) β β policy search) β β
β ββββββββββββββββ ββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Design Decision: Why Pure Python
The original spec called for a pi-agent-core (Node.js) subprocess bridge. After evaluation, we ported the agent loop to Python instead. Reasons:
- HF Spaces constraint β spawning a Node subprocess on a Gradio Space is fragile (subprocess lifecycle, signal handling, buffering in sandbox).
- Single process β Gradio's
asyncgenerator support means the loop can yield events directly into the UI with no IPC layer. - Porting cost is low β the pi-agent-core loop is ~300 lines of logic.
Python's
asyncio,typing, anddataclassesmap cleanly. - LLM layer is commodity β
ollamaPython SDK covers the Ollama case in 2 lines;litellmcovers multi-provider if needed. No need to port pi-ai's 20-provider abstraction.
What was kept from pi-agent-core's design:
- The while-loop with tool execution and event emission pattern
- Typed event contracts (ported to Python dataclasses)
- Steering queue (inject messages mid-run)
- Tool definition schema (ported to Python TypedDict / Pydantic)
Source: https://github.com/earendil-works/pi/tree/main/packages/pi-agent-core/src
Read agent-loop.ts, event-stream.ts, and types.ts before implementing agent/loop.py.
Component Contracts
1. Agent Loop (agent/loop.py)
Pure Python async generator. Gradio phases call loop.run(prompt, session)
and iterate over the yielded AgentEvent objects.
Event types (Python dataclasses in agent/events.py):
@dataclass
class AgentStartEvent: type: str = "agent_start"
@dataclass
class TurnStartEvent: type: str = "turn_start"
@dataclass
class TextDeltaEvent: type: str = "text_delta"; delta: str = ""
@dataclass
class ToolStartEvent: type: str = "tool_start"; name: str = ""; args: dict = field(default_factory=dict)
@dataclass
class ToolEndEvent: type: str = "tool_end"; name: str = ""; result: dict = field(default_factory=dict)
@dataclass
class TurnEndEvent: type: str = "turn_end"; message: dict = field(default_factory=dict)
@dataclass
class AgentEndEvent: type: str = "agent_end"; messages: list = field(default_factory=list)
@dataclass
class ErrorEvent: type: str = "error"; message: str = ""
AgentEvent = Union[AgentStartEvent, TurnStartEvent, TextDeltaEvent,
ToolStartEvent, ToolEndEvent, TurnEndEvent,
AgentEndEvent, ErrorEvent]
Loop contract:
async def run(
prompt: str,
session: SessionState,
system_prompt: str,
tools: list[AgentTool],
thinking_level: Literal["low", "medium", "high"] = "low",
) -> AsyncGenerator[AgentEvent, None]:
...
- MUST NOT buffer; yield each event as it is produced
- MUST propagate errors as
ErrorEvent(never raise through Gradio) - MUST support
steering_queueinjection mid-run (for follow-up questions) - MUST support
abort_eventto stop the loop gracefully
Loop internals (ported from pi-agent-core patterns):
# Simplified structure β see agent/loop.py for full implementation
async def run(...):
yield AgentStartEvent()
messages = list(session.messages)
messages.append({"role": "user", "content": prompt})
while True:
yield TurnStartEvent()
response = await llm.stream(messages, tools, system_prompt)
# Stream text deltas
async for chunk in response:
if chunk.type == "text":
yield TextDeltaEvent(delta=chunk.text)
elif chunk.type == "tool_call":
yield ToolStartEvent(name=chunk.name, args=chunk.args)
result = await execute_tool(chunk.name, chunk.args, tools)
yield ToolEndEvent(name=chunk.name, result=result)
# Check steering queue
if not steering_queue.empty():
steer_msg = await steering_queue.get()
messages.append({"role": "user", "content": steer_msg})
continue
yield TurnEndEvent(message=response.final_message)
break
yield AgentEndEvent(messages=messages)
2. Agent Tools (agent/tools/)
Tools are Python callables registered with the loop. Each tool is a
AgentTool dataclass:
@dataclass
class AgentTool:
name: str
description: str
parameters: dict # JSON Schema object
execute: Callable # async (args: dict) -> dict
web_search tool (agent/tools/web_search.py):
AgentTool(
name="web_search",
description="Search for current asylum policies, UNHCR data, country safety",
parameters={
"type": "object",
"properties": {
"query": {"type": "string"},
"focus": {"type": "string", "enum": ["asylum", "safety", "process", "contacts"]}
},
"required": ["query"]
},
execute=web_search_execute # calls Tavily or Serper API
)
country_lookup tool (agent/tools/country_lookup.py):
AgentTool(
name="country_lookup",
description="Look up a country's asylum program: acceptance rates, processing times, UNHCR presence",
parameters={
"type": "object",
"properties": {
"country": {"type": "string"},
"profile": {
"type": "object",
"properties": {
"origin": {"type": "string"},
"persecutionType": {"type": "string"}
}
}
},
"required": ["country"]
},
# Data source: specs/data/countries_enriched.json (preferred, post enrich_downloader.py)
# specs/data/countries.json (curated fallback, always present)
# Both share the same schema β see specs/data/README.md.
# Load at startup; do NOT re-read per call. Fail loudly if neither file exists.
execute=country_lookup_execute
)
3. Session State Machine (app/state/session.py)
The interview follows a strict state machine. States are append-only β no going back to modify earlier answers once the user has moved forward.
LANGUAGE_SELECT
β
βΌ
INTAKE β Phase 1 complete
β
βΌ
SITUATION β "What type of persecution?"
β "Are you in immediate danger?"
β "Current location / transit countries?"
βΌ
HISTORY β "How long have you been displaced?"
β "Have you made prior asylum claims?"
β "What documents do you have?"
βΌ
GOALS β "Do you have destination preferences?"
β "Family or connections in specific countries?"
β "Languages spoken?"
βΌ
REVIEW β Structured summary, user confirms
β
βΌ
ASSESSMENT β Phase 3 begins
β
βΌ
RECOMMENDATIONS β Phase 4 begins
β
βΌ
DOCUMENTS β Phase 5 begins
β
βΌ
COMPLETE
Session object schema:
{
"session_id": str, # UUID
"language": str, # ISO 639-1 (e.g. "fr", "ar", "sw")
"state": str, # current state machine state
"interview": {
"origin_country": str | None,
"current_country": str | None,
"persecution_types": list[str], # ["political", "ethnic", ...]
"immediate_danger": bool | None,
"family_situation": str | None,
"documents_available": list[str],
"languages_spoken": list[str],
"destination_preferences": list[str],
"prior_claims": bool | None,
"displacement_duration": str | None,
"free_text_history": str | None,
},
"assessment": {
"convention_grounds": list[str], # 1951 + AU Refugee Convention
"risk_level": str, # "high" | "moderate" | "low"
"reasoning_trace": str, # full visible reasoning text
"recommended_countries": list[CountryRecommendation],
},
"selected_country": str | None,
"messages": list[dict], # full conversation history
"created_at": str, # ISO 8601
"updated_at": str,
}
4. Gradio Phases (app/phases/)
Each phase is a self-contained Python module that exports a build(session)
function returning a gr.Blocks or gr.Column component tree.
Phase β State contract:
| Phase module | Entry state | Exit state | Gradio yield pattern |
|---|---|---|---|
intake.py |
LANGUAGE_SELECT |
INTAKE |
gr.update() on language pill click |
interview.py |
INTAKE |
REVIEW |
Streaming via AgentLoop.run() async generator |
assessment.py |
ASSESSMENT |
RECOMMENDATIONS |
Streaming text delta to gr.Textbox |
recommendations.py |
RECOMMENDATIONS |
DOCUMENTS |
Card selection + roadmap render |
documents.py |
DOCUMENTS |
COMPLETE |
PDF download via gr.File |
Consuming the agent loop in Gradio:
# In interview.py β streaming loop events into Gradio
async def on_submit(user_input, session_state):
async for event in agent_loop.run(user_input, session_state, ...):
if isinstance(event, TextDeltaEvent):
yield gr.update(value=accumulated_text + event.delta)
elif isinstance(event, ToolStartEvent):
yield gr.update(value=f"[searching: {event.name}...]")
elif isinstance(event, AgentEndEvent):
session_state.messages = event.messages
yield gr.update(value=accumulated_text)
Gradio theming contract:
All Gradio components use elem_id and elem_classes so custom CSS (injected
via gr.HTML or gr.Blocks(css=...)) can target them with DESIGN.md tokens.
Example mapping:
gr.Button("Begin", elem_classes=["btn-primary"]) # β amber, DESIGN.md button-primary
gr.Button("Save and continue later", elem_classes=["btn-ghost"])
The CSS variable block from DESIGN.md tokens is injected as a :root block
at app startup. Never hardcode hex values in Python.
5. Document Generator (agent/tools/doc_generator.py)
Generates the Phase 5 document package. Input: completed session object. Output: list of file paths (PDF + text).
Documents produced:
| File | Content | Pre-filled from |
|---|---|---|
personal_statement.pdf |
Structured personal statement template | session.interview.* |
action_plan.pdf |
Country-specific step-by-step action plan | session.selected_country + assessment |
emergency_contacts.pdf |
UNHCR offices + legal aid orgs | Country lookup tool result |
rights_summary_card.pdf |
Plain-language rights card | Selected country asylum law |
Pre-filling rules:
- Every pre-filled field is tagged with a visual amber highlight in the PDF
(per
DESIGN.md Β§7 document-item) - No field is pre-filled with data not present in
session.interview - The document generator MUST log every field it fills and its source key
Data Flow: Full Interview β Document
User selects language
β
βΌ
Gradio intake.py β session.language = "fr"
β
βΌ
agent_loop.run("Begin interview in French", session, ...)
β turns: SITUATION β HISTORY β GOALS β REVIEW
β tools: none in this phase
β
βΌ
Events stream back β interview.py renders chat bubbles + structured responders
β
βΌ
session.state = REVIEW β user confirms summary
β
βΌ
agent_loop.run("Assess this case", session, thinking_level="medium")
β
βΌ
tool: web_search("Ethiopia asylum seeker Sudan UNHCR 2026")
tool: country_lookup("Kenya", profile={origin:"Ethiopia", persecution:"political"})
tool: country_lookup("Uganda", ...)
β
βΌ
Events stream β assessment.py renders reasoning trace progressively
β
βΌ
AgentEndEvent carries structured assessment JSON in final message
β
βΌ
session.assessment populated β recommendations.py renders country cards
β
βΌ
User selects country β session.selected_country = "Kenya"
β
βΌ
doc_generator.py generates 4 PDFs from session
β
βΌ
documents.py renders download list + preview
Performance Targets
| Metric | Target | Notes |
|---|---|---|
| First agent token latency | < 3s | From user submission to first streaming character |
| Interview turn round-trip | < 8s | Single question turn on 7B model, Ollama local |
| Assessment completion | < 45s | Includes 2β3 tool calls + full reasoning trace |
| Document generation | < 10s | All 4 PDFs |
| App cold start | < 8s | Gradio only β no subprocess spawn needed |
Security and Privacy Constraints
- No external logging of personal data. Session objects are in-memory only for the hackathon. No database writes, no external analytics calls.
- Tool calls for country data only. The
web_searchtool MUST be scoped to asylum/UNHCR/safety queries. Queries containing personal details from the interview MUST NOT be sent to external search APIs. - Document files are ephemeral. Generated PDFs are stored in a temp directory and deleted after download or session end.
- No PII in logs. Log only state transitions and tool call metadata (not content). Never log interview answers.
Dependency Versions (pinned)
# Python only β no Node.js dependencies
gradio==4.x
weasyprint==62.x
httpx==0.27.x
pytest==8.x
ollama==0.x # LLM calls to local Ollama instance
litellm==1.x # Optional: multi-provider abstraction (Ollama + Modal + Anthropic)
Lock file (requirements.txt) is committed.
Do not upgrade dependencies during a phase without explicit developer approval.