emmzy550
fix(market intel): readable section labels, summary, pills on dark agent card (light text + colored backgrounds)
32a962e | 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'<div class="next-step-callout"><strong>Next step:</strong>{safe_text}</div>' | |
| 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'<div class="provider-badge {size_class}" ' | |
| f'style="background:{branding["bg"]}; color:{branding["fg"]};">{branding["abbr"]}</div>' | |
| ) | |
| 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""" | |
| <div class="section-intro"> | |
| <div class="section-intro-title">{title}</div> | |
| <div class="section-intro-subtitle">{subtitle}</div> | |
| </div> | |
| """, | |
| 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 = '<div class="duka-dots"><span></span><span></span><span></span></div>' | |
| else: | |
| ind_html = '<div class="duka-ring"></div>' | |
| sub_html = f'<div class="duka-loader-sub">{subtitle}</div>' if subtitle else "" | |
| badge_html = f'<div class="duka-loader-badge">{badge}</div>' if badge else "" | |
| return ( | |
| f'<div class="duka-loader">' | |
| f'{ind_html}' | |
| f'<div class="duka-loader-body">' | |
| f'<div class="duka-loader-title">{title}</div>' | |
| f'{sub_html}' | |
| f'</div>' | |
| f'{badge_html}' | |
| f'</div>' | |
| ) | |
| 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'<div class="duka-skeleton duka-skeleton-line {w}"></div>' for w in classes) | |
| return f'<div style="padding:8px 0;">{lines}</div>' | |
| 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""" | |
| <div class="metric-card" style="border-top: 4px solid {accent};"> | |
| <div class="metric-row"> | |
| <span class="metric-icon">{icon}</span> | |
| <span class="metric-label">{label}</span> | |
| </div> | |
| <div class="metric-value">{value}</div> | |
| <div class="metric-note">{note}</div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="prompt-card"> | |
| <div class="prompt-title">{case["title"]}</div> | |
| <div class="prompt-preview">{preview}</div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="sample-card-featured"> | |
| <span class="recommended-badge">⭐ Recommended</span> | |
| <div class="card-icon" style="background:{meta['icon_bg']};">{meta['icon']}</div> | |
| <div style="font-weight:600;color:#E2E8F0;font-size:14px; | |
| margin-bottom:4px;">{case['title']}</div> | |
| <div style="color:#6B7280;font-size:12px;line-height:1.5;"> | |
| {meta['desc']}</div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="sample-card-normal"> | |
| <div class="card-icon" style="background:{meta['icon_bg']};">{meta['icon']}</div> | |
| <div style="font-weight:600;color:#E2E8F0;font-size:14px; | |
| margin-bottom:4px;">{case['title']}</div> | |
| <div style="color:#6B7280;font-size:12px;line-height:1.5;"> | |
| {meta['desc']}</div> | |
| </div> | |
| """, | |
| 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""" | |
| <script> | |
| const doc = window.parent.document; | |
| const exampleMap = {json.dumps(example_map)}; | |
| const suggestionLabels = {json.dumps(suggestion_labels)}; | |
| const numberLabels = {json.dumps(number_labels)}; | |
| const selectedExamplePrompt = {json.dumps(selected_example_prompt)}; | |
| const selectedExampleText = {json.dumps(selected_example_text or "Hover over a prompt to preview it here.")}; | |
| const selectedNumbersMode = {json.dumps(selected_numbers_mode)}; | |
| const scrollRequested = {json.dumps(scroll_requested)}; | |
| const findButton = (label) => Array.from(doc.querySelectorAll("button")).find( | |
| (button) => button.innerText.trim() === label | |
| ); | |
| const descriptionNode = doc.getElementById("duka-ai-example-description"); | |
| const setDescription = (text) => {{ | |
| if (descriptionNode) {{ | |
| descriptionNode.textContent = text; | |
| }} | |
| }}; | |
| Object.entries(exampleMap).forEach(([label, description]) => {{ | |
| const button = findButton(label); | |
| if (!button) return; | |
| button.classList.add("duka-ai-example-pill-btn"); | |
| if (label === selectedExamplePrompt) {{ | |
| button.classList.add("is-selected"); | |
| }} | |
| button.onmouseenter = () => setDescription(description); | |
| button.onfocus = () => setDescription(description); | |
| button.onmouseleave = () => setDescription(selectedExampleText); | |
| button.onblur = () => setDescription(selectedExampleText); | |
| }}); | |
| suggestionLabels.forEach((label) => {{ | |
| const button = findButton(label); | |
| if (button) {{ | |
| button.classList.add("duka-ai-suggestion-pill-btn"); | |
| }} | |
| }}); | |
| numberLabels.forEach((label) => {{ | |
| const button = findButton(label); | |
| if (!button) return; | |
| button.classList.add("duka-ai-number-pill-btn"); | |
| const mode = label.includes("Upload") ? "upload" : label.includes("Connect") ? "connect" : "manual"; | |
| if (mode === selectedNumbersMode) {{ | |
| button.classList.add("is-selected"); | |
| }} | |
| }}); | |
| const analyzeLabel = "⚡ Run Full Business Analysis"; | |
| let analyzeButton = findButton(analyzeLabel); | |
| if (!analyzeButton) {{ | |
| analyzeButton = findButton("⚡ Analyze My Business →"); | |
| }} | |
| if (analyzeButton) {{ | |
| analyzeButton.classList.add("duka-ai-analyze-btn"); | |
| }} | |
| if (scrollRequested) {{ | |
| window.setTimeout(() => {{ | |
| var anchorEl = doc.getElementById("duka-ai-analyze-anchor"); | |
| if (anchorEl) {{ | |
| anchorEl.scrollIntoView({{ behavior: "smooth", block: "start" }}); | |
| }} else if (analyzeButton) {{ | |
| analyzeButton.scrollIntoView({{ behavior: "smooth", block: "center" }}); | |
| }} | |
| }}, 260); | |
| }} | |
| </script> | |
| """, | |
| 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""" | |
| <div class="provider-dropdown-preview" style="--provider:{chosen_bg}; border-left-color:{chosen_bg};"> | |
| <div class="provider-connected-mark" style="background:{chosen_bg}; color:{chosen_fg};">{chosen_abbr}</div> | |
| <div> | |
| <div class="provider-connected-title">{chosen_provider}</div> | |
| <div class="provider-connected-copy">{chosen_details.get("kind", "Financial source")} · {chosen_details.get("signal", "Transaction patterns and cash flow signals")}</div> | |
| </div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="provider-connected-summary" style="border-left-color:{bg_color};"> | |
| <div class="provider-connected-mark" style="background:{bg_color}; color:{fg_color};">{abbr}</div> | |
| <div> | |
| <div class="provider-connected-title">{provider} connected</div> | |
| <div class="provider-connected-copy">47 simulated transactions loaded. {form_hint}.</div> | |
| </div> | |
| </div> | |
| """, | |
| 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'<div class="preview-warning">{w}</div>' 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""" | |
| <div class="preview-card"> | |
| <div class="preview-title">AI Document Analysis</div> | |
| <div class="preview-grid"> | |
| <div><span>Document Type</span><strong>{document_data.get("document_type", "Other")}</strong></div> | |
| <div><span>File Name</span><strong>{document_data.get("file_name", "Uploaded file")}</strong></div> | |
| <div><span>Period</span><strong>{period}</strong></div> | |
| <div><span>{volume_label} Analyzed</span><strong>{volume}</strong></div> | |
| <div><span>Revenue</span><strong>{format_currency(document_data.get("revenue", 0.0))}</strong></div> | |
| <div><span>Expenses</span><strong>{format_currency(document_data.get("expenses", 0.0))}</strong></div> | |
| <div><span>Profit</span><strong>{format_currency(document_data.get("profit", 0.0))}</strong></div> | |
| <div><span>Profit Margin</span><strong>{document_data.get("profit_margin", 0.0):.1f}%</strong></div> | |
| <div><span>Debt</span><strong>{format_currency(document_data.get("debt", 0.0))}</strong></div> | |
| </div> | |
| <div class="preview-summary">{document_data.get("summary", "")}</div> | |
| {warnings_html} | |
| </div> | |
| """, | |
| 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( | |
| '<div class="preview-title" style="margin-top:1rem;color:#0F172A;">Monthly Breakdown</div>', | |
| 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'<div class="agent-detail">{obs}</div>' for obs in observations[:4]) | |
| st.markdown( | |
| f'<div class="preview-card" style="margin-top:0.5rem;">' | |
| f'<div class="preview-title">Key Observations</div>{obs_html}</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| def render_connected_preview(connected_data: dict[str, Any]) -> None: | |
| st.markdown( | |
| f""" | |
| <div class="preview-card"> | |
| <div class="preview-title">Connected Financial Data Demo</div> | |
| <div class="provider-selected"> | |
| {get_provider_badge_html(connected_data["provider_selected"], large=True)} | |
| <div> | |
| <div class="provider-selected-label">Selected provider</div> | |
| <div class="provider-selected-name">{connected_data["provider_selected"]}</div> | |
| </div> | |
| </div> | |
| <div class="preview-summary"> | |
| Demo connection enabled. Duka AI is using sample transaction data to simulate secure financial analysis. | |
| </div> | |
| <div class="preview-grid"> | |
| <div><span>Transactions</span><strong>{connected_data["rows_analyzed"]}</strong></div> | |
| <div><span>Total Revenue</span><strong>{format_currency(connected_data["total_revenue"])}</strong></div> | |
| <div><span>Total Expenses</span><strong>{format_currency(connected_data["total_expenses"])}</strong></div> | |
| <div><span>Net Cash Flow</span><strong>{format_currency(connected_data["net_cash_flow"])}</strong></div> | |
| <div><span>Date Range</span><strong>{connected_data["date_range"]}</strong></div> | |
| <div><span>Biggest Expense</span><strong>{connected_data["biggest_expense_category"]}</strong></div> | |
| <div><span>Average Daily Sales</span><strong>{format_currency(connected_data["average_daily_sales"])}</strong></div> | |
| <div><span>Stability Score</span><strong>{connected_data["transaction_stability_score"]}/100</strong></div> | |
| </div> | |
| <div class="preview-warning">Your data is used only for analysis in this demo.</div> | |
| <div class="preview-warning">For the hackathon MVP, this is simulated data. Real integrations would require secure user consent, encryption, and provider approval.</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_data_sources_card(report: dict[str, Any]) -> None: | |
| chips = "".join( | |
| f'<div class="source-line"><span class="source-check">+</span><span>{source}</span></div>' | |
| for source in report.get("data_sources", []) | |
| ) | |
| agent_statuses = "".join( | |
| f'<div class="source-line"><span class="source-check">{("✓" if used_llm else "F")}</span>' | |
| f'<span>{name}: {("⚡ Live AI" if used_llm else "Standard mode")}</span></div>' | |
| for name, used_llm in report.get("agent_statuses", {}).items() | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="summary-panel"> | |
| <div> | |
| <div class="summary-kicker">Data Sources Used</div> | |
| <div class="summary-headline">Duka AI combined your business context, records, transaction patterns, and market context to produce this report.</div> | |
| </div> | |
| <div class="source-list">{chips}</div> | |
| </div> | |
| <div class="summary-panel" style="margin-top:-0.35rem;"> | |
| <div> | |
| <div class="summary-kicker">Agent Execution Status</div> | |
| <div class="summary-headline">Which specialist agents contributed to this analysis.</div> | |
| </div> | |
| <div class="source-list">{agent_statuses}</div> | |
| </div> | |
| """, | |
| 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'<div class="source-chip">{label}</div>' for label in report.get("source_labels", [])) | |
| st.markdown( | |
| f""" | |
| <div class="summary-panel"> | |
| <div class="summary-copy"> | |
| <div class="summary-kicker">Business Financial Health Summary</div> | |
| <div class="summary-headline">{cashflow["summary"]}</div> | |
| <div class="source-chip-row">{source_html}</div> | |
| </div> | |
| <div class="summary-statuses"> | |
| <div class="status-card"> | |
| <span>Business Health</span> | |
| <strong>{business_health["status"]}</strong> | |
| </div> | |
| <div class="status-card"> | |
| <span>Cash Flow</span> | |
| <strong>{cashflow["cash_flow_status"]}</strong> | |
| </div> | |
| <div class="status-card" style="border-color:{band_color};"> | |
| <span>Borrowing View</span> | |
| <strong style="color:{band_color};">{band_label}</strong> | |
| </div> | |
| </div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="loan-card"> | |
| <div class="card-title">Business Health Score</div> | |
| <div class="loan-score-row"> | |
| <div class="loan-score">{health["score"]}/100</div> | |
| <div class="loan-badge" style="color:{status_color}; border-color:{status_color};">{health["status"]}</div> | |
| </div> | |
| <div class="progress-shell"> | |
| <div class="progress-fill" style="width:{health["score"]}%; background:{status_color};"></div> | |
| </div> | |
| <div class="loan-explainer">Business health is an overall financial fitness score — profitability, margin strength, and expense control. This is <em>separate</em> from the Loan Readiness score below, which specifically measures borrowing safety.</div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="loan-card"> | |
| <div class="card-title">Loan Readiness Score — Borrowing & Debt Review</div> | |
| <div class="loan-score-row"> | |
| <div class="loan-score">{loan["loan_readiness_score"]}/100</div> | |
| <div class="loan-badge" style="color:{color}; border-color:{color};">{label}</div> | |
| </div> | |
| <div class="progress-shell"> | |
| <div class="progress-fill" style="width:{loan["loan_readiness_score"]}%; background:{color};"></div> | |
| </div> | |
| <div class="loan-explainer">{explanation}</div> | |
| <div class="loan-meta">Safe borrowing amount: <strong>{format_currency(loan["suggested_loan_amount"])}</strong> · calculated as {loan.get("loan_multiplier", 1.5)}× your monthly profit — a conservative SME lending rule of thumb</div> | |
| <div class="loan-risk-note"><strong>Risk warning:</strong> {risk_warning}</div> | |
| </div> | |
| """, | |
| 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}<br>%{text}<extra></extra>", | |
| ) | |
| ] | |
| ) | |
| 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""" | |
| <div class="risk-card"> | |
| <div class="risk-icon">!</div> | |
| <div class="risk-text">{text}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_action_card(index: int, action: str) -> None: | |
| st.markdown( | |
| f""" | |
| <div class="action-card"> | |
| <div class="action-number">{index}</div> | |
| <div class="action-text">{action}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_financial_advice_section(report: dict[str, Any]) -> None: | |
| advisor = report["advisor"] | |
| st.markdown( | |
| f""" | |
| <div class="agent-card"> | |
| <div class="card-title">Financial Advice</div> | |
| <div class="market-subhead">What this means</div> | |
| <div class="agent-detail">{advisor["what_this_means"]}</div> | |
| <div class="market-subhead">What you are doing well</div> | |
| <div class="agent-detail">{advisor["doing_well"]}</div> | |
| <div class="market-subhead">What needs attention</div> | |
| <div class="agent-detail">{advisor["needs_attention"]}</div> | |
| <div class="market-subhead">Best next move</div> | |
| <div class="agent-detail">{advisor["best_next_move"]}</div> | |
| <div class="market-subhead">This week's action</div> | |
| <div class="agent-detail">{advisor["this_weeks_action"]}</div> | |
| <div class="market-subhead">Growth recommendation</div> | |
| <div class="agent-detail">{advisor["growth_recommendation"]}</div> | |
| </div> | |
| """, | |
| 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( | |
| """ | |
| <div class="agent-card"> | |
| <div class="card-title">Cash Flow & Profit Analysis</div> | |
| </div> | |
| """, | |
| 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'<div class="agent-detail">{detail}</div>', 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'<span style="font-size:11px;color:#6EE7B7;background:rgba(16,185,129,0.12);' | |
| f'padding:2px 8px;border-radius:999px;">🌐 Live web search · {len(sources)} sources</span>' | |
| if web_used else | |
| '<span style="font-size:11px;color:#94A3B8;background:rgba(148,163,184,0.1);' | |
| 'padding:2px 8px;border-radius:999px;">📚 AI knowledge base</span>' | |
| ) | |
| risk_cards = "".join(f'<div class="market-pill">{r}</div>' for r in market.get("risk_factors", [])) | |
| monitor_cards = "".join(f'<div class="market-pill market-pill-blue">{m}</div>' for m in market.get("monitor_next", [])) | |
| st.markdown( | |
| f""" | |
| <div class="agent-card"> | |
| <div class="card-title" style="display:flex;justify-content:space-between;align-items:center;"> | |
| <span>Market Intelligence</span>{source_badge} | |
| </div> | |
| <div class="agent-summary">{market.get("market_summary", "")}</div> | |
| <div class="market-subhead">Key market risks</div> | |
| <div class="market-pill-row">{risk_cards}</div> | |
| <div class="market-subhead">Opportunity</div> | |
| <div class="agent-detail">{market.get("opportunity", "")}</div> | |
| <div class="market-subhead">Recommended move</div> | |
| <div class="agent-detail">{market.get("recommendation", "")}</div> | |
| <div class="market-subhead">What to monitor</div> | |
| <div class="market-pill-row">{monitor_cards}</div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="mini-card"> | |
| <div class="card-title">Transaction Analysis</div> | |
| <div class="agent-detail">Average daily sales: {format_currency(transaction_summary["average_daily_sales"])}</div> | |
| <div class="agent-detail">Biggest expense category: {transaction_summary["biggest_expense_category"]}</div> | |
| <div class="agent-detail">Recurring expense patterns: {", ".join(transaction_summary["recurring_expense_patterns"]) or "None detected"}</div> | |
| <div class="agent-detail">Cash flow risk level: {transaction_summary["cash_flow_risk"]}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_followup_visual(visual: dict[str, Any]) -> None: | |
| rows = "".join( | |
| f'<div class="visual-row"><span>{item["label"]}</span><strong>{item["value"]}</strong></div>' | |
| for item in visual.get("items", []) | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="visual-card"> | |
| <div class="card-title">{visual["title"]}</div> | |
| {rows} | |
| </div> | |
| """, | |
| 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}<extra></extra>", | |
| )) | |
| 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}<extra></extra>", | |
| )) | |
| 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}}<extra>{series['name']}</extra>", | |
| )) | |
| 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""" | |
| <div class="analysis-hero"> | |
| <div class="analysis-kicker">Analysis complete</div> | |
| <div class="analysis-headline">{html.escape(headline)}</div> | |
| <div class="analysis-support">{html.escape(support or advisor.get("needs_attention", ""))}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('<div class="chat-block-gap"></div>', unsafe_allow_html=True) | |
| render_chat_metrics_bar(cashflow) | |
| st.markdown('<div class="chat-block-gap"></div>', unsafe_allow_html=True) | |
| needs = advisor.get("needs_attention", "").rstrip(".") | |
| move = advisor.get("best_next_move", "").rstrip(".") | |
| if needs and move: | |
| st.markdown( | |
| f""" | |
| <div class="analysis-guidance"> | |
| <strong>What to focus on now:</strong> {html.escape(needs)}. | |
| <br /> | |
| <strong>Best next move:</strong> {html.escape(move)}. | |
| </div> | |
| """, | |
| 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("<div style='height:0.5rem;'></div>", 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("<div style='height:0.8rem;'></div>", unsafe_allow_html=True) | |
| if report.get("transaction_summary"): | |
| render_transaction_card(report["transaction_summary"]) | |
| st.markdown("<div style='height:0.8rem;'></div>", unsafe_allow_html=True) | |
| st.markdown( | |
| '<div class="mini-card"><div class="card-title">All Recommended Actions</div></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| for i, action in enumerate(report["final_recommended_actions"], start=1): | |
| render_action_card(i, action) | |
| st.markdown("<div style='height:0.85rem;'></div>", 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'<div class="agent-detail">{detail}</div>', 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'<div class="chat-invite">' | |
| f"I've analyzed {doc_desc}. " | |
| f"What would you like to explore first — your expenses, loan readiness, or growth opportunities?" | |
| f"</div>", | |
| 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( | |
| """ | |
| <div class="welcome-hero"> | |
| <div class="welcome-brand">Duka <span style="color:#1D9E75;">AI</span></div> | |
| <div class="welcome-tagline">Smart finance for every small business</div> | |
| <div class="welcome-desc"> | |
| Understand your cash flow, loan readiness, and growth opportunities — in seconds. | |
| Built for African small businesses. | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('<div style="height:1.25rem;"></div>', unsafe_allow_html=True) | |
| col1, col2, col3 = st.columns(3, gap="medium") | |
| with col1: | |
| st.markdown( | |
| """ | |
| <div class="welcome-option-card"> | |
| <div class="woc-badge">⭐ Recommended</div> | |
| <div class="woc-icon">⚡</div> | |
| <div class="woc-title">Try Demo Analysis</div> | |
| <div class="woc-desc"> | |
| One click loads a real sample <strong>income statement</strong>, pre-fills the | |
| business profile, and runs the full multi-agent analysis automatically — perfect | |
| for first-time visitors. | |
| </div> | |
| </div> | |
| """, | |
| 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( | |
| """ | |
| <div class="welcome-option-card"> | |
| <div class="woc-icon">📄</div> | |
| <div class="woc-title">Upload Documents</div> | |
| <div class="woc-desc"> | |
| Upload a CSV, Excel, or PDF — income statement, bank statement, or | |
| sales record for AI extraction. | |
| </div> | |
| </div> | |
| """, | |
| 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( | |
| """ | |
| <div class="welcome-option-card"> | |
| <div class="woc-icon">✏️</div> | |
| <div class="woc-title">Start from Scratch</div> | |
| <div class="woc-desc"> | |
| Describe your business and enter figures manually — no documents needed, | |
| just what you know. | |
| </div> | |
| </div> | |
| """, | |
| 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( | |
| """ | |
| <div class="sidebar-brand"> | |
| <div class="sidebar-brand-row"> | |
| <div class="sidebar-brand-mark" aria-hidden="true">🌍</div> | |
| <div class="sidebar-brand-copy"> | |
| <div class="sidebar-brand-title">Duka AI</div> | |
| <div class="sidebar-brand-subtitle">SME finance workspace</div> | |
| </div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| for section, items in NAV_SECTIONS.items(): | |
| st.markdown(f'<span class="sidebar-section-label">{section.title()}</span>', 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( | |
| '<div class="sidebar-footer">Start in Chat Advisor, then use tools and reports from this sidebar.</div>', | |
| 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( | |
| """ | |
| <div class="empty-state"> | |
| <div class="empty-state-kicker">Analysis required</div> | |
| <div class="empty-state-title">Run Chat Advisor first</div> | |
| <div class="empty-state-copy">This tool needs your latest revenue, expenses, profit, and business context before it can calculate anything useful.</div> | |
| </div> | |
| """, | |
| 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'<div class="page-badge">{html.escape(badge)}</div>' if badge else "" | |
| st.markdown( | |
| f""" | |
| <div class="page-header"> | |
| <div> | |
| <div class="page-title">{html.escape(title)}</div> | |
| <div class="page-subtitle">{html.escape(subtitle)}</div> | |
| </div> | |
| {badge_html} | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def render_tool_tip(title: str, copy: str) -> None: | |
| st.markdown( | |
| f""" | |
| <div class="tool-tip"> | |
| <strong>{html.escape(title)}</strong> | |
| <span>{html.escape(copy)}</span> | |
| </div> | |
| """, | |
| 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('<div class="mini-card"><div class="card-title">All Recommended Actions</div></div>', 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 <CHART> 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 <CHART> 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 <CHART> block using this exact format. " | |
| "NEVER reply with only the JSON — there must always be readable prose first.\n" | |
| "<CHART>\n" | |
| "{\n" | |
| ' "type": "line",\n' | |
| ' "title": "...",\n' | |
| ' "data": {\n' | |
| ' "labels": [...],\n' | |
| ' "datasets": [{"name": "...", "values": [...], "color": "..."}]\n' | |
| ' }\n' | |
| "}\n" | |
| "</CHART>\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 <CHART> 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"<CHART>.*?</CHART>", result, flags=re.DOTALL | re.IGNORECASE) | |
| if m_chart: | |
| chart_tag = m_chart.group(0).strip() | |
| prose = re.sub(r"<CHART>.*?</CHART>", "", 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 <CHART> 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 <CHART>…</CHART> 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"<CHART>(.*?)</CHART>" | |
| 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}}%<extra>{name}</extra>" | |
| if is_pct_series | |
| else f"%{{x}}: K%{{y:,.0f}}<extra>{name}</extra>" | |
| ) | |
| 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}}%<extra>{name}</extra>" | |
| if is_margin | |
| else f"%{{x}}: K%{{y:,.0f}}<extra>{name}</extra>" | |
| ) | |
| 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( | |
| '<div id="duka-ai-forecast-page-marker" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden"></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| if not baseline: | |
| st.markdown( | |
| f'<div style="background:rgba(245,158,11,0.1);border-left:3px solid #F59E0B;' | |
| f'padding:12px 16px;border-radius:6px;color:#FCD34D;margin-bottom:16px;">' | |
| f'<strong>⚠ No financial data found.</strong><br>' | |
| f'Go to Chat Advisor and run an analysis first, or enter your numbers in the Business Context form.' | |
| f'</div>', | |
| 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'<div style="background:rgba(37,99,235,0.08);border:1px solid rgba(37,99,235,0.2);' | |
| f'border-radius:8px;padding:10px 16px;font-size:13px;color:#93C5FD;margin-bottom:16px;">' | |
| f'<strong>📊 Forecast based on:</strong> {baseline["source"]}' | |
| f' · Starting monthly revenue: <strong>K{baseline["monthly_revenue"]:,.2f}</strong>' | |
| f' · Expenses: <strong>K{baseline["monthly_expenses"]:,.2f}</strong>' | |
| f'{months_note}</div>', | |
| 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('<div class="duka-forecast-dashboard">', 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="<b>6-month outlook</b> — 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'<div style="background:rgba(239,68,68,0.1);border-left:3px solid {THEME["red"]};' | |
| f'padding:10px 14px;border-radius:6px;font-size:13px;color:#FCA5A5;margin:4px 0 8px;">' | |
| f'⚠️ Expenses may exceed revenue from <strong>{s["first_loss_month"]}</strong> under this scenario.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.markdown( | |
| f'<div style="background:rgba(16,185,129,0.08);border-left:3px solid {THEME["emerald"]};' | |
| f'padding:10px 14px;border-radius:6px;font-size:13px;color:#6EE7B7;margin:4px 0 8px;">' | |
| f'✅ All 6 months profitable. Month 6 margin: <strong>{s["final_margin"]:.1f}%</strong>.</div>', | |
| 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' <span style="color:{THEME["emerald"]}">↑K{diff:,.0f}</span>' | |
| if diff >= 0 | |
| else f' <span style="color:{THEME["red"]}">↓K{abs(diff):,.0f}</span>' | |
| ) | |
| else: | |
| trend_html = "" | |
| warn = " ⚠️" if not r["profitable"] else "" | |
| return ( | |
| f'<tr style="border-bottom:1px solid rgba(148,163,184,0.1);">' | |
| f'<td style="padding:8px 12px;font-weight:600;">{r["month_name"]}</td>' | |
| f'<td style="padding:8px 12px;color:{THEME["emerald"]};">K{r["revenue"]:,.0f}</td>' | |
| f'<td style="padding:8px 12px;color:{THEME["orange"]};">K{r["expenses"]:,.0f}</td>' | |
| f'<td style="padding:8px 12px;color:{pc};font-weight:600;">K{r["profit"]:,.0f}{warn}{trend_html}</td>' | |
| f'<td style="padding:8px 12px;color:{pc};">{r["margin"]:.1f}%</td>' | |
| f'</tr>' | |
| ) | |
| table_html = "".join( | |
| _tr(r, rows[i-1]["profit"] if i > 0 else None) | |
| for i, r in enumerate(rows) | |
| ) | |
| st.markdown( | |
| '<table style="width:100%;border-collapse:collapse;font-size:13px;color:#E2E8F0;">' | |
| '<thead><tr style="border-bottom:1px solid rgba(148,163,184,0.25);color:#94A3B8;' | |
| 'font-size:11px;text-transform:uppercase;letter-spacing:.06em;">' | |
| '<th style="padding:8px 12px;text-align:left;">Month</th>' | |
| '<th style="padding:8px 12px;text-align:left;">Revenue</th>' | |
| '<th style="padding:8px 12px;text-align:left;">Expenses</th>' | |
| '<th style="padding:8px 12px;text-align:left;">Profit</th>' | |
| '<th style="padding:8px 12px;text-align:left;">Margin</th>' | |
| f'</tr></thead><tbody>{table_html}</tbody></table>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ── AI analyst summary (right side of dashboard) ─────────────────────────── | |
| with col_ai: | |
| st.markdown( | |
| '<div class="duka-forecast-analyst">' | |
| '<div style="color:#94A3B8;font-size:0.72rem;font-weight:800;letter-spacing:0.12em;text-transform:uppercase;margin-bottom:0.35rem;">' | |
| "AI analyst</div>" | |
| '<div style="color:#F8FAFC;font-size:1.05rem;font-weight:700;line-height:1.25;">Forecast snapshot</div>' | |
| '<div style="color:#94A3B8;font-size:0.82rem;margin-top:0.35rem;line-height:1.45;">' | |
| "A plain-English read of the chart on the left.</div>" | |
| "</div>", | |
| 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( | |
| '<div class="duka-forecast-narrative-card">' + html.escape( | |
| st.session_state[cache_key] | |
| ).replace("\n", "<br>") | |
| + "</div>", | |
| 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("</div>", unsafe_allow_html=True) | |
| # ── Conversation (centered, full-width row) ──────────────────────────────── | |
| if "forecast_chat" not in st.session_state: | |
| st.session_state["forecast_chat"] = [] | |
| st.markdown( | |
| '<div id="duka-ai-forecast-analysis-anchor"></div>', | |
| 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( | |
| '<div class="duka-forecast-convo-card">' | |
| '<div class="duka-forecast-convo-head">' | |
| '<span class="duka-forecast-convo-title">💬 Chat with the analyst</span>' | |
| '<span class="duka-forecast-convo-sub">Ask anything — answers use the numbers from this scenario.</span>' | |
| "</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| if not st.session_state["forecast_chat"]: | |
| st.markdown( | |
| '<div class="duka-forecast-empty">No questions yet — try a quick prompt above ' | |
| 'or type below.</div>', | |
| 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'<div class="user-bubble-wrap"><div class="user-bubble">{escaped}</div></div>', | |
| 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("</div>", 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( | |
| '<div class="empty-state">' | |
| '<div class="empty-state-kicker">Analysis required</div>' | |
| '<div class="empty-state-title">Run Chat Advisor first</div>' | |
| '<div class="empty-state-copy">The Scenario Planner needs your actual revenue and expenses before it can calculate anything useful.</div>' | |
| '</div>', | |
| 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'<div style="background:rgba(37,99,235,0.08);border:1px solid rgba(37,99,235,0.2);' | |
| f'border-radius:8px;padding:10px 16px;font-size:13px;color:#93C5FD;margin-bottom:16px;">' | |
| f'<strong>📊 Current baseline:</strong> {baseline["source"]}' | |
| f' · Revenue <strong>K{baseline["monthly_revenue"]:,.2f}</strong>' | |
| f' · Expenses <strong>K{baseline["monthly_expenses"]:,.2f}</strong>' | |
| f' · Profit <strong>K{baseline["monthly_profit"]:,.2f}</strong>' | |
| f' · Margin <strong>{baseline["profit_margin"]:.1f}%</strong>' | |
| f'</div>', | |
| 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( | |
| '<div id="duka-ai-expense-analyzer-page-marker" aria-hidden="true" ' | |
| 'style="position:absolute;width:0;height:0;overflow:hidden"></div>', | |
| 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( | |
| '<div id="duka-ai-expense-analyzer-chat-anchor"></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| '<div class="duka-forecast-convo-card">' | |
| '<div class="duka-forecast-convo-head">' | |
| '<span class="duka-forecast-convo-title">🔍 Ask the Expense Analyst</span>' | |
| '<span class="duka-forecast-convo-sub">' | |
| "Grounded in your verified categories — no invented numbers." | |
| "</span>" | |
| "</div>", | |
| 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("<div style='height:8px;'></div>", 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( | |
| '<div class="duka-forecast-empty">No messages yet — tap a suggestion ' | |
| "or type below.</div>", | |
| 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'<div class="user-bubble-wrap"><div class="user-bubble">{escaped}</div></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| with st.chat_message("assistant", avatar="🔍"): | |
| st.markdown(msg["content"]) | |
| st.markdown("</div>", 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", "<br/>") | |
| 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"<i>{_pdf_escape(_ins)}</i>", 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("<b>How to improve</b>", 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"<b>Opportunity:</b> {_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"<b>!</b> {_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("<div style='height:12px;'></div>", 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( | |
| '<div id="duka-ai-report-page-marker" aria-hidden="true"></div>' | |
| '<div class="duka-report-premium">', | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Hero + KPI ribbon ──────────────────────────────────────────────────── | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-hero"> | |
| <div class="duka-report-hero-kicker">Financial Health Report</div> | |
| <div class="duka-report-hero-title">{_rn}</div> | |
| <div class="duka-report-hero-meta">{_rloc} · Generated {_rat}</div> | |
| <span class="duka-report-hero-badge">Verified numbers · AI narrative</span> | |
| </div> | |
| <div class="duka-report-kpi-row"> | |
| <div class="duka-report-kpi"> | |
| <div class="duka-report-kpi-lab">Monthly revenue</div> | |
| <div class="duka-report-kpi-val">K{snap.get("revenue", 0):,.0f}</div> | |
| </div> | |
| <div class="duka-report-kpi"> | |
| <div class="duka-report-kpi-lab">Monthly expenses</div> | |
| <div class="duka-report-kpi-val">K{snap.get("expenses", 0):,.0f}</div> | |
| </div> | |
| <div class="duka-report-kpi"> | |
| <div class="duka-report-kpi-lab">Net profit</div> | |
| <div class="duka-report-kpi-val {_kpi_pc}">K{abs(_snap_pr):,.0f}</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-exec"> | |
| <div class="duka-report-section-h">Executive summary</div> | |
| <div class="duka-report-exec-body">{_report_text_escape(full.get("executive_summary", "—"))}</div> | |
| </div> | |
| """, | |
| 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""" | |
| <div class="duka-report-card" style="height:100%;"> | |
| <div class="duka-report-section-h">Financial snapshot</div> | |
| <table style="width:100%;border-collapse:collapse;font-size:13px;color:#E2E8F0;"> | |
| <tr><td style="padding:7px 0;color:#94A3B8;">Revenue</td> | |
| <td style="text-align:right;font-weight:700;">K{snap.get("revenue",0):,.0f}</td></tr> | |
| <tr><td style="padding:7px 0;color:#94A3B8;">Expenses</td> | |
| <td style="text-align:right;font-weight:700;color:#F87171;"> | |
| K{snap.get("expenses",0):,.0f} | |
| <span style="font-size:11px;color:#94A3B8;"> ({snap.get("expense_pct",0):.0f}% of rev.)</span> | |
| </td></tr> | |
| <tr><td style="padding:7px 0;color:#94A3B8;">Profit</td> | |
| <td style="text-align:right;font-weight:700;color:{_pr_col};"> | |
| K{_pr_disp:,.0f} | |
| <span style="font-size:11px;color:#94A3B8;"> ({snap.get("margin_pct",0):.1f}% margin)</span> | |
| </td></tr> | |
| <tr><td colspan="2" style="border-top:1px solid rgba(148,163,184,0.12);padding-top:10px;"></td></tr> | |
| <tr><td style="padding:7px 0;color:#94A3B8;">Health score</td> | |
| <td style="text-align:right;font-weight:800;color:{h_color};"> | |
| {snap.get("health_score",0)}/100 — {_hl} | |
| </td></tr> | |
| <tr><td style="padding:7px 0;color:#94A3B8;">Cash flow</td> | |
| <td style="text-align:right;font-weight:600;">{_cfs}</td></tr> | |
| <tr><td style="padding:7px 0;color:#94A3B8;">Data source</td> | |
| <td style="text-align:right;font-size:12px;color:#CBD5E1;">{_src}</td></tr> | |
| </table> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| with col_exp: | |
| cats = full.get("expense_categories", []) | |
| if cats: | |
| rows_html = "".join( | |
| f"""<tr> | |
| <td style="padding:6px 0;color:#CBD5E1;">{i + 1}. {_report_text_escape(c.get("name", ""))}</td> | |
| <td style="text-align:right;color:#F87171;font-weight:600;">K{c.get("amount", 0):,.0f}</td> | |
| <td style="text-align:right;color:#94A3B8;font-size:11px;">{c.get("pct_of_expenses", 0):.1f}%</td> | |
| </tr>""" | |
| for i, c in enumerate(cats[:6]) | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-card" style="height:100%;"> | |
| <div class="duka-report-section-h">Expense breakdown</div> | |
| <table style="width:100%;border-collapse:collapse;font-size:13px;color:#E2E8F0;"> | |
| {rows_html} | |
| </table> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.markdown( | |
| '<div class="duka-report-card" style="color:#94A3B8;font-size:13px;line-height:1.55;">' | |
| "No expense category breakdown available for this analysis." | |
| "</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<div style='height:16px;'></div>", 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"<div style='margin-top:12px;font-size:12px;color:#94A3B8;line-height:1.55;font-style:italic;border-top:1px solid rgba(148,163,184,0.12);padding-top:10px;'>{_report_text_escape(_cf_ins)}</div>" | |
| if _cf_ins | |
| else "" | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-card"> | |
| <div class="duka-report-section-h">Cash flow analysis</div> | |
| <div style="font-size:14px;color:#E2E8F0;line-height:1.68;font-weight:450;"> | |
| {_cf_sum} | |
| </div> | |
| {_cf_ins_html} | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| with col_loan: | |
| improve_html = "".join( | |
| f'<li style="color:#CBD5E1;font-size:12px;margin-bottom:5px;">{_report_text_escape(item)}</li>' | |
| 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""" | |
| <div class="duka-report-card"> | |
| <div class="duka-report-section-h">Loan readiness</div> | |
| <div style="font-size:22px;font-weight:850;color:{l_color};margin-bottom:6px;letter-spacing:-0.02em;"> | |
| {loan_d.get("score", 0)}/100 — {_lst} | |
| </div> | |
| <div style="font-size:13px;color:#E2E8F0;margin-bottom:8px;line-height:1.55;"> | |
| Safe borrowing: <strong>K{loan_d.get("safe_amount", 0):,.0f}</strong> | |
| <span style="font-size:11px;color:#94A3B8;"> · {_lfr}</span> | |
| </div> | |
| {"<ul style='padding-left:18px;margin-top:8px;list-style:disc;'>" + improve_html + "</ul>" if improve_html else ""} | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<div style='height:18px;'></div>", 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""" | |
| <div class="duka-report-card" style="margin-bottom:18px;"> | |
| <div class="duka-report-section-h">6-month outlook (base case)</div> | |
| <div class="duka-report-fc-grid"> | |
| <div class="duka-report-fc-stat"> | |
| <div class="duka-report-fc-lab">Month 6 profit</div> | |
| <div class="duka-report-fc-val">K{fc6.get("base_profit_m6", 0):,.0f}</div> | |
| </div> | |
| <div class="duka-report-fc-stat"> | |
| <div class="duka-report-fc-lab">Total 6-mo profit</div> | |
| <div class="duka-report-fc-val">K{fc6.get("total_profit", 0):,.0f}</div> | |
| </div> | |
| <div class="duka-report-fc-stat"> | |
| <div class="duka-report-fc-lab">Profitable months</div> | |
| <div class="duka-report-fc-val">{fc6.get("profitable_months", 0)}/6</div> | |
| </div> | |
| <div class="duka-report-fc-stat"> | |
| <div class="duka-report-fc-lab">Outlook</div> | |
| <div class="duka-report-fc-val" style="font-size:0.95rem;line-height:1.35;color:#CBD5E1;"> | |
| {_report_text_escape(_fc_out)} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<div style='height:8px;'></div>", unsafe_allow_html=True) | |
| # ── Market Conditions ──────────────────────────────────────────────────── | |
| mkt = full.get("market", {}) | |
| if mkt.get("summary"): | |
| monitor_html = "".join( | |
| f'<span style="background:rgba(37,99,235,0.18);border:1px solid rgba(37,99,235,0.35);' | |
| f'border-radius:999px;padding:3px 11px;font-size:11px;color:#93C5FD;margin:4px 6px 0 0;display:inline-block;">' | |
| f'{_report_text_escape(m)}</span>' | |
| for m in mkt.get("monitor", []) | |
| ) | |
| _opp = mkt.get("opportunity", "") | |
| _opp_blk = ( | |
| f"<div style='font-size:13px;color:#6EE7B7;margin-top:10px;line-height:1.55;'><strong>Opportunity:</strong> {_report_text_escape(_opp)}</div>" | |
| if _opp | |
| else "" | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-card" style="margin-bottom:18px;"> | |
| <div class="duka-report-section-h">Market conditions</div> | |
| <div style="font-size:14px;color:#E2E8F0;line-height:1.68;margin-bottom:6px;"> | |
| {_report_text_escape(mkt.get("summary", "—"))} | |
| </div> | |
| {_opp_blk} | |
| {("<div style='margin-top:12px;'>" + monitor_html + "</div>") if monitor_html else ""} | |
| </div> | |
| """, | |
| 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""" | |
| <div class="duka-report-rec-item"> | |
| <div class="duka-report-rec-num">{i + 1}</div> | |
| <div style="flex:1;min-width:0;"> | |
| <div class="duka-report-rec-action">{_act}</div> | |
| <div class="duka-report-rec-meta"> | |
| <span class="duka-report-rec-impact">→ {_imp}</span> | |
| <span class="duka-report-rec-pill" style="color:{t_color};border-color:{t_color};">{_tl}</span> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-card" style="margin-bottom:18px;"> | |
| <div class="duka-report-section-h">Top recommendations</div> | |
| <div class="duka-report-rec-list">{rec_cards}</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Risks to Watch ─────────────────────────────────────────────────────── | |
| risks = full.get("risks", []) | |
| if risks: | |
| risk_html = "".join( | |
| f'<div class="duka-report-risk-row">' | |
| f'<span class="duka-report-risk-ic" aria-hidden="true">⚠</span>' | |
| f'<span class="duka-report-risk-txt">{_report_text_escape(r)}</span></div>' | |
| for r in risks | |
| ) | |
| st.markdown( | |
| f""" | |
| <div class="duka-report-risk-card" style="margin-bottom:18px;"> | |
| <div class="duka-report-section-h" style="color:#FBBF24;">Risks to watch</div> | |
| {risk_html} | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Footer / branding ──────────────────────────────────────────────────── | |
| st.markdown( | |
| '<div class="duka-report-foot">' | |
| "Generated by Duka AI · Powered by AMD MI300X · Qwen Model" | |
| "</div>" | |
| "</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Share section ──────────────────────────────────────────────────────── | |
| st.markdown("<div style='height:20px;'></div>", unsafe_allow_html=True) | |
| with st.expander("📤 Share this report", expanded=False): | |
| st.markdown( | |
| '<div style="font-size:13px;color:#94A3B8;margin-bottom:12px;">' | |
| 'Share this report with your bank manager or accountant via WhatsApp or email. ' | |
| 'Download the PDF above and attach it to your message.' | |
| '</div>', | |
| 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("<div style='height:20px;'></div>", unsafe_allow_html=True) | |
| st.markdown( | |
| """ | |
| <div style="background: linear-gradient(135deg, rgba(29,158,117,0.12) 0%, | |
| rgba(37,99,235,0.08) 100%); border: 1px solid rgba(29,158,117,0.35); | |
| border-radius: 12px; padding: 20px 24px; margin-bottom: 16px;"> | |
| <div style="font-size: 18px; font-weight: 800; color: #E2E8F0; margin-bottom: 6px;"> | |
| 📬 Want this report automatically? | |
| </div> | |
| <div style="font-size: 13px; color: #94A3B8; line-height: 1.65;"> | |
| I can analyze your business and email you a complete financial report on a | |
| schedule you choose — no action needed from you. | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| "<div style='font-size:12px;font-weight:700;text-transform:uppercase;" | |
| "letter-spacing:.1em;color:#94A3B8;margin-bottom:10px;'>" | |
| "Set up automatic reports:</div>", | |
| 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("<div style='height:10px;'></div>", 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( | |
| '<div id="duka-ai-market-intel-page-marker" aria-hidden="true" ' | |
| 'style="position:absolute;width:0;height:0;overflow:hidden"></div>', | |
| 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( | |
| '<div id="duka-ai-market-intel-chat-anchor"></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| '<div class="duka-forecast-convo-card">' | |
| '<div class="duka-forecast-convo-head">' | |
| '<span class="duka-forecast-convo-title">🌍 Ask the Market Agent</span>' | |
| '<span class="duka-forecast-convo-sub">' | |
| f"{html.escape(sub_line)}" | |
| "</span>" | |
| "</div>", | |
| 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("<div style='height:8px;'></div>", 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( | |
| '<div class="duka-forecast-empty">No messages yet — tap a suggestion ' | |
| "or type below.</div>", | |
| 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'<div class="user-bubble-wrap"><div class="user-bubble">{escaped}</div></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| with st.chat_message("assistant", avatar="🌍"): | |
| st.markdown(msg["content"]) | |
| st.markdown("</div>", 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""" | |
| <style> | |
| html, body, .stApp, | |
| [data-testid="stAppViewContainer"], | |
| [data-testid="stAppViewContainer"] > .main, | |
| [data-testid="stMain"], | |
| [data-testid="stMainBlockContainer"], | |
| [data-testid="stHeader"] {{ | |
| background: #212121 !important; | |
| background-color: #212121 !important; | |
| background-image: none !important; | |
| }} | |
| [data-testid="stHeader"] {{ | |
| box-shadow: none !important; | |
| }} | |
| .block-container {{ | |
| max-width: 1280px; | |
| padding-top: 0.75rem; | |
| padding-bottom: 6rem; /* room above sticky composer */ | |
| padding-left: 2rem; | |
| padding-right: 2rem; | |
| }} | |
| /* ChatGPT-style narrow conversation column once analysis is live */ | |
| .chatgpt-stage .block-container, | |
| body:has(#duka-ai-chat-anchor) .block-container {{ | |
| max-width: 920px; | |
| padding-left: 1rem; | |
| padding-right: 1rem; | |
| }} | |
| /* Cash Flow Forecast: use a comfortable wide canvas */ | |
| body:has(#duka-ai-forecast-page-marker) .block-container {{ | |
| max-width: min(1480px, 94vw) !important; | |
| padding-left: 1.35rem !important; | |
| padding-right: 1.35rem !important; | |
| padding-bottom: 6rem !important; | |
| }} | |
| body:has(#duka-ai-market-intel-page-marker) .block-container {{ | |
| max-width: min(1480px, 94vw) !important; | |
| padding-left: 1.35rem !important; | |
| padding-right: 1.35rem !important; | |
| padding-bottom: 6rem !important; | |
| }} | |
| body:has(#duka-ai-expense-analyzer-page-marker) .block-container {{ | |
| max-width: min(1480px, 94vw) !important; | |
| padding-left: 1.35rem !important; | |
| padding-right: 1.35rem !important; | |
| padding-bottom: 6rem !important; | |
| }} | |
| .duka-forecast-dashboard {{ | |
| margin-bottom: 0.4rem; | |
| }} | |
| /* Generate Report — premium document layout */ | |
| body:has(#duka-ai-report-page-marker) .block-container {{ | |
| max-width: min(920px, 96vw) !important; | |
| padding-left: 1.25rem !important; | |
| padding-right: 1.25rem !important; | |
| }} | |
| .duka-report-premium {{ | |
| position: relative; | |
| }} | |
| .duka-report-hero {{ | |
| position: relative; | |
| overflow: hidden; | |
| background: linear-gradient(152deg, #080d18 0%, #101827 45%, #0b1324 100%); | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| border-radius: 18px; | |
| padding: 26px 32px 22px; | |
| margin-bottom: 22px; | |
| box-shadow: | |
| 0 28px 90px rgba(0, 0, 0, 0.5), | |
| 0 0 0 1px rgba(255, 255, 255, 0.04) inset; | |
| }} | |
| .duka-report-hero::after {{ | |
| content: ""; | |
| position: absolute; | |
| top: -20%; | |
| right: -10%; | |
| width: 52%; | |
| height: 140%; | |
| background: radial-gradient(ellipse at 70% 20%, rgba(59, 130, 246, 0.14), transparent 58%); | |
| pointer-events: none; | |
| }} | |
| .duka-report-hero-kicker {{ | |
| font-size: 0.68rem; | |
| font-weight: 800; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| color: #6EE7B7; | |
| margin-bottom: 6px; | |
| position: relative; | |
| z-index: 1; | |
| }} | |
| .duka-report-hero-title {{ | |
| font-size: clamp(1.45rem, 3.5vw, 1.85rem); | |
| font-weight: 850; | |
| color: #F8FAFC; | |
| line-height: 1.15; | |
| margin-bottom: 6px; | |
| letter-spacing: -0.02em; | |
| position: relative; | |
| z-index: 1; | |
| }} | |
| .duka-report-hero-meta {{ | |
| font-size: 0.88rem; | |
| color: rgba(148, 163, 184, 0.95); | |
| position: relative; | |
| z-index: 1; | |
| }} | |
| .duka-report-hero-badge {{ | |
| display: inline-block; | |
| margin-top: 12px; | |
| padding: 4px 12px; | |
| border-radius: 999px; | |
| font-size: 0.65rem; | |
| font-weight: 800; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: #93C5FD; | |
| background: rgba(37, 99, 235, 0.15); | |
| border: 1px solid rgba(96, 165, 250, 0.35); | |
| position: relative; | |
| z-index: 1; | |
| }} | |
| .duka-report-kpi-row {{ | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 22px; | |
| }} | |
| @media (max-width: 700px) {{ | |
| .duka-report-kpi-row {{ grid-template-columns: 1fr; }} | |
| }} | |
| .duka-report-kpi {{ | |
| background: linear-gradient(180deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.65) 100%); | |
| border: 1px solid rgba(148, 163, 184, 0.18); | |
| border-radius: 14px; | |
| padding: 14px 16px; | |
| text-align: center; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); | |
| }} | |
| .duka-report-kpi-lab {{ | |
| font-size: 0.65rem; | |
| font-weight: 800; | |
| letter-spacing: 0.11em; | |
| text-transform: uppercase; | |
| color: #94A3B8; | |
| margin-bottom: 4px; | |
| }} | |
| .duka-report-kpi-val {{ | |
| font-size: 1.15rem; | |
| font-weight: 800; | |
| color: #F1F5F9; | |
| }} | |
| .duka-report-kpi-val.loss {{ color: #FCA5A5; }} | |
| .duka-report-kpi-val.gain {{ color: #6EE7B7; }} | |
| .duka-report-exec {{ | |
| position: relative; | |
| background: rgba(15, 23, 42, 0.75); | |
| border: 1px solid rgba(51, 65, 85, 0.45); | |
| border-radius: 16px; | |
| padding: 20px 24px 22px; | |
| margin-bottom: 22px; | |
| box-shadow: 0 16px 48px rgba(0, 0, 0, 0.28); | |
| }} | |
| .duka-report-exec::before {{ | |
| content: ""; | |
| position: absolute; | |
| left: 0; | |
| top: 14px; | |
| bottom: 14px; | |
| width: 4px; | |
| border-radius: 4px; | |
| background: linear-gradient(180deg, #10B981, #2563EB); | |
| }} | |
| .duka-report-section-h {{ | |
| font-size: 0.68rem; | |
| font-weight: 800; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: #94A3B8; | |
| margin-bottom: 10px; | |
| }} | |
| .duka-report-exec-body {{ | |
| font-size: 1.02rem; | |
| color: #E2E8F0; | |
| line-height: 1.72; | |
| font-weight: 450; | |
| padding-left: 12px; | |
| }} | |
| .duka-report-card {{ | |
| background: rgba(15, 23, 42, 0.72); | |
| border: 1px solid rgba(148, 163, 184, 0.16); | |
| border-radius: 14px; | |
| padding: 18px 22px; | |
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22); | |
| }} | |
| .duka-report-fc-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 14px; | |
| margin-top: 8px; | |
| }} | |
| @media (max-width: 540px) {{ | |
| .duka-report-fc-grid {{ grid-template-columns: 1fr; }} | |
| }} | |
| .duka-report-fc-stat {{ | |
| background: rgba(30, 41, 59, 0.5); | |
| border: 1px solid rgba(148, 163, 184, 0.12); | |
| border-radius: 12px; | |
| padding: 12px 14px; | |
| }} | |
| .duka-report-fc-lab {{ | |
| font-size: 0.62rem; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: #94A3B8; | |
| }} | |
| .duka-report-fc-val {{ | |
| font-size: 1.1rem; | |
| font-weight: 800; | |
| color: #F8FAFC; | |
| margin-top: 4px; | |
| }} | |
| .duka-report-foot {{ | |
| text-align: center; | |
| padding: 18px 12px 8px; | |
| font-size: 0.72rem; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| color: rgba(148, 163, 184, 0.55); | |
| border-top: 1px solid rgba(148, 163, 184, 0.12); | |
| margin-top: 12px; | |
| }} | |
| .duka-report-rec-list {{ | |
| margin-top: 4px; | |
| }} | |
| .duka-report-rec-item {{ | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 14px; | |
| padding: 14px 0; | |
| border-bottom: 1px solid rgba(148, 163, 184, 0.1); | |
| }} | |
| .duka-report-rec-item:first-child {{ padding-top: 4px; }} | |
| .duka-report-rec-item:last-child {{ | |
| border-bottom: none; | |
| padding-bottom: 4px; | |
| }} | |
| .duka-report-rec-num {{ | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| background: linear-gradient(145deg, rgba(37, 99, 235, 0.35), rgba(15, 23, 42, 0.8)); | |
| border: 1px solid rgba(96, 165, 250, 0.45); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 800; | |
| color: #93C5FD; | |
| font-size: 0.82rem; | |
| flex-shrink: 0; | |
| box-shadow: 0 4px 14px rgba(37, 99, 235, 0.2); | |
| }} | |
| .duka-report-rec-action {{ | |
| font-size: 0.95rem; | |
| color: #F1F5F9; | |
| font-weight: 650; | |
| line-height: 1.45; | |
| margin-bottom: 6px; | |
| }} | |
| .duka-report-rec-meta {{ | |
| font-size: 0.78rem; | |
| line-height: 1.5; | |
| }} | |
| .duka-report-rec-impact {{ | |
| color: #6EE7B7; | |
| }} | |
| .duka-report-rec-pill {{ | |
| display: inline-block; | |
| background: rgba(0, 0, 0, 0.28); | |
| border: 1px solid currentColor; | |
| border-radius: 999px; | |
| padding: 2px 10px; | |
| font-size: 0.68rem; | |
| font-weight: 700; | |
| margin-left: 8px; | |
| }} | |
| .duka-report-risk-card {{ | |
| background: linear-gradient(160deg, rgba(245, 158, 11, 0.08) 0%, rgba(15, 23, 42, 0.75) 100%); | |
| border: 1px solid rgba(245, 158, 11, 0.28); | |
| border-radius: 14px; | |
| padding: 18px 22px; | |
| box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22); | |
| }} | |
| .duka-report-risk-row {{ | |
| display: flex; | |
| gap: 12px; | |
| align-items: flex-start; | |
| margin-bottom: 12px; | |
| }} | |
| .duka-report-risk-row:last-child {{ margin-bottom: 0; }} | |
| .duka-report-risk-ic {{ | |
| color: #FBBF24; | |
| font-size: 1.1rem; | |
| flex-shrink: 0; | |
| line-height: 1.4; | |
| }} | |
| .duka-report-risk-txt {{ | |
| font-size: 0.9rem; | |
| color: #FCD34D; | |
| line-height: 1.6; | |
| }} | |
| [data-testid="stSidebar"] {{ | |
| background: #171717; | |
| border-right: 1px solid rgba(255,255,255,0.06); | |
| }} | |
| [data-testid="stSidebar"] [data-testid="stSidebarContent"] {{ | |
| padding: 1rem 1rem 1.2rem; | |
| }} | |
| .sidebar-brand {{ | |
| padding: 0.15rem 0 0.85rem; | |
| margin-bottom: 0.15rem; | |
| }} | |
| .sidebar-brand-row {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.65rem; | |
| }} | |
| .sidebar-brand-mark {{ | |
| width: 2.35rem; | |
| height: 2.35rem; | |
| min-width: 2.35rem; | |
| border-radius: 0.55rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 1.15rem; | |
| background: linear-gradient(135deg, #1D9E75, #378ADD); | |
| box-shadow: 0 4px 14px rgba(29, 158, 117, 0.28); | |
| }} | |
| .sidebar-brand-copy {{ | |
| min-width: 0; | |
| }} | |
| .sidebar-brand-title {{ | |
| color: #FFFFFF; | |
| font-size: 1.2rem; | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| line-height: 1.15; | |
| }} | |
| .sidebar-brand-subtitle {{ | |
| color: rgba(255,255,255,0.58); | |
| font-size: 0.84rem; | |
| font-weight: 600; | |
| margin-top: 0.18rem; | |
| letter-spacing: 0.01em; | |
| }} | |
| .sidebar-status {{ | |
| display: inline-flex; | |
| align-items: center; | |
| color: #A7F3D0; | |
| background: rgba(16,185,129,0.10); | |
| border: 1px solid rgba(16,185,129,0.18); | |
| border-radius: 999px; | |
| padding: 0.24rem 0.62rem; | |
| font-size: 0.68rem; | |
| font-weight: 700; | |
| margin: 0.4rem 0 1.1rem; | |
| }} | |
| .sidebar-section {{ | |
| color: rgba(255,255,255,0.58); | |
| border-top: 1px solid rgba(255,255,255,0.10); | |
| margin-top: 0.95rem; | |
| padding-top: 0.85rem; | |
| margin-bottom: 0.45rem; | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| letter-spacing: 0.11em; | |
| }} | |
| .sidebar-footer {{ | |
| border-top: 1px solid rgba(255,255,255,0.10); | |
| color: rgba(255,255,255,0.48); | |
| font-size: 0.72rem; | |
| line-height: 1.45; | |
| margin-top: 1rem; | |
| padding-top: 0.9rem; | |
| }} | |
| [data-testid="stSidebar"] .stButton > button {{ | |
| min-height: 2.25rem !important; | |
| border-radius: 12px !important; | |
| justify-content: flex-start !important; | |
| padding: 0.35rem 0.7rem !important; | |
| font-size: 0.84rem !important; | |
| font-weight: 700 !important; | |
| box-shadow: none !important; | |
| margin-bottom: 0.2rem; | |
| transition: opacity 0.15s ease, transform 0.15s ease, | |
| border-color 0.18s ease, background 0.18s ease !important; | |
| }} | |
| [data-testid="stSidebar"] .stButton > button:active {{ | |
| opacity: 0.9 !important; | |
| transform: scale(0.988) !important; | |
| }} | |
| [data-testid="stSidebar"] .stButton > button[kind="primary"] {{ | |
| background: linear-gradient(135deg, #2563EB 0%, #14B8A6 100%) !important; | |
| border: 1px solid rgba(255,255,255,0.16) !important; | |
| color: #FFFFFF !important; | |
| }} | |
| [data-testid="stSidebar"] .stButton > button[kind="secondary"] {{ | |
| background: rgba(255,255,255,0.055) !important; | |
| border: 1px solid rgba(255,255,255,0.11) !important; | |
| color: rgba(255,255,255,0.86) !important; | |
| }} | |
| [data-testid="stSidebar"] .stButton > button[kind="secondary"]:hover {{ | |
| background: rgba(255,255,255,0.10) !important; | |
| border-color: rgba(20,184,166,0.42) !important; | |
| color: #FFFFFF !important; | |
| }} | |
| .page-header {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| gap: 1rem; | |
| padding: 1rem 0 0.75rem; | |
| margin-bottom: 0.65rem; | |
| border-bottom: 1px solid rgba(255,255,255,0.08); | |
| }} | |
| .page-title {{ | |
| color: #FFFFFF; | |
| font-size: 1.65rem; | |
| line-height: 1.15; | |
| font-weight: 850; | |
| }} | |
| .page-subtitle {{ | |
| color: rgba(226,232,240,0.72); | |
| font-size: 0.9rem; | |
| line-height: 1.55; | |
| margin-top: 0.3rem; | |
| max-width: 760px; | |
| }} | |
| .page-badge {{ | |
| flex: 0 0 auto; | |
| color: #A7F3D0; | |
| background: rgba(16,185,129,0.10); | |
| border: 1px solid rgba(16,185,129,0.22); | |
| border-radius: 999px; | |
| padding: 0.35rem 0.7rem; | |
| font-size: 0.74rem; | |
| font-weight: 800; | |
| }} | |
| .tool-tip, .empty-state {{ | |
| background: rgba(255,255,255,0.055); | |
| border: 1px solid rgba(255,255,255,0.09); | |
| border-radius: 16px; | |
| padding: 0.9rem 1rem; | |
| margin-bottom: 0.9rem; | |
| }} | |
| .tool-tip strong {{ | |
| display: block; | |
| color: #FFFFFF; | |
| font-size: 0.9rem; | |
| margin-bottom: 0.18rem; | |
| }} | |
| .tool-tip span {{ | |
| color: rgba(226,232,240,0.72); | |
| font-size: 0.82rem; | |
| line-height: 1.5; | |
| }} | |
| .empty-state {{ | |
| max-width: 620px; | |
| margin-top: 1rem; | |
| }} | |
| .empty-state-kicker {{ | |
| color: #FBBF24; | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| letter-spacing: 0.11em; | |
| text-transform: uppercase; | |
| margin-bottom: 0.35rem; | |
| }} | |
| .empty-state-title {{ | |
| color: #FFFFFF; | |
| font-size: 1.15rem; | |
| font-weight: 800; | |
| margin-bottom: 0.2rem; | |
| }} | |
| .empty-state-copy {{ | |
| color: rgba(226,232,240,0.72); | |
| font-size: 0.9rem; | |
| line-height: 1.55; | |
| }} | |
| h1, h2, h3, h4, h5, p, label {{ | |
| color: {THEME["white"]}; | |
| }} | |
| .hero-header {{ | |
| background: | |
| radial-gradient(circle at top right, rgba(255,255,255,0.18), transparent 26%), | |
| linear-gradient(135deg, #0B1220 0%, #12365E 45%, #0E7C66 100%); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 18px; | |
| padding: 1.5rem 2rem; | |
| box-shadow: 0 8px 24px rgba(2, 6, 23, 0.22); | |
| margin-top: 1rem; | |
| margin-bottom: 0.75rem; | |
| overflow: visible; | |
| }} | |
| .hero-kicker {{ | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| font-size: 0.78rem; | |
| color: rgba(255,255,255,0.72); | |
| margin-bottom: 0.45rem; | |
| margin-top: 0; | |
| font-weight: 700; | |
| }} | |
| .hero-title {{ | |
| color: #FFFFFF; | |
| font-size: 2rem; | |
| line-height: 1.02; | |
| font-weight: 800; | |
| margin-bottom: 0.2rem; | |
| }} | |
| .hero-subtitle {{ | |
| color: rgba(255,255,255,0.9); | |
| font-size: 0.96rem; | |
| margin-bottom: 0.2rem; | |
| }} | |
| .hero-statement {{ | |
| color: rgba(255,255,255,0.74); | |
| font-size: 0.92rem; | |
| max-width: 860px; | |
| }} | |
| .mode-line {{ | |
| color: rgba(255,255,255,0.74); | |
| font-size: 0.82rem; | |
| margin-bottom: 0.55rem; | |
| }} | |
| .section-intro {{ | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 18px; | |
| padding: 0.95rem 1rem; | |
| margin-bottom: 0.75rem; | |
| }} | |
| .section-intro-title {{ | |
| color: #FFFFFF; | |
| font-size: 0.96rem; | |
| font-weight: 800; | |
| margin-bottom: 0.22rem; | |
| }} | |
| .section-intro-subtitle {{ | |
| color: rgba(255,255,255,0.74); | |
| line-height: 1.5; | |
| font-size: 0.86rem; | |
| }} | |
| .subsection-label {{ | |
| color: rgba(255,255,255,0.94); | |
| font-size: 0.92rem; | |
| font-weight: 700; | |
| margin-bottom: 0.55rem; | |
| }} | |
| .example-description {{ | |
| color: rgba(255,255,255,0.78); | |
| font-size: 0.82rem; | |
| font-style: italic; | |
| min-height: 1.3rem; | |
| margin-top: 0.45rem; | |
| margin-bottom: 0.2rem; | |
| }} | |
| .numbers-helper, .analyze-note {{ | |
| color: rgba(255,255,255,0.62); | |
| font-size: 0.78rem; | |
| line-height: 1.5; | |
| }} | |
| .mode-helper {{ | |
| color: rgba(255,255,255,0.72); | |
| font-size: 0.8rem; | |
| line-height: 1.5; | |
| margin-bottom: 0.65rem; | |
| }} | |
| /* Data source / upload section polish */ | |
| [data-testid="stSelectbox"] > div[data-baseweb="select"] {{ | |
| background: rgba(15, 23, 42, 0.82) !important; | |
| border: 1px solid rgba(148, 163, 184, 0.28) !important; | |
| border-radius: 12px !important; | |
| }} | |
| [data-testid="stFileUploader"] {{ | |
| background: rgba(15, 23, 42, 0.55); | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| border-radius: 16px; | |
| padding: 0.72rem 0.78rem 0.82rem; | |
| margin-top: 0.22rem; | |
| }} | |
| [data-testid="stFileUploaderDropzone"] {{ | |
| background: rgba(2, 6, 23, 0.66) !important; | |
| border: 1px dashed rgba(148, 163, 184, 0.42) !important; | |
| border-radius: 12px !important; | |
| }} | |
| [data-testid="stFileUploaderDropzone"] section {{ | |
| padding: 0.55rem 0.35rem !important; | |
| }} | |
| [data-testid="stFileUploader"] small {{ | |
| color: rgba(203, 213, 225, 0.78) !important; | |
| }} | |
| .analyze-note {{ | |
| text-align: center; | |
| margin-top: 0.38rem; | |
| margin-bottom: 0.15rem; | |
| }} | |
| .analysis-hero {{ | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 18px; | |
| padding: 1rem 1.05rem; | |
| }} | |
| .analysis-kicker {{ | |
| color: rgba(255,255,255,0.62); | |
| font-size: 0.72rem; | |
| font-weight: 800; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| margin-bottom: 0.35rem; | |
| }} | |
| .analysis-headline {{ | |
| color: #FFFFFF; | |
| font-size: 1.1rem; | |
| line-height: 1.45; | |
| font-weight: 700; | |
| margin-bottom: 0.3rem; | |
| }} | |
| .analysis-support {{ | |
| color: rgba(255,255,255,0.74); | |
| font-size: 0.9rem; | |
| line-height: 1.6; | |
| }} | |
| .analysis-guidance {{ | |
| color: rgba(255,255,255,0.84); | |
| font-size: 0.92rem; | |
| line-height: 1.65; | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid rgba(255,255,255,0.07); | |
| border-radius: 16px; | |
| padding: 0.9rem 1rem; | |
| }} | |
| .analysis-guidance strong {{ | |
| color: #FFFFFF; | |
| }} | |
| .chat-stage-header {{ | |
| background: transparent; | |
| border: none; | |
| border-radius: 0; | |
| padding: 0.4rem 0 0.6rem; | |
| }} | |
| .chat-stage-title {{ | |
| color: #ECECEC; | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| margin-bottom: 0.15rem; | |
| letter-spacing: -0.01em; | |
| }} | |
| .chat-stage-copy {{ | |
| color: rgba(236,236,236,0.55); | |
| font-size: 0.82rem; | |
| line-height: 1.5; | |
| }} | |
| .chat-placeholder {{ | |
| background: rgba(255,255,255,0.04); | |
| border: 1px dashed rgba(255,255,255,0.12); | |
| border-radius: 18px; | |
| padding: 1rem 1.05rem; | |
| color: rgba(255,255,255,0.68); | |
| font-size: 0.9rem; | |
| line-height: 1.55; | |
| margin-top: 1rem; | |
| }} | |
| .chat-block-gap {{ | |
| height: 1rem; | |
| }} | |
| .summary-panel, .loan-card, .metric-card, .prompt-card, .preview-card, .agent-card, .mini-card, .advisor-panel, .chat-card, .visual-card {{ | |
| background: linear-gradient(180deg, #1B1F2A 0%, #161A23 100%); | |
| border: 1px solid rgba(148, 163, 184, 0.18); | |
| border-radius: 18px; | |
| box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28); | |
| color: #E5E7EB; | |
| }} | |
| .summary-panel *, .loan-card *, .preview-card *, .agent-card *, .mini-card *, .advisor-panel *, .chat-card *, .visual-card * {{ | |
| color: inherit; | |
| }} | |
| .advisor-panel {{ | |
| padding: 1.2rem 1.25rem; | |
| margin-bottom: 1rem; | |
| }} | |
| .advisor-headline {{ | |
| color: {THEME["text"]}; | |
| font-size: 1.08rem; | |
| line-height: 1.7; | |
| font-weight: 600; | |
| }} | |
| .advisor-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 0.8rem; | |
| margin-top: 0.9rem; | |
| }} | |
| .advisor-insight {{ | |
| background: #FFFFFF; | |
| border: 1px solid {THEME["border"]}; | |
| border-radius: 18px; | |
| padding: 0.9rem; | |
| }} | |
| .advisor-insight span {{ | |
| display: block; | |
| color: {THEME["muted"]}; | |
| font-size: 0.76rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin-bottom: 0.28rem; | |
| }} | |
| .advisor-insight strong {{ | |
| color: {THEME["text"]}; | |
| line-height: 1.55; | |
| }} | |
| .summary-panel {{ | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| align-items: flex-start; | |
| padding: 1.15rem 1.2rem; | |
| margin-bottom: 1rem; | |
| }} | |
| .summary-kicker {{ | |
| color: #7DD3FC; | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| font-size: 0.74rem; | |
| font-weight: 800; | |
| margin-bottom: 0.42rem; | |
| }} | |
| .summary-headline {{ | |
| color: #E5E7EB; | |
| font-size: 0.96rem; | |
| line-height: 1.65; | |
| font-weight: 500; | |
| max-width: 760px; | |
| }} | |
| .source-chip-row {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| margin-top: 0.8rem; | |
| }} | |
| .source-chip {{ | |
| color: #93C5FD; | |
| background: rgba(59, 130, 246, 0.10); | |
| border: 1px solid rgba(96, 165, 250, 0.32); | |
| border-radius: 999px; | |
| padding: 0.34rem 0.7rem; | |
| font-size: 0.78rem; | |
| font-weight: 700; | |
| letter-spacing: 0.01em; | |
| }} | |
| .source-list {{ | |
| min-width: 290px; | |
| display: grid; | |
| gap: 0.55rem; | |
| }} | |
| .source-line {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.55rem; | |
| color: #E5E7EB; | |
| font-weight: 600; | |
| font-size: 0.88rem; | |
| }} | |
| .source-check {{ | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 999px; | |
| background: rgba(16, 185, 129, 0.12); | |
| border: 1px solid rgba(16, 185, 129, 0.45); | |
| color: #6EE7B7; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 800; | |
| flex-shrink: 0; | |
| }} | |
| #duka-ai-forecast-analysis-anchor {{ | |
| scroll-margin-top: 5.5rem; | |
| }} | |
| #duka-ai-market-intel-chat-anchor {{ | |
| scroll-margin-top: 5.5rem; | |
| }} | |
| .duka-forecast-analyst {{ | |
| border: 1px solid rgba(148, 163, 184, 0.2); | |
| border-radius: 14px; | |
| background: rgba(15, 23, 42, 0.72); | |
| padding: 1rem 1.05rem; | |
| margin-bottom: 0.85rem; | |
| }} | |
| .duka-forecast-narrative-card {{ | |
| border: 1px solid rgba(148, 163, 184, 0.16); | |
| border-radius: 14px; | |
| background: rgba(30, 41, 59, 0.65); | |
| padding: 1.1rem 1.15rem; | |
| margin-bottom: 1rem; | |
| color: #F1F5F9; | |
| font-size: 1.02rem; | |
| line-height: 1.65; | |
| font-weight: 500; | |
| }} | |
| .duka-forecast-convo-card {{ | |
| margin-top: 1.4rem; | |
| padding: 1.1rem 1.2rem 0.4rem; | |
| background: rgba(15, 23, 42, 0.55); | |
| border: 1px solid rgba(148, 163, 184, 0.18); | |
| border-radius: 18px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.18); | |
| }} | |
| .duka-forecast-convo-head {{ | |
| display: flex; | |
| align-items: baseline; | |
| gap: 0.85rem; | |
| flex-wrap: wrap; | |
| padding-bottom: 0.7rem; | |
| margin-bottom: 0.6rem; | |
| border-bottom: 1px solid rgba(148, 163, 184, 0.16); | |
| }} | |
| .duka-forecast-convo-title {{ | |
| color: #F8FAFC; | |
| font-weight: 800; | |
| font-size: 1.05rem; | |
| letter-spacing: 0.01em; | |
| }} | |
| .duka-forecast-convo-sub {{ | |
| color: rgba(148, 163, 184, 0.95); | |
| font-size: 0.84rem; | |
| }} | |
| .duka-forecast-empty {{ | |
| color: rgba(148, 163, 184, 0.85); | |
| font-size: 0.92rem; | |
| padding: 0.9rem 0.5rem 1.1rem; | |
| text-align: center; | |
| }} | |
| body:has(#duka-ai-forecast-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] {{ | |
| background: rgba(15, 23, 42, 0.7) !important; | |
| border: 1px solid rgba(148, 163, 184, 0.18) !important; | |
| border-radius: 14px !important; | |
| padding: 0.6rem 0.8rem 0.7rem !important; | |
| margin-bottom: 0.7rem !important; | |
| }} | |
| body:has(#duka-ai-forecast-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] p, | |
| body:has(#duka-ai-forecast-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] li {{ | |
| font-size: 1.02rem !important; | |
| line-height: 1.62 !important; | |
| color: #F1F5F9 !important; | |
| }} | |
| body:has(#duka-ai-forecast-page-marker) [data-testid="stChatInput"] {{ | |
| background: rgba(15, 23, 42, 0.85) !important; | |
| border-radius: 14px !important; | |
| border: 1px solid rgba(148, 163, 184, 0.22) !important; | |
| }} | |
| body:has(#duka-ai-market-intel-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] {{ | |
| background: rgba(15, 23, 42, 0.7) !important; | |
| border: 1px solid rgba(148, 163, 184, 0.18) !important; | |
| border-radius: 14px !important; | |
| padding: 0.6rem 0.8rem 0.7rem !important; | |
| margin-bottom: 0.7rem !important; | |
| }} | |
| body:has(#duka-ai-market-intel-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] p, | |
| body:has(#duka-ai-market-intel-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] li {{ | |
| font-size: 1.02rem !important; | |
| line-height: 1.62 !important; | |
| color: #F1F5F9 !important; | |
| }} | |
| body:has(#duka-ai-expense-analyzer-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] {{ | |
| background: rgba(15, 23, 42, 0.7) !important; | |
| border: 1px solid rgba(148, 163, 184, 0.18) !important; | |
| border-radius: 14px !important; | |
| padding: 0.6rem 0.8rem 0.7rem !important; | |
| margin-bottom: 0.7rem !important; | |
| }} | |
| body:has(#duka-ai-expense-analyzer-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] p, | |
| body:has(#duka-ai-expense-analyzer-page-marker) .duka-forecast-convo-card [data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] li {{ | |
| font-size: 1.02rem !important; | |
| line-height: 1.62 !important; | |
| color: #F1F5F9 !important; | |
| }} | |
| body:has(#duka-ai-expense-analyzer-page-marker) [data-testid="stChatInput"] {{ | |
| background: rgba(15, 23, 42, 0.85) !important; | |
| border-radius: 14px !important; | |
| border: 1px solid rgba(148, 163, 184, 0.22) !important; | |
| }} | |
| body:has(#duka-ai-market-intel-page-marker) [data-testid="stChatInput"] {{ | |
| background: rgba(15, 23, 42, 0.85) !important; | |
| border-radius: 14px !important; | |
| border: 1px solid rgba(148, 163, 184, 0.22) !important; | |
| }} | |
| .summary-statuses {{ | |
| display: grid; | |
| gap: 0.6rem; | |
| min-width: 220px; | |
| }} | |
| .status-card {{ | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid rgba(148, 163, 184, 0.22); | |
| border-radius: 14px; | |
| padding: 0.75rem 0.9rem; | |
| }} | |
| .status-card span {{ | |
| display: block; | |
| color: rgba(226, 232, 240, 0.62); | |
| font-size: 0.7rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| margin-bottom: 0.28rem; | |
| font-weight: 700; | |
| }} | |
| .status-card strong {{ | |
| color: #F8FAFC; | |
| font-size: 0.95rem; | |
| font-weight: 700; | |
| }} | |
| .metric-card {{ | |
| padding: 0.95rem 1rem; | |
| min-height: 136px; | |
| }} | |
| .metric-row {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.45rem; | |
| margin-bottom: 0.55rem; | |
| }} | |
| .metric-icon {{ | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 10px; | |
| background: rgba(255,255,255,0.06); | |
| color: #F8FAFC; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 800; | |
| }} | |
| .metric-label {{ | |
| color: rgba(226, 232, 240, 0.7); | |
| font-weight: 700; | |
| letter-spacing: 0.02em; | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| }} | |
| .metric-value {{ | |
| color: #F8FAFC; | |
| font-size: 1.65rem; | |
| font-weight: 800; | |
| margin-bottom: 0.2rem; | |
| letter-spacing: -0.01em; | |
| }} | |
| .metric-note {{ | |
| color: rgba(226, 232, 240, 0.62); | |
| line-height: 1.45; | |
| font-size: 0.84rem; | |
| }} | |
| .prompt-card {{ | |
| padding: 0.95rem; | |
| min-height: 180px; | |
| margin-bottom: 0.5rem; | |
| background: linear-gradient(180deg, #172033 0%, #111827 100%); | |
| border: 1px solid #334155; | |
| box-shadow: 0 16px 30px rgba(2, 6, 23, 0.22); | |
| }} | |
| .prompt-title {{ | |
| color: #F8FAFC; | |
| font-weight: 800; | |
| font-size: 1.05rem; | |
| margin-bottom: 0.55rem; | |
| }} | |
| .prompt-preview {{ | |
| color: #CBD5E1; | |
| line-height: 1.65; | |
| }} | |
| .preview-card {{ | |
| padding: 1rem 1.05rem; | |
| background: linear-gradient(180deg, #131A28 0%, #0F1522 100%); | |
| border: 1px solid rgba(148, 163, 184, 0.26); | |
| box-shadow: 0 10px 26px rgba(0, 0, 0, 0.32); | |
| }} | |
| .preview-title {{ | |
| color: #F8FAFC; | |
| font-size: 1rem; | |
| font-weight: 800; | |
| margin-bottom: 0.75rem; | |
| }} | |
| .preview-summary {{ | |
| color: rgba(226, 232, 240, 0.82); | |
| line-height: 1.58; | |
| margin-top: 0.65rem; | |
| }} | |
| .preview-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 0.7rem 0.8rem; | |
| }} | |
| .preview-grid span {{ | |
| display: block; | |
| color: rgba(148, 163, 184, 0.95); | |
| font-size: 0.72rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.09em; | |
| margin-bottom: 0.18rem; | |
| }} | |
| .preview-grid strong {{ | |
| color: #F8FAFC; | |
| font-size: 0.97rem; | |
| font-weight: 700; | |
| }} | |
| .preview-warning {{ | |
| background: #FFF7ED; | |
| border: 1px solid #FED7AA; | |
| border-left: 4px solid {THEME["gold"]}; | |
| color: {THEME["text"]}; | |
| border-radius: 12px; | |
| padding: 0.7rem 0.8rem; | |
| margin-top: 0.45rem; | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| }} | |
| .provider-selected {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| }} | |
| .provider-selected-label {{ | |
| color: {THEME["muted"]}; | |
| font-size: 0.74rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| }} | |
| .provider-selected-name {{ | |
| color: {THEME["text"]}; | |
| font-weight: 800; | |
| font-size: 1rem; | |
| }} | |
| .provider-tile {{ | |
| position: relative; | |
| overflow: hidden; | |
| min-height: 158px; | |
| background: | |
| linear-gradient(145deg, rgba(255,255,255,0.085) 0%, rgba(255,255,255,0.035) 100%), | |
| #111827; | |
| border: 1px solid rgba(255,255,255,0.12); | |
| border-radius: 18px; | |
| padding: 0.95rem; | |
| margin-bottom: 0.55rem; | |
| transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; | |
| }} | |
| .provider-tile:hover {{ | |
| transform: translateY(-2px); | |
| border-color: color-mix(in srgb, var(--provider) 62%, white 8%); | |
| background: | |
| linear-gradient(145deg, rgba(255,255,255,0.11) 0%, rgba(255,255,255,0.045) 100%), | |
| #111827; | |
| }} | |
| .provider-tile-connected {{ | |
| background: | |
| linear-gradient(145deg, color-mix(in srgb, var(--provider) 18%, transparent) 0%, rgba(255,255,255,0.045) 100%), | |
| #111827; | |
| border-color: color-mix(in srgb, var(--provider) 72%, white 6%); | |
| }} | |
| .provider-accent {{ | |
| position: absolute; | |
| inset: 0 0 auto 0; | |
| height: 4px; | |
| }} | |
| .provider-topline {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 0.65rem; | |
| margin-bottom: 0.78rem; | |
| }} | |
| .provider-logo {{ | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 15px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.72rem; | |
| font-weight: 900; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.24), 0 10px 22px rgba(0,0,0,0.24); | |
| }} | |
| .provider-status {{ | |
| color: rgba(255,255,255,0.72); | |
| border: 1px solid rgba(255,255,255,0.13); | |
| background: rgba(255,255,255,0.055); | |
| border-radius: 999px; | |
| padding: 0.22rem 0.5rem; | |
| font-size: 0.66rem; | |
| font-weight: 800; | |
| }} | |
| .provider-tile-connected .provider-status {{ | |
| color: #D1FAE5; | |
| border-color: rgba(16,185,129,0.28); | |
| background: rgba(16,185,129,0.12); | |
| }} | |
| .provider-name {{ | |
| color: #FFFFFF; | |
| font-size: 0.95rem; | |
| font-weight: 850; | |
| line-height: 1.25; | |
| margin-bottom: 0.2rem; | |
| }} | |
| .provider-kind {{ | |
| color: color-mix(in srgb, var(--provider) 82%, white 18%); | |
| font-size: 0.74rem; | |
| font-weight: 800; | |
| margin-bottom: 0.5rem; | |
| }} | |
| .provider-signal {{ | |
| color: rgba(226,232,240,0.68); | |
| min-height: 2.1rem; | |
| font-size: 0.73rem; | |
| line-height: 1.45; | |
| }} | |
| .provider-meta-row {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.35rem; | |
| margin-top: 0.75rem; | |
| }} | |
| .provider-meta-row span {{ | |
| color: rgba(255,255,255,0.72); | |
| border: 1px solid rgba(255,255,255,0.10); | |
| background: rgba(255,255,255,0.045); | |
| border-radius: 999px; | |
| padding: 0.2rem 0.45rem; | |
| font-size: 0.64rem; | |
| font-weight: 700; | |
| }} | |
| .provider-connected-summary {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| background: rgba(255,255,255,0.055); | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-left: 4px solid; | |
| border-radius: 14px; | |
| padding: 0.75rem 0.85rem; | |
| margin: 0.55rem 0; | |
| }} | |
| .provider-dropdown-preview {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| background: | |
| linear-gradient(135deg, color-mix(in srgb, var(--provider) 13%, transparent), rgba(255,255,255,0.045)), | |
| #111827; | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-left: 4px solid; | |
| border-radius: 16px; | |
| padding: 0.75rem 0.85rem; | |
| min-height: 70px; | |
| margin: 0.4rem 0 0.75rem; | |
| }} | |
| .provider-connected-mark {{ | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 13px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.7rem; | |
| font-weight: 900; | |
| flex: 0 0 auto; | |
| }} | |
| .provider-connected-title {{ | |
| color: #FFFFFF; | |
| font-size: 0.9rem; | |
| font-weight: 850; | |
| margin-bottom: 0.16rem; | |
| }} | |
| .provider-connected-copy {{ | |
| color: rgba(226,232,240,0.70); | |
| font-size: 0.78rem; | |
| line-height: 1.45; | |
| }} | |
| .provider-badge {{ | |
| width: 42px; | |
| height: 42px; | |
| border-radius: 14px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.74rem; | |
| font-weight: 800; | |
| flex-shrink: 0; | |
| }} | |
| .provider-badge-large {{ | |
| width: 54px; | |
| height: 54px; | |
| border-radius: 16px; | |
| font-size: 0.85rem; | |
| }} | |
| .loan-card, .agent-card, .mini-card, .chat-card, .visual-card {{ | |
| padding: 1rem 1.05rem; | |
| }} | |
| .card-title {{ | |
| color: #F8FAFC; | |
| font-size: 0.96rem; | |
| font-weight: 800; | |
| margin-bottom: 0.7rem; | |
| letter-spacing: 0.01em; | |
| }} | |
| .loan-score-row {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 0.8rem; | |
| margin-bottom: 0.75rem; | |
| }} | |
| .loan-score {{ | |
| color: #F8FAFC; | |
| font-size: 1.9rem; | |
| font-weight: 800; | |
| letter-spacing: -0.01em; | |
| }} | |
| .loan-badge {{ | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid; | |
| border-radius: 999px; | |
| padding: 0.36rem 0.72rem; | |
| font-size: 0.8rem; | |
| font-weight: 800; | |
| }} | |
| .progress-shell {{ | |
| width: 100%; | |
| height: 10px; | |
| background: rgba(148, 163, 184, 0.18); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| margin-bottom: 0.8rem; | |
| }} | |
| .progress-fill {{ | |
| height: 100%; | |
| border-radius: 999px; | |
| }} | |
| .loan-explainer, .loan-meta {{ | |
| color: rgba(226, 232, 240, 0.72); | |
| line-height: 1.55; | |
| margin-bottom: 0.55rem; | |
| font-size: 0.86rem; | |
| }} | |
| .loan-meta strong {{ | |
| color: #F8FAFC; | |
| }} | |
| .loan-risk-note {{ | |
| background: #FEF2F2; | |
| border: 1px solid #FECACA; | |
| border-left: 4px solid {THEME["red"]}; | |
| color: {THEME["text"]}; | |
| border-radius: 14px; | |
| padding: 0.8rem 0.9rem; | |
| line-height: 1.55; | |
| }} | |
| .risk-card {{ | |
| display: flex; | |
| gap: 0.75rem; | |
| align-items: flex-start; | |
| background: #FFF7ED; | |
| border: 1px solid #FED7AA; | |
| border-left: 5px solid {THEME["red"]}; | |
| border-radius: 16px; | |
| padding: 0.9rem; | |
| margin-bottom: 0.7rem; | |
| }} | |
| .risk-icon {{ | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 999px; | |
| background: {THEME["red"]}; | |
| color: #FFFFFF; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.8rem; | |
| font-weight: 800; | |
| flex-shrink: 0; | |
| }} | |
| .risk-text, .agent-summary, .agent-detail, .chat-question, .chat-answer {{ | |
| color: {THEME["text"]}; | |
| line-height: 1.58; | |
| font-size: 0.94rem; | |
| }} | |
| .ok-card {{ | |
| background: #ECFDF5; | |
| border: 1px solid #A7F3D0; | |
| border-left: 5px solid {THEME["emerald"]}; | |
| color: {THEME["text"]}; | |
| border-radius: 16px; | |
| padding: 0.9rem; | |
| line-height: 1.55; | |
| }} | |
| .action-card {{ | |
| display: flex; | |
| gap: 0.85rem; | |
| align-items: flex-start; | |
| background: #EFF6FF; | |
| border: 1px solid #BFDBFE; | |
| border-radius: 16px; | |
| padding: 0.9rem; | |
| margin-bottom: 0.65rem; | |
| }} | |
| .action-number {{ | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 10px; | |
| background: {THEME["blue"]}; | |
| color: #FFFFFF; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-weight: 800; | |
| flex-shrink: 0; | |
| }} | |
| .action-text {{ | |
| color: {THEME["text"]}; | |
| line-height: 1.55; | |
| font-size: 0.94rem; | |
| }} | |
| .agent-detail {{ | |
| background: rgba(255,255,255,0.9); | |
| border: 1px solid {THEME["border"]}; | |
| border-radius: 14px; | |
| padding: 0.78rem 0.88rem; | |
| color: {THEME["muted"]}; | |
| margin-bottom: 0.5rem; | |
| }} | |
| .market-subhead {{ | |
| color: #F8FAFC; | |
| font-weight: 800; | |
| font-size: 0.78rem; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| margin: 1rem 0 0.5rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| }} | |
| .market-subhead::before {{ | |
| content: ""; | |
| display: inline-block; | |
| width: 4px; | |
| height: 14px; | |
| background: linear-gradient(180deg, #34D399, #2563EB); | |
| border-radius: 2px; | |
| }} | |
| .market-pill-row {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| margin-bottom: 0.2rem; | |
| }} | |
| .market-pill {{ | |
| background: rgba(239, 68, 68, 0.14); | |
| border: 1px solid rgba(248, 113, 113, 0.45); | |
| color: #FCA5A5; | |
| border-radius: 999px; | |
| padding: 0.42rem 0.78rem; | |
| font-size: 0.84rem; | |
| font-weight: 600; | |
| }} | |
| .market-pill-blue {{ | |
| background: rgba(37, 99, 235, 0.16); | |
| border-color: rgba(96, 165, 250, 0.45); | |
| color: #93C5FD; | |
| }} | |
| .agent-card .agent-summary {{ | |
| color: #E5E7EB !important; | |
| background: rgba(15, 23, 42, 0.55); | |
| border: 1px solid rgba(148, 163, 184, 0.18); | |
| border-left: 3px solid #34D399; | |
| border-radius: 12px; | |
| padding: 0.85rem 1rem; | |
| font-size: 0.96rem; | |
| line-height: 1.6; | |
| margin: 0.4rem 0 0.4rem; | |
| }} | |
| .agent-card .agent-detail {{ | |
| background: rgba(15, 23, 42, 0.55) !important; | |
| border: 1px solid rgba(148, 163, 184, 0.18) !important; | |
| color: #E5E7EB !important; | |
| border-radius: 12px; | |
| padding: 0.78rem 0.95rem; | |
| font-size: 0.94rem; | |
| line-height: 1.55; | |
| margin-bottom: 0.4rem; | |
| }} | |
| .chat-label {{ | |
| color: {THEME["muted"]}; | |
| font-size: 0.74rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin-bottom: 0.2rem; | |
| font-weight: 800; | |
| }} | |
| .chat-question {{ | |
| margin-bottom: 0.7rem; | |
| font-weight: 700; | |
| }} | |
| .visual-row {{ | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| padding: 0.65rem 0; | |
| border-top: 1px solid {THEME["border"]}; | |
| color: #E5E7EB; | |
| font-size: 0.92rem; | |
| }} | |
| .visual-row:first-of-type {{ | |
| border-top: none; | |
| padding-top: 0.2rem; | |
| }} | |
| .visual-row span {{ | |
| color: rgba(226, 232, 240, 0.72); | |
| font-weight: 500; | |
| }} | |
| .visual-row strong {{ | |
| color: #F8FAFC; | |
| font-weight: 700; | |
| text-align: right; | |
| }} | |
| .chat-invite {{ | |
| color: rgba(255,255,255,0.52); | |
| font-size: 0.92rem; | |
| margin-top: 0.95rem; | |
| margin-bottom: 0.2rem; | |
| font-style: italic; | |
| line-height: 1.65; | |
| }} | |
| .agent-label {{ | |
| color: rgba(255,255,255,0.38); | |
| font-size: 0.7rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| margin-bottom: 0.3rem; | |
| }} | |
| /* User message bubble */ | |
| .user-bubble-wrap {{ | |
| display: flex; | |
| justify-content: flex-end; | |
| margin: 0.6rem 0 0.9rem 0; | |
| padding-left: 3rem; | |
| gap: 0.55rem; | |
| align-items: flex-end; | |
| }} | |
| .user-bubble {{ | |
| background: linear-gradient(135deg, #1D9E75 0%, #14B783 60%, #2563EB 140%); | |
| color: #FFFFFF; | |
| border-radius: 18px 18px 4px 18px; | |
| padding: 0.7rem 1.05rem; | |
| max-width: 75%; | |
| font-size: 0.95rem; | |
| line-height: 1.55; | |
| word-wrap: break-word; | |
| box-shadow: 0 4px 14px rgba(29, 158, 117, 0.22); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| font-weight: 500; | |
| }} | |
| /* Polished avatars for st.chat_message (assistant) */ | |
| [data-testid="stChatMessageAvatarAssistant"] {{ | |
| background: linear-gradient(135deg, #1D9E75, #2563EB) !important; | |
| color: #FFFFFF !important; | |
| border: 1px solid rgba(255,255,255,0.15) !important; | |
| box-shadow: 0 4px 12px rgba(37, 99, 235, 0.28) !important; | |
| width: 34px !important; | |
| height: 34px !important; | |
| border-radius: 50% !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| font-size: 1.05rem !important; | |
| }} | |
| [data-testid="stChatMessageAvatarUser"] {{ | |
| background: linear-gradient(135deg, #475569, #1E293B) !important; | |
| color: #F8FAFC !important; | |
| border: 1px solid rgba(255,255,255,0.12) !important; | |
| width: 34px !important; | |
| height: 34px !important; | |
| border-radius: 50% !important; | |
| font-size: 1rem !important; | |
| }} | |
| /* Inline metrics strip */ | |
| .inline-metrics {{ | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 10px; | |
| padding: 0.42rem 0.92rem; | |
| color: rgba(255,255,255,0.68); | |
| font-size: 0.83rem; | |
| font-family: 'SF Mono', 'Segoe UI Mono', 'Courier New', monospace; | |
| margin: 0.65rem 0 0.72rem; | |
| display: inline-block; | |
| letter-spacing: 0.01em; | |
| }} | |
| /* Suggestion pills label */ | |
| .sug-pills-label {{ | |
| color: rgba(255,255,255,0.35); | |
| font-size: 0.7rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.12em; | |
| margin: 0.5rem 0 0.45rem; | |
| }} | |
| /* Assistant message — flat ChatGPT-style, no card */ | |
| [data-testid="stChatMessage"] {{ | |
| background: transparent !important; | |
| border: none !important; | |
| border-radius: 0 !important; | |
| margin: 0.4rem 0 1.2rem 0 !important; | |
| padding: 0 !important; | |
| gap: 0.75rem !important; | |
| }} | |
| [data-testid="stChatMessage"] [data-testid="stChatMessageAvatarUser"], | |
| [data-testid="stChatMessage"] [data-testid="stChatMessageAvatarAssistant"] {{ | |
| background: #19C37D !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| }} | |
| [data-testid="stChatMessageContent"] {{ | |
| background: transparent !important; | |
| border: none !important; | |
| padding: 0 !important; | |
| }} | |
| [data-testid="stChatMessageContent"] p, | |
| [data-testid="stChatMessageContent"] li {{ | |
| color: #ECECEC !important; | |
| font-size: 0.96rem !important; | |
| line-height: 1.7 !important; | |
| }} | |
| [data-testid="stChatMessageContent"] p {{ | |
| margin: 0 0 0.75rem 0 !important; | |
| }} | |
| [data-testid="stChatMessageContent"] p:last-child {{ | |
| margin-bottom: 0 !important; | |
| }} | |
| [data-testid="stChatMessageContent"] h1, | |
| [data-testid="stChatMessageContent"] h2, | |
| [data-testid="stChatMessageContent"] h3, | |
| [data-testid="stChatMessageContent"] h4 {{ | |
| color: #FFFFFF !important; | |
| }} | |
| /* "Next step:" callout — only when the agent ends with a real action */ | |
| [data-testid="stChatMessageContent"] p:has(> strong:first-child:only-child), | |
| [data-testid="stChatMessageContent"] p:has(> strong:first-child) {{ | |
| /* base paragraph style preserved */ | |
| }} | |
| .next-step-callout {{ | |
| margin-top: 0.85rem !important; | |
| padding: 0.7rem 0.9rem !important; | |
| background: rgba(25, 195, 125, 0.08) !important; | |
| border-left: 3px solid #19C37D !important; | |
| border-radius: 8px !important; | |
| color: #ECECEC !important; | |
| font-size: 0.92rem !important; | |
| line-height: 1.55 !important; | |
| }} | |
| .next-step-callout strong {{ | |
| color: #19C37D !important; | |
| margin-right: 0.4rem; | |
| }} | |
| [data-testid="stChatMessageContent"] code {{ | |
| background: rgba(255,255,255,0.06) !important; | |
| color: #F8FAFC !important; | |
| padding: 0.1rem 0.35rem; | |
| border-radius: 6px; | |
| font-size: 0.88em; | |
| }} | |
| [data-testid="stMetric"] {{ | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.08); | |
| border-radius: 16px; | |
| padding: 0.95rem 1rem; | |
| min-height: 108px; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); | |
| }} | |
| [data-testid="stMetricLabel"] p {{ | |
| color: rgba(255,255,255,0.64) !important; | |
| font-size: 0.8rem !important; | |
| line-height: 1.2 !important; | |
| }} | |
| [data-testid="stMetricValue"] {{ | |
| color: #F8FAFC !important; | |
| font-size: 1.45rem !important; | |
| line-height: 1.15 !important; | |
| }} | |
| [data-testid="stMetricDelta"] {{ | |
| display: none; | |
| }} | |
| [data-testid="stCaptionContainer"] p {{ | |
| color: rgba(255,255,255,0.58) !important; | |
| font-size: 0.76rem !important; | |
| }} | |
| .stDivider {{ | |
| margin: 0.9rem 0 1rem 0; | |
| }} | |
| /* Static composer panel — solid background, anchored at bottom of column */ | |
| [data-testid="stBottom"], | |
| [data-testid="stBottom"] > div {{ | |
| background: #212121 !important; | |
| background-color: #212121 !important; | |
| background-image: none !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| backdrop-filter: none !important; | |
| -webkit-backdrop-filter: none !important; | |
| }} | |
| [data-testid="stBottom"] {{ | |
| padding: 0 !important; | |
| }} | |
| /* Constrain the bottom composer to the chat column width */ | |
| [data-testid="stBottomBlockContainer"] {{ | |
| max-width: 780px !important; | |
| margin: 0 auto !important; | |
| padding: 0.85rem 1rem 1rem !important; | |
| background: #212121 !important; | |
| }} | |
| /* Composer panel — solid card, NOT transparent */ | |
| [data-testid="stChatInput"], | |
| [data-testid="stChatInput"] > div, | |
| [data-testid="stChatInput"] section, | |
| [data-testid="stChatInput"] form, | |
| [data-testid="stChatInputContainer"] {{ | |
| background: #2F2F2F !important; | |
| background-color: #2F2F2F !important; | |
| background-image: none !important; | |
| border: 1px solid #3A3A3A !important; | |
| border-radius: 24px !important; | |
| box-shadow: none !important; | |
| backdrop-filter: none !important; | |
| -webkit-backdrop-filter: none !important; | |
| }} | |
| [data-testid="stChatInput"] {{ | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| }} | |
| /* The textarea sits flush inside the panel — no double border */ | |
| [data-testid="stChatInput"] textarea, | |
| [data-testid="stChatInputTextArea"] {{ | |
| background: transparent !important; | |
| border: none !important; | |
| border-radius: 24px !important; | |
| color: #ECECEC !important; | |
| font-size: 1rem !important; | |
| line-height: 1.55 !important; | |
| min-height: 52px !important; | |
| padding: 0.95rem 3rem 0.95rem 1.15rem !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| resize: none !important; | |
| }} | |
| [data-testid="stChatInput"]:hover {{ | |
| border-color: #4A4A4A !important; | |
| }} | |
| [data-testid="stChatInput"]:focus-within {{ | |
| border-color: #5A5A5A !important; | |
| }} | |
| [data-testid="stChatInput"] textarea:focus, | |
| [data-testid="stChatInput"] textarea:focus-visible {{ | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| }} | |
| [data-testid="stChatInput"] textarea::placeholder {{ | |
| color: rgba(236,236,236,0.45) !important; | |
| }} | |
| /* Send button — flat white circle inside the input pill */ | |
| [data-testid="stChatInput"] button, | |
| [data-testid="stChatInputSubmitButton"] {{ | |
| background: #ECECEC !important; | |
| color: #1F1F1F !important; | |
| border-radius: 50% !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| width: 30px !important; | |
| height: 30px !important; | |
| min-width: 30px !important; | |
| min-height: 30px !important; | |
| margin: 0.55rem !important; | |
| }} | |
| [data-testid="stChatInput"] button:hover {{ | |
| background: #FFFFFF !important; | |
| box-shadow: none !important; | |
| }} | |
| [data-testid="stChatInput"] button:disabled {{ | |
| background: #3A3A3A !important; | |
| color: rgba(236,236,236,0.4) !important; | |
| }} | |
| [data-testid="stChatInput"] button svg {{ | |
| fill: #1F1F1F !important; | |
| color: #1F1F1F !important; | |
| }} | |
| /* Form inputs */ | |
| .stTextInput label, .stTextArea label, .stSelectbox label, .stFileUploader label {{ | |
| color: #E5E7EB !important; | |
| font-weight: 700; | |
| font-size: 0.86rem !important; | |
| }} | |
| .stTextInput input, | |
| .stTextArea textarea, | |
| .stSelectbox div[data-baseweb="select"] > div, | |
| .stSelectbox input {{ | |
| background: #111827 !important; | |
| color: #F8FAFC !important; | |
| border: 1px solid #334155 !important; | |
| border-radius: 14px !important; | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); | |
| }} | |
| .stTextInput input, | |
| .stSelectbox div[data-baseweb="select"] > div {{ | |
| min-height: 3rem; | |
| }} | |
| .stTextArea textarea {{ | |
| min-height: 150px; | |
| line-height: 1.6; | |
| }} | |
| .stTextInput input::placeholder, | |
| .stTextArea textarea::placeholder {{ | |
| color: #94A3B8 !important; | |
| opacity: 1 !important; | |
| }} | |
| .stTextInput input:focus, | |
| .stTextArea textarea:focus, | |
| .stSelectbox div[data-baseweb="select"] > div:focus-within {{ | |
| border-color: #10B981 !important; | |
| box-shadow: 0 0 0 1px #10B981, 0 0 0 4px rgba(16, 185, 129, 0.12) !important; | |
| outline: none !important; | |
| }} | |
| .stSelectbox svg {{ fill: #CBD5E1 !important; }} | |
| div[data-baseweb="menu"] {{ | |
| background: #111827 !important; | |
| border: 1px solid #334155 !important; | |
| border-radius: 14px !important; | |
| }} | |
| div[data-baseweb="menu"] [role="option"] {{ background: transparent !important; }} | |
| div[data-baseweb="menu"] [role="option"]:hover {{ background: rgba(16, 185, 129, 0.14) !important; }} | |
| div[data-baseweb="menu"] * {{ color: #F8FAFC !important; }} | |
| [data-testid="stFileUploader"] {{ | |
| background: #1F2937; | |
| border: 1px solid rgba(148, 163, 184, 0.3); | |
| border-radius: 18px; | |
| padding: 0.75rem; | |
| }} | |
| [data-testid="stFileUploaderDropzone"] {{ | |
| background: #111827 !important; | |
| border: 1px dashed rgba(148, 163, 184, 0.45) !important; | |
| border-radius: 16px !important; | |
| color: #F8FAFC !important; | |
| }} | |
| [data-testid="stFileUploaderDropzone"]:hover {{ | |
| border-color: #10B981 !important; | |
| background: rgba(16, 185, 129, 0.06) !important; | |
| }} | |
| [data-testid="stFileUploaderDropzone"] * {{ color: #CBD5E1 !important; }} | |
| [data-testid="stFileUploader"] button {{ | |
| background: linear-gradient(135deg, #1D9E75 0%, #10B981 100%) !important; | |
| border: 1px solid rgba(16, 185, 129, 0.55) !important; | |
| color: #FFFFFF !important; | |
| border-radius: 10px !important; | |
| font-weight: 700 !important; | |
| box-shadow: none !important; | |
| }} | |
| [data-testid="stFileUploader"] button:hover {{ | |
| background: linear-gradient(135deg, #178762 0%, #0EA271 100%) !important; | |
| border-color: rgba(16, 185, 129, 0.72) !important; | |
| color: #FFFFFF !important; | |
| }} | |
| .stTabs [data-baseweb="tab-list"] {{ | |
| gap: 0.6rem; | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid rgba(255,255,255,0.09); | |
| border-radius: 20px; | |
| padding: 0.45rem 0.5rem; | |
| }} | |
| .stTabs [data-baseweb="tab"] {{ | |
| background: transparent; | |
| border-radius: 14px; | |
| color: rgba(255,255,255,0.55); | |
| min-height: 2.9rem; | |
| padding: 0 1.2rem; | |
| font-weight: 700; | |
| font-size: 0.92rem; | |
| letter-spacing: 0.01em; | |
| border: 1px solid transparent; | |
| transition: all 0.18s ease; | |
| }} | |
| .stTabs [data-baseweb="tab"]:hover {{ | |
| background: rgba(255,255,255,0.07); | |
| color: rgba(255,255,255,0.85); | |
| border-color: rgba(255,255,255,0.10); | |
| }} | |
| .stTabs [aria-selected="true"] {{ | |
| background: linear-gradient(135deg, #1E3A5F 0%, #0E4D3A 100%) !important; | |
| color: #FFFFFF !important; | |
| border-color: rgba(16, 185, 129, 0.35) !important; | |
| box-shadow: 0 4px 14px rgba(16, 185, 129, 0.18), inset 0 1px 0 rgba(255,255,255,0.10); | |
| }} | |
| .stButton > button, .stDownloadButton > button, .stFormSubmitButton > button {{ | |
| border-radius: 999px; | |
| min-height: 2.35rem; | |
| font-weight: 600; | |
| border: none; | |
| transition: all 0.15s ease; | |
| }} | |
| .stButton > button {{ | |
| background: rgba(255,255,255,0.06); | |
| color: #CBD5E1; | |
| border: 1px solid rgba(255,255,255,0.12); | |
| font-size: 0.88rem; | |
| padding: 0.35rem 1.1rem; | |
| }} | |
| .stButton > button:hover {{ | |
| background: rgba(255,255,255,0.12); | |
| border-color: rgba(16,185,129,0.45); | |
| color: #FFFFFF; | |
| }} | |
| .duka-ai-example-pill-btn, | |
| .duka-ai-number-pill-btn, | |
| .duka-ai-suggestion-pill-btn {{ | |
| background: transparent !important; | |
| border: 0.5px solid rgba(148, 163, 184, 0.55) !important; | |
| color: #E2E8F0 !important; | |
| box-shadow: none !important; | |
| }} | |
| .duka-ai-example-pill-btn:hover, | |
| .duka-ai-number-pill-btn:hover, | |
| .duka-ai-suggestion-pill-btn:hover {{ | |
| background: rgba(255,255,255,0.08) !important; | |
| border-color: rgba(191, 219, 254, 0.85) !important; | |
| color: #FFFFFF !important; | |
| }} | |
| .duka-ai-example-pill-btn.is-selected, | |
| .duka-ai-number-pill-btn.is-selected {{ | |
| background: rgba(37, 99, 235, 0.16) !important; | |
| border-color: rgba(96, 165, 250, 0.78) !important; | |
| color: #FFFFFF !important; | |
| }} | |
| .duka-ai-example-pill-btn {{ | |
| min-height: 2.3rem !important; | |
| font-size: 0.86rem !important; | |
| padding: 0.3rem 0.92rem !important; | |
| }} | |
| .duka-ai-number-pill-btn {{ | |
| min-height: 2.15rem !important; | |
| font-size: 0.8rem !important; | |
| padding: 0.26rem 0.85rem !important; | |
| }} | |
| .duka-ai-suggestion-pill-btn {{ | |
| width: 100% !important; | |
| min-height: 40px !important; | |
| height: 40px !important; | |
| font-size: 12.5px !important; | |
| font-weight: 600 !important; | |
| padding: 0.25rem 0.85rem !important; | |
| background: rgba(255,255,255,0.05) !important; | |
| border-radius: 12px !important; | |
| white-space: nowrap !important; | |
| overflow: hidden !important; | |
| text-overflow: ellipsis !important; | |
| display: inline-flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| }} | |
| .duka-ai-analyze-btn {{ | |
| min-height: 4.1rem !important; | |
| font-size: 1.14rem !important; | |
| font-weight: 800 !important; | |
| padding: 0.95rem 1.25rem !important; | |
| box-shadow: 0 14px 30px rgba(16, 185, 129, 0.24) !important; | |
| }} | |
| .stButton > button[kind="primary"], .stFormSubmitButton > button {{ | |
| background: linear-gradient(135deg, {THEME["blue"]} 0%, {THEME["emerald"]} 100%); | |
| color: #FFFFFF; | |
| box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22); | |
| min-height: 3.5rem; | |
| font-size: 1.08rem; | |
| letter-spacing: 0.02em; | |
| border-radius: 999px; | |
| }} | |
| .stDownloadButton > button {{ | |
| background: rgba(255,255,255,0.07); | |
| color: #E2E8F0; | |
| border: 1px solid rgba(255,255,255,0.14); | |
| font-size: 0.84rem; | |
| }} | |
| .stExpander {{ | |
| border: 1px solid rgba(148, 163, 184, 0.18) !important; | |
| border-radius: 16px !important; | |
| background: rgba(15, 20, 32, 0.55) !important; | |
| box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); | |
| }} | |
| .stExpander summary {{ | |
| color: #E5E7EB !important; | |
| font-weight: 700 !important; | |
| padding: 0.85rem 1rem !important; | |
| }} | |
| .stExpander summary:hover {{ | |
| color: #FFFFFF !important; | |
| background: rgba(255,255,255,0.03) !important; | |
| border-radius: 14px !important; | |
| }} | |
| .stExpander [data-testid="stExpanderDetails"] {{ | |
| background: rgba(10, 14, 22, 0.55) !important; | |
| border-top: 1px solid rgba(148, 163, 184, 0.14) !important; | |
| padding: 1rem 1rem 1.1rem !important; | |
| border-radius: 0 0 14px 14px !important; | |
| }} | |
| [data-testid="stPlotlyChart"] {{ | |
| background: transparent !important; | |
| border-radius: 12px; | |
| }} | |
| [data-testid="stPlotlyChart"] > div {{ | |
| background: transparent !important; | |
| border-radius: 12px; | |
| }} | |
| [data-testid="stStatusWidget"] {{ | |
| background: rgba(15,23,42,0.85) !important; | |
| border: 1px solid rgba(255,255,255,0.12) !important; | |
| border-radius: 16px !important; | |
| }} | |
| [data-testid="stChatMessage"] {{ | |
| background: transparent !important; | |
| box-shadow: none !important; | |
| padding-top: 0 !important; | |
| padding-bottom: 0 !important; | |
| }} | |
| [data-testid="stChatMessageContent"] {{ | |
| background: transparent !important; | |
| padding: 0 !important; | |
| }} | |
| .welcome-hero {{ | |
| text-align: center; | |
| padding: 3.5rem 1rem 2.25rem; | |
| }} | |
| .welcome-brand {{ | |
| font-size: 3.8rem; | |
| font-weight: 900; | |
| line-height: 1; | |
| background: linear-gradient(135deg, #FFFFFF 0%, #A7F3D0 45%, #60A5FA 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| margin-bottom: 0.55rem; | |
| }} | |
| .welcome-tagline {{ | |
| color: rgba(255,255,255,0.92); | |
| font-size: 1.45rem; | |
| font-weight: 700; | |
| margin-bottom: 0.75rem; | |
| }} | |
| .welcome-desc {{ | |
| color: rgba(255,255,255,0.62); | |
| font-size: 1rem; | |
| max-width: 560px; | |
| margin: 0 auto; | |
| line-height: 1.65; | |
| }} | |
| .welcome-option-card {{ | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.12); | |
| border-radius: 22px; | |
| padding: 1.5rem 1.2rem 1.2rem; | |
| text-align: center; | |
| margin-bottom: 0; | |
| min-height: 200px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: flex-start; | |
| box-sizing: border-box; | |
| transition: border-color 0.18s ease, background 0.18s ease; | |
| }} | |
| .welcome-option-card:hover {{ | |
| border-color: rgba(16,185,129,0.35); | |
| background: rgba(255,255,255,0.09); | |
| }} | |
| .woc-icon {{ | |
| font-size: 2.2rem; | |
| margin-bottom: 0.65rem; | |
| flex-shrink: 0; | |
| }} | |
| .woc-title {{ | |
| color: #FFFFFF; | |
| font-size: 1.02rem; | |
| font-weight: 800; | |
| margin-bottom: 0.4rem; | |
| flex-shrink: 0; | |
| }} | |
| .woc-desc {{ | |
| color: rgba(255,255,255,0.62); | |
| font-size: 0.82rem; | |
| line-height: 1.55; | |
| flex: 1 1 auto; | |
| min-height: 4.6em; | |
| }} | |
| .woc-badge {{ | |
| display: inline-block; | |
| background: linear-gradient(135deg, #F59E0B, #EAB308); | |
| color: #1F2937; | |
| font-size: 0.65rem; | |
| font-weight: 800; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| padding: 0.18rem 0.55rem; | |
| border-radius: 999px; | |
| margin-bottom: 0.6rem; | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.18); | |
| }} | |
| /* Welcome triad: equal column height, buttons on one baseline */ | |
| div[data-testid="stHorizontalBlock"]:has(.welcome-option-card) {{ | |
| align-items: stretch !important; | |
| }} | |
| div[data-testid="column"]:has(.welcome-option-card) {{ | |
| display: flex !important; | |
| flex-direction: column !important; | |
| align-items: stretch !important; | |
| }} | |
| div[data-testid="column"]:has(.welcome-option-card) > *:first-child {{ | |
| flex: 1 1 auto !important; | |
| }} | |
| div[data-testid="column"]:has(.welcome-option-card) > *:last-child {{ | |
| margin-top: auto !important; | |
| width: 100% !important; | |
| padding-top: 0.65rem !important; | |
| }} | |
| div[data-testid="column"]:has(.welcome-option-card) .stButton > button {{ | |
| width: 100% !important; | |
| min-height: 2.85rem !important; | |
| padding-top: 0.45rem !important; | |
| padding-bottom: 0.45rem !important; | |
| }} | |
| @media (max-width: 900px) {{ | |
| .summary-panel, .advisor-grid {{ | |
| flex-direction: column; | |
| grid-template-columns: 1fr; | |
| }} | |
| .summary-statuses, .source-list {{ width: 100%; }} | |
| .preview-grid {{ grid-template-columns: repeat(2, minmax(0, 1fr)); }} | |
| .hero-title {{ font-size: 2.1rem; }} | |
| }} | |
| @media (max-width: 400px) {{ | |
| .welcome-brand {{ font-size: 2.4rem !important; }} | |
| .welcome-tagline {{ font-size: 1.1rem !important; }} | |
| .welcome-desc {{ font-size: 0.82rem !important; }} | |
| .welcome-option-card {{ min-height: 11rem; padding: 1rem 0.9rem 0.85rem; }} | |
| .hero-title {{ font-size: 1.5rem !important; }} | |
| .hero-subtitle {{ font-size: 0.82rem !important; }} | |
| .block-container {{ padding-left: 0.5rem !important; padding-right: 0.5rem !important; }} | |
| .preview-grid {{ grid-template-columns: 1fr !important; }} | |
| .metric-card {{ padding: 0.65rem 0.7rem !important; }} | |
| .summary-panel {{ padding: 0.75rem 0.8rem !important; }} | |
| .loan-card, .agent-card, .mini-card, .chat-card, .visual-card {{ padding: 0.75rem 0.8rem !important; }} | |
| .page-title {{ font-size: 1.2rem !important; }} | |
| .page-subtitle {{ font-size: 0.78rem !important; }} | |
| .analysis-headline {{ font-size: 0.95rem !important; }} | |
| .loan-score {{ font-size: 1.5rem !important; }} | |
| .summary-statuses {{ flex-direction: column !important; gap: 0.4rem !important; }} | |
| .woc-title {{ font-size: 0.9rem !important; }} | |
| }} | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ── Polish CSS: fonts, cards, button hover, scrollbar, footer ──────────── | |
| st.markdown( | |
| """ | |
| <style> | |
| html, body, [class*="css"] { | |
| font-family: 'Inter', 'Arial', sans-serif; | |
| } | |
| [data-testid="stSidebar"] { | |
| background-color: #0D1117 !important; | |
| border-right: 1px solid #1A3A5C; | |
| } | |
| .sidebar-section-label { | |
| font-size: 10px; | |
| font-weight: 600; | |
| letter-spacing: 1.5px; | |
| color: #4A5568; | |
| text-transform: uppercase; | |
| padding: 12px 12px 4px; | |
| display: block; | |
| } | |
| .hero-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: rgba(29,158,117,0.15); | |
| border: 1px solid rgba(29,158,117,0.3); | |
| color: #1D9E75; | |
| font-size: 12px; | |
| font-weight: 500; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| margin-bottom: 8px; | |
| } | |
| .trust-item { | |
| text-align: center; | |
| padding: 12px 8px; | |
| color: #6B7280; | |
| font-size: 12px; | |
| } | |
| .trust-item span { | |
| display: block; | |
| font-size: 18px; | |
| margin-bottom: 4px; | |
| } | |
| .sample-card-featured { | |
| border: 1.5px solid #1D9E75 !important; | |
| border-radius: 12px; | |
| padding: 16px; | |
| background: rgba(29,158,117,0.05); | |
| position: relative; | |
| margin-bottom: 8px; | |
| } | |
| .sample-card-normal { | |
| border: 1px solid #2D3748; | |
| border-radius: 12px; | |
| padding: 16px; | |
| background: rgba(255,255,255,0.02); | |
| margin-bottom: 8px; | |
| } | |
| .recommended-badge { | |
| display: inline-block; | |
| background: #1D9E75; | |
| color: white; | |
| font-size: 10px; | |
| font-weight: 700; | |
| padding: 2px 8px; | |
| border-radius: 10px; | |
| margin-bottom: 8px; | |
| letter-spacing: 0.5px; | |
| text-transform: uppercase; | |
| } | |
| .card-icon { | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 8px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 16px; | |
| margin-bottom: 8px; | |
| } | |
| .stButton > button { | |
| border-radius: 8px !important; | |
| font-size: 13px !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| .stButton > button:hover { | |
| transform: translateY(-1px) !important; | |
| box-shadow: 0 4px 12px rgba(29,158,117,0.3) !important; | |
| } | |
| div[data-testid="stButton"] > button[kind="primary"] { | |
| background: linear-gradient(135deg, #1D9E75, #16836A) !important; | |
| border: none !important; | |
| font-size: 15px !important; | |
| font-weight: 600 !important; | |
| padding: 14px 24px !important; | |
| border-radius: 10px !important; | |
| letter-spacing: 0.3px !important; | |
| box-shadow: 0 4px 15px rgba(29,158,117,0.4) !important; | |
| } | |
| div[data-testid="stButton"] > button[kind="primary"]:hover { | |
| background: linear-gradient(135deg, #22B584, #1D9E75) !important; | |
| box-shadow: 0 6px 20px rgba(29,158,117,0.5) !important; | |
| transform: translateY(-2px) !important; | |
| } | |
| h1, h2, h3 { | |
| text-transform: none !important; | |
| letter-spacing: -0.3px; | |
| } | |
| [data-testid="stChatInput"] { | |
| border-radius: 12px !important; | |
| } | |
| [data-testid="metric-container"] { | |
| border-radius: 10px !important; | |
| padding: 12px !important; | |
| } | |
| footer {visibility: hidden;} | |
| #MainMenu {visibility: hidden;} | |
| ::-webkit-scrollbar { width: 4px; } | |
| ::-webkit-scrollbar-track { background: #0D1117; } | |
| ::-webkit-scrollbar-thumb { background: #1A3A5C; border-radius: 4px; } | |
| /* ═══════════════════════════════════════════════════════ | |
| DUKA AI — BRANDED LOADER SYSTEM | |
| ═══════════════════════════════════════════════════════ */ | |
| /* ── Keyframe animations ──────────────────────────────── */ | |
| @keyframes duka-spin {{ | |
| 0% {{ transform: rotate(0deg); }} | |
| 100% {{ transform: rotate(360deg); }} | |
| }} | |
| @keyframes duka-pulse-dot {{ | |
| 0%, 80%, 100% {{ transform: scale(0.55); opacity: 0.25; }} | |
| 40% {{ transform: scale(1.0); opacity: 1.0; }} | |
| }} | |
| @keyframes duka-shimmer {{ | |
| 0% {{ background-position: -400px 0; }} | |
| 100% {{ background-position: 400px 0; }} | |
| }} | |
| @keyframes duka-fade-up {{ | |
| from {{ opacity: 0; transform: translateY(10px); }} | |
| to {{ opacity: 1; transform: translateY(0); }} | |
| }} | |
| @keyframes duka-glow-border {{ | |
| 0%, 100% {{ border-color: rgba(29,158,117,0.25); box-shadow: 0 0 0 0 rgba(29,158,117,0); }} | |
| 50% {{ border-color: rgba(29,158,117,0.55); box-shadow: 0 0 14px 2px rgba(29,158,117,0.15); }} | |
| }} | |
| /* ── Branded loader card ─────────────────────────────── */ | |
| .duka-loader {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 18px; | |
| background: linear-gradient(135deg, #0D1B2E 0%, #162035 100%); | |
| border: 1px solid rgba(29,158,117,0.3); | |
| border-radius: 16px; | |
| padding: 18px 22px; | |
| margin: 10px 0; | |
| animation: duka-fade-up 0.28s ease-out, duka-glow-border 2.4s ease-in-out infinite; | |
| box-shadow: 0 6px 28px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.04); | |
| }} | |
| /* Spinning ring indicator */ | |
| .duka-ring {{ | |
| width: 38px; | |
| height: 38px; | |
| border: 3px solid rgba(29,158,117,0.18); | |
| border-top: 3px solid #1D9E75; | |
| border-right: 3px solid rgba(29,158,117,0.55); | |
| border-radius: 50%; | |
| animation: duka-spin 0.75s linear infinite; | |
| flex-shrink: 0; | |
| }} | |
| /* Bouncing dots indicator */ | |
| .duka-dots {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| flex-shrink: 0; | |
| }} | |
| .duka-dots span {{ | |
| width: 9px; | |
| height: 9px; | |
| background: #1D9E75; | |
| border-radius: 50%; | |
| animation: duka-pulse-dot 1.35s ease-in-out infinite; | |
| }} | |
| .duka-dots span:nth-child(1) {{ animation-delay: 0s; }} | |
| .duka-dots span:nth-child(2) {{ animation-delay: 0.18s; }} | |
| .duka-dots span:nth-child(3) {{ animation-delay: 0.36s; }} | |
| /* Loader text */ | |
| .duka-loader-body {{ flex: 1; min-width: 0; }} | |
| .duka-loader-title {{ | |
| font-size: 13.5px; | |
| font-weight: 700; | |
| color: #E2E8F0; | |
| margin-bottom: 3px; | |
| letter-spacing: 0.015em; | |
| }} | |
| .duka-loader-sub {{ | |
| font-size: 11.5px; | |
| color: rgba(148,163,184,0.75); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| }} | |
| /* Brand accent on loader */ | |
| .duka-loader-badge {{ | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: #1D9E75; | |
| opacity: 0.85; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| }} | |
| /* ── Skeleton shimmer placeholders ──────────────────── */ | |
| .duka-skeleton {{ | |
| background: linear-gradient( | |
| 90deg, | |
| rgba(26,39,68,0) 0%, | |
| rgba(55,90,140,0.35) 40%, | |
| rgba(26,39,68,0) 80% | |
| ); | |
| background-size: 400px 100%; | |
| animation: duka-shimmer 1.5s ease-in-out infinite; | |
| border-radius: 6px; | |
| }} | |
| .duka-skeleton-line {{ height: 13px; margin: 6px 0; }} | |
| .duka-skeleton-line.wide {{ width: 85%; }} | |
| .duka-skeleton-line.med {{ width: 60%; }} | |
| .duka-skeleton-line.short {{ width: 40%; }} | |
| /* ── Theme Streamlit's native st.spinner ─────────────── */ | |
| [data-testid="stSpinner"] > div {{ | |
| background: linear-gradient(135deg, #0D1B2E 0%, #162035 100%) !important; | |
| border: 1px solid rgba(29,158,117,0.28) !important; | |
| border-radius: 14px !important; | |
| padding: 14px 20px !important; | |
| box-shadow: 0 4px 22px rgba(0,0,0,0.4) !important; | |
| animation: duka-glow-border 2.4s ease-in-out infinite !important; | |
| }} | |
| [data-testid="stSpinner"] p {{ | |
| color: #94A3B8 !important; | |
| font-size: 13px !important; | |
| font-weight: 500 !important; | |
| }} | |
| /* Colour the SVG spinner icon teal */ | |
| [data-testid="stSpinner"] svg {{ | |
| color: #1D9E75 !important; | |
| }} | |
| /* ── Theme Streamlit's native st.status ──────────────── */ | |
| [data-testid="stStatusWidget"] {{ | |
| background: linear-gradient(135deg, #0D1B2E 0%, #162035 100%) !important; | |
| border: 1px solid rgba(29,158,117,0.28) !important; | |
| border-radius: 14px !important; | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.4) !important; | |
| }} | |
| [data-testid="stStatusWidget"] summary {{ | |
| color: #E2E8F0 !important; | |
| font-weight: 600 !important; | |
| }} | |
| [data-testid="stStatusWidget"] p {{ | |
| color: #CBD5E1 !important; | |
| font-size: 13.5px !important; | |
| font-weight: 500 !important; | |
| line-height: 1.45 !important; | |
| }} | |
| </style> | |
| """, | |
| 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( | |
| """ | |
| <script> | |
| (function() { | |
| var win = window.parent; | |
| var doc = win.document; | |
| function pinToQuestion() { | |
| var nodes = doc.querySelectorAll('.user-bubble-wrap'); | |
| if (!nodes.length) return; | |
| var el = nodes[nodes.length - 1]; | |
| try { | |
| el.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' }); | |
| } catch (e) { | |
| try { el.scrollIntoView(true); } catch (e2) {} | |
| } | |
| } | |
| var cancelled = false; | |
| ['wheel', 'touchmove', 'keydown', 'mousedown'].forEach(function(ev) { | |
| win.addEventListener(ev, function() { cancelled = true; }, { passive: true, once: true }); | |
| }); | |
| [0, 80, 220, 480, 800, 1300].forEach(function(d) { | |
| win.setTimeout(function() { if (!cancelled) pinToQuestion(); }, d); | |
| }); | |
| })(); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| def inject_navigation_loader() -> None: | |
| """Full-screen loader when switching sidebar pages (injected into parent document).""" | |
| components.html( | |
| """ | |
| <script> | |
| (function () { | |
| var root; | |
| try { | |
| root = window.parent; | |
| if (!root || !root.document) { | |
| return; | |
| } | |
| } catch (e) { | |
| return; | |
| } | |
| var doc = root.document; | |
| // Versioned flag — bump suffix when loader markup/CSS changes so | |
| // existing sessions pick up the new design instead of being stuck | |
| // on the previous one cached on window.parent. | |
| var LOADER_VERSION = "duka-nav-loader-v3"; | |
| if (root.__dukaNavLoaderVersion === LOADER_VERSION) { | |
| return; | |
| } | |
| // Old version present — purge stale overlay/style before re-init. | |
| try { | |
| var staleOverlay = doc.getElementById("duka-nav-loader-overlay"); | |
| if (staleOverlay && staleOverlay.parentNode) { | |
| staleOverlay.parentNode.removeChild(staleOverlay); | |
| } | |
| var staleStyle = doc.getElementById("duka-nav-loader-style"); | |
| if (staleStyle && staleStyle.parentNode) { | |
| staleStyle.parentNode.removeChild(staleStyle); | |
| } | |
| } catch (e) {} | |
| root.__dukaNavLoaderVersion = LOADER_VERSION; | |
| var css = doc.createElement("style"); | |
| css.id = "duka-nav-loader-style"; | |
| css.textContent = | |
| "#duka-nav-loader-overlay{" + | |
| "display:none;position:fixed;inset:0;z-index:2147483646;" + | |
| "background:radial-gradient(circle at 50% 45%, rgba(29,158,117,0.18) 0%, rgba(13,17,23,0.92) 60%);" + | |
| "backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);" + | |
| "flex-direction:column;align-items:center;justify-content:center;gap:1.1rem;" + | |
| "pointer-events:none;animation:dukaNavFadeIn 0.18s ease-out;" + | |
| "}" + | |
| "@keyframes dukaNavFadeIn{from{opacity:0;}to{opacity:1;}}" + | |
| "#duka-nav-loader-overlay.duka-nav-visible{display:flex !important;}" + | |
| ".duka-nav-loader-card{display:flex;flex-direction:column;align-items:center;" + | |
| "gap:0.85rem;padding:1.6rem 2.4rem 1.5rem;border-radius:22px;" + | |
| "background:rgba(15,23,42,0.7);border:1px solid rgba(29,158,117,0.32);" + | |
| "box-shadow:0 20px 50px rgba(0,0,0,0.45),inset 0 1px 0 rgba(255,255,255,0.04);}" + | |
| ".duka-nav-loader-orb{position:relative;width:64px;height:64px;}" + | |
| ".duka-nav-loader-orb::before,.duka-nav-loader-orb::after{" + | |
| "content:'';position:absolute;inset:0;border-radius:50%;" + | |
| "border:3px solid transparent;}" + | |
| ".duka-nav-loader-orb::before{" + | |
| "border-top-color:#1D9E75;border-right-color:rgba(29,158,117,0.4);" + | |
| "animation:dukaNavSpin 0.95s cubic-bezier(0.5,0,0.5,1) infinite;}" + | |
| ".duka-nav-loader-orb::after{" + | |
| "border-bottom-color:#378ADD;border-left-color:rgba(55,138,221,0.35);" + | |
| "animation:dukaNavSpin 1.4s cubic-bezier(0.5,0,0.5,1) infinite reverse;" + | |
| "inset:8px;}" + | |
| ".duka-nav-loader-mark{position:absolute;inset:0;display:flex;" + | |
| "align-items:center;justify-content:center;font-size:1.4rem;" + | |
| "background:linear-gradient(135deg,#1D9E75,#378ADD);" + | |
| "-webkit-background-clip:text;-webkit-text-fill-color:transparent;" + | |
| "background-clip:text;font-weight:900;}" + | |
| "@keyframes dukaNavSpin{to{transform:rotate(360deg);}}" + | |
| ".duka-nav-loader-dots{display:flex;gap:8px;}" + | |
| ".duka-nav-loader-dots span{width:7px;height:7px;border-radius:50%;" + | |
| "background:linear-gradient(135deg,#378ADD,#1D9E75);" + | |
| "animation:dukaNavDot 1.2s ease-in-out infinite;opacity:0.45;" + | |
| "box-shadow:0 0 8px rgba(29,158,117,0.4);}" + | |
| ".duka-nav-loader-dots span:nth-child(2){animation-delay:0.2s;}" + | |
| ".duka-nav-loader-dots span:nth-child(3){animation-delay:0.4s;}" + | |
| "@keyframes dukaNavDot{" + | |
| "0%,100%{opacity:0.35;transform:scale(0.85);}" + | |
| "50%{opacity:1;transform:scale(1.1);}" + | |
| "}" + | |
| ".duka-nav-loader-label{font-size:0.72rem;font-weight:800;" + | |
| "letter-spacing:0.24em;text-transform:uppercase;" + | |
| "background:linear-gradient(90deg,#A7F3D0,#FFFFFF,#A7F3D0);" + | |
| "-webkit-background-clip:text;-webkit-text-fill-color:transparent;" + | |
| "background-clip:text;background-size:200% 100%;" + | |
| "animation:dukaNavShine 2.4s linear infinite;}" + | |
| "@keyframes dukaNavShine{from{background-position:200% 0;}to{background-position:-200% 0;}}" + | |
| ".duka-nav-loader-sub{font-size:0.78rem;color:rgba(148,163,184,0.92);" + | |
| "font-weight:500;margin-top:-0.1rem;letter-spacing:0.02em;}"; | |
| doc.head.appendChild(css); | |
| var overlay = doc.getElementById("duka-nav-loader-overlay"); | |
| if (!overlay) { | |
| overlay = doc.createElement("div"); | |
| overlay.id = "duka-nav-loader-overlay"; | |
| overlay.setAttribute("aria-live", "polite"); | |
| overlay.innerHTML = | |
| '<div class="duka-nav-loader-card">' + | |
| '<div class="duka-nav-loader-orb">' + | |
| '<div class="duka-nav-loader-mark">D</div>' + | |
| '</div>' + | |
| '<div class="duka-nav-loader-dots">' + | |
| "<span></span><span></span><span></span></div>" + | |
| '<div class="duka-nav-loader-label">Loading workspace</div>' + | |
| '<div class="duka-nav-loader-sub">Preparing your view…</div>' + | |
| '</div>'; | |
| doc.body.appendChild(overlay); | |
| } | |
| var shownAt = 0; | |
| var debounceTimer = null; | |
| function showLoader() { | |
| overlay.classList.add("duka-nav-visible"); | |
| overlay.setAttribute("aria-busy", "true"); | |
| shownAt = Date.now(); | |
| } | |
| function hideLoader() { | |
| overlay.classList.remove("duka-nav-visible"); | |
| overlay.setAttribute("aria-busy", "false"); | |
| } | |
| function scheduleHideAfterNav() { | |
| if (!overlay.classList.contains("duka-nav-visible")) { | |
| return; | |
| } | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(function () { | |
| var elapsed = Date.now() - shownAt; | |
| var extra = elapsed < 320 ? 320 - elapsed : 0; | |
| setTimeout(hideLoader, extra); | |
| }, 160); | |
| } | |
| doc.addEventListener( | |
| "click", | |
| function (ev) { | |
| var btn = ev.target.closest("button"); | |
| if (!btn) { | |
| return; | |
| } | |
| var sb = btn.closest('[data-testid="stSidebar"]'); | |
| if (!sb) { | |
| return; | |
| } | |
| showLoader(); | |
| }, | |
| true | |
| ); | |
| var observeRoot = | |
| doc.querySelector('[data-testid="stAppViewContainer"]') || doc.body; | |
| try { | |
| new MutationObserver(function () { | |
| scheduleHideAfterNav(); | |
| }).observe(observeRoot, { childList: true, subtree: true }); | |
| } catch (e) {} | |
| hideLoader(); | |
| })(); | |
| </script> | |
| """, | |
| 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( | |
| """ | |
| <div style="text-align:center;padding:8px 0 4px;"> | |
| <span class="hero-badge"> | |
| ⚡ AI-powered · 🌎 Built for Africa | |
| </span> | |
| </div> | |
| <div style="text-align:center;padding:16px 0 8px;"> | |
| <h1 style="font-size:2.2rem;font-weight:800;color:#FFFFFF; | |
| margin:0;line-height:1.2;"> | |
| Your <span style="color:#1D9E75;">AI financial advisor</span> | |
| <br>for African businesses | |
| </h1> | |
| <p style="color:#6B7280;font-size:15px;margin:12px auto 0; | |
| max-width:480px;line-height:1.6;"> | |
| Analyze cash flow, loan readiness, and market conditions. | |
| Talk to your finances like a conversation, not a spreadsheet. | |
| </p> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| if st.session_state.pop("scroll_to_demo_analyze", False): | |
| components.html( | |
| """ | |
| <script> | |
| window.setTimeout(function() { | |
| var el = window.parent.document.getElementById("duka-ai-analyze-anchor"); | |
| if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } | |
| }, 120); | |
| </script> | |
| """, | |
| 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""" | |
| <div style="display:flex;align-items:center;justify-content:center; | |
| gap:8px;padding:6px 0;margin-bottom:12px;"> | |
| <div style="width:8px;height:8px;border-radius:50%;background:#1D9E75; | |
| box-shadow:0 0 6px #1D9E75;"></div> | |
| <span style="color:#4A5568;font-size:12px;"> | |
| Powered by Duka AI · {_model_display} | |
| · Talk to your finances like a conversation | |
| </span> | |
| </div> | |
| """, | |
| 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""" | |
| <script> | |
| window.setTimeout(function() {{ | |
| var anchor = window.parent.document.getElementById("{_scroll_id}"); | |
| if (anchor) {{ anchor.scrollIntoView({{ behavior: "smooth", block: "start" }}); }} | |
| }}, 220); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| elif _scroll_numbers: | |
| components.html( | |
| """ | |
| <script> | |
| window.setTimeout(function() { | |
| var anchor = window.parent.document.getElementById("duka-ai-numbers-anchor"); | |
| if (anchor) { anchor.scrollIntoView({ behavior: "smooth", block: "start" }); } | |
| }, 180); | |
| </script> | |
| """, | |
| 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('<div id="duka-ai-form-anchor"></div>', 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('<div style="margin-top:1.5rem;"></div>', 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( | |
| '<div class="analyze-note">Want higher accuracy? Add a document or figures in the section below first.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| if continue_to_data: | |
| components.html( | |
| """ | |
| <script> | |
| window.setTimeout(function() { | |
| var anchor = window.parent.document.getElementById("duka-ai-numbers-anchor"); | |
| if (anchor) { anchor.scrollIntoView({ behavior: "smooth", block: "start" }); } | |
| }, 100); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| st.divider() | |
| if not st.session_state.get("hide_example_prompts"): | |
| st.markdown('<div class="subsection-label">Example prompts</div>', unsafe_allow_html=True) | |
| render_example_prompt_pills(sample_cases) | |
| # ── Trust strip ──────────────────────────────────────────────────────────── | |
| st.markdown("<br>", 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""" | |
| <div class="trust-item"> | |
| <span>{_icon}</span> | |
| <strong style="color:#C9D1D9;font-size:12px;">{_title}</strong> | |
| <br> | |
| <span style="color:#4A5568;font-size:11px;">{_sub}</span> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.divider() | |
| # ── Section 2: Data Sources ──────────────────────────────────────────────── | |
| st.markdown('<div id="duka-ai-numbers-anchor"></div>', 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( | |
| '<div class="numbers-helper">You can switch between options at any time before you analyze.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| if selected_mode == "manual": | |
| st.markdown('<div class="mode-helper">Enter only the figures you know. Even partial numbers make the analysis more precise.</div>', 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('<div class="subsection-label">Upload your business document</div>', unsafe_allow_html=True) | |
| st.markdown('<div class="mode-helper">Select the document type, upload the file, and Duka AI will extract key financial figures.</div>', 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('<div id="duka-ai-upload-summary-anchor"></div>', 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( | |
| """ | |
| <script> | |
| window.setTimeout(function() { | |
| var anchor = window.parent.document.getElementById("duka-ai-upload-summary-anchor"); | |
| if (anchor) { anchor.scrollIntoView({ behavior: "smooth", block: "start" }); } | |
| }, 120); | |
| </script> | |
| """, | |
| 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('<div class="mode-helper">Use one of these starter files if you want a clean format to upload.</div>', unsafe_allow_html=True) | |
| render_download_buttons() | |
| else: | |
| st.markdown( | |
| '<div style="display:inline-flex;align-items:center;gap:0.5rem;background:#7C2D12;border:1px solid #F97316;' | |
| 'border-radius:999px;padding:0.35rem 0.85rem;margin-bottom:0.75rem;">' | |
| '<span style="font-size:0.78rem;font-weight:800;color:#FED7AA;text-transform:uppercase;letter-spacing:0.1em;">' | |
| '⚠ Demo Mode — no real accounts connected</span></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('<div class="subsection-label">Connect a demo account</div>', unsafe_allow_html=True) | |
| st.markdown('<div class="mode-helper">This is a safe demo flow. Pick a provider to preview how synced account data would improve the analysis.</div>', 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('<div id="duka-ai-analyze-anchor"></div>', 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( | |
| '<div class="analyze-note">Runs all agents (cash flow, advisor, loan readiness, market, and summary).</div>', | |
| 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( | |
| """ | |
| <script> | |
| window.setTimeout(function() { | |
| var el = window.parent.document.getElementById("duka-ai-analyze-anchor"); | |
| if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } | |
| }, 80); | |
| </script> | |
| """, | |
| 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( | |
| '<div class="chat-placeholder">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.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| return | |
| # ── Auto-scroll to results after analysis ────────────────────────────────── | |
| st.markdown('<div id="duka-ai-chat-anchor"></div>', unsafe_allow_html=True) | |
| if st.session_state.pop("scroll_to_results", False): | |
| components.html( | |
| """ | |
| <script> | |
| window.setTimeout(function() { | |
| var el = window.parent.document.getElementById("duka-ai-chat-anchor"); | |
| if (el) { | |
| el.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| } | |
| }, 400); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| stage_col, reset_col = st.columns([4, 1], gap="medium") | |
| with stage_col: | |
| st.markdown( | |
| """ | |
| <div class="chat-stage-header"> | |
| <div class="chat-stage-title">Chat with your analysis</div> | |
| <div class="chat-stage-copy">Start with the summary below, tap a suggested question, or type your own follow-up when you're ready.</div> | |
| </div> | |
| """, | |
| 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('<div class="chat-block-gap"></div>', 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'<div class="user-bubble-wrap"><div class="user-bubble">{escaped}</div></div>', | |
| 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'<div class="agent-label">{icon} {label}</div>', | |
| 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('<div class="chat-block-gap"></div>', 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('<div class="chat-block-gap"></div>', 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'<div class="user-bubble-wrap"><div class="user-bubble">{escaped_prompt}</div></div>', | |
| 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( | |
| """ | |
| <script> | |
| (function() { | |
| var win = window.parent; | |
| var doc = win.document; | |
| function pinToQuestion() { | |
| var nodes = doc.querySelectorAll('.user-bubble-wrap'); | |
| if (!nodes.length) return; | |
| var el = nodes[nodes.length - 1]; | |
| try { | |
| el.scrollIntoView({ | |
| behavior: 'auto', | |
| block: 'start', | |
| inline: 'nearest' | |
| }); | |
| } catch (e) { | |
| try { el.scrollIntoView(true); } catch (e2) {} | |
| } | |
| } | |
| // Cancel any further pins as soon as user shows scroll intent | |
| // (wheel, touch, or arrow keys). This guarantees we never | |
| // fight the user's manual scroll. | |
| var cancelled = false; | |
| function cancel() { cancelled = true; } | |
| ['wheel', 'touchmove', 'keydown', 'mousedown'].forEach(function(ev) { | |
| win.addEventListener(ev, cancel, { passive: true, once: true }); | |
| }); | |
| // Hammered instant pins to land us on the question quickly. | |
| [0, 60, 150, 300, 550, 900, 1300].forEach(function(d) { | |
| win.setTimeout(function() { | |
| if (!cancelled) pinToQuestion(); | |
| }, d); | |
| }); | |
| })(); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| with st.chat_message("assistant", avatar=icon): | |
| st.markdown( | |
| f'<div class="agent-label">{icon} {label}</div>', | |
| 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('<div id="duka-ai-chat-scroll-target"></div>', 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( | |
| """ | |
| <script> | |
| (function() { | |
| var win = window.parent; | |
| var doc = win.document; | |
| function pinToLatestQuestion() { | |
| var nodes = doc.querySelectorAll('.user-bubble-wrap'); | |
| if (!nodes.length) return false; | |
| var el = nodes[nodes.length - 1]; | |
| try { | |
| el.scrollIntoView({ | |
| behavior: 'auto', | |
| block: 'start', | |
| inline: 'nearest' | |
| }); | |
| } catch (e) { | |
| try { el.scrollIntoView(true); } catch (e2) {} | |
| } | |
| return true; | |
| } | |
| var cancelled = false; | |
| ['wheel', 'touchmove', 'keydown', 'mousedown'].forEach(function(ev) { | |
| win.addEventListener(ev, function() { cancelled = true; }, { passive: true, once: true }); | |
| }); | |
| [0, 80, 220, 480, 800, 1300].forEach(function(d) { | |
| win.setTimeout(function() { | |
| if (!cancelled) pinToLatestQuestion(); | |
| }, d); | |
| }); | |
| })(); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |