sidewalk-fm / app.py
Joshua-Abok's picture
Wire extract_swahili into parse_transaction (Swahili text now logs end-to-end)
3171841
Raw
History Blame Contribute Delete
29.6 kB
#!/usr/bin/env python3
"""
Sidewalk FM β€” Small Trader Ledger & Advisor
HuggingFace Build Small Hackathon 2026 β€” Backyard AI track
Architecture:
1. Extraction: regex/rules first β†’ NVIDIA NIM backup (nemotron-mini-4b-instruct)
2. Math: pure Python β€” never the LLM (deterministic)
3. Advisory: Modal vLLM (nemotron-mini-4b-instruct) β†’ deterministic fallback
Fully NVIDIA stack. All models ≀4B β†’ Tiny Titan eligible.
Advisory served on Modal for runtime integration β†’ Modal prize eligible.
Models:
- Extraction backup: nvidia/nemotron-mini-4b-instruct (4B, NIM)
- Advisory: nvidia/nemotron-mini-4b-instruct (4B, Modal vLLM)
"""
import os
import re
import json
import math
import logging
import base64
from datetime import datetime
from typing import Dict, List, Optional
# --- Config ---------------------------------------------------------------
NIM_API_KEY = os.environ.get("NVIDIA_NIM_API_KEY", "")
NIM_BASE = "https://integrate.api.nvidia.com/v1"
# Modal advisory endpoint (rebuild per P1)
MODAL_BASE = os.environ.get("MODAL_BASE", "")
# e.g. https://joshua-abok--sidewalk-fm-advisor-serve.modal.run/v1
EXTRACTION_MODEL = "nvidia/nemotron-mini-4b-instruct"
ADVISORY_MODEL = "nvidia/nemotron-mini-4b-instruct"
# Ledger state
ledger: List[Dict] = []
# Structured agent traces (πŸ“‘ badge)
agent_traces: List[Dict] = []
def add_trace(step: str, **kwargs):
"""Log a structured agent trace."""
trace = {"step": step, "ts": datetime.now().isoformat(), **kwargs}
agent_traces.append(trace)
# --- Extraction: Deterministic First, LLM Backup --------------------------
TRANSACTION_PATTERNS = [
re.compile(r'(?i)(?:bought|purchased|got)\s+(\d+(?:\.\d+)?)\s+(\w[\w\s]*?)\s+(?:at|for)\s+(\d+(?:\.\d+)?)\s*(\w*)?'),
re.compile(r'(?i)(?:sold|gave|exchanged)\s+(\d+(?:\.\d+)?)\s+(\w[\w\s]*?)\s+(?:at|for)\s+(\d+(?:\.\d+)?)\s*(\w*)?'),
re.compile(r'(?i)(?:return|returned|brought back)\s+(\d+(?:\.\d+)?)\s+(\w[\w\s]*?)'),
re.compile(r'(?i)(?:bought|purchased)\s+(\d+(?:\.\d+)?)\s+(\w[\w\s]*?)\s+(?:at|for)\s+(\d+(?:\.\d+)?)'),
re.compile(r'(\d+(?:\.\d+)?)\s+(\w[\w\s]*?)\s+at\s+(\d+(?:\.\d+)?)'),
]
BUY_PATTERN = re.compile(r'(?i)(?:bought|purchased|got|purchase|buy)\b')
SELL_PATTERN = re.compile(r'(?i)(?:sold|gave|exchanged|sell)\b')
RETURN_PATTERN = re.compile(r'(?i)(?:return|returned|brought back)\b')
REFUND_PATTERN = re.compile(r'(?i)(?:refund|got money back)\b')
def extract_deterministic(text: str) -> Optional[Dict]:
"""Extract structured transaction using regex only."""
text_stripped = text.strip()
if RETURN_PATTERN.search(text_stripped) or REFUND_PATTERN.search(text_stripped):
action = "return"
elif BUY_PATTERN.search(text_stripped):
action = "buy"
elif SELL_PATTERN.search(text_stripped):
action = "sell"
else:
action = "buy"
for pattern in TRANSACTION_PATTERNS:
m = pattern.search(text_stripped)
if m:
groups = m.groups()
if len(groups) >= 3:
try:
qty = float(groups[0])
except (ValueError, IndexError):
continue
item = groups[1].strip()
item = re.sub(r'\s+(and|or|but|for|with|from)\s*$', '', item, flags=re.I).strip()
try:
price = float(groups[2])
except (ValueError, IndexError):
continue
currency = ""
vendor = ""
if len(groups) > 3 and groups[3]:
cc = groups[3].strip()
currency_map = {
"shilling": "KES", "shillings": "KES", "sh": "KES", "kes": "KES", "kenya": "KES",
"dollar": "USD", "dollars": "USD", "$": "USD", "usd": "USD", "us dollars": "USD",
"naira": "NGN", "ngn": "NGN", "nigerian": "NGN",
"peso": "MXN", "pesos": "MXN", "mxn": "MXN",
"rupee": "INR", "rupees": "INR", "inr": "INR",
"euro": "EUR", "eur": "EUR",
"pound": "GBP", "gbp": "GBP", "sterling": "GBP",
"cfa": "XOF", "yen": "JPY", "jpy": "JPY",
"rand": "ZAR", "zar": "ZAR",
}
currency = currency_map.get(cc.lower(), cc.upper()) if cc.lower() in currency_map else ("KES" if cc.lower() in currency_map else "")
from_match = re.search(r'from\s+(\w[\w\s]{0,30}?)(?:\s+(?:at|for|and|but|with|$))', text_stripped)
if from_match:
vendor = from_match.group(1).strip()
return {
"action": action, "item": item,
"quantity": round(qty, 2), "unit_price": round(price, 2),
"currency": currency if currency else "KES",
"vendor": vendor if vendor else None,
}
return None
# --- Swahili (Kiswahili) deterministic extraction ------------------------
# Spoken Swahili order is action β†’ item β†’ qty β†’ "kwa" β†’ price, with number WORDS
# (hamsini=50, "mia mbili"=200). Lets a Kiswahili voice note log end-to-end.
_SW_UNITS = {"sifuri": 0, "moja": 1, "mbili": 2, "tatu": 3, "nne": 4, "tano": 5,
"sita": 6, "saba": 7, "nane": 8, "tisa": 9, "kumi": 10}
_SW_TENS = {"ishirini": 20, "thelathini": 30, "arobaini": 40, "hamsini": 50,
"sitini": 60, "sabini": 70, "themanini": 80, "tisini": 90}
_SW_SCALE = {"mia": 100, "elfu": 1000, "laki": 100000, "milioni": 1000000}
_SW_NUMWORDS = set(_SW_UNITS) | set(_SW_TENS) | set(_SW_SCALE) | {"na"}
_SW_STOP = {"na", "kwa", "kila", "ya", "za", "wa", "la", "ni", "kwenye"}
_SW_VERB_STEMS = ("nunua", "uza", "rudish")
def _sw_words_to_int(tokens):
"""Compose Swahili number words into an int (e.g. ['mia','mbili']β†’200)."""
total = 0
i, n = 0, len(tokens)
seen = False
while i < n:
t = tokens[i]
if t == "na":
i += 1
continue
if t in _SW_SCALE:
mult = 1
if i + 1 < n and tokens[i + 1] in _SW_UNITS and _SW_UNITS[tokens[i + 1]] <= 9:
mult = _SW_UNITS[tokens[i + 1]]
i += 1
total += _SW_SCALE[t] * mult
seen = True
elif t in _SW_TENS:
total += _SW_TENS[t]
seen = True
elif t in _SW_UNITS:
total += _SW_UNITS[t]
seen = True
else:
break
i += 1
return total if seen else None
def _grab_number(tokens):
"""Digits win; else the first contiguous run of Swahili number words."""
for tk in tokens:
if re.fullmatch(r"\d+(?:\.\d+)?", tk):
return float(tk)
run, started = [], False
for w in tokens:
if w in _SW_NUMWORDS:
run.append(w)
started = True
elif started:
break
v = _sw_words_to_int(run)
return float(v) if v is not None else None
def extract_swahili(text: str) -> Optional[Dict]:
"""Deterministic Kiswahili extractor. Returns None if not Swahili / not parseable."""
t = text.lower().strip()
if "rudish" in t:
action = "return"
elif "uza" in t:
action = "sell"
elif "nunua" in t:
action = "buy"
else:
return None
tokens = re.findall(r"[a-z]+|\d+(?:\.\d+)?", t)
if "kwa" in tokens:
k = tokens.index("kwa")
left, right = tokens[:k], tokens[k + 1:]
else:
left, right = tokens, []
qty = _grab_number(left)
price = _grab_number(right) if right else None
item_words = [
w for w in left
if w.isalpha() and w not in _SW_NUMWORDS and w not in _SW_STOP
and not any(stem in w for stem in _SW_VERB_STEMS)
]
item = " ".join(item_words[:3]).strip()
if qty is None or not item:
return None
return {
"action": action, "item": item,
"quantity": round(qty, 2), "unit_price": round(price if price else 0.0, 2),
"currency": "KES", "vendor": None,
}
def extract_via_llm(text: str) -> Optional[Dict]:
"""Fallback: use NIM nemotron-mini-4b-instruct for messy input."""
if not NIM_API_KEY:
return None
try:
import httpx
headers = {"Authorization": f"Bearer {NIM_API_KEY}", "Content-Type": "application/json"}
payload = {
"model": EXTRACTION_MODEL,
"messages": [
{"role": "system", "content": "You are a transaction parser for a small street trader. Extract action (buy/sell/return/refund), item (string), quantity (number), unit_price (number), currency (string), vendor (string, optional). Return ONLY valid JSON. No markdown fences."},
{"role": "user", "content": text},
],
"max_tokens": 256, "temperature": 0.0,
}
with httpx.Client(timeout=30) as client:
resp = client.post(f"{NIM_BASE}/chat/completions", headers=headers, json=payload)
resp.raise_for_status()
raw = resp.json()["choices"][0]["message"]["content"]
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("\n", 1)[0]
return json.loads(raw)
except Exception:
return None
def parse_transaction(text: str) -> Dict:
"""Strategy: regex first (fast, reliable) β†’ LLM backup (handles messy input)."""
result = extract_deterministic(text)
if result:
add_trace("extraction", method="regex-en", success=True, fields=list(result.keys()))
return result
add_trace("extraction", method="regex-en", success=False)
# Kiswahili deterministic extractor (action→item→qty→kwa→price, number words).
result = extract_swahili(text)
if result:
add_trace("extraction", method="regex-sw", success=True, fields=list(result.keys()))
return result
add_trace("extraction", method="regex-sw", success=False)
result = extract_via_llm(text)
if result and "error" not in result:
add_trace("extraction", method="llm", success=True, fields=list(result.keys()))
return result
add_trace("extraction", method="llm", success=False)
return {"error": "Could not parse transaction. Try: 'bought 10 mangoes at 200 shillings'"}
# --- Ledger Operations ----------------------------------------------------
def validate_transaction(tx: Dict) -> Optional[str]:
"""Validate transaction against hard rules."""
if "error" in tx:
return tx["error"]
action = tx.get("action", "").lower()
if action not in ("buy", "sell", "return", "refund"):
return f"Unknown action: '{action}'. Must be buy/sell/return/refund."
qty = tx.get("quantity", 0)
if not isinstance(qty, (int, float)) or qty <= 0:
return f"Quantity must be a positive number, got: {qty}"
price = tx.get("unit_price", 0)
if not isinstance(price, (int, float)) or price < 0:
return f"Price must be zero or positive, got: {price}"
if not tx.get("item", "").strip():
return "Item name cannot be empty."
return None
def add_entry(entry: Dict) -> Dict:
"""Add a validated entry to the ledger."""
entry["timestamp"] = datetime.now().isoformat()
entry["id"] = len(ledger) + 1
ledger.append(entry)
return entry
def calculate_margins() -> Dict:
"""Calculate current P&L from ledger. Pure Python β€” never the LLM."""
revenue = 0.0
costs = 0.0
inventory: Dict[str, float] = {}
daily_stats: Dict[str, Dict] = {}
for entry in ledger:
action = entry.get("action", "").lower()
item = str(entry.get("item", "unknown"))
qty = float(entry.get("quantity", 0))
price = float(entry.get("unit_price", 0))
ts = entry.get("timestamp", "")
day = ts[:10] if ts else "unknown"
if day not in daily_stats:
daily_stats[day] = {"revenue": 0, "costs": 0, "transactions": 0}
daily_stats[day]["transactions"] += 1
if action in ("buy", "purchase"):
costs += qty * price
inventory[item] = inventory.get(item, 0) + qty
elif action in ("sell", "sale"):
revenue += qty * price
inventory[item] = inventory.get(item, 0) - qty
elif action in ("return", "refund"):
if entry.get("action", "").lower() == "return":
costs -= qty * price
inventory[item] = inventory.get(item, 0) + qty
else:
revenue -= qty * price
inventory[item] = inventory.get(item, 0) + qty
profit = revenue - costs
margin_pct = (profit / revenue * 100) if revenue > 0 else 0.0
item_revenue: Dict[str, float] = {}
for entry in ledger:
item = str(entry.get("item", "unknown"))
total = float(entry.get("quantity", 0)) * float(entry.get("unit_price", 0))
if entry.get("action", "").lower() in ("sell", "sale"):
item_revenue[item] = item_revenue.get(item, 0) + total
top_items = sorted(item_revenue.items(), key=lambda x: x[1], reverse=True)[:5]
return {
"revenue": round(revenue, 2), "costs": round(costs, 2),
"profit": round(profit, 2), "margin_pct": round(margin_pct, 1),
"transaction_count": len(ledger),
"inventory": {k: round(v, 1) for k, v in sorted(inventory.items())},
"top_items": top_items, "days_active": len(daily_stats), "daily_stats": daily_stats,
}
# --- Voice Input: Nemotron ASR 0.6B (gated β€” never breaks demo) ----------
# --- Zero GPU ASR: run our fine-tuned Swahili wav2vec2 + Whisper in-Space --------
# HF Inference (serverless) no longer serves these ASR models, so we run them on the
# Space's Zero GPU. `spaces` is only present on HF; import is guarded so local runs
# (and non-GPU hardware) still import the module without error.
try:
import spaces
_HAS_SPACES = True
except Exception:
_HAS_SPACES = False
_ASR_PIPES: Dict[str, object] = {}
def _get_asr_pipe(model_id: str):
"""Lazily build + cache a transformers ASR pipeline (loads on CPU; moved to GPU
inside the Zero GPU call)."""
if model_id not in _ASR_PIPES:
from transformers import pipeline
_ASR_PIPES[model_id] = pipeline("automatic-speech-recognition", model=model_id)
return _ASR_PIPES[model_id]
def _run_asr(audio_path: str, model_id: str, language: Optional[str] = None) -> str:
pipe = _get_asr_pipe(model_id)
try:
pipe.model.to("cuda")
except Exception:
pass
kwargs = {}
# Pin Whisper to the chosen language so it can't mis-detect Swahili as French.
if "whisper" in model_id.lower() and language in ("sw", "en"):
kwargs["generate_kwargs"] = {
"language": {"sw": "swahili", "en": "english"}[language],
"task": "transcribe",
}
out = pipe(audio_path, **kwargs)
return (out.get("text") if isinstance(out, dict) else str(out)).strip()
# Decorate for Zero GPU when running on HF (no-op otherwise).
if _HAS_SPACES:
_run_asr = spaces.GPU(duration=300)(_run_asr)
def transcribe_audio(audio_path: str, language: str = "auto") -> Optional[str]:
"""Transcribe in-Space on Zero GPU, routed by language. Gated β€” never breaks the demo.
Swahili uses our own fine-tuned, published model (Well-Tuned); English/auto uses
Whisper. Both run on the Space's Zero GPU. Any failure degrades to 'type instead'.
"""
if not audio_path:
return None
SW_MODEL = os.environ.get(
"ASR_SW_MODEL", "Joshua-Abok/finetuning-wav2vec-large-swahili-asr-model_v12")
EN_MODEL = os.environ.get("ASR_EN_MODEL", "openai/whisper-small") # fast on a GPU slice
if language == "sw":
order = [SW_MODEL, EN_MODEL]
elif language == "en":
order = [EN_MODEL]
else: # auto
order = [EN_MODEL, SW_MODEL]
hint = language if language in ("sw", "en") else None
for model in order:
try:
text = _run_asr(audio_path, model, hint)
if text:
add_trace("asr", method=model, success=True)
return text
except Exception as e:
add_trace("asr", method=model, success=False, error=str(e)[:80])
continue
return None
def generate_speech(text: str) -> Optional[bytes]:
"""Generate speech via NVIDIA Nemotron TTS. Fails silently."""
if not text or not NIM_API_KEY:
return None
try:
import httpx
headers = {"Authorization": f"Bearer {NIM_API_KEY}", "Content-Type": "application/json"}
payload = {"model": "nvidia/nemotron-tts-1b", "text": text}
with httpx.Client(timeout=30) as client:
resp = client.post(f"{NIM_BASE}/tts/generate", headers=headers, json=payload)
resp.raise_for_status()
add_trace("tts", method="nemotron-tts-1b", success=True)
return resp.content
except Exception as e:
add_trace("tts", method="nemotron-tts-1b", success=False, error=str(e)[:80])
return None
# --- Advisory -------------------------------------------------------------
def get_deterministic_advice() -> str:
"""Rule-based advisory from ledger numbers."""
m = calculate_margins()
parts = []
if m["transaction_count"] == 0:
return "πŸ“Š Add your first transaction to get started! Example: 'bought 10 mangoes at 200 shillings'"
if m["transaction_count"] < 3:
parts.append(f"πŸ“Š {m['transaction_count']} transaction(s) logged. Keep going!")
if m["top_items"]:
parts.append(f"πŸ“¦ Top item: {m['top_items'][0][0]}")
return " ".join(parts)
parts.append(f"πŸ“Š Revenue: {m['revenue']:.0f} | Costs: {m['costs']:.0f} | Profit: {m['profit']:.0f}")
if m["profit"] > 0:
parts.append(f"βœ… You're making a {m['margin_pct']:.1f}% margin. Good work!")
elif m["profit"] < 0:
parts.append(f"⚠️ You're losing {abs(m['profit']):.0f}. Check your costs!")
else:
parts.append("βš–οΈ Breaking even β€” look for ways to increase prices or reduce costs.")
if m["top_items"]:
parts.append(f"πŸ’° Your best seller: {m['top_items'][0][0]} ({m['top_items'][0][1]:.0f} total)")
low_stock = [item for item, qty in m["inventory"].items() if qty < 0]
if low_stock:
parts.append(f"🚨 {', '.join(low_stock)} are oversold/out of stock!")
if m["days_active"] >= 2:
parts.append(f"πŸ“… You've been tracking {m['days_active']} days. Consistency builds business!")
return "\n".join(parts)
async def get_modal_advisory():
"""Get AI advisory from Modal vLLM. Falls back to deterministic if unavailable."""
if not MODAL_BASE:
return get_deterministic_advice()
try:
m = calculate_margins()
prompt = f"""You are a helpful bookkeeping assistant for a small street trader named Mama Aisha.
Current financial summary:
- Revenue: {m['revenue']:.0f} | Costs: {m['costs']:.0f} | Profit: {m['profit']:.0f}
- Margin: {m['margin_pct']:.1f}% | Transactions: {m['transaction_count']}
- Inventory: {m['inventory']}
Provide concise, actionable advice. 2-3 sentences max. Street-smart."""
payload = {
"model": ADVISORY_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 512, "temperature": 0.7,
}
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(f"{MODAL_BASE}/chat/completions", json=payload)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except Exception as e:
logging.warning(f"Modal advisory failed: {e}")
add_trace("advisory", method="modal", success=False, error=str(e))
return get_deterministic_advice()
# --- Custom Ledger UI (HTML for Off Brand badge) --------------------------
def format_ledger() -> str:
"""Format ledger as styled HTML table."""
if not ledger:
return '<p class="empty">No transactions yet. Try: "bought 50 mangoes at 200 shillings"</p>'
html = '<table class="ledger"><thead><tr>'
html += '<th>#</th><th>Time</th><th>Action</th><th>Item</th><th>Qty</th><th>Price</th><th>Total</th><th>Currency</th>'
html += '</tr></thead><tbody>'
for e in ledger:
action = e.get("action", "")
item = e.get("item", "")
qty = e.get("quantity", 0)
price = e.get("unit_price", 0)
total = qty * price
currency = e.get("currency", "")
ts = e.get("timestamp", "")[:16]
html += '<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{:.1f}</td><td>{:.2f}</td><td>{:.2f}</td><td>{}</td></tr>'.format(e["id"], ts, action, item, qty, price, total, currency)
html += '</tbody></table>'
return html
def format_margins() -> str:
"""Format margins as styled HTML block with color coding."""
m = calculate_margins()
if m["transaction_count"] == 0:
return '<p>Add transactions to see your margins.</p>'
if m["profit"] > 0:
color, icon, bg = "#2E7D32", "βœ…", "rgba(46,125,50,0.04)"
elif m["profit"] < 0:
color, icon, bg = "#C62828", "⚠️", "rgba(198,40,40,0.04)"
else:
color, icon, bg = "#F57C00", "βš–οΈ", "rgba(245,124,0,0.04)"
inv_items = list(m["inventory"].items())
inv_str = ", ".join("{}={}".format(k, v) for k, v in inv_items[:6])
if len(inv_items) > 6:
inv_str += " (+{} more)".format(len(inv_items) - 6)
return '<div style="color: {}; padding: 14px; border-radius: 10px; border: 1px solid {}; background: {}; font-family: inherit;">'.format(color, color + "22", bg) + \
'<p style="margin: 4px 0;"><b>{}</b> Revenue: {:.0f}</p>'.format(icon, m["revenue"]) + \
'<p style="margin: 4px 0;"><b>Costs:</b> {:.0f}</p>'.format(m["costs"]) + \
'<p style="margin: 4px 0;"><b>Profit:</b> {:.0f}</p>'.format(m["profit"]) + \
'<p style="margin: 4px 0;"><b>Margin:</b> {:.1f}%</p>'.format(m["margin_pct"]) + \
'<p style="margin: 4px 0;"><b>Transactions:</b> {} | <b>Days:</b> {}</p>'.format(m["transaction_count"], m["days_active"]) + \
'<p style="margin: 4px 0; font-size: 12px; color: #666;">Inventory: {}</p>'.format(inv_str) + \
'</div>'
CSS = """
.ledger table { width: 100%; border-collapse: collapse; }
.ledger th, .ledger td { padding: 8px 10px; border: 1px solid #ddd; text-align: left; font-size: 13px; }
.ledger th { background: #2E7D32; color: white; font-weight: 600; }
.ledger tr:nth-child(even) { background: #fafafa; }
.ledger tr:hover { background: #f0f7f0; }
.empty { color: #888; font-style: italic; font-size: 14px; }
.voice-status { font-size: 12px; color: #666; margin-top: 4px; }
"""
def process_transaction(text: str, state: Dict):
"""Process a natural language transaction."""
if not text.strip():
return state, "Please enter a transaction.", format_ledger(), format_margins()
parsed = parse_transaction(text)
error = validate_transaction(parsed)
if error:
add_trace("validation", success=False, error=error)
return state, "❌ {}".format(error), format_ledger(), format_margins()
entry = add_entry(parsed)
state["ledger"] = ledger
status = "βœ… {} {}x {} @ {} {}".format(
entry.get('action', '?'), entry.get('quantity', '?'),
entry.get('item', '?'), entry.get('unit_price', '?'),
entry.get('currency', ''))
if entry.get("vendor"):
status += " from {}".format(entry["vendor"])
return state, status, format_ledger(), format_margins()
def process_voice(audio_path: str, language: str, state: Dict):
"""Process voice input (English/Swahili) β€” gated so it never crashes the demo."""
if not audio_path:
return state, "Record and upload audio to use voice input.", format_ledger(), format_margins()
lang = {"English": "en", "Swahili": "sw", "Auto": "auto"}.get(language, "auto")
try:
text = transcribe_audio(audio_path, language=lang)
if not text:
notice = "🎀 Voice unavailable β€” please type your transaction instead."
add_trace("voice", success=False, reason="no_transcription")
return state, notice, format_ledger(), format_margins()
parsed = parse_transaction(text)
error = validate_transaction(parsed)
if error:
add_trace("voice", success=False, reason="validation", error=error)
return state, "🎀 Heard: \"{}\"\n\n❌ {}".format(text, error), format_ledger(), format_margins()
entry = add_entry(parsed)
state["ledger"] = ledger
status = "🎀 Heard ({}): \"{}\"\n\nβœ… {} {}x {} @ {} {}".format(
lang, text, entry.get('action', '?'), entry.get('quantity', '?'),
entry.get('item', '?'), entry.get('unit_price', '?'),
entry.get('currency', ''))
if entry.get("vendor"):
status += " from {}".format(entry["vendor"])
return state, status, format_ledger(), format_margins()
except Exception as e:
add_trace("voice", success=False, reason="exception", error=str(e)[:80])
return state, "🎀 Voice unavailable β€” please type your transaction instead.", format_ledger(), format_margins()
def get_ai_advice(state: Dict):
"""Get AI advisory (Modal β†’ deterministic fallback)."""
if not ledger:
return "πŸ“Š Add some transactions first! Try: 'bought 10 onions at 50 shillings'"
try:
import asyncio
loop = asyncio.get_event_loop()
return loop.run_until_complete(get_modal_advisory())
except Exception:
return get_deterministic_advice()
def get_traces():
"""Return latest 20 agent traces."""
if not agent_traces:
return "No traces yet. Start logging transactions!"
recent = agent_traces[-20:]
lines = []
for t in recent:
lines.append("{} | {} | {} | {}".format(
t.get('ts', '')[:19], t['step'], t.get('method', '?'), t.get('success', '?')))
return "\n".join(lines)
# --- Build Gradio interface -----------------------------------------------
import gradio as gr
with gr.Blocks(title="Sidewalk FM β€” Mama Aisha's Ledger") as demo:
gr.Markdown("""
# πŸ“Š Sidewalk FM β€” Leja ya Mama Aisha / Mama Aisha's Ledger
*Msaidizi wa hesabu kwa Mama Aisha, mfanyabiashara wa sokoni.*
*A bookkeeping assistant for Mama Aisha, a morning-market produce trader.*
**Andika au sema** muamala wako β€” **kwa Kiswahili au Kiingereza.** Hesabu ni sahihi kila wakati.
Track buys, sells & margins by **voice or text, in Swahili or English.**
Powered by **NVIDIA Nemotron Mini 4B** (advice) + our **fine-tuned Kiswahili ASR** β€” Tiny Titan + Well-Tuned.
**Mifano / Examples:**
- πŸ‡°πŸ‡ͺ *"nimenunua maembe hamsini kwa mia mbili"* (bought 50 mangoes at 200)
- πŸ‡¬πŸ‡§ *"sold 30 maize for 1500"*
- *"return 5 broken tomatoes" / "rudisha nyanya tano"*
""")
with gr.Row():
with gr.Column(scale=2):
tx_input = gr.Textbox(
label="Muamala / Transaction (sema kwa lugha yako)",
placeholder="nimenunua maembe hamsini kwa mia mbili / bought 50 mangoes at 200",
lines=2,
)
tx_btn = gr.Button("πŸ“ Ongeza / Add Transaction", variant="primary")
tx_status = gr.Markdown("Add a transaction above...")
gr.HTML("<hr>")
gr.Markdown("### 🎀 Voice Input β€” English & Kiswahili")
gr.Markdown("*Pick a language, click the mic, speak your transaction, then press Record & Add.*")
lang_dropdown = gr.Dropdown(
choices=["Auto", "English", "Swahili"],
value="Auto",
label="Language (Lugha) β€” Swahili uses our fine-tuned model πŸ…",
)
audio_input = gr.Audio(
label="Record your transaction",
type="filepath",
sources=["microphone"],
)
voice_btn = gr.Button("πŸŽ™οΈ Rekodi / Record & Add", variant="secondary")
voice_notice = gr.Markdown("Voice input ready", elem_classes=["voice-status"])
with gr.Column(scale=1):
advice_btn = gr.Button("πŸ’‘ Ushauri / Get Advice", variant="secondary")
advice_out = gr.Markdown("Add transactions, then click for advice!")
gr.HTML(label="Margins")
margins_display = gr.HTML(label="Margins")
with gr.Tabs():
with gr.TabItem("Leja / Ledger"):
ledger_display = gr.HTML(label="Transaction Ledger")
with gr.TabItem("Agent Traces"):
trace_display = gr.Textbox(label="Structured Agent Traces (πŸ“‘)", lines=8, interactive=False)
state = gr.State({"ledger": []})
tx_btn.click(process_transaction, inputs=[tx_input, state],
outputs=[state, tx_status, ledger_display, margins_display])
voice_btn.click(process_voice, inputs=[audio_input, lang_dropdown, state],
outputs=[state, tx_status, ledger_display, margins_display])
advice_btn.click(get_ai_advice, inputs=[state], outputs=[advice_out])
trace_display.change(get_traces, outputs=[trace_display])
demo.load(lambda: {"ledger": []}, outputs=[state])
if __name__ == "__main__":
theme = gr.themes.Soft(primary_hue="green")
demo.launch(server_name="0.0.0.0", server_port=7860, share=False, theme=theme, css=CSS)