Ekow24 commited on
Commit
914adef
·
verified ·
1 Parent(s): c78ff46

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +571 -37
src/streamlit_app.py CHANGED
@@ -1,40 +1,574 @@
1
- import altair as alt
2
- import numpy as np
 
 
 
 
 
3
  import pandas as pd
 
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ import os
3
+ import time
4
+ import json
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
  import pandas as pd
9
+ import requests
10
  import streamlit as st
11
 
12
+ # Support running as a module or script
13
+ try:
14
+ from .utils import (
15
+ generate_synthetic_transactions,
16
+ filter_transactions,
17
+ compute_aggregations,
18
+ build_time_series_chart,
19
+ build_category_bar_chart,
20
+ build_payment_method_pie_chart,
21
+ summarize_with_ai,
22
+ )
23
+ except Exception: # ImportError or relative import context issues
24
+ from utils import (
25
+ generate_synthetic_transactions,
26
+ filter_transactions,
27
+ compute_aggregations,
28
+ build_time_series_chart,
29
+ build_category_bar_chart,
30
+ build_payment_method_pie_chart,
31
+ summarize_with_ai,
32
+ )
33
+
34
+
35
+ st.set_page_config(
36
+ page_title="AI Spending Analyser",
37
+ page_icon="💳",
38
+ layout="wide",
39
+ )
40
+
41
+
42
+ def init_session_state():
43
+ if "data" not in st.session_state:
44
+ st.session_state.data = generate_synthetic_transactions(n_rows=900, seed=42)
45
+ if "filters" not in st.session_state:
46
+ min_date = st.session_state.data["Date"].min()
47
+ max_date = st.session_state.data["Date"].max()
48
+ st.session_state.filters = {
49
+ "date_range": (min_date, max_date),
50
+ "categories": [],
51
+ "merchant_query": "",
52
+ }
53
+
54
+
55
+ def render_header():
56
+ """
57
+ Render a header with a blue ^ symbol and app title.
58
+ """
59
+ st.markdown(
60
+ """
61
+ <div style='display: flex; align-items: baseline; gap: 15px; margin-bottom: 20px;'>
62
+ <div style='font-size: 80px; color: #00AEEF; font-weight: bold; line-height: 1;'>^</div>
63
+ <div style='font-size: 36px; color: #697089; font-weight: 500; line-height: 1;'>AI Spending Analyser</div>
64
+ </div>
65
+ """,
66
+ unsafe_allow_html=True,
67
+ )
68
+
69
+
70
+ def render_assistant_banner():
71
+ # Removed per request: no top assistant banner
72
+ return
73
+
74
+
75
+ def render_chat_fab():
76
+ # Removed per request: no floating chat widget
77
+ return
78
+
79
+
80
+ def render_sidebar(df: pd.DataFrame):
81
+ st.sidebar.header("Filters")
82
+ min_d = df["Date"].min()
83
+ max_d = df["Date"].max()
84
+
85
+ # Separate From and To date inputs
86
+ st.sidebar.subheader("Date Range")
87
+ col1, col2 = st.sidebar.columns(2)
88
+
89
+ with col1:
90
+ from_date = st.date_input(
91
+ "From",
92
+ value=min_d.date(),
93
+ min_value=min_d.date(),
94
+ max_value=max_d.date(),
95
+ key="from_date"
96
+ )
97
+
98
+ with col2:
99
+ to_date = st.date_input(
100
+ "To",
101
+ value=max_d.date(),
102
+ min_value=min_d.date(),
103
+ max_value=max_d.date(),
104
+ key="to_date"
105
+ )
106
+
107
+ # Validation for date range
108
+ date_error = None
109
+ if from_date > to_date:
110
+ date_error = "From date cannot be after To date"
111
+ elif from_date < min_d.date() or to_date > max_d.date():
112
+ date_error = f"Date range can only be between {min_d.date().strftime('%Y-%m-%d')} and {max_d.date().strftime('%Y-%m-%d')}"
113
+ elif from_date > max_d.date() or to_date < min_d.date():
114
+ date_error = f"Date range can only be between {min_d.date().strftime('%Y-%m-%d')} and {max_d.date().strftime('%Y-%m-%d')}"
115
+
116
+ if date_error:
117
+ st.sidebar.error(date_error)
118
+ # Use valid defaults when there's an error
119
+ from_date = min_d.date()
120
+ to_date = max_d.date()
121
+
122
+ all_categories = sorted(df["Category"].unique().tolist())
123
+ categories = st.sidebar.multiselect("Category", options=all_categories, default=[])
124
+
125
+ merchant_query = st.sidebar.text_input("Merchant search", value="", placeholder="Type a merchant name…")
126
+
127
+ st.sidebar.divider()
128
+ st.sidebar.header("AI")
129
+ # Default engine is now HuggingFace (not heuristic)
130
+ summary_mode = st.sidebar.radio("Summary", options=["Concise", "Detailed"], index=0, horizontal=True)
131
+ engine = st.sidebar.selectbox("Engine", options=["HuggingFace", "OpenAI", "Heuristic"], index=0)
132
+ ollama_model = None
133
+
134
+ st.sidebar.divider()
135
+ st.sidebar.header("Anomalies & Highlights")
136
+ show_spikes = st.sidebar.toggle("Show spike markers", value=True)
137
+ large_tx_threshold = st.sidebar.slider("Large transaction threshold (£)", 50, 1000, 250, step=25)
138
+
139
+ col1, col2 = st.sidebar.columns(2)
140
+ with col1:
141
+ regen = st.button("Regenerate")
142
+ with col2:
143
+ st.sidebar.write("")
144
+
145
+ if regen:
146
+ st.session_state.data = generate_synthetic_transactions(n_rows=900)
147
+
148
+ # Update filters
149
+ st.session_state.filters = {
150
+ "date_range": (
151
+ datetime.combine(from_date, datetime.min.time()),
152
+ datetime.combine(to_date, datetime.max.time()),
153
+ ),
154
+ "categories": categories,
155
+ "merchant_query": merchant_query.strip(),
156
+ "summary_mode": summary_mode,
157
+ "engine": engine,
158
+ "ollama_model": None,
159
+ "show_spikes": show_spikes,
160
+ "large_tx_threshold": large_tx_threshold,
161
+ }
162
+
163
+
164
+ def render_metrics(agg: dict):
165
+ col1, col2, col3, col4 = st.columns(4)
166
+ with col1:
167
+ st.markdown(f"<div class='metric-card'><div class='metric-label'>Total Value</div><div class='kpi-value'><span style='font-size: 0.8em;'>£</span><span style='font-size: 1.2em; font-weight: bold;'>{agg['total_spend']:,.0f}</span></div></div>", unsafe_allow_html=True)
168
+ with col2:
169
+ st.markdown(f"<div class='metric-card'><div class='metric-label'>Avg Monthly</div><div class='kpi-value'><span style='font-size: 0.8em;'>£</span><span style='font-size: 1.2em; font-weight: bold;'>{agg['avg_monthly_spend']:,.0f}</span></div></div>", unsafe_allow_html=True)
170
+ with col3:
171
+ st.markdown(f"<div class='metric-card'><div class='metric-label'>Max Transaction</div><div class='kpi-value kpi-accent'><span style='font-size: 0.8em;'>£</span><span style='font-size: 1.2em; font-weight: bold;'>{agg['max_transaction']['Amount']:,.0f}</span></div></div>", unsafe_allow_html=True)
172
+ with col4:
173
+ st.markdown(f"<div class='metric-card'><div class='metric-label'>Min Transaction</div><div class='kpi-value'><span style='font-size: 0.8em;'>£</span><span style='font-size: 1.2em; font-weight: bold;'>{agg['min_transaction']['Amount']:,.0f}</span></div></div>", unsafe_allow_html=True)
174
+
175
+
176
+ def render_isa_widget(current_spend: float, allowance: float):
177
+ used = min(current_spend, allowance)
178
+ remaining = max(allowance - used, 0)
179
+ percent = 0 if allowance <= 0 else int((used / allowance) * 100)
180
+ st.markdown("<div class='isa-widget'>", unsafe_allow_html=True)
181
+ st.subheader("ISA allowance")
182
+ st.markdown(f"<div class='progress'><div style='width:{percent}%;'></div></div>", unsafe_allow_html=True)
183
+ col1, col2 = st.columns(2)
184
+ with col1:
185
+ st.markdown(f"<div><span class='kpi-accent' style='font-size: 1.1rem; font-weight: 600;'>USED</span><br/><span style='font-size: 1.8rem; font-weight: bold;'>£{used:,.2f}</span></div>", unsafe_allow_html=True)
186
+ with col2:
187
+ st.markdown(f"<div><span style='font-size: 1.1rem; font-weight: 600; color: rgba(255,255,255,0.8);'>REMAINING</span><br/><span style='font-size: 1.8rem; font-weight: bold;'>£{remaining:,.2f}</span></div>", unsafe_allow_html=True)
188
+ st.markdown("</div>", unsafe_allow_html=True)
189
+
190
+
191
+ def render_charts(filtered_df: pd.DataFrame, agg: dict, template: str, show_spikes: bool):
192
+ t1, t2, t3 = st.tabs(["Trend", "By Category", "Payment Methods"])
193
+ with t1:
194
+ fig = build_time_series_chart(
195
+ filtered_df,
196
+ template=template,
197
+ spike_overlay=agg["spikes"] if show_spikes else None,
198
+ )
199
+ st.plotly_chart(fig, use_container_width=True)
200
+ with t2:
201
+ st.caption("Tip: Select categories in the sidebar to compare their total spend.")
202
+ brand_seq = ["#00AEEF", "#697089", "#005F7F", "#00CC99", "#7A7F87"]
203
+ fig = build_category_bar_chart(agg["spend_per_category"], template=template, color_sequence=brand_seq)
204
+ st.plotly_chart(fig, use_container_width=True)
205
+ with t3:
206
+ brand_seq = ["#00AEEF", "#00CC99", "#697089"]
207
+ fig = build_payment_method_pie_chart(agg["spend_per_payment"], template=template, color_sequence=brand_seq)
208
+ st.plotly_chart(fig, use_container_width=True)
209
+
210
+
211
+ # Simple deterministic heuristic fallback (keeps behavior predictable)
212
+ def heuristic_summary(agg: dict, mode: str) -> str:
213
+ # Produce a short, deterministic summary using aggregations
214
+ total = agg.get("total_spend", 0)
215
+ avg_month = agg.get("avg_monthly_spend", 0)
216
+ top_cat = None
217
+ if "spend_per_category" in agg and agg["spend_per_category"]:
218
+ top_cat = max(agg["spend_per_category"].items(), key=lambda x: x[1])[0]
219
+ spikes = agg.get("spikes", [])
220
+ lines = []
221
+ lines.append(f"Total spend in the selected period: £{total:,.2f}.")
222
+ lines.append(f"Average monthly spend: £{avg_month:,.2f}.")
223
+ if top_cat:
224
+ lines.append(f"Top category by spend: {top_cat}.")
225
+ lines.append(f"Detected {len(spikes)} spending spikes.")
226
+ if mode == "Detailed":
227
+ # Add a little more deterministic detail
228
+ items = list(agg.get("spend_per_category", {}).items())[:5]
229
+ lines.append("Spend per category: " + ", ".join(f"{k}: {chr(163)}{v:,.0f}" for k, v in items))
230
+ return " ".join(lines)
231
+
232
+
233
+ def _get_hf_token() -> Optional[str]:
234
+ """Return a Hugging Face token using a configurable secret name.
235
+
236
+ Behavior:
237
+ - Look up env var HF_TOKEN_NAME to get the secret key name (default 'HF_TOKEN').
238
+ - Prefer Streamlit secrets (st.secrets[name]) when running on Spaces.
239
+ - Fall back to environment variable with that name, then to HUGGINGFACE_API_KEY or HF_TOKEN.
240
+ """
241
+ # First, allow an explicit env var to override the secret name
242
+ name = os.getenv("HF_TOKEN_NAME", None)
243
+ # If the user used the name 'streamlit' for their token, prefer that too
244
+ preferred_names = []
245
+ if name:
246
+ preferred_names.append(name)
247
+ # include the user-specified token name 'streamlit' as a high-priority fallback
248
+ preferred_names.append("streamlit")
249
+ # finally include the common default
250
+ preferred_names.append("HF_TOKEN")
251
+
252
+ try:
253
+ for n in preferred_names:
254
+ if isinstance(st.secrets, dict) and n in st.secrets:
255
+ return st.secrets[n]
256
+ except Exception:
257
+ pass
258
+
259
+ for n in preferred_names:
260
+ val = os.getenv(n)
261
+ if val:
262
+ return val
263
+
264
+ # last-resort fallbacks
265
+ return os.getenv("HUGGINGFACE_API_KEY") or os.getenv("HF_TOKEN")
266
+
267
+
268
+ def _call_hf_inference(prompt: str, model: str = "tiiuae/falcon-7b-instruct", token: Optional[str] = None, max_tokens: int = 256) -> str:
269
+ """Call the Hugging Face Inference API and return generated text.
270
+
271
+ Raises RuntimeError on non-200 responses.
272
+ """
273
+ if not token:
274
+ raise RuntimeError("No Hugging Face token provided.")
275
+ url = f"https://api-inference.huggingface.co/models/{model}"
276
+ headers = {"Authorization": f"Bearer {token}"}
277
+ payload = {"inputs": prompt, "parameters": {"max_new_tokens": max_tokens, "temperature": 0.2}}
278
+ resp = requests.post(url, headers=headers, json=payload, timeout=60)
279
+ if resp.status_code != 200:
280
+ try:
281
+ msg = resp.json()
282
+ except Exception:
283
+ msg = resp.text
284
+ raise RuntimeError(f"Hugging Face inference error {resp.status_code}: {msg}")
285
+ data = resp.json()
286
+ if isinstance(data, dict):
287
+ if "error" in data:
288
+ raise RuntimeError(f"Hugging Face error: {data['error']}")
289
+ if "generated_text" in data:
290
+ return data["generated_text"]
291
+ for v in data.values():
292
+ if isinstance(v, dict) and "generated_text" in v:
293
+ return v["generated_text"]
294
+ return str(data)
295
+ if isinstance(data, list) and len(data) > 0:
296
+ if isinstance(data[0], dict) and "generated_text" in data[0]:
297
+ return data[0]["generated_text"]
298
+ return str(data[0])
299
+ return str(data)
300
+
301
+
302
+ # External inference via Hugging Face API and OpenAI have been intentionally
303
+ # removed to keep the app free to run on Hugging Face Spaces without paid APIs.
304
+
305
+
306
+ def render_ai_summary(agg: dict, mode: str, engine: str, ollama_model: str | None):
307
+ st.subheader("AI Summary")
308
+ placeholder = st.empty()
309
+ placeholder.markdown(f"<div class='ai-card'>Generating summary…</div>", unsafe_allow_html=True)
310
+
311
+ # Build a short prompt from agg (keep it concise)
312
+ prompt = f"Provide a {mode.lower()} natural-language summary of these spending analytics: {json.dumps({'total_spend': agg.get('total_spend'), 'avg_monthly_spend': agg.get('avg_monthly_spend'), 'top_categories': agg.get('spend_per_category'), 'spikes': agg.get('spikes')}, default=str)}"
313
+
314
+ # Preferred: Hugging Face
315
+ if engine == "HuggingFace":
316
+ # Use the local summarizer which prefers a small HF model when available
317
+ try:
318
+ text = summarize_with_ai(agg, api_key=None, mode=mode, engine="HuggingFace")
319
+ if not text:
320
+ raise RuntimeError("No response from local Hugging Face summarizer.")
321
+ placeholder.markdown(f"<div class='ai-card'>{text}</div>", unsafe_allow_html=True)
322
+ return
323
+ except Exception as e:
324
+ # If local summarizer failed, try remote HF inference if a token is available
325
+ hf_token = _get_hf_token()
326
+ if hf_token:
327
+ try:
328
+ prompt = f"Provide a {mode.lower()} natural-language summary of these spending analytics: {json.dumps({'total_spend': agg.get('total_spend'), 'avg_monthly_spend': agg.get('avg_monthly_spend'), 'top_categories': agg.get('spend_per_category'), 'spikes': agg.get('spikes')}, default=str)}"
329
+ full_text = _call_hf_inference(prompt, model="gpt2", token=hf_token, max_tokens=256)
330
+ placeholder.markdown(f"<div class='ai-card'>{full_text}</div>", unsafe_allow_html=True)
331
+ return
332
+ except Exception:
333
+ # Fall back to heuristic if remote inference fails
334
+ text = heuristic_summary(agg, mode)
335
+ placeholder.markdown(f"<div class='ai-card'>{text}</div>", unsafe_allow_html=True)
336
+ return
337
+ else:
338
+ placeholder.markdown(f"<div class='ai-card'>Local summarizer error: {e}. No Hugging Face token configured; showing deterministic summary instead.</div>", unsafe_allow_html=True)
339
+ text = heuristic_summary(agg, mode)
340
+ placeholder.markdown(f"<div class='ai-card'>{text}</div>", unsafe_allow_html=True)
341
+ return
342
+
343
+ # If the user explicitly selected OpenAI, show Coming soon (we don't want to rely on paid APIs)
344
+ if engine == "OpenAI":
345
+ placeholder.markdown("<div class='ai-card'>OpenAI summaries are coming soon. Please select HuggingFace (default) or Ollama (local) instead.</div>", unsafe_allow_html=True)
346
+ # still provide deterministic fallback to keep UX
347
+ text = heuristic_summary(agg, mode)
348
+ placeholder.markdown(f"<div class='ai-card'>{text}</div>", unsafe_allow_html=True)
349
+ return
350
+
351
+ # Ollama support removed — local Hugging Face (distilgpt2) is the supported free option.
352
+
353
+ # If Heuristic selected explicitly
354
+ if engine == "Heuristic":
355
+ text = heuristic_summary(agg, mode)
356
+ placeholder.markdown(f"<div class='ai-card'>{text}</div>", unsafe_allow_html=True)
357
+ return
358
+
359
+ # Fallback
360
+ placeholder.markdown("<div class='ai-card'>Coming soon — selected engine not available.</div>", unsafe_allow_html=True)
361
+
362
+
363
+ def main():
364
+ init_session_state()
365
+
366
+ # Inject custom CSS with hover animations (preserved exactly)
367
+ st.markdown("""
368
+ <style>
369
+ :root {
370
+ --t212: #00AEEF;
371
+ --t212-light: #33BFEF;
372
+ --t212-lighter: #66CFEF;
373
+ }
374
+
375
+ /* Base card styles */
376
+ .card {
377
+ background: rgba(0,0,0,0.25);
378
+ border: 1px solid rgba(255,255,255,0.08);
379
+ border-radius: 12px;
380
+ padding: 1.2rem;
381
+ transition: all 0.3s ease;
382
+ cursor: pointer;
383
+ }
384
+
385
+ .card:hover {
386
+ background: rgba(0,174,239,0.08);
387
+ border: 1px solid rgba(0,174,239,0.2);
388
+ transform: scale(1.02);
389
+ box-shadow: 0 8px 25px rgba(0,174,239,0.15);
390
+ }
391
+
392
+ /* Metric card styles with hover */
393
+ .metric-card {
394
+ background: rgba(0,0,0,0.20);
395
+ border-radius: 12px;
396
+ padding: 1.2rem;
397
+ border: 1px solid rgba(255,255,255,0.08);
398
+ transition: all 0.3s ease;
399
+ cursor: pointer;
400
+ text-align: center;
401
+ }
402
+
403
+ .metric-card:hover {
404
+ background: rgba(0,174,239,0.1);
405
+ border: 1px solid rgba(0,174,239,0.3);
406
+ transform: scale(1.03);
407
+ box-shadow: 0 10px 30px rgba(0,174,239,0.2);
408
+ }
409
+
410
+ /* AI card styles with hover */
411
+ .ai-card {
412
+ background: rgba(0, 204, 153, 0.06);
413
+ border-left: 4px solid #00CC99;
414
+ border-radius: 8px;
415
+ padding: 1.5rem;
416
+ transition: all 0.3s ease;
417
+ cursor: pointer;
418
+ font-size: 1.1rem;
419
+ line-height: 1.6;
420
+ }
421
+
422
+ .ai-card:hover {
423
+ background: rgba(0, 204, 153, 0.12);
424
+ border-left: 4px solid #33D9B3;
425
+ transform: scale(1.01);
426
+ box-shadow: 0 6px 20px rgba(0, 204, 153, 0.15);
427
+ }
428
+
429
+ /* ISA widget specific hover */
430
+ .isa-widget {
431
+ background: rgba(0,0,0,0.25);
432
+ border: 1px solid rgba(255,255,255,0.08);
433
+ border-radius: 12px;
434
+ padding: 1.5rem;
435
+ transition: all 0.3s ease;
436
+ cursor: pointer;
437
+ }
438
+
439
+ .isa-widget:hover {
440
+ background: rgba(0,174,239,0.08);
441
+ border: 1px solid rgba(0,174,239,0.2);
442
+ transform: scale(1.02);
443
+ box-shadow: 0 8px 25px rgba(0,174,239,0.15);
444
+ }
445
+
446
+ /* KPI value styles */
447
+ .kpi-value {
448
+ font-size: 2.2rem;
449
+ font-weight: 800;
450
+ margin-top: 0.5rem;
451
+ transition: all 0.2s ease;
452
+ }
453
+
454
+ .metric-card:hover .kpi-value {
455
+ color: var(--t212-light);
456
+ }
457
+
458
+ .kpi-accent {
459
+ color: var(--t212);
460
+ font-weight: 700;
461
+ }
462
+
463
+ .kpi-accent:hover {
464
+ color: var(--t212-lighter);
465
+ }
466
+
467
+ /* Progress bar styles */
468
+ .progress {
469
+ height: 8px;
470
+ background: rgba(255,255,255,0.1);
471
+ border-radius: 999px;
472
+ overflow: hidden;
473
+ width: 100%;
474
+ margin: 1rem 0;
475
+ transition: all 0.3s ease;
476
+ }
477
+
478
+ .progress > div {
479
+ height: 100%;
480
+ background: linear-gradient(90deg, var(--t212), var(--t212-light));
481
+ transition: all 0.3s ease;
482
+ }
483
+
484
+ .isa-widget:hover .progress {
485
+ height: 10px;
486
+ box-shadow: 0 2px 8px rgba(0,174,239,0.3);
487
+ }
488
+
489
+ /* Utility classes */
490
+ .pos { color: #1ECB4F; }
491
+ .neg { color: #FF4D4F; }
492
+
493
+ /* Enhanced text styles */
494
+ .metric-label {
495
+ font-size: 0.9rem;
496
+ color: rgba(255,255,255,0.7);
497
+ font-weight: 500;
498
+ margin-bottom: 0.5rem;
499
+ }
500
+
501
+ .metric-card:hover .metric-label {
502
+ color: rgba(255,255,255,0.9);
503
+ }
504
+
505
+ /* Subheader improvements */
506
+ h3 {
507
+ font-size: 1.4rem !important;
508
+ font-weight: 600 !important;
509
+ color: rgba(255,255,255,0.9) !important;
510
+ margin-bottom: 1rem !important;
511
+ }
512
+ </style>
513
+ """, unsafe_allow_html=True)
514
+ render_header()
515
+ render_assistant_banner()
516
+
517
+ # Floating chat button
518
+ render_chat_fab()
519
+
520
+ # Sidebar filters and regenerate
521
+ render_sidebar(st.session_state.data)
522
+
523
+ # Apply filters
524
+ filters = st.session_state.filters
525
+ filtered = filter_transactions(
526
+ st.session_state.data,
527
+ date_range=filters["date_range"],
528
+ categories=filters["categories"],
529
+ merchant_query=filters["merchant_query"],
530
+ )
531
+
532
+ if filtered.empty:
533
+ st.info("No data for selected filters. Adjust filters to see insights.")
534
+ return
535
+
536
+ agg = compute_aggregations(filtered)
537
+
538
+ # Top KPIs
539
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
540
+ render_metrics(agg)
541
+ st.markdown("</div>", unsafe_allow_html=True)
542
+
543
+ # ISA-style allowance widget (configurable)
544
+ with st.expander("Allowance widget"):
545
+ allowance = st.number_input("Annual allowance (£)", min_value=0, value=20000, step=500)
546
+ render_isa_widget(current_spend=float(agg['total_spend']), allowance=float(allowance))
547
+
548
+ # Charts (use dark theme consistently as requested)
549
+ template = "plotly_dark"
550
+ render_charts(filtered, agg, template, show_spikes=filters["show_spikes"])
551
+
552
+ # AI Summary only
553
+ render_ai_summary(agg, mode=filters["summary_mode"], engine=filters["engine"], ollama_model=filters["ollama_model"])
554
+
555
+ # Large transactions table
556
+ threshold = filters["large_tx_threshold"]
557
+ large_df = filtered[filtered["Amount"] >= threshold].sort_values("Amount", ascending=False)
558
+ with st.expander(f"Show large transactions (≥ £{threshold}) [{len(large_df)}]"):
559
+ st.dataframe(large_df, use_container_width=True, hide_index=True)
560
+
561
+ # Downloads
562
+ st.divider()
563
+ col1, col2 = st.columns([2,1])
564
+ with col1:
565
+ st.caption("Download filtered data")
566
+ csv = filtered.to_csv(index=False).encode("utf-8")
567
+ st.download_button("Download CSV", csv, file_name="transactions_filtered.csv", mime="text/csv")
568
+ with col2:
569
+ st.caption("Dataset size")
570
+ st.write(f"{len(filtered):,} rows")
571
+
572
+
573
+ if __name__ == "__main__":
574
+ main()