fin-infra 0.1.62__py3-none-any.whl → 0.1.82__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.
Files changed (126) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +30 -32
  4. fin_infra/analytics/cash_flow.py +6 -5
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/portfolio.py +19 -26
  7. fin_infra/analytics/projections.py +1 -3
  8. fin_infra/analytics/rebalancing.py +2 -4
  9. fin_infra/analytics/savings.py +1 -1
  10. fin_infra/analytics/spending.py +15 -11
  11. fin_infra/banking/__init__.py +33 -31
  12. fin_infra/banking/history.py +11 -12
  13. fin_infra/banking/utils.py +116 -110
  14. fin_infra/brokerage/__init__.py +27 -27
  15. fin_infra/budgets/__init__.py +3 -3
  16. fin_infra/budgets/add.py +16 -17
  17. fin_infra/budgets/alerts.py +3 -3
  18. fin_infra/budgets/tracker.py +4 -5
  19. fin_infra/cashflows/__init__.py +8 -10
  20. fin_infra/cashflows/core.py +1 -1
  21. fin_infra/categorization/__init__.py +1 -1
  22. fin_infra/categorization/add.py +17 -19
  23. fin_infra/categorization/ease.py +3 -4
  24. fin_infra/categorization/engine.py +21 -18
  25. fin_infra/categorization/llm_layer.py +10 -10
  26. fin_infra/categorization/models.py +1 -1
  27. fin_infra/categorization/rules.py +2 -4
  28. fin_infra/categorization/taxonomy.py +2 -2
  29. fin_infra/chat/__init__.py +13 -22
  30. fin_infra/chat/planning.py +57 -1
  31. fin_infra/cli/cmds/scaffold_cmds.py +11 -12
  32. fin_infra/clients/__init__.py +23 -1
  33. fin_infra/clients/base.py +1 -1
  34. fin_infra/clients/plaid.py +2 -2
  35. fin_infra/compliance/__init__.py +7 -6
  36. fin_infra/credit/add.py +7 -7
  37. fin_infra/credit/experian/auth.py +3 -2
  38. fin_infra/credit/experian/client.py +2 -2
  39. fin_infra/credit/experian/provider.py +19 -19
  40. fin_infra/crypto/__init__.py +8 -10
  41. fin_infra/crypto/insights.py +5 -6
  42. fin_infra/documents/add.py +11 -13
  43. fin_infra/documents/analysis.py +9 -9
  44. fin_infra/documents/ease.py +18 -17
  45. fin_infra/documents/models.py +7 -7
  46. fin_infra/documents/ocr.py +8 -8
  47. fin_infra/documents/storage.py +23 -14
  48. fin_infra/exceptions.py +1 -2
  49. fin_infra/goals/__init__.py +8 -8
  50. fin_infra/goals/add.py +36 -36
  51. fin_infra/goals/funding.py +4 -6
  52. fin_infra/goals/management.py +6 -7
  53. fin_infra/goals/milestones.py +2 -3
  54. fin_infra/goals/models.py +7 -11
  55. fin_infra/insights/__init__.py +12 -10
  56. fin_infra/insights/aggregator.py +1 -1
  57. fin_infra/investments/__init__.py +14 -9
  58. fin_infra/investments/add.py +53 -73
  59. fin_infra/investments/ease.py +16 -13
  60. fin_infra/investments/models.py +135 -69
  61. fin_infra/investments/providers/base.py +9 -15
  62. fin_infra/investments/providers/plaid.py +70 -55
  63. fin_infra/investments/providers/snaptrade.py +35 -53
  64. fin_infra/markets/__init__.py +16 -11
  65. fin_infra/models/__init__.py +10 -10
  66. fin_infra/models/accounts.py +2 -1
  67. fin_infra/models/brokerage.py +2 -1
  68. fin_infra/models/candle.py +1 -0
  69. fin_infra/models/money.py +1 -0
  70. fin_infra/models/quotes.py +4 -3
  71. fin_infra/models/tax.py +2 -1
  72. fin_infra/models/transactions.py +4 -4
  73. fin_infra/net_worth/__init__.py +7 -0
  74. fin_infra/net_worth/add.py +8 -5
  75. fin_infra/net_worth/aggregator.py +9 -6
  76. fin_infra/net_worth/calculator.py +8 -6
  77. fin_infra/net_worth/ease.py +36 -15
  78. fin_infra/net_worth/insights.py +4 -5
  79. fin_infra/net_worth/models.py +237 -116
  80. fin_infra/normalization/__init__.py +17 -15
  81. fin_infra/normalization/providers/exchangerate.py +5 -5
  82. fin_infra/obs/classifier.py +3 -3
  83. fin_infra/providers/banking/plaid_client.py +23 -22
  84. fin_infra/providers/banking/teller_client.py +14 -7
  85. fin_infra/providers/base.py +131 -14
  86. fin_infra/providers/brokerage/alpaca.py +7 -7
  87. fin_infra/providers/credit/experian.py +5 -0
  88. fin_infra/providers/market/alphavantage.py +6 -11
  89. fin_infra/providers/market/ccxt_crypto.py +25 -4
  90. fin_infra/providers/market/coingecko.py +5 -6
  91. fin_infra/providers/market/yahoo.py +23 -8
  92. fin_infra/providers/tax/__init__.py +1 -1
  93. fin_infra/providers/tax/irs.py +1 -1
  94. fin_infra/providers/tax/mock.py +8 -8
  95. fin_infra/providers/tax/taxbit.py +1 -1
  96. fin_infra/recurring/__init__.py +6 -6
  97. fin_infra/recurring/add.py +24 -12
  98. fin_infra/recurring/detector.py +8 -8
  99. fin_infra/recurring/detectors_llm.py +14 -13
  100. fin_infra/recurring/ease.py +3 -5
  101. fin_infra/recurring/insights.py +20 -19
  102. fin_infra/recurring/models.py +3 -3
  103. fin_infra/recurring/normalizer.py +3 -2
  104. fin_infra/recurring/normalizers.py +11 -10
  105. fin_infra/recurring/summary.py +13 -15
  106. fin_infra/scaffold/__init__.py +1 -1
  107. fin_infra/scaffold/budgets.py +9 -9
  108. fin_infra/scaffold/goals.py +5 -5
  109. fin_infra/security/__init__.py +8 -8
  110. fin_infra/security/encryption.py +6 -6
  111. fin_infra/security/models.py +7 -7
  112. fin_infra/security/pii_filter.py +6 -6
  113. fin_infra/security/pii_patterns.py +1 -1
  114. fin_infra/security/token_store.py +3 -1
  115. fin_infra/settings.py +2 -1
  116. fin_infra/tax/__init__.py +2 -2
  117. fin_infra/tax/add.py +3 -2
  118. fin_infra/tax/tlh.py +5 -5
  119. fin_infra/utils/http.py +5 -3
  120. fin_infra/utils/retry.py +2 -1
  121. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
  122. fin_infra-0.1.82.dist-info/RECORD +180 -0
  123. fin_infra-0.1.62.dist-info/RECORD +0 -180
  124. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
  125. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
  126. {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
@@ -22,8 +22,8 @@ Example:
22
22
  from decimal import Decimal
23
23
 
24
24
  from fin_infra.models.tax import (
25
- TaxDocument,
26
25
  CryptoTaxReport,
26
+ TaxDocument,
27
27
  TaxLiability,
28
28
  )
29
29
  from fin_infra.providers.base import TaxProvider
@@ -14,14 +14,14 @@ from datetime import date, datetime
14
14
  from decimal import Decimal
15
15
 
16
16
  from fin_infra.models.tax import (
17
+ CryptoTaxReport,
18
+ CryptoTransaction,
17
19
  TaxDocument,
18
- TaxFormW2,
19
- TaxForm1099INT,
20
- TaxForm1099DIV,
21
20
  TaxForm1099B,
21
+ TaxForm1099DIV,
22
+ TaxForm1099INT,
22
23
  TaxForm1099MISC,
23
- CryptoTransaction,
24
- CryptoTaxReport,
24
+ TaxFormW2,
25
25
  TaxLiability,
26
26
  )
27
27
  from fin_infra.providers.base import TaxProvider
@@ -313,9 +313,9 @@ class MockTaxProvider(TaxProvider):
313
313
  return CryptoTaxReport(
314
314
  user_id=user_id,
315
315
  tax_year=tax_year,
316
- total_gain_loss=short_term + long_term,
317
- short_term_gain_loss=short_term,
318
- long_term_gain_loss=long_term,
316
+ total_gain_loss=Decimal(short_term + long_term),
317
+ short_term_gain_loss=Decimal(short_term),
318
+ long_term_gain_loss=Decimal(long_term),
319
319
  transaction_count=len(crypto_transactions),
320
320
  cost_basis_method=cost_basis_method,
321
321
  transactions=crypto_transactions,
@@ -21,8 +21,8 @@ Example:
21
21
  from decimal import Decimal
22
22
 
23
23
  from fin_infra.models.tax import (
24
- TaxDocument,
25
24
  CryptoTaxReport,
25
+ TaxDocument,
26
26
  TaxLiability,
27
27
  )
28
28
  from fin_infra.providers.base import TaxProvider
@@ -46,18 +46,18 @@ from .models import (
46
46
  SubscriptionDetection,
47
47
  SubscriptionStats,
48
48
  )
49
- from .summary import (
50
- CancellationOpportunity,
51
- RecurringItem,
52
- RecurringSummary,
53
- get_recurring_summary,
54
- )
55
49
  from .normalizer import (
56
50
  FuzzyMatcher,
57
51
  get_canonical_merchant,
58
52
  is_generic_merchant,
59
53
  normalize_merchant,
60
54
  )
55
+ from .summary import (
56
+ CancellationOpportunity,
57
+ RecurringItem,
58
+ RecurringSummary,
59
+ get_recurring_summary,
60
+ )
61
61
 
62
62
  __all__ = [
63
63
  # Easy builders
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import time
13
13
  from datetime import datetime, timedelta
14
- from typing import TYPE_CHECKING, Optional
14
+ from typing import TYPE_CHECKING, Any
15
15
 
16
16
  from .ease import easy_recurring_detection
17
17
  from .models import (
@@ -24,6 +24,7 @@ from .models import (
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from fastapi import FastAPI
27
+
27
28
  from .detector import RecurringDetector
28
29
 
29
30
 
@@ -35,9 +36,9 @@ def add_recurring_detection(
35
36
  date_tolerance_days: int = 7,
36
37
  enable_llm: bool = False,
37
38
  llm_provider: str = "google",
38
- llm_model: Optional[str] = None,
39
+ llm_model: str | None = None,
39
40
  include_in_schema: bool = True,
40
- ) -> "RecurringDetector":
41
+ ) -> RecurringDetector:
41
42
  """
42
43
  Add recurring transaction detection endpoints to FastAPI app.
43
44
 
@@ -93,7 +94,7 @@ def add_recurring_detection(
93
94
  llm_model=llm_model,
94
95
  )
95
96
 
96
- # Store on app.state
97
+ # Store on app.state
97
98
  app.state.recurring_detector = detector
98
99
 
99
100
  # Use svc-infra user_router for authentication (recurring detection is user-specific)
@@ -133,7 +134,7 @@ def add_recurring_detection(
133
134
  # For now, return empty result with structure.
134
135
  # In production: transactions = get_user_transactions(user.id, days=request.days)
135
136
 
136
- transactions = [] # Placeholder
137
+ transactions: list[dict[str, Any]] = [] # Placeholder
137
138
 
138
139
  # Detect patterns
139
140
  patterns = detector.detect_patterns(transactions)
@@ -180,7 +181,7 @@ def add_recurring_detection(
180
181
  # return cached
181
182
 
182
183
  # Detect patterns (same as /detect endpoint)
183
- transactions = [] # Placeholder
184
+ transactions: list[dict[str, Any]] = [] # Placeholder
184
185
  patterns = detector.detect_patterns(transactions)
185
186
  patterns = [p for p in patterns if p.confidence >= min_confidence]
186
187
 
@@ -208,7 +209,7 @@ def add_recurring_detection(
208
209
  List of predicted charges with expected dates and amounts
209
210
  """
210
211
  # Get detected patterns
211
- transactions = [] # Placeholder
212
+ transactions: list[dict[str, Any]] = [] # Placeholder
212
213
  patterns = detector.detect_patterns(transactions)
213
214
  patterns = [p for p in patterns if p.confidence >= min_confidence]
214
215
 
@@ -230,7 +231,7 @@ def add_recurring_detection(
230
231
  - Top merchants by amount
231
232
  """
232
233
  # Get all detected patterns
233
- transactions = [] # Placeholder
234
+ transactions: list[dict[str, Any]] = [] # Placeholder
234
235
  patterns = detector.detect_patterns(transactions)
235
236
 
236
237
  # Calculate stats
@@ -242,7 +243,7 @@ def add_recurring_detection(
242
243
  @router.get("/summary")
243
244
  async def get_recurring_summary(
244
245
  user_id: str,
245
- category_map: Optional[dict[str, str]] = None,
246
+ category_map: dict[str, str] | None = None,
246
247
  ):
247
248
  """
248
249
  Get comprehensive recurring transaction summary.
@@ -321,7 +322,9 @@ def add_recurring_detection(
321
322
  from .summary import get_recurring_summary
322
323
 
323
324
  # Get detected patterns for user
324
- transactions = [] # Placeholder - in production: get_user_transactions(user_id)
325
+ transactions: list[
326
+ dict[str, Any]
327
+ ] = [] # Placeholder - in production: get_user_transactions(user_id)
325
328
  patterns = detector.detect_patterns(transactions)
326
329
 
327
330
  # Generate summary
@@ -375,7 +378,7 @@ def add_recurring_detection(
375
378
  **Cost:** ~$0.0002/generation with Google Gemini, <$0.00004 effective with caching
376
379
  """
377
380
  # Get detected patterns
378
- transactions = [] # Placeholder
381
+ transactions: list[dict[str, Any]] = [] # Placeholder
379
382
  patterns = detector.detect_patterns(transactions)
380
383
 
381
384
  # Convert patterns to subscription dicts for LLM
@@ -403,7 +406,16 @@ def add_recurring_detection(
403
406
 
404
407
  # Generate insights with LLM
405
408
  # TODO: Pass user_id for better caching (currently uses subscriptions hash)
406
- insights = await detector.insights_generator.generate(subscriptions)
409
+ insights_generator = detector.insights_generator
410
+ if insights_generator is None:
411
+ from fastapi import HTTPException
412
+
413
+ raise HTTPException(
414
+ status_code=500,
415
+ detail="Subscription insights generator not configured (enable_llm=True required).",
416
+ )
417
+
418
+ insights = await insights_generator.generate(subscriptions)
407
419
 
408
420
  return insights
409
421
  else:
@@ -17,15 +17,15 @@ from __future__ import annotations
17
17
  import statistics
18
18
  from collections import defaultdict
19
19
  from datetime import datetime, timedelta
20
- from typing import Any, Optional, TYPE_CHECKING
20
+ from typing import TYPE_CHECKING, Any
21
21
 
22
22
  from .models import CadenceType, PatternType, RecurringPattern
23
23
  from .normalizer import get_canonical_merchant, is_generic_merchant
24
24
 
25
25
  if TYPE_CHECKING:
26
- from .normalizers import MerchantNormalizer
27
26
  from .detectors_llm import VariableDetectorLLM
28
27
  from .insights import SubscriptionInsightsGenerator
28
+ from .normalizers import MerchantNormalizer
29
29
 
30
30
 
31
31
  class Transaction:
@@ -59,8 +59,8 @@ class PatternDetector:
59
59
  min_occurrences: int = 3,
60
60
  amount_tolerance: float = 0.02,
61
61
  date_tolerance_days: int = 7,
62
- merchant_normalizer: Optional[MerchantNormalizer] = None,
63
- variable_detector_llm: Optional[VariableDetectorLLM] = None,
62
+ merchant_normalizer: MerchantNormalizer | None = None,
63
+ variable_detector_llm: VariableDetectorLLM | None = None,
64
64
  ):
65
65
  """
66
66
  Initialize pattern detector.
@@ -450,7 +450,7 @@ class PatternDetector:
450
450
  min_amt, max_amt = pattern.amount_range or (0, 0)
451
451
  return (
452
452
  f"Variable amount ${min_amt:.2f}-${max_amt:.2f} charged {pattern.cadence.value} "
453
- f"({pattern.amount_variance_pct*100:.1f}% variance, "
453
+ f"({pattern.amount_variance_pct * 100:.1f}% variance, "
454
454
  f"{pattern.occurrence_count} occurrences)"
455
455
  )
456
456
  else: # IRREGULAR
@@ -512,9 +512,9 @@ class RecurringDetector:
512
512
  min_occurrences: int = 3,
513
513
  amount_tolerance: float = 0.02,
514
514
  date_tolerance_days: int = 7,
515
- merchant_normalizer: Optional[MerchantNormalizer] = None,
516
- variable_detector_llm: Optional[VariableDetectorLLM] = None,
517
- insights_generator: Optional[SubscriptionInsightsGenerator] = None,
515
+ merchant_normalizer: MerchantNormalizer | None = None,
516
+ variable_detector_llm: VariableDetectorLLM | None = None,
517
+ insights_generator: SubscriptionInsightsGenerator | None = None,
518
518
  ):
519
519
  """
520
520
  Initialize recurring detector.
@@ -14,15 +14,18 @@ Only called for ambiguous patterns (20-40% variance, ~10% of patterns).
14
14
  from __future__ import annotations
15
15
 
16
16
  import logging
17
- from typing import Any, Optional
17
+ from typing import Any, cast
18
18
 
19
19
  from pydantic import BaseModel, ConfigDict, Field
20
20
 
21
21
  # Lazy import for optional dependency (ai-infra)
22
22
  try:
23
23
  from ai_infra.llm import LLM
24
+
25
+ LLM_AVAILABLE = True
24
26
  except ImportError:
25
- LLM = None
27
+ LLM = None # type: ignore[misc,assignment]
28
+ LLM_AVAILABLE = False
26
29
 
27
30
  logger = logging.getLogger(__name__)
28
31
 
@@ -38,14 +41,14 @@ class VariableRecurringPattern(BaseModel):
38
41
  ...,
39
42
  description="True if pattern is recurring despite variance",
40
43
  )
41
- cadence: Optional[str] = Field(
44
+ cadence: str | None = Field(
42
45
  None,
43
46
  description=(
44
47
  "Frequency if recurring: monthly, bi-weekly, quarterly, annual, etc. "
45
48
  "None if not recurring."
46
49
  ),
47
50
  )
48
- expected_range: Optional[tuple[float, float]] = Field(
51
+ expected_range: tuple[float, float] | None = Field(
49
52
  None,
50
53
  description=(
51
54
  "Expected amount range (min, max) if recurring. "
@@ -73,7 +76,7 @@ class VariableRecurringPattern(BaseModel):
73
76
  "example": {
74
77
  "is_recurring": True,
75
78
  "cadence": "monthly",
76
- "expected_range": (45.0, 60.0),
79
+ "expected_range": [45.0, 60.0],
77
80
  "reasoning": "Seasonal winter heating causes variance",
78
81
  "confidence": 0.85,
79
82
  }
@@ -97,7 +100,7 @@ Examples:
97
100
  1. Merchant: "City Electric"
98
101
  Amounts: [$45, $52, $48, $55, $50, $49]
99
102
  Dates: Monthly (15th ±7 days)
100
- → is_recurring: true, cadence: "monthly", range: (40, 60),
103
+ → is_recurring: true, cadence: "monthly", range: (40, 60),
101
104
  reasoning: "Seasonal winter heating variation", confidence: 0.85
102
105
 
103
106
  2. Merchant: "T-Mobile"
@@ -161,7 +164,7 @@ class VariableDetectorLLM:
161
164
  def __init__(
162
165
  self,
163
166
  provider: str = "google",
164
- model_name: Optional[str] = None,
167
+ model_name: str | None = None,
165
168
  max_cost_per_day: float = 0.10,
166
169
  max_cost_per_month: float = 2.00,
167
170
  ):
@@ -278,12 +281,10 @@ class VariableDetectorLLM:
278
281
  )
279
282
 
280
283
  response = await self.llm.achat(
284
+ user_msg=user_prompt,
281
285
  provider=self.provider,
282
- model=self.model_name,
283
- messages=[
284
- {"role": "system", "content": VARIABLE_DETECTION_SYSTEM_PROMPT},
285
- {"role": "user", "content": user_prompt},
286
- ],
286
+ model_name=self.model_name,
287
+ system=VARIABLE_DETECTION_SYSTEM_PROMPT,
287
288
  output_schema=VariableRecurringPattern,
288
289
  output_method="prompt", # Cross-provider compatibility
289
290
  temperature=0.0, # Deterministic
@@ -292,7 +293,7 @@ class VariableDetectorLLM:
292
293
 
293
294
  # Extract structured output
294
295
  if hasattr(response, "structured") and response.structured:
295
- return response.structured
296
+ return cast("VariableRecurringPattern", response.structured)
296
297
  else:
297
298
  raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
298
299
 
@@ -9,8 +9,6 @@ variable amount detection, and natural language insights.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from typing import Optional
13
-
14
12
  from .detector import RecurringDetector
15
13
 
16
14
 
@@ -20,7 +18,7 @@ def easy_recurring_detection(
20
18
  date_tolerance_days: int = 7,
21
19
  enable_llm: bool = False,
22
20
  llm_provider: str = "google",
23
- llm_model: Optional[str] = None,
21
+ llm_model: str | None = None,
24
22
  llm_confidence_threshold: float = 0.8,
25
23
  llm_cache_merchant_ttl: int = 604800, # 7 days
26
24
  llm_cache_insights_ttl: int = 86400, # 24 hours
@@ -200,7 +198,7 @@ def easy_recurring_detection(
200
198
  )
201
199
 
202
200
  # Validate config keys (reserved for future use)
203
- valid_config_keys = set() # Will expand in future versions
201
+ valid_config_keys: set[str] = set() # Will expand in future versions
204
202
  invalid_keys = set(config.keys()) - valid_config_keys
205
203
  if invalid_keys:
206
204
  raise ValueError(
@@ -216,9 +214,9 @@ def easy_recurring_detection(
216
214
  if enable_llm:
217
215
  # Import V2 components only if needed (avoid circular imports)
218
216
  try:
219
- from .normalizers import MerchantNormalizer
220
217
  from .detectors_llm import VariableDetectorLLM
221
218
  from .insights import SubscriptionInsightsGenerator
219
+ from .normalizers import MerchantNormalizer
222
220
  except ImportError as e:
223
221
  raise ImportError(
224
222
  f"LLM components not available. Install ai-infra: pip install ai-infra. Error: {e}"
@@ -15,15 +15,18 @@ from __future__ import annotations
15
15
 
16
16
  import hashlib
17
17
  import logging
18
- from typing import Any, Optional
18
+ from typing import Any, cast
19
19
 
20
20
  from pydantic import BaseModel, ConfigDict, Field
21
21
 
22
22
  # Lazy import for optional dependency (ai-infra)
23
23
  try:
24
24
  from ai_infra.llm import LLM
25
+
26
+ LLM_AVAILABLE = True
25
27
  except ImportError:
26
- LLM = None
28
+ LLM = None # type: ignore[misc,assignment]
29
+ LLM_AVAILABLE = False
27
30
 
28
31
  logger = logging.getLogger(__name__)
29
32
 
@@ -58,7 +61,7 @@ class SubscriptionInsights(BaseModel):
58
61
  ge=0.0,
59
62
  description="Total monthly subscription cost",
60
63
  )
61
- potential_savings: Optional[float] = Field(
64
+ potential_savings: float | None = Field(
62
65
  None,
63
66
  ge=0.0,
64
67
  description="Potential monthly savings from recommendations (if applicable)",
@@ -102,20 +105,20 @@ Guidelines:
102
105
 
103
106
  Examples:
104
107
  1. Subscriptions: Netflix $15.99, Hulu $12.99, Disney+ $10.99, Spotify $9.99, Amazon Prime $14.99
105
- → "You have 5 subscriptions totaling $64.95/month. Consider the Disney+ bundle
106
- (Disney+, Hulu, ESPN+ for $19.99) to save $29.98/month. Also, Amazon Prime
108
+ → "You have 5 subscriptions totaling $64.95/month. Consider the Disney+ bundle
109
+ (Disney+, Hulu, ESPN+ for $19.99) to save $29.98/month. Also, Amazon Prime
107
110
  includes Prime Video - you may be able to cancel Netflix or Hulu."
108
111
  → total_monthly_cost: 64.95
109
112
  → potential_savings: 30.00
110
113
 
111
114
  2. Subscriptions: Spotify $9.99, Apple Music $10.99
112
- → "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
115
+ → "You're paying for both Spotify and Apple Music ($20.98/month). Cancel one
113
116
  to save $10.99/month."
114
117
  → total_monthly_cost: 20.98
115
118
  → potential_savings: 10.99
116
119
 
117
120
  3. Subscriptions: LA Fitness $40, Planet Fitness $10
118
- → "You have 2 gym memberships totaling $50/month. Consider consolidating to
121
+ → "You have 2 gym memberships totaling $50/month. Consider consolidating to
119
122
  just Planet Fitness to save $40/month."
120
123
  → total_monthly_cost: 50.00
121
124
  → potential_savings: 40.00
@@ -160,7 +163,7 @@ class SubscriptionInsightsGenerator:
160
163
  def __init__(
161
164
  self,
162
165
  provider: str = "google",
163
- model_name: Optional[str] = None,
166
+ model_name: str | None = None,
164
167
  cache_ttl: int = 86400, # 24 hours
165
168
  enable_cache: bool = True,
166
169
  max_cost_per_day: float = 0.10,
@@ -227,7 +230,7 @@ class SubscriptionInsightsGenerator:
227
230
  async def generate(
228
231
  self,
229
232
  subscriptions: list[dict[str, Any]],
230
- user_id: Optional[str] = None,
233
+ user_id: str | None = None,
231
234
  ) -> SubscriptionInsights:
232
235
  """
233
236
  Generate subscription insights with natural language recommendations.
@@ -288,8 +291,8 @@ class SubscriptionInsightsGenerator:
288
291
  async def _get_cached(
289
292
  self,
290
293
  subscriptions: list[dict[str, Any]],
291
- user_id: Optional[str] = None,
292
- ) -> Optional[SubscriptionInsights]:
294
+ user_id: str | None = None,
295
+ ) -> SubscriptionInsights | None:
293
296
  """
294
297
  Get cached insights.
295
298
 
@@ -314,7 +317,7 @@ class SubscriptionInsightsGenerator:
314
317
  self,
315
318
  subscriptions: list[dict[str, Any]],
316
319
  result: SubscriptionInsights,
317
- user_id: Optional[str] = None,
320
+ user_id: str | None = None,
318
321
  ) -> None:
319
322
  """
320
323
  Cache insights result.
@@ -335,7 +338,7 @@ class SubscriptionInsightsGenerator:
335
338
  def _make_cache_key(
336
339
  self,
337
340
  subscriptions: list[dict[str, Any]],
338
- user_id: Optional[str] = None,
341
+ user_id: str | None = None,
339
342
  ) -> str:
340
343
  """
341
344
  Generate cache key for insights.
@@ -369,12 +372,10 @@ class SubscriptionInsightsGenerator:
369
372
  user_prompt = INSIGHTS_GENERATION_USER_PROMPT.format(subscriptions_json=subscriptions_json)
370
373
 
371
374
  response = await self.llm.achat(
375
+ user_msg=user_prompt,
372
376
  provider=self.provider,
373
- model=self.model_name,
374
- messages=[
375
- {"role": "system", "content": INSIGHTS_GENERATION_SYSTEM_PROMPT},
376
- {"role": "user", "content": user_prompt},
377
- ],
377
+ model_name=self.model_name,
378
+ system=INSIGHTS_GENERATION_SYSTEM_PROMPT,
378
379
  output_schema=SubscriptionInsights,
379
380
  output_method="prompt", # Cross-provider compatibility
380
381
  temperature=0.3, # Slight creativity for recommendations
@@ -383,7 +384,7 @@ class SubscriptionInsightsGenerator:
383
384
 
384
385
  # Extract structured output
385
386
  if hasattr(response, "structured") and response.structured:
386
- return response.structured
387
+ return cast("SubscriptionInsights", response.structured)
387
388
  else:
388
389
  raise ValueError("LLM returned no structured output for insights")
389
390
 
@@ -228,9 +228,9 @@ class SubscriptionStats(BaseModel):
228
228
  "by_pattern_type": {"fixed": 12, "variable": 2, "irregular": 1},
229
229
  "by_cadence": {"monthly": 13, "quarterly": 1, "annual": 1},
230
230
  "top_merchants": [
231
- ("Netflix", 15.99),
232
- ("Spotify", 9.99),
233
- ("Amazon Prime", 14.99),
231
+ ["Netflix", 15.99],
232
+ ["Spotify", 9.99],
233
+ ["Amazon Prime", 14.99],
234
234
  ],
235
235
  "confidence_distribution": {
236
236
  "high (0.85-1.0)": 12,
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import re
13
13
  from functools import lru_cache
14
+ from typing import cast
14
15
 
15
16
  try:
16
17
  from rapidfuzz import fuzz, process
@@ -95,7 +96,7 @@ class FuzzyMatcher:
95
96
  """
96
97
  if not RAPIDFUZZ_AVAILABLE:
97
98
  raise ImportError(
98
- "rapidfuzz is required for fuzzy matching. " "Install with: pip install rapidfuzz"
99
+ "rapidfuzz is required for fuzzy matching. Install with: pip install rapidfuzz"
99
100
  )
100
101
  self.similarity_threshold = similarity_threshold
101
102
 
@@ -165,7 +166,7 @@ class FuzzyMatcher:
165
166
  norm2 = normalize_merchant(name2)
166
167
 
167
168
  similarity = fuzz.token_sort_ratio(norm1, norm2)
168
- return similarity >= self.similarity_threshold
169
+ return cast("bool", similarity >= self.similarity_threshold)
169
170
 
170
171
  def group_merchants(self, merchants: list[str]) -> dict[str, list[str]]:
171
172
  """
@@ -16,15 +16,18 @@ from __future__ import annotations
16
16
 
17
17
  import hashlib
18
18
  import logging
19
- from typing import Any, Optional
19
+ from typing import Any, cast
20
20
 
21
21
  from pydantic import BaseModel, ConfigDict, Field
22
22
 
23
23
  # Lazy import for optional dependency (ai-infra)
24
24
  try:
25
25
  from ai_infra.llm import LLM
26
+
27
+ LLM_AVAILABLE = True
26
28
  except ImportError:
27
- LLM = None
29
+ LLM = None # type: ignore[misc,assignment]
30
+ LLM_AVAILABLE = False
28
31
 
29
32
  logger = logging.getLogger(__name__)
30
33
 
@@ -142,7 +145,7 @@ class MerchantNormalizer:
142
145
  def __init__(
143
146
  self,
144
147
  provider: str = "google",
145
- model_name: Optional[str] = None,
148
+ model_name: str | None = None,
146
149
  cache_ttl: int = 604800, # 7 days
147
150
  enable_cache: bool = True,
148
151
  confidence_threshold: float = 0.8,
@@ -281,7 +284,7 @@ class MerchantNormalizer:
281
284
  logger.error(f"LLM normalization failed for '{merchant_name}': {e}")
282
285
  return self._fallback_normalize(merchant_name, fallback_confidence)
283
286
 
284
- async def _get_cached(self, merchant_name: str) -> Optional[MerchantNormalized]:
287
+ async def _get_cached(self, merchant_name: str) -> MerchantNormalized | None:
285
288
  """
286
289
  Get cached normalization result.
287
290
 
@@ -340,12 +343,10 @@ class MerchantNormalizer:
340
343
  user_prompt = MERCHANT_NORMALIZATION_USER_PROMPT.format(merchant_name=merchant_name)
341
344
 
342
345
  response = await self.llm.achat(
346
+ user_msg=user_prompt,
343
347
  provider=self.provider,
344
- model=self.model_name,
345
- messages=[
346
- {"role": "system", "content": MERCHANT_NORMALIZATION_SYSTEM_PROMPT},
347
- {"role": "user", "content": user_prompt},
348
- ],
348
+ model_name=self.model_name,
349
+ system=MERCHANT_NORMALIZATION_SYSTEM_PROMPT,
349
350
  output_schema=MerchantNormalized,
350
351
  output_method="prompt", # Cross-provider compatibility
351
352
  temperature=0.0, # Deterministic
@@ -354,7 +355,7 @@ class MerchantNormalizer:
354
355
 
355
356
  # Extract structured output
356
357
  if hasattr(response, "structured") and response.structured:
357
- return response.structured
358
+ return cast("MerchantNormalized", response.structured)
358
359
  else:
359
360
  raise ValueError(f"LLM returned no structured output for '{merchant_name}'")
360
361