"""Spendif.ai โ€“ Streamlit entrypoint (RF-08). Pages: ๐Ÿ“ฅ Import โ€“ upload + pipeline processing ๐Ÿ“œ Import History โ€“ import timeline with undo ๐Ÿ“‹ Ledger โ€“ filterable transaction table + export ๐Ÿงพ Confirm expense - a receipt in hand: date and amount, then the category โœ๏ธ Bulk Edit โ€“ bulk edits: category, context, deletion, duplicates ๐Ÿ“Š Analytics โ€“ interactive charts (Plotly) ๐Ÿ“‹ Report โ€“ spending by context/category with pivot, trends, Excel export ๐Ÿ’ฐ Budget โ€“ define % budget targets per category ๐Ÿ“Š Budget vs Actual โ€“ compare actual spending vs budget targets ๐Ÿ” Review โ€“ manual review of low-confidence items ๐Ÿ“ Rules โ€“ manage category rules (edit / delete / create) ๐Ÿช Counterparts โ€“ per-vendor stats grid with inline rule creation ๐Ÿ—‚๏ธ Taxonomy โ€“ manage categories and subcategories โš™๏ธ Settings โ€“ locale, language, LLM backend preferences โœ… Checklist โ€“ monthly tx presence per account (pivot table) ๐Ÿ’ฌ Chat โ€“ adaptive support chatbot (RAG cloud/local or FAQ match) """ import os from pathlib import Path from dotenv import load_dotenv # Bundled installs (Linux .deb/.rpm) ship the app under /opt/spendifai which is # read-only โ€” the launcher writes the user-specific .env to ~/.spendifai/.env. # Load that first; load_dotenv() does NOT override existing env vars, so the # desktop launcher's os.environ wins and a cwd-local .env (dev mode) acts as a # fallback for keys neither parent set. load_dotenv(Path.home() / ".spendifai" / ".env") load_dotenv() import sys import streamlit as st from db.models import create_tables, get_engine from support.logging import capture_unhandled_exceptions, setup_logging logger = setup_logging() capture_unhandled_exceptions() logger.info("Starting Spendif.ai") # The server never outlives the desktop launcher (support/parent_watchdog.py): # once per process, a no-op when run without the launcher. from support.parent_watchdog import start as _watch_launcher _watch_launcher() # โ”€โ”€ S-01: Prompt integrity check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ from core.prompt_guard import verify_prompt_integrity _prompt_errors = verify_prompt_integrity() if _prompt_errors: # Shown before i18n is loaded โ€” hardcoded bilingual warning st.error( "**LLM prompts modified from certified version / Prompt LLM modificati rispetto alla versione certificata.**\n\n" + "\n".join(f"- {e}" for e in _prompt_errors) + "\n\nRun / Eseguire: `python tools/compute_prompt_hashes.py`" ) # โ”€โ”€ DB bootstrap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ DB_URL = os.getenv("SPENDIFAI_DB", "sqlite:///ledger.db") engine = create_tables(get_engine(DB_URL)) # Payment intermediaries shipped with the app (spec 20): an update can add some. from services.intermediary_service import sync_seed_once sync_seed_once(engine) # โ”€โ”€ Dev mode (AI-193) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # When SPENDIFAI_DEV_MODE=1 the developer-only pages (e.g. the pipeline # Debugger) become reachable. Never set in production builds. _DEV_MODE = os.getenv("SPENDIFAI_DEV_MODE") == "1" # โ”€โ”€ Startup cleanup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Once per process, not per browser session: a second tab during an import # must not declare that import interrupted. from services.import_service import reset_stale_jobs_once _n_stale = reset_stale_jobs_once(engine) if _n_stale: logger.info(f"startup: reset {_n_stale} stale running job(s) to error") # Installations onboarded before 2026-10-23 analyse on the processor. from services.llm_service import restore_accelerator_layers _layers = restore_accelerator_layers(engine) if _layers: logger.info(f"startup: accelerator layers restored for {', '.join(_layers)}") # โ”€โ”€ Page config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.set_page_config( page_title="Spendif.ai", layout="wide", page_icon="๐Ÿฆ", ) # โ”€โ”€ Compact layout: reduce top margin and padding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ st.markdown(""" """, unsafe_allow_html=True) # โ”€โ”€ First-run experience โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Before the first onboarding the user should see ONE focused page โ€” no # sidebar, no toolbar โ€” that combines the live model-download status and # the configuration wizard. As soon as onboarding is done we drop back to # the normal layout. If a model download is still in flight at that point, # the wizard has already completed and the user opted in to using the rest # of the app (Import is gated separately). from services.settings_service import SettingsService as _SvcCheck _cfg_check = _SvcCheck(engine) # `onboarding_done` is flipped at the end of step 5 (Conferma) so that # closing the app on step 6 (Primo Import) does NOT replay the wizard at # next launch. We keep rendering the wizard while the session is still on # step 6, so the user can finish choosing between "upload now" vs "skip". _in_step6 = st.session_state.get("_ob_step") == 6 if not _cfg_check.is_onboarding_done() or _in_step6: # Hide chrome: full-screen immersive look while onboarding. from ui.widgets.immersive import hide_chrome hide_chrome() # Live download status (banner-only, sits above the wizard). from ui.widgets.model_download_banner import render_model_download_banner render_model_download_banner() from ui.onboarding_page import render_onboarding_page render_onboarding_page(engine) st.stop() # โ”€โ”€ Normal-mode banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Once onboarding is done, the user can roam the app. If the model is still # downloading in the background, show the same live banner at the top of # every page (sidebar visible). Renders nothing when no download is active. from ui.widgets.model_download_banner import render_model_download_banner render_model_download_banner() # โ”€โ”€ i18n: set UI language from user settings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ from ui.i18n import set_language as _set_lang _set_lang(_cfg_check.get_all().get("ui_language", "en")) # โ”€โ”€ Update check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Once per session: record which build is running (update_event table) and ask # GitHub in the background whether a newer release exists. The banner is built # from STORED state, so nothing here waits on the network. The sidebar stays # engine-free, so the answer travels through session_state like the default # page above. if "update_check_done" not in st.session_state: st.session_state["update_check_done"] = True from services.update_service import UpdateService _upd_svc = UpdateService(engine) _upd_svc.record_launch() _upd_svc.start_background_check() st.session_state["update_banner"] = _upd_svc.pending_update() # โ”€โ”€ Sidebar navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Default landing page is data-driven: with at least one movement the app # lands on the Home; on an empty DB on the page that asks how to start. # Computed once here so the sidebar can stay engine-free. from ui.sidebar import render_sidebar if "page" not in st.session_state: from ui.start_page import landing_page st.session_state["page"] = landing_page(engine) page = render_sidebar() # On a Mac, once: would the user like to tell expenses to Siri, even with the # app closed? (spec 32, 4.9). Not over the onboarding nor on an empty registry. if sys.platform == "darwin" and page not in ("start", "onboarding"): from ui.siri_install import render_siri_offer render_siri_offer(engine) # โ”€โ”€ Route โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _route(page: str, engine) -> None: """Render the selected page. A function rather than a bare chain so there is one place to catch what a page raises. Streamlit renders an uncaught exception on screen and does not put it in the application log: on 2026-09-23 the traceback of the broken Chat page existed in exactly one file, the launcher log, and that file is rewritten at every start. The technical report for support collects the application log, so it arrived empty on precisely the defect it was built to describe. """ if page == "home": from ui.home_page import render_home_page render_home_page(engine) elif page == "import": from ui.upload_page import render_upload_page render_upload_page(engine) elif page == "history": from ui.history_page import render_history_page render_history_page(engine) elif page == "ledger": from ui.registry_page import render_registry_page render_registry_page(engine) elif page == "expense_confirm": from ui.expense_confirm_page import render_expense_confirm_page render_expense_confirm_page(engine) elif page == "bulk_edit": from ui.bulk_edit_page import render_bulk_edit_page render_bulk_edit_page(engine) elif page == "analytics": from ui.analysis_page import render_analysis_page render_analysis_page(engine) elif page == "report": from ui.report_page import render_report_page render_report_page(engine) elif page == "mixed": from ui.mixed_page import render_mixed_page render_mixed_page(engine) elif page == "transfers": from ui.transfers_page import render_transfers_page render_transfers_page(engine) elif page == "budget": from ui.budget_page import render_budget_page render_budget_page(engine) elif page == "budget_vs_actual": from ui.budget_vs_actual_page import render_budget_vs_actual_page render_budget_vs_actual_page(engine) elif page == "review": from ui.review_page import render_review_page render_review_page(engine) elif page == "rules": from ui.rules_page import render_rules_page render_rules_page(engine) elif page == "intermediaries": from ui.intermediaries_page import render_intermediaries_page render_intermediaries_page(engine) elif page == "counterparts": from ui.counterparts_page import render_counterparts_page render_counterparts_page(engine) elif page == "income": from ui.counterparts_page import render_income_page render_income_page(engine) elif page == "taxonomy": from ui.taxonomy_page import render_taxonomy_page render_taxonomy_page(engine) elif page == "llm_models": from ui.llm_models_page import render_llm_models_page render_llm_models_page(engine) elif page == "settings": from ui.settings_page import render_settings_page render_settings_page(engine) elif page == "diagnostics": from ui.diagnostics_page import render_diagnostics_page render_diagnostics_page(engine) elif page == "checklist": from ui.checklist_page import render_checklist_page render_checklist_page(engine) elif page == "check": from ui.checking_page import render_checking_page render_checking_page(engine) elif page == "chat": from ui.chat_page import render_chat_page render_chat_page(engine) elif page == "start": from ui.start_page import render_start_page render_start_page(engine) elif page == "guided": # The workflow renders the regular pages through this same router. from ui.guided_page import render_guided_page render_guided_page(engine, _route) elif _DEV_MODE and page == "debugger": from ui.debugger_page import render_debugger_page render_debugger_page(engine) try: _route(page, engine) except Exception: # Logged, then re-raised: Streamlit still shows the error on screen, the # log keeps the only copy that survives the restart. logger.exception("Unhandled exception while rendering page '%s'", page) raise