fin-infra 0.1.62__py3-none-any.whl → 0.1.69__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/analytics/add.py +9 -11
- fin_infra/analytics/cash_flow.py +6 -5
- fin_infra/analytics/portfolio.py +13 -20
- 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 +8 -5
- fin_infra/banking/history.py +3 -3
- fin_infra/banking/utils.py +93 -88
- fin_infra/brokerage/__init__.py +5 -3
- fin_infra/budgets/tracker.py +2 -3
- fin_infra/cashflows/__init__.py +6 -8
- fin_infra/categorization/__init__.py +1 -1
- fin_infra/categorization/add.py +15 -16
- fin_infra/categorization/ease.py +3 -4
- fin_infra/categorization/engine.py +4 -4
- fin_infra/categorization/llm_layer.py +5 -6
- fin_infra/categorization/models.py +1 -1
- fin_infra/chat/__init__.py +7 -16
- fin_infra/chat/planning.py +57 -0
- fin_infra/cli/cmds/scaffold_cmds.py +1 -1
- fin_infra/compliance/__init__.py +3 -3
- fin_infra/credit/add.py +3 -2
- fin_infra/credit/experian/auth.py +3 -2
- fin_infra/credit/experian/client.py +2 -2
- fin_infra/credit/experian/provider.py +16 -16
- fin_infra/crypto/__init__.py +1 -1
- fin_infra/crypto/insights.py +1 -3
- fin_infra/documents/add.py +5 -5
- fin_infra/documents/ease.py +4 -3
- fin_infra/documents/models.py +3 -3
- fin_infra/documents/ocr.py +1 -1
- fin_infra/documents/storage.py +2 -1
- fin_infra/exceptions.py +1 -1
- fin_infra/goals/add.py +2 -2
- fin_infra/goals/management.py +6 -6
- fin_infra/goals/milestones.py +2 -2
- fin_infra/insights/__init__.py +7 -8
- fin_infra/investments/__init__.py +13 -8
- fin_infra/investments/add.py +39 -59
- fin_infra/investments/ease.py +16 -13
- fin_infra/investments/models.py +130 -64
- fin_infra/investments/providers/base.py +3 -8
- fin_infra/investments/providers/plaid.py +23 -34
- fin_infra/investments/providers/snaptrade.py +22 -40
- fin_infra/markets/__init__.py +11 -8
- fin_infra/models/accounts.py +2 -1
- fin_infra/models/transactions.py +3 -2
- fin_infra/net_worth/add.py +8 -5
- fin_infra/net_worth/aggregator.py +5 -4
- fin_infra/net_worth/calculator.py +8 -6
- fin_infra/net_worth/ease.py +36 -15
- fin_infra/net_worth/insights.py +4 -4
- fin_infra/net_worth/models.py +237 -116
- fin_infra/normalization/__init__.py +15 -13
- fin_infra/normalization/providers/exchangerate.py +3 -3
- fin_infra/obs/classifier.py +2 -2
- fin_infra/providers/banking/plaid_client.py +20 -19
- fin_infra/providers/banking/teller_client.py +13 -7
- fin_infra/providers/base.py +105 -13
- fin_infra/providers/brokerage/alpaca.py +7 -7
- fin_infra/providers/credit/experian.py +5 -0
- fin_infra/providers/market/ccxt_crypto.py +8 -3
- fin_infra/providers/tax/mock.py +3 -3
- fin_infra/recurring/add.py +20 -9
- fin_infra/recurring/detector.py +1 -1
- fin_infra/recurring/detectors_llm.py +10 -9
- fin_infra/recurring/ease.py +1 -1
- fin_infra/recurring/insights.py +9 -8
- fin_infra/recurring/models.py +3 -3
- fin_infra/recurring/normalizer.py +3 -2
- fin_infra/recurring/normalizers.py +9 -8
- fin_infra/scaffold/__init__.py +1 -1
- fin_infra/security/encryption.py +2 -2
- fin_infra/security/pii_patterns.py +1 -1
- fin_infra/security/token_store.py +3 -1
- fin_infra/tax/__init__.py +1 -1
- fin_infra/utils/http.py +3 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/METADATA +1 -2
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/RECORD +83 -83
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/LICENSE +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/WHEEL +0 -0
- {fin_infra-0.1.62.dist-info → fin_infra-0.1.69.dist-info}/entry_points.txt +0 -0
|
@@ -23,7 +23,7 @@ from .taxonomy import Category
|
|
|
23
23
|
try:
|
|
24
24
|
from .llm_layer import LLMCategorizer
|
|
25
25
|
except ImportError:
|
|
26
|
-
LLMCategorizer = None
|
|
26
|
+
LLMCategorizer = None # type: ignore[assignment,misc]
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
29
29
|
|
|
@@ -95,7 +95,7 @@ class CategorizationEngine:
|
|
|
95
95
|
Returns:
|
|
96
96
|
CategoryPrediction with category, confidence, and method
|
|
97
97
|
"""
|
|
98
|
-
|
|
98
|
+
time.perf_counter()
|
|
99
99
|
|
|
100
100
|
# Normalize merchant name
|
|
101
101
|
normalized = self._normalize(merchant_name)
|
|
@@ -333,7 +333,7 @@ def get_engine() -> CategorizationEngine:
|
|
|
333
333
|
return _default_engine
|
|
334
334
|
|
|
335
335
|
|
|
336
|
-
def categorize(
|
|
336
|
+
async def categorize(
|
|
337
337
|
merchant_name: str,
|
|
338
338
|
user_id: Optional[str] = None,
|
|
339
339
|
include_alternatives: bool = False,
|
|
@@ -350,4 +350,4 @@ def categorize(
|
|
|
350
350
|
CategoryPrediction with category, confidence, and method
|
|
351
351
|
"""
|
|
352
352
|
engine = get_engine()
|
|
353
|
-
return engine.categorize(merchant_name, user_id, include_alternatives)
|
|
353
|
+
return await engine.categorize(merchant_name, user_id, include_alternatives)
|
|
@@ -15,13 +15,12 @@ Expected performance:
|
|
|
15
15
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import logging
|
|
18
|
-
from typing import
|
|
18
|
+
from typing import Any, List, Optional, Tuple, cast
|
|
19
19
|
from pydantic import BaseModel, Field
|
|
20
20
|
|
|
21
21
|
# ai-infra imports
|
|
22
22
|
try:
|
|
23
23
|
from ai_infra.llm import LLM
|
|
24
|
-
from ai_infra.llm.providers import Providers
|
|
25
24
|
except ImportError:
|
|
26
25
|
raise ImportError("ai-infra not installed. Install with: pip install ai-infra")
|
|
27
26
|
|
|
@@ -217,7 +216,7 @@ class LLMCategorizer:
|
|
|
217
216
|
user_message = self._build_user_message(merchant_name, user_id)
|
|
218
217
|
|
|
219
218
|
# Call LLM with retry logic
|
|
220
|
-
extra = {
|
|
219
|
+
extra: dict[str, Any] = {
|
|
221
220
|
"retry": {
|
|
222
221
|
"max_tries": 3,
|
|
223
222
|
"base": 0.5,
|
|
@@ -245,7 +244,7 @@ class LLMCategorizer:
|
|
|
245
244
|
f"Must be one of {len(valid_categories)} valid categories."
|
|
246
245
|
)
|
|
247
246
|
|
|
248
|
-
return response
|
|
247
|
+
return cast(CategoryPrediction, response)
|
|
249
248
|
|
|
250
249
|
def _build_system_prompt(self) -> str:
|
|
251
250
|
"""Build system prompt with few-shot examples (reused across all requests)."""
|
|
@@ -285,8 +284,8 @@ class LLMCategorizer:
|
|
|
285
284
|
Merchant: "{merchant_name}"
|
|
286
285
|
|
|
287
286
|
User context:
|
|
288
|
-
- Frequently shops at: {context[
|
|
289
|
-
- Top spending categories: {context[
|
|
287
|
+
- Frequently shops at: {context["top_merchants"]}
|
|
288
|
+
- Top spending categories: {context["top_categories"]}
|
|
290
289
|
|
|
291
290
|
Return JSON with category, confidence, and reasoning."""
|
|
292
291
|
else:
|
fin_infra/chat/__init__.py
CHANGED
|
@@ -28,6 +28,11 @@ Example:
|
|
|
28
28
|
)
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from fastapi import FastAPI
|
|
35
|
+
|
|
31
36
|
from fin_infra.chat.planning import (
|
|
32
37
|
FinancialPlanningConversation,
|
|
33
38
|
ConversationResponse,
|
|
@@ -51,7 +56,7 @@ __all__ = [
|
|
|
51
56
|
|
|
52
57
|
|
|
53
58
|
def add_financial_conversation(
|
|
54
|
-
app: "FastAPI",
|
|
59
|
+
app: "FastAPI",
|
|
55
60
|
*,
|
|
56
61
|
prefix: str = "/chat",
|
|
57
62
|
conversation: FinancialPlanningConversation | None = None,
|
|
@@ -110,11 +115,6 @@ def add_financial_conversation(
|
|
|
110
115
|
- Includes financial advice disclaimer in all responses
|
|
111
116
|
- Logs all LLM calls for compliance (via svc-infra logging)
|
|
112
117
|
"""
|
|
113
|
-
from typing import TYPE_CHECKING, Any
|
|
114
|
-
|
|
115
|
-
if TYPE_CHECKING:
|
|
116
|
-
from fastapi import FastAPI
|
|
117
|
-
|
|
118
118
|
from pydantic import BaseModel, Field
|
|
119
119
|
|
|
120
120
|
# Import svc-infra user router (requires auth)
|
|
@@ -149,15 +149,6 @@ def add_financial_conversation(
|
|
|
149
149
|
# TODO: Get user_id from svc-infra auth context
|
|
150
150
|
user_id = "demo_user" # Placeholder
|
|
151
151
|
|
|
152
|
-
# Check for sensitive content
|
|
153
|
-
if is_sensitive_question(request.question):
|
|
154
|
-
return ConversationResponse(
|
|
155
|
-
answer="I cannot process requests containing sensitive information like SSNs, passwords, or account numbers. Please rephrase your question without this information.",
|
|
156
|
-
follow_up_questions=[],
|
|
157
|
-
conversation_id=f"{user_id}_denied",
|
|
158
|
-
disclaimer="This is an automated safety response.",
|
|
159
|
-
)
|
|
160
|
-
|
|
161
152
|
# Ask conversation
|
|
162
153
|
response = await conversation.ask(
|
|
163
154
|
user_id=user_id,
|
|
@@ -173,7 +164,7 @@ def add_financial_conversation(
|
|
|
173
164
|
# TODO: Get user_id from svc-infra auth context
|
|
174
165
|
user_id = "demo_user"
|
|
175
166
|
context = await conversation._get_context(user_id)
|
|
176
|
-
return context.
|
|
167
|
+
return context.previous_exchanges if context else []
|
|
177
168
|
|
|
178
169
|
@router.delete("/history")
|
|
179
170
|
async def clear_history():
|
fin_infra/chat/planning.py
CHANGED
|
@@ -338,8 +338,65 @@ class FinancialPlanningConversation:
|
|
|
338
338
|
# Save updated context (24h TTL)
|
|
339
339
|
await self._save_context(context)
|
|
340
340
|
|
|
341
|
+
# Track latest session id for convenience endpoints (history/clear).
|
|
342
|
+
# Best-effort: failures here must not break the chat response.
|
|
343
|
+
try:
|
|
344
|
+
await self.cache.set(
|
|
345
|
+
self._latest_session_key(user_id),
|
|
346
|
+
context.session_id,
|
|
347
|
+
ttl=86400,
|
|
348
|
+
)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
341
352
|
return response
|
|
342
353
|
|
|
354
|
+
# ---------------------------------------------------------------------
|
|
355
|
+
# Backward-compatible context helpers
|
|
356
|
+
# ---------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def _latest_session_key(self, user_id: str) -> str:
|
|
359
|
+
return f"fin_infra:conversation_latest_session:{user_id}"
|
|
360
|
+
|
|
361
|
+
async def _get_latest_session_id(self, user_id: str) -> str | None:
|
|
362
|
+
try:
|
|
363
|
+
value = await self.cache.get(self._latest_session_key(user_id))
|
|
364
|
+
except Exception:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
if value is None:
|
|
368
|
+
return None
|
|
369
|
+
if isinstance(value, bytes):
|
|
370
|
+
try:
|
|
371
|
+
return value.decode("utf-8")
|
|
372
|
+
except Exception:
|
|
373
|
+
return None
|
|
374
|
+
if isinstance(value, str):
|
|
375
|
+
return value
|
|
376
|
+
return str(value)
|
|
377
|
+
|
|
378
|
+
async def _get_context(
|
|
379
|
+
self, user_id: str, session_id: str | None = None
|
|
380
|
+
) -> ConversationContext | None:
|
|
381
|
+
if session_id is None:
|
|
382
|
+
session_id = await self._get_latest_session_id(user_id)
|
|
383
|
+
if session_id is None:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
return await self._load_context(user_id=user_id, session_id=session_id)
|
|
387
|
+
|
|
388
|
+
async def _clear_context(self, user_id: str, session_id: str | None = None) -> None:
|
|
389
|
+
if session_id is None:
|
|
390
|
+
session_id = await self._get_latest_session_id(user_id)
|
|
391
|
+
|
|
392
|
+
if session_id is not None:
|
|
393
|
+
await self.clear_session(user_id=user_id, session_id=session_id)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
await self.cache.delete(self._latest_session_key(user_id))
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
|
|
343
400
|
async def _load_context(
|
|
344
401
|
self,
|
|
345
402
|
user_id: str,
|
fin_infra/compliance/__init__.py
CHANGED
|
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
import logging
|
|
23
23
|
from datetime import datetime
|
|
24
|
-
from typing import Any, Callable, TYPE_CHECKING
|
|
24
|
+
from typing import Any, Callable, TYPE_CHECKING, cast
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
27
27
|
from fastapi import FastAPI, Request, Response
|
|
@@ -118,7 +118,7 @@ def add_compliance_tracking(
|
|
|
118
118
|
|
|
119
119
|
# Track only GET requests (data access)
|
|
120
120
|
if method != "GET":
|
|
121
|
-
return await call_next(request)
|
|
121
|
+
return cast("Response", await call_next(request))
|
|
122
122
|
|
|
123
123
|
# Determine if path is a compliance-tracked endpoint
|
|
124
124
|
event = None
|
|
@@ -148,7 +148,7 @@ def add_compliance_tracking(
|
|
|
148
148
|
if on_event:
|
|
149
149
|
on_event(event, context)
|
|
150
150
|
|
|
151
|
-
return response
|
|
151
|
+
return cast("Response", response)
|
|
152
152
|
|
|
153
153
|
logger.info(
|
|
154
154
|
"Compliance tracking enabled",
|
fin_infra/credit/add.py
CHANGED
|
@@ -23,6 +23,7 @@ Example:
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
import logging
|
|
26
|
+
from typing import cast
|
|
26
27
|
|
|
27
28
|
from fastapi import FastAPI, Depends, HTTPException, status
|
|
28
29
|
|
|
@@ -175,7 +176,7 @@ def add_credit(
|
|
|
175
176
|
# Don't fail request if webhook publishing fails
|
|
176
177
|
logger.warning(f"Failed to publish credit.score_changed webhook: {e}")
|
|
177
178
|
|
|
178
|
-
return score
|
|
179
|
+
return cast(CreditScore, score)
|
|
179
180
|
|
|
180
181
|
@router.post("/report", response_model=CreditReport)
|
|
181
182
|
@credit_resource.cache_read(ttl=cache_ttl, suffix="report")
|
|
@@ -219,7 +220,7 @@ def add_credit(
|
|
|
219
220
|
detail="Credit bureau service unavailable",
|
|
220
221
|
)
|
|
221
222
|
|
|
222
|
-
return report
|
|
223
|
+
return cast(CreditReport, report)
|
|
223
224
|
|
|
224
225
|
# Mount router with dual routes (with/without trailing slash)
|
|
225
226
|
app.include_router(router, include_in_schema=True)
|
|
@@ -24,6 +24,7 @@ Example:
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
import base64
|
|
27
|
+
from typing import cast
|
|
27
28
|
|
|
28
29
|
import httpx
|
|
29
30
|
from svc_infra.cache import cache_read
|
|
@@ -85,7 +86,7 @@ class ExperianAuthManager:
|
|
|
85
86
|
>>> headers = {"Authorization": f"Bearer {token}"}
|
|
86
87
|
"""
|
|
87
88
|
# Call the cached implementation with client_id for cache key
|
|
88
|
-
return await self._get_token_cached(client_id=self.client_id)
|
|
89
|
+
return cast(str, await self._get_token_cached(client_id=self.client_id))
|
|
89
90
|
|
|
90
91
|
@cache_read(
|
|
91
92
|
key="oauth_token:experian:{client_id}", # Use client_id for uniqueness
|
|
@@ -140,7 +141,7 @@ class ExperianAuthManager:
|
|
|
140
141
|
|
|
141
142
|
# Parse and return token
|
|
142
143
|
data = response.json()
|
|
143
|
-
return data["access_token"]
|
|
144
|
+
return cast(str, data["access_token"])
|
|
144
145
|
|
|
145
146
|
async def invalidate(self) -> None:
|
|
146
147
|
"""Invalidate cached token for THIS client (force refresh on next get_token call).
|
|
@@ -14,7 +14,7 @@ Example:
|
|
|
14
14
|
>>> data = await client.get_credit_score("user123")
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
from typing import Any
|
|
17
|
+
from typing import Any, cast
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
from tenacity import (
|
|
@@ -155,7 +155,7 @@ class ExperianClient:
|
|
|
155
155
|
**kwargs,
|
|
156
156
|
)
|
|
157
157
|
response.raise_for_status()
|
|
158
|
-
return response.json()
|
|
158
|
+
return cast(dict[str, Any], response.json())
|
|
159
159
|
|
|
160
160
|
except httpx.HTTPStatusError as e:
|
|
161
161
|
# Parse error response
|
|
@@ -31,7 +31,7 @@ Example:
|
|
|
31
31
|
|
|
32
32
|
import logging
|
|
33
33
|
from datetime import datetime, timezone
|
|
34
|
-
from typing import Literal
|
|
34
|
+
from typing import Literal, cast
|
|
35
35
|
|
|
36
36
|
from fin_infra.credit.experian.auth import ExperianAuthManager
|
|
37
37
|
from fin_infra.credit.experian.client import ExperianClient
|
|
@@ -174,7 +174,7 @@ class ExperianProvider(CreditProvider):
|
|
|
174
174
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
175
175
|
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
176
176
|
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
177
|
-
|
|
177
|
+
|
|
178
178
|
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
179
179
|
# This log must be retained for at least 2 years per FCRA requirements
|
|
180
180
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
@@ -190,7 +190,7 @@ class ExperianProvider(CreditProvider):
|
|
|
190
190
|
"environment": self.environment,
|
|
191
191
|
"timestamp": timestamp,
|
|
192
192
|
"result": "pending",
|
|
193
|
-
}
|
|
193
|
+
},
|
|
194
194
|
)
|
|
195
195
|
|
|
196
196
|
try:
|
|
@@ -202,7 +202,7 @@ class ExperianProvider(CreditProvider):
|
|
|
202
202
|
|
|
203
203
|
# Parse response to CreditScore model
|
|
204
204
|
result = parse_credit_score(data, user_id=user_id)
|
|
205
|
-
|
|
205
|
+
|
|
206
206
|
# Log successful pull
|
|
207
207
|
fcra_audit_logger.info(
|
|
208
208
|
"FCRA_CREDIT_PULL_SUCCESS",
|
|
@@ -213,11 +213,11 @@ class ExperianProvider(CreditProvider):
|
|
|
213
213
|
"timestamp": timestamp,
|
|
214
214
|
"result": "success",
|
|
215
215
|
"score_returned": result.score is not None,
|
|
216
|
-
}
|
|
216
|
+
},
|
|
217
217
|
)
|
|
218
|
-
|
|
218
|
+
|
|
219
219
|
return result
|
|
220
|
-
|
|
220
|
+
|
|
221
221
|
except Exception as e:
|
|
222
222
|
# Log failed pull - still required for FCRA audit trail
|
|
223
223
|
fcra_audit_logger.warning(
|
|
@@ -229,7 +229,7 @@ class ExperianProvider(CreditProvider):
|
|
|
229
229
|
"timestamp": timestamp,
|
|
230
230
|
"result": "error",
|
|
231
231
|
"error_type": type(e).__name__,
|
|
232
|
-
}
|
|
232
|
+
},
|
|
233
233
|
)
|
|
234
234
|
raise
|
|
235
235
|
|
|
@@ -262,7 +262,7 @@ class ExperianProvider(CreditProvider):
|
|
|
262
262
|
permissible_purpose = kwargs.get("permissible_purpose", "account_review")
|
|
263
263
|
requester_ip = kwargs.get("requester_ip", "unknown")
|
|
264
264
|
requester_user_id = kwargs.get("requester_user_id", "unknown")
|
|
265
|
-
|
|
265
|
+
|
|
266
266
|
# FCRA Audit Log - REQUIRED for regulatory compliance (15 USC § 1681b)
|
|
267
267
|
# Full credit report pulls have stricter requirements than score-only pulls
|
|
268
268
|
# This log must be retained for at least 2 years per FCRA requirements
|
|
@@ -280,7 +280,7 @@ class ExperianProvider(CreditProvider):
|
|
|
280
280
|
"timestamp": timestamp,
|
|
281
281
|
"result": "pending",
|
|
282
282
|
"report_type": "full",
|
|
283
|
-
}
|
|
283
|
+
},
|
|
284
284
|
)
|
|
285
285
|
|
|
286
286
|
try:
|
|
@@ -292,7 +292,7 @@ class ExperianProvider(CreditProvider):
|
|
|
292
292
|
|
|
293
293
|
# Parse response to CreditReport model
|
|
294
294
|
result = parse_credit_report(data, user_id=user_id)
|
|
295
|
-
|
|
295
|
+
|
|
296
296
|
# Log successful pull
|
|
297
297
|
fcra_audit_logger.info(
|
|
298
298
|
"FCRA_CREDIT_PULL_SUCCESS",
|
|
@@ -304,11 +304,11 @@ class ExperianProvider(CreditProvider):
|
|
|
304
304
|
"result": "success",
|
|
305
305
|
"accounts_returned": len(result.accounts) if result.accounts else 0,
|
|
306
306
|
"inquiries_returned": len(result.inquiries) if result.inquiries else 0,
|
|
307
|
-
}
|
|
307
|
+
},
|
|
308
308
|
)
|
|
309
|
-
|
|
309
|
+
|
|
310
310
|
return result
|
|
311
|
-
|
|
311
|
+
|
|
312
312
|
except Exception as e:
|
|
313
313
|
# Log failed pull - still required for FCRA audit trail
|
|
314
314
|
fcra_audit_logger.warning(
|
|
@@ -320,7 +320,7 @@ class ExperianProvider(CreditProvider):
|
|
|
320
320
|
"timestamp": timestamp,
|
|
321
321
|
"result": "error",
|
|
322
322
|
"error_type": type(e).__name__,
|
|
323
|
-
}
|
|
323
|
+
},
|
|
324
324
|
)
|
|
325
325
|
raise
|
|
326
326
|
|
|
@@ -360,4 +360,4 @@ class ExperianProvider(CreditProvider):
|
|
|
360
360
|
signature_key=signature_key,
|
|
361
361
|
)
|
|
362
362
|
|
|
363
|
-
return data.get("subscriptionId", "unknown")
|
|
363
|
+
return cast(str, data.get("subscriptionId", "unknown"))
|
fin_infra/crypto/__init__.py
CHANGED
|
@@ -70,7 +70,7 @@ def easy_crypto(
|
|
|
70
70
|
return CoinGeckoCryptoData()
|
|
71
71
|
|
|
72
72
|
else:
|
|
73
|
-
raise ValueError(f"Unknown crypto data provider: {provider_name}.
|
|
73
|
+
raise ValueError(f"Unknown crypto data provider: {provider_name}. Supported: coingecko")
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def add_crypto_data(
|
fin_infra/crypto/insights.py
CHANGED
|
@@ -258,10 +258,8 @@ Provide your insight:"""
|
|
|
258
258
|
|
|
259
259
|
try:
|
|
260
260
|
# Use natural language conversation (no output_schema)
|
|
261
|
-
# Note: In tests, achat is mocked with messages= parameter
|
|
262
|
-
# In production, this should use user_msg, provider, model_name parameters
|
|
263
261
|
response = await llm.achat(
|
|
264
|
-
|
|
262
|
+
user_msg=prompt,
|
|
265
263
|
)
|
|
266
264
|
|
|
267
265
|
# Parse response text
|
fin_infra/documents/add.py
CHANGED
|
@@ -93,6 +93,7 @@ def add_documents(
|
|
|
93
93
|
# Import svc-infra base function to mount base endpoints (with fallback)
|
|
94
94
|
try:
|
|
95
95
|
from svc_infra.documents import add_documents as add_base_documents
|
|
96
|
+
|
|
96
97
|
HAS_SVC_INFRA_DOCUMENTS = True
|
|
97
98
|
except ImportError:
|
|
98
99
|
# Fallback for older svc-infra versions - skip base endpoints
|
|
@@ -104,16 +105,17 @@ def add_documents(
|
|
|
104
105
|
|
|
105
106
|
# Step 1: Mount base endpoints (upload, list, get, delete) via svc-infra
|
|
106
107
|
# This returns the base DocumentManager, but we'll create our own FinancialDocumentManager
|
|
107
|
-
if HAS_SVC_INFRA_DOCUMENTS and add_base_documents:
|
|
108
|
+
if HAS_SVC_INFRA_DOCUMENTS and add_base_documents is not None:
|
|
108
109
|
add_base_documents(app, storage_backend=storage, prefix=prefix, tags=tags)
|
|
109
110
|
else:
|
|
110
111
|
# Legacy mode: mount basic endpoints inline (for svc-infra < 0.1.668)
|
|
111
112
|
import warnings
|
|
113
|
+
|
|
112
114
|
warnings.warn(
|
|
113
115
|
"svc_infra.documents not found. Using legacy document endpoints. "
|
|
114
116
|
"Please upgrade svc-infra to >=0.1.668 for full functionality.",
|
|
115
117
|
DeprecationWarning,
|
|
116
|
-
stacklevel=2
|
|
118
|
+
stacklevel=2,
|
|
117
119
|
)
|
|
118
120
|
|
|
119
121
|
# Step 2: Create financial document manager with OCR/AI capabilities
|
|
@@ -210,9 +212,7 @@ def add_documents(
|
|
|
210
212
|
```
|
|
211
213
|
"""
|
|
212
214
|
try:
|
|
213
|
-
return await manager.analyze(
|
|
214
|
-
document_id=document_id, force_refresh=force_refresh
|
|
215
|
-
)
|
|
215
|
+
return await manager.analyze(document_id=document_id, force_refresh=force_refresh)
|
|
216
216
|
except ValueError as e:
|
|
217
217
|
raise HTTPException(status_code=404, detail=str(e))
|
|
218
218
|
|
fin_infra/documents/ease.py
CHANGED
|
@@ -44,11 +44,12 @@ except ImportError:
|
|
|
44
44
|
# Fallback for older svc-infra versions without documents module
|
|
45
45
|
# This provides backward compatibility until svc-infra 0.1.668+ is published
|
|
46
46
|
import warnings
|
|
47
|
+
|
|
47
48
|
warnings.warn(
|
|
48
49
|
"svc_infra.documents not found. Using legacy implementation. "
|
|
49
50
|
"Please upgrade svc-infra to >=0.1.668 for layered architecture support.",
|
|
50
51
|
DeprecationWarning,
|
|
51
|
-
stacklevel=2
|
|
52
|
+
stacklevel=2,
|
|
52
53
|
)
|
|
53
54
|
BaseDocumentManager = object # type: ignore
|
|
54
55
|
|
|
@@ -65,10 +66,10 @@ class FinancialDocumentManager(BaseDocumentManager):
|
|
|
65
66
|
Inherits from svc-infra DocumentManager:
|
|
66
67
|
- upload(), download(), delete(), get(), list() for base document CRUD
|
|
67
68
|
- storage backend integration
|
|
68
|
-
|
|
69
|
+
|
|
69
70
|
Adds financial-specific methods:
|
|
70
71
|
- upload_financial(): Upload with DocumentType, tax_year, form_type
|
|
71
|
-
- extract_text(): OCR for tax forms
|
|
72
|
+
- extract_text(): OCR for tax forms
|
|
72
73
|
- analyze(): AI-powered financial insights
|
|
73
74
|
|
|
74
75
|
Attributes:
|
fin_infra/documents/models.py
CHANGED
|
@@ -52,17 +52,17 @@ class DocumentType(str, Enum):
|
|
|
52
52
|
class FinancialDocument(BaseDocument):
|
|
53
53
|
"""
|
|
54
54
|
Financial document extending base Document with financial-specific fields.
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
Inherits from svc-infra Document:
|
|
57
57
|
- id, user_id, filename, file_size, upload_date
|
|
58
58
|
- storage_path, content_type, checksum
|
|
59
59
|
- metadata (Dict[str, Any])
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
Adds financial-specific fields:
|
|
62
62
|
- type: DocumentType enum
|
|
63
63
|
- tax_year: Optional year for tax documents
|
|
64
64
|
- form_type: Optional form identifier (W-2, 1099, etc.)
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
Examples:
|
|
67
67
|
>>> # Tax document with W-2 form
|
|
68
68
|
>>> doc = FinancialDocument(
|
fin_infra/documents/ocr.py
CHANGED
|
@@ -46,7 +46,7 @@ async def extract_text(
|
|
|
46
46
|
Extract text from a document using OCR (uses svc-infra storage).
|
|
47
47
|
|
|
48
48
|
Args:
|
|
49
|
-
storage: Storage backend instance
|
|
49
|
+
storage: Storage backend instance
|
|
50
50
|
document_id: Document identifier
|
|
51
51
|
provider: OCR provider ("tesseract" or "textract")
|
|
52
52
|
force_refresh: Force re-extraction even if cached result exists
|
fin_infra/documents/storage.py
CHANGED
|
@@ -46,6 +46,7 @@ try:
|
|
|
46
46
|
list_documents as base_list_documents,
|
|
47
47
|
upload_document as base_upload_document,
|
|
48
48
|
)
|
|
49
|
+
|
|
49
50
|
HAS_SVC_INFRA_DOCUMENTS = True
|
|
50
51
|
except ImportError:
|
|
51
52
|
# Fallback for older svc-infra versions - use legacy implementation
|
|
@@ -153,7 +154,7 @@ def get_document(document_id: str) -> Optional["FinancialDocument"]:
|
|
|
153
154
|
>>> doc = get_document("doc_abc123")
|
|
154
155
|
>>> if doc:
|
|
155
156
|
... print(doc.filename, doc.type, doc.tax_year)
|
|
156
|
-
|
|
157
|
+
|
|
157
158
|
Notes:
|
|
158
159
|
- Delegates to svc-infra.documents.get_document
|
|
159
160
|
- Converts base Document to FinancialDocument
|
fin_infra/exceptions.py
CHANGED
|
@@ -6,7 +6,7 @@ This module provides a consistent exception hierarchy across all fin-infra compo
|
|
|
6
6
|
- Validation errors (data validation, compliance)
|
|
7
7
|
- Calculation errors (financial calculations)
|
|
8
8
|
|
|
9
|
-
All exceptions inherit from FinInfraError, allowing users to catch all library
|
|
9
|
+
All exceptions inherit from FinInfraError, allowing users to catch all library
|
|
10
10
|
errors with a single except clause.
|
|
11
11
|
|
|
12
12
|
Example:
|
fin_infra/goals/add.py
CHANGED
|
@@ -29,7 +29,7 @@ add_goals(app)
|
|
|
29
29
|
|
|
30
30
|
import logging
|
|
31
31
|
from datetime import datetime
|
|
32
|
-
from typing import List, Optional
|
|
32
|
+
from typing import Any, List, Optional, cast
|
|
33
33
|
|
|
34
34
|
from fastapi import FastAPI, HTTPException, status, Query, Body
|
|
35
35
|
from pydantic import BaseModel, Field
|
|
@@ -469,7 +469,7 @@ def add_goals(
|
|
|
469
469
|
# Get all milestones from the goal (check_milestones only returns newly reached ones)
|
|
470
470
|
goal = get_goal(goal_id)
|
|
471
471
|
milestones = goal.get("milestones", [])
|
|
472
|
-
return milestones
|
|
472
|
+
return cast(list[dict[Any, Any]], milestones)
|
|
473
473
|
except KeyError:
|
|
474
474
|
raise HTTPException(
|
|
475
475
|
status_code=status.HTTP_404_NOT_FOUND, detail=f"Goal {goal_id} not found"
|
fin_infra/goals/management.py
CHANGED
|
@@ -41,7 +41,7 @@ Example:
|
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
43
|
from datetime import datetime
|
|
44
|
-
from typing import Any
|
|
44
|
+
from typing import Any, cast
|
|
45
45
|
|
|
46
46
|
from pydantic import BaseModel, Field
|
|
47
47
|
|
|
@@ -597,10 +597,10 @@ Goal type: {goal_type}
|
|
|
597
597
|
Goal data: {goal}
|
|
598
598
|
|
|
599
599
|
CALCULATED VALUES (use these exactly, don't recalculate):
|
|
600
|
-
- Feasibility: {calc[
|
|
601
|
-
- Required monthly: ${calc[
|
|
600
|
+
- Feasibility: {calc["feasibility"]}
|
|
601
|
+
- Required monthly: ${calc["required_monthly"]:,.0f}
|
|
602
602
|
- Projected completion: {projected_date}
|
|
603
|
-
- Current progress: {calc[
|
|
603
|
+
- Current progress: {calc["current_progress"]:.1%}
|
|
604
604
|
|
|
605
605
|
Provide context and advice around these calculations. Suggest 2-3 alternative paths and 3-5 specific recommendations."""
|
|
606
606
|
|
|
@@ -839,7 +839,7 @@ def get_goal(goal_id: str) -> dict[str, Any]:
|
|
|
839
839
|
if goal_id not in _GOALS_STORE:
|
|
840
840
|
raise KeyError(f"Goal not found: {goal_id}")
|
|
841
841
|
|
|
842
|
-
return _GOALS_STORE[goal_id]
|
|
842
|
+
return cast(dict[str, Any], _GOALS_STORE[goal_id])
|
|
843
843
|
|
|
844
844
|
|
|
845
845
|
def update_goal(
|
|
@@ -885,7 +885,7 @@ def update_goal(
|
|
|
885
885
|
|
|
886
886
|
Goal(**goal) # Will raise ValidationError if invalid
|
|
887
887
|
|
|
888
|
-
return goal
|
|
888
|
+
return cast(dict[str, Any], goal)
|
|
889
889
|
|
|
890
890
|
|
|
891
891
|
def delete_goal(goal_id: str) -> None:
|
fin_infra/goals/milestones.py
CHANGED
|
@@ -26,7 +26,7 @@ Example:
|
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from datetime import datetime
|
|
29
|
-
from typing import Any
|
|
29
|
+
from typing import Any, cast
|
|
30
30
|
|
|
31
31
|
from fin_infra.goals.management import get_goal, update_goal
|
|
32
32
|
from fin_infra.goals.models import Milestone
|
|
@@ -229,7 +229,7 @@ def get_next_milestone(goal_id: str) -> dict[str, Any] | None:
|
|
|
229
229
|
# Find first unreached milestone (sorted by amount)
|
|
230
230
|
for milestone in milestones:
|
|
231
231
|
if not milestone.get("reached", False):
|
|
232
|
-
return milestone
|
|
232
|
+
return cast(dict[str, Any], milestone)
|
|
233
233
|
|
|
234
234
|
return None
|
|
235
235
|
|