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.
- fin_infra/__init__.py +53 -3
- fin_infra/analytics/__init__.py +13 -2
- fin_infra/analytics/add.py +30 -32
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/portfolio.py +19 -26
- fin_infra/analytics/projections.py +1 -3
- fin_infra/analytics/rebalancing.py +2 -4
- fin_infra/analytics/savings.py +1 -1
- fin_infra/analytics/spending.py +15 -11
- fin_infra/banking/__init__.py +33 -31
- fin_infra/banking/history.py +11 -12
- fin_infra/banking/utils.py +116 -110
- fin_infra/brokerage/__init__.py +27 -27
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +3 -3
- fin_infra/budgets/tracker.py +4 -5
- fin_infra/cashflows/__init__.py +8 -10
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +17 -19
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +21 -18
- fin_infra/categorization/llm_layer.py +10 -10
- fin_infra/categorization/models.py +1 -1
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +13 -22
- fin_infra/chat/planning.py +57 -1
- fin_infra/cli/cmds/scaffold_cmds.py +11 -12
- fin_infra/clients/__init__.py +23 -1
- fin_infra/clients/base.py +1 -1
- fin_infra/clients/plaid.py +2 -2
- fin_infra/compliance/__init__.py +7 -6
- fin_infra/credit/add.py +7 -7
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +19 -19
- fin_infra/crypto/__init__.py +8 -10
- fin_infra/crypto/insights.py +5 -6
- fin_infra/documents/add.py +11 -13
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +18 -17
- fin_infra/documents/models.py +7 -7
- fin_infra/documents/ocr.py +8 -8
- fin_infra/documents/storage.py +23 -14
- fin_infra/exceptions.py +1 -2
- fin_infra/goals/__init__.py +8 -8
- fin_infra/goals/add.py +36 -36
- fin_infra/goals/funding.py +4 -6
- fin_infra/goals/management.py +6 -7
- fin_infra/goals/milestones.py +2 -3
- fin_infra/goals/models.py +7 -11
- fin_infra/insights/__init__.py +12 -10
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +14 -9
- fin_infra/investments/add.py +53 -73
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +135 -69
- fin_infra/investments/providers/base.py +9 -15
- fin_infra/investments/providers/plaid.py +70 -55
- fin_infra/investments/providers/snaptrade.py +35 -53
- fin_infra/markets/__init__.py +16 -11
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/brokerage.py +2 -1
- fin_infra/models/candle.py +1 -0
- fin_infra/models/money.py +1 -0
- fin_infra/models/quotes.py +4 -3
- fin_infra/models/tax.py +2 -1
- fin_infra/models/transactions.py +4 -4
- fin_infra/net_worth/__init__.py +7 -0
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +9 -6
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -5
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +17 -15
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +23 -22
- fin_infra/providers/banking/teller_client.py +14 -7
- fin_infra/providers/base.py +131 -14
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +25 -4
- fin_infra/providers/market/coingecko.py +5 -6
- fin_infra/providers/market/yahoo.py +23 -8
- fin_infra/providers/tax/__init__.py +1 -1
- fin_infra/providers/tax/irs.py +1 -1
- fin_infra/providers/tax/mock.py +8 -8
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +24 -12
- fin_infra/recurring/detector.py +8 -8
- fin_infra/recurring/detectors_llm.py +14 -13
- fin_infra/recurring/ease.py +3 -5
- fin_infra/recurring/insights.py +20 -19
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +11 -10
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +5 -5
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +6 -6
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +2 -2
- fin_infra/tax/add.py +3 -2
- fin_infra/tax/tlh.py +5 -5
- fin_infra/utils/http.py +5 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/METADATA +14 -9
- fin_infra-0.1.82.dist-info/RECORD +180 -0
- fin_infra-0.1.62.dist-info/RECORD +0 -180
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.82.dist-info}/entry_points.txt +0 -0
fin_infra/providers/tax/irs.py
CHANGED
fin_infra/providers/tax/mock.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
fin_infra/recurring/__init__.py
CHANGED
|
@@ -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
|
fin_infra/recurring/add.py
CHANGED
|
@@ -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,
|
|
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:
|
|
39
|
+
llm_model: str | None = None,
|
|
39
40
|
include_in_schema: bool = True,
|
|
40
|
-
) ->
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
fin_infra/recurring/detector.py
CHANGED
|
@@ -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
|
|
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:
|
|
63
|
-
variable_detector_llm:
|
|
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:
|
|
516
|
-
variable_detector_llm:
|
|
517
|
-
insights_generator:
|
|
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,
|
|
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:
|
|
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:
|
|
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":
|
|
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:
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
fin_infra/recurring/ease.py
CHANGED
|
@@ -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:
|
|
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}"
|
fin_infra/recurring/insights.py
CHANGED
|
@@ -15,15 +15,18 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import Any,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
292
|
-
) ->
|
|
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:
|
|
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:
|
|
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
|
-
|
|
374
|
-
|
|
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
|
|
fin_infra/recurring/models.py
CHANGED
|
@@ -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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|
|
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,
|
|
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:
|
|
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) ->
|
|
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
|
-
|
|
345
|
-
|
|
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
|
|