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.
Files changed (131) hide show
  1. fin_infra/__init__.py +53 -3
  2. fin_infra/analytics/__init__.py +13 -2
  3. fin_infra/analytics/add.py +24 -24
  4. fin_infra/analytics/cash_flow.py +3 -3
  5. fin_infra/analytics/ease.py +19 -20
  6. fin_infra/analytics/models.py +5 -5
  7. fin_infra/analytics/portfolio.py +18 -18
  8. fin_infra/analytics/projections.py +1 -3
  9. fin_infra/analytics/spending.py +4 -5
  10. fin_infra/banking/__init__.py +27 -28
  11. fin_infra/banking/history.py +12 -13
  12. fin_infra/banking/utils.py +27 -26
  13. fin_infra/brokerage/__init__.py +29 -31
  14. fin_infra/budgets/__init__.py +3 -3
  15. fin_infra/budgets/add.py +16 -17
  16. fin_infra/budgets/alerts.py +4 -4
  17. fin_infra/budgets/ease.py +1 -2
  18. fin_infra/budgets/models.py +1 -2
  19. fin_infra/budgets/templates.py +4 -4
  20. fin_infra/budgets/tracker.py +4 -4
  21. fin_infra/cashflows/__init__.py +3 -3
  22. fin_infra/cashflows/core.py +1 -1
  23. fin_infra/categorization/__init__.py +1 -1
  24. fin_infra/categorization/add.py +2 -3
  25. fin_infra/categorization/ease.py +3 -3
  26. fin_infra/categorization/engine.py +18 -15
  27. fin_infra/categorization/llm_layer.py +13 -10
  28. fin_infra/categorization/models.py +3 -4
  29. fin_infra/categorization/rules.py +2 -4
  30. fin_infra/categorization/taxonomy.py +2 -2
  31. fin_infra/chat/__init__.py +6 -6
  32. fin_infra/chat/planning.py +1 -2
  33. fin_infra/cli/cmds/scaffold_cmds.py +16 -17
  34. fin_infra/clients/__init__.py +23 -1
  35. fin_infra/clients/base.py +1 -1
  36. fin_infra/clients/plaid.py +2 -2
  37. fin_infra/compliance/__init__.py +5 -4
  38. fin_infra/credit/add.py +6 -7
  39. fin_infra/credit/experian/auth.py +2 -2
  40. fin_infra/credit/experian/client.py +1 -1
  41. fin_infra/credit/experian/parser.py +5 -5
  42. fin_infra/credit/experian/provider.py +4 -4
  43. fin_infra/crypto/__init__.py +9 -11
  44. fin_infra/crypto/insights.py +4 -3
  45. fin_infra/documents/add.py +6 -8
  46. fin_infra/documents/analysis.py +9 -9
  47. fin_infra/documents/ease.py +14 -14
  48. fin_infra/documents/models.py +5 -6
  49. fin_infra/documents/ocr.py +7 -7
  50. fin_infra/documents/storage.py +21 -13
  51. fin_infra/exceptions.py +0 -1
  52. fin_infra/goals/__init__.py +8 -8
  53. fin_infra/goals/add.py +36 -36
  54. fin_infra/goals/funding.py +4 -6
  55. fin_infra/goals/management.py +5 -6
  56. fin_infra/goals/milestones.py +7 -8
  57. fin_infra/goals/models.py +9 -13
  58. fin_infra/insights/__init__.py +6 -3
  59. fin_infra/insights/aggregator.py +1 -1
  60. fin_infra/investments/__init__.py +3 -3
  61. fin_infra/investments/add.py +23 -23
  62. fin_infra/investments/ease.py +2 -2
  63. fin_infra/investments/models.py +27 -29
  64. fin_infra/investments/providers/base.py +12 -13
  65. fin_infra/investments/providers/plaid.py +52 -26
  66. fin_infra/investments/providers/snaptrade.py +19 -19
  67. fin_infra/investments/scaffold_templates/README.md +17 -17
  68. fin_infra/markets/__init__.py +7 -5
  69. fin_infra/models/__init__.py +10 -10
  70. fin_infra/models/accounts.py +4 -5
  71. fin_infra/models/brokerage.py +2 -1
  72. fin_infra/models/candle.py +1 -0
  73. fin_infra/models/money.py +1 -0
  74. fin_infra/models/quotes.py +4 -3
  75. fin_infra/models/tax.py +2 -1
  76. fin_infra/models/transactions.py +4 -5
  77. fin_infra/net_worth/__init__.py +8 -1
  78. fin_infra/net_worth/aggregator.py +5 -3
  79. fin_infra/net_worth/calculator.py +1 -1
  80. fin_infra/net_worth/insights.py +7 -8
  81. fin_infra/normalization/__init__.py +4 -4
  82. fin_infra/normalization/currency_converter.py +7 -8
  83. fin_infra/normalization/models.py +9 -10
  84. fin_infra/normalization/providers/exchangerate.py +5 -5
  85. fin_infra/normalization/providers/static_mappings.py +1 -1
  86. fin_infra/normalization/symbol_resolver.py +3 -4
  87. fin_infra/obs/classifier.py +3 -3
  88. fin_infra/providers/banking/plaid_client.py +5 -5
  89. fin_infra/providers/banking/teller_client.py +7 -6
  90. fin_infra/providers/base.py +27 -2
  91. fin_infra/providers/brokerage/alpaca.py +4 -4
  92. fin_infra/providers/market/alphavantage.py +6 -11
  93. fin_infra/providers/market/ccxt_crypto.py +19 -3
  94. fin_infra/providers/market/coingecko.py +5 -6
  95. fin_infra/providers/market/yahoo.py +23 -8
  96. fin_infra/providers/tax/__init__.py +1 -1
  97. fin_infra/providers/tax/irs.py +1 -1
  98. fin_infra/providers/tax/mock.py +5 -5
  99. fin_infra/providers/tax/taxbit.py +1 -1
  100. fin_infra/recurring/__init__.py +6 -6
  101. fin_infra/recurring/add.py +6 -5
  102. fin_infra/recurring/detector.py +7 -7
  103. fin_infra/recurring/detectors_llm.py +10 -10
  104. fin_infra/recurring/ease.py +6 -8
  105. fin_infra/recurring/insights.py +25 -24
  106. fin_infra/recurring/normalizer.py +7 -7
  107. fin_infra/recurring/normalizers.py +31 -30
  108. fin_infra/recurring/summary.py +13 -15
  109. fin_infra/scaffold/budgets.py +9 -9
  110. fin_infra/scaffold/goals.py +9 -9
  111. fin_infra/security/__init__.py +8 -8
  112. fin_infra/security/add.py +1 -2
  113. fin_infra/security/audit.py +6 -7
  114. fin_infra/security/encryption.py +6 -6
  115. fin_infra/security/models.py +7 -7
  116. fin_infra/security/pii_filter.py +16 -16
  117. fin_infra/security/token_store.py +2 -3
  118. fin_infra/settings.py +2 -1
  119. fin_infra/tax/__init__.py +1 -1
  120. fin_infra/tax/add.py +5 -4
  121. fin_infra/tax/tlh.py +10 -10
  122. fin_infra/utils/__init__.py +15 -1
  123. fin_infra/utils/deprecation.py +161 -0
  124. fin_infra/utils/http.py +4 -3
  125. fin_infra/utils/retry.py +2 -1
  126. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/METADATA +30 -16
  127. fin_infra-0.4.0.dist-info/RECORD +181 -0
  128. fin_infra-0.1.69.dist-info/RECORD +0 -180
  129. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/LICENSE +0 -0
  130. {fin_infra-0.1.69.dist-info → fin_infra-0.4.0.dist-info}/WHEEL +0 -0
  131. {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: Optional[datetime] = Field(None, description="Start date (defaults to now)")
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: Optional[str] = Field(None, description="Updated budget name")
53
- categories: Optional[dict[str, float]] = Field(None, description="Updated categories")
54
- rollover_enabled: Optional[bool] = Field(None, description="Updated rollover setting")
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: Optional[str] = Field(None, description="Optional budget name")
64
- start_date: Optional[datetime] = Field(None, description="Optional 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: Optional[BudgetTracker] = None,
70
- db_url: Optional[str] = None,
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: {str(e)}")
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: Optional[BudgetType] = Query(None, description="Filter by budget 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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}")
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: {str(e)}"
404
+ status_code=500, detail=f"Failed to create budget from template: {e!s}"
406
405
  )
407
406
 
408
407
  # Mount router
@@ -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, List, Optional
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: Optional[dict[str, float]] = None,
55
- ) -> List[BudgetAlert]:
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: List[BudgetAlert] = []
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: Optional[str] = None,
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,
@@ -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: Optional[str] = Field(None, description="Category triggering alert")
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")
@@ -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, Optional
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: Optional[str] = None,
181
- start_date: Optional[datetime] = None,
182
- custom_template: Optional[BudgetTemplate] = None,
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
 
@@ -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, List, Optional
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: Optional[datetime] = None,
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: Optional[str] = None,
209
- ) -> List[Budget]:
208
+ type: str | None = None,
209
+ ) -> list[Budget]:
210
210
  """
211
211
  Get all budgets for a user.
212
212
 
@@ -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 npv, irr
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("Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
255
+ print("Cashflow calculations enabled (NPV, IRR, PMT, FV, PV)")
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Iterable
3
+ from collections.abc import Iterable
4
4
 
5
5
  import numpy as np
6
6
  import numpy_financial as npf
@@ -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 regex sklearn Naive Bayes LLM).
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
@@ -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: Optional[CategoryGroup] = None):
124
+ async def list_categories(group: CategoryGroup | None = None):
126
125
  """
127
126
  List all available categories.
128
127
 
@@ -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, Optional
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: Optional[Path] = None,
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: Optional[str] = None,
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 regex ML LLM).
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: Optional[Path] = None,
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: Optional[str] = None,
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
- ) -> Optional[CategoryPrediction]:
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
- # Log error and return None (will fallback to uncategorized)
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
- print(f"ML model not found at {self.model_path}")
277
- print("Run training script to generate model files")
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
- print(f"Loaded ML model from {self.model_path}")
286
+ logger.info("Loaded ML model from %s", self.model_path)
286
287
  except ImportError:
287
- print("scikit-learn not installed. ML predictions disabled.")
288
- print("Install with: pip install scikit-learn")
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
- print(f"Error loading ML model: {e}")
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: Optional[CategorizationEngine] = None
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: Optional[str] = None,
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, List, Optional, Tuple, cast
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: List[Tuple[str, str, str]] = [
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: Optional[str] = None,
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}' {prediction.category} "
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: Optional[str] = None,
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 Category: "{category}"\n Reasoning: "{reasoning}"'
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: Optional[str] = None,
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} $0.00")
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} $0.00")
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: Optional[str] = Field(None, description="Explanation of prediction (for LLM)")
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: Optional[str] = Field(None, description="User ID for personalized overrides")
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: Optional[float] = Field(None, description="Cache hit rate (0-1)")
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) -> Optional[Category]:
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) -> Optional[tuple[Category, int]]:
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
- from typing import Optional
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) -> Optional[CategoryMetadata]:
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
 
@@ -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
- FinancialPlanningConversation,
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"Financial chat enabled (AI-powered Q&A with {provider})")
192
+ print(f"Financial chat enabled (AI-powered Q&A with {provider})")
193
193
 
194
194
  return conversation
@@ -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
- ⚠️ This is AI-generated advice. Not a substitute for a certified financial advisor.
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