# Project export: HOPE - Humanitarian Operations & Personal Empowerment

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: HOPE turns displaced people from rows in a spreadsheet into a self-healing community — an empathetic AI listens to their needs, matches neighbors' skills to the most vulnerable shelters.
- Devpost: https://devpost.com/software/hope-humanitarian-operations-personal-empowerment
- GitHub: https://github.com/lekhit/HOPE
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Inspiration

The project is addressing an ugly reality of the world: War. The struggle for power between countries brings suffering to the innocent. Unfortunately people are getting displaced all too often, irrespective of their involvement or alignment with the cause. When someone is forced to move out of their habitat, it's hard sustaining even in ideal conditions — and these are far from ideal. To add insult to injury, lives are lost. Sometimes a dear one is lost or missing in action. If he/she is the sole breadwinner, it adds financial strain on top of mental trauma. During ideation we noticed there is a triangle of information flow. Every section wants to help — the displaced, their neighbors, and the NGOs/donors. The real limitation is the lack of organized information. Until now we've used methodical information-gathering strategies, starting with word of mouth (limited reach, and facts distort as they move up the ladder) and online forms (large reach, but limited options — a fixed set of checkboxes dumped into spreadsheets, then aggregated in a way that loses attention to the individual). Fortunately we have two amazing technologies at hand. We can combine the scale of online forms with the individual attention of personal methods. AI makes this possible.

### What it does

HOPE (Humanitarian Operations Platform for Empowerment) replaces rigid intake forms with an empathetic AI that listens, then turns what it hears into action. It closes all three sides of the information triangle: Empathetic intake (information flowing up): Instead of "tick a box," a displaced person simply talks. The AI captures their name, the human-described location of their shelter, their needs (food/shelter/children/medical), and — crucially — their skills, then structures it into clean, queryable data. No more "row 847." Neighbor-to-neighbor matching (information flowing sideways): HOPE matches a person's skills against the most vulnerable nearby needs. An electrician isn't just "registered" — he's pointed to the widow's roof and the water pipe serving 40 people, ranked by human-described proximity and social need. Donor & NGO clarity (information flowing down): A dashboard shows ranked community needs and exactly which gaps neighbors can cover vs. which need funding — so every dollar closes the last gap, not a duplicated one.

### How we built it

The design philosophy was maximum impact, minimum moving parts. Three pieces do the work of a dozen: One conversational AI (MiniMax-M3 via TokenRouter) handles intake, extraction, and the dashboard Q&A — the same empathetic interface serves displaced people and coordinators. One data layer (Redis Stack + RediSearch) is simultaneously the database, the cache, and the semantic search engine — using local BGE-small embeddings so matching runs without external API calls or quota. One thin React/Vite frontend with streaming responses, talking to a small FastAPI backend (/chat, /chat/stream, /intake/save, /dashboard/ask). The elegance is in what we didn't add: no GPS stack, no microservice sprawl, no heavyweight ML pipeline. Each layer earns its place by doing more than one job, and the whole thing runs as a reproducible Docker Compose sandbox. Why human allocation instead of GPS This is a deliberate, field-driven choice. In a war zone, GPS is unreliable — signals are jammed, degraded, or deliberately constrained, and devices may be offline. Worse, displacement shelters and huts are packed so close together that GPS resolution can't tell one from the next — coordinates would point to "somewhere in that cluster of ten families," which is useless for sending the right neighbor to the right door. So HOPE allocates the way humans actually do: by landmarks and relationships — "the blue tent past the water tank," "two rows behind the clinic." The AI captures these human descriptions during intake and reasons over relative proximity and social need instead of latitude/longitude. It's not just a fallback for missing GPS; in a dense camp it's more accurate and more humane than coordinates. Observability: Sentry + Arize (and why they matter) For a tool people may depend on in a crisis, reliability is a feature, so we instrumented HOPE for observability from day one: Sentry (frontend + backend) gives us real-time error and performance monitoring. If an intake fails or an endpoint slows down, we see exactly where and why — stack trace, request, and timing — instead of a frustrated user silently dropping off. In a humanitarian context, a silent failure can mean a need that never gets recorded; Sentry makes those failures loud and fixable. Arize gives us LLM observability — every model call is traced (inputs, outputs, latency, token usage). Because the entire product hinges on an LLM correctly extracting needs and skills, Arize lets us catch hallucinations, drift, and bad extractions, and continuously evaluate prompt quality. It turns "the AI feels off today" into measurable, debuggable data. Together they mean HOPE isn't a black box — it's monitored, measurable, and trustworthy, which is exactly what an aid tool has to be.

### Challenges we ran into

Keeping it simple was the hardest part. The instinct on a humanitarian problem is to build everything. Resisting scope creep — distilling the triangle of information flow into three clean features and one conversation — took more discipline than code. Every feature had to justify itself against "does a person in a tent actually benefit from this?" Latency. Blocking LLM calls made /intake/save and the dashboard feel sluggish (5–11s of dead air). We re-architected around a streaming endpoint that strips the model's internal reasoning on the fly and emits tokens progressively, so responses appear word-by-word. We couldn't reduce raw generation time, but we made the wait feel instant — critical when your user has limited patience and limited battery. Designing Redis for many communities and for scale. A single camp is easy; the real question is how does one index serve dozens of communities without cross-contamination? We designed the schema so each community is namespaced/partitioned, and matching queries are scoped to a community before semantic search runs — so an electrician in one camp is never matched to a roof in another. Because Redis Stack handles vector search, caching, and storage in one engine, scaling out means scaling one component, not four. Connecting the community. Deciding how neighbors actually link up — who sees whose need, how a match is proposed, how trust is preserved — was a genuine product problem. We anchored it on the human-proximity model so connections feel natural and local, not algorithmic and anonymous. Conveying what is needed to the community. A need in a database is worthless if the right person can't understand it. We focused on translating structured gaps back into plain, human, actionable asks ("the widow two tents over needs her roof patched before the rain"), so the loop from need → match → action closes in language people actually use.

### Accomplishments we're proud of

A working, deployed full stack — not a slide deck. A genuinely elegant, minimal architecture where every layer does double duty. A field-realistic human allocation model that works precisely where GPS fails. Full observability (Sentry + Arize) baked in from the start — reliability treated as a first-class feature. Reframing displaced people from "recipients in a spreadsheet" to active contributors — a self-healing community.

### What we learned

Empathy is an interface. Letting people speak instead of filling forms captures richer, truer data — including skills nobody thinks to ask about. Constraints breed better design. Dropping GPS for human landmarks wasn't a compromise — it was the right answer for the environment. Simplicity is engineering, not laziness. The discipline to keep the app small is what makes it deployable and understandable. Stream everything when LLMs are in the loop; perceived latency is latency. You can't fix what you can't see — Sentry and Arize turned vague worries into concrete, actionable signals.

### What's next

for HOPE Voice-first intake in the field, offline-tolerant, in local languages. Smarter human-proximity routing that learns landmark vocabulary per community. Multi-community dashboards for NGOs coordinating across camps. Donor funding loop so a coordinator can fund a matched task directly. Pilot with a partner organization using anonymized, consented data.

## README (from the GitHub repository)

# 🕊️ HOPE — Humanitarian Operations & Personal Empowerment

> Turning war's survivors into its rebuilders. An empathetic, multimodal AI that
> *sees* the damage and *hears* the skills — replacing traumatic paper forms with
> a 3-minute conversation, and turning chaos into allocatable data for every ministry.

Built for the UC Berkeley AI Hackathon 2026 — Social Impact track + Fetch.ai ASI:One Agent challenge.

## The problem
Existing humanitarian needs-assessments rely on forms that displaced, traumatized
people cannot fill accurately. Two data points decide whether a settlement recovers:
**what was destroyed**, and **what skills the community can rebuild with**. Forms
capture neither well. HOPE collects both through empathetic AI — then turns them
into action.

## What it does
1. **Empathetic intake** (voice + vision) — people *speak* and *show* their situation
   - Voice: Deepgram Voice Agent (STT + TTS), brain = MiniMax-M3 via TokenRouter
   - Vision: damage photos → structured assessment
2. **Store & index** — Redis vector search over every record
3. **Ministry dashboards** — analyst agents brief Housing / Health / Labor / Education
   on real UNHCR data + live intake
4. **Livelihood Advisor (Fetch.ai / ASI:One)** — "How can I make a living?" →
   fuses community needs + **live gov/NGO programs (Browserbase)** → ranked options

## Architecture
```
Mobile PWA ─► Backend (FastAPI)
                ├─ MiniMax-M3 (TokenRouter)  empathetic chat + reasoning
                ├─ Deepgram Voice Agent       full-duplex voice
                ├─ Redis vector index         store + semantic search
                ├─ Ministry analyst agents     allocation briefs
                └─ Livelihood Advisor (uAgent) ─► ASI:One Chat Protocol
                       ├─ Browserbase          live policy research
                       └─ Redis index          community needs
```

## Tech & sponsors
TokenRouter (MiniMax-M3) · Deepgram (Voice Agent) · Redis · **Fetch.ai / ASI:One** ·
**Browserbase** · Real UNHCR / IOM open data

## Run (Docker)
```bash
cp .env.example .env   # add keys
docker compose up --build
docker compose exec backend python -m app.seed
```
App → :5173 · API → :8000/docs · RedisInsight → :8001

## Fetch.ai deliverables
- Agent: `fetch_agent/` — ASI:One Chat Protocol compliant Livelihood Advisor
- Agent README: `fetch_agent/README.md`
- Agent address: `agent1q0aehg4ch8vr0gk6sf44wnd0df08wk0qxewe9gt233r5cc5flg3mvhhtm4q`

## License
MIT


## Detected evidence (automated analysis)

Indexed codebase: 30 recognized source files, 79 KB.
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- Docker (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (38 of 38)

```
.env.example
.gitignore
backend/app/__init__.py
backend/app/agents/__init__.py
backend/app/agents/fetch_agent.py
backend/app/agents/livelihood.py
backend/app/agents/ministry.py
backend/app/llm.py
backend/app/main.py
backend/app/matching.py
backend/app/research.py
backend/app/routes/__init__.py
backend/app/routes/agent.py
backend/app/routes/dashboard.py
backend/app/routes/livelihood.py
backend/app/routes/llm_proxy.py
backend/app/routes/voice.py
backend/app/seed.py
backend/app/store.py
backend/Dockerfile
backend/requirements.txt
docker-compose.yml
fetch_agent/Dockerfile
fetch_agent/README.md
fetch_agent/requirements.txt
frontend/Dockerfile
frontend/index.html
frontend/package.json
frontend/src/App.jsx
frontend/src/Contribute.jsx
frontend/src/Dashboard.jsx
frontend/src/HelpOut.jsx
frontend/src/main.jsx
frontend/src/Shell.jsx
frontend/src/theme.js
frontend/src/voiceAgent.js
frontend/vite.config.js
README.md
```

### Dependencies

- backend/requirements.txt: deepgram-sdk@==3.5.0, fastapi@==0.111.0, fastembed@==0.3.6, httpx@==0.27.0, numpy@==1.26.4, openai@==1.40.0, pydantic@==2.8.2, python-multipart@==0.0.9, redis@==5.0.7, uvicorn[standard]@==0.30.1, websockets@==12.0
- fetch_agent/requirements.txt: fastembed@==0.3.6, httpx@==0.27.0, numpy@==1.26.4, openai@==1.40.0, redis@==5.0.7, uagents@==0.22.5
- frontend/package.json: @vitejs/plugin-react@^4.3.1, react@^18.3.1, react-dom@^18.3.1, vite@^5.3.1

### Recent commits (newest first)

- Fix live Deepgram voice agent: websockets extra_headers (v12) + needs-focused prompt + Talk-live UI; pin websockets
- Add community resource-sharing matcher: commute-aware reach, social-priority, load balancing + redirect; donor coverage view
- Redesign: needs-first mobile intake, NGO query dashboard, warm hopeful theme
- HOPE: multimodal war-recovery intake + ASI:One livelihood agent

## Key source files (fetched from GitHub, selected and truncated for size)

### docker-compose.yml

```yaml
services:
  redis:
    image: redis/redis-stack:latest   # includes RediSearch for vector index
    ports:
      - "6379:6379"
      - "8001:8001"                    # RedisInsight UI
    volumes:
      - redis_data:/data

  backend:
    build: ./backend
    env_file: .env
    ports:
      - "8000:8000"
    volumes:
      - ./backend:/app
      - ./data:/data
    depends_on:
      - redis
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  frontend:
    build: ./frontend
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    command: npm run dev -- --host 0.0.0.0
    depends_on:
      - backend

  fetch-agent:
    build: ./fetch_agent
    env_file: .env
    ports:
      - "8100:8100"
    volumes:
      - ./backend:/app
      - ./data:/data
    depends_on:
      - redis
    command: python -m app.agents.fetch_agent

volumes:
  redis_data:
```

### fetch_agent/requirements.txt

```
uagents==0.22.5
openai==1.40.0
redis==5.0.7
httpx==0.27.0
fastembed==0.3.6
numpy==1.26.4

```

### frontend/Dockerfile

```
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

```

### backend/requirements.txt

```
fastapi==0.111.0
uvicorn[standard]==0.30.1
openai==1.40.0
redis==5.0.7
python-multipart==0.0.9
httpx==0.27.0
pydantic==2.8.2
deepgram-sdk==3.5.0
numpy==1.26.4
fastembed==0.3.6
websockets==12.0  # pinned: 12.x uses extra_headers in agent.py bridge; do not bump to 14+ without switching to additional_headers
```

### fetch_agent/Dockerfile

```
FROM python:3.11-slim
WORKDIR /app
ENV PIP_DEFAULT_TIMEOUT=180 PIP_RETRIES=10
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 180 --retries 10 -r requirements.txt
CMD ["python", "-m", "app.agents.fetch_agent"]

```

### frontend/package.json

```
{
  "name": "atlas-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.1",
    "vite": "^5.3.1"
  }
}
```

### backend/Dockerfile

```
FROM python:3.11-slim
WORKDIR /app
ENV PIP_DEFAULT_TIMEOUT=180 PIP_RETRIES=10
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 180 --retries 10 -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

```

### frontend/src/main.jsx

```javascript
// HOPE entry
import React from 'react'
import * as ReactDOM from 'react-dom/client'
import Shell from './Shell.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(<Shell />)

```

### frontend/src/App.jsx

```javascript
import React from 'react'
import * as Th from './theme.js'
import * as VA from './voiceAgent.js'
const useState = React.useState
const useRef = React.useRef
const useEffect = React.useEffect
const T = Th.default
const VoiceAgent = VA.VoiceAgent
const API = '/api'

const GREETING = "Peace be upon you. I'm HOPE, and I'm here to listen — take all the time you need. To start, how are you and your family doing today?"
const CHIPS = ["We need food", "Our shelter is damaged", "The water isn't safe", "We need medicine", "It's getting cold"]

function Bubble({ role, text }) {
  const me = role === 'user'
  return (
    <div style={{ display:'flex', justifyContent: me?'flex-end':'flex-start', margin:'6px 0' }}>
      <div style={{ maxWidth:'82%', padding:'11px 14px', borderRadius:16,
        borderBottomRightRadius: me?4:16, borderBottomLeftRadius: me?16:4,
        background: me?T.bubbleUser:T.bubbleHope, color: me?'#fff':T.ink,
        fontSize:15, lineHeight:1.45, boxShadow:T.shadow, whiteSpace:'pre-wrap' }}>{text}</div>
    </div>
  )
}

function NeedsCard({ data }) {
  if (!data) return null
  return (
    <div style={{ background:T.card, borderRadius:T.radius, padding:16, boxShadow:T.shadow, margin:'10px 0', border:'1px solid '+T.line }}>
      <div style={{ fontWeight:700, color:T.primaryDk, fontSize:16, marginBottom:6 }}>✅ Thank you — we heard you</div>
      {data.summary && <div style={{ color:T.sub, fontSize:14, marginBottom:10 }}>{data.summary}</div>}
      {(data.needs||[]).map((n,i)=>{
        const u = Th.URG[n.urgency] || Th.URG.moderate
        return <div key={i} style={{ display:'flex', alignItems:'center', gap:8, padding:'7px 0', borderTop: i?'1px solid '+T.line:'none' }}>
          <span style={{ fontSize:14 }}>{(Th.CAT[n.category]||n.category)}</span>
          <span style={{ flex:1, color:T.ink, fontSize:14 }}>{n.detail}</span>
          <span style={{ background:u.bg, color:u.c, fontSize:11, fontWeight:700, padding:'3px 9px', borderRadius:20 }}>{u.label}</span>
        </div>
      })}
      {(data.skills||[]).length>0 && <div style={{ marginTop:10, fontSize:13, color:T.sub }}>
        🛠️ You can help with: <b style={{ color:T.primaryDk }}>{data.skills.join(', ')}</b></div>}
      <div style={{ marginTop:12, background:T.primarySoft, color:T.primaryDk, fontSize:13, padding:'10px 12px', borderRadius:12 }}>
        Your needs have been shared with the local aid team. You are not alone. 🕊️</div>
    </div>
  )
}

export default function Intake() {
  const [history, setHistory] = useState([{ role:'assistant', content: GREETING }])
  const [input, setInput] = useState('')
  const [busy, setBusy] = useState(false)
  const [saved, setSaved] = useState(null)
  const [saving, setSaving] = useState(false)
  // live voice-agent state
  const [callState, setCallState] = useState('idle') // idle | connecting | live | speaking | listening
  const [agentTalking, setAgentTalking] = useState(false)
  const scroller = useRef(null)
  const agentRef = useRef(null)
  const histRef = useRef(history)
  useEffect(()=>{ histRef.current = history }, [history])
  useEffect(()=>{ if(scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight }, [history, saved, callState])
  useEffect(()=>()=>{ try{ agentRef.current && agentRef.current.stop() }catch(e){} }, [])

  async function send(text) {
    const msg = (text || input).trim()
    if (!msg || busy) return
    setInput(''); setSaved(null)
    const next = [...history, { role:'user', content: msg }]
    setHistory(next); setBusy(true)
    try {
      const r = await fetch(API+'/chat', { method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ history: next.slice(0,-1), message: msg }) })
      const j = await r.json()
      setHistory([...next, { role:'assistant', content: j.reply || '...' }])
    } catch(e) { setHistory([...next, { role:'assistant', content:"I'm having trouble connecting, but I'm still here." }]) }
    setBusy(false)
  }

  function onAgentEvent(ev){
    const t = ev && ev.type
    if (t === 'Started') setCallState('live')
    else if (t === 'Closed') setCallState('idle')
    else if (t === 'UserStartedSpeaking') { setAgentTalking(false); setCallState('listening') }
    else if (t === 'AgentStartedSpeaking') { setAgentTalking(true); setCallState('speaking') }
    else if (t === 'AgentAudioDone') { setAgentTalking(false); setCallState('live') }
    else if (t === 'ConversationText' && ev.content) {
      const role = ev.role === 'assistant' ? 'assistant' : 'user'
      setHistory(h => [...h, { role, content: ev.content }])
    }
  }

  async function startCall(){
    if (callState !== 'idle') return
    setCallState('connecting'); setSaved(null)
    try {
      const a = new VoiceAgent(onAgentEvent)
      agentRef.current = a
      await a.start()
    } catch(e) {
      setCallState('idle')
      alert('Please allow microphone access to talk live with HOPE.')
    }
  }

  async function endCall(){
    try{ agentRef.current && agentRef.current.stop() }catch(e){}
    agentRef.current = null
    setCallState('idle'); setAgentTalking(false)
    // auto-extract needs from the spoken conversation
    if (histRef.current.length >= 3) { await finish() }
  }

  async function finish() {
    setSaving(true)
    try {
      const r = await fetch(API+'/intake/save', { method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({ history: histRef.current }) })
      setSaved(await r.json())
    } catch(e) { setSaved({ needs:[], summary:'Saved locally.' }) }
    setSaving(false)
  }

  const inCall = callState !== 'idle'
  const stateLabel = { connecting:'Connecting…', live:'Listening…', listening:'I’m listening…', speaking:'HOPE is speaking…' }[callState] || ''

  return (
    <div style={{ maxWidth:480, margin:'0 auto', height:'100%', display:'flex', flexDirection:'column' }}>
      <div style={{ padding:'14px 16px 10px', textAlign:'center' }}>
        <div style={
[truncated — 3552 more characters]
```

### backend/app/main.py

```python
from fastapi import FastAPI, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from .llm import chat, read_damage, embed, caption_image
from .store import ensure_index, save, search, list_all
from . import matching
from .routes.dashboard import router as dashboard_router
from .routes.voice import router as voice_router
from .routes.agent import router as agent_router
from .routes.llm_proxy import router as llm_proxy_router
from .routes.livelihood import router as livelihood_router

app = FastAPI(title="HOPE API")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.include_router(dashboard_router)
app.include_router(voice_router)
app.include_router(agent_router)
app.include_router(llm_proxy_router)
app.include_router(livelihood_router)

# Single deployment locality for this instance.
LOCALITY = "Aleppo, Syria"

NEED_CATEGORIES = ["food", "water", "shelter", "medical", "hygiene", "safety", "children", "energy", "other"]

SYSTEM = (
    "You are HOPE, a warm and caring human companion for people living through "
    "displacement and conflict. You are NOT a form and NOT a survey. You speak "
    "like a kind person sitting beside them with a cup of tea.\n"
    "Your purpose is to gently understand their most pressing BASIC NEEDS right "
    "now: feeding their family, clean and safe drinking water, shelter and "
    "protection from weather (for example a hole in their tent), heating or "
    "cooling, medical care and medicine, hygiene and sanitation, safety, and "
    "care for children, elderly or people who are sick.\n"
    "Rules: Acknowledge their feelings first, briefly and sincerely. Ask only "
    "ONE simple question at a time. Never re-traumatize, never interrogate, "
    "never ask about documents or blame. Keep every reply short (2 to 4 warm "
    "sentences) ending in a single gentle question.\n"
    "As the conversation flows naturally and they feel heard, also learn what "
    "they are GOOD AT or used to do for work (a teacher, electrician, nurse, "
    "baker, someone strong who can carry water). Frame it hopefully, as a way "
    "their gifts could help their community heal. Do not force it.\n"
    "Always respond with plain, comforting words. Do not output JSON to the user."
)


@app.on_event("startup")
def _startup():
    ensure_index()


class Turn(BaseModel):
    history: list = []
    message: str


@app.post("/chat")
def chat_ep(t: Turn):
    msgs = [{"role": "system", "content": SYSTEM}] + t.history + [
        {"role": "user", "content": t.message}]
    return {"reply": chat(msgs)}


# ---------------------------------------------------------------------------
# Intake -> structured needs + skills, saved into the shared index so the
# NGO dashboard can aggregate the pressing needs of this locality.
# ---------------------------------------------------------------------------
import json as _json, os as _os2, re as _re

_EXTRACT = (
    "You read a caring conversation between HOPE and a displaced person. "
    "Extract ONLY what was actually said. Return STRICT JSON, no prose, with keys:\n"
    '{"name": string or null, "household": {"adults": int or null, "children": int or null}, '
    '"needs": [{"category": one of ' + str(NEED_CATEGORIES) + ', "detail": short string, '
    '"urgency": one of ["critical","high","moderate","low"]}], '
    '"skills": [short strings], "summary": one warm sentence}\n'
    "If something was not mentioned, omit it or use null/empty. Do not invent."
)


def _safe_json(txt):
    try:
        m = _re.search(r"\{.*\}", txt, _re.S)
        return _json.loads(m.group(0)) if m else {}
    except Exception:
        return {}


class Conversation(BaseModel):
    history: list = []
    region: str = LOCALITY


@app.post("/intake/save")
def intake_save(c: Conversation):
    convo = "\n".join(
        ("Person: " if m.get("role") == "user" else "HOPE: ") + str(m.get("content", ""))
        for m in c.history)
    data = _safe_json(chat([
        {"role": "system", "content": _EXTRACT},
        {"role": "user", "content": convo}]))
    needs = data.get("needs", []) or []
    skills = data.get("skills", []) or []
    saved_ids = []
    for n in needs:
        cat = n.get("category", "other")
        detail = n.get("detail", "")
        urg = n.get("urgency", "moderate")
        text = f"[{cat}/{urg}] {detail}"
        rec = {"text": text, "category": cat, "region": c.region,
               "urgency": urg, "skills": skills, "kind": "need"}
        try:
            saved_ids.append(save(rec, embed(text or cat)))
        except Exception:
            pass
    if skills:
        stext = "Skills offered: " + ", ".join(skills)
        try:
            save({"text": stext, "category": "skills", "region": c.region,
                  "skills": skills, "kind": "contribution"}, embed(stext))
        except Exception:
            pass
    return {"saved": len(saved_ids), "needs": needs, "skills": skills,
            "household": data.get("household", {}), "summary": data.get("summary", ""),
            "name": data.get("name")}


# ---------------------------------------------------------------------------
# NGO dashboard: aggregate pressing needs for this locality + ask the agent.
# ---------------------------------------------------------------------------
_URG_WEIGHT = {"critical": 4, "high": 3, "moderate": 2, "low": 1}


@app.get("/dashboard/needs")
def dashboard_needs():
    recs = [r for r in list_all() if r.get("kind") == "need"]
    cats = {}
    for r in recs:
        c = r.get("category", "other")
        d = cats.setdefault(c, {"category": c, "count": 0, "score": 0,
                                "critical": 0, "high": 0, "samples": []})
        d["count"] += 1
        u = r.get("urgency", "moderate")
        d["score"] += _URG_WEIGHT.get(u, 2)
        if u == "critical":
            d["critical"] += 1
        if u == "high":
            d["high"] += 
[truncated — 3956 more characters]
```

[21 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]