Skip to content

Commit 0e5c3e2

Browse files
committed
fix: resolve 10 diagnosed bugs + Excel viewer prep
- Fix #1: Header row contamination - skip rows with header-alias text - Fix #2: Balance double-run - single authoritative closing balance - Fix #3: Date normalization - parse dates properly, skip non-dates - Fix #4: Round-number detector - exclude zero amounts, require %100 - Fix #5: Flow summary offset - correct opening balance detection - Fix #6: Anomaly reason logging - descriptive entries in repair_log - Fix #7: Skip image QA on Excel files - Fix #8: Backboard OCR fallback - preserve all normalizer fields - Fix #9: Confidence penalty based on validation severity - Fix #10: Severity grading (critical/warning/info) on all validators - Updated .gitignore to exclude node_modules, storage, db files
1 parent 1980765 commit 0e5c3e2

116 files changed

Lines changed: 15246 additions & 9691 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# FinShield Monorepo .gitignore
22

3+
# =============================================================================
4+
# Node / Web App
5+
# =============================================================================
6+
node_modules/
7+
webapp/node_modules/
8+
frontend/build/
9+
webapp/dist/
10+
311
# =============================================================================
412
# Python / Backend
513
# =============================================================================
@@ -149,6 +157,11 @@ Thumbs.db
149157
# Project Specific
150158
# =============================================================================
151159
*.log
160+
*.db
152161
logs/
153162
temp/
154163
tmp/
164+
backend/storage/
165+
frontend_backup/
166+
frontend_backup_20260209/
167+
backup/

backend/FRONTEND_INTEGRATION.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ http://localhost:8000/api
2222

2323
## 📤 1. Document Upload & Analysis
2424

25-
**Endpoint**: `POST /api/documents/analyze`
25+
**Primary endpoint (single document)**: `POST /api/documents/analyze`
2626

2727
**Request**:
2828
```javascript
@@ -73,6 +73,28 @@ const result = await response.json();
7373

7474
---
7575

76+
### Bulk ingestion (new web UI)
77+
78+
The new React-based Reviewer UI uses a bulk-ingestion API that wraps the same
79+
pipeline and stores the results in the shared memory + knowledge graph.
80+
81+
**Endpoint**: `POST /api/ingestion/documents`
82+
83+
**Response (summary per file)**:
84+
```json
85+
{
86+
"documents": [
87+
{
88+
"document_id": "uuid-here",
89+
"filename": "invoice.pdf",
90+
"doc_type": "INVOICE",
91+
"confidence": 0.94,
92+
"status": "success"
93+
}
94+
]
95+
}
96+
```
97+
7698
## ✏️ 2. Submit Corrections
7799

78100
**Endpoint**: `POST /api/review/{doc_id}/correct`
@@ -187,7 +209,9 @@ const metrics = await fetch('http://localhost:8000/api/dashboard/metrics')
187209

188210
## 📈 6. Error Clusters
189211

190-
**Endpoint**: `GET /api/review/errors/clusters`
212+
**Legacy endpoint**: `GET /api/review/errors/clusters`
213+
214+
**Learning-loop endpoint (for analytics)**: `GET /api/learning/errors/clusters`
191215

192216
**Use Case**: View common extraction errors
193217

backend/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Finsight Backend
2+
3+
FastAPI service for the Finsight autonomous financial document intelligence engine.

backend/app/api/__init__.py

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,16 @@
1-
"""FinShield API Router"""
1+
"""Finsight API Router"""
22

33
from fastapi import APIRouter
44

5-
from app.api.v1 import router as v1_router
6-
from app.api import documents, review, dashboard, admin
5+
from app.api import health, documents, ingestion, review, dashboard, learning, knowledge, forensics
76

87
router = APIRouter(prefix="/api")
98

10-
router.include_router(v1_router)
11-
12-
# Document Intelligence
13-
router.include_router(
14-
documents.router,
15-
prefix="/documents",
16-
tags=["documents"]
17-
)
18-
19-
# Review & Corrections
20-
router.include_router(
21-
review.router,
22-
prefix="/review",
23-
tags=["review"]
24-
)
25-
26-
# Dashboard & Metrics
27-
router.include_router(
28-
dashboard.router,
29-
prefix="/dashboard",
30-
tags=["dashboard"]
31-
)
32-
33-
# Admin & Learning
34-
router.include_router(
35-
admin.router,
36-
prefix="/admin",
37-
tags=["admin"]
38-
)
9+
router.include_router(health.router, tags=["health"])
10+
router.include_router(documents.router, tags=["documents"])
11+
router.include_router(ingestion.router, tags=["ingestion"])
12+
router.include_router(review.router, tags=["review"])
13+
router.include_router(dashboard.router, tags=["dashboard"])
14+
router.include_router(learning.router, tags=["learning"])
15+
router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])
16+
router.include_router(forensics.router, prefix="/forensics", tags=["forensics"])

backend/app/api/dashboard.py

Lines changed: 199 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,227 @@
1-
"""
2-
FinShield - Dashboard API
1+
"""Finsight - Dashboard API."""
32

4-
Extraction quality metrics and analytics.
5-
"""
3+
from datetime import datetime, timedelta
4+
import math
5+
from typing import Any, Dict, List, Tuple
66

7-
from fastapi import APIRouter
8-
from typing import Dict, Any
9-
import logging
7+
from fastapi import APIRouter, Depends
8+
from sqlmodel import Session, select
109

11-
from app.services.learning_loop import get_correction_store
10+
from app.db.models import Anomaly, Correction, Document, Entity
11+
from app.db.session import get_session
12+
from app.services.learning import cluster_corrections
1213

13-
logger = logging.getLogger(__name__)
14-
15-
router = APIRouter()
14+
router = APIRouter(prefix="/dashboard")
1615

1716

1817
@router.get("/metrics")
19-
async def get_dashboard_metrics() -> Dict[str, Any]:
20-
"""
21-
Get extraction quality dashboard metrics.
22-
23-
Returns:
24-
Metrics including accuracy, error rate, processing time, etc.
25-
"""
26-
store = get_correction_store()
27-
28-
# Calculate metrics
29-
total_corrections = len(store.corrections)
30-
error_rate = store.get_error_rate()
31-
error_clusters = store.get_error_clusters()
32-
33-
# Mock additional metrics (replace with real data in production)
18+
async def get_dashboard_metrics(session: Session = Depends(get_session)) -> Dict[str, Any]:
19+
docs = session.exec(select(Document)).all()
20+
corrections = session.exec(select(Correction)).all()
21+
entities = session.exec(select(Entity)).all()
22+
all_anomalies = session.exec(select(Anomaly)).all()
23+
24+
total_docs = len(docs)
25+
total_corrections = len(corrections)
26+
error_rate = (total_corrections / total_docs) if total_docs else 0.0
27+
28+
# Processing time stats
29+
processing_times = [d.processing_time_ms for d in docs if d.processing_time_ms is not None]
30+
avg_processing_time = round(sum(processing_times) / len(processing_times), 1) if processing_times else None
31+
max_processing_time = max(processing_times) if processing_times else None
32+
33+
# Anomaly aggregation
34+
anomaly_by_type: Dict[str, int] = {}
35+
anomaly_by_severity: Dict[str, int] = {"critical": 0, "warning": 0, "info": 0}
36+
for a in all_anomalies:
37+
anomaly_by_type[a.anomaly_type] = anomaly_by_type.get(a.anomaly_type, 0) + 1
38+
anomaly_by_severity[a.severity] = anomaly_by_severity.get(a.severity, 0) + 1
39+
40+
accuracy_by_type: Dict[str, Dict[str, float | int]] = {}
41+
for doc in docs:
42+
stats = accuracy_by_type.setdefault(
43+
doc.doc_type or "unknown", {"accuracy": 0.0, "count": 0, "passes": 0}
44+
)
45+
stats["count"] = int(stats["count"]) + 1
46+
if not doc.validation_errors:
47+
stats["passes"] = int(stats["passes"]) + 1
48+
49+
for doc_type, stats in accuracy_by_type.items():
50+
count = int(stats["count"]) or 1
51+
passes = int(stats.get("passes", 0))
52+
stats["accuracy"] = passes / count
53+
stats.pop("passes", None)
54+
55+
error_clusters = cluster_corrections(session).get("clusters", {})
56+
57+
week_ago = datetime.utcnow() - timedelta(days=7)
58+
docs_last_7 = [doc for doc in docs if doc.created_at >= week_ago]
59+
corrections_last_7 = [corr for corr in corrections if corr.created_at >= week_ago]
60+
error_rate_7 = (len(corrections_last_7) / len(docs_last_7)) if docs_last_7 else 0.0
61+
62+
avg_quality = None
63+
quality_scores = [doc.image_quality for doc in docs if doc.image_quality is not None]
64+
if quality_scores:
65+
avg_quality = sum(quality_scores) / len(quality_scores)
66+
67+
benford_series = _build_benford_series(docs)
68+
flow_data = _build_money_flow(docs)
69+
70+
# Status distribution
71+
status_dist: Dict[str, int] = {}
72+
for doc in docs:
73+
status_dist[doc.status] = status_dist.get(doc.status, 0) + 1
74+
3475
metrics = {
3576
"overview": {
36-
"total_documents_processed": 0, # Track in production
77+
"total_documents_processed": total_docs,
3778
"total_corrections": total_corrections,
3879
"error_rate": error_rate,
39-
"avg_processing_time": 0.0 # Track in production
80+
"avg_processing_time_ms": avg_processing_time,
81+
"max_processing_time_ms": max_processing_time,
82+
"avg_quality_score": avg_quality,
83+
},
84+
"anomaly_overview": {
85+
"total_anomalies": len(all_anomalies),
86+
"by_type": anomaly_by_type,
87+
"by_severity": anomaly_by_severity,
88+
"density": round(len(all_anomalies) / total_docs, 2) if total_docs else 0,
4089
},
41-
"accuracy_by_type": {
42-
"invoice": {"accuracy": 0.95, "count": 0},
43-
"bank_statement": {"accuracy": 0.92, "count": 0},
44-
"payslip": {"accuracy": 0.88, "count": 0}
90+
"knowledge_graph": {
91+
"entities": len(entities),
92+
"documents": total_docs,
4593
},
94+
"accuracy_by_type": accuracy_by_type,
4695
"error_clusters": error_clusters,
96+
"quality_distribution": {
97+
"low": len([q for q in quality_scores if q < 0.4]),
98+
"medium": len([q for q in quality_scores if 0.4 <= q < 0.75]),
99+
"high": len([q for q in quality_scores if q >= 0.75]),
100+
},
101+
"status_distribution": status_dist,
102+
"benford": benford_series,
103+
"money_flow": flow_data,
47104
"trends": {
48105
"last_7_days": {
49-
"documents": 0,
50-
"corrections": total_corrections,
51-
"error_rate": error_rate
106+
"documents": len(docs_last_7),
107+
"corrections": len(corrections_last_7),
108+
"error_rate": error_rate_7,
52109
}
53110
},
54111
"top_error_fields": [
55112
{"field": field, "count": data["count"]}
56113
for field, data in sorted(
57114
error_clusters.items(),
58115
key=lambda x: x[1]["count"],
59-
reverse=True
116+
reverse=True,
60117
)[:5]
61-
]
118+
],
62119
}
63-
120+
64121
return metrics
65122

66123

67-
@router.get("/health")
68-
async def dashboard_health() -> Dict[str, str]:
69-
"""Dashboard health check."""
70-
return {"status": "healthy", "service": "dashboard"}
124+
def _iter_transaction_amounts(docs: List[Document]) -> List[Tuple[float, str]]:
125+
amounts: List[Tuple[float, str]] = []
126+
for doc in docs:
127+
extracted = doc.extracted_fields or {}
128+
transactions = extracted.get("transactions") or []
129+
if not isinstance(transactions, list):
130+
continue
131+
for tx in transactions:
132+
if not isinstance(tx, dict):
133+
continue
134+
amount = tx.get("amount")
135+
if amount is None:
136+
continue
137+
try:
138+
value = float(amount)
139+
except (TypeError, ValueError):
140+
continue
141+
description = str(tx.get("description") or "")
142+
amounts.append((value, description))
143+
return amounts
144+
145+
146+
def _build_benford_series(docs: List[Document]) -> List[Dict[str, float]]:
147+
counts = [0] * 9
148+
amounts = _iter_transaction_amounts(docs)
149+
for value, _ in amounts:
150+
value = abs(value)
151+
if value < 1:
152+
continue
153+
first_digit = int(str(int(value))[0])
154+
if 1 <= first_digit <= 9:
155+
counts[first_digit - 1] += 1
156+
total = sum(counts) or 1
157+
series: List[Dict[str, float]] = []
158+
for idx, count in enumerate(counts):
159+
digit = idx + 1
160+
expected = round(math.log10(1 + 1 / digit), 4)
161+
observed = round(count / total, 4)
162+
series.append({"digit": digit, "observed": observed, "expected": expected})
163+
return series
164+
165+
166+
def _build_money_flow(docs: List[Document]) -> Dict[str, Any]:
167+
categories = [
168+
"Income",
169+
"Expense",
170+
"Transfers",
171+
"Fees",
172+
"Card",
173+
"ATM",
174+
"Payments",
175+
"Interest",
176+
"Other",
177+
"Suspicious",
178+
]
179+
nodes = [{"name": name} for name in categories]
180+
amounts = _iter_transaction_amounts(docs)
181+
abs_values = sorted(abs(value) for value, _ in amounts if abs(value) > 0)
182+
threshold = abs_values[int(len(abs_values) * 0.95)] if abs_values else 0.0
183+
184+
def classify(desc: str, amount: float) -> str:
185+
lower = desc.lower()
186+
if not desc or abs(amount) >= threshold:
187+
return "Suspicious"
188+
if "fee" in lower or "charge" in lower:
189+
return "Fees"
190+
if "card" in lower or "pos" in lower:
191+
return "Card"
192+
if "atm" in lower or "cash" in lower:
193+
return "ATM"
194+
if "transfer" in lower or "neft" in lower or "imps" in lower:
195+
return "Transfers"
196+
if "interest" in lower:
197+
return "Interest"
198+
if "payment" in lower or "bill" in lower or "upi" in lower:
199+
return "Payments"
200+
if amount >= 0:
201+
return "Income"
202+
return "Other"
203+
204+
totals: Dict[str, float] = {}
205+
for amount, desc in amounts:
206+
source_name = "Income" if amount >= 0 else "Expense"
207+
target_name = classify(desc, amount)
208+
key = f"{source_name}->{target_name}"
209+
totals[key] = (totals.get(key, 0.0) + abs(amount))
210+
211+
def idx(name: str) -> int:
212+
return categories.index(name)
213+
214+
links = []
215+
for key, value in totals.items():
216+
source_name, target_name = key.split("->")
217+
if value <= 0:
218+
continue
219+
links.append(
220+
{
221+
"source": idx(source_name),
222+
"target": idx(target_name),
223+
"value": round(value, 2),
224+
}
225+
)
226+
227+
return {"nodes": nodes, "links": links, "suspicious_threshold": threshold}

0 commit comments

Comments
 (0)