from __future__ import annotations import html import json import os import re import textwrap from pathlib import Path from typing import Any import datetime import pandas as pd import plotly.graph_objects as go import streamlit as st import streamlit.components.v1 as components from agents import _print_provider_banner from agents.followup_agent import ( AGENT_LABELS, answer_followup_question, route_question, stream_followup_for_agent, ) _print_provider_banner() from agents.orchestrator import generate_business_report from agents.visualization_agent import build_chart_data, build_followup_visual from config import get_settings from tools.document_parser import DOCUMENT_TYPES, parse_uploaded_file, summarize_document_records from tools.financial_calculator import format_currency from tools.financial_engine import ( SCENARIOS as FE_SCENARIOS, calculate_forecast, compute_expense_breakdown, compute_metrics, get_baseline, get_loan_label, ) from tools.text_parser import parse_business_input from tools.transaction_analyzer import analyze_transactions, load_transactions_frame ROOT_DIR = Path(__file__).resolve().parent DATA_DIR = ROOT_DIR / "data" SAMPLE_CASES_PATH = DATA_DIR / "sample_business_cases.json" SAMPLE_TRANSACTIONS_PATH = DATA_DIR / "sample_transactions.csv" SAMPLE_TEMPLATES = { "Income Statement CSV": DATA_DIR / "sample_income_statement.csv", "Cash Flow Record CSV": DATA_DIR / "sample_cash_flow_record.csv", "Sales Record CSV": DATA_DIR / "sample_sales_record.csv", "Expense Record CSV": DATA_DIR / "sample_expense_record.csv", "Mobile Money Statement CSV": DATA_DIR / "sample_mobile_money_statement.csv", "General Business Workbook": DATA_DIR / "sample_business_records.xlsx", } THEME = { "bg": "#0F172A", "bg_alt": "#111827", "card": "#F8FAFC", "white": "#FFFFFF", "text": "#0F172A", "muted": "#64748B", "border": "#E5E7EB", "emerald": "#10B981", "blue": "#2563EB", "gold": "#F59E0B", "red": "#EF4444", "orange": "#F97316", } DEMO_PROVIDERS = [ ("MTN Mobile Money", "#FFCB05", "#111827", "MTN"), ("Airtel Money", "#EF4444", "#FFFFFF", "Airtel"), ("Zamtel Money", "#10B981", "#FFFFFF", "Zamtel"), ("Bank Account", "#2563EB", "#FFFFFF", "Bank"), ] PROVIDER_BRANDING = { "MTN Mobile Money": {"abbr": "MTN", "bg": "#FFCB05", "fg": "#111827"}, "Airtel Money": {"abbr": "Airtel", "bg": "#EF4444", "fg": "#FFFFFF"}, "Zamtel Money": {"abbr": "Zamtel", "bg": "#10B981", "fg": "#FFFFFF"}, "Bank Account": {"abbr": "Bank", "bg": "#2563EB", "fg": "#FFFFFF"}, "Upload Statement": {"abbr": "DOC", "bg": "#64748B", "fg": "#FFFFFF"}, "Manual Entry": {"abbr": "TXT", "bg": "#7C3AED", "fg": "#FFFFFF"}, } PROVIDER_DETAILS = { "MTN Mobile Money": { "kind": "Mobile money wallet", "signal": "Airtime, customer payments, merchant flows", "demo": "47 demo transactions", }, "Airtel Money": { "kind": "Mobile money wallet", "signal": "Agent cash-ins, transfers, daily receipts", "demo": "47 demo transactions", }, "Zamtel Money": { "kind": "Mobile money wallet", "signal": "Wallet activity, recurring payments", "demo": "47 demo transactions", }, "Bank Account": { "kind": "Bank statement", "signal": "Deposits, supplier payments, debt service", "demo": "Statement demo", }, } FOLLOWUP_SUGGESTIONS = [ "Where is my money going?", "Am I ready for a loan?", "What should I reduce?", "How do I grow revenue?", "Is this a good season?", "Explain my cash flow", ] EXAMPLE_PROMPT_LABELS = { "Lusaka Grocery Shop": "๐Ÿช Lusaka Grocery", "Salon Slow Month": "๐Ÿ’‡ Salon Slow Month", "Farmer Restocking Decision": "๐ŸŒพ Farmer Decision", } FOLLOWUP_SUGGESTION_PILLS = [ ("๐Ÿ’ธ Where's my money going?", "Where is my money going?"), ("๐Ÿฆ Am I ready for a loan?", "Am I ready for a loan?"), ("โœ‚๏ธ What should I reduce?", "What should I reduce?"), ("๐Ÿ“ˆ How do I grow revenue?", "How do I grow revenue?"), ("๐ŸŒฆ๏ธ Is this a good season?", "Is this a good season?"), ("๐Ÿงพ Explain my cash flow", "Explain my cash flow"), ] NAV_SECTIONS = { "MAIN": ["๐Ÿ’ฌ Chat Advisor", "๐Ÿ“Š Dashboard"], "TOOLS": ["๐Ÿ“ˆ Cash Flow Forecast", "๐Ÿ”ฎ Scenario Planner", "๐Ÿฆ Loan Calculator", "๐Ÿ” Expense Analyzer"], "REPORTS": ["๐Ÿ“„ Generate Report", "๐ŸŒ Market Intel"], "SETTINGS": ["โš™๏ธ Settings"], } NAV_LABEL_TO_PAGE = { "๐Ÿ’ฌ Chat Advisor": "Chat Advisor", "๐Ÿ“Š Dashboard": "Dashboard", "๐Ÿ“ˆ Cash Flow Forecast": "Cash Flow Forecast", "๐Ÿ”ฎ Scenario Planner": "Scenario Planner", "๐Ÿฆ Loan Calculator": "Loan Calculator", "๐Ÿ” Expense Analyzer": "Expense Analyzer", "๐Ÿ“„ Generate Report": "Generate Report", "๐ŸŒ Market Intel": "Market Intel", "โš™๏ธ Settings": "Settings", } NUMBER_SOURCE_OPTIONS = [ ("๐Ÿ“„ Upload file", "upload"), ("๐Ÿ”— Connect account", "connect"), ("โœ๏ธ Enter manually", "manual"), ] AGENT_STATUS_MESSAGES = { "cashflow": ("๐Ÿ“Š", "Cash Flow Agent", "Analyzing revenue, expenses, and profit margins..."), "advisor": ("๐Ÿ’ก", "Business Advisor Agent", "Generating specific recommendations for your business..."), "loan": ("๐Ÿฆ", "Loan Readiness Agent", "Calculating your credit score and borrowing capacity..."), "market": ("๐Ÿ“ˆ", "Market Intelligence Agent", "Checking Zambian market trends and opportunities..."), "summary": ("๐Ÿ”„", "Executive Summary Agent", "Writing your personalized financial summary..."), } def _normalize_assistant_response(text: str) -> str: """Tidy up agent replies before display/storage. - Strip trailing 'Next step' lines that just rephrase a question the agent already asked (so we don't get redundant Q + Next step pairs). - Force the 'Next step:' line onto its own paragraph with a blank line above it, so markdown renders it as a separate paragraph. - Wrap a clean Next-step line in a styled callout div so it pops visually (green left bar) instead of bleeding into the body text. """ if not text: return text body = text.rstrip() # Find the LAST occurrence of "Next step:" โ€” tolerate optional bold markers # and tolerate the model putting it directly after a sentence with no space. m = re.search( r"(?:\n+|(?<=[\.\?\!\)])\s*|^)\s*(?:\*\*\s*)?Next\s*step\s*:\s*(?:\*\*)?\s*(.*?)\s*$", body, flags=re.IGNORECASE | re.DOTALL, ) if not m: return text next_step_text = m.group(1).strip() pre = body[:m.start()].rstrip() # If the body already ends with a question (the agent is asking the user # for info), drop the redundant Next step entirely. if pre.endswith("?"): return pre # If the next step itself is just a question rephrasing the same thing, drop it. if next_step_text.endswith("?"): return pre # Otherwise render as a styled callout block. safe_text = next_step_text.replace("<", "<").replace(">", ">") callout = f'
Next step:{safe_text}
' return f"{pre}\n\n{callout}" def _resolve_agents(question: str, messages: list[dict]) -> list[str]: """LLM-route the question, fall back to keyword routing if LLM unavailable. Handles 'continuation' by returning the last agent that responded. """ recent: list[dict[str, str]] = [] for i, msg in enumerate(messages): if msg["role"] == "user" and i + 1 < len(messages): nxt = messages[i + 1] if nxt["role"] == "assistant" and nxt.get("type") != "initial_analysis": recent.append({"question": msg["content"], "answer": nxt.get("content", "")}) recent = recent[-3:] routed = route_question(question, recent) if routed == ["continuation"]: last = st.session_state.get("last_agent", "advisor") return [last] return routed def load_sample_cases() -> list[dict[str, str]]: try: return json.loads(SAMPLE_CASES_PATH.read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError): return [] def load_template_bytes(path: Path) -> bytes: try: return path.read_bytes() except FileNotFoundError: return b"" def empty_parsed_data(raw_text: str = "") -> dict[str, Any]: return { "raw_text": raw_text, "revenue": 0.0, "expenses": 0.0, "expenses_breakdown": { "stock": 0.0, "rent": 0.0, "transport": 0.0, "other_expenses": 0.0, }, "debt": 0.0, "loan_amount": 0.0, "business_type": None, "location": None, "categories_detected": [], } def get_loan_band(score: int, profit_margin: float = 0.0) -> tuple[str, str, str]: """Single consistent loan label โ€” always margin-based via financial_engine.""" margin = profit_margin if profit_margin > 0 else 0.0 label, color, _ = get_loan_label(margin) explanations = { "Loan ready": "Borrowing looks manageable if it serves a clear business purpose.", "Loan possible with caution": "Small, targeted borrowing may be feasible โ€” keep repayments below 20% of monthly profit.", "Limited borrowing capacity": "Profit margin is too thin to safely absorb loan repayments right now โ€” improve margins first.", } return (label, color, explanations[label]) def get_provider_badge_html(provider: str, large: bool = False) -> str: branding = PROVIDER_BRANDING.get(provider, {"abbr": provider[:4], "bg": "#334155", "fg": "#FFFFFF"}) size_class = "provider-badge-large" if large else "" return ( f'
{branding["abbr"]}
' ) def get_connected_demo(provider: str | None) -> dict[str, Any] | None: if not provider: return None frame = load_transactions_frame(str(SAMPLE_TRANSACTIONS_PATH)) filtered = frame[frame["Provider"] == provider].copy() if filtered.empty: return None summary = analyze_transactions(filtered) summary["provider_selected"] = provider return summary def build_business_context_text(profile: dict[str, str], manual_notes: str) -> str: parts = [] if profile.get("business_type"): parts.append(f"Business type: {profile['business_type']}.") if profile.get("location"): parts.append(f"Location: {profile['location']}.") if profile.get("products_services"): parts.append(f"Main products or services: {profile['products_services']}.") if profile.get("main_question"): parts.append(f"Main question: {profile['main_question']}.") if manual_notes.strip(): parts.append(manual_notes.strip()) return " ".join(parts).strip() def combine_analysis_inputs( business_profile: dict[str, str], manual_notes: str, document_data: dict[str, Any] | None, connected_data: dict[str, Any] | None, manual_figures: dict[str, float | int] | None = None, ) -> tuple[str, dict[str, Any], list[str], list[str], list[str]]: business_context = build_business_context_text(business_profile, manual_notes) parsed_text = parse_business_input(business_context) if business_context else empty_parsed_data() combined = dict(parsed_text) combined["business_type"] = business_profile.get("business_type") or combined.get("business_type") combined["location"] = business_profile.get("location") or combined.get("location") if document_data: document_metadata = document_data.get("metadata", {}) combined["business_type"] = combined.get("business_type") or document_metadata.get("business_type") combined["location"] = combined.get("location") or document_metadata.get("location") basis_notes: list[str] = [] source_labels: list[str] = [] data_sources: list[str] = [] if any(value.strip() for value in business_profile.values()): basis_notes.append("Analysis used the business profile you provided.") source_labels.append("Business context") data_sources.append("Business context") if manual_notes.strip(): basis_notes.append("Additional context was included from your written description.") source_labels.append("Manual text input") data_sources.append("Manual text input") figures_provided = any(float(manual_figures.get(key, 0) or 0) > 0 for key in ["revenue", "expenses", "debt", "staff"]) if manual_figures else False if figures_provided: basis_notes.append("Analysis used the manual figures you entered.") source_labels.append("Manual figures") data_sources.append("Manual figures") if connected_data: combined["revenue"] = round(float(connected_data["total_revenue"]), 2) combined["expenses"] = round(float(connected_data["total_expenses"]), 2) if combined.get("debt", 0.0) <= 0 and connected_data.get("debt_payments", 0.0) > 0: combined["debt"] = round(float(connected_data["debt_payments"]), 2) basis_notes.append("Analysis used simulated connected transaction data.") source_labels.append("Connected financial data demo") data_sources.append(f"Simulated connection: {connected_data['provider_selected']}") if document_data: if not connected_data and document_data.get("revenue", 0) > 0: combined["revenue"] = round(float(document_data["revenue"]), 2) if not connected_data and document_data.get("expenses", 0) > 0: combined["expenses"] = round(float(document_data["expenses"]), 2) if combined.get("debt", 0.0) <= 0 and document_data.get("debt", 0) > 0: combined["debt"] = round(float(document_data["debt"]), 2) # Carry through expense breakdown extracted by the LLM parser doc_breakdown = document_data.get("expenses_breakdown") or {} if doc_breakdown and not any(v > 0 for v in combined.get("expenses_breakdown", {}).values()): combined["expenses_breakdown"] = doc_breakdown basis_notes.append(f"Analysis used an uploaded {document_data.get('document_type', 'document').lower()}.") source_labels.append(f"Uploaded document: {document_data.get('document_type', 'Other')}") data_sources.append(f"Uploaded document: {document_data.get('document_type', 'Other')}") if manual_figures: manual_revenue = round(float(manual_figures.get("revenue", 0.0) or 0.0), 2) manual_expenses = round(float(manual_figures.get("expenses", 0.0) or 0.0), 2) manual_debt = round(float(manual_figures.get("debt", 0.0) or 0.0), 2) if manual_revenue > 0 and not connected_data and not (document_data and document_data.get("revenue", 0) > 0): combined["revenue"] = manual_revenue if manual_expenses > 0 and not connected_data and not (document_data and document_data.get("expenses", 0) > 0): combined["expenses"] = manual_expenses if manual_debt > 0 and combined.get("debt", 0.0) <= 0: combined["debt"] = manual_debt if not basis_notes: basis_notes.append("Analysis used the written business information that was provided.") source_labels.append("Manual text input") data_sources.append("Manual text input") combined["raw_text"] = business_context combined["categories_detected"] = list(dict.fromkeys(combined.get("categories_detected", []))) context_parts: list[str] = [] if any(value.strip() for value in business_profile.values()): context_parts.append("Business profile: " + "; ".join(f"{key.replace('_', ' ')}: {value}" for key, value in business_profile.items() if value.strip())) if manual_notes.strip(): context_parts.append("Manual business notes: " + manual_notes.strip()) if figures_provided and manual_figures: figure_parts = [] if float(manual_figures.get("revenue", 0) or 0) > 0: figure_parts.append(f"Revenue: {format_currency(float(manual_figures['revenue']))}") if float(manual_figures.get("expenses", 0) or 0) > 0: figure_parts.append(f"Expenses: {format_currency(float(manual_figures['expenses']))}") if float(manual_figures.get("debt", 0) or 0) > 0: figure_parts.append(f"Debt: {format_currency(float(manual_figures['debt']))}") if int(manual_figures.get("staff", 0) or 0) > 0: figure_parts.append(f"Staff: {int(manual_figures['staff'])}") if figure_parts: context_parts.append("Manual figures: " + "; ".join(figure_parts)) if document_data: context_parts.append("Uploaded document summary: " + summarize_document_records(document_data)) if connected_data: context_parts.append("Connected transaction summary: " + connected_data.get("summary", "Transaction patterns were analyzed.")) analysis_context = "\n\n".join(part for part in context_parts if part).strip() if not analysis_context: analysis_context = "Business information was provided for analysis." data_sources.append("Market Intelligence") return ( analysis_context, combined, basis_notes, list(dict.fromkeys(source_labels)), list(dict.fromkeys(data_sources)), ) # โ”€โ”€โ”€ Render helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def render_section_intro(title: str, subtitle: str) -> None: st.markdown( f"""
{title}
{subtitle}
""", unsafe_allow_html=True, ) def _loading_card( title: str, subtitle: str = "", indicator: str = "ring", # "ring" | "dots" badge: str = "Duka AI", ) -> str: """Return themed HTML for a branded loading card (render with unsafe_allow_html=True). Use inside an st.empty() placeholder that you clear once the operation completes. indicator="ring" โ†’ spinning teal ring indicator="dots" โ†’ three bouncing dots """ if indicator == "dots": ind_html = '
' else: ind_html = '
' sub_html = f'
{subtitle}
' if subtitle else "" badge_html = f'
{badge}
' if badge else "" return ( f'
' f'{ind_html}' f'
' f'
{title}
' f'{sub_html}' f'
' f'{badge_html}' f'
' ) def _skeleton_lines(widths: list[str] | None = None) -> str: """Return HTML skeleton shimmer lines for content-is-loading placeholders.""" classes = widths or ["wide", "med", "short"] lines = "".join(f'
' for w in classes) return f'
{lines}
' def _profit_label_value(profit: float, margin: float | None = None) -> tuple[str, str, str]: """Return (label, formatted_value, accent_color) for a profit/loss figure. Positive profit โ†’ "Profit", teal/blue display. Negative profit โ†’ "Net Loss", red display with positive K amount. """ if profit < 0: label = "Net Loss" value = f"K{abs(profit):,.2f}" color = "#E24B4A" else: label = "Profit" value = format_currency(profit) color = THEME["blue"] if margin is not None: value = f"{value} ({margin:.1f}% margin)" return label, value, color def render_metric_card(label: str, value: str, note: str, accent: str, icon: str) -> None: st.markdown( f"""
{icon} {label}
{value}
{note}
""", unsafe_allow_html=True, ) def render_metric_grid(report: dict[str, Any]) -> None: cashflow = report["cashflow"] business_health = report["business_health"] profit = cashflow["profit"] p_label, p_value, p_color = _profit_label_value(profit) p_note = "What remains after expenses." if profit >= 0 else "Expenses exceed revenue โ€” this business is at a loss." items = [ ("Revenue", format_currency(cashflow["revenue"]), "Money coming into the business.", THEME["emerald"], "K"), ("Expenses", format_currency(cashflow["expenses"]), "Costs and outgoing spending.", THEME["orange"], "#"), (p_label, p_value, p_note, p_color, "+" if profit >= 0 else "โš "), ("Profit Margin", f'{cashflow["profit_margin"]:.1f}%', "Profit as a share of revenue.", THEME["gold"], "%"), ("Business Health", f'{business_health["score"]}/100', f'{business_health["status"]} ยท Overall fitness, not loan score', THEME["emerald"], "*"), ] for column, item in zip(st.columns(5, gap="medium"), items): with column: render_metric_card(*item) def render_prompt_card(case: dict[str, str], key: str) -> None: preview = textwrap.shorten(case["input"], width=110, placeholder="...") st.markdown( f"""
{case["title"]}
{preview}
""", unsafe_allow_html=True, ) _spacer, try_col = st.columns([3, 1]) with try_col: if st.button("โ†’ Try this", key=key): st.session_state.manual_notes = case["input"] st.rerun() def populate_example_prompt(case: dict[str, str]) -> None: parsed = parse_business_input(case["input"]) prompt_label = EXAMPLE_PROMPT_LABELS.get(case["title"], case["title"]) st.session_state.manual_notes = case["input"] st.session_state.business_type = parsed.get("business_type") or st.session_state.business_type st.session_state.location = parsed.get("location") or st.session_state.location if not st.session_state.products_services and parsed.get("categories_detected"): st.session_state.products_services = ", ".join(parsed["categories_detected"][:3]) if "?" in case["input"]: last_question = case["input"].rsplit("?", 1)[0].split(".")[-1].strip() if last_question: st.session_state.main_question = f"{last_question}?" st.session_state.selected_example_prompt = prompt_label st.session_state.selected_example_prompt_text = case["input"] st.session_state.scroll_target = "duka-ai-analyze-anchor" def render_example_prompt_pills(sample_cases: list[dict[str, str]]) -> None: _CARD_META: dict[str, dict] = { "Lusaka Grocery Shop": { "icon": "๐Ÿช", "icon_bg": "rgba(29,158,117,0.2)", "desc": "Weekly sales analysis, cash flow check, loan readiness for a Lusaka trader.", "featured": True, }, "Salon Slow Month": { "icon": "๐Ÿ’‡", "icon_bg": "rgba(55,138,221,0.2)", "desc": "Ndola salon navigating a slow January with high product costs.", "featured": False, }, "Farmer Restocking Decision": { "icon": "๐ŸŒพ", "icon_bg": "rgba(245,158,11,0.2)", "desc": "Seasonal restocking decisions for a Zambian smallholder farmer.", "featured": False, }, } prompt_columns = st.columns(max(1, min(3, len(sample_cases) or 1)), gap="small") for index, case in enumerate(sample_cases[:3]): meta = _CARD_META.get(case["title"], { "icon": "๐Ÿ“Š", "icon_bg": "rgba(100,100,100,0.2)", "desc": textwrap.shorten(case["input"], width=90, placeholder="..."), "featured": False, }) with prompt_columns[index]: if meta["featured"]: st.markdown( f""" """, unsafe_allow_html=True, ) if st.button("Try this โ†’", key=f"example_pill_{index}", use_container_width=True, type="primary"): populate_example_prompt(case) st.rerun() else: st.markdown( f"""
{meta['icon']}
{case['title']}
{meta['desc']}
""", unsafe_allow_html=True, ) if st.button("Try this โ†’", key=f"example_pill_{index}", use_container_width=True): populate_example_prompt(case) st.rerun() def render_chat_metrics_bar(cashflow: dict[str, Any]) -> None: _profit = cashflow["profit"] _p_label, _p_val, _ = _profit_label_value(_profit) _p_arrow = " โ†‘" if _profit > 0 else " โ†“" if _profit < 0 else "" metric_items = [ ("Revenue", format_currency(cashflow["revenue"])), ("Expenses", format_currency(cashflow["expenses"])), (_p_label, f"{_p_val}{_p_arrow}".strip()), ("Margin", f'{cashflow.get("profit_margin", 0.0):.1f}%'), ] metric_columns = st.columns(4, gap="small") for column, (label, value) in zip(metric_columns, metric_items): with column: st.metric(label, value) def inject_ui_behavior( sample_cases: list[dict[str, str]], selected_example_prompt: str, selected_example_text: str, selected_numbers_mode: str, scroll_requested: bool, ) -> None: example_map = {EXAMPLE_PROMPT_LABELS.get(case["title"], case["title"]): case["input"] for case in sample_cases[:3]} suggestion_labels = [label for label, _ in FOLLOWUP_SUGGESTION_PILLS] number_labels = [label for label, _ in NUMBER_SOURCE_OPTIONS] components.html( f""" """, height=0, width=0, ) def render_clickable_provider_cards() -> None: """Clickable provider cards with brand colors, selection state, and simulated connection form.""" selected: set[str] = st.session_state.get("selected_providers", set()) provider_names = [provider for provider, *_ in DEMO_PROVIDERS] preferred_provider = st.session_state.get("connected_provider") or st.session_state.get("provider_picker") or provider_names[0] selected_index = provider_names.index(preferred_provider) if preferred_provider in provider_names else 0 provider_choice = st.selectbox( "Choose financial source", provider_names, index=selected_index, key="provider_picker", help="Select the mobile money wallet or bank account to preview and connect.", ) chosen_provider, chosen_bg, chosen_fg, chosen_abbr = next( item for item in DEMO_PROVIDERS if item[0] == provider_choice ) chosen_details = PROVIDER_DETAILS.get(chosen_provider, {}) picker_left, picker_right = st.columns([1.7, 0.8], gap="large") with picker_left: st.markdown( f"""
{chosen_abbr}
{chosen_provider}
{chosen_details.get("kind", "Financial source")} ยท {chosen_details.get("signal", "Transaction patterns and cash flow signals")}
""", unsafe_allow_html=True, ) with picker_right: already_connected = chosen_provider in selected picker_action = "Disconnect selected" if already_connected else "Connect selected" if st.button(picker_action, key="provider_dropdown_action", use_container_width=True, type="primary" if not already_connected else "secondary"): new_selected: set[str] = set(selected) if already_connected: new_selected.discard(chosen_provider) if st.session_state.get("connected_provider") == chosen_provider: remaining = new_selected - {chosen_provider} st.session_state.connected_provider = next(iter(remaining), None) else: new_selected.add(chosen_provider) st.session_state.connected_provider = chosen_provider st.session_state.selected_providers = new_selected st.rerun() # Show connection forms for selected providers for provider, bg_color, fg_color, abbr in DEMO_PROVIDERS: if provider in selected: if provider == "Bank Account": form_hint = "Upload bank statement CSV โ€” using simulated data for demo" else: form_hint = f"Phone number registered with {provider} โ€” using simulated data for demo" st.markdown( f"โœ… Connected โ€” 47 transactions loaded from **{provider}** (simulated demo data)\n\n" f"""
{abbr}
{provider} connected
47 simulated transactions loaded. {form_hint}.
""", unsafe_allow_html=True, ) def render_file_preview(document_data: dict[str, Any]) -> None: confidence = document_data.get("confidence", "low") used_llm = document_data.get("used_llm", False) metadata = document_data.get("metadata") if isinstance(document_data.get("metadata"), dict) else {} used_structured_sheet_parser = bool(metadata.get("sheets_used")) first_warning = (document_data.get("warnings") or [""])[0] is_rate_limited = "token limit" in first_warning.lower() or "rate limit" in first_warning.lower() or "429" in first_warning if used_llm and confidence == "high": st.success(f"โœ… LLM successfully extracted financial data from **{document_data.get('document_type', 'document')}** โ€” high confidence.") elif used_llm and confidence == "medium": st.warning(f"โš ๏ธ LLM partial extraction from **{document_data.get('document_type', 'document')}** โ€” verify figures below.") elif used_llm and confidence == "low": st.error("โŒ LLM could not extract reliable data. Please check the file or enter figures manually.") elif used_structured_sheet_parser and confidence == "high": st.success( f"โœ… Structured extraction loaded **{document_data.get('document_type', 'document')}** โ€” high confidence." ) elif is_rate_limited: st.error(f"โณ {first_warning}") else: st.warning("โš ๏ธ LLM parsing skipped โ€” used keyword-based fallback. Numbers may be inaccurate for multi-sheet files.") profit_value = float(document_data.get("profit", 0.0) or 0.0) revenue_value = float(document_data.get("revenue", 0.0) or 0.0) if revenue_value > 0 or profit_value != 0: if profit_value < 0: st.error(f"โš ๏ธ This business is making a LOSS of K{abs(profit_value):,.0f}") elif profit_value > 0: st.success(f"โœ… This business is profitable: K{profit_value:,.0f} profit") warnings_html = "".join( f'
{w}
' for w in document_data.get("warnings", [])[:4] if "LLM unavailable" not in w and "low confidence" not in w.lower() ) volume = document_data.get("rows_analyzed") or document_data.get("pages_analyzed") or document_data.get("characters_analyzed") or 0 volume_label = "Rows" if document_data.get("rows_analyzed") else "Pages" if document_data.get("pages_analyzed") else "Characters" period = document_data.get("period_description") or document_data.get("date_range") or "Unknown" st.markdown( f"""
AI Document Analysis
Document Type{document_data.get("document_type", "Other")}
File Name{document_data.get("file_name", "Uploaded file")}
Period{period}
{volume_label} Analyzed{volume}
Revenue{format_currency(document_data.get("revenue", 0.0))}
Expenses{format_currency(document_data.get("expenses", 0.0))}
Profit{format_currency(document_data.get("profit", 0.0))}
Profit Margin{document_data.get("profit_margin", 0.0):.1f}%
Debt{format_currency(document_data.get("debt", 0.0))}
{document_data.get("summary", "")}
{warnings_html}
""", unsafe_allow_html=True, ) # Monthly breakdown chart monthly = document_data.get("monthly_breakdown") if monthly and isinstance(monthly, list) and len(monthly) > 1: try: df_monthly = pd.DataFrame(monthly) if "month" in df_monthly.columns: df_monthly = df_monthly.set_index("month") numeric_cols = [c for c in ["revenue", "expenses", "profit"] if c in df_monthly.columns] if numeric_cols: st.markdown( '
Monthly Breakdown
', unsafe_allow_html=True, ) colors = { "revenue": THEME["emerald"], "expenses": THEME["orange"], "profit": THEME["blue"], } fig = go.Figure() for col in numeric_cols: fig.add_trace(go.Bar( name=col.title(), x=df_monthly.index.tolist(), y=df_monthly[col].tolist(), marker_color=colors.get(col, THEME["blue"]), text=[format_currency(v) for v in df_monthly[col]], textposition="outside", )) fig.update_layout( height=280, barmode="group", margin=dict(l=8, r=8, t=8, b=8), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(color=THEME["text"], family="Segoe UI, Arial, sans-serif"), xaxis=dict(showgrid=False, title=""), yaxis=dict(gridcolor="rgba(100,116,139,0.18)", zeroline=False), legend=dict(orientation="h", y=1.08), showlegend=True, ) st.plotly_chart(fig, use_container_width=True) except Exception: pass # Key observations observations = document_data.get("key_observations", []) if observations: obs_html = "".join(f'
{obs}
' for obs in observations[:4]) st.markdown( f'
' f'
Key Observations
{obs_html}
', unsafe_allow_html=True, ) def render_connected_preview(connected_data: dict[str, Any]) -> None: st.markdown( f"""
Connected Financial Data Demo
{get_provider_badge_html(connected_data["provider_selected"], large=True)}
Selected provider
{connected_data["provider_selected"]}
Demo connection enabled. Duka AI is using sample transaction data to simulate secure financial analysis.
Transactions{connected_data["rows_analyzed"]}
Total Revenue{format_currency(connected_data["total_revenue"])}
Total Expenses{format_currency(connected_data["total_expenses"])}
Net Cash Flow{format_currency(connected_data["net_cash_flow"])}
Date Range{connected_data["date_range"]}
Biggest Expense{connected_data["biggest_expense_category"]}
Average Daily Sales{format_currency(connected_data["average_daily_sales"])}
Stability Score{connected_data["transaction_stability_score"]}/100
Your data is used only for analysis in this demo.
For the hackathon MVP, this is simulated data. Real integrations would require secure user consent, encryption, and provider approval.
""", unsafe_allow_html=True, ) def render_data_sources_card(report: dict[str, Any]) -> None: chips = "".join( f'
+{source}
' for source in report.get("data_sources", []) ) agent_statuses = "".join( f'
{("โœ“" if used_llm else "F")}' f'{name}: {("โšก Live AI" if used_llm else "Standard mode")}
' for name, used_llm in report.get("agent_statuses", {}).items() ) st.markdown( f"""
Data Sources Used
Duka AI combined your business context, records, transaction patterns, and market context to produce this report.
{chips}
Agent Execution Status
Which specialist agents contributed to this analysis.
{agent_statuses}
""", unsafe_allow_html=True, ) def render_summary_panel(report: dict[str, Any]) -> None: cashflow = report["cashflow"] loan = report["loan"] business_health = report["business_health"] band_label, band_color, _ = get_loan_band(loan["loan_readiness_score"], cashflow.get("profit_margin", 0.0)) source_html = "".join(f'
{label}
' for label in report.get("source_labels", [])) st.markdown( f"""
Business Financial Health Summary
{cashflow["summary"]}
{source_html}
Business Health {business_health["status"]}
Cash Flow {cashflow["cash_flow_status"]}
Borrowing View {band_label}
""", unsafe_allow_html=True, ) def render_business_health_card(report: dict[str, Any]) -> None: health = report["business_health"] color_map = { "Healthy": THEME["emerald"], "Stable but needs attention": THEME["gold"], "At risk": THEME["orange"], "Critical": THEME["red"], } status_color = color_map.get(health["status"], THEME["blue"]) st.markdown( f"""
Business Health Score
{health["score"]}/100
{health["status"]}
Business health is an overall financial fitness score โ€” profitability, margin strength, and expense control. This is separate from the Loan Readiness score below, which specifically measures borrowing safety.
""", unsafe_allow_html=True, ) def render_loan_readiness_card(report: dict[str, Any]) -> None: loan = report["loan"] cashflow = report.get("cashflow", {}) label, color, explanation = get_loan_band(loan["loan_readiness_score"], cashflow.get("profit_margin", 0.0)) risk_warning = loan["warnings"][0] if loan.get("warnings") else "No immediate borrowing warning was detected from the current numbers." st.markdown( f"""
Loan Readiness Score โ€” Borrowing & Debt Review
{loan["loan_readiness_score"]}/100
{label}
{explanation}
Safe borrowing amount: {format_currency(loan["suggested_loan_amount"])}  ยท  calculated as {loan.get("loan_multiplier", 1.5)}ร— your monthly profit โ€” a conservative SME lending rule of thumb
Risk warning: {risk_warning}
""", unsafe_allow_html=True, ) def render_financial_chart(report: dict[str, Any]) -> None: cashflow = report["cashflow"] metrics = ["Revenue", "Expenses", "Profit"] amounts = [cashflow["revenue"], cashflow["expenses"], cashflow["profit"]] colors = [THEME["emerald"], THEME["orange"], THEME["blue"]] figure = go.Figure( data=[ go.Bar( x=metrics, y=amounts, marker_color=colors, text=[format_currency(value) for value in amounts], textposition="outside", hovertemplate="%{x}
%{text}", ) ] ) figure.update_layout( height=320, margin=dict(l=8, r=8, t=8, b=8), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(color=THEME["text"], family="Segoe UI, Arial, sans-serif"), xaxis=dict(showgrid=False, title=""), yaxis=dict(title="Amount (Kwacha)", gridcolor="rgba(100, 116, 139, 0.18)", zeroline=False), showlegend=False, ) st.plotly_chart(figure, use_container_width=True) def render_risk_card(text: str) -> None: st.markdown( f"""
!
{text}
""", unsafe_allow_html=True, ) def render_action_card(index: int, action: str) -> None: st.markdown( f"""
{index}
{action}
""", unsafe_allow_html=True, ) def render_financial_advice_section(report: dict[str, Any]) -> None: advisor = report["advisor"] st.markdown( f"""
Financial Advice
What this means
{advisor["what_this_means"]}
What you are doing well
{advisor["doing_well"]}
What needs attention
{advisor["needs_attention"]}
Best next move
{advisor["best_next_move"]}
This week's action
{advisor["this_weeks_action"]}
Growth recommendation
{advisor["growth_recommendation"]}
""", unsafe_allow_html=True, ) with st.expander("Detailed reasoning: Financial Advice"): st.write(advisor["reasoning"]) def render_cashflow_section(report: dict[str, Any]) -> None: cashflow = report["cashflow"] st.markdown( """
Cash Flow & Profit Analysis
""", unsafe_allow_html=True, ) render_financial_chart(report) details = [ f"Cash flow status: {cashflow['cash_flow_status']}", f"Expense ratio: {cashflow['expense_ratio']:.1f}%", *cashflow["warnings"], ] for detail in details: st.markdown(f'
{detail}
', unsafe_allow_html=True) with st.expander("Detailed reasoning: Cash Flow & Profit Analysis"): st.write(cashflow["reasoning"]) def render_market_intelligence_section(report: dict[str, Any]) -> None: market = report["market"] web_used = market.get("web_search_used", False) sources = market.get("sources", []) queries = market.get("queries_used", []) # Source badge source_badge = ( f'๐ŸŒ Live web search ยท {len(sources)} sources' if web_used else '๐Ÿ“š AI knowledge base' ) risk_cards = "".join(f'
{r}
' for r in market.get("risk_factors", [])) monitor_cards = "".join(f'
{m}
' for m in market.get("monitor_next", [])) st.markdown( f"""
Market Intelligence{source_badge}
{market.get("market_summary", "")}
Key market risks
{risk_cards}
Opportunity
{market.get("opportunity", "")}
Recommended move
{market.get("recommendation", "")}
What to monitor
{monitor_cards}
""", unsafe_allow_html=True, ) if web_used and sources: with st.expander(f"๐Ÿ“š Sources ({len(sources)} web results used)", expanded=False): seen: set[str] = set() for src in sources: url = src.get("url", "") title = src.get("title", url) if url and url not in seen: seen.add(url) st.markdown(f"- [{title}]({url})") if web_used and queries: with st.expander("๐Ÿ” Search queries used", expanded=False): for q in queries: st.caption(f"โ€ข {q}") if market.get("reasoning"): with st.expander("Reasoning: Market Intelligence", expanded=False): st.write(market["reasoning"]) def render_transaction_card(transaction_summary: dict[str, Any]) -> None: st.markdown( f"""
Transaction Analysis
Average daily sales: {format_currency(transaction_summary["average_daily_sales"])}
Biggest expense category: {transaction_summary["biggest_expense_category"]}
Recurring expense patterns: {", ".join(transaction_summary["recurring_expense_patterns"]) or "None detected"}
Cash flow risk level: {transaction_summary["cash_flow_risk"]}
""", unsafe_allow_html=True, ) def render_followup_visual(visual: dict[str, Any]) -> None: rows = "".join( f'
{item["label"]}{item["value"]}
' for item in visual.get("items", []) ) st.markdown( f"""
{visual["title"]}
{rows}
""", unsafe_allow_html=True, ) def render_followup_chart(chart: dict[str, Any]) -> None: chart_type = chart.get("chart_type", "bar") title = chart.get("title", "") base_layout = dict( title=dict(text=title, font=dict(size=14, color=THEME["text"])), paper_bgcolor="white", plot_bgcolor="white", margin=dict(t=48, b=30, l=50, r=20), font=dict(family="Inter, sans-serif", color=THEME["text"]), ) if chart_type == "pie": fig = go.Figure(go.Pie( labels=chart["labels"], values=chart["values"], hole=0.38, marker_colors=["#10B981", "#EF4444", "#2563EB", "#F59E0B", "#7C3AED", "#EC4899"], textinfo="label+percent", hovertemplate="%{label}: K%{value:,.2f}", )) fig.update_layout(**base_layout) elif chart_type == "bar": raw_colors = chart.get("colors", ["#2563EB"] * len(chart["values"])) colors = ["#EF4444" if v < 0 else c for v, c in zip(chart["values"], raw_colors)] fig = go.Figure(go.Bar( x=chart["labels"], y=chart["values"], marker_color=colors, text=[format_currency(v) for v in chart["values"]], textposition="outside", hovertemplate="%{x}: K%{y:,.2f}", )) fig.update_layout( yaxis=dict(title="Amount (Kwacha)", gridcolor="rgba(100,116,139,0.15)", zeroline=True), **base_layout, ) elif chart_type == "line": fig = go.Figure() months = chart.get("months", []) for series in chart.get("series", []): fig.add_trace(go.Scatter( x=months, y=series["values"], name=series["name"], line=dict(color=series["color"], width=2), mode="lines+markers", hovertemplate=f"%{{x}}: K%{{y:,.2f}}{series['name']}", )) fig.update_layout( yaxis=dict(title="Amount (Kwacha)", gridcolor="rgba(100,116,139,0.15)", zeroline=False), legend=dict(orientation="h", y=1.08), **base_layout, ) else: return st.plotly_chart(fig, use_container_width=True) def render_download_buttons() -> None: columns = st.columns(3, gap="small") template_items = list(SAMPLE_TEMPLATES.items()) for index, (label, path) in enumerate(template_items): with columns[index % 3]: mime = "text/csv" if path.suffix == ".csv" else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" st.download_button( f"Download {label}", data=load_template_bytes(path), file_name=path.name, mime=mime, use_container_width=True, key=f"download_{path.stem}", ) def split_summary_text(summary: str) -> tuple[str, str]: cleaned = " ".join(summary.split()).strip() if not cleaned: return ("Your analysis is ready.", "") sentences = [part.strip() for part in re.split(r"(?<=[.!?])\s+", cleaned) if part.strip()] if len(sentences) <= 1: return (sentences[0], "") return (sentences[0], " ".join(sentences[1:3])) def render_initial_analysis_in_chat(report: dict[str, Any]) -> None: """Clean conversational initial analysis โ€” minimal Claude.ai style.""" advisor = report["advisor"] cashflow = report["cashflow"] loan = report["loan"] doc_analysis = report.get("document_analysis") or {} headline, support = split_summary_text(report["final_summary"]) # One-line inline metrics strip _cf_profit = cashflow["profit"] _pl_label, _pl_value, _ = _profit_label_value(_cf_profit) metrics_line = ( f"Revenue {format_currency(cashflow['revenue'])}  ยท  " f"Expenses {format_currency(cashflow['expenses'])}  ยท  " f"{_pl_label} {_pl_value}  ยท  " f"Margin {cashflow.get('profit_margin', 0.0):.1f}%" ) # Natural flowing text โ€” no ALL CAPS headers st.markdown( f"""
Analysis complete
{html.escape(headline)}
{html.escape(support or advisor.get("needs_attention", ""))}
""", unsafe_allow_html=True, ) st.markdown('
', unsafe_allow_html=True) render_chat_metrics_bar(cashflow) st.markdown('
', unsafe_allow_html=True) needs = advisor.get("needs_attention", "").rstrip(".") move = advisor.get("best_next_move", "").rstrip(".") if needs and move: st.markdown( f"""
What to focus on now: {html.escape(needs)}.
Best next move: {html.escape(move)}.
""", unsafe_allow_html=True, ) # Full dashboard in expander โ€” keep for detail seekers with st.expander("๐Ÿ“Š See Full Dashboard โ€” Charts, Loan Score, Market Intelligence", expanded=False): render_summary_panel(report) render_data_sources_card(report) st.markdown("
", unsafe_allow_html=True) dash_left, dash_right = st.columns([1.15, 0.85], gap="large") with dash_left: render_cashflow_section(report) render_financial_advice_section(report) with dash_right: render_business_health_card(report) st.markdown("
", unsafe_allow_html=True) if report.get("transaction_summary"): render_transaction_card(report["transaction_summary"]) st.markdown("
", unsafe_allow_html=True) st.markdown( '
All Recommended Actions
', unsafe_allow_html=True, ) for i, action in enumerate(report["final_recommended_actions"], start=1): render_action_card(i, action) st.markdown("
", unsafe_allow_html=True) lower_left, lower_right = st.columns(2, gap="large") with lower_left: render_market_intelligence_section(report) with lower_right: render_loan_readiness_card(report) for detail in [ f"Risk level: {loan['risk_level']}", f"Reason: {loan['reason']}", *loan["how_to_improve"], ]: st.markdown(f'
{detail}
', unsafe_allow_html=True) with st.expander("Detailed reasoning: Borrowing & Debt Review"): st.write(loan["reasoning"]) # Conversational follow-up invitation doc_type = doc_analysis.get("document_type", "") period = doc_analysis.get("period_description") or doc_analysis.get("date_range") or "" if doc_type: doc_desc = f"your {doc_type.lower()}" + (f" ({period})" if period else "") else: doc_desc = "your business" st.markdown( f'
' f"I've analyzed {doc_desc}. " f"What would you like to explore first โ€” your expenses, loan readiness, or growth opportunities?" f"
", unsafe_allow_html=True, ) def init_session_state() -> None: defaults = { "business_type": "", "location": "", "products_services": "", "main_question": "", "manual_notes": "", "manual_revenue": 0.0, "manual_expenses": 0.0, "manual_debt": 0.0, "manual_staff": 0, "document_type": "Income Statement", "numbers_entry_mode": "upload", "selected_example_prompt": "", "selected_example_prompt_text": "", "scroll_target": None, "connected_provider": None, "selected_providers": set(), "provider_picker": "MTN Mobile Money", "analysis_report": None, "baseline": None, "metrics": None, "messages": [], "last_agent": "advisor", "active_page": "Chat Advisor", "show_welcome": True, "form_prefilled": False, "scroll_to_numbers": False, "scroll_to_results": False, "scroll_to_chat_conversation": False, "pending_demo_analysis_run": False, "scroll_to_demo_analyze": False, "hide_example_prompts": False, } for key, value in defaults.items(): st.session_state.setdefault(key, value) class _LocalUploadShim: """Mimic Streamlit's UploadedFile API so bundled samples can flow through `parse_uploaded_file` without an actual file_uploader event.""" def __init__(self, path: Path) -> None: self._bytes = path.read_bytes() self.name = path.name self.size = len(self._bytes) def getvalue(self) -> bytes: return self._bytes def _load_demo_analysis() -> bool: """Load a bundled sample document and prep all session state required by the analysis pipeline. Sets `auto_run_demo` so the main page kicks off Run Full Business Analysis automatically on the next render. Returns True on success, False if the sample file is unavailable. """ demo_path = SAMPLE_TEMPLATES.get("Income Statement CSV") if demo_path is None or not Path(demo_path).exists(): return False shim = _LocalUploadShim(Path(demo_path)) try: parsed = parse_uploaded_file(shim, "Income Statement") except Exception: return False for _k in ( "baseline", "metrics", "parsed_document", "manual_revenue", "manual_expenses", "manual_profit", "document_revenue", "document_expenses", "generated_report", "analysis_report", ): st.session_state.pop(_k, None) for _k in [k for k in st.session_state if k.startswith("forecast_summary_")]: del st.session_state[_k] st.session_state["forecast_chat"] = [] st.session_state["business_type"] = "Grocery shop" st.session_state["location"] = "Lusaka" st.session_state["products_services"] = "Groceries, mealie meal, drinks" st.session_state["main_question"] = "Should I restock or save cash?" st.session_state["manual_notes"] = ( "I run a small grocery shop in Lusaka. This week I made K4,500 in sales. " "I spent K2,700 on stock, K500 on rent, K250 on transport, and K300 on other expenses. " "I owe K1,000 to my supplier." ) st.session_state["manual_debt"] = 1000.0 st.session_state["parsed_document"] = parsed doc_rev = float(parsed.get("revenue") or 0) doc_exp = float(parsed.get("expenses") or 0) if doc_rev > 0 or doc_exp > 0: st.session_state["manual_revenue"] = doc_rev st.session_state["manual_expenses"] = doc_exp baseline = { "monthly_revenue": doc_rev, "monthly_expenses": doc_exp, "monthly_profit": doc_rev - doc_exp, "months_of_data": int(parsed.get("months_of_data") or 1), "source": ( f"Demo sample: {parsed.get('document_type', 'document')} " f"({Path(demo_path).name})" ), "expenses_breakdown": parsed.get("expenses_breakdown", {}), } st.session_state["baseline"] = baseline st.session_state["metrics"] = compute_metrics(baseline) st.session_state["_doc_fingerprint"] = f"{shim.name}:{shim.size}" st.session_state["numbers_entry_mode"] = "upload" st.session_state["hide_example_prompts"] = True st.session_state["show_welcome"] = False st.session_state["form_prefilled"] = True st.session_state["scroll_to_numbers"] = False st.session_state["auto_run_demo"] = True st.session_state["demo_mode_active"] = True st.session_state["pending_demo_analysis_run"] = True st.session_state["scroll_to_demo_analyze"] = True st.session_state["scroll_target"] = "duka-ai-analyze-anchor" return True def render_welcome_screen(_sample_cases: list[dict]) -> None: st.markdown( """
Duka AI
Smart finance for every small business
Understand your cash flow, loan readiness, and growth opportunities โ€” in seconds. Built for African small businesses.
""", unsafe_allow_html=True, ) st.markdown('
', unsafe_allow_html=True) col1, col2, col3 = st.columns(3, gap="medium") with col1: st.markdown( """
โญ Recommended
โšก
Try Demo Analysis
One click loads a real sample income statement, pre-fills the business profile, and runs the full multi-agent analysis automatically โ€” perfect for first-time visitors.
""", unsafe_allow_html=True, ) if st.button("Try Demo Analysis โ†’", key="welcome_sample", use_container_width=True, type="primary"): with st.status("โšก Starting your demoโ€ฆ", expanded=True) as _demo_status: st.write("๐Ÿ“‚ Loading bundled sample income statementโ€ฆ") ok = _load_demo_analysis() if ok: st.write("๐Ÿ“Š Extracting revenue, expenses, and profit from the sampleโ€ฆ") st.write("๐Ÿช Applying **Lusaka Grocery Shop** business profileโ€ฆ") _demo_status.update(label="โœ… Demo ready โ€” opening workspaceโ€ฆ", state="complete") else: st.write("โš ๏ธ Sample CSV not found โ€” falling back to manual figures.") _demo_status.update(label="Using manual demo figuresโ€ฆ", state="complete") if ok: st.rerun() else: st.session_state.business_type = "Grocery shop" st.session_state.location = "Lusaka" st.session_state.products_services = "Groceries, mealie meal, drinks" st.session_state.main_question = "Should I restock or save cash?" st.session_state.manual_notes = ( "I run a small grocery shop in Lusaka. This week I made K4,500 in sales. " "I spent K2,700 on stock, K500 on rent, K250 on transport, and K300 on other expenses. " "I owe K1,000 to my supplier." ) st.session_state.manual_revenue = 4500.0 st.session_state.manual_expenses = 3750.0 st.session_state.manual_debt = 1000.0 st.session_state.numbers_entry_mode = "manual" st.session_state.hide_example_prompts = True st.session_state.show_welcome = False st.session_state.form_prefilled = True st.session_state.scroll_to_numbers = True st.session_state.auto_run_demo = True st.session_state.pending_demo_analysis_run = True st.session_state.scroll_to_demo_analyze = True st.session_state.scroll_target = "duka-ai-analyze-anchor" st.session_state.demo_mode_active = True st.rerun() with col2: st.markdown( """
๐Ÿ“„
Upload Documents
Upload a CSV, Excel, or PDF โ€” income statement, bank statement, or sales record for AI extraction.
""", unsafe_allow_html=True, ) if st.button("Upload Documents โ†’", key="welcome_upload", use_container_width=True): # Navigate to form, scroll to upload section โ€” user uploads then clicks Analyze st.session_state.show_welcome = False st.session_state.numbers_entry_mode = "upload" st.session_state.scroll_to_numbers = True st.rerun() with col3: st.markdown( """
โœ๏ธ
Start from Scratch
Describe your business and enter figures manually โ€” no documents needed, just what you know.
""", unsafe_allow_html=True, ) if st.button("Start from Scratch โ†’", key="welcome_scratch", use_container_width=True): # Navigate to the empty form โ€” user fills in their details then clicks Analyze st.session_state.show_welcome = False st.session_state.numbers_entry_mode = "manual" st.session_state.form_prefilled = False st.rerun() def render_sidebar_navigation(settings: Any) -> str: with st.sidebar: st.markdown( """ """, unsafe_allow_html=True, ) for section, items in NAV_SECTIONS.items(): st.markdown(f'{section.title()}', unsafe_allow_html=True) for label in items: page = NAV_LABEL_TO_PAGE[label] is_active = page == st.session_state.active_page if st.button( label, key=f"nav_{page.lower().replace(' ', '_')}", use_container_width=True, type="primary" if is_active else "secondary", ): st.session_state.active_page = page st.rerun() st.markdown( '', unsafe_allow_html=True, ) return st.session_state.active_page def require_report() -> dict[str, Any] | None: report = st.session_state.get("analysis_report") if report: return report st.markdown( """
Analysis required
Run Chat Advisor first
This tool needs your latest revenue, expenses, profit, and business context before it can calculate anything useful.
""", unsafe_allow_html=True, ) if st.button("Open Chat Advisor", type="primary"): st.session_state.active_page = "Chat Advisor" st.rerun() return None def render_page_header(title: str, subtitle: str, badge: str | None = None) -> None: badge_html = f'
{html.escape(badge)}
' if badge else "" st.markdown( f""" """, unsafe_allow_html=True, ) def render_tool_tip(title: str, copy: str) -> None: st.markdown( f"""
{html.escape(title)} {html.escape(copy)}
""", unsafe_allow_html=True, ) def render_dashboard_page() -> None: render_page_header( "Dashboard", "Full business snapshot: financial health, cash flow, loan readiness, market context, and next actions.", "Full report", ) report = require_report() if not report: return render_summary_panel(report) render_data_sources_card(report) left, right = st.columns([1.15, 0.85], gap="large") with left: render_cashflow_section(report) render_financial_advice_section(report) with right: render_business_health_card(report) if report.get("transaction_summary"): render_transaction_card(report["transaction_summary"]) st.markdown('
All Recommended Actions
', unsafe_allow_html=True) for i, action in enumerate(report["final_recommended_actions"], start=1): render_action_card(i, action) lower_left, lower_right = st.columns(2, gap="large") with lower_left: render_market_intelligence_section(report) with lower_right: render_loan_readiness_card(report) def _generate_forecast_ai_summary(baseline: dict, forecast: dict, scenario_key: str) -> str: """Call LLM to narrate the forecast. Falls back to template if LLM unavailable.""" from agents import request_llm s = forecast["summary"] sc = forecast["scenario"] system = ( "You are Duka AI's Cash Flow Analyst. In 2-3 sentences summarise what this " "6-month forecast means for the business owner. Be direct and practical. Use K (Kwacha). " "No bullet points." ) user = ( f"Scenario: {sc['label']} โ€” {sc['description']}\n" f"Starting: revenue K{baseline['monthly_revenue']:,.0f}, " f"expenses K{baseline['monthly_expenses']:,.0f}, profit K{baseline['monthly_profit']:,.0f}\n" f"Month 6: revenue K{s['final_revenue']:,.0f}, expenses K{s['final_expenses']:,.0f}, " f"profit K{s['final_profit']:,.0f}\n" f"Profitable months: {s['profitable_months']}/{s['total_months']}\n" + (f"First difficult month: {s['first_loss_month']}\n" if s["first_loss_month"] else "") + "Summarise in 2-3 plain sentences what this means for the owner." ) result = request_llm(system, user, max_tokens=120) if result: return result.strip() if s["all_profitable"]: return ( f"Under the {sc['label']} scenario all 6 months stay profitable, " f"ending at K{s['final_profit']:,.0f} profit and a {s['final_margin']:.1f}% margin. " f"Revenue grows from K{baseline['monthly_revenue']:,.0f} to K{s['final_revenue']:,.0f}." ) return ( f"The {sc['label']} scenario shows {s['profitable_months']} out of {s['total_months']} months profitable. " f"The first difficult month is {s['first_loss_month']}. " "Consider reducing the expense growth rate or boosting revenue to stay positive." ) _CHART_TRIGGERS_FORECAST = ("graph", "chart", "plot", "visualize", "visualise", "show me a chart", "show graph", "line chart") # Quick prompts / natural phrases that imply a chart without saying "chart" _FORECAST_CHART_PHRASES = ( "riskiest", "expense breakdown", "profit trend", "revenue vs", "show profit", "trendline", "trend line", ) _CHART_AFFIRM_FORECAST = ("yes", "yeah", "yep", "sure", "ok", "okay", "please", "go ahead", "show me", "show it") def _is_forecast_chart_request(q: str, prev_assistant_msg: str) -> bool: """True if the user is asking for / agreeing to a chart on the forecast page.""" q = (q or "").lower().strip() if not q: return False if any(kw in q for kw in _CHART_TRIGGERS_FORECAST): return True if any(p in q for p in _FORECAST_CHART_PHRASES): return True # Short affirmation right after the agent offered a chart if len(q.split()) <= 6 and any( q == w or q.startswith(w + " ") or q.startswith(w + ",") or q.startswith(w + ".") for w in _CHART_AFFIRM_FORECAST ): if "chart" in (prev_assistant_msg or "").lower() or "graph" in (prev_assistant_msg or "").lower() or "plot" in (prev_assistant_msg or "").lower(): return True return False def _margin_band_colors(margins: list[float]) -> list[str]: """SME-tuned bands: >20% healthy (green), 10โ€“20% watch (amber), 0โ€“10% thin (red), <0% loss (deep red). Aligns with health/loan scoring.""" out: list[str] = [] for v in margins: if v <= 0: out.append("#B91C1C") elif v < 10: out.append("#EF4444") elif v < 20: out.append("#F59E0B") else: out.append("#10B981") return out def _build_forecast_page_chart( user_q: str, baseline: dict, scenario_key: str, forecast: dict, chat_history: list, ) -> dict[str, Any] | None: """Build a Plotly-ready chart dict (same shape as AI JSON) when keywords match. Honours the horizon when intent detects months (e.g. 'in 10 months'). """ from tools.financial_engine import detect_forecast_intent prev_assistant = "" for m in reversed(chat_history[:-1] if chat_history else []): if m.get("role") == "assistant": prev_assistant = m.get("content", "") break if not _is_forecast_chart_request(user_q, prev_assistant): return None intent = detect_forecast_intent(user_q) if not intent: for m in reversed(chat_history[:-1] if chat_history else []): if m.get("role") == "user": intent = detect_forecast_intent(m.get("content", "")) if intent: break months_n = intent["months"] if intent else len(forecast.get("months") or []) or 6 fc = calculate_forecast(baseline, scenario_key, months=months_n) rows = fc["months"] labels = [r["month_name"] for r in rows] ql = (user_q or "").lower() scen_label = fc["scenario"]["label"] if "riskiest" in ql: margins = [round(float(r["margin"]), 1) for r in rows] return { "type": "bar", "title": f"Riskiest months โ€” profit margin % ({scen_label})", "data": { "labels": labels, "datasets": [ { "name": "Profit Margin (%)", "values": margins, "colors": _margin_band_colors(margins), } ], }, } if "expense" in ql and "breakdown" in ql: return { "type": "bar", "title": f"Monthly expenses ({scen_label})", "data": { "labels": labels, "datasets": [ { "name": "Monthly Expenses", "values": [int(r["expenses"]) for r in rows], "color": "#E24B4A", } ], }, } if "profit" in ql and "trend" in ql: return { "type": "line", "title": f"Profit trend ({scen_label})", "data": { "labels": labels, "datasets": [ { "name": "Profit", "values": [int(r["profit"]) for r in rows], "color": "#378ADD", } ], }, } if ("revenue" in ql and "expense" in ql) or "revenue vs" in ql: return { "type": "line", "title": f"Revenue vs expenses ({scen_label})", "data": { "labels": labels, "datasets": [ {"name": "Revenue", "values": [int(r["revenue"]) for r in rows], "color": "#1D9E75"}, {"name": "Expenses", "values": [int(r["expenses"]) for r in rows], "color": "#E24B4A"}, ], }, } return { "type": "line", "title": f"Forecast โ€” {scen_label} ({months_n} months)", "data": { "labels": labels, "datasets": [ {"name": "Revenue", "values": [int(r["revenue"]) for r in rows], "color": "#10B981"}, {"name": "Expenses", "values": [int(r["expenses"]) for r in rows], "color": "#EF4444"}, {"name": "Profit", "values": [int(r["profit"]) for r in rows], "color": "#2563EB"}, ], }, } _FORECAST_GREETING_PATTERNS = ( r"^\s*(hi|hey|hello|hola|yo|sup|howdy|good\s*(morning|afternoon|evening))\b", r"\bhow\s+are\s+you\b", r"\bwho\s+are\s+you\b", r"\bwhat\s+are\s+you\b", r"\bwhat\s+can\s+you\s+do\b", r"\bhelp\b\s*$", ) def _classify_forecast_user_input(text: str, months: list[dict]) -> str: """Classify the user's input before we ever call the LLM. Returns one of: 'gibberish', 'greeting', 'identity', 'thanks', 'normal'. """ raw = (text or "").strip() if not raw: return "gibberish" low = raw.lower() if any(re.search(p, low) for p in _FORECAST_GREETING_PATTERNS): if any(w in low for w in ("who", "what")): return "identity" return "greeting" if low in ("thanks", "thank you", "ty", "ta", "cheers"): return "thanks" if _is_low_vowel_noise(raw, 4, 0.20): return "gibberish" letters = "".join(c for c in raw if c.isalpha()) has_digit = any(c.isdigit() for c in raw) has_finance_term = any( w in low for w in ( "revenue", "expense", "expenses", "profit", "margin", "month", "chart", "graph", "plot", "trend", "forecast", "scenario", "loss", "earn", "make", "growth", "risk", "kwacha", "k", "show", "compare", "vs", "versus", "next", "cash", "money", "income", ) ) has_month = any( m["month_name"].split()[0].lower() in low for m in months ) or any( x in low for x in ( "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", ) ) if ( len(letters) <= 8 and not has_digit and not has_finance_term and not has_month and len(low.split()) <= 2 ): return "gibberish" return "normal" def _forecast_intro_reply(months: list[dict], sc: dict) -> str: first, last = months[0], months[-1] return ( "Iโ€™m **Duka AIโ€™s Cash Flow Analyst**. I read your **6โ€‘month forecast** and answer " "questions in plain language using the numbers from this scenario.\n\n" f"Right now youโ€™re looking at the **{sc['label']}** scenario " f"({first['month_name']} โ†’ {last['month_name']}). Try a quick prompt above, or ask:\n\n" "โ€ข *โ€œWhatโ€™s my profit in 3 months?โ€* \n" "โ€ข *โ€œShow profit trend.โ€* \n" "โ€ข *โ€œWhy are expenses rising?โ€*" ) def _forecast_greeting_reply(months: list[dict], sc: dict) -> str: return ( "Hi! Iโ€™m **Duka AIโ€™s Cash Flow Analyst** โ€” here to help with your forecast. " f"Youโ€™re on the **{sc['label']}** scenario ({months[0]['month_name']}โ€“{months[-1]['month_name']}). " "Ask me about a month, profit, expenses, or say *โ€œrevenue vs expensesโ€* for a chart." ) def _forecast_gibberish_reply(months: list[dict]) -> str: return ( "I didnโ€™t catch that. Try a short question about **this forecast** " f"({months[0]['month_name']}โ€“{months[-1]['month_name']}) โ€” for example: " "*โ€œprofit in 3 monthsโ€*, *โ€œexpense breakdownโ€*, or *โ€œriskiest monthsโ€*." ) def _handle_forecast_question( question: str, baseline: dict, forecast: dict, scenario_key: str, chat_history: list, ) -> str: """Answer a forecast question using ONLY computed forecast numbers. If the user asks about a horizon longer than the page's default forecast (e.g. "in 10 months" when the page shows 6), recompute a fresh forecast that covers that horizon so the answer is grounded in real numbers. """ from agents import request_llm_chat from tools.financial_engine import detect_forecast_intent s = forecast["summary"] sc = forecast["scenario"] months_now = forecast["months"] # โ”€โ”€ Pre-LLM classification: short-circuit for noise / greetings / identity kind = _classify_forecast_user_input(question, months_now) if kind == "gibberish": return _forecast_gibberish_reply(months_now) if kind == "identity": return _forecast_intro_reply(months_now, sc) if kind == "greeting": return _forecast_greeting_reply(months_now, sc) if kind == "thanks": return "Youโ€™re welcome! Ask me anything else about this forecast." intent = detect_forecast_intent(question) requested_months = intent["months"] if intent else 0 page_months = len(forecast["months"]) # If the user wants a horizon beyond what the page rendered, recompute. if requested_months > page_months: forecast = calculate_forecast(baseline, scenario_key, months=requested_months) s = forecast["summary"] sc = forecast["scenario"] months = forecast["months"] target_block = "" if requested_months and 1 <= requested_months <= len(months): target = months[requested_months - 1] target_block = ( f"\nDIRECT ANSWER FOR MONTH {requested_months} ({target['month_name']}):\n" f" Revenue: K{target['revenue']:,.0f}\n" f" Expenses: K{target['expenses']:,.0f}\n" f" Profit: K{target['profit']:,.0f} (margin {target['margin']:.1f}%)\n" "Use these exact figures when answering. Do NOT say data is unavailable.\n" ) cumulative_block = "" if requested_months and requested_months > 1 and len(months) >= requested_months: segment = months[:requested_months] tot_r = sum(m["revenue"] for m in segment) tot_e = sum(m["expenses"] for m in segment) tot_p = sum(m["profit"] for m in segment) cumulative_block = ( f"\nCUMULATIVE FOR FIRST {requested_months} MONTHS (sum of month-by-month rows):\n" f" Total revenue: K{tot_r:,.0f}\n" f" Total expenses: K{tot_e:,.0f}\n" f" Total profit: K{tot_p:,.0f}\n" "If the user asks how much they make/earn or total profit over that many months, " "lead with these cumulative totals (not only the last month).\n" ) context = ( "FORECAST CONTEXT (use ONLY these numbers - never invent figures):\n" f"Scenario: {sc['label']} - {sc['description']}\n" f"Starting: revenue K{baseline['monthly_revenue']:,.0f}, " f"expenses K{baseline['monthly_expenses']:,.0f}, profit K{baseline['monthly_profit']:,.0f}\n" f"Revenue growth: {sc['rev_growth']*100:+.1f}% per month | " f"Expense growth: {sc['exp_growth']*100:+.1f}% per month\n" f"Profitable months: {s['profitable_months']}/{s['total_months']}\n" + (f"First loss month: {s['first_loss_month']}\n" if s["first_loss_month"] else "") + "Month-by-month:\n" + "\n".join( f" Month {m['month_num']} ({m['month_name']}): rev K{m['revenue']:,.0f}, " f"exp K{m['expenses']:,.0f}, profit K{m['profit']:,.0f} ({m['margin']:.1f}%)" for m in months ) + target_block + cumulative_block ) # Build chart-ready arrays the AI can copy verbatim into JSON chart_labels = [m["month_name"] for m in months] chart_rev = [int(m["revenue"]) for m in months] chart_exp = [int(m["expenses"]) for m in months] chart_profit = [int(m["profit"]) for m in months] chart_margins = [round(m["margin"], 1) for m in months] margin_colors = [ "#10B981" if v > 25 else ("#F59E0B" if v >= 15 else "#EF4444") for v in chart_margins ] chart_data_block = ( "\n\nCHART-READY DATA โ€” copy these EXACT values into JSON, do NOT alter them:\n" f"Labels: {json.dumps(chart_labels)}\n" f"Revenue: {json.dumps(chart_rev)}\n" f"Expenses: {json.dumps(chart_exp)}\n" f"Profit: {json.dumps(chart_profit)}\n" f"Margins (%): {json.dumps(chart_margins)}\n" f"Margin colors: {json.dumps(margin_colors)}\n" ) chart_instructions = ( "\n\nCHART INSTRUCTIONS:\n" "When the user asks for a chart, graph, trend, visualization, or breakdown, " "FIRST write a 1โ€“2 sentence plain-language explanation of what the chart will show " "(use the actual numbers, e.g. 'Revenue grows from K50,553 to K61,505 while expenses rise from K75,874 to K77,790'). " "THEN end your response with a block using this exact format. " "NEVER reply with only the JSON โ€” there must always be readable prose first.\n" "\n" "{\n" ' "type": "line",\n' ' "title": "...",\n' ' "data": {\n' ' "labels": [...],\n' ' "datasets": [{"name": "...", "values": [...], "color": "..."}]\n' ' }\n' "}\n" "\n\n" "If you show BOTH monthly profit in Kwacha AND margin % on one line chart, use two datasets: " "one named e.g. 'Profit' and one named e.g. 'Profit margin (%)' or 'Margin (%)' so % stays on its own scale.\n" "What to produce per trigger (use CHART-READY DATA above):\n" "โ€ข 'show profit trend' โ†’ type=line, 1 dataset: Profit #378ADD\n" "โ€ข 'revenue vs expenses' โ†’ type=line, 2 datasets: Revenue #1D9E75, Expenses #E24B4A\n" "โ€ข 'riskiest months' / 'risk' โ†’ type=bar, 1 dataset: name='Profit Margin %', values=Margins, " "use 'colors' (array) = Margin colors โ€” NOT a single 'color'\n" "โ€ข 'expense breakdown' โ†’ type=bar, 1 dataset: name='Monthly Expenses', values=Expenses, color=#E24B4A\n" "โ€ข 'show me a chart' / 'all' โ†’ type=line, 3 datasets: Revenue #1D9E75, Expenses #E24B4A, Profit #378ADD\n\n" "IMPORTANT: Use ONLY the CHART-READY DATA values โ€” never invent numbers.\n" "IMPORTANT: For riskiest months use per-bar colors as 'colors': [...] or 'color': [...] (array).\n" "Do NOT include a block if the user is not asking for a chart or visualization." ) system = ( "You are **Duka AI's Cash Flow Analyst**, a financial assistant inside the Duka AI app. " "If the user asks who/what you are, say so plainly โ€” never call yourself a generic computer program. " "Use ONLY the numbers in FORECAST CONTEXT. Every Kwacha figure you mention must match " "a value listed there (or a cumulative sum of those rows). Never invent month-specific amounts. " "SCOPE: Only answer using this scenario's month-by-month rows. If the question is unrelated " "(general trivia, other businesses, topics with no numbers here), reply in ONE short sentence that you " "only have this forecast and suggest one example question โ€” do not fabricate data, do not give " "long generic business advice. " "Do not output random characters, keyboard mash, or filler before your answer. " "If a CUMULATIVE FOR FIRST N MONTHS block is present and the user asked about that span, " "lead with those totals. If only DIRECT ANSWER FOR MONTH X applies, use that month. " "When the forecast rows fully answer the horizon, do not refuse โ€” use those numbers. " "Be concise (2-4 sentences). For non-chart questions you may offer: 'Want me to plot this on a chart?' " "OUTPUT STYLE: Plain sentences and Markdown only. Do NOT use LaTeX (no \\times, \\text{...}, \\( \\), " "\\[ \\]). For maths use readable lines like \"K50,553 ร— 1.04 = K52,575\"." f"\n\n{context}" + chart_data_block + chart_instructions ) messages = [ {"role": "system", "content": system}, *[{"role": m["role"], "content": m["content"]} for m in chat_history[-6:]], {"role": "user", "content": question}, ] result = request_llm_chat(messages, temperature=0.15, max_tokens=600) if not result: return "I couldn't generate a response right now. Please try again." chart_tag = "" m_chart = re.search(r".*?", result, flags=re.DOTALL | re.IGNORECASE) if m_chart: chart_tag = m_chart.group(0).strip() prose = re.sub(r".*?", "", result, flags=re.DOTALL | re.IGNORECASE).strip() _bare, prose_no_json = _try_extract_bare_chart_json(prose) prose_check = prose_no_json if prose_no_json else prose if prose_check.strip(): if _is_low_vowel_noise(prose_check, 15, 0.13): fixed = _safe_deterministic_forecast_answer(question, months, sc, baseline) return f"{fixed}\n\n{chart_tag}" if chart_tag else fixed if not _forecast_prose_is_grounded(prose_check, months, baseline): prose_fixed = _repair_or_replace_forecast_prose( prose_check, question, months, sc, baseline ) if chart_tag: return f"{prose_fixed}\n\n{chart_tag}" return prose_fixed return result def _forecast_allowed_kwacha_ints(months: list[dict], baseline: dict) -> set[int]: """Every integer Kwacha amount that may appear in a grounded forecast reply.""" s: set[int] = set() for m in months: for key in ("revenue", "expenses", "profit"): s.add(int(round(float(m[key])))) for key in ("monthly_revenue", "monthly_expenses", "monthly_profit"): v = baseline.get(key) if v is not None: s.add(int(round(float(v)))) n = len(months) for i in range(n): seg = months[: i + 1] s.add(int(round(sum(float(x["revenue"]) for x in seg)))) s.add(int(round(sum(float(x["expenses"]) for x in seg)))) s.add(int(round(sum(float(x["profit"]) for x in seg)))) return s def _extract_kwacha_ints_from_prose(text: str) -> list[int]: out: list[int] = [] for m in re.finditer(r"K\s*([\d,]+)", text or ""): try: out.append(int(m.group(1).replace(",", ""))) except ValueError: continue return out def _forecast_prose_is_grounded(prose: str, months: list[dict], baseline: dict) -> bool: """True if every K-amount in prose appears in the allowed forecast math.""" if not (prose or "").strip(): return True allowed = _forecast_allowed_kwacha_ints(months, baseline) amounts = _extract_kwacha_ints_from_prose(prose) return all(a in allowed for a in amounts) def _is_low_vowel_noise(text: str, min_letters: int = 12, vowel_ratio_max: float = 0.14) -> bool: """Very low vowel density usually means noise / accidental keyboard mash.""" t = "".join(c for c in (text or "") if c.isalpha()) if len(t) < min_letters: return False vowels = sum(1 for c in t if c.lower() in "aeiou") return (vowels / len(t)) < vowel_ratio_max def _is_gibberish_user_question(text: str) -> bool: return _is_low_vowel_noise(text or "", 12, 0.14) def _safe_deterministic_forecast_answer( question: str, months: list[dict], sc: dict[str, Any], baseline: dict, ) -> str: """Facts-only reply when the model hallucinated amounts.""" ql = (question or "").lower() first, last = months[0], months[-1] hi_exp_m = max(months, key=lambda m: float(m["expenses"])) hi_rev_m = max(months, key=lambda m: float(m["revenue"])) if any( w in ql for w in ( "revenue", "expense", "vs", "versus", "compare", "highest", "most", "biggest", "largest", ) ): return ( f"In the **{sc['label']}** scenario: revenue moves from **K{first['revenue']:,.0f}** " f"({first['month_name']}) to **K{last['revenue']:,.0f}** ({last['month_name']}); " f"expenses from **K{first['expenses']:,.0f}** to **K{last['expenses']:,.0f}**. " f"Highest expense month in this forecast: **{hi_exp_m['month_name']}** at **K{hi_exp_m['expenses']:,.0f}**. " f"Peak revenue month: **{hi_rev_m['month_name']}** (**K{hi_rev_m['revenue']:,.0f}**)." ) return ( f"**{sc['label']}** forecast ({first['month_name']} โ†’ {last['month_name']}): " f"revenue **K{first['revenue']:,.0f}**โ†’**K{last['revenue']:,.0f}**, " f"expenses **K{first['expenses']:,.0f}**โ†’**K{last['expenses']:,.0f}**, " f"profit **K{first['profit']:,.0f}**โ†’**K{last['profit']:,.0f}**. " "Ask about a specific month or say โ€œrevenue vs expensesโ€ for a chart." ) def _repair_or_replace_forecast_prose( prose: str, question: str, months: list[dict], sc: dict[str, Any], baseline: dict, ) -> str: """Drop leading junk paragraphs, or replace with deterministic facts.""" if _forecast_prose_is_grounded(prose, months, baseline): return prose.strip() chunks = [c.strip() for c in re.split(r"\n\s*\n+", prose) if c.strip()] for i in range(len(chunks)): tail = "\n\n".join(chunks[i:]) if _forecast_prose_is_grounded(tail, months, baseline): return tail return _safe_deterministic_forecast_answer(question, months, sc, baseline) def sanitize_forecast_answer_text(raw: str) -> str: """Turn LaTeX-style fragments into readable text for Markdown (Streamlit-safe).""" if not raw: return raw s = raw.replace("\\times", "ร—").replace("\\cdot", "ร—") for _ in range(8): n = re.sub(r"\\text\{([^{}]*)\}", r"\1", s) if n == s: break s = n s = re.sub(r"\\\(|\\\)|\\\[|\\\]", "", s) return re.sub(r"\n{3,}", "\n\n", s).strip() def _looks_like_forecast_chart_json(d: Any) -> bool: if not isinstance(d, dict): return False if d.get("type") not in ("line", "bar"): return False data = d.get("data") if not isinstance(data, dict): return False labels, sets = data.get("labels"), data.get("datasets") return isinstance(labels, list) and isinstance(sets, list) and len(labels) > 0 and len(sets) > 0 def _extract_first_brace_json_object(s: str, start: int = 0) -> tuple[str | None, int]: """Return (json_blob, index_after_blob) for first balanced `{...}` from start, string-aware.""" i = s.find("{", start) if i < 0: return None, start depth = 0 in_str = False esc = False for j in range(i, len(s)): c = s[j] if in_str: if esc: esc = False elif c == "\\": esc = True elif c == '"': in_str = False continue if c == '"': in_str = True elif c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return s[i : j + 1], j + 1 return None, start def _try_extract_bare_chart_json(text: str) -> tuple[dict | None, str]: """When the model emits chart JSON without tags, strip it and return chart_data.""" raw = text.strip() if not raw: return None, text # Markdown fenced block fence = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", raw, re.DOTALL) if fence: try: d = json.loads(fence.group(1)) if _looks_like_forecast_chart_json(d): clean = raw.replace(fence.group(0), "").strip() return d, re.sub(r"\n{3,}", "\n\n", clean) except Exception: pass # Whole message is just JSON if raw.startswith("{") and raw.endswith("}"): try: d = json.loads(raw) if _looks_like_forecast_chart_json(d): return d, "" except Exception: pass # Scan for embedded chart object (common when prose + JSON) search_from = 0 while True: blob, end = _extract_first_brace_json_object(raw, search_from) if not blob: break try: d = json.loads(blob) if _looks_like_forecast_chart_json(d): pos = raw.find(blob) clean = (raw[:pos] + raw[pos + len(blob) :]).strip() return d, re.sub(r"\n{3,}", "\n\n", clean) except Exception: pass search_from = raw.find("{", search_from + 1) if search_from < 0: break return None, text def parse_and_render_chart(response_text: str) -> tuple[str, dict | None]: """Extract a โ€ฆ block from an AI response. Returns (clean_text, chart_data_dict). chart_data_dict is None when no valid block is found. """ chart_pattern = r"(.*?)" match = re.search(chart_pattern, response_text, re.DOTALL | re.IGNORECASE) if match: clean_text = re.sub(chart_pattern, "", response_text, flags=re.DOTALL | re.IGNORECASE).strip() try: chart_data = json.loads(match.group(1).strip()) return clean_text, chart_data except Exception: return clean_text, None bare, stripped = _try_extract_bare_chart_json(response_text) return stripped, bare def _format_kwacha(v: float) -> str: return f"K{v:,.0f}" def _format_pct(v: float) -> str: return f"{v:+.1f}%" if v else "0.0%" def _caption_for_forecast_chart(chart_data: dict, scenario_label: str | None = None) -> str: """Generate a multi-sentence breakdown from a chart JSON. Used both when the model returns no prose and when its prose is too thin (so the user always gets a numbered breakdown alongside the chart). """ try: title = (chart_data.get("title") or "").strip() data = chart_data.get("data") or {} labels = data.get("labels") or [] datasets = data.get("datasets") or [] if not labels or not datasets: return "" first, last = labels[0], labels[-1] scen_suffix = f" under the **{scenario_label}** scenario" if scenario_label else "" def _delta_pct(v0: float, vN: float) -> str: if v0 == 0: return "" pct = (vN - v0) / abs(v0) * 100 return f" ({pct:+.1f}%)" # Single-series shortcuts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if len(datasets) == 1: d = datasets[0] name = (d.get("name") or "").strip() or title or "Series" vals = d.get("values") or [] if not vals: return "" is_pct = "%" in name or "margin" in name.lower() v0, vN = vals[0], vals[-1] if is_pct: trend = "improves" if vN > v0 else ("declines" if vN < v0 else "stays flat") avg = sum(vals) / len(vals) return ( f"**{name}** {trend} from **{v0:.1f}%** in {first} to **{vN:.1f}%** in " f"{last}{scen_suffix} โ€” average of **{avg:.1f}%** across the {len(vals)} months.\n\n" f"- **Highest:** {max(vals):.1f}% ยท **Lowest:** {min(vals):.1f}%\n" f"- **Net change:** {_format_pct(vN - v0)}\n" ) trend = "rises" if vN > v0 else ("falls" if vN < v0 else "stays flat") total = sum(vals) return ( f"**{name}** {trend} from **{_format_kwacha(v0)}** in {first} to " f"**{_format_kwacha(vN)}** in {last}{scen_suffix}{_delta_pct(v0, vN)}.\n\n" f"- **Total across the period:** {_format_kwacha(total)}\n" f"- **Average per month:** {_format_kwacha(total / len(vals))}\n" ) # Two-series โ€” typical revenue vs expenses, profit vs margin โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if len(datasets) == 2: a, b = datasets[0], datasets[1] name_a = (a.get("name") or "Series A").strip() name_b = (b.get("name") or "Series B").strip() va, vb = a.get("values") or [], b.get("values") or [] if not va or not vb: return "" a_pct = "%" in name_a or "margin" in name_a.lower() b_pct = "%" in name_b or "margin" in name_b.lower() fmt_a = (lambda v: f"{v:.1f}%") if a_pct else _format_kwacha fmt_b = (lambda v: f"{v:.1f}%") if b_pct else _format_kwacha lines = [ f"Here's how **{name_a}** and **{name_b}** move from {first} to " f"{last}{scen_suffix}:", "", f"- **{name_a}:** {fmt_a(va[0])} โ†’ {fmt_a(va[-1])}" + (f" {_delta_pct(va[0], va[-1])}" if not a_pct else f" ({(va[-1] - va[0]):+.1f} pts)"), f"- **{name_b}:** {fmt_b(vb[0])} โ†’ {fmt_b(vb[-1])}" + (f" {_delta_pct(vb[0], vb[-1])}" if not b_pct else f" ({(vb[-1] - vb[0]):+.1f} pts)"), ] # If looks like Revenue vs Expenses, add net (gap) if not a_pct and not b_pct and len(va) == len(vb): gap_first = va[0] - vb[0] gap_last = va[-1] - vb[-1] lines.append( f"- **Gap ({name_a} โˆ’ {name_b}):** " f"{_format_kwacha(gap_first)} โ†’ {_format_kwacha(gap_last)}" ) return "\n".join(lines) # Three-series fallback (e.g. Revenue + Expenses + Profit) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ try: names = [(d.get("name") or "?").strip() for d in datasets] firsts = [d.get("values", [None])[0] for d in datasets] lasts = [d.get("values", [None])[-1] for d in datasets] lines = [ f"Here's the breakdown from {first} to {last}{scen_suffix}:", "", ] for n, v0, vN in zip(names, firsts, lasts): if v0 is None or vN is None: continue is_pct = "%" in n or "margin" in n.lower() fmt = (lambda v: f"{v:.1f}%") if is_pct else _format_kwacha lines.append(f"- **{n}:** {fmt(v0)} โ†’ {fmt(vN)}") return "\n".join(lines) except Exception: return f"{title or 'Forecast view'}: {', '.join(names)} from {first} to {last}{scen_suffix}." except Exception: return "" def _forecast_series_is_percentage_axis(name: str) -> bool: """Names that denote margin / % โ€” must use a separate axis from Kwacha amounts.""" if not name: return False if "%" in name: return True low = name.lower() return "margin" in low def build_plotly_chart(chart_data: dict) -> go.Figure: """Build a dark-themed Plotly chart from AI-generated chart JSON data.""" fig = go.Figure() chart_type = chart_data.get("type", "line") labels = chart_data["data"]["labels"] datasets = chart_data["data"]["datasets"] pct_flags = [_forecast_series_is_percentage_axis(d.get("name", "")) for d in datasets] dual_axis = bool(datasets) and any(pct_flags) and not all(pct_flags) for dataset in datasets: name = dataset["name"] values = dataset["values"] per_bar_colors = dataset.get("colors") raw_color = dataset.get("color", "#1D9E75") if per_bar_colors is None and isinstance(raw_color, list): per_bar_colors = raw_color single_color = "#1D9E75" else: single_color = raw_color if isinstance(raw_color, str) else "#1D9E75" is_pct_series = _forecast_series_is_percentage_axis(name) yaxis_ref = "y2" if dual_axis and is_pct_series else "y" if chart_type == "line": hover = ( f"%{{x}}: %{{y:.1f}}%{name}" if is_pct_series else f"%{{x}}: K%{{y:,.0f}}{name}" ) fig.add_trace(go.Scatter( x=labels, y=values, name=name, yaxis=yaxis_ref, line=dict(color=single_color, width=3), mode="lines+markers", marker=dict(size=9, color=single_color, line=dict(width=1.5, color="rgba(255,255,255,0.35)")), hovertemplate=hover, )) elif chart_type == "bar": is_margin = is_pct_series hover = ( f"%{{x}}: %{{y:.1f}}%{name}" if is_margin else f"%{{x}}: K%{{y:,.0f}}{name}" ) n_bars = len(values) if per_bar_colors: if len(per_bar_colors) < n_bars: last = per_bar_colors[-1] if per_bar_colors else single_color bar_colors = list(per_bar_colors) + [last] * (n_bars - len(per_bar_colors)) else: bar_colors = list(per_bar_colors)[:n_bars] else: bar_colors = [single_color] * n_bars fig.add_trace(go.Bar( x=labels, y=values, name=name, yaxis=yaxis_ref, marker=dict(color=bar_colors, line=dict(width=0)), hovertemplate=hover, text=[f"{v:.1f}%" if is_margin else f"K{v:,.0f}" for v in values], textposition="outside", textfont=dict(size=11, color="#E2E8F0"), )) only_pct_single_axis = bool(datasets) and all(pct_flags) fig.update_layout( title=dict( text=chart_data.get("title", "Financial Chart"), font=dict(size=17, color="#F1F5F9", family="Segoe UI, Arial, sans-serif"), x=0.5, xanchor="center", ), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(13,31,60,0.88)", font=dict(color="#E2E8F0", size=14, family="Segoe UI, Arial, sans-serif"), legend=dict( bgcolor="rgba(15,23,42,0.78)", bordercolor="rgba(148,163,184,0.22)", borderwidth=1, font=dict(size=13), orientation="h", y=1.09, x=0.5, xanchor="center", ), xaxis=dict( gridcolor="rgba(148,163,184,0.1)", color="#E2E8F0", tickfont=dict(size=13), showline=True, linecolor="rgba(148,163,184,0.25)", ), margin=dict(l=56, r=56 if dual_axis else 28, t=76, b=44), height=int(chart_data.get("height", 455)), hovermode="x unified", bargap=0.28, ) if dual_axis: fig.update_layout( yaxis=dict( title=dict(text="Amount (Kwacha)", font=dict(size=13)), gridcolor="rgba(148,163,184,0.12)", color="#93C5FD", tickprefix="K", ticksuffix="", zeroline=True, zerolinecolor="rgba(148,163,184,0.35)", zerolinewidth=1, tickfont=dict(size=13), ), yaxis2=dict( title=dict(text="Margin / %", font=dict(size=13)), overlaying="y", side="right", gridcolor="rgba(148,163,184,0.06)", color="#FCA5A5", tickprefix="", ticksuffix="%", zeroline=False, showgrid=False, tickfont=dict(size=13), ), ) else: fig.update_layout( yaxis=dict( title=dict( text="Margin (%)" if only_pct_single_axis else "Amount (Kwacha)", font=dict(size=13), ), gridcolor="rgba(148,163,184,0.12)", color="#CBD5E1", tickprefix="" if only_pct_single_axis else "K", ticksuffix="%" if only_pct_single_axis else "", zeroline=True, zerolinecolor="rgba(148,163,184,0.3)", zerolinewidth=1, tickfont=dict(size=13), ), ) return fig def render_cash_flow_forecast_page() -> None: render_page_header( "Cash Flow Forecast", "Project the next 6 months using your current revenue and expenses.", "6 months", ) # โ”€โ”€ Resolve baseline (single source of truth) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ baseline = st.session_state.get("baseline") or get_baseline(st.session_state) st.markdown( '', unsafe_allow_html=True, ) if not baseline: st.markdown( f'
' f'โš  No financial data found.
' f'Go to Chat Advisor and run an analysis first, or enter your numbers in the Business Context form.' f'
', unsafe_allow_html=True, ) if st.button("โ†’ Go to Chat Advisor"): st.session_state.active_page = "Chat Advisor" st.rerun() return # Data source banner months_note = ( f"  ยท  Average of {baseline['months_of_data']} months of actual data" if baseline["months_of_data"] > 1 else "" ) st.markdown( f'
' f'๐Ÿ“Š Forecast based on: {baseline["source"]}' f'  ยท  Starting monthly revenue: K{baseline["monthly_revenue"]:,.2f}' f'  ยท  Expenses: K{baseline["monthly_expenses"]:,.2f}' f'{months_note}
', unsafe_allow_html=True, ) # โ”€โ”€ Scenario selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if "forecast_scenario" not in st.session_state: st.session_state["forecast_scenario"] = "base_case" s_col1, s_col2, s_col3 = st.columns(3, gap="small") for col, key in zip([s_col1, s_col2, s_col3], ["pessimistic", "base_case", "optimistic"]): sc = FE_SCENARIOS[key] active = st.session_state["forecast_scenario"] == key btn_label = f"{sc['icon']} **{sc['label']}**\n{sc['description']}" if col.button(btn_label, key=f"sc_{key}", use_container_width=True, type="primary" if active else "secondary"): st.session_state["forecast_scenario"] = key for k in list(st.session_state.keys()): if k.startswith("forecast_summary_"): del st.session_state[k] st.rerun() scenario_key = st.session_state["forecast_scenario"] forecast = calculate_forecast(baseline, scenario_key, months=6) s = forecast["summary"] rows = forecast["months"] # โ”€โ”€ Single-column dashboard (full width). Conversation lives below in a # centered, narrower column so chat doesn't sprawl across the whole page. st.markdown('
', unsafe_allow_html=True) # KPI strip โ€” full width k1, k2, k3, k4 = st.columns(4, gap="small") profit_now = baseline["monthly_profit"] rev_now = baseline["monthly_revenue"] exp_now = baseline["monthly_expenses"] k1.metric( "Month 6 Revenue", f"K{s['final_revenue']:,.0f}", f"{(s['final_revenue']-rev_now)/max(rev_now,1)*100:+.0f}% vs now", ) k2.metric( "Month 6 Expenses", f"K{s['final_expenses']:,.0f}", f"{(s['final_expenses']-exp_now)/max(exp_now,1)*100:+.0f}% vs now", ) profit_delta = s["final_profit"] - profit_now k3.metric( "Month 6 Profit", f"K{s['final_profit']:,.0f}", f"K{profit_delta:+,.0f} vs now", delta_color="normal" if profit_delta >= 0 else "inverse", ) k4.metric( "Profitable Months", f"{s['profitable_months']}/6", "All positive" if s["all_profitable"] else f"Risk from {s['first_loss_month']}", delta_color="off" if s["all_profitable"] else "inverse", ) # โ”€โ”€ Chart + AI summary side-by-side. Both end at roughly the same height # so there is no orphaned empty space when conversation grows below. col_chart, col_ai = st.columns([1.75, 1], gap="large") with col_chart: # Area + Bar chart month_labels = [r["month_name"] for r in rows] rev_vals = [r["revenue"] for r in rows] exp_vals = [r["expenses"] for r in rows] profit_vals = [r["profit"] for r in rows] bar_colors = [THEME["emerald"] if p >= 0 else THEME["red"] for p in profit_vals] fig = go.Figure() fig.add_trace(go.Scatter( x=month_labels, y=rev_vals, name="Revenue", mode="lines+markers", line=dict(color=THEME["emerald"], width=2.5), fill="tozeroy", fillcolor="rgba(16,185,129,0.07)", marker=dict(size=6), )) fig.add_trace(go.Scatter( x=month_labels, y=exp_vals, name="Expenses", mode="lines+markers", line=dict(color=THEME["orange"], width=2.5, dash="dot"), fill="tozeroy", fillcolor="rgba(249,115,22,0.05)", marker=dict(size=6), )) fig.add_trace(go.Bar( x=month_labels, y=profit_vals, name="Profit", marker_color=bar_colors, opacity=0.75, yaxis="y2", )) fig.add_hline( y=0, line_dash="dash", line_color="rgba(148,163,184,0.4)", annotation_text="Break-even", annotation_position="bottom right", annotation_font=dict(color="rgba(148,163,184,0.6)", size=10), ) fig.update_layout( height=480, margin=dict(l=8, r=12, t=110, b=12), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(15,23,42,0.35)", font=dict(color="#E2E8F0", size=14, family="Segoe UI, Arial, sans-serif"), title=dict( text="6-month outlook โ€” revenue, expenses & profit", font=dict(size=17, color="#F8FAFC"), x=0.005, xanchor="left", y=0.97, yanchor="top", ), xaxis=dict(showgrid=False, tickfont=dict(size=13), color="#94A3B8"), yaxis=dict( title=dict(text="Revenue & expenses (K)", font=dict(size=13)), gridcolor="rgba(148,163,184,0.12)", zeroline=False, tickfont=dict(size=13), tickprefix="K", ), yaxis2=dict( title=dict(text="Profit (K)", font=dict(size=13)), overlaying="y", side="right", showgrid=False, zeroline=True, zerolinecolor="rgba(148,163,184,0.35)", tickfont=dict(size=13), tickprefix="K", ), legend=dict( orientation="h", y=1.07, yanchor="bottom", x=0, xanchor="left", font=dict(size=12.5), bgcolor="rgba(15,23,42,0)", ), bargap=0.32, hovermode="x unified", ) st.plotly_chart(fig, use_container_width=True) # Insight strip if s["first_loss_month"]: st.markdown( f'
' f'โš ๏ธ Expenses may exceed revenue from {s["first_loss_month"]} under this scenario.
', unsafe_allow_html=True, ) else: st.markdown( f'
' f'โœ… All 6 months profitable. Month 6 margin: {s["final_margin"]:.1f}%.
', unsafe_allow_html=True, ) # Monthly breakdown table with st.expander("๐Ÿ“‹ Monthly Breakdown Table", expanded=False): def _tr(r: dict, prev_profit: float | None) -> str: pc = THEME["emerald"] if r["profit"] >= 0 else THEME["red"] if prev_profit is not None: diff = r["profit"] - prev_profit trend_html = ( f' โ†‘K{diff:,.0f}' if diff >= 0 else f' โ†“K{abs(diff):,.0f}' ) else: trend_html = "" warn = " โš ๏ธ" if not r["profitable"] else "" return ( f'' f'{r["month_name"]}' f'K{r["revenue"]:,.0f}' f'K{r["expenses"]:,.0f}' f'K{r["profit"]:,.0f}{warn}{trend_html}' f'{r["margin"]:.1f}%' f'' ) table_html = "".join( _tr(r, rows[i-1]["profit"] if i > 0 else None) for i, r in enumerate(rows) ) st.markdown( '' '' '' '' '' '' '' f'{table_html}
MonthRevenueExpensesProfitMargin
', unsafe_allow_html=True, ) # โ”€โ”€ AI analyst summary (right side of dashboard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ with col_ai: st.markdown( '
' '
' "AI analyst
" '
Forecast snapshot
' '
' "A plain-English read of the chart on the left.
" "
", unsafe_allow_html=True, ) cache_key = f"forecast_summary_{scenario_key}" if cache_key not in st.session_state: with st.spinner("๐Ÿ”ฎ Writing AI forecast narrativeโ€ฆ"): st.session_state[cache_key] = _generate_forecast_ai_summary( baseline, forecast, scenario_key ) st.markdown( '
' + html.escape( st.session_state[cache_key] ).replace("\n", "
") + "
", unsafe_allow_html=True, ) sugg_pills = [ ("๐Ÿ“ˆ Profit trend", "Show profit trend"), ("๐Ÿ“Š Revenue vs expenses", "Revenue vs expenses"), ("โš ๏ธ Riskiest months", "Riskiest months"), ("๐Ÿ’ฐ Expense breakdown", "Expense breakdown"), ] st.caption("Quick prompts ยท tap to ask") for idx, (label, prompt_text) in enumerate(sugg_pills): if st.button(label, key=f"fpill_{idx}", use_container_width=True): st.session_state["forecast_question"] = prompt_text st.session_state["forecast_pin_to_question"] = True st.rerun() # End dashboard wrapper st.markdown("
", unsafe_allow_html=True) # โ”€โ”€ Conversation (centered, full-width row) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if "forecast_chat" not in st.session_state: st.session_state["forecast_chat"] = [] st.markdown( '
', unsafe_allow_html=True, ) # Centre the conversation in a narrower column so it doesn't stretch wide convo_left, convo_main, convo_right = st.columns([1, 4, 1], gap="small") with convo_main: st.markdown( '
' '
' '๐Ÿ’ฌ Chat with the analyst' 'Ask anything โ€” answers use the numbers from this scenario.' "
", unsafe_allow_html=True, ) if not st.session_state["forecast_chat"]: st.markdown( '
No questions yet โ€” try a quick prompt above ' 'or type below.
', unsafe_allow_html=True, ) for _msg_idx, msg in enumerate(st.session_state["forecast_chat"]): if msg["role"] == "user": escaped = ( msg["content"] .replace("&", "&") .replace("<", "<") .replace(">", ">") ) st.markdown( f'
{escaped}
', unsafe_allow_html=True, ) else: with st.chat_message("assistant", avatar="๐Ÿ“Š"): body = sanitize_forecast_answer_text(msg["content"]) st.markdown(body) if msg.get("plotly_chart"): st.plotly_chart( build_plotly_chart(msg["plotly_chart"]), use_container_width=True, key=f"forecast_chat_plotly_{_msg_idx}", ) elif msg.get("chart"): render_followup_chart(msg["chart"]) st.markdown("
", unsafe_allow_html=True) user_q = st.chat_input("Ask about your forecast...", key="forecast_chat_input") if st.session_state.get("forecast_question"): user_q = st.session_state.pop("forecast_question") if user_q: st.session_state["forecast_chat"].append({"role": "user", "content": user_q}) with st.spinner("๐Ÿ“Š Analyzing your forecastโ€ฆ"): reply = _handle_forecast_question( user_q, baseline, forecast, scenario_key, st.session_state["forecast_chat"], ) clean_text, chart_data = parse_and_render_chart(reply) clean_text = sanitize_forecast_answer_text(clean_text) if not chart_data: fallback = _build_forecast_page_chart( user_q, baseline, scenario_key, forecast, st.session_state["forecast_chat"], ) if fallback: chart_data = fallback # Make sure there is always readable prose AND a real number # breakdown alongside the chart. If the AI wrote a thin one-liner # (e.g. "Here's a visual representation of the trends:") prepend # the auto-generated multi-line breakdown so the user sees real # figures before the chart. scen_label = forecast.get("scenario", {}).get("label") if chart_data: short_or_empty = (not clean_text) or len(clean_text) < 80 lacks_kwacha = "K" not in (clean_text or "") if short_or_empty or lacks_kwacha: breakdown = _caption_for_forecast_chart(chart_data, scen_label) if breakdown: if clean_text and len(clean_text) >= 10: clean_text = breakdown + "\n\n" + clean_text else: clean_text = breakdown assistant_msg: dict[str, Any] = {"role": "assistant", "content": clean_text} if chart_data: assistant_msg["plotly_chart"] = chart_data st.session_state["forecast_chat"].append(assistant_msg) st.session_state["forecast_pin_to_question"] = True st.rerun() # ChatGPT-style scroll: pin the user's last question to top after a reply if st.session_state.pop("forecast_pin_to_question", False): inject_pin_to_question_scroll() def render_scenario_planner_page() -> None: render_page_header( "Scenario Planner", "Adjust sliders to stress-test what happens if sales rise, costs shift, or debt increases.", "What-if", ) baseline = st.session_state.get("baseline") or get_baseline(st.session_state) base_metrics = st.session_state.get("metrics") if not baseline: st.markdown( '
' '
Analysis required
' '
Run Chat Advisor first
' '
The Scenario Planner needs your actual revenue and expenses before it can calculate anything useful.
' '
', unsafe_allow_html=True, ) if st.button("โ†’ Go to Chat Advisor"): st.session_state.active_page = "Chat Advisor" st.rerun() return if not base_metrics: base_metrics = compute_metrics(baseline) # Current baseline banner st.markdown( f'
' f'๐Ÿ“Š Current baseline: {baseline["source"]}' f'  ยท  Revenue K{baseline["monthly_revenue"]:,.2f}' f'  ยท  Expenses K{baseline["monthly_expenses"]:,.2f}' f'  ยท  Profit K{baseline["monthly_profit"]:,.2f}' f'  ยท  Margin {baseline["profit_margin"]:.1f}%' f'
', unsafe_allow_html=True, ) render_tool_tip("How to use", "Move the sliders to model a what-if scenario. All metrics recalculate instantly in Python โ€” no AI required.") # โ”€โ”€ Sliders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ctrl1, ctrl2, ctrl3 = st.columns(3, gap="large") with ctrl1: revenue_delta = st.slider("Revenue change", -50, 75, 0, 5, format="%d%%", help="What if your sales grew or fell by this percentage?") with ctrl2: expense_delta = st.slider("Expense change", -50, 50, 0, 5, format="%d%%", help="What if your total costs changed by this percentage?") with ctrl3: extra_debt = st.number_input( "Extra monthly debt payment (K)", min_value=0.0, value=0.0, step=100.0, help="Add a hypothetical loan repayment to see the impact.", ) # โ”€โ”€ Python does ALL the math โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ new_rev = baseline["monthly_revenue"] * (1 + revenue_delta / 100) new_exp = baseline["monthly_expenses"] * (1 + expense_delta / 100) + extra_debt scenario_baseline = { **baseline, "monthly_revenue": round(new_rev, 2), "monthly_expenses": round(new_exp, 2), "monthly_profit": round(new_rev - new_exp, 2), "profit_margin": round((new_rev - new_exp) / new_rev * 100, 1) if new_rev > 0 else 0.0, } new_metrics = compute_metrics(scenario_baseline) delta_profit = new_metrics["monthly_profit"] - base_metrics["monthly_profit"] delta_health = new_metrics["health_score"] - base_metrics["health_score"] delta_margin = new_metrics["profit_margin_pct"] - base_metrics["profit_margin_pct"] # โ”€โ”€ KPI cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cols = st.columns(4, gap="small") cols[0].metric("New Revenue", f"K{new_rev:,.0f}", f"{revenue_delta:+.0f}%") cols[1].metric("New Expenses", f"K{new_exp:,.0f}", f"{expense_delta:+.0f}%") cols[2].metric( "New Profit", f"K{new_metrics['monthly_profit']:,.0f}", f"K{delta_profit:+,.0f}", delta_color="normal" if delta_profit >= 0 else "inverse", ) cols[3].metric( "Health Score", f"{new_metrics['health_score']}/100", f"{delta_health:+.0f} pts", delta_color="normal" if delta_health >= 0 else "inverse", ) # Margin and loan label change loan_label, loan_color, loan_icon = get_loan_label(new_metrics["profit_margin_pct"]) m_col1, m_col2 = st.columns(2, gap="large") m_col1.metric( "New Margin", f"{new_metrics['profit_margin_pct']:.1f}%", f"{delta_margin:+.1f}pp vs current", delta_color="normal" if delta_margin >= 0 else "inverse", ) m_col2.metric( "Loan Readiness", f"{loan_icon} {loan_label}", f"Safe borrowing: K{new_metrics['safe_loan_amount']:,.0f}", delta_color="off", ) # Verdict if delta_health > 10: st.success(f"โœ… This scenario improves your business health by {delta_health} points.") elif delta_health < -10: st.error(f"โŒ This scenario reduces your business health by {abs(delta_health)} points.") elif new_metrics["monthly_profit"] <= 0: st.error("โŒ This scenario results in a loss. Expenses exceed revenue.") else: st.info("โ„น๏ธ This scenario has minimal impact on overall business health.") # Safe loan formula display st.caption( f"Safe borrowing amount under this scenario: K{new_metrics['safe_loan_amount']:,.2f} " f"({new_metrics['safe_loan_formula']}) โ€” a conservative SME lending rule of thumb." ) # โ”€โ”€ Send to Chat button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.divider() if st.button("๐Ÿ’ฌ Ask Duka AI to explain this scenario", type="primary"): scenario_desc = ( f"I modelled a scenario: revenue {revenue_delta:+.0f}%, " f"expenses {expense_delta:+.0f}%" + (f", extra debt payment K{extra_debt:,.0f}/month" if extra_debt > 0 else "") + f". New profit would be K{new_metrics['monthly_profit']:,.0f} " f"(was K{base_metrics['monthly_profit']:,.0f}). " f"Health score changes from {base_metrics['health_score']} to {new_metrics['health_score']}. " f"Margin changes from {base_metrics['profit_margin_pct']:.1f}% to {new_metrics['profit_margin_pct']:.1f}%. " "What should I focus on given these numbers?" ) st.session_state["pending_chat_question"] = scenario_desc st.session_state.active_page = "Chat Advisor" st.rerun() def render_loan_calculator_page() -> None: render_page_header( "Loan Calculator", "Estimate monthly repayment and compare it against current profit.", "Repayment", ) report = require_report() if not report: return loan = report["loan"] cashflow = report["cashflow"] render_tool_tip("Borrowing guardrail", "A repayment above roughly 20-30% of current profit is usually a warning sign for a small business.") control_cols = st.columns(3, gap="large") with control_cols[0]: amount = st.number_input("Loan amount (K)", min_value=0.0, value=float(loan.get("suggested_loan_amount", 0.0)), step=500.0) with control_cols[1]: annual_rate = st.slider("Annual interest rate", 0.0, 60.0, 28.0, 0.5, format="%.1f%%") with control_cols[2]: months = st.slider("Repayment period (months)", 1, 60, 12) monthly_rate = annual_rate / 100 / 12 payment = amount / months if monthly_rate == 0 else amount * monthly_rate / (1 - (1 + monthly_rate) ** -months) monthly_profit = max(float(cashflow.get("profit", 0.0)), 0.0) burden = (payment / monthly_profit * 100) if monthly_profit else 0 cols = st.columns(3) cols[0].metric("Monthly repayment", format_currency(payment)) cols[1].metric("Total repayment", format_currency(payment * months)) cols[2].metric("Profit used", f"{burden:.1f}%") if monthly_profit <= 0: st.error("Current profit is not positive, so new borrowing is not advisable from these numbers.") elif burden > 30: st.warning("This repayment would use a high share of current profit.") else: st.success("This repayment is within a more manageable range based on current profit.") render_loan_readiness_card(report) def get_expense_breakdown(report: dict[str, Any]) -> dict[str, float]: parsed = report.get("parsed_data", {}) breakdown = {str(k).replace("_", " ").title(): float(v or 0) for k, v in (parsed.get("expenses_breakdown") or {}).items() if float(v or 0) > 0} if breakdown: return breakdown doc_breakdown = report.get("document_analysis", {}).get("expenses_breakdown") or {} return {str(k).replace("_", " ").title(): float(v or 0) for k, v in doc_breakdown.items() if float(v or 0) > 0} def _expense_identity_reply() -> str: return ( "I'm **Duka AI's Expense Analyst** โ€” I focus only on **where your Kwacha is going**. " "I work from the verified expense breakdown on this page (no guesses): I can rank your top " "categories, show what % of revenue each one eats, and suggest **specific** cost cuts.\n\n" "Try: *Which category is hurting me most?* ยท *How can I cut transport costs?* ยท " "*Where should I trim 10% of expenses?*" ) def _build_expense_chat_context( report: dict[str, Any], breakdown: dict[str, float], frame: "pd.DataFrame" ) -> str: cf = report.get("cashflow") or {} revenue = float(cf.get("revenue", 0) or 0) total_exp = float(frame["Amount"].sum()) rows = frame.sort_values("Amount", ascending=False).to_dict("records") bp = report.get("business_profile") or {} bt = (bp.get("business_type") or "").strip() or "SME" loc = (bp.get("location") or "").strip() or "Zambia" lines: list[str] = [] lines.append("VERIFIED EXPENSE BREAKDOWN (Python-computed, do NOT invent figures):\n") lines.append( f"- Business: {bt} ยท Location: {loc}\n" f"- Monthly revenue: K{revenue:,.0f}\n" f"- Total monthly expenses: K{total_exp:,.0f}" + (f" ({total_exp / revenue * 100:.1f}% of revenue)\n" if revenue > 0 else "\n") ) lines.append("- Categories (largest โ†’ smallest):\n") for r in rows: cat = str(r["Category"]) amt = float(r["Amount"]) pct_rev = (amt / revenue * 100) if revenue > 0 else 0.0 pct_exp = (amt / total_exp * 100) if total_exp > 0 else 0.0 lines.append( f" ยท {cat}: K{amt:,.0f} ({pct_exp:.1f}% of expenses, {pct_rev:.1f}% of revenue)\n" ) return "".join(lines) def _classify_expense_chat_input(question: str) -> str | None: low = (question or "").lower().strip() if not low: return "empty" if any(p in low for p in ("who are you", "what are you", "what do you do", "introduce yourself")): return "identity" if low in ("thanks", "thank you", "ty", "cheers", "ta"): return "thanks" return None def _handle_expense_question( question: str, report: dict, breakdown: dict[str, float], frame: "pd.DataFrame", chat_history: list[dict], ) -> str: from agents import request_llm_chat canned = _classify_expense_chat_input(question) if canned == "identity": return _expense_identity_reply() if canned == "thanks": return "Glad it helped โ€” ask anything else about your expense breakdown." if canned == "empty": return "Type a question about your expense categories or how to cut them." ctx = _build_expense_chat_context(report, breakdown, frame) system = ( "You are **Duka AI's Expense Analyst** for **Zambian SMEs**. " "You ONLY answer using the VERIFIED EXPENSE BREAKDOWN below โ€” never invent categories or amounts. " "When you reference a category, **always** quote its exact K amount and its % of revenue or % of expenses. " "Be specific with cost-cutting advice (which line, by how much in K, and why it's realistic for that business type/location). " "Use **K** for Kwacha. Reply in 3โ€“6 short sentences or short bullets โ€” no fluff.\n\n" f"{ctx}" ) messages = [ {"role": "system", "content": system}, *[{"role": m["role"], "content": m["content"]} for m in chat_history[-8:]], {"role": "user", "content": question}, ] result = request_llm_chat(messages, temperature=0.2, max_tokens=500) return result or "I couldn't generate a response right now. Please try again." def render_expense_analyzer_page() -> None: render_page_header( "Expense Analyzer", "Break down where money is going and spot the biggest cost pressure.", "Donut + AI agent", ) report = require_report() if not report: return st.markdown( '', unsafe_allow_html=True, ) cashflow = report["cashflow"] breakdown = get_expense_breakdown(report) if not breakdown: breakdown = {"Total expenses": float(cashflow["expenses"])} st.info("Detailed expense categories were not provided, so this view shows total expenses only.") frame = pd.DataFrame( [{"Category": k, "Amount": v} for k, v in breakdown.items()] ).sort_values("Amount", ascending=False) top_row = frame.iloc[0] # Two-column layout: snapshot/chart left, AI agent chat right col_chart, col_chat = st.columns([3, 2], gap="large") with col_chart: metric_cols = st.columns(3) metric_cols[0].metric("Total expenses", format_currency(float(frame["Amount"].sum()))) metric_cols[1].metric("Largest category", str(top_row["Category"])) metric_cols[2].metric("Largest amount", format_currency(float(top_row["Amount"]))) st.dataframe(frame, use_container_width=True, hide_index=True) fig = go.Figure(data=[go.Pie(labels=frame["Category"], values=frame["Amount"], hole=0.58)]) fig.update_layout( height=380, margin=dict(l=8, r=8, t=16, b=8), paper_bgcolor="rgba(0,0,0,0)", font=dict(color="#E2E8F0", family="Segoe UI, Arial, sans-serif"), legend=dict(orientation="h", y=-0.05), ) st.plotly_chart(fig, use_container_width=True) with col_chat: st.markdown( '
', unsafe_allow_html=True, ) st.markdown( '
' '
' '๐Ÿ” Ask the Expense Analyst' '' "Grounded in your verified categories โ€” no invented numbers." "" "
", unsafe_allow_html=True, ) sugg_pills = [ "๐Ÿฅ‡ Which category hurts me most?", "โœ‚๏ธ Where can I cut 10%?", "๐Ÿšš How do I reduce transport?", "๐Ÿ‘ฅ Are wages too high?", "๐Ÿ’ก Which line saves me the most?", "๐Ÿ“‰ What % of revenue goes to expenses?", ] p1, p2 = st.columns(2) for idx, pill in enumerate(sugg_pills): col = p1 if idx % 2 == 0 else p2 if col.button(pill, key=f"epill_{idx}", use_container_width=True): st.session_state["expense_question"] = pill st.markdown("
", unsafe_allow_html=True) if "expense_chat" not in st.session_state: st.session_state["expense_chat"] = [] if not st.session_state["expense_chat"]: st.markdown( '
No messages yet โ€” tap a suggestion ' "or type below.
", unsafe_allow_html=True, ) for msg in st.session_state["expense_chat"]: if msg["role"] == "user": escaped = ( msg["content"] .replace("&", "&") .replace("<", "<") .replace(">", ">") ) st.markdown( f'
{escaped}
', unsafe_allow_html=True, ) else: with st.chat_message("assistant", avatar="๐Ÿ”"): st.markdown(msg["content"]) st.markdown("
", unsafe_allow_html=True) user_q = st.chat_input("Ask about your expenses...", key="expense_chat_input") if st.session_state.get("expense_question"): user_q = st.session_state.pop("expense_question") if user_q: st.session_state["expense_chat"].append({"role": "user", "content": user_q}) with st.spinner("๐Ÿ” Analyzing your expensesโ€ฆ"): reply = _handle_expense_question( user_q, report, breakdown, frame, st.session_state["expense_chat"] ) st.session_state["expense_chat"].append({"role": "assistant", "content": reply}) st.session_state["expense_pin_to_question"] = True st.rerun() if st.session_state.pop("expense_pin_to_question", False): inject_pin_to_question_scroll() def build_report_text(report: dict[str, Any]) -> str: cashflow = report["cashflow"] loan = report["loan"] actions = "\n".join(f"- {action}" for action in report.get("final_recommended_actions", [])) return ( "Duka AI Summary\n\n" f"{report['final_summary']}\n\n" f"Revenue: {format_currency(cashflow['revenue'])}\n" f"Expenses: {format_currency(cashflow['expenses'])}\n" f"{'Net Loss' if cashflow['profit'] < 0 else 'Profit'}: {format_currency(abs(cashflow['profit']))}\n" f"{'Loss' if cashflow['profit'] < 0 else 'Profit'} margin: {cashflow.get('profit_margin', 0):.1f}%\n" f"Loan readiness: {loan['loan_readiness_score']}/100\n\n" f"Recommended actions:\n{actions}\n" ) def build_pdf_report_bytes(report: dict[str, Any]) -> bytes: text = build_report_text(report) lines: list[str] = [] for paragraph in text.splitlines(): wrapped = textwrap.wrap(paragraph, width=88) if paragraph.strip() else [""] lines.extend(wrapped) content_parts = ["BT", "/F1 11 Tf", "50 790 Td", "14 TL"] for line in lines[:52]: safe = line.encode("latin-1", "replace").decode("latin-1") safe = safe.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") content_parts.append(f"({safe}) Tj") content_parts.append("T*") content_parts.append("ET") stream = "\n".join(content_parts).encode("latin-1") objects = [ b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", b"<< /Length " + str(len(stream)).encode("ascii") + b" >>\nstream\n" + stream + b"\nendstream", ] pdf = bytearray(b"%PDF-1.4\n") offsets = [0] for i, obj in enumerate(objects, start=1): offsets.append(len(pdf)) pdf.extend(f"{i} 0 obj\n".encode("ascii")) pdf.extend(obj) pdf.extend(b"\nendobj\n") xref = len(pdf) pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) pdf.extend(b"0000000000 65535 f \n") for offset in offsets[1:]: pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii")) pdf.extend(f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode("ascii")) return bytes(pdf) def _build_plaintext_pdf_from_full(full: dict) -> bytes: """Raw-bytes PDF fallback used when ReportLab is unavailable or broken.""" snap = full.get("snapshot", {}) loan = full.get("loan", {}) fc6 = full.get("forecast_6m", {}) mkt = full.get("market", {}) paragraphs: list[str] = [ "Duka AI - Financial Health Report", f"{full.get('business_name','Your Business')} | " f"{full.get('location','Zambia')} | Generated {full.get('generated_at','')}", "", "EXECUTIVE SUMMARY", full.get("executive_summary", "โ€”"), "", "FINANCIAL SNAPSHOT", f"Revenue: K{snap.get('revenue', 0):,.0f}", f"Expenses: K{snap.get('expenses', 0):,.0f} ({snap.get('expense_pct', 0):.0f}% of revenue)", f"{'Net Loss' if snap.get('profit',0) < 0 else 'Profit'}: K{abs(snap.get('profit', 0)):,.0f} ({snap.get('margin_pct', 0):.1f}% margin)", f"Health Score: {snap.get('health_score', 0)}/100 โ€” {snap.get('health_label', 'N/A')}", f"Cash Flow: {snap.get('cash_flow_status', 'โ€”')}", "", "LOAN READINESS", f"Score: {loan.get('score', 0)}/100 โ€” {loan.get('status', 'โ€”')}", f"Safe borrowing: K{loan.get('safe_amount', 0):,.0f}", f"Formula: {loan.get('formula', 'โ€”')}", "", "6-MONTH FORECAST (BASE CASE)", f"Month 6 Profit: K{fc6.get('base_profit_m6', 0):,.0f}", f"Total Profit: K{fc6.get('total_profit', 0):,.0f}", f"Outlook: {'All 6 months profitable' if fc6.get('all_profitable') else 'Risk from ' + str(fc6.get('first_loss_month',''))}", "", "MARKET CONDITIONS", mkt.get("summary", "โ€”"), "", "TOP 3 RECOMMENDATIONS", ] for i, r in enumerate(full.get("recommendations", []), 1): paragraphs.append( f"{i}. {r.get('action','โ€”')} โ†’ {r.get('impact','โ€”')} ({r.get('timeline','โ€”')})" ) risks = full.get("risks", []) if risks: paragraphs += ["", "RISKS TO WATCH"] + [f"! {r}" for r in risks] paragraphs += ["", "Generated by Duka AI | AMD MI300X | Qwen Model"] paragraphs = [_pdf_safe_text(p) for p in paragraphs] # Wrap and build raw PDF stream lines: list[str] = [] for para in paragraphs: wrapped = textwrap.wrap(para, width=88) if para.strip() else [""] lines.extend(wrapped) content_parts = ["BT", "/F1 11 Tf", "50 790 Td", "14 TL"] for line in lines[:52]: safe = line.encode("latin-1", "replace").decode("latin-1") safe = safe.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") content_parts += [f"({safe}) Tj", "T*"] content_parts.append("ET") stream = "\n".join(content_parts).encode("latin-1") objects = [ b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 842]" b" /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream", ] pdf = bytearray(b"%PDF-1.4\n") offsets = [0] for i, obj in enumerate(objects, start=1): offsets.append(len(pdf)) pdf.extend(f"{i} 0 obj\n".encode()) pdf.extend(obj) pdf.extend(b"\nendobj\n") xref = len(pdf) pdf.extend(f"xref\n0 {len(objects)+1}\n".encode()) pdf.extend(b"0000000000 65535 f \n") for off in offsets[1:]: pdf.extend(f"{off:010d} 00000 n \n".encode()) pdf.extend( f"trailer\n<< /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() ) return bytes(pdf) def _report_text_escape(s: object) -> str: """Escape LLM/user text for safe insertion into report HTML.""" return html.escape(str(s or ""), quote=False) def _pdf_safe_text(s: object) -> str: """Normalize text for PDF built-in fonts (WinAnsi); avoids '?' from latin-1 mapping.""" t = str(s or "") for a, b in ( ("\u2014", "-"), ("\u2013", "-"), ("\u2212", "-"), ("\u2022", "*"), ("\u2192", "->"), ("\u2019", "'"), ("\u2018", "'"), ("\u201c", '"'), ("\u201d", '"'), ("\u2026", "..."), ("\u00a0", " "), ): t = t.replace(a, b) return t.encode("latin-1", "replace").decode("latin-1") def _pdf_escape(s: object) -> str: """Escape for ReportLab Paragraph XML/HTML subset.""" from xml.sax.saxutils import escape return escape(_pdf_safe_text(s), {"'": "'", '"': """}) def _pdf_para(text: object, style): """ReportLab Paragraph with safe body text (newlines -> br).""" from reportlab.platypus import Paragraph t = _pdf_escape(text).replace("\n", "
") return Paragraph(t, style) def build_full_report_pdf(full: dict) -> bytes: """Build a styled PDF from generate_full_report(); falls back if ReportLab is missing or fails.""" try: return _render_reportlab_pdf(full) except Exception: return _build_plaintext_pdf_from_full(full) def _render_reportlab_pdf(full: dict) -> bytes: """Premium ReportLab layout (requires `reportlab` package).""" from io import BytesIO from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm from reportlab.lib import colors from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, KeepTogether, ) buf = BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, rightMargin=1.8 * cm, leftMargin=1.8 * cm, topMargin=1.6 * cm, bottomMargin=1.8 * cm, ) base = getSampleStyleSheet() C_DARK = colors.HexColor("#0F172A") C_BLUE = colors.HexColor("#2563EB") C_SLATE = colors.HexColor("#1e293b") C_MID = colors.HexColor("#64748B") C_LITE = colors.HexColor("#F1F5F9") C_CARD = colors.HexColor("#f8fafc") C_GRID = colors.HexColor("#CBD5E1") C_GRN = colors.HexColor("#059669") C_RED = colors.HexColor("#DC2626") C_AMB = colors.HexColor("#D97706") def _style(name, **kw): return ParagraphStyle(name, parent=base["Normal"], **kw) ST_SUB = _style( "RP_SUB", fontSize=9.5, textColor=colors.HexColor("#cbd5e1"), leading=13, ) NM = _style("RP_NM", fontSize=10, textColor=C_DARK, leading=15) NM_SM = _style("RP_NM_SM", fontSize=9, textColor=C_DARK, leading=13) SM = _style("RP_SM", fontSize=9, textColor=C_MID, leading=13) H3 = _style( "RP_H3", fontSize=10, textColor=C_MID, fontName="Helvetica-Bold", spaceBefore=6, spaceAfter=2, ) FT = _style("RP_FT", fontSize=8, textColor=C_MID, alignment=1) TITLE = _style( "RP_TITLE", fontSize=20, textColor=colors.white, fontName="Helvetica-Bold", leading=24, spaceAfter=4, ) snap = full.get("snapshot", {}) h_score = int(snap.get("health_score", 0) or 0) h_color = C_GRN if h_score >= 70 else (C_AMB if h_score >= 40 else C_RED) loan = full.get("loan", {}) l_score = int(loan.get("score", 0) or 0) l_color = C_GRN if l_score >= 70 else (C_AMB if l_score >= 40 else C_RED) def _hr(thickness=0.8, c=C_GRID): return HRFlowable(width="100%", thickness=thickness, color=c, spaceAfter=8, spaceBefore=8) def _tbl(data, col_widths, style_cmds): t = Table(data, colWidths=col_widths) t.setStyle( TableStyle( [ ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), ("FONTSIZE", (0, 0), (-1, -1), 10), ( "ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.white, C_CARD], ), ("GRID", (0, 0), (-1, -1), 0.35, C_GRID), ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 8), ] + style_cmds ) ) return t def _section_bar(label: str): lab = _style( "RP_SEC", fontSize=9, textColor=colors.white, fontName="Helvetica-Bold", leading=11, letterSpacing=1.2, ) tb = Table([[Paragraph(_pdf_escape(label.upper()), lab)]], colWidths=[17.4 * cm]) tb.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), C_BLUE), ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), ("LEFTPADDING", (0, 0), (-1, -1), 10), ] ) ) return tb story = [] # โ”€โ”€ Hero header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ hero = Table( [ [Paragraph(_pdf_escape("Duka AI Financial Health Report"), TITLE)], [ _pdf_para( f"{full.get('business_name', 'Your Business')} | " f"{full.get('location', 'Zambia')} | " f"Generated {full.get('generated_at', '')}", ST_SUB, ) ], ], colWidths=[17.4 * cm], ) hero.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), C_SLATE), ("TOPPADDING", (0, 0), (-1, -1), 16), ("BOTTOMPADDING", (0, 0), (-1, -1), 14), ("LEFTPADDING", (0, 0), (-1, -1), 14), ("RIGHTPADDING", (0, 0), (-1, -1), 14), ("LINEABOVE", (0, 0), (-1, 0), 3, C_BLUE), ] ) ) story.append(hero) _snap_profit = float(snap.get("profit", 0) or 0) _pr_col = C_RED if _snap_profit < 0 else C_GRN _rev = float(snap.get("revenue", 0) or 0) _exp = float(snap.get("expenses", 0) or 0) kpi = Table( [ [ "MONTHLY REVENUE", "MONTHLY EXPENSES", "NET " + ("LOSS" if _snap_profit < 0 else "PROFIT"), ], [ f"K{_rev:,.0f}", f"K{_exp:,.0f}", f"K{abs(_snap_profit):,.0f}", ], ], colWidths=[5.8 * cm, 5.8 * cm, 5.8 * cm], ) kpi.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), C_LITE), ("BOX", (0, 0), (-1, -1), 0.75, C_GRID), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, 0), 7.5), ("TEXTCOLOR", (0, 0), (-1, 0), C_MID), ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"), ("FONTSIZE", (0, 1), (-1, 1), 14), ("TEXTCOLOR", (0, 1), (1, 1), C_DARK), ("TEXTCOLOR", (2, 1), (2, 1), _pr_col), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("TOPPADDING", (0, 0), (-1, -1), 10), ("BOTTOMPADDING", (0, 0), (-1, -1), 12), ] ) ) story.append(kpi) story.append(Spacer(1, 0.35 * cm)) # โ”€โ”€ Executive Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ story.append(_section_bar("Executive summary")) story.append(Spacer(1, 0.15 * cm)) story.append(_pdf_para(full.get("executive_summary", ""), NM)) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Financial Snapshot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ story.append(_section_bar("Financial snapshot")) story.append(Spacer(1, 0.15 * cm)) _snap_p_label = "Net Loss" if _snap_profit < 0 else "Profit" _snap_p_value = ( f"K{abs(_snap_profit):,.0f} ({float(snap.get('margin_pct', 0) or 0):.1f}% margin)" ) snap_data = [ ["Revenue", f"K{float(snap.get('revenue', 0) or 0):,.0f}"], [ "Expenses", f"K{float(snap.get('expenses', 0) or 0):,.0f} " f"({float(snap.get('expense_pct', 0) or 0):.0f}% of revenue)", ], [_snap_p_label, _snap_p_value], [ "Health Score", f"{h_score}/100 - {_pdf_safe_text(snap.get('health_label', 'N/A'))}", ], ["Cash Flow", _pdf_safe_text(snap.get("cash_flow_status", "N/A"))], ["Data Source", _pdf_safe_text(snap.get("source", "-"))], ] story.append( KeepTogether( [ _tbl( snap_data, [5.2 * cm, 11.8 * cm], [ ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ("TEXTCOLOR", (1, 2), (1, 2), _pr_col), ("TEXTCOLOR", (1, 3), (1, 3), h_color), ], ) ] ) ) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Expense Breakdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cats = full.get("expense_categories", []) if cats: story.append(_section_bar("Expense breakdown")) story.append(Spacer(1, 0.15 * cm)) exp_data = [["Category", "Amount", "% of Expenses"]] + [ [ _pdf_safe_text(c.get("name", "")), f"K{float(c.get('amount', 0) or 0):,.0f}", f"{float(c.get('pct_of_expenses', 0) or 0):.1f}%", ] for c in cats[:6] ] story.append( _tbl( exp_data, [7 * cm, 4 * cm, 4 * cm], [ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("BACKGROUND", (0, 0), (-1, 0), C_SLATE), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ( "ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, C_CARD], ), ], ) ) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Cash Flow Analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cf_sect = full.get("cash_flow", {}) if cf_sect.get("summary"): story.append(_section_bar("Cash flow analysis")) story.append(Spacer(1, 0.15 * cm)) story.append(_pdf_para(cf_sect["summary"], NM)) _ins = cf_sect.get("insight") or cf_sect.get("reasoning") if _ins: story.append(Spacer(1, 0.12 * cm)) story.append( Paragraph(f"{_pdf_escape(_ins)}", SM) ) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Loan Readiness โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ story.append(_section_bar("Loan readiness")) story.append(Spacer(1, 0.15 * cm)) loan_data = [ ["Score", f"{l_score}/100 - {_pdf_safe_text(loan.get('status', ''))}"], ["Risk Level", _pdf_safe_text(loan.get("risk_level", "-"))], ["Safe Borrowing", f"K{float(loan.get('safe_amount', 0) or 0):,.0f}"], ["Formula", _pdf_safe_text(loan.get("formula", "-"))], ] story.append( _tbl( loan_data, [5.2 * cm, 11.8 * cm], [ ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ("TEXTCOLOR", (1, 0), (1, 0), l_color), ], ) ) improve = loan.get("improve", []) if improve: story.append(Spacer(1, 0.18 * cm)) story.append(Paragraph("How to improve", H3)) for item in improve: story.append( Paragraph(f"- {_pdf_escape(item)}", NM) ) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Market Conditions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ mkt = full.get("market", {}) if mkt.get("summary"): story.append(_section_bar("Market conditions")) story.append(Spacer(1, 0.15 * cm)) story.append(_pdf_para(mkt["summary"], NM)) if mkt.get("opportunity"): story.append(Spacer(1, 0.12 * cm)) story.append( Paragraph( f"Opportunity: {_pdf_escape(mkt['opportunity'])}", NM, ) ) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ 6-Month Forecast โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ fc6 = full.get("forecast_6m", {}) story.append(_section_bar("6-month forecast (base case)")) story.append(Spacer(1, 0.15 * cm)) fc_status = ( "All 6 months profitable" if fc6.get("all_profitable") else f"Risk from {_pdf_safe_text(fc6.get('first_loss_month', 'month unknown'))}" ) fc_data = [ ["Month 6 profit", f"K{float(fc6.get('base_profit_m6', 0) or 0):,.0f}"], ["Total 6-mo profit", f"K{float(fc6.get('total_profit', 0) or 0):,.0f}"], ["Profitable months", f"{fc6.get('profitable_months', 0)}/6"], ["Outlook", fc_status], ] story.append( _tbl( fc_data, [6 * cm, 11 * cm], [ ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ], ) ) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Recommendations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ recs = full.get("recommendations", []) if recs: story.append(_section_bar("Top recommendations")) story.append(Spacer(1, 0.15 * cm)) hdr = _style( "RH", fontSize=9, textColor=colors.white, fontName="Helvetica-Bold", leading=11, ) rec_rows = [ [ Paragraph(_pdf_escape("#"), hdr), Paragraph(_pdf_escape("Action"), hdr), Paragraph(_pdf_escape("Impact"), hdr), Paragraph(_pdf_escape("Timeline"), hdr), ] ] for i, r in enumerate(recs): rec_rows.append( [ Paragraph(_pdf_escape(str(i + 1)), NM_SM), Paragraph(_pdf_escape(_pdf_safe_text(r.get("action", ""))), NM_SM), Paragraph(_pdf_escape(_pdf_safe_text(r.get("impact", ""))), NM_SM), Paragraph(_pdf_escape(_pdf_safe_text(r.get("timeline", ""))), NM_SM), ] ) rt = Table(rec_rows, colWidths=[0.9 * cm, 7.3 * cm, 4.6 * cm, 4.6 * cm]) rt.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), C_DARK), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), ("FONTSIZE", (0, 1), (-1, -1), 9), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, C_CARD]), ("GRID", (0, 0), (-1, -1), 0.35, C_GRID), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("LEFTPADDING", (0, 0), (-1, -1), 6), ] ) ) story.append(rt) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Risks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ risks = full.get("risks", []) if risks: story.append(_section_bar("Risks to watch")) story.append(Spacer(1, 0.15 * cm)) NM_RISK = _style("RP_RISK", fontSize=10, textColor=C_AMB, leading=15) risk_rows = [ [Paragraph(f"! {_pdf_escape(_pdf_safe_text(risk))}", NM_RISK)] for risk in risks ] rt2 = Table(risk_rows, colWidths=[17.4 * cm]) rt2.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#fffbeb")), ("BOX", (0, 0), (-1, -1), 0.6, colors.HexColor("#fbbf24")), ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10), ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), ] ) ) story.append(rt2) story.append(Spacer(1, 0.3 * cm)) # โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ story.append(_hr(1, C_GRID)) story.append( Paragraph( _pdf_escape( "Generated by Duka AI | Powered by AMD MI300X | Qwen Model" ), FT, ) ) doc.build(story) return buf.getvalue() def build_excel_report_bytes(full: dict) -> tuple[bytes, str]: """Return (bytes, mime_type) for an Excel or CSV of the report data.""" from io import BytesIO, StringIO snap = full.get("snapshot", {}) loan = full.get("loan", {}) fc6 = full.get("forecast_6m", {}) try: import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side wb = openpyxl.Workbook() # โ”€โ”€ Sheet 1: Overview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ws = wb.active ws.title = "Overview" hdr_fill = PatternFill("solid", fgColor="0F172A") hdr_font = Font(bold=True, color="FFFFFF", size=11) sub_fill = PatternFill("solid", fgColor="1D4ED8") sub_font = Font(bold=True, color="FFFFFF", size=10) bold = Font(bold=True) thin = Border( left=Side(style="thin", color="CBD5E1"), right=Side(style="thin", color="CBD5E1"), top=Side(style="thin", color="CBD5E1"), bottom=Side(style="thin", color="CBD5E1"), ) def _hdr(ws, row, col, text, span=1): ws.cell(row, col, text).font = hdr_font ws.cell(row, col).fill = hdr_fill ws.cell(row, col).alignment = Alignment(horizontal="left", vertical="center") if span > 1: ws.merge_cells(start_row=row, start_column=col, end_row=row, end_column=col+span-1) def _sub(ws, row, col, text): ws.cell(row, col, text).font = sub_font ws.cell(row, col).fill = sub_fill def _row(ws, row, label, value): ws.cell(row, 1, label).font = bold ws.cell(row, 2, value) for c in (1, 2): ws.cell(row, c).border = thin r = 1 _hdr(ws, r, 1, "Duka AI โ€” Financial Health Report", 2); r += 1 ws.cell(r, 1, f"{full.get('business_name','')} | {full.get('location','')} | {full.get('generated_at','')}"); r += 2 _sub(ws, r, 1, "EXECUTIVE SUMMARY"); ws.cell(r, 2).fill = sub_fill; r += 1 ws.cell(r, 1, full.get("executive_summary","")).alignment = Alignment(wrap_text=True) ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=2); r += 2 _sub(ws, r, 1, "FINANCIAL SNAPSHOT"); ws.cell(r, 2).fill = sub_fill; r += 1 _xls_profit = snap.get("profit", 0) _xls_p_label = "Net Loss" if _xls_profit < 0 else "Profit" _row(ws, r, "Revenue", f"K{snap.get('revenue',0):,.0f}"); r += 1 _row(ws, r, "Expenses", f"K{snap.get('expenses',0):,.0f} ({snap.get('expense_pct',0):.0f}% of revenue)"); r += 1 _row(ws, r, _xls_p_label, f"K{abs(_xls_profit):,.0f} ({snap.get('margin_pct',0):.1f}% margin)"); r += 1 _row(ws, r, "Health Score",f"{snap.get('health_score',0)}/100 โ€” {snap.get('health_label','')}"); r += 1 _row(ws, r, "Cash Flow", snap.get("cash_flow_status","โ€”")); r += 2 _sub(ws, r, 1, "LOAN READINESS"); ws.cell(r, 2).fill = sub_fill; r += 1 _row(ws, r, "Score", f"{loan.get('score',0)}/100 โ€” {loan.get('status','')}"); r += 1 _row(ws, r, "Risk Level", loan.get("risk_level","โ€”")); r += 1 _row(ws, r, "Safe Borrowing", f"K{loan.get('safe_amount',0):,.0f}"); r += 1 _row(ws, r, "Formula", loan.get("formula","โ€”")); r += 2 _sub(ws, r, 1, "6-MONTH FORECAST (BASE CASE)"); ws.cell(r, 2).fill = sub_fill; r += 1 outlook = "All months profitable" if fc6.get("all_profitable") else f"Risk from {fc6.get('first_loss_month','')}" _row(ws, r, "Month 6 Profit", f"K{fc6.get('base_profit_m6',0):,.0f}"); r += 1 _row(ws, r, "Total 6-Mo Profit", f"K{fc6.get('total_profit',0):,.0f}"); r += 1 _row(ws, r, "Outlook", outlook); r += 2 # Recommendations recs = full.get("recommendations", []) if recs: _sub(ws, r, 1, "TOP RECOMMENDATIONS"); ws.cell(r, 2).fill = sub_fill; ws.cell(r, 3).fill = sub_fill; r += 1 ws.cell(r, 1, "Action").font = bold; ws.cell(r, 2, "Impact").font = bold; ws.cell(r, 3, "Timeline").font = bold; r += 1 for rec in recs: ws.cell(r, 1, rec.get("action","")) ws.cell(r, 2, rec.get("impact","")) ws.cell(r, 3, rec.get("timeline","")) r += 1 r += 1 # Risks risks = full.get("risks", []) if risks: _sub(ws, r, 1, "RISKS TO WATCH"); ws.cell(r, 2).fill = sub_fill; r += 1 for risk in risks: ws.cell(r, 1, f"โš  {risk}"); r += 1 r += 1 ws.column_dimensions["A"].width = 28 ws.column_dimensions["B"].width = 40 ws.column_dimensions["C"].width = 18 # โ”€โ”€ Sheet 2: Expense Breakdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cats = full.get("expense_categories", []) if cats: ws2 = wb.create_sheet("Expense Breakdown") _hdr(ws2, 1, 1, "Category") _hdr(ws2, 1, 2, "Amount (K)") _hdr(ws2, 1, 3, "% of Expenses") for i, c in enumerate(cats, start=2): ws2.cell(i, 1, c.get("name","")) ws2.cell(i, 2, round(c.get("amount",0), 2)) ws2.cell(i, 3, f"{c.get('pct_of_expenses',0):.1f}%") ws2.column_dimensions["A"].width = 28 ws2.column_dimensions["B"].width = 18 ws2.column_dimensions["C"].width = 18 out = BytesIO() wb.save(out) return out.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" except ImportError: # openpyxl not installed โ€” produce a UTF-8 CSV instead import csv sio = StringIO() w = csv.writer(sio) w.writerow(["Duka AI โ€” Financial Health Report"]) w.writerow([full.get("business_name",""), full.get("location",""), full.get("generated_at","")]) w.writerow([]) w.writerow(["EXECUTIVE SUMMARY"]) w.writerow([full.get("executive_summary","")]) w.writerow([]) w.writerow(["FINANCIAL SNAPSHOT", ""]) _csv_profit = snap.get("profit", 0) _csv_p_label = "Net Loss" if _csv_profit < 0 else "Profit" w.writerow(["Revenue", f"K{snap.get('revenue',0):,.0f}"]) w.writerow(["Expenses", f"K{snap.get('expenses',0):,.0f}"]) w.writerow([_csv_p_label, f"K{abs(_csv_profit):,.0f}"]) w.writerow(["Health Score",f"{snap.get('health_score',0)}/100"]) w.writerow([]) w.writerow(["LOAN READINESS", ""]) w.writerow(["Score", f"{loan.get('score',0)}/100"]) w.writerow(["Safe Borrowing",f"K{loan.get('safe_amount',0):,.0f}"]) w.writerow([]) w.writerow(["RECOMMENDATIONS", "Impact", "Timeline"]) for r in full.get("recommendations",[]): w.writerow([r.get("action",""), r.get("impact",""), r.get("timeline","")]) w.writerow([]) w.writerow(["RISKS TO WATCH"]) for risk in full.get("risks",[]): w.writerow([risk]) return sio.getvalue().encode("utf-8"), "text/csv" def render_generate_report_page() -> None: render_page_header( "Generate Report", "AI-compiled financial health report โ€” ready to share with your bank or accountant.", "Report", ) report = require_report() if not report: return baseline = st.session_state.get("baseline") or get_baseline(st.session_state) if not baseline: st.warning("No financial baseline found. Run an analysis in Chat Advisor first.") return metrics = st.session_state.get("metrics") or compute_metrics(baseline) bp = { "business_type": st.session_state.get("business_type", ""), "location": st.session_state.get("location", ""), "products_services": st.session_state.get("products_services", ""), } # โ”€โ”€ Trigger / cache logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ full: dict | None = st.session_state.get("generated_report") if full is None: if not st.session_state.get("_report_generating"): st.info( "Click below to compile a full financial health report from your analysis data. " "The AI will write the narrative; all numbers come from verified calculations." ) if st.button("โšก Generate Full Report", type="primary", use_container_width=True): st.session_state["_report_generating"] = True st.rerun() return # Generating โ€” phased progress (matches agents/report_agent.generate_full_report) from agents.report_agent import generate_full_report with st.status("๐Ÿ“„ Starting financial report pipelineโ€ฆ", expanded=True) as status: st.markdown( "**Typical duration:** about **15โ€“60 seconds**, depending on API latency. " "Every Kwacha amount is taken from Python-verified calculations first; " "the AI only writes narrative text grounded in those numbers." ) st.caption( "Pipeline: verified snapshot โ†’ expense categories โ†’ 6-month base-case forecast " "โ†’ AI executive summary โ†’ AI recommendations & risks โ†’ merge for PDF/Excel export." ) prog = st.progress(0) def _on_report_progress(step: int, total: int, detail: str) -> None: pct = min(step / max(total, 1), 1.0) try: prog.progress(pct, text=f"Step {step}/{total}") except TypeError: prog.progress(pct) short = detail if len(detail) <= 80 else detail[:77] + "โ€ฆ" status.update(label=f"๐Ÿ“„ {short}", state="running") st.markdown(f"##### Step {step} of {total}") st.markdown(detail) st.divider() try: full = generate_full_report( baseline, metrics, report, bp, on_progress=_on_report_progress ) except Exception as exc: st.session_state["_report_generating"] = False try: prog.progress(0, text="Stopped") except TypeError: prog.progress(0) status.update( label="โŒ Report generation failed โ€” see details below", state="error", expanded=True, ) st.exception(exc) return try: prog.progress(1.0, text="Done") except TypeError: prog.progress(1.0) status.update( label="โœ… Report compiled โ€” scroll down for preview and downloads.", state="complete", expanded=False, ) st.session_state["generated_report"] = full st.session_state["_report_generating"] = False st.rerun() # โ”€โ”€ Action bar (top) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ snap = full.get("snapshot", {}) loan_d = full.get("loan", {}) fc6 = full.get("forecast_6m", {}) pdf_bytes = build_full_report_pdf(full) excel_bytes, xls_mime = build_excel_report_bytes(full) xls_ext = "xlsx" if "spreadsheet" in xls_mime else "csv" xls_label = "๐Ÿ“Š Download Excel" if xls_ext == "xlsx" else "๐Ÿ“Š Download CSV" col_pdf, col_xls, col_regen = st.columns([3, 3, 2], gap="small") col_pdf.download_button( "๐Ÿ“„ Download PDF", pdf_bytes, "duka-ai-report.pdf", "application/pdf", use_container_width=True, type="primary", key="rpt_dl_pdf_top", ) col_xls.download_button( xls_label, excel_bytes, f"duka-ai-report.{xls_ext}", xls_mime, use_container_width=True, key="rpt_dl_xls_top", ) if col_regen.button("๐Ÿ”„ Regenerate", use_container_width=True): st.session_state.pop("generated_report", None) st.rerun() st.markdown("
", unsafe_allow_html=True) _rn = _report_text_escape(full.get("business_name", "Your Business")) _rloc = _report_text_escape(full.get("location", "Zambia")) _rat = _report_text_escape(full.get("generated_at", "")) _snap_pr = float(snap.get("profit", 0) or 0) _kpi_pc = "loss" if _snap_pr < 0 else "gain" st.markdown( '' '
', unsafe_allow_html=True, ) # โ”€โ”€ Hero + KPI ribbon โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown( f"""
Financial Health Report
{_rn}
{_rloc}  ยท  Generated {_rat}
Verified numbers ยท AI narrative
Monthly revenue
K{snap.get("revenue", 0):,.0f}
Monthly expenses
K{snap.get("expenses", 0):,.0f}
Net profit
K{abs(_snap_pr):,.0f}
""", unsafe_allow_html=True, ) st.markdown( f"""
Executive summary
{_report_text_escape(full.get("executive_summary", "โ€”"))}
""", unsafe_allow_html=True, ) # โ”€โ”€ Financial Snapshot + Expense Breakdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _score_color(score: int) -> str: return "#10B981" if score >= 70 else ("#F59E0B" if score >= 40 else "#EF4444") h_color = _score_color(snap.get("health_score", 0)) l_color = _score_color(loan_d.get("score", 0)) col_snap, col_exp = st.columns([1, 1], gap="medium") with col_snap: _cfs = _report_text_escape(snap.get("cash_flow_status", "โ€”")) _hl = _report_text_escape(snap.get("health_label", "N/A")) _src = _report_text_escape(snap.get("source", "โ€”")) _pr_disp = float(snap.get("profit", 0) or 0) _pr_col = "#FCA5A5" if _pr_disp < 0 else "#6EE7B7" st.markdown( f"""
Financial snapshot
Revenue K{snap.get("revenue",0):,.0f}
Expenses K{snap.get("expenses",0):,.0f} ({snap.get("expense_pct",0):.0f}% of rev.)
Profit K{_pr_disp:,.0f} ({snap.get("margin_pct",0):.1f}% margin)
Health score {snap.get("health_score",0)}/100 โ€” {_hl}
Cash flow {_cfs}
Data source {_src}
""", unsafe_allow_html=True, ) with col_exp: cats = full.get("expense_categories", []) if cats: rows_html = "".join( f""" {i + 1}. {_report_text_escape(c.get("name", ""))} K{c.get("amount", 0):,.0f} {c.get("pct_of_expenses", 0):.1f}% """ for i, c in enumerate(cats[:6]) ) st.markdown( f"""
Expense breakdown
{rows_html}
""", unsafe_allow_html=True, ) else: st.markdown( '
' "No expense category breakdown available for this analysis." "
", unsafe_allow_html=True, ) st.markdown("
", unsafe_allow_html=True) # โ”€โ”€ Cash Flow + Loan side by side โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ col_cf, col_loan = st.columns([1, 1], gap="medium") with col_cf: cf_sect = full.get("cash_flow", {}) _cf_sum = _report_text_escape(cf_sect.get("summary", "โ€”")) _cf_ins = cf_sect.get("insight") or cf_sect.get("reasoning") or "" _cf_ins_html = ( f"
{_report_text_escape(_cf_ins)}
" if _cf_ins else "" ) st.markdown( f"""
Cash flow analysis
{_cf_sum}
{_cf_ins_html}
""", unsafe_allow_html=True, ) with col_loan: improve_html = "".join( f'
  • {_report_text_escape(item)}
  • ' for item in loan_d.get("improve", []) ) _lst = _report_text_escape(loan_d.get("status", "โ€”")) _lfr = _report_text_escape(loan_d.get("formula", "")) st.markdown( f"""
    Loan readiness
    {loan_d.get("score", 0)}/100 โ€” {_lst}
    Safe borrowing: K{loan_d.get("safe_amount", 0):,.0f} ยท {_lfr}
    {"
      " + improve_html + "
    " if improve_html else ""}
    """, unsafe_allow_html=True, ) st.markdown("
    ", unsafe_allow_html=True) # โ”€โ”€ 6-Month outlook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _fc_out = ( "All 6 months profitable (base case)." if fc6.get("all_profitable") else f"Attention: losses possible from {fc6.get('first_loss_month', 'โ€”')} upward." ) st.markdown( f"""
    6-month outlook (base case)
    Month 6 profit
    K{fc6.get("base_profit_m6", 0):,.0f}
    Total 6-mo profit
    K{fc6.get("total_profit", 0):,.0f}
    Profitable months
    {fc6.get("profitable_months", 0)}/6
    Outlook
    {_report_text_escape(_fc_out)}
    """, unsafe_allow_html=True, ) st.markdown("
    ", unsafe_allow_html=True) # โ”€โ”€ Market Conditions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ mkt = full.get("market", {}) if mkt.get("summary"): monitor_html = "".join( f'' f'{_report_text_escape(m)}' for m in mkt.get("monitor", []) ) _opp = mkt.get("opportunity", "") _opp_blk = ( f"
    Opportunity: {_report_text_escape(_opp)}
    " if _opp else "" ) st.markdown( f"""
    Market conditions
    {_report_text_escape(mkt.get("summary", "โ€”"))}
    {_opp_blk} {("
    " + monitor_html + "
    ") if monitor_html else ""}
    """, unsafe_allow_html=True, ) # โ”€โ”€ Recommendations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ recs = full.get("recommendations", []) if recs: TIMELINE_COLORS = {"Now": "#10B981", "This month": "#2563EB"} rec_cards = "" for i, r in enumerate(recs): t_color = TIMELINE_COLORS.get(r.get("timeline", ""), "#64748B") _act = _report_text_escape(r.get("action", "โ€”")) _imp = _report_text_escape(r.get("impact", "โ€”")) _tl = _report_text_escape(r.get("timeline", "โ€”")) rec_cards += f"""
    {i + 1}
    {_act}
    โ†’ {_imp} {_tl}
    """ st.markdown( f"""
    Top recommendations
    {rec_cards}
    """, unsafe_allow_html=True, ) # โ”€โ”€ Risks to Watch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ risks = full.get("risks", []) if risks: risk_html = "".join( f'
    ' f'' f'{_report_text_escape(r)}
    ' for r in risks ) st.markdown( f"""
    Risks to watch
    {risk_html}
    """, unsafe_allow_html=True, ) # โ”€โ”€ Footer / branding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown( '
    ' "Generated by Duka AI  ยท  Powered by AMD MI300X  ยท  Qwen Model" "
    " "
    ", unsafe_allow_html=True, ) # โ”€โ”€ Share section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown("
    ", unsafe_allow_html=True) with st.expander("๐Ÿ“ค Share this report", expanded=False): st.markdown( '
    ' 'Share this report with your bank manager or accountant via WhatsApp or email. ' 'Download the PDF above and attach it to your message.' '
    ', unsafe_allow_html=True, ) # Plain-text copy of the report report_text = ( f"Duka AI โ€” Financial Health Report\n" f"{full.get('business_name','')} | {full.get('location','')} | {full.get('generated_at','')}\n" f"{'='*60}\n\n" f"EXECUTIVE SUMMARY\n{full.get('executive_summary','')}\n\n" f"FINANCIAL SNAPSHOT\n" f"Revenue: K{snap.get('revenue',0):,.0f}\n" f"Expenses: K{snap.get('expenses',0):,.0f} ({snap.get('expense_pct',0):.0f}%)\n" f"{'Net Loss' if snap.get('profit',0) < 0 else 'Profit'}: K{abs(snap.get('profit',0)):,.0f} ({snap.get('margin_pct',0):.1f}% margin)\n" f"Health Score: {snap.get('health_score',0)}/100 โ€” {snap.get('health_label','')}\n\n" f"LOAN READINESS\n" f"Score: {loan_d.get('score',0)}/100 โ€” {loan_d.get('status','')}\n" f"Safe borrowing: K{loan_d.get('safe_amount',0):,.0f}\n\n" + "TOP RECOMMENDATIONS\n" + "\n".join( f"{i+1}. {r.get('action','')} โ†’ {r.get('impact','')} ({r.get('timeline','')})" for i, r in enumerate(recs) ) + "\n\n" + ("RISKS TO WATCH\n" + "\n".join(f"โš  {r}" for r in risks) + "\n\n" if risks else "") + f"{'='*60}\nGenerated by Duka AI | Powered by AMD MI300X | Qwen Model" ) st.text_area("Copy report text", report_text, height=200, key="report_share_text") sh_pdf, sh_xls = st.columns(2) sh_pdf.download_button( "๐Ÿ“„ Download PDF", pdf_bytes, "duka-ai-report.pdf", "application/pdf", use_container_width=True, key="rpt_dl_pdf_share", ) sh_xls.download_button( xls_label, excel_bytes, f"duka-ai-report.{xls_ext}", xls_mime, use_container_width=True, key="rpt_dl_xls_share", ) # โ”€โ”€ Schedule automatic reports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown("
    ", unsafe_allow_html=True) st.markdown( """
    📬 Want this report automatically?
    I can analyze your business and email you a complete financial report on a schedule you choose — no action needed from you.
    """, unsafe_allow_html=True, ) st.markdown( "
    " "Set up automatic reports:
    ", unsafe_allow_html=True, ) sched_freq = st.session_state.get("schedule_frequency") btn_w, btn_m, btn_c = st.columns(3, gap="small") if btn_w.button( "๐Ÿ“… Weekly โ€” every Monday", use_container_width=True, type="primary" if sched_freq == "weekly" else "secondary", key="sched_weekly", ): st.session_state["schedule_frequency"] = "weekly" st.session_state.pop("schedule_confirmed", None) st.rerun() if btn_m.button( "๐Ÿ“… Monthly โ€” 1st of month", use_container_width=True, type="primary" if sched_freq == "monthly" else "secondary", key="sched_monthly", ): st.session_state["schedule_frequency"] = "monthly" st.session_state.pop("schedule_confirmed", None) st.rerun() if btn_c.button( "โœ๏ธ Custom schedule", use_container_width=True, type="primary" if sched_freq == "custom" else "secondary", key="sched_custom", ): st.session_state["schedule_frequency"] = "custom" st.session_state.pop("schedule_confirmed", None) st.rerun() if sched_freq and not st.session_state.get("schedule_confirmed"): st.markdown("
    ", unsafe_allow_html=True) if sched_freq == "custom": _custom_map = { "Every 2 weeks (bi-weekly)": "biweekly", "Quarterly (every 3 months)": "quarterly", } _chosen = st.selectbox( "Choose your schedule:", list(_custom_map.keys()), key="sched_custom_choice", ) effective_freq = _custom_map[_chosen] else: effective_freq = sched_freq sched_email = st.text_input( "Your email address", placeholder="you@example.com", key="sched_email_input", ) if st.button( "Set up automatic reports โœ…", type="primary", use_container_width=True, key="sched_confirm", ): import re as _re if not sched_email or not _re.match(r"[^@]+@[^@]+\.[^@]+", sched_email.strip()): st.error("Please enter a valid email address.") else: _biz_ctx = { "business_type": st.session_state.get("business_type", ""), "location": st.session_state.get("location", "Zambia"), "products_services": st.session_state.get("products_services", ""), "manual_notes": st.session_state.get("manual_notes", ""), "manual_revenue": st.session_state.get("manual_revenue", 0.0), "manual_expenses": st.session_state.get("manual_expenses", 0.0), "manual_debt": st.session_state.get("manual_debt", 0.0), "manual_staff": st.session_state.get("manual_staff", 0), } try: from tools.scheduler import save_schedule _sid = save_schedule( email=sched_email.strip(), frequency=effective_freq, business_context=_biz_ctx, ) st.session_state["schedule_confirmed"] = True st.session_state["schedule_email"] = sched_email.strip() st.session_state["schedule_id"] = _sid st.rerun() except Exception as _exc: st.error(f"Could not save schedule: {_exc}") if st.session_state.get("schedule_confirmed"): _conf_email = st.session_state.get("schedule_email", "") _conf_freq = st.session_state.get("schedule_frequency", "weekly") _freq_display = { "weekly": "every Monday at 8 AM", "monthly": "on the 1st of each month at 8 AM", "biweekly": "every 2 weeks at 8 AM", "quarterly": "every quarter at 8 AM", "custom": "on your chosen schedule", }.get(_conf_freq, "on your chosen schedule") st.success( f"Scheduled! Your financial report will be emailed to **{_conf_email}** {_freq_display}." ) if st.button("Cancel / change schedule", key="sched_cancel"): for _k in ["schedule_confirmed", "schedule_frequency", "schedule_email", "schedule_id"]: st.session_state.pop(_k, None) st.rerun() def _market_intel_identity_reply() -> str: return ( "I'm **Duka AI's Market Intelligence Agent** โ€” part of **Duka AI**, built for **Zambian SMEs**. " "I connect **your business profile and numbers** from your last analysis to **market conditions** " "(this pageโ€™s snapshot and, when enabled, web sources from when your report was built). " "I'm not a general chatbot for every topic โ€” I stay focused on **your market and your financial picture**.\n\n" "Try: *What's the biggest risk for my type of business right now?* or " "*How does my revenue pattern line up with what the market intel says?*" ) def _build_market_chat_business_context(report: dict[str, Any]) -> str: """Ground Market Intel chat in the same numbers as the rest of the report.""" lines: list[str] = [] cf = report.get("cashflow") or {} lines.append("VERIFIED BUSINESS NUMBERS (from last analysis โ€” cite these for sales/profit/trend questions):\n") lines.append( f"- Revenue K{float(cf.get('revenue', 0)):,.0f} ยท Expenses K{float(cf.get('expenses', 0)):,.0f} ยท " f"Profit K{float(cf.get('profit', 0)):,.0f} ยท Margin {float(cf.get('profit_margin', 0)):.1f}% ยท " f"Status: {cf.get('cash_flow_status', 'n/a')}\n" ) if (cf.get("summary") or "").strip(): lines.append(f"- Summary: {(cf.get('summary') or '')[:500]}\n") parsed = report.get("parsed_data") or {} ac = parsed.get("_analysis_context") or {} doc = report.get("document_analysis") or ac.get("document_analysis") or {} bp = report.get("business_profile") or ac.get("business_profile") or {} bt = (parsed.get("business_type") or bp.get("business_type") or "").strip() loc = (parsed.get("location") or bp.get("location") or "").strip() products = (parsed.get("products_services") or bp.get("products_services") or "").strip() if bt or loc: lines.append(f"- Business: {bt or 'SME'} ยท Location: {loc or 'Zambia'}\n") if products: lines.append(f"- Products/services listed by owner: {products[:600]}\n") mb = doc.get("monthly_breakdown") if isinstance(doc.get("monthly_breakdown"), list) else [] if len(mb) > 1: lines.append( "MONTHLY REVENUE PATTERN (use this for โ€œtrendingโ€, seasonality, up/down โ€” not individual SKUs):\n" ) for entry in mb[:14]: if not isinstance(entry, dict): continue mn = entry.get("month") or entry.get("Month") or "" rev = entry.get("revenue", entry.get("Revenue")) if rev is not None: try: lines.append(f" ยท {mn}: revenue K{float(rev):,.0f}\n") except (TypeError, ValueError): pass eb = parsed.get("expenses_breakdown") or {} if isinstance(eb, dict) and eb: top = sorted( ((k, float(v or 0)) for k, v in eb.items() if float(v or 0) > 0), key=lambda x: x[1], reverse=True, )[:5] if top: lines.append( "TOP EXPENSE CATEGORIES (if user asks where money goes):\n" + "".join(f" ยท {k}: K{v:,.0f}\n" for k, v in top) ) ts = report.get("transaction_summary") or {} if ts.get("average_daily_sales"): lines.append( f"- Transactions (if connected): avg daily sales ~{format_currency(ts['average_daily_sales'])}, " f"risk {ts.get('cash_flow_risk', 'n/a')}.\n" ) lines.append( "\nSCOPE NOTE: There is usually **no per-product SKU sales feed**. " "For โ€œare my products trendingโ€, use **monthly revenue trend** above + ownerโ€™s product list + market intel. " "If monthly data is missing, say revenue is only known as totals above.\n" ) return "".join(lines)[:7500] def _classify_market_chat_input(question: str) -> str | None: """Return a canned reply key, or None to use the LLM.""" low = (question or "").lower().strip() if not low: return "empty" if any( p in low for p in ( "who are you", "what are you", "what do you do", "introduce yourself", ) ): return "identity" if low in ("thanks", "thank you", "ty", "cheers", "ta"): return "thanks" return None def _handle_market_question( question: str, market: dict, chat_history: list[dict], report: dict[str, Any] | None = None, ) -> str: """Answer using the market block + verified business numbers from the same report.""" from agents import request_llm_chat canned = _classify_market_chat_input(question) if canned == "identity": return _market_intel_identity_reply() if canned == "thanks": return "Glad it helped โ€” ask anything else about your market snapshot or your numbers." if canned == "empty": return "Type a question about your market or how it relates to your business." sources = market.get("sources", []) queries = market.get("queries_used", []) web_used = market.get("web_search_used", False) # Build context from real search data source_context = "" if web_used and sources: source_context = "\n\nWEB SEARCH SOURCES (real, retrieved data โ€” use these):\n" seen: set[str] = set() for s in sources: url = s.get("url", "") if url in seen: continue seen.add(url) source_context += f"- {s.get('title', '')} ({url})\n" if s.get("content"): source_context += f" {s['content'][:300]}\n" market_context = ( f"MARKET INTELLIGENCE RESULTS:\n" f"Business type: {market.get('business_type', 'SME')}\n" f"Location: {market.get('location', 'Zambia')}\n" f"Market summary: {market.get('market_summary', '')}\n" f"Key risks: {'; '.join(market.get('risk_factors', []))}\n" f"Opportunity: {market.get('opportunity', '')}\n" f"Recommendation: {market.get('recommendation', '')}\n" f"What to monitor: {'; '.join(market.get('monitor_next', []))}\n" f"Search queries used: {'; '.join(queries)}\n" f"{source_context}" ) biz_block = _build_market_chat_business_context(report) if report else "" if web_used: grounding = ( "Live Tavily web search was used when this report was built โ€” snippets and titles below are real. " "Answer using the MARKET INTELLIGENCE block + VERIFIED BUSINESS NUMBERS + web excerpts. " "Do NOT invent statistics. Cite or paraphrase sources when you mention external facts." ) else: grounding = ( "No live web search ran for this report (missing TAVILY_API_KEY or search failed). " "The MARKET INTELLIGENCE RESULTS below are best-effort synthesis from business context โ€” " "do NOT claim you ran a fresh web search. " "Still use VERIFIED BUSINESS NUMBERS for anything about **their** sales, profit, or month-to-month trend.\n" "If something isn't in the data, say exactly what's missing (e.g. no SKU-level sales)." ) system = ( "You are **Duka AI's Market Intelligence Agent** inside **Duka AI** for **Zambian SMEs**. " "You are NOT a generic assistant for โ€œthe economyโ€ or random topics โ€” you tie **market conditions** to " "**this owner's** business type, location, and **verified numbers**.\n\n" "WHO YOU ARE (if asked): You help this user interpret **their** market snapshot (this page) and how it " "relates to **their revenue/cash flow** โ€” not broad unrelated advice.\n\n" "PRODUCT / โ€œTRENDINGโ€ QUESTIONS: You normally do **not** have per-SKU sales. " "Answer using **monthly revenue** (if provided), overall totals, their stated products/services, " "and the market intel risks/opportunities. Say clearly when you're inferring vs when data proves it.\n\n" f"{grounding}\n" "Be concise (2โ€“6 sentences unless they ask for detail). " "Never refuse to discuss trends if monthly revenue or totals exist โ€” use them.\n\n" f"{biz_block}\n" f"{market_context}" ) messages = [ {"role": "system", "content": system}, *[{"role": m["role"], "content": m["content"]} for m in chat_history[-8:]], {"role": "user", "content": question}, ] result = request_llm_chat(messages, temperature=0.2, max_tokens=500) return result or "I couldn't generate a response right now. Please try again." def render_market_intel_page() -> None: render_page_header( "Market Intel", "Live web search for Zambian market conditions, risks, and opportunities.", "Live", ) report = require_report() if not report: return market = report.get("market", {}) st.markdown( '', unsafe_allow_html=True, ) # Two-column layout: intel card left, chat panel right col_intel, col_chat = st.columns([3, 2], gap="large") with col_intel: # Market data was computed during Chat Advisor analysis โ€” we don't re-search here. render_market_intelligence_section(report) with col_chat: web_used = market.get("web_search_used", False) sources = market.get("sources", []) if web_used: sub_line = ( f"Grounded in {len(sources)} live web sources ยท " "Ask anything about your market" ) else: sub_line = "Ask anything about your business's market conditions in Zambia" st.markdown( '
    ', unsafe_allow_html=True, ) st.markdown( '
    ' '
    ' '๐ŸŒ Ask the Market Agent' '' f"{html.escape(sub_line)}" "" "
    ", unsafe_allow_html=True, ) # Suggestion pills sugg_pills = [ "๐Ÿ“ˆ What's the market trend?", "โš ๏ธ What are the main risks?", "๐ŸŽฏ What's my best opportunity?", "๐Ÿ’ฐ How do prices affect me?", "๐Ÿ—“๏ธ What's the best season to grow?", "๐Ÿ† How do I beat competition?", ] p1, p2 = st.columns(2) for idx, pill in enumerate(sugg_pills): col = p1 if idx % 2 == 0 else p2 if col.button(pill, key=f"mpill_{idx}", use_container_width=True): st.session_state["market_question"] = pill st.markdown("
    ", unsafe_allow_html=True) # Chat history (same card shell as Cash Flow Forecast conversation) if "market_chat" not in st.session_state: st.session_state["market_chat"] = [] if not st.session_state["market_chat"]: st.markdown( '
    No messages yet โ€” tap a suggestion ' "or type below.
    ", unsafe_allow_html=True, ) for msg in st.session_state["market_chat"]: if msg["role"] == "user": escaped = ( msg["content"] .replace("&", "&") .replace("<", "<") .replace(">", ">") ) st.markdown( f'
    {escaped}
    ', unsafe_allow_html=True, ) else: with st.chat_message("assistant", avatar="๐ŸŒ"): st.markdown(msg["content"]) st.markdown("
    ", unsafe_allow_html=True) user_q = st.chat_input("Ask about your market...", key="market_chat_input") if st.session_state.get("market_question"): user_q = st.session_state.pop("market_question") if user_q: st.session_state["market_chat"].append({"role": "user", "content": user_q}) with st.spinner("๐ŸŒ Searching market intelligenceโ€ฆ"): reply = _handle_market_question( user_q, market, st.session_state["market_chat"], report, ) st.session_state["market_chat"].append({"role": "assistant", "content": reply}) st.session_state["market_pin_to_question"] = True st.rerun() # ChatGPT-style scroll: pin the user's last question to top after a reply if st.session_state.pop("market_pin_to_question", False): inject_pin_to_question_scroll() def render_settings_page(settings: Any) -> None: render_page_header( "Settings", "Control the current session, provider preference, and reset behavior.", ) ai_status = "โœ… AI analysis active" if settings.llm_enabled else "โš ๏ธ AI unavailable โ€” check API key" st.caption(ai_status) if settings.llm_provider == "amd": active_model = settings.amd_model provider_label = "AMD MI300X vLLM" elif settings.llm_provider == "openai": active_model = settings.model_name provider_label = "OpenAI-compatible API" else: active_model = settings.amd_model or settings.model_name provider_label = settings.llm_provider st.text_input("Provider", value=provider_label, disabled=True) st.text_input("Model", value=active_model, disabled=True) if st.button("Clear session", type="primary"): for key in ["analysis_report", "messages", "manual_notes", "selected_example_prompt", "selected_example_prompt_text"]: st.session_state[key] = [] if key == "messages" else None if key == "analysis_report" else "" st.session_state.active_page = "Chat Advisor" st.rerun() def render_selected_page(page: str, settings: Any) -> None: if page == "Dashboard": render_dashboard_page() elif page == "Cash Flow Forecast": render_cash_flow_forecast_page() elif page == "Scenario Planner": render_scenario_planner_page() elif page == "Loan Calculator": render_loan_calculator_page() elif page == "Expense Analyzer": render_expense_analyzer_page() elif page == "Generate Report": render_generate_report_page() elif page == "Market Intel": render_market_intel_page() elif page == "Settings": render_settings_page(settings) def apply_custom_styles() -> None: st.markdown( f""" """, unsafe_allow_html=True, ) # โ”€โ”€ Polish CSS: fonts, cards, button hover, scrollbar, footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown( """ """, unsafe_allow_html=True, ) def inject_pin_to_question_scroll() -> None: """ChatGPT-style scroll: pin the LATEST .user-bubble-wrap to the top of the viewport and bow out the moment the user shows scroll intent. Reusable across Chat Advisor, Cash Flow Forecast chat, Market Intel chat, or any other page that uses the .user-bubble-wrap right-aligned bubble pattern. Safe to call multiple times per render โ€” only the most recent invocation is meaningful. """ components.html( """ """, height=0, width=0, ) def inject_navigation_loader() -> None: """Full-screen loader when switching sidebar pages (injected into parent document).""" components.html( """ """, height=0, width=0, ) def main() -> None: st.set_page_config( page_title="Duka AI", page_icon=":material/monitoring:", layout="wide", initial_sidebar_state="expanded", ) apply_custom_styles() inject_navigation_loader() settings = get_settings() sample_cases = load_sample_cases() init_session_state() from tools.scheduler import start_scheduler start_scheduler() defaults = { "business_type": "", "location": "", "products_services": "", "main_question": "", "manual_notes": "", "manual_revenue": 0.0, "manual_expenses": 0.0, "manual_debt": 0.0, "manual_staff": 0, "document_type": "Income Statement", "numbers_entry_mode": "upload", "selected_example_prompt": "", "selected_example_prompt_text": "", "scroll_target": None, "connected_provider": None, "selected_providers": set(), "analysis_report": None, "messages": [], "last_agent": "advisor", "hide_example_prompts": False, } for key, value in defaults.items(): st.session_state.setdefault(key, value) # โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ active_page = render_sidebar_navigation(settings) if active_page != "Chat Advisor": render_selected_page(active_page, settings) return # โ”€โ”€ Welcome screen (shown on first load and after reset) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if st.session_state.get("show_welcome") and not st.session_state.get("analysis_report"): render_welcome_screen(sample_cases) return if st.session_state.get("analysis_report"): st.session_state.pop("pending_demo_analysis_run", None) st.markdown( """
    ⚡ AI-powered  ·  🌎 Built for Africa

    Your AI financial advisor
    for African businesses

    Analyze cash flow, loan readiness, and market conditions. Talk to your finances like a conversation, not a spreadsheet.

    """, unsafe_allow_html=True, ) if st.session_state.pop("scroll_to_demo_analyze", False): components.html( """ """, height=0, width=0, ) if ( st.session_state.get("pending_demo_analysis_run") and not st.session_state.get("analysis_report") ): with st.status("โšก Demo analysis in progressโ€ฆ", expanded=True): st.write("๐Ÿ“‚ Sample income statement loaded ยท **Lusaka Grocery Shop** profile applied.") st.write("๐Ÿง  Starting all agents next โ€” detailed steps appear in the panel below.") st.caption("Stay on this page โ€” the workspace scrolls to **Run Full Business Analysis** automatically.") # โ”€โ”€ Powered-by indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _provider = os.getenv("LLM_PROVIDER", "amd").strip().upper() _model_display = ( "AMD MI300X" if _provider == "AMD" else ("OpenAI-compatible" if _provider == "OPENAI" else _provider or "AMD MI300X") ) st.markdown( f"""
    Powered by Duka AI  ·  {_model_display}  ·  Talk to your finances like a conversation
    """, unsafe_allow_html=True, ) # โ”€โ”€ Pre-fill banner & scroll (welcome sample uses numbers anchor + hides duplicate example pills) _did_prefill = st.session_state.pop("form_prefilled", False) _scroll_numbers = st.session_state.pop("scroll_to_numbers", False) if _did_prefill: if st.session_state.get("hide_example_prompts"): if st.session_state.get("pending_demo_analysis_run"): st.success( "โœ… **Lusaka Grocery Shop** demo loaded โ€” sample income statement + figures are applied. " "**Running full analysis automatically** โ€” watch the progress panel below." ) else: st.success( "โœ… **Lusaka Grocery Shop** sample loaded โ€” your story and figures are filled in above. " "Upload a file below (optional) or jump to **Manual numbers**, then click **Run Full Business Analysis**." ) else: st.success( "โœ… Sample business loaded โ€” review the details below and click **Run Full Business Analysis**" ) if st.session_state.get("pending_demo_analysis_run"): _scroll_id = "duka-ai-analyze-anchor" else: _scroll_id = "duka-ai-numbers-anchor" if _scroll_numbers else "duka-ai-form-anchor" components.html( f""" """, height=0, width=0, ) elif _scroll_numbers: components.html( """ """, height=0, width=0, ) has_existing_analysis = bool(st.session_state.get("analysis_report")) if not has_existing_analysis: # โ”€โ”€ Section 1: Business Context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown('
    ', unsafe_allow_html=True) render_section_intro( "Tell us about your business", "A few details help Duka AI tailor its analysis to your situation.", ) context_col_1, context_col_2, context_col_3, context_col_4 = st.columns(4, gap="medium") with context_col_1: st.session_state.business_type = st.text_input( "Business type", value=st.session_state.business_type, placeholder="Grocery shop", help="What kind of business do you run?", ) with context_col_2: st.session_state.location = st.text_input( "Location", value=st.session_state.location, placeholder="Lusaka", help="City or area where your business operates", ) with context_col_3: st.session_state.products_services = st.text_input( "Main products / services", value=st.session_state.products_services, placeholder="Groceries, mealie meal, drinks", help="What do you sell or offer?", ) with context_col_4: st.session_state.main_question = st.text_input( "Main question or concern", value=st.session_state.main_question, placeholder="Should I restock or save cash?", help="What do you want Duka AI to help with?", ) st.session_state.manual_notes = st.text_area( "Additional notes (optional)", value=st.session_state.manual_notes, placeholder="I run a small grocery shop in Lusaka. I want to know whether to restock or save cash. This week I made K4,500 in sales...", help="Add any extra details, financial numbers, debt, or business notes.", ) st.markdown('
    ', unsafe_allow_html=True) continue_to_data = st.button( "โ†“ Continue to data sources", key="continue_to_data_sources", use_container_width=True, help="Jump to the upload/connect/manual section", ) st.markdown( '
    Want higher accuracy? Add a document or figures in the section below first.
    ', unsafe_allow_html=True, ) if continue_to_data: components.html( """ """, height=0, width=0, ) st.divider() if not st.session_state.get("hide_example_prompts"): st.markdown('
    Example prompts
    ', unsafe_allow_html=True) render_example_prompt_pills(sample_cases) # โ”€โ”€ Trust strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown("
    ", unsafe_allow_html=True) _trust_items = [ ("๐Ÿ”’", "Bank-grade security", "Your data stays private"), ("๐Ÿค–", "4 AI agents", "Working together for you"), ("๐ŸŒ", "Built for Africa", "Zambian market context"), ("โšก", "AMD MI300X", "Enterprise-grade GPU power"), ] for _col, (_icon, _title, _sub) in zip(st.columns(4), _trust_items): with _col: st.markdown( f"""
    {_icon} {_title}
    {_sub}
    """, unsafe_allow_html=True, ) st.markdown("
    ", unsafe_allow_html=True) st.divider() # โ”€โ”€ Section 2: Data Sources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown('
    ', unsafe_allow_html=True) render_section_intro( "๐Ÿ“Ž Want more precise analysis? Add your numbers", "Choose one path below. Any extra numbers help Duka AI give tighter, more specific advice.", ) uploaded_analysis: dict[str, Any] | None = None upload_error: str | None = None option_cols = st.columns(3, gap="small") for column, (label, mode_value) in zip(option_cols, NUMBER_SOURCE_OPTIONS): with column: if st.button(label, key=f"numbers_mode_{mode_value}"): st.session_state.numbers_entry_mode = mode_value st.rerun() selected_mode = st.session_state.numbers_entry_mode st.markdown( '
    You can switch between options at any time before you analyze.
    ', unsafe_allow_html=True, ) if selected_mode == "manual": st.markdown('
    Enter only the figures you know. Even partial numbers make the analysis more precise.
    ', unsafe_allow_html=True) num_col1, num_col2, num_col3, num_col4 = st.columns(4, gap="medium") with num_col1: st.session_state.manual_revenue = st.number_input( "Weekly or monthly revenue (K)", min_value=0.0, value=float(st.session_state.manual_revenue), step=100.0, format="%.0f", help="Your typical sales or income per week or month in Kwacha", ) with num_col2: st.session_state.manual_expenses = st.number_input( "Monthly expenses (K)", min_value=0.0, value=float(st.session_state.manual_expenses), step=100.0, format="%.0f", help="Your total monthly costs โ€” stock, rent, wages, etc.", ) with num_col3: st.session_state.manual_debt = st.number_input( "Outstanding debt (K)", min_value=0.0, value=float(st.session_state.manual_debt), step=100.0, format="%.0f", help="Any loans or supplier debt you currently owe", ) with num_col4: st.session_state.manual_staff = st.number_input( "Number of staff", min_value=0, value=int(st.session_state.manual_staff), step=1, help="How many people work in your business (including yourself)", ) elif selected_mode == "upload": st.markdown('
    Upload your business document
    ', unsafe_allow_html=True) st.markdown('
    Select the document type, upload the file, and Duka AI will extract key financial figures.
    ', unsafe_allow_html=True) st.caption("Accepted file types: CSV, Excel (.xlsx), TXT, PDF") st.caption("PDF support is experimental. For best results, upload CSV, Excel, or text records.") st.session_state.document_type = st.selectbox( "Document type", DOCUMENT_TYPES, index=DOCUMENT_TYPES.index(st.session_state.document_type) if st.session_state.document_type in DOCUMENT_TYPES else 0, ) uploaded_file = st.file_uploader( "Upload business document", type=["csv", "xlsx", "txt", "pdf"], help="Upload an income statement, sales record, expense log, bank statement, mobile money statement, or business notes.", ) if uploaded_file is not None: try: _doc_fp = f"{uploaded_file.name}:{uploaded_file.size}" _is_new_file = st.session_state.get("_doc_fingerprint") != _doc_fp if _is_new_file: _ext = uploaded_file.name.rsplit(".", 1)[-1].upper() if "." in uploaded_file.name else "FILE" with st.status( f"๐Ÿ“‚ Reading {uploaded_file.name} โ€ฆ", expanded=True, ) as _upload_status: st.write(f"๐Ÿ” Detecting document structure ({_ext} format)โ€ฆ") st.write("๐Ÿ“Š Extracting financial figuresโ€ฆ") uploaded_analysis = parse_uploaded_file( uploaded_file, st.session_state.document_type ) _det_type = uploaded_analysis.get("document_type", "document") _conf = uploaded_analysis.get("confidence", "") _conf_str = f" ยท {_conf} confidence" if _conf else "" _upload_status.update( label=f"โœ… {_det_type} loaded{_conf_str}", state="complete", expanded=False, ) else: uploaded_analysis = parse_uploaded_file( uploaded_file, st.session_state.document_type ) # Replace stale data only when a genuinely new file is detected. if _is_new_file: # โ”€โ”€ Wipe ALL previous financial state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ for _k in ( "baseline", "metrics", "parsed_document", "manual_revenue", "manual_expenses", "manual_profit", "document_revenue", "document_expenses", "generated_report", "analysis_report", ): st.session_state.pop(_k, None) for _k in [k for k in st.session_state if k.startswith("forecast_summary_")]: del st.session_state[_k] st.session_state["forecast_chat"] = [] # โ”€โ”€ Load fresh data from the new document โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.session_state["parsed_document"] = uploaded_analysis doc_rev = float(uploaded_analysis.get("revenue") or 0) doc_exp = float(uploaded_analysis.get("expenses") or 0) if doc_rev > 0 or doc_exp > 0: st.session_state["manual_revenue"] = doc_rev st.session_state["manual_expenses"] = doc_exp new_baseline = { "monthly_revenue": doc_rev, "monthly_expenses": doc_exp, "monthly_profit": doc_rev - doc_exp, "months_of_data": int(uploaded_analysis.get("months_of_data") or 1), "source": ( f"Uploaded {uploaded_analysis.get('document_type', 'document')}: " f"{uploaded_file.name}" ), "expenses_breakdown": uploaded_analysis.get("expenses_breakdown", {}), } st.session_state["baseline"] = new_baseline st.session_state["metrics"] = compute_metrics(new_baseline) st.session_state["_doc_fingerprint"] = _doc_fp st.markdown('
    ', unsafe_allow_html=True) render_file_preview(uploaded_analysis) if st.session_state.get("analysis_report"): st.info("Document updated. Click **Re-run Analysis with Current Data** below to refresh all agent insights.") else: st.info("Document extracted successfully. Next step: click **Run Full Business Analysis** below.") if _is_new_file: components.html( """ """, height=0, width=0, ) except Exception as exc: upload_error = f"Could not parse the uploaded file. {exc}" st.error(upload_error) with st.expander("Need a sample template?", expanded=False): st.markdown('
    Use one of these starter files if you want a clean format to upload.
    ', unsafe_allow_html=True) render_download_buttons() else: st.markdown( '
    ' '' 'โš  Demo Mode โ€” no real accounts connected
    ', unsafe_allow_html=True, ) st.markdown('
    Connect a demo account
    ', unsafe_allow_html=True) st.markdown('
    This is a safe demo flow. Pick a provider to preview how synced account data would improve the analysis.
    ', unsafe_allow_html=True) render_clickable_provider_cards() connected_preview = get_connected_demo(st.session_state.connected_provider) if connected_preview: render_connected_preview(connected_preview) # โ”€โ”€ Analyze Button (bottom โ€” after optional data sources) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.divider() st.markdown('
    ', unsafe_allow_html=True) has_existing_analysis = bool(st.session_state.get("analysis_report")) analyze_clicked = False if not has_existing_analysis: analyze_clicked = st.button( "โšก Run Full Business Analysis", key="analyze_main", type="primary", use_container_width=True, ) st.markdown( '
    Runs all agents (cash flow, advisor, loan readiness, market, and summary).
    ', unsafe_allow_html=True, ) # Auto-trigger when demo flow set the flag (judge-friendly one-click path). if not analyze_clicked and st.session_state.pop("auto_run_demo", False): analyze_clicked = True st.info("โœจ Demo data loaded โ€” running full analysis automaticallyโ€ฆ") # Allow downstream analyze branch to use the previously parsed sample # document when no fresh st.file_uploader event has occurred. if uploaded_analysis is None and st.session_state.get("parsed_document"): uploaded_analysis = st.session_state.get("parsed_document") if analyze_clicked: # Immediately scroll to the status panel so user sees agents working components.html( """ """, height=0, width=0, ) connected_analysis = get_connected_demo(st.session_state.connected_provider) manual_figures = { "revenue": float(st.session_state.manual_revenue), "expenses": float(st.session_state.manual_expenses), "debt": float(st.session_state.manual_debt), "staff": int(st.session_state.manual_staff), } has_manual_figures = any(float(value or 0) > 0 for value in manual_figures.values()) business_profile = { "business_type": st.session_state.business_type, "location": st.session_state.location, "products_services": st.session_state.products_services, "main_question": st.session_state.main_question, } if ( not any(value.strip() for value in business_profile.values()) and not st.session_state.manual_notes.strip() and not has_manual_figures and uploaded_analysis is None and connected_analysis is None ): st.warning("Please provide business context, upload a document, connect demo transaction data, or combine them before running the analysis.") elif upload_error and not st.session_state.manual_notes.strip() and not has_manual_figures and connected_analysis is None: st.error("The uploaded file could not be analyzed, so there is not enough data to continue.") else: analysis_context, combined_parsed_data, basis_notes, source_labels, data_sources = combine_analysis_inputs( business_profile, st.session_state.manual_notes, uploaded_analysis, connected_analysis, manual_figures=manual_figures, ) with st.status("๐Ÿง  Duka AI โ€” analyzing your businessโ€ฆ", expanded=True) as status: pre_steps = sum([ 1 if any(value.strip() for value in business_profile.values()) else 0, 1 if st.session_state.manual_notes.strip() else 0, 1 if has_manual_figures else 0, 1 if uploaded_analysis else 0, 1 if connected_analysis else 0, ]) total_steps = pre_steps + 5 # 5 downstream agents incl. executive summary step_index = 0 progress = st.progress(0) def _step(msg: str) -> None: nonlocal step_index step_index += 1 st.write(f"{step_index}/{total_steps} โ€ข {msg}") progress.progress(min(step_index / total_steps, 1.0)) if any(value.strip() for value in business_profile.values()): _step("๐Ÿ“‹ Reading your business context and profileโ€ฆ") if st.session_state.manual_notes.strip(): _step("๐Ÿ“ Parsing your written business descriptionโ€ฆ") if has_manual_figures: _step("๐Ÿ“Ž Locking in the numbers you enteredโ€ฆ") if uploaded_analysis: _step(f"๐Ÿ“„ Interpreting uploaded {uploaded_analysis.get('document_type', 'document').lower()}โ€ฆ") if connected_analysis: _step(f"๐Ÿ”— Reading {connected_analysis.get('provider_selected', 'connected')} transaction dataโ€ฆ") completed_agents: set[str] = set() def _on_agent_progress(agent_key: str) -> None: rev = combined_parsed_data.get("revenue", 0) or 0 exp = combined_parsed_data.get("expenses", 0) or 0 profit = round(rev - exp, 0) dynamic_msgs: dict[str, str] = { "cashflow": ( f"๐Ÿ“Š Cash Flow Agent โ€” analyzing {format_currency(rev)} revenue " f"and {format_currency(exp)} expenses." ), "advisor": ( f"๐Ÿ’ก Business Advisor Agent โ€” building recommendations " f"from {format_currency(profit)} monthly profit." ), "loan": ( f"๐Ÿฆ Loan Readiness Agent โ€” calculating safe borrowing capacity " f"from {format_currency(profit)} profit and {format_currency(combined_parsed_data.get('debt', 0) or 0)} existing debt." ), "market": ( "๐Ÿ“ˆ Market Intelligence Agent โ€” checking Zambian market trends " f"for {combined_parsed_data.get('business_type') or 'your business type'}." ), "summary": ( "๐Ÿ”„ Executive Summary Agent โ€” writing your personalized " f"financial summary for {business_profile.get('location') or 'your business'}." ), } msg = dynamic_msgs.get(agent_key) if msg: if agent_key not in completed_agents: completed_agents.add(agent_key) _step(msg) else: icon, name, action = AGENT_STATUS_MESSAGES.get(agent_key, ("โš™๏ธ", agent_key, "Running...")) if agent_key not in completed_agents: completed_agents.add(agent_key) _step(f"{icon} {name} โ€” {action}") report = generate_business_report( analysis_context, parsed_data_override=combined_parsed_data, analysis_basis=basis_notes, source_labels=source_labels, transaction_summary=connected_analysis, business_profile=business_profile, document_analysis=uploaded_analysis, data_sources=data_sources, progress_callback=_on_agent_progress, ) progress.progress(1.0) status.update(label="โœ… Analysis complete! Scroll down to chat with your results.", state="complete", expanded=False) st.session_state.analysis_report = report st.session_state.pop("pending_demo_analysis_run", None) _baseline = get_baseline(st.session_state) st.session_state["baseline"] = _baseline st.session_state["metrics"] = compute_metrics(_baseline) if _baseline else None st.session_state.messages = [ { "role": "assistant", "content": report["final_summary"], "type": "initial_analysis", "report_key": True, } ] st.session_state["scroll_to_results"] = True st.rerun() # โ”€โ”€ Conversational Chat Interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ report = st.session_state.get("analysis_report") messages: list[dict] = st.session_state.get("messages", []) scroll_requested = st.session_state.get("scroll_target") == "duka-ai-analyze-anchor" inject_ui_behavior( sample_cases=sample_cases, selected_example_prompt=st.session_state.get("selected_example_prompt", ""), selected_example_text=st.session_state.get("selected_example_prompt_text", ""), selected_numbers_mode=st.session_state.get("numbers_entry_mode", "upload"), scroll_requested=scroll_requested, ) if scroll_requested: st.session_state.scroll_target = None if not messages: st.markdown( '
    Your chat workspace opens here after the first analysis. Fill in the form above, run the analysis, then ask follow-up questions about cash flow, borrowing, or growth.
    ', unsafe_allow_html=True, ) return # โ”€โ”€ Auto-scroll to results after analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown('
    ', unsafe_allow_html=True) if st.session_state.pop("scroll_to_results", False): components.html( """ """, height=0, width=0, ) stage_col, reset_col = st.columns([4, 1], gap="medium") with stage_col: st.markdown( """
    Chat with your analysis
    Start with the summary below, tap a suggested question, or type your own follow-up when you're ready.
    """, unsafe_allow_html=True, ) with reset_col: if st.button("โ†บ Reset / Start New Analysis", key="reset_analysis", use_container_width=True, help="Clear this analysis and start fresh"): for _k in ["analysis_report", "baseline", "metrics"]: st.session_state[_k] = None st.session_state.messages = [] st.session_state.manual_notes = "" st.session_state.manual_revenue = 0.0 st.session_state.manual_expenses = 0.0 st.session_state.manual_debt = 0.0 st.session_state.manual_staff = 0 st.session_state.business_type = "" st.session_state.location = "" st.session_state.products_services = "" st.session_state.main_question = "" st.session_state.selected_example_prompt = "" st.session_state.selected_example_prompt_text = "" st.session_state.hide_example_prompts = False st.session_state.show_welcome = True st.rerun() st.markdown('
    ', unsafe_allow_html=True) # Render all chat messages for msg in messages: if msg["role"] == "user": # Right-aligned bubble โ€” no Streamlit chat component needed escaped = msg["content"].replace("&", "&").replace("<", "<").replace(">", ">") st.markdown( f'
    {escaped}
    ', unsafe_allow_html=True, ) else: agent_key = msg.get("agent", "") avatar_icon = AGENT_LABELS.get(agent_key, ("โœจ", ""))[0] if agent_key else "โœจ" with st.chat_message("assistant", avatar=avatar_icon): if msg.get("type") == "initial_analysis" and report: render_initial_analysis_in_chat(report) else: if agent_key and agent_key in AGENT_LABELS: icon, label = AGENT_LABELS[agent_key] st.markdown( f'
    {icon} {label}
    ', unsafe_allow_html=True, ) st.markdown(msg.get("content", ""), unsafe_allow_html=True) if msg.get("visual"): render_followup_visual(msg["visual"]) if msg.get("chart"): render_followup_chart(msg["chart"]) # โ”€โ”€ Suggestion pills โ€” above input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if report and len(messages) == 1: st.markdown('
    ', unsafe_allow_html=True) sug_cols = st.columns(3, gap="small") for sug_i, (suggestion_label, suggestion_prompt) in enumerate(FOLLOWUP_SUGGESTION_PILLS): with sug_cols[sug_i % 3]: if st.button(suggestion_label, key=f"sug_{sug_i}", use_container_width=True): agents_to_run = _resolve_agents(suggestion_prompt, messages) primary_agent = agents_to_run[0] st.session_state.messages.append({"role": "user", "content": suggestion_prompt}) with st.spinner(f"๐Ÿ’ฌ {suggestion_label} โ€ฆ"): result = answer_followup_question(suggestion_prompt, report, history=None) visual = build_followup_visual(suggestion_prompt, report) chart = build_chart_data(suggestion_prompt, report, st.session_state.messages) followup_msg: dict[str, Any] = { "role": "assistant", "content": _normalize_assistant_response(result["answer"]), "agent": primary_agent, } if visual: followup_msg["visual"] = visual if chart: followup_msg["chart"] = chart st.session_state.messages.append(followup_msg) st.session_state.last_agent = primary_agent st.session_state.scroll_to_chat_conversation = True st.rerun() # โ”€โ”€ Chat input with streaming โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if report: st.markdown('
    ', unsafe_allow_html=True) _injected = st.session_state.pop("pending_chat_question", None) if prompt := (_injected or st.chat_input("Ask about your business...")): agents_to_run = _resolve_agents(prompt, messages) primary_agent = agents_to_run[0] icon, label = AGENT_LABELS.get(primary_agent, ("๐Ÿ’ก", "Business Advisor")) # Right-aligned user bubble (consistent with message loop) escaped_prompt = prompt.replace("&", "&").replace("<", "<").replace(">", ">") st.markdown( f'
    {escaped_prompt}
    ', unsafe_allow_html=True, ) st.session_state.messages.append({"role": "user", "content": prompt}) # ChatGPT-style anchoring: pin the user's question at the top of # the viewport so the streaming response fills the screen below it. # Hammered instant jumps in the first ~1.3s overpower Streamlit's # scroll-restore on rerun. After that, the script BOWS OUT and lets # the user scroll freely โ€” never yanks them back up. components.html( """ """, height=0, width=0, ) with st.chat_message("assistant", avatar=icon): st.markdown( f'
    {icon} {label}
    ', unsafe_allow_html=True, ) if settings.llm_enabled: # ChatGPT-style: stream tokens into the UI with typewriter effect + auto-follow scroll stream_gen = stream_followup_for_agent( primary_agent, prompt, report, st.session_state.messages, ) try: full_response = st.write_stream(stream_gen, cursor="โ–Œ") except TypeError: # Older Streamlit versions don't support `cursor` kwarg full_response = st.write_stream(stream_gen) if isinstance(full_response, list): full_response = "".join(str(x) for x in full_response) else: full_response = str(full_response or "") else: result = answer_followup_question(prompt, report, history=None) full_response = result["answer"] st.markdown(full_response) visual = build_followup_visual(prompt, report) if visual: render_followup_visual(visual) chart = build_chart_data(prompt, report, st.session_state.messages) if chart: render_followup_chart(chart) followup_msg = { "role": "assistant", "content": _normalize_assistant_response(str(full_response)), "agent": primary_agent, } if visual: followup_msg["visual"] = visual if chart: followup_msg["chart"] = chart st.session_state.messages.append(followup_msg) st.session_state.last_agent = primary_agent st.session_state.scroll_to_chat_conversation = True # Anchor at bottom of chat thread (kept for compatibility with other flows # that may toggle scroll_to_chat_conversation). st.markdown('
    ', unsafe_allow_html=True) if st.session_state.pop("scroll_to_chat_conversation", False): # Pin the latest user question to the top of viewport (ChatGPT-style) # so the response below it is visible. Bows out as soon as the user # shows scroll intent. components.html( """ """, height=0, width=0, ) if __name__ == "__main__": main()