fin-infra 0.1.69__py3-none-any.whl → 0.4.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.
- fin_infra/__init__.py +53 -3
- fin_infra/analytics/__init__.py +13 -2
- fin_infra/analytics/add.py +24 -24
- fin_infra/analytics/cash_flow.py +3 -3
- fin_infra/analytics/ease.py +19 -20
- fin_infra/analytics/models.py +5 -5
- fin_infra/analytics/portfolio.py +18 -18
- fin_infra/analytics/projections.py +1 -3
- fin_infra/analytics/spending.py +4 -5
- fin_infra/banking/__init__.py +27 -28
- fin_infra/banking/history.py +12 -13
- fin_infra/banking/utils.py +27 -26
- fin_infra/brokerage/__init__.py +29 -31
- fin_infra/budgets/__init__.py +3 -3
- fin_infra/budgets/add.py +16 -17
- fin_infra/budgets/alerts.py +4 -4
- fin_infra/budgets/ease.py +1 -2
- fin_infra/budgets/models.py +1 -2
- fin_infra/budgets/templates.py +4 -4
- fin_infra/budgets/tracker.py +4 -4
- fin_infra/cashflows/__init__.py +3 -3
- fin_infra/cashflows/core.py +1 -1
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +2 -3
- fin_infra/categorization/ease.py +3 -3
- fin_infra/categorization/engine.py +18 -15
- fin_infra/categorization/llm_layer.py +13 -10
- fin_infra/categorization/models.py +3 -4
- fin_infra/categorization/rules.py +2 -4
- fin_infra/categorization/taxonomy.py +2 -2
- fin_infra/chat/__init__.py +6 -6
- fin_infra/chat/planning.py +1 -2
- fin_infra/cli/cmds/scaffold_cmds.py +16 -17
- 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 +5 -4
- fin_infra/credit/add.py +6 -7
- fin_infra/credit/experian/auth.py +2 -2
- fin_infra/credit/experian/client.py +1 -1
- fin_infra/credit/experian/parser.py +5 -5
- fin_infra/credit/experian/provider.py +4 -4
- fin_infra/crypto/__init__.py +9 -11
- fin_infra/crypto/insights.py +4 -3
- fin_infra/documents/add.py +6 -8
- fin_infra/documents/analysis.py +9 -9
- fin_infra/documents/ease.py +14 -14
- fin_infra/documents/models.py +5 -6
- fin_infra/documents/ocr.py +7 -7
- fin_infra/documents/storage.py +21 -13
- fin_infra/exceptions.py +0 -1
- 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 +5 -6
- fin_infra/goals/milestones.py +7 -8
- fin_infra/goals/models.py +9 -13
- fin_infra/insights/__init__.py +6 -3
- fin_infra/insights/aggregator.py +1 -1
- fin_infra/investments/__init__.py +3 -3
- fin_infra/investments/add.py +23 -23
- fin_infra/investments/ease.py +2 -2
- fin_infra/investments/models.py +27 -29
- fin_infra/investments/providers/base.py +12 -13
- fin_infra/investments/providers/plaid.py +52 -26
- fin_infra/investments/providers/snaptrade.py +19 -19
- fin_infra/investments/scaffold_templates/README.md +17 -17
- fin_infra/markets/__init__.py +7 -5
- fin_infra/models/__init__.py +10 -10
- fin_infra/models/accounts.py +4 -5
- 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 -5
- fin_infra/net_worth/__init__.py +8 -1
- fin_infra/net_worth/aggregator.py +5 -3
- fin_infra/net_worth/calculator.py +1 -1
- fin_infra/net_worth/insights.py +7 -8
- fin_infra/normalization/__init__.py +4 -4
- fin_infra/normalization/currency_converter.py +7 -8
- fin_infra/normalization/models.py +9 -10
- fin_infra/normalization/providers/exchangerate.py +5 -5
- fin_infra/normalization/providers/static_mappings.py +1 -1
- fin_infra/normalization/symbol_resolver.py +3 -4
- fin_infra/obs/classifier.py +3 -3
- fin_infra/providers/banking/plaid_client.py +5 -5
- fin_infra/providers/banking/teller_client.py +7 -6
- fin_infra/providers/base.py +27 -2
- fin_infra/providers/brokerage/alpaca.py +4 -4
- fin_infra/providers/market/alphavantage.py +6 -11
- fin_infra/providers/market/ccxt_crypto.py +19 -3
- 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 +5 -5
- fin_infra/providers/tax/taxbit.py +1 -1
- fin_infra/recurring/__init__.py +6 -6
- fin_infra/recurring/add.py +6 -5
- fin_infra/recurring/detector.py +7 -7
- fin_infra/recurring/detectors_llm.py +10 -10
- fin_infra/recurring/ease.py +6 -8
- fin_infra/recurring/insights.py +25 -24
- fin_infra/recurring/normalizer.py +7 -7
- fin_infra/recurring/normalizers.py +31 -30
- fin_infra/recurring/summary.py +13 -15
- fin_infra/scaffold/budgets.py +9 -9
- fin_infra/scaffold/goals.py +9 -9
- fin_infra/security/__init__.py +8 -8
- fin_infra/security/add.py +1 -2
- fin_infra/security/audit.py +6 -7
- fin_infra/security/encryption.py +6 -6
- fin_infra/security/models.py +7 -7
- fin_infra/security/pii_filter.py +16 -16
- fin_infra/security/token_store.py +2 -3
- fin_infra/settings.py +2 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/tax/add.py +5 -4
- fin_infra/tax/tlh.py +10 -10
- fin_infra/utils/__init__.py +15 -1
- fin_infra/utils/deprecation.py +161 -0
- fin_infra/utils/http.py +4 -3
- fin_infra/utils/retry.py +2 -1
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
- fin_infra-0.4.0.dist-info/RECORD +181 -0
- fin_infra-0.1.69.dist-info/RECORD +0 -180
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/entry_points.txt +0 -0
fin_infra/budgets/add.py
CHANGED
|
@@ -22,7 +22,6 @@ Generic Design:
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
from datetime import datetime
|
|
25
|
-
from typing import Optional
|
|
26
25
|
|
|
27
26
|
from fastapi import FastAPI, HTTPException, Query
|
|
28
27
|
from pydantic import BaseModel, Field
|
|
@@ -42,16 +41,16 @@ class CreateBudgetRequest(BaseModel):
|
|
|
42
41
|
type: BudgetType = Field(..., description="Budget type")
|
|
43
42
|
period: BudgetPeriod = Field(..., description="Budget period")
|
|
44
43
|
categories: dict[str, float] = Field(..., description="Category allocations")
|
|
45
|
-
start_date:
|
|
44
|
+
start_date: datetime | None = Field(None, description="Start date (defaults to now)")
|
|
46
45
|
rollover_enabled: bool = Field(False, description="Enable rollover")
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
class UpdateBudgetRequest(BaseModel):
|
|
50
49
|
"""Request model for updating a budget."""
|
|
51
50
|
|
|
52
|
-
name:
|
|
53
|
-
categories:
|
|
54
|
-
rollover_enabled:
|
|
51
|
+
name: str | None = Field(None, description="Updated budget name")
|
|
52
|
+
categories: dict[str, float] | None = Field(None, description="Updated categories")
|
|
53
|
+
rollover_enabled: bool | None = Field(None, description="Updated rollover setting")
|
|
55
54
|
|
|
56
55
|
|
|
57
56
|
class ApplyTemplateRequest(BaseModel):
|
|
@@ -60,14 +59,14 @@ class ApplyTemplateRequest(BaseModel):
|
|
|
60
59
|
user_id: str = Field(..., description="User identifier")
|
|
61
60
|
template_name: str = Field(..., description="Template name (e.g., '50_30_20')")
|
|
62
61
|
total_income: float = Field(..., description="Total income/budget amount", gt=0)
|
|
63
|
-
budget_name:
|
|
64
|
-
start_date:
|
|
62
|
+
budget_name: str | None = Field(None, description="Optional budget name")
|
|
63
|
+
start_date: datetime | None = Field(None, description="Optional start date")
|
|
65
64
|
|
|
66
65
|
|
|
67
66
|
def add_budgets(
|
|
68
67
|
app: FastAPI,
|
|
69
|
-
tracker:
|
|
70
|
-
db_url:
|
|
68
|
+
tracker: BudgetTracker | None = None,
|
|
69
|
+
db_url: str | None = None,
|
|
71
70
|
prefix: str = "/budgets",
|
|
72
71
|
) -> BudgetTracker:
|
|
73
72
|
"""Add budget management endpoints to FastAPI app.
|
|
@@ -162,13 +161,13 @@ def add_budgets(
|
|
|
162
161
|
except ValueError as e:
|
|
163
162
|
raise HTTPException(status_code=400, detail=str(e))
|
|
164
163
|
except Exception as e:
|
|
165
|
-
raise HTTPException(status_code=500, detail=f"Failed to create budget: {
|
|
164
|
+
raise HTTPException(status_code=500, detail=f"Failed to create budget: {e!s}")
|
|
166
165
|
|
|
167
166
|
# Endpoint 2: List budgets
|
|
168
167
|
@router.get("", response_model=list[Budget], summary="List Budgets")
|
|
169
168
|
async def list_budgets(
|
|
170
169
|
user_id: str = Query(..., description="User identifier"),
|
|
171
|
-
type:
|
|
170
|
+
type: BudgetType | None = Query(None, description="Filter by budget type"),
|
|
172
171
|
) -> list[Budget]:
|
|
173
172
|
"""
|
|
174
173
|
List budgets for a user.
|
|
@@ -189,7 +188,7 @@ def add_budgets(
|
|
|
189
188
|
budgets = await tracker.get_budgets(user_id=user_id, type=type)
|
|
190
189
|
return budgets
|
|
191
190
|
except Exception as e:
|
|
192
|
-
raise HTTPException(status_code=500, detail=f"Failed to list budgets: {
|
|
191
|
+
raise HTTPException(status_code=500, detail=f"Failed to list budgets: {e!s}")
|
|
193
192
|
|
|
194
193
|
# Endpoint 3: Get single budget
|
|
195
194
|
@router.get("/{budget_id}", response_model=Budget, summary="Get Budget")
|
|
@@ -217,7 +216,7 @@ def add_budgets(
|
|
|
217
216
|
except ValueError as e:
|
|
218
217
|
raise HTTPException(status_code=404, detail=str(e))
|
|
219
218
|
except Exception as e:
|
|
220
|
-
raise HTTPException(status_code=500, detail=f"Failed to get budget: {
|
|
219
|
+
raise HTTPException(status_code=500, detail=f"Failed to get budget: {e!s}")
|
|
221
220
|
|
|
222
221
|
# Endpoint 4: Update budget
|
|
223
222
|
@router.patch("/{budget_id}", response_model=Budget, summary="Update Budget")
|
|
@@ -269,7 +268,7 @@ def add_budgets(
|
|
|
269
268
|
else:
|
|
270
269
|
raise HTTPException(status_code=400, detail=error_msg)
|
|
271
270
|
except Exception as e:
|
|
272
|
-
raise HTTPException(status_code=500, detail=f"Failed to update budget: {
|
|
271
|
+
raise HTTPException(status_code=500, detail=f"Failed to update budget: {e!s}")
|
|
273
272
|
|
|
274
273
|
# Endpoint 5: Delete budget
|
|
275
274
|
@router.delete("/{budget_id}", status_code=204, summary="Delete Budget", response_model=None)
|
|
@@ -296,7 +295,7 @@ def add_budgets(
|
|
|
296
295
|
except ValueError as e:
|
|
297
296
|
raise HTTPException(status_code=404, detail=str(e))
|
|
298
297
|
except Exception as e:
|
|
299
|
-
raise HTTPException(status_code=500, detail=f"Failed to delete budget: {
|
|
298
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete budget: {e!s}")
|
|
300
299
|
|
|
301
300
|
# Endpoint 6: Get budget progress
|
|
302
301
|
@router.get(
|
|
@@ -328,7 +327,7 @@ def add_budgets(
|
|
|
328
327
|
except ValueError as e:
|
|
329
328
|
raise HTTPException(status_code=404, detail=str(e))
|
|
330
329
|
except Exception as e:
|
|
331
|
-
raise HTTPException(status_code=500, detail=f"Failed to get budget progress: {
|
|
330
|
+
raise HTTPException(status_code=500, detail=f"Failed to get budget progress: {e!s}")
|
|
332
331
|
|
|
333
332
|
# Endpoint 7: List templates
|
|
334
333
|
@router.get("/templates/list", response_model=dict, summary="List Budget Templates")
|
|
@@ -402,7 +401,7 @@ def add_budgets(
|
|
|
402
401
|
raise HTTPException(status_code=400, detail=str(e))
|
|
403
402
|
except Exception as e:
|
|
404
403
|
raise HTTPException(
|
|
405
|
-
status_code=500, detail=f"Failed to create budget from template: {
|
|
404
|
+
status_code=500, detail=f"Failed to create budget from template: {e!s}"
|
|
406
405
|
)
|
|
407
406
|
|
|
408
407
|
# Mount router
|
fin_infra/budgets/alerts.py
CHANGED
|
@@ -35,7 +35,7 @@ Example:
|
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
37
|
from datetime import datetime
|
|
38
|
-
from typing import TYPE_CHECKING
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
39
|
|
|
40
40
|
from fin_infra.budgets.models import (
|
|
41
41
|
AlertSeverity,
|
|
@@ -51,8 +51,8 @@ if TYPE_CHECKING:
|
|
|
51
51
|
async def check_budget_alerts(
|
|
52
52
|
budget_id: str,
|
|
53
53
|
tracker: BudgetTracker,
|
|
54
|
-
thresholds:
|
|
55
|
-
) ->
|
|
54
|
+
thresholds: dict[str, float] | None = None,
|
|
55
|
+
) -> list[BudgetAlert]:
|
|
56
56
|
"""
|
|
57
57
|
Check budget for alerts (overspending, approaching limits, unusual patterns).
|
|
58
58
|
|
|
@@ -111,7 +111,7 @@ async def check_budget_alerts(
|
|
|
111
111
|
# Get budget progress
|
|
112
112
|
progress = await tracker.get_budget_progress(budget_id)
|
|
113
113
|
|
|
114
|
-
alerts:
|
|
114
|
+
alerts: list[BudgetAlert] = []
|
|
115
115
|
|
|
116
116
|
# Check each category for alerts
|
|
117
117
|
for category in progress.categories:
|
fin_infra/budgets/ease.py
CHANGED
|
@@ -12,7 +12,6 @@ Generic Design:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import os
|
|
15
|
-
from typing import Optional
|
|
16
15
|
|
|
17
16
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
18
17
|
|
|
@@ -20,7 +19,7 @@ from fin_infra.budgets.tracker import BudgetTracker
|
|
|
20
19
|
|
|
21
20
|
|
|
22
21
|
def easy_budgets(
|
|
23
|
-
db_url:
|
|
22
|
+
db_url: str | None = None,
|
|
24
23
|
pool_size: int = 5,
|
|
25
24
|
max_overflow: int = 10,
|
|
26
25
|
pool_pre_ping: bool = True,
|
fin_infra/budgets/models.py
CHANGED
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from datetime import datetime
|
|
10
10
|
from enum import Enum
|
|
11
|
-
from typing import Optional
|
|
12
11
|
|
|
13
12
|
from pydantic import BaseModel, ConfigDict, Field
|
|
14
13
|
|
|
@@ -296,7 +295,7 @@ class BudgetAlert(BaseModel):
|
|
|
296
295
|
"""
|
|
297
296
|
|
|
298
297
|
budget_id: str = Field(..., description="Budget identifier")
|
|
299
|
-
category:
|
|
298
|
+
category: str | None = Field(None, description="Category triggering alert")
|
|
300
299
|
alert_type: AlertType = Field(..., description="Type of alert")
|
|
301
300
|
threshold: float = Field(..., description="Threshold that triggered alert")
|
|
302
301
|
message: str = Field(..., description="Human-readable alert message")
|
fin_infra/budgets/templates.py
CHANGED
|
@@ -16,7 +16,7 @@ Generic Design:
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
from datetime import datetime
|
|
19
|
-
from typing import TYPE_CHECKING
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
20
|
|
|
21
21
|
from fin_infra.budgets.models import Budget, BudgetPeriod, BudgetType
|
|
22
22
|
|
|
@@ -177,9 +177,9 @@ async def apply_template(
|
|
|
177
177
|
template_name: str,
|
|
178
178
|
total_income: float,
|
|
179
179
|
tracker: BudgetTracker,
|
|
180
|
-
budget_name:
|
|
181
|
-
start_date:
|
|
182
|
-
custom_template:
|
|
180
|
+
budget_name: str | None = None,
|
|
181
|
+
start_date: datetime | None = None,
|
|
182
|
+
custom_template: BudgetTemplate | None = None,
|
|
183
183
|
) -> Budget:
|
|
184
184
|
"""Apply a budget template to create a new budget.
|
|
185
185
|
|
fin_infra/budgets/tracker.py
CHANGED
|
@@ -36,7 +36,7 @@ from __future__ import annotations
|
|
|
36
36
|
|
|
37
37
|
import uuid
|
|
38
38
|
from datetime import datetime, timedelta
|
|
39
|
-
from typing import TYPE_CHECKING
|
|
39
|
+
from typing import TYPE_CHECKING
|
|
40
40
|
|
|
41
41
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
42
42
|
|
|
@@ -116,7 +116,7 @@ class BudgetTracker:
|
|
|
116
116
|
type: str, # BudgetType value
|
|
117
117
|
period: str, # BudgetPeriod value
|
|
118
118
|
categories: dict[str, float],
|
|
119
|
-
start_date:
|
|
119
|
+
start_date: datetime | None = None,
|
|
120
120
|
rollover_enabled: bool = False,
|
|
121
121
|
) -> Budget:
|
|
122
122
|
"""
|
|
@@ -205,8 +205,8 @@ class BudgetTracker:
|
|
|
205
205
|
async def get_budgets(
|
|
206
206
|
self,
|
|
207
207
|
user_id: str,
|
|
208
|
-
type:
|
|
209
|
-
) ->
|
|
208
|
+
type: str | None = None,
|
|
209
|
+
) -> list[Budget]:
|
|
210
210
|
"""
|
|
211
211
|
Get all budgets for a user.
|
|
212
212
|
|
fin_infra/cashflows/__init__.py
CHANGED
|
@@ -29,7 +29,7 @@ import numpy_financial as npf
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
30
30
|
from fastapi import FastAPI
|
|
31
31
|
|
|
32
|
-
from .core import
|
|
32
|
+
from .core import irr, npv
|
|
33
33
|
|
|
34
34
|
__all__ = ["npv", "irr", "pmt", "fv", "pv", "add_cashflows"]
|
|
35
35
|
|
|
@@ -173,10 +173,10 @@ def add_cashflows(
|
|
|
173
173
|
- Scoped docs at {prefix}/docs
|
|
174
174
|
"""
|
|
175
175
|
from pydantic import BaseModel, Field
|
|
176
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
176
177
|
|
|
177
178
|
# Import svc-infra public router (no auth - utility calculations)
|
|
178
179
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
179
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
180
180
|
|
|
181
181
|
# Request/Response models
|
|
182
182
|
class NPVRequest(BaseModel):
|
|
@@ -252,4 +252,4 @@ def add_cashflows(
|
|
|
252
252
|
# Mount router
|
|
253
253
|
app.include_router(router, include_in_schema=True)
|
|
254
254
|
|
|
255
|
-
print("
|
|
255
|
+
print("Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
|
fin_infra/cashflows/core.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Transaction categorization module.
|
|
3
3
|
|
|
4
4
|
Provides ML-based categorization of merchant transactions into 56 categories
|
|
5
|
-
using a hybrid approach (exact match
|
|
5
|
+
using a hybrid approach (exact match -> regex -> sklearn Naive Bayes -> LLM).
|
|
6
6
|
|
|
7
7
|
Basic usage:
|
|
8
8
|
from fin_infra.categorization import categorize
|
fin_infra/categorization/add.py
CHANGED
|
@@ -6,10 +6,10 @@ Uses svc-infra dual routers for consistent behavior.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import time
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
from fastapi import FastAPI, HTTPException
|
|
12
11
|
|
|
12
|
+
from . import rules
|
|
13
13
|
from .ease import easy_categorization
|
|
14
14
|
from .engine import CategorizationEngine
|
|
15
15
|
from .models import (
|
|
@@ -18,7 +18,6 @@ from .models import (
|
|
|
18
18
|
CategoryStats,
|
|
19
19
|
)
|
|
20
20
|
from .taxonomy import CategoryGroup, count_categories, get_all_categories
|
|
21
|
-
from . import rules
|
|
22
21
|
|
|
23
22
|
|
|
24
23
|
def add_categorization(
|
|
@@ -122,7 +121,7 @@ def add_categorization(
|
|
|
122
121
|
raise HTTPException(status_code=400, detail=str(e))
|
|
123
122
|
|
|
124
123
|
@router.get("/categories")
|
|
125
|
-
async def list_categories(group:
|
|
124
|
+
async def list_categories(group: CategoryGroup | None = None):
|
|
126
125
|
"""
|
|
127
126
|
List all available categories.
|
|
128
127
|
|
fin_infra/categorization/ease.py
CHANGED
|
@@ -5,7 +5,7 @@ Provides one-line setup with sensible defaults.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Literal
|
|
8
|
+
from typing import Literal
|
|
9
9
|
|
|
10
10
|
from .engine import CategorizationEngine
|
|
11
11
|
|
|
@@ -21,10 +21,10 @@ def easy_categorization(
|
|
|
21
21
|
taxonomy: str = "mx",
|
|
22
22
|
enable_ml: bool = False,
|
|
23
23
|
confidence_threshold: float = 0.6,
|
|
24
|
-
model_path:
|
|
24
|
+
model_path: Path | None = None,
|
|
25
25
|
# LLM-specific parameters (V2)
|
|
26
26
|
llm_provider: Literal["google", "openai", "anthropic", "none"] = "google",
|
|
27
|
-
llm_model:
|
|
27
|
+
llm_model: str | None = None,
|
|
28
28
|
llm_confidence_threshold: float = 0.6,
|
|
29
29
|
llm_cache_ttl: int = 86400, # 24 hours
|
|
30
30
|
llm_max_cost_per_day: float = 0.10, # $0.10/day
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Hybrid categorization engine (exact
|
|
2
|
+
Hybrid categorization engine (exact -> regex -> ML -> LLM).
|
|
3
3
|
|
|
4
4
|
4-layer approach:
|
|
5
5
|
1. Layer 1 (Exact Match): O(1) dictionary lookup, 85-90% coverage
|
|
@@ -10,8 +10,8 @@ Hybrid categorization engine (exact → regex → ML → LLM).
|
|
|
10
10
|
Expected overall accuracy: 95-97% (V2 with LLM)
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
import time
|
|
14
13
|
import logging
|
|
14
|
+
import time
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Optional
|
|
17
17
|
|
|
@@ -51,7 +51,7 @@ class CategorizationEngine:
|
|
|
51
51
|
enable_ml: bool = False,
|
|
52
52
|
enable_llm: bool = False,
|
|
53
53
|
confidence_threshold: float = 0.6,
|
|
54
|
-
model_path:
|
|
54
|
+
model_path: Path | None = None,
|
|
55
55
|
llm_categorizer: Optional["LLMCategorizer"] = None,
|
|
56
56
|
):
|
|
57
57
|
self.enable_ml = enable_ml
|
|
@@ -81,7 +81,7 @@ class CategorizationEngine:
|
|
|
81
81
|
async def categorize(
|
|
82
82
|
self,
|
|
83
83
|
merchant_name: str,
|
|
84
|
-
user_id:
|
|
84
|
+
user_id: str | None = None,
|
|
85
85
|
include_alternatives: bool = False,
|
|
86
86
|
) -> CategoryPrediction:
|
|
87
87
|
"""
|
|
@@ -195,7 +195,7 @@ class CategorizationEngine:
|
|
|
195
195
|
|
|
196
196
|
def _predict_ml(
|
|
197
197
|
self, merchant_name: str, include_alternatives: bool = False
|
|
198
|
-
) ->
|
|
198
|
+
) -> CategoryPrediction | None:
|
|
199
199
|
"""
|
|
200
200
|
Predict category using ML model.
|
|
201
201
|
|
|
@@ -251,8 +251,7 @@ class CategorizationEngine:
|
|
|
251
251
|
)
|
|
252
252
|
|
|
253
253
|
except Exception as e:
|
|
254
|
-
|
|
255
|
-
print(f"ML prediction error: {e}")
|
|
254
|
+
logger.error("ML prediction error: %s", e)
|
|
256
255
|
return None
|
|
257
256
|
|
|
258
257
|
def _load_ml_model(self) -> None:
|
|
@@ -273,8 +272,10 @@ class CategorizationEngine:
|
|
|
273
272
|
vectorizer_file = self.model_path / "vectorizer.joblib"
|
|
274
273
|
|
|
275
274
|
if not model_file.exists() or not vectorizer_file.exists():
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
logger.warning(
|
|
276
|
+
"ML model not found at %s. Run training script to generate model files.",
|
|
277
|
+
self.model_path,
|
|
278
|
+
)
|
|
278
279
|
return
|
|
279
280
|
|
|
280
281
|
try:
|
|
@@ -282,12 +283,14 @@ class CategorizationEngine:
|
|
|
282
283
|
|
|
283
284
|
self._ml_model = joblib.load(model_file)
|
|
284
285
|
self._ml_vectorizer = joblib.load(vectorizer_file)
|
|
285
|
-
|
|
286
|
+
logger.info("Loaded ML model from %s", self.model_path)
|
|
286
287
|
except ImportError:
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
logger.warning(
|
|
289
|
+
"scikit-learn not installed. ML predictions disabled. "
|
|
290
|
+
"Install with: pip install scikit-learn"
|
|
291
|
+
)
|
|
289
292
|
except Exception as e:
|
|
290
|
-
|
|
293
|
+
logger.error("Error loading ML model: %s", e)
|
|
291
294
|
|
|
292
295
|
def add_rule(
|
|
293
296
|
self,
|
|
@@ -322,7 +325,7 @@ class CategorizationEngine:
|
|
|
322
325
|
|
|
323
326
|
|
|
324
327
|
# Singleton instance (for easy access)
|
|
325
|
-
_default_engine:
|
|
328
|
+
_default_engine: CategorizationEngine | None = None
|
|
326
329
|
|
|
327
330
|
|
|
328
331
|
def get_engine() -> CategorizationEngine:
|
|
@@ -335,7 +338,7 @@ def get_engine() -> CategorizationEngine:
|
|
|
335
338
|
|
|
336
339
|
async def categorize(
|
|
337
340
|
merchant_name: str,
|
|
338
|
-
user_id:
|
|
341
|
+
user_id: str | None = None,
|
|
339
342
|
include_alternatives: bool = False,
|
|
340
343
|
) -> CategoryPrediction:
|
|
341
344
|
"""
|
|
@@ -15,7 +15,8 @@ Expected performance:
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import Any,
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
19
20
|
from pydantic import BaseModel, Field
|
|
20
21
|
|
|
21
22
|
# ai-infra imports
|
|
@@ -40,7 +41,7 @@ class CategoryPrediction(BaseModel):
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
# Few-shot examples (20 diverse merchants covering all major categories)
|
|
43
|
-
FEW_SHOT_EXAMPLES:
|
|
44
|
+
FEW_SHOT_EXAMPLES: list[tuple[str, str, str]] = [
|
|
44
45
|
# Food & Dining (5 examples)
|
|
45
46
|
("STARBUCKS #1234", "Coffee Shops", "Popular coffee shop chain"),
|
|
46
47
|
("MCDONALD'S", "Fast Food", "Fast food restaurant"),
|
|
@@ -157,7 +158,7 @@ class LLMCategorizer:
|
|
|
157
158
|
async def categorize(
|
|
158
159
|
self,
|
|
159
160
|
merchant_name: str,
|
|
160
|
-
user_id:
|
|
161
|
+
user_id: str | None = None,
|
|
161
162
|
) -> CategoryPrediction:
|
|
162
163
|
"""
|
|
163
164
|
Categorize merchant using LLM.
|
|
@@ -196,7 +197,7 @@ class LLMCategorizer:
|
|
|
196
197
|
self._track_cost()
|
|
197
198
|
|
|
198
199
|
logger.info(
|
|
199
|
-
f"LLM categorized '{merchant_name}'
|
|
200
|
+
f"LLM categorized '{merchant_name}' -> {prediction.category} "
|
|
200
201
|
f"(confidence={prediction.confidence:.2f})"
|
|
201
202
|
)
|
|
202
203
|
|
|
@@ -209,7 +210,7 @@ class LLMCategorizer:
|
|
|
209
210
|
async def _call_llm(
|
|
210
211
|
self,
|
|
211
212
|
merchant_name: str,
|
|
212
|
-
user_id:
|
|
213
|
+
user_id: str | None = None,
|
|
213
214
|
) -> CategoryPrediction:
|
|
214
215
|
"""Call LLM API with structured output."""
|
|
215
216
|
# Build user message
|
|
@@ -244,14 +245,14 @@ class LLMCategorizer:
|
|
|
244
245
|
f"Must be one of {len(valid_categories)} valid categories."
|
|
245
246
|
)
|
|
246
247
|
|
|
247
|
-
return cast(CategoryPrediction, response)
|
|
248
|
+
return cast("CategoryPrediction", response)
|
|
248
249
|
|
|
249
250
|
def _build_system_prompt(self) -> str:
|
|
250
251
|
"""Build system prompt with few-shot examples (reused across all requests)."""
|
|
251
252
|
# Format few-shot examples
|
|
252
253
|
examples_text = "\n\n".join(
|
|
253
254
|
[
|
|
254
|
-
f'Merchant: "{merchant}"\n
|
|
255
|
+
f'Merchant: "{merchant}"\n-> Category: "{category}"\n-> Reasoning: "{reasoning}"'
|
|
255
256
|
for merchant, category, reasoning in FEW_SHOT_EXAMPLES
|
|
256
257
|
]
|
|
257
258
|
)
|
|
@@ -269,7 +270,7 @@ class LLMCategorizer:
|
|
|
269
270
|
def _build_user_message(
|
|
270
271
|
self,
|
|
271
272
|
merchant_name: str,
|
|
272
|
-
user_id:
|
|
273
|
+
user_id: str | None = None,
|
|
273
274
|
) -> str:
|
|
274
275
|
"""Build user message with optional personalization."""
|
|
275
276
|
if self.enable_personalization and user_id:
|
|
@@ -299,6 +300,8 @@ Return JSON with category, confidence, and reasoning."""
|
|
|
299
300
|
def _get_cache_key(self, merchant_name: str) -> str:
|
|
300
301
|
"""Generate stable cache key from merchant name."""
|
|
301
302
|
normalized = merchant_name.lower().strip()
|
|
303
|
+
# Security: B324 skip justified - MD5 used for cache key generation only,
|
|
304
|
+
# not for security. We need deterministic hashing for cache lookups.
|
|
302
305
|
hash_value = hashlib.md5(normalized.encode()).hexdigest()
|
|
303
306
|
return f"llm_category:{hash_value}"
|
|
304
307
|
|
|
@@ -337,10 +340,10 @@ Return JSON with category, confidence, and reasoning."""
|
|
|
337
340
|
|
|
338
341
|
def reset_daily_cost(self):
|
|
339
342
|
"""Reset daily cost counter (called at midnight UTC)."""
|
|
340
|
-
logger.info(f"Resetting daily cost: ${self.daily_cost:.5f}
|
|
343
|
+
logger.info(f"Resetting daily cost: ${self.daily_cost:.5f} -> $0.00")
|
|
341
344
|
self.daily_cost = 0.0
|
|
342
345
|
|
|
343
346
|
def reset_monthly_cost(self):
|
|
344
347
|
"""Reset monthly cost counter (called on 1st of month)."""
|
|
345
|
-
logger.info(f"Resetting monthly cost: ${self.monthly_cost:.5f}
|
|
348
|
+
logger.info(f"Resetting monthly cost: ${self.monthly_cost:.5f} -> $0.00")
|
|
346
349
|
self.monthly_cost = 0.0
|
|
@@ -4,7 +4,6 @@ Pydantic models for transaction categorization.
|
|
|
4
4
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import Optional
|
|
8
7
|
|
|
9
8
|
from pydantic import BaseModel, ConfigDict, Field
|
|
10
9
|
|
|
@@ -34,7 +33,7 @@ class CategoryPrediction(BaseModel):
|
|
|
34
33
|
default_factory=list,
|
|
35
34
|
description="Alternative predictions (category, confidence)",
|
|
36
35
|
)
|
|
37
|
-
reasoning:
|
|
36
|
+
reasoning: str | None = Field(None, description="Explanation of prediction (for LLM)")
|
|
38
37
|
|
|
39
38
|
model_config = ConfigDict(
|
|
40
39
|
json_schema_extra={
|
|
@@ -100,7 +99,7 @@ class CategorizationRequest(BaseModel):
|
|
|
100
99
|
"""Request to categorize a merchant."""
|
|
101
100
|
|
|
102
101
|
merchant_name: str = Field(..., description="Merchant name to categorize")
|
|
103
|
-
user_id:
|
|
102
|
+
user_id: str | None = Field(None, description="User ID for personalized overrides")
|
|
104
103
|
include_alternatives: bool = Field(default=False, description="Include alternative predictions")
|
|
105
104
|
min_confidence: float = Field(
|
|
106
105
|
default=0.0,
|
|
@@ -152,7 +151,7 @@ class CategoryStats(BaseModel):
|
|
|
152
151
|
total_categories: int = Field(..., description="Total number of categories")
|
|
153
152
|
categories_by_group: dict[str, int] = Field(..., description="Category counts by group")
|
|
154
153
|
total_rules: int = Field(..., description="Total number of rules")
|
|
155
|
-
cache_hit_rate:
|
|
154
|
+
cache_hit_rate: float | None = Field(None, description="Cache hit rate (0-1)")
|
|
156
155
|
|
|
157
156
|
model_config = ConfigDict(
|
|
158
157
|
json_schema_extra={
|
|
@@ -6,12 +6,10 @@ Organized by category for maintainability.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import re
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
from .models import CategoryRule
|
|
12
11
|
from .taxonomy import Category
|
|
13
12
|
|
|
14
|
-
|
|
15
13
|
# ===== HELPER FUNCTIONS (defined first) =====
|
|
16
14
|
|
|
17
15
|
|
|
@@ -306,7 +304,7 @@ COMPILED_REGEX_RULES = [
|
|
|
306
304
|
# ===== PUBLIC FUNCTIONS =====
|
|
307
305
|
|
|
308
306
|
|
|
309
|
-
def get_exact_match(merchant: str) ->
|
|
307
|
+
def get_exact_match(merchant: str) -> Category | None:
|
|
310
308
|
"""
|
|
311
309
|
Get category by exact match.
|
|
312
310
|
|
|
@@ -320,7 +318,7 @@ def get_exact_match(merchant: str) -> Optional[Category]:
|
|
|
320
318
|
return EXACT_RULES_NORMALIZED.get(normalized)
|
|
321
319
|
|
|
322
320
|
|
|
323
|
-
def get_regex_match(merchant: str) ->
|
|
321
|
+
def get_regex_match(merchant: str) -> tuple[Category, int] | None:
|
|
324
322
|
"""
|
|
325
323
|
Get category by regex match.
|
|
326
324
|
|
|
@@ -12,7 +12,7 @@ Total: 56 leaf categories
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from enum import Enum
|
|
15
|
-
|
|
15
|
+
|
|
16
16
|
from pydantic import BaseModel
|
|
17
17
|
|
|
18
18
|
|
|
@@ -315,7 +315,7 @@ def get_category_group(category: Category) -> CategoryGroup:
|
|
|
315
315
|
return CATEGORY_GROUPS.get(category, CategoryGroup.UNCATEGORIZED)
|
|
316
316
|
|
|
317
317
|
|
|
318
|
-
def get_category_metadata(category: Category) ->
|
|
318
|
+
def get_category_metadata(category: Category) -> CategoryMetadata | None:
|
|
319
319
|
"""Get metadata for a category."""
|
|
320
320
|
return CATEGORY_METADATA.get(category)
|
|
321
321
|
|
fin_infra/chat/__init__.py
CHANGED
|
@@ -33,15 +33,15 @@ from typing import TYPE_CHECKING, Any
|
|
|
33
33
|
if TYPE_CHECKING:
|
|
34
34
|
from fastapi import FastAPI
|
|
35
35
|
|
|
36
|
+
from fin_infra.chat.ease import easy_financial_conversation
|
|
36
37
|
from fin_infra.chat.planning import (
|
|
37
|
-
|
|
38
|
-
ConversationResponse,
|
|
38
|
+
SENSITIVE_PATTERNS,
|
|
39
39
|
ConversationContext,
|
|
40
|
+
ConversationResponse,
|
|
40
41
|
Exchange,
|
|
42
|
+
FinancialPlanningConversation,
|
|
41
43
|
is_sensitive_question,
|
|
42
|
-
SENSITIVE_PATTERNS,
|
|
43
44
|
)
|
|
44
|
-
from fin_infra.chat.ease import easy_financial_conversation
|
|
45
45
|
|
|
46
46
|
__all__ = [
|
|
47
47
|
"FinancialPlanningConversation",
|
|
@@ -116,10 +116,10 @@ def add_financial_conversation(
|
|
|
116
116
|
- Logs all LLM calls for compliance (via svc-infra logging)
|
|
117
117
|
"""
|
|
118
118
|
from pydantic import BaseModel, Field
|
|
119
|
+
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
119
120
|
|
|
120
121
|
# Import svc-infra user router (requires auth)
|
|
121
122
|
from svc_infra.api.fastapi.dual.protected import user_router
|
|
122
|
-
from svc_infra.api.fastapi.docs.scoped import add_prefixed_docs
|
|
123
123
|
|
|
124
124
|
# Auto-create conversation if not provided
|
|
125
125
|
if conversation is None:
|
|
@@ -189,6 +189,6 @@ def add_financial_conversation(
|
|
|
189
189
|
# Store on app.state for programmatic access
|
|
190
190
|
app.state.financial_conversation = conversation
|
|
191
191
|
|
|
192
|
-
print(f"
|
|
192
|
+
print(f"Financial chat enabled (AI-powered Q&A with {provider})")
|
|
193
193
|
|
|
194
194
|
return conversation
|
fin_infra/chat/planning.py
CHANGED
|
@@ -54,7 +54,6 @@ from typing import Any
|
|
|
54
54
|
|
|
55
55
|
from pydantic import BaseModel, Field
|
|
56
56
|
|
|
57
|
-
|
|
58
57
|
# ============================================================================
|
|
59
58
|
# Pydantic Schemas (Structured Output)
|
|
60
59
|
# ============================================================================
|
|
@@ -174,7 +173,7 @@ Answer: "To assess your retirement progress, I need more information: (1) What's
|
|
|
174
173
|
Follow-ups: ["I want to retire at 65 with $1.5M", "How much should I save monthly?", "What's a realistic retirement goal?"]
|
|
175
174
|
Sources: []
|
|
176
175
|
|
|
177
|
-
|
|
176
|
+
[!] This is AI-generated advice. Not a substitute for a certified financial advisor.
|
|
178
177
|
Verify calculations independently. For personalized advice, consult a professional."""
|
|
179
178
|
|
|
180
179
|
|