fleece-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli.py +391 -0
- db.py +162 -0
- fleece_cli-0.1.0.dist-info/METADATA +151 -0
- fleece_cli-0.1.0.dist-info/RECORD +10 -0
- fleece_cli-0.1.0.dist-info/WHEEL +4 -0
- fleece_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fleece_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- tools/__init__.py +3 -0
- tools/brave_client.py +69 -0
- tools/credit_card_tools.py +261 -0
cli.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fleece CLI — agent-friendly companion to the Fleece Streamlit chatbot.
|
|
3
|
+
|
|
4
|
+
Exposes credit card research tools as shell commands backed by Brave Search.
|
|
5
|
+
Designed for use by AI agents (Claude Code, Codex) and humans alike.
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
0 success
|
|
9
|
+
1 search / tool error
|
|
10
|
+
2 configuration error (missing API key)
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Annotated, Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="fleece",
|
|
21
|
+
help="Fleece credit card research CLI — live data via Brave Search.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Shared helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _resolve_key(api_key: Optional[str], no_dotenv: bool) -> str:
|
|
30
|
+
"""Load env and return the Brave API key, or exit with code 2."""
|
|
31
|
+
import os
|
|
32
|
+
if not no_dotenv:
|
|
33
|
+
load_dotenv()
|
|
34
|
+
key = api_key or os.getenv("BRAVE_API_KEY", "")
|
|
35
|
+
if not key:
|
|
36
|
+
_error_exit(
|
|
37
|
+
"BRAVE_API_KEY not set. Pass --api-key or add it to .env.",
|
|
38
|
+
code=2,
|
|
39
|
+
)
|
|
40
|
+
return key
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_wrapper(key: str, freshness: Optional[str] = None):
|
|
44
|
+
from tools.brave_client import build_brave_wrapper
|
|
45
|
+
return build_brave_wrapper(key, freshness=freshness)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_stdin_or_arg(value: str) -> str:
|
|
49
|
+
"""If value is '-', read the first line from stdin."""
|
|
50
|
+
if value == "-":
|
|
51
|
+
return sys.stdin.readline().strip()
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _emit(result: str, as_json: bool, command: str, query: str) -> None:
|
|
56
|
+
if as_json:
|
|
57
|
+
typer.echo(json.dumps({"command": command, "query": query, "result": result, "ok": True, "error": None}))
|
|
58
|
+
else:
|
|
59
|
+
typer.echo(result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _error_exit(message: str, code: int = 1) -> None:
|
|
63
|
+
typer.echo(
|
|
64
|
+
json.dumps({"ok": False, "error": message}),
|
|
65
|
+
err=True,
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(code=code)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_search(wrapper, query: str, command: str, as_json: bool) -> None:
|
|
71
|
+
from tools.brave_client import search_and_format
|
|
72
|
+
try:
|
|
73
|
+
result = search_and_format(wrapper, query)
|
|
74
|
+
_emit(result, as_json, command, query)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
_error_exit(str(e), code=1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# User profile helpers (fleece.db)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _load_profile() -> list[dict]:
|
|
84
|
+
import db
|
|
85
|
+
return db.get_cards()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _profile_card_names() -> list[str]:
|
|
89
|
+
import db
|
|
90
|
+
return db.get_card_names()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Reusable option annotations
|
|
94
|
+
ApiKeyOpt = Annotated[Optional[str], typer.Option("--api-key", help="Override BRAVE_API_KEY env var.")]
|
|
95
|
+
JsonOpt = Annotated[bool, typer.Option("--json", "-j", help="Emit JSON output (agent-friendly).")]
|
|
96
|
+
NoDotenv = Annotated[bool, typer.Option("--no-dotenv", help="Skip loading .env file.")]
|
|
97
|
+
FromProfileOpt = Annotated[bool, typer.Option("--from-profile", "-p", help="Use cards saved in user_cards.json instead of passing names.")]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Commands
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def card(
|
|
106
|
+
name: Annotated[str, typer.Argument(help="Card name. Use '-' to read from stdin.")],
|
|
107
|
+
api_key: ApiKeyOpt = None,
|
|
108
|
+
as_json: JsonOpt = False,
|
|
109
|
+
no_dotenv: NoDotenv = False,
|
|
110
|
+
):
|
|
111
|
+
"""Full card report — fees, welcome offer, earning rates, credits, benefits, strategy."""
|
|
112
|
+
name = _read_stdin_or_arg(name)
|
|
113
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
114
|
+
query = f'"{name}" credit card annual fee welcome offer earning rates benefits credits 2025'
|
|
115
|
+
_run_search(_get_wrapper(key), query, "card", as_json)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def rates(
|
|
120
|
+
name: Annotated[str, typer.Argument(help="Card name. Use '-' to read from stdin.")],
|
|
121
|
+
category: Annotated[Optional[str], typer.Option("--category", "-c", help="Spending category (dining, travel, groceries…)")] = None,
|
|
122
|
+
api_key: ApiKeyOpt = None,
|
|
123
|
+
as_json: JsonOpt = False,
|
|
124
|
+
no_dotenv: NoDotenv = False,
|
|
125
|
+
):
|
|
126
|
+
"""Earning rates by spend category — points, miles, or cash back per dollar."""
|
|
127
|
+
name = _read_stdin_or_arg(name)
|
|
128
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
129
|
+
cat = f"{category} " if category else ""
|
|
130
|
+
query = f'"{name}" {cat}earning rates points miles cashback per dollar categories 2025'
|
|
131
|
+
_run_search(_get_wrapper(key), query, "rates", as_json)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def partners(
|
|
136
|
+
name: Annotated[str, typer.Argument(help="Card name. Use '-' to read from stdin.")],
|
|
137
|
+
api_key: ApiKeyOpt = None,
|
|
138
|
+
as_json: JsonOpt = False,
|
|
139
|
+
no_dotenv: NoDotenv = False,
|
|
140
|
+
):
|
|
141
|
+
"""Transfer partners — airlines and hotels, ratios, and transfer timing."""
|
|
142
|
+
name = _read_stdin_or_arg(name)
|
|
143
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
144
|
+
query = f'"{name}" transfer partners airlines hotels ratio transfer time 2025'
|
|
145
|
+
_run_search(_get_wrapper(key), query, "partners", as_json)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command()
|
|
149
|
+
def credits(
|
|
150
|
+
name: Annotated[str, typer.Argument(help="Card name. Use '-' to read from stdin.")],
|
|
151
|
+
api_key: ApiKeyOpt = None,
|
|
152
|
+
as_json: JsonOpt = False,
|
|
153
|
+
no_dotenv: NoDotenv = False,
|
|
154
|
+
):
|
|
155
|
+
"""Statement credits and perks — amounts, cadence, and enrollment requirements."""
|
|
156
|
+
name = _read_stdin_or_arg(name)
|
|
157
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
158
|
+
query = f'"{name}" statement credits perks benefits complete list how to use 2025'
|
|
159
|
+
_run_search(_get_wrapper(key), query, "credits", as_json)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def news(
|
|
164
|
+
name: Annotated[str, typer.Argument(help="Card name. Use '-' to read from stdin.")],
|
|
165
|
+
api_key: ApiKeyOpt = None,
|
|
166
|
+
as_json: JsonOpt = False,
|
|
167
|
+
no_dotenv: NoDotenv = False,
|
|
168
|
+
):
|
|
169
|
+
"""Recent changes in the past month — fee increases, benefit cuts, new perks."""
|
|
170
|
+
name = _read_stdin_or_arg(name)
|
|
171
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
172
|
+
query = f'"{name}" credit card changes update news 2025'
|
|
173
|
+
_run_search(_get_wrapper(key, freshness="pm"), query, "news", as_json)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command()
|
|
177
|
+
def compare(
|
|
178
|
+
card_a: Annotated[str, typer.Argument(help="First card name.")],
|
|
179
|
+
card_b: Annotated[str, typer.Argument(help="Second card name.")],
|
|
180
|
+
aspects: Annotated[str, typer.Option("--aspects", help="Comma-separated aspects to compare.")] = "fees,rewards,welcome_offer,credits,transfer_partners",
|
|
181
|
+
api_key: ApiKeyOpt = None,
|
|
182
|
+
as_json: JsonOpt = False,
|
|
183
|
+
no_dotenv: NoDotenv = False,
|
|
184
|
+
):
|
|
185
|
+
"""Side-by-side card comparison across fees, rewards, credits, and transfer partners."""
|
|
186
|
+
from tools.brave_client import search_and_format
|
|
187
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
188
|
+
wrapper = _get_wrapper(key)
|
|
189
|
+
asp = aspects.replace(",", ", ")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
result_a = search_and_format(wrapper, f'"{card_a}" credit card {asp} 2025')
|
|
193
|
+
result_b = search_and_format(wrapper, f'"{card_b}" credit card {asp} 2025')
|
|
194
|
+
combined = f"## {card_a}\n{result_a}\n\n## {card_b}\n{result_b}"
|
|
195
|
+
except Exception as e:
|
|
196
|
+
_error_exit(str(e), code=1)
|
|
197
|
+
return
|
|
198
|
+
query = f"{card_a} vs {card_b}"
|
|
199
|
+
_emit(combined, as_json, "compare", query)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def wallet(
|
|
204
|
+
cards: Annotated[Optional[list[str]], typer.Argument(help="Card names (space-separated). Omit to use saved profile. Use '-' as sole arg to read from stdin.")] = None,
|
|
205
|
+
from_profile: FromProfileOpt = False,
|
|
206
|
+
api_key: ApiKeyOpt = None,
|
|
207
|
+
as_json: JsonOpt = False,
|
|
208
|
+
no_dotenv: NoDotenv = False,
|
|
209
|
+
):
|
|
210
|
+
"""Portfolio analysis — category coverage map, overlaps, gaps, and next-card suggestions.
|
|
211
|
+
|
|
212
|
+
Card names can come from three sources (in priority order):
|
|
213
|
+
1. Arguments passed directly
|
|
214
|
+
2. --from-profile / -p (reads user_cards.json)
|
|
215
|
+
3. No args + saved profile exists (auto-loads profile)
|
|
216
|
+
"""
|
|
217
|
+
from tools.brave_client import search_and_format
|
|
218
|
+
|
|
219
|
+
# Resolve card list
|
|
220
|
+
if cards and cards != ["-"]:
|
|
221
|
+
card_list = cards
|
|
222
|
+
elif cards == ["-"]:
|
|
223
|
+
card_list = [line.strip() for line in sys.stdin if line.strip()]
|
|
224
|
+
else:
|
|
225
|
+
# No args passed — fall back to saved profile
|
|
226
|
+
card_list = _profile_card_names()
|
|
227
|
+
if not card_list:
|
|
228
|
+
_error_exit(
|
|
229
|
+
"No cards provided and user_cards.json is empty. "
|
|
230
|
+
"Pass card names as arguments or add cards with: python cli.py cards add \"<name>\"",
|
|
231
|
+
code=1,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
235
|
+
wrapper = _get_wrapper(key)
|
|
236
|
+
parts = []
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
for name in card_list:
|
|
240
|
+
result = search_and_format(wrapper, f'"{name}" earning rates categories benefits 2025', max_results=2)
|
|
241
|
+
parts.append(f"### {name}\n{result}")
|
|
242
|
+
except Exception as e:
|
|
243
|
+
_error_exit(str(e), code=1)
|
|
244
|
+
|
|
245
|
+
combined = "\n\n".join(parts)
|
|
246
|
+
combined += (
|
|
247
|
+
"\n\nBased on the above, identify:\n"
|
|
248
|
+
"1. Category coverage map\n"
|
|
249
|
+
"2. Overlapping benefits or redundant categories\n"
|
|
250
|
+
"3. Gaps — categories with no bonus multiplier\n"
|
|
251
|
+
"4. Top 1-2 cards that would complement this portfolio"
|
|
252
|
+
)
|
|
253
|
+
_emit(combined, as_json, "wallet", ", ".join(card_list))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@app.command()
|
|
257
|
+
def roi(
|
|
258
|
+
name: Annotated[str, typer.Argument(help="Card name. Use '-' to read from stdin.")],
|
|
259
|
+
travel: Annotated[float, typer.Option("--travel", help="Monthly travel spend ($).")] = 0.0,
|
|
260
|
+
dining: Annotated[float, typer.Option("--dining", help="Monthly dining spend ($).")] = 0.0,
|
|
261
|
+
other: Annotated[float, typer.Option("--other", help="Monthly other spend ($).")] = 0.0,
|
|
262
|
+
api_key: ApiKeyOpt = None,
|
|
263
|
+
as_json: JsonOpt = False,
|
|
264
|
+
no_dotenv: NoDotenv = False,
|
|
265
|
+
):
|
|
266
|
+
"""First-year ROI estimate — welcome bonus + earn + credits minus annual fee."""
|
|
267
|
+
from tools.brave_client import search_and_format
|
|
268
|
+
from tools.credit_card_tools import _guess_cpp
|
|
269
|
+
|
|
270
|
+
name = _read_stdin_or_arg(name)
|
|
271
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
272
|
+
|
|
273
|
+
annual_travel = travel * 12
|
|
274
|
+
annual_dining = dining * 12
|
|
275
|
+
annual_other = other * 12
|
|
276
|
+
cpp = _guess_cpp(name)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
research = search_and_format(
|
|
280
|
+
_get_wrapper(key),
|
|
281
|
+
f'"{name}" annual fee welcome bonus points value first year 2025',
|
|
282
|
+
)
|
|
283
|
+
result = (
|
|
284
|
+
f"Annual spend — Travel: ${annual_travel:,.0f} | Dining: ${annual_dining:,.0f} | Other: ${annual_other:,.0f}\n"
|
|
285
|
+
f"Assumed value per point: {cpp}¢\n\n"
|
|
286
|
+
f"Live research:\n{research}\n\n"
|
|
287
|
+
"Calculate net first-year value: welcome bonus value + estimated annual earn + credits - annual fee. Show the math."
|
|
288
|
+
)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
_error_exit(str(e), code=1)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
_emit(result, as_json, "roi", name)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command()
|
|
297
|
+
def recommend(
|
|
298
|
+
profile: Annotated[str, typer.Argument(help="Spending profile description. Use '-' to read from stdin.")],
|
|
299
|
+
preferences: Annotated[Optional[str], typer.Option("--preferences", "-p", help="Extra preferences (e.g. 'no annual fee').")] = None,
|
|
300
|
+
api_key: ApiKeyOpt = None,
|
|
301
|
+
as_json: JsonOpt = False,
|
|
302
|
+
no_dotenv: NoDotenv = False,
|
|
303
|
+
):
|
|
304
|
+
"""Card recommendations matched to a spending profile and preferences."""
|
|
305
|
+
profile = _read_stdin_or_arg(profile)
|
|
306
|
+
key = _resolve_key(api_key, no_dotenv)
|
|
307
|
+
pref = f"{preferences} " if preferences else ""
|
|
308
|
+
query = f"best credit cards {profile} {pref}US 2025 site:nerdwallet.com OR site:thepointsguy.com OR site:doctorofcredit.com"
|
|
309
|
+
_run_search(_get_wrapper(key), query, "recommend", as_json)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# cards — profile management (read/write user_cards.json)
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
cards_app = typer.Typer(name="cards", help="Manage your saved card profile (user_cards.json).", no_args_is_help=True)
|
|
317
|
+
app.add_typer(cards_app)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@cards_app.command("list")
|
|
321
|
+
def cards_list(
|
|
322
|
+
as_json: JsonOpt = False,
|
|
323
|
+
):
|
|
324
|
+
"""List all cards saved in your profile."""
|
|
325
|
+
profile = _load_profile()
|
|
326
|
+
if not profile:
|
|
327
|
+
typer.echo("No cards in profile. Add one with: python cli.py cards add \"<card name>\"")
|
|
328
|
+
return
|
|
329
|
+
if as_json:
|
|
330
|
+
typer.echo(json.dumps({"command": "cards list", "cards": profile, "ok": True, "error": None}))
|
|
331
|
+
else:
|
|
332
|
+
for i, card in enumerate(profile, 1):
|
|
333
|
+
fee = card.get("annual_fee", "N/A")
|
|
334
|
+
typer.echo(f"{i}. {card['name']} (fee: {fee})")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@cards_app.command("add")
|
|
338
|
+
def cards_add(
|
|
339
|
+
name: Annotated[str, typer.Argument(help="Card name to add.")],
|
|
340
|
+
annual_fee: Annotated[str, typer.Option("--fee", help="Annual fee (e.g. $95).")] = "$0",
|
|
341
|
+
as_json: JsonOpt = False,
|
|
342
|
+
):
|
|
343
|
+
"""Add a card to your profile."""
|
|
344
|
+
import datetime
|
|
345
|
+
import db
|
|
346
|
+
new_card = {
|
|
347
|
+
"name": name,
|
|
348
|
+
"annual_fee": annual_fee,
|
|
349
|
+
"date_added": datetime.date.today().isoformat(),
|
|
350
|
+
}
|
|
351
|
+
try:
|
|
352
|
+
db.add_card(new_card)
|
|
353
|
+
except ValueError as e:
|
|
354
|
+
_error_exit(str(e), code=1)
|
|
355
|
+
total = len(db.get_card_names())
|
|
356
|
+
if as_json:
|
|
357
|
+
typer.echo(json.dumps({"command": "cards add", "card": new_card, "ok": True, "error": None}))
|
|
358
|
+
else:
|
|
359
|
+
typer.echo(f'Added "{name}" to your profile. ({total} card(s) total)')
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@cards_app.command("remove")
|
|
363
|
+
def cards_remove(
|
|
364
|
+
name: Annotated[str, typer.Argument(help="Card name to remove (partial match OK).")],
|
|
365
|
+
as_json: JsonOpt = False,
|
|
366
|
+
):
|
|
367
|
+
"""Remove a card from your profile."""
|
|
368
|
+
import db
|
|
369
|
+
# Partial match against all names
|
|
370
|
+
all_cards = db.get_cards()
|
|
371
|
+
matches = [c for c in all_cards if name.lower() in c["name"].lower()]
|
|
372
|
+
if not matches:
|
|
373
|
+
_error_exit(f'No card matching "{name}" found in profile.', code=1)
|
|
374
|
+
if len(matches) > 1:
|
|
375
|
+
names = ", ".join(f'"{c["name"]}"' for c in matches)
|
|
376
|
+
_error_exit(f'Ambiguous match — found {names}. Be more specific.', code=1)
|
|
377
|
+
removed = matches[0]
|
|
378
|
+
db.remove_card(removed["name"])
|
|
379
|
+
remaining = len(db.get_card_names())
|
|
380
|
+
if as_json:
|
|
381
|
+
typer.echo(json.dumps({"command": "cards remove", "removed": removed["name"], "ok": True, "error": None}))
|
|
382
|
+
else:
|
|
383
|
+
typer.echo(f'Removed "{removed["name"]}" from your profile. ({remaining} card(s) remaining)')
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Entrypoint
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
app()
|
db.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite persistence layer for Fleece user card profiles.
|
|
3
|
+
|
|
4
|
+
Database file: fleece.db (project root, next to this file).
|
|
5
|
+
Shared between the Streamlit UI and the CLI — both import this module directly.
|
|
6
|
+
"""
|
|
7
|
+
import sqlite3
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
DB_PATH = Path(__file__).parent / "fleece.db"
|
|
13
|
+
|
|
14
|
+
_SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS cards (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
name TEXT NOT NULL UNIQUE,
|
|
18
|
+
last_four TEXT NOT NULL DEFAULT '',
|
|
19
|
+
annual_fee TEXT NOT NULL DEFAULT '$0',
|
|
20
|
+
credit_limit INTEGER NOT NULL DEFAULT 0,
|
|
21
|
+
rewards TEXT NOT NULL DEFAULT '',
|
|
22
|
+
expiration TEXT NOT NULL DEFAULT '',
|
|
23
|
+
image_url TEXT NOT NULL DEFAULT '',
|
|
24
|
+
date_added TEXT NOT NULL DEFAULT (date('now'))
|
|
25
|
+
);
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_COLUMNS = ("id", "name", "last_four", "annual_fee", "credit_limit",
|
|
29
|
+
"rewards", "expiration", "image_url", "date_added")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def _conn() -> Iterator[sqlite3.Connection]:
|
|
34
|
+
con = sqlite3.connect(DB_PATH)
|
|
35
|
+
con.row_factory = sqlite3.Row
|
|
36
|
+
try:
|
|
37
|
+
yield con
|
|
38
|
+
con.commit()
|
|
39
|
+
finally:
|
|
40
|
+
con.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def init_db() -> None:
|
|
44
|
+
"""Create tables if they don't exist. Safe to call on every startup."""
|
|
45
|
+
with _conn() as con:
|
|
46
|
+
con.executescript(_SCHEMA)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _row_to_dict(row: sqlite3.Row) -> dict:
|
|
50
|
+
return dict(row)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Read
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def get_cards() -> list[dict]:
|
|
58
|
+
"""Return all cards ordered by date_added desc."""
|
|
59
|
+
init_db()
|
|
60
|
+
with _conn() as con:
|
|
61
|
+
rows = con.execute("SELECT * FROM cards ORDER BY date_added DESC").fetchall()
|
|
62
|
+
return [_row_to_dict(r) for r in rows]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_card_names() -> list[str]:
|
|
66
|
+
"""Return just the card names — used by the CLI wallet/roi commands."""
|
|
67
|
+
init_db()
|
|
68
|
+
with _conn() as con:
|
|
69
|
+
rows = con.execute("SELECT name FROM cards ORDER BY date_added DESC").fetchall()
|
|
70
|
+
return [r["name"] for r in rows]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_card(name: str) -> dict | None:
|
|
74
|
+
"""Return a single card by exact name, or None."""
|
|
75
|
+
init_db()
|
|
76
|
+
with _conn() as con:
|
|
77
|
+
row = con.execute("SELECT * FROM cards WHERE name = ?", (name,)).fetchone()
|
|
78
|
+
return _row_to_dict(row) if row else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Write
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def add_card(card: dict) -> None:
|
|
86
|
+
"""Insert a new card. Raises ValueError if name already exists."""
|
|
87
|
+
init_db()
|
|
88
|
+
with _conn() as con:
|
|
89
|
+
try:
|
|
90
|
+
con.execute(
|
|
91
|
+
"""
|
|
92
|
+
INSERT INTO cards (name, last_four, annual_fee, credit_limit,
|
|
93
|
+
rewards, expiration, image_url, date_added)
|
|
94
|
+
VALUES (:name, :last_four, :annual_fee, :credit_limit,
|
|
95
|
+
:rewards, :expiration, :image_url, :date_added)
|
|
96
|
+
""",
|
|
97
|
+
{
|
|
98
|
+
"name": card.get("name", ""),
|
|
99
|
+
"last_four": card.get("last_four", ""),
|
|
100
|
+
"annual_fee": card.get("annual_fee", "$0"),
|
|
101
|
+
"credit_limit": card.get("credit_limit", 0),
|
|
102
|
+
"rewards": card.get("rewards", ""),
|
|
103
|
+
"expiration": card.get("expiration", ""),
|
|
104
|
+
"image_url": card.get("image_url", ""),
|
|
105
|
+
"date_added": card.get("date_added", ""),
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
except sqlite3.IntegrityError:
|
|
109
|
+
raise ValueError(f'Card "{card.get("name")}" already exists.')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def update_card(name: str, updates: dict) -> bool:
|
|
113
|
+
"""Update fields on an existing card. Returns True if found."""
|
|
114
|
+
if not updates:
|
|
115
|
+
return False
|
|
116
|
+
allowed = set(_COLUMNS) - {"id", "name", "date_added"}
|
|
117
|
+
fields = {k: v for k, v in updates.items() if k in allowed}
|
|
118
|
+
if not fields:
|
|
119
|
+
return False
|
|
120
|
+
init_db()
|
|
121
|
+
set_clause = ", ".join(f"{k} = :{k}" for k in fields)
|
|
122
|
+
fields["_name"] = name
|
|
123
|
+
with _conn() as con:
|
|
124
|
+
cur = con.execute(f"UPDATE cards SET {set_clause} WHERE name = :_name", fields)
|
|
125
|
+
return cur.rowcount > 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def remove_card(name: str) -> bool:
|
|
129
|
+
"""Delete a card by exact name. Returns True if it existed."""
|
|
130
|
+
init_db()
|
|
131
|
+
with _conn() as con:
|
|
132
|
+
cur = con.execute("DELETE FROM cards WHERE name = ?", (name,))
|
|
133
|
+
return cur.rowcount > 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Migration
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def migrate_from_json(json_path: Path) -> int:
|
|
141
|
+
"""
|
|
142
|
+
Import cards from a JSON file into SQLite. Skips duplicates.
|
|
143
|
+
Returns the number of cards inserted.
|
|
144
|
+
"""
|
|
145
|
+
import json
|
|
146
|
+
|
|
147
|
+
if not json_path.exists():
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
raw = json.loads(json_path.read_text())
|
|
151
|
+
if not isinstance(raw, list):
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
init_db()
|
|
155
|
+
inserted = 0
|
|
156
|
+
for card in raw:
|
|
157
|
+
try:
|
|
158
|
+
add_card(card)
|
|
159
|
+
inserted += 1
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass # already exists — skip
|
|
162
|
+
return inserted
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fleece-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Credit card research CLI — live data via Brave Search, designed for humans and AI agents.
|
|
5
|
+
Author-email: Yuan Chen <cysbc1999@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ai,brave-search,cli,credit-cards,finance
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
21
|
+
Requires-Dist: requests>=2.31.0
|
|
22
|
+
Requires-Dist: typer[all]>=0.12.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Fleece - Credit Card Recommendation and Management App
|
|
26
|
+
|
|
27
|
+
## Overview
|
|
28
|
+
|
|
29
|
+
Fleece is a comprehensive credit card recommendation and management application built with Streamlit and LangChain. The app helps users find the best credit cards based on their spending habits and manage their existing credit card portfolio.
|
|
30
|
+
|
|
31
|
+
## Author
|
|
32
|
+
|
|
33
|
+
@chenyuan99
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
### Chat Interface
|
|
38
|
+
- Interactive AI-powered chat assistant using OpenAI's GPT models
|
|
39
|
+
- Conversation memory that remembers context across sessions
|
|
40
|
+
- Entity memory to track important information mentioned in conversations
|
|
41
|
+
- Ability to save and download conversation history
|
|
42
|
+
|
|
43
|
+
### Credit Card Recommendations
|
|
44
|
+
- Browse a curated list of popular credit cards with detailed information
|
|
45
|
+
- Filter cards by annual fee, reward type, and other criteria
|
|
46
|
+
- Get personalized card recommendations based on spending habits
|
|
47
|
+
- Compare different cards side by side
|
|
48
|
+
|
|
49
|
+
### My Credit Cards Management
|
|
50
|
+
- Track all your existing credit cards in one place
|
|
51
|
+
- Add new cards with templates or custom information
|
|
52
|
+
- Edit card details and remove cards you no longer use
|
|
53
|
+
- View portfolio insights with visualizations of credit limits and annual fees
|
|
54
|
+
- Sort and filter your cards by various criteria
|
|
55
|
+
|
|
56
|
+
## Performance Optimizations
|
|
57
|
+
|
|
58
|
+
The application includes several performance optimizations:
|
|
59
|
+
|
|
60
|
+
1. **Image Handling**
|
|
61
|
+
- Parallel image loading with ThreadPoolExecutor
|
|
62
|
+
- Multi-level image caching to reduce network requests
|
|
63
|
+
- Pre-generated default card images
|
|
64
|
+
|
|
65
|
+
2. **Data Management**
|
|
66
|
+
- Streamlit caching with TTL for expensive operations
|
|
67
|
+
- Lazy loading of UI components
|
|
68
|
+
- Optimized data structures
|
|
69
|
+
|
|
70
|
+
3. **UI/UX Improvements**
|
|
71
|
+
- Pagination for card displays
|
|
72
|
+
- Progressive loading with "Load More" buttons
|
|
73
|
+
- Form submission to reduce rerendering
|
|
74
|
+
|
|
75
|
+
## Technical Architecture
|
|
76
|
+
|
|
77
|
+
### Core Components
|
|
78
|
+
- **fleece.py**: Main application entry point with chat interface
|
|
79
|
+
- **pages/credit_cards.py**: Credit card recommendation page
|
|
80
|
+
- **pages/my_credit_cards.py**: Personal credit card management page
|
|
81
|
+
- **image_service.py**: Optimized image loading and caching service
|
|
82
|
+
- **style.css**: Custom styling for the application
|
|
83
|
+
|
|
84
|
+
### Dependencies
|
|
85
|
+
- Streamlit: Web application framework
|
|
86
|
+
- LangChain: Framework for working with language models
|
|
87
|
+
- OpenAI: API for accessing GPT models
|
|
88
|
+
- Pandas: Data manipulation and analysis
|
|
89
|
+
- PIL: Image processing
|
|
90
|
+
- Requests: HTTP requests for fetching card images
|
|
91
|
+
|
|
92
|
+
## Installation and Setup
|
|
93
|
+
|
|
94
|
+
1. Clone the repository:
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/chenyuan99/fleece.git
|
|
97
|
+
cd fleece
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
2. Create and activate a virtual environment:
|
|
101
|
+
```bash
|
|
102
|
+
python -m venv .venv
|
|
103
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. Install dependencies:
|
|
107
|
+
```bash
|
|
108
|
+
pip install -r requirements.txt
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
4. Set up your OpenAI API key in a `.env` file:
|
|
112
|
+
```
|
|
113
|
+
OPENAI_API_KEY=your_api_key_here
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
5. Run the application:
|
|
117
|
+
```bash
|
|
118
|
+
streamlit run fleece.py
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Usage Guide
|
|
122
|
+
|
|
123
|
+
### Chat Interface
|
|
124
|
+
- Enter your query in the text input field
|
|
125
|
+
- The AI assistant will respond based on the conversation context
|
|
126
|
+
- Use the "New Chat" button to start a fresh conversation
|
|
127
|
+
- Download your conversation history using the download button
|
|
128
|
+
|
|
129
|
+
### Credit Card Recommendations
|
|
130
|
+
- Browse available credit cards in the expandable card views
|
|
131
|
+
- Use sidebar filters to narrow down card options
|
|
132
|
+
- Enter your spending habits in the form to get personalized recommendations
|
|
133
|
+
- Click "Apply" on any card to start the application process
|
|
134
|
+
|
|
135
|
+
### My Credit Cards
|
|
136
|
+
- View all your cards with detailed information
|
|
137
|
+
- Use the "Add New Card" tab to add a new credit card
|
|
138
|
+
- Choose from templates or create a custom card entry
|
|
139
|
+
- Edit or remove existing cards as needed
|
|
140
|
+
- View portfolio insights to understand your credit profile
|
|
141
|
+
|
|
142
|
+
## Resources
|
|
143
|
+
|
|
144
|
+
- [OpenAI](https://openai.com/)
|
|
145
|
+
- [LangChain](https://langchain.readthedocs.io/)
|
|
146
|
+
- [Streamlit](https://streamlit.io/)
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
151
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cli.py,sha256=6H1jaAH2-GUXSNBd3sRfkmq488PCeDI5xxGOf3sQmeE,14635
|
|
2
|
+
db.py,sha256=x-oQYlCdZbyIupWhPGHgM9ZjRXkZ5RFIA1uI9YNHlok,5264
|
|
3
|
+
tools/__init__.py,sha256=Bv38s7NE-dZbJt-FsatsiWDQZbFNcGygdaxK6rtFCqA,75
|
|
4
|
+
tools/brave_client.py,sha256=kY4bOIzBZ-Ms0_qxC10q98XJKpq4zpiRYdr_Y5cG9Wg,2087
|
|
5
|
+
tools/credit_card_tools.py,sha256=Zxbq_3IT268b5qIm-D54xhvsuRYJyllr3SFHxrq-tjU,11279
|
|
6
|
+
fleece_cli-0.1.0.dist-info/METADATA,sha256=voWV4ueBzSjpl9LDDxScPwgM8rfGGr6Qv3IjoqubTIQ,4834
|
|
7
|
+
fleece_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
fleece_cli-0.1.0.dist-info/entry_points.txt,sha256=Lfg5NUmgv1I4rEQ8-0_Pvw1Zil6jJCQPzBd1NLIy3XI,35
|
|
9
|
+
fleece_cli-0.1.0.dist-info/licenses/LICENSE,sha256=O-5w9AgSe9tlcKUD8TeZ7iPvQAlc8nRY3l8bDCLc8qo,1066
|
|
10
|
+
fleece_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Yuan Chen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tools/__init__.py
ADDED
tools/brave_client.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pydantic import SecretStr
|
|
3
|
+
|
|
4
|
+
PRIORITY_SITES = [
|
|
5
|
+
"chase.com",
|
|
6
|
+
"americanexpress.com",
|
|
7
|
+
"citi.com",
|
|
8
|
+
"capitalone.com",
|
|
9
|
+
"bankofamerica.com",
|
|
10
|
+
"wellsfargo.com",
|
|
11
|
+
"discover.com",
|
|
12
|
+
"barclays.com",
|
|
13
|
+
"biltrewards.com",
|
|
14
|
+
"nerdwallet.com",
|
|
15
|
+
"thepointsguy.com",
|
|
16
|
+
"doctorofcredit.com",
|
|
17
|
+
"bankrate.com",
|
|
18
|
+
"onemileatatime.com",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_brave_wrapper(api_key: str, freshness: str | None = None, count: int = 5):
|
|
23
|
+
from langchain_community.utilities import BraveSearchWrapper
|
|
24
|
+
|
|
25
|
+
search_kwargs: dict = {"count": count}
|
|
26
|
+
if freshness:
|
|
27
|
+
search_kwargs["freshness"] = freshness
|
|
28
|
+
|
|
29
|
+
return BraveSearchWrapper(
|
|
30
|
+
api_key=SecretStr(api_key),
|
|
31
|
+
search_kwargs=search_kwargs,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def rerank_results(results: list[dict]) -> list[dict]:
|
|
36
|
+
def priority(r: dict) -> int:
|
|
37
|
+
url = r.get("link", r.get("url", ""))
|
|
38
|
+
for i, domain in enumerate(PRIORITY_SITES):
|
|
39
|
+
if domain in url:
|
|
40
|
+
return i
|
|
41
|
+
return len(PRIORITY_SITES)
|
|
42
|
+
|
|
43
|
+
return sorted(results, key=priority)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_results_for_llm(results: list[dict], max_results: int = 4) -> str:
|
|
47
|
+
lines = []
|
|
48
|
+
for r in results[:max_results]:
|
|
49
|
+
title = r.get("title", "No title")
|
|
50
|
+
source = r.get("link", r.get("url", "Unknown source"))
|
|
51
|
+
snippet = r.get("snippet", r.get("description", ""))
|
|
52
|
+
if len(snippet) > 300:
|
|
53
|
+
snippet = snippet[:297] + "..."
|
|
54
|
+
lines.append(f"{title} | {source} | {snippet}")
|
|
55
|
+
return "\n".join(lines) if lines else "No results found."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def search_and_format(wrapper, query: str, max_results: int = 4) -> str:
|
|
59
|
+
"""Run a Brave search and return LLM-ready formatted results.
|
|
60
|
+
|
|
61
|
+
Falls back gracefully: tries _search() for structured reranking,
|
|
62
|
+
falls back to run() string output if _search() is unavailable.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
raw: list[dict] = wrapper._search(query)
|
|
66
|
+
reranked = rerank_results(raw)
|
|
67
|
+
return format_results_for_llm(reranked, max_results=max_results)
|
|
68
|
+
except (AttributeError, Exception):
|
|
69
|
+
return wrapper.run(query)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
from langchain_core.tools import tool, BaseTool, StructuredTool
|
|
4
|
+
|
|
5
|
+
from tools.brave_client import build_brave_wrapper, search_and_format
|
|
6
|
+
|
|
7
|
+
# Cents-per-point defaults by issuer keyword for ROI calculation
|
|
8
|
+
_CPP_MAP = {
|
|
9
|
+
"chase": 1.5,
|
|
10
|
+
"sapphire": 1.5,
|
|
11
|
+
"freedom": 1.0,
|
|
12
|
+
"amex": 1.8,
|
|
13
|
+
"american express": 1.8,
|
|
14
|
+
"gold": 1.8,
|
|
15
|
+
"platinum": 1.8,
|
|
16
|
+
"capital one": 1.35,
|
|
17
|
+
"venture": 1.35,
|
|
18
|
+
"citi": 1.6,
|
|
19
|
+
"strata": 1.6,
|
|
20
|
+
"bilt": 1.7,
|
|
21
|
+
"marriott": 0.8,
|
|
22
|
+
"hilton": 0.5,
|
|
23
|
+
"delta": 1.1,
|
|
24
|
+
"united": 1.2,
|
|
25
|
+
"southwest": 1.5,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _guess_cpp(card_name: str) -> float:
|
|
30
|
+
lower = card_name.lower()
|
|
31
|
+
for keyword, cpp in _CPP_MAP.items():
|
|
32
|
+
if keyword in lower:
|
|
33
|
+
return cpp
|
|
34
|
+
return 1.0 # default to cash back value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Pydantic schemas for multi-argument tools
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
class CompareCardsInput(BaseModel):
|
|
42
|
+
card_a: str = Field(description="Name of the first credit card")
|
|
43
|
+
card_b: str = Field(description="Name of the second credit card")
|
|
44
|
+
comparison_aspects: str = Field(
|
|
45
|
+
default="fees,rewards,welcome_offer,credits,transfer_partners",
|
|
46
|
+
description="Comma-separated aspects to compare",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FirstYearROIInput(BaseModel):
|
|
51
|
+
card_name: str = Field(description="Name of the credit card")
|
|
52
|
+
monthly_spend_travel: float = Field(default=0, description="Monthly travel spend in dollars")
|
|
53
|
+
monthly_spend_dining: float = Field(default=0, description="Monthly dining spend in dollars")
|
|
54
|
+
monthly_spend_other: float = Field(default=0, description="Monthly other spend in dollars")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AnalyzeWalletInput(BaseModel):
|
|
58
|
+
cards_owned: str = Field(description="Comma-separated list of credit cards the user currently holds")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RecommendCardsInput(BaseModel):
|
|
62
|
+
spending_profile: str = Field(description="Description of the user's spending habits and priorities")
|
|
63
|
+
preferences: str = Field(default="", description="Additional preferences (e.g. no annual fee, travel perks)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Tool factory
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def build_tools(brave_api_key: str) -> list[BaseTool]:
|
|
71
|
+
wrapper = build_brave_wrapper(brave_api_key)
|
|
72
|
+
recent_wrapper = build_brave_wrapper(brave_api_key, freshness="pm")
|
|
73
|
+
|
|
74
|
+
# --- Tool 1: Full card report ---
|
|
75
|
+
@tool
|
|
76
|
+
def search_card_full_report(card_name: str) -> str:
|
|
77
|
+
"""Look up a comprehensive credit card report including annual fee, welcome offer,
|
|
78
|
+
earning rates, statement credits, benefits, and redemption strategy.
|
|
79
|
+
Use when the user asks about a specific card in detail."""
|
|
80
|
+
query = f'"{card_name}" credit card annual fee welcome offer earning rates benefits credits 2025'
|
|
81
|
+
return search_and_format(wrapper, query)
|
|
82
|
+
|
|
83
|
+
# --- Tool 2: Earning rates ---
|
|
84
|
+
@tool
|
|
85
|
+
def search_card_earning_rates(card_name: str, category: str = "") -> str:
|
|
86
|
+
"""Find the earning rates for a credit card, optionally filtered to a spending category
|
|
87
|
+
(dining, travel, groceries, gas, etc.).
|
|
88
|
+
Use when the user asks how many points, miles, or cash back a card earns."""
|
|
89
|
+
category_clause = f"{category} " if category else ""
|
|
90
|
+
query = f'"{card_name}" {category_clause}earning rates points miles cashback per dollar categories 2025'
|
|
91
|
+
return search_and_format(wrapper, query)
|
|
92
|
+
|
|
93
|
+
# --- Tool 3: Transfer partners ---
|
|
94
|
+
@tool
|
|
95
|
+
def search_transfer_partners(card_name: str) -> str:
|
|
96
|
+
"""Find the airline and hotel transfer partners for a credit card's rewards program,
|
|
97
|
+
including transfer ratios and timing.
|
|
98
|
+
Use when the user asks about transfer partners or moving points to airlines/hotels."""
|
|
99
|
+
query = f'"{card_name}" transfer partners airlines hotels ratio transfer time 2025'
|
|
100
|
+
return search_and_format(wrapper, query)
|
|
101
|
+
|
|
102
|
+
# --- Tool 4: Statement credits ---
|
|
103
|
+
@tool
|
|
104
|
+
def search_statement_credits(card_name: str) -> str:
|
|
105
|
+
"""Look up all statement credits and perks a credit card offers — dining, travel,
|
|
106
|
+
streaming, hotel, airline credits — including enrollment requirements.
|
|
107
|
+
Use when the user asks about credits, perks, or offsetting the annual fee."""
|
|
108
|
+
query = f'"{card_name}" statement credits perks benefits complete list how to use 2025'
|
|
109
|
+
return search_and_format(wrapper, query)
|
|
110
|
+
|
|
111
|
+
# --- Tool 5: First-year ROI ---
|
|
112
|
+
def _calculate_first_year_roi(
|
|
113
|
+
card_name: str,
|
|
114
|
+
monthly_spend_travel: float = 0,
|
|
115
|
+
monthly_spend_dining: float = 0,
|
|
116
|
+
monthly_spend_other: float = 0,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Calculate the estimated first-year return on investment for a credit card given
|
|
119
|
+
the user's spending habits.
|
|
120
|
+
Use when the user wants to know if a card is worth it based on how they spend."""
|
|
121
|
+
annual_travel = monthly_spend_travel * 12
|
|
122
|
+
annual_dining = monthly_spend_dining * 12
|
|
123
|
+
annual_other = monthly_spend_other * 12
|
|
124
|
+
|
|
125
|
+
query = f'"{card_name}" annual fee welcome bonus points value first year 2025'
|
|
126
|
+
research = search_and_format(wrapper, query)
|
|
127
|
+
|
|
128
|
+
cpp = _guess_cpp(card_name)
|
|
129
|
+
spend_summary = (
|
|
130
|
+
f"Annual spend — Travel: ${annual_travel:,.0f} | "
|
|
131
|
+
f"Dining: ${annual_dining:,.0f} | "
|
|
132
|
+
f"Other: ${annual_other:,.0f}\n"
|
|
133
|
+
f"Assumed value per point: {cpp}¢\n\n"
|
|
134
|
+
f"Live research results:\n{research}\n\n"
|
|
135
|
+
"Based on the research above, calculate the net first-year value as: "
|
|
136
|
+
"welcome bonus value + estimated annual earn value + annual credits - annual fee. "
|
|
137
|
+
"Show the math step by step."
|
|
138
|
+
)
|
|
139
|
+
return spend_summary
|
|
140
|
+
|
|
141
|
+
calculate_first_year_roi = StructuredTool.from_function(
|
|
142
|
+
func=_calculate_first_year_roi,
|
|
143
|
+
name="calculate_first_year_roi",
|
|
144
|
+
description=(
|
|
145
|
+
"Calculate the estimated first-year return on investment for a credit card "
|
|
146
|
+
"given the user's monthly spending on travel, dining, and other categories. "
|
|
147
|
+
"Use when the user wants to know if a card is worth it for them."
|
|
148
|
+
),
|
|
149
|
+
args_schema=FirstYearROIInput,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# --- Tool 6: Compare two cards ---
|
|
153
|
+
def _compare_cards(
|
|
154
|
+
card_a: str,
|
|
155
|
+
card_b: str,
|
|
156
|
+
comparison_aspects: str = "fees,rewards,welcome_offer,credits,transfer_partners",
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Compare two credit cards side by side across fees, rewards, welcome offers,
|
|
159
|
+
credits, and transfer partners.
|
|
160
|
+
Use when the user asks 'which is better' or wants to compare two specific cards."""
|
|
161
|
+
aspects = comparison_aspects.replace(",", ", ")
|
|
162
|
+
query_a = f'"{card_a}" credit card {aspects} 2025'
|
|
163
|
+
query_b = f'"{card_b}" credit card {aspects} 2025'
|
|
164
|
+
results_a = search_and_format(wrapper, query_a)
|
|
165
|
+
results_b = search_and_format(wrapper, query_b)
|
|
166
|
+
return (
|
|
167
|
+
f"## {card_a}\n{results_a}\n\n"
|
|
168
|
+
f"## {card_b}\n{results_b}\n\n"
|
|
169
|
+
"Compare the two cards above across these aspects and present a side-by-side "
|
|
170
|
+
f"markdown table: {aspects}."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
compare_cards = StructuredTool.from_function(
|
|
174
|
+
func=_compare_cards,
|
|
175
|
+
name="compare_cards",
|
|
176
|
+
description=(
|
|
177
|
+
"Compare two credit cards side by side. Searches for current data on both cards "
|
|
178
|
+
"and produces a structured comparison. Use when the user asks 'which is better' "
|
|
179
|
+
"or wants to compare two specific cards."
|
|
180
|
+
),
|
|
181
|
+
args_schema=CompareCardsInput,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# --- Tool 7: Wallet / portfolio analysis ---
|
|
185
|
+
def _analyze_wallet(cards_owned: str) -> str:
|
|
186
|
+
"""Analyze the user's existing credit card portfolio for gaps, overlaps, and
|
|
187
|
+
optimization opportunities.
|
|
188
|
+
Use when the user says 'I have X and Y cards, how do I maximize?' or asks about their wallet."""
|
|
189
|
+
card_list = [c.strip() for c in cards_owned.split(",") if c.strip()]
|
|
190
|
+
results = []
|
|
191
|
+
for card in card_list:
|
|
192
|
+
query = f'"{card}" earning rates categories benefits 2025'
|
|
193
|
+
result = search_and_format(wrapper, query, max_results=2)
|
|
194
|
+
results.append(f"### {card}\n{result}")
|
|
195
|
+
|
|
196
|
+
combined = "\n\n".join(results)
|
|
197
|
+
return (
|
|
198
|
+
f"{combined}\n\n"
|
|
199
|
+
"Based on the card details above, identify:\n"
|
|
200
|
+
"1. Category coverage map (which card to use for travel, dining, groceries, gas, etc.)\n"
|
|
201
|
+
"2. Overlapping benefits or redundant earning categories\n"
|
|
202
|
+
"3. Gaps — spending categories with no bonus multiplier\n"
|
|
203
|
+
"4. Top 1-2 cards that would complement this portfolio"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
analyze_wallet = StructuredTool.from_function(
|
|
207
|
+
func=_analyze_wallet,
|
|
208
|
+
name="analyze_wallet",
|
|
209
|
+
description=(
|
|
210
|
+
"Analyze the user's existing credit card portfolio for gaps, overlaps, and "
|
|
211
|
+
"optimization opportunities. Use when the user says 'I have X and Y cards, "
|
|
212
|
+
"how do I maximize?' or asks about their wallet/portfolio."
|
|
213
|
+
),
|
|
214
|
+
args_schema=AnalyzeWalletInput,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# --- Tool 8: Recent news / changes ---
|
|
218
|
+
@tool
|
|
219
|
+
def search_recent_changes(card_name: str) -> str:
|
|
220
|
+
"""Search for recent news and changes to a credit card in the last month —
|
|
221
|
+
benefit cuts, fee increases, new perks, or limited-time offers.
|
|
222
|
+
Use when the user asks if anything has changed recently about a card."""
|
|
223
|
+
query = f'"{card_name}" credit card changes update news 2025'
|
|
224
|
+
return search_and_format(recent_wrapper, query)
|
|
225
|
+
|
|
226
|
+
# --- Tool 9: Profile-based recommendations ---
|
|
227
|
+
def _recommend_cards_for_profile(
|
|
228
|
+
spending_profile: str,
|
|
229
|
+
preferences: str = "",
|
|
230
|
+
) -> str:
|
|
231
|
+
"""Search for the best US credit cards matching a spending profile and preferences.
|
|
232
|
+
Use when the user asks for card recommendations based on how they spend."""
|
|
233
|
+
pref_clause = f"{preferences} " if preferences else ""
|
|
234
|
+
query = (
|
|
235
|
+
f"best credit cards {spending_profile} {pref_clause}US 2025 "
|
|
236
|
+
f"site:nerdwallet.com OR site:thepointsguy.com OR site:doctorofcredit.com"
|
|
237
|
+
)
|
|
238
|
+
return search_and_format(wrapper, query)
|
|
239
|
+
|
|
240
|
+
recommend_cards_for_profile = StructuredTool.from_function(
|
|
241
|
+
func=_recommend_cards_for_profile,
|
|
242
|
+
name="recommend_cards_for_profile",
|
|
243
|
+
description=(
|
|
244
|
+
"Search for the best US credit cards matching a spending profile and preferences "
|
|
245
|
+
"(e.g. 'high dining and travel spend, no annual fee preferred'). "
|
|
246
|
+
"Use when the user asks for card recommendations based on their habits."
|
|
247
|
+
),
|
|
248
|
+
args_schema=RecommendCardsInput,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return [
|
|
252
|
+
search_card_full_report,
|
|
253
|
+
search_card_earning_rates,
|
|
254
|
+
search_transfer_partners,
|
|
255
|
+
search_statement_credits,
|
|
256
|
+
calculate_first_year_roi,
|
|
257
|
+
compare_cards,
|
|
258
|
+
analyze_wallet,
|
|
259
|
+
search_recent_changes,
|
|
260
|
+
recommend_cards_for_profile,
|
|
261
|
+
]
|