| |
| """ |
| 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 |
|
|
| |
| NIM_API_KEY = os.environ.get("NVIDIA_NIM_API_KEY", "") |
| NIM_BASE = "https://integrate.api.nvidia.com/v1" |
|
|
| |
| MODAL_BASE = os.environ.get("MODAL_BASE", "") |
| |
|
|
| EXTRACTION_MODEL = "nvidia/nemotron-mini-4b-instruct" |
| ADVISORY_MODEL = "nvidia/nemotron-mini-4b-instruct" |
|
|
| |
| ledger: List[Dict] = [] |
|
|
| |
| 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) |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| _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) |
| |
| 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'"} |
|
|
|
|
| |
|
|
| 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, |
| } |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| 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 = {} |
| |
| 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() |
|
|
| |
| 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") |
| if language == "sw": |
| order = [SW_MODEL, EN_MODEL] |
| elif language == "en": |
| order = [EN_MODEL] |
| else: |
| 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 |
|
|
|
|
| |
|
|
| 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() |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
| 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) |
|
|