validops-east-1 commited on
Commit
69786b0
·
1 Parent(s): 4b54fab

feat: add csv reconciliation

Browse files
app/api/v1/reconcile.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import time
7
+ import uuid
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import aiohttp
11
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
12
+ from pydantic import BaseModel
13
+
14
+ from app.api.deps import require_auth
15
+ from app.config import get_settings
16
+ from app.core.logger import get_logger
17
+ from app.services.reconciliation_service import (
18
+ SUPPORTED_EXTENSIONS,
19
+ analyze_duplicates,
20
+ analyze_missing_data,
21
+ compare_rows,
22
+ compare_schemas,
23
+ download_file_with_retry,
24
+ normalize_dataframe,
25
+ reconcile_columns,
26
+ reconcile_date_columns,
27
+ reconcile_numeric_columns,
28
+ read_to_dataframe,
29
+ )
30
+
31
+ router = APIRouter()
32
+ _logger = get_logger(__name__)
33
+ _settings = get_settings()
34
+ _MAX_UPLOAD_BYTES = _settings.max_upload_bytes
35
+ _MAX_PAIRS = 10
36
+ semaphore_jobs = asyncio.Semaphore(5)
37
+
38
+
39
+ class ReconciliationPair(BaseModel):
40
+ source: str
41
+ destination: str
42
+
43
+
44
+ class ReconciliationUrlRequest(BaseModel):
45
+ pairs: List[ReconciliationPair]
46
+ numeric_columns: Optional[List[str]] = None
47
+
48
+
49
+ class ReconciliationPairResult(BaseModel):
50
+ pair_index: int
51
+ status: str
52
+ error: Optional[str] = None
53
+ schema_report: Optional[Dict[str, Any]] = None
54
+ columns: Optional[List[Dict[str, Any]]] = None
55
+ numeric_columns: Optional[List[Dict[str, Any]]] = None
56
+ date_columns: Optional[List[Dict[str, Any]]] = None
57
+ duplicates: Optional[Dict[str, int]] = None
58
+ missing_values: Optional[List[Dict[str, Any]]] = None
59
+
60
+
61
+ class ReconciliationResponse(BaseModel):
62
+ success: bool
63
+ total_pairs: int
64
+ completed_pairs: int
65
+ failed_pairs: int
66
+ processing_time_ms: float
67
+ results: List[ReconciliationPairResult]
68
+
69
+
70
+ def _extract_extension(filename: str) -> str:
71
+ return os.path.splitext(filename)[1].lstrip(".").lower()
72
+
73
+
74
+ def _failed_result(pair_index: int, error: str) -> Dict[str, Any]:
75
+ return {
76
+ "pair_index": pair_index,
77
+ "status": "FAILED",
78
+ "error": error,
79
+ "schema_report": None,
80
+ "columns": None,
81
+ "numeric_columns": None,
82
+ "date_columns": None,
83
+ "duplicates": None,
84
+ "missing_values": None,
85
+ }
86
+
87
+
88
+ def _parse_numeric_keywords(numeric_columns: Optional[List[str]]) -> Optional[set]:
89
+ if not numeric_columns:
90
+ return None
91
+ return set(c.lower() for c in numeric_columns)
92
+
93
+
94
+ def _validate_extensions(src_ext: str, dst_ext: str, idx: int) -> None:
95
+ if src_ext not in SUPPORTED_EXTENSIONS:
96
+ raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Unsupported source format '{src_ext}'."})
97
+ if dst_ext not in SUPPORTED_EXTENSIONS:
98
+ raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Unsupported destination format '{dst_ext}'."})
99
+ if src_ext != dst_ext:
100
+ raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: File type mismatch - source '{src_ext}' != destination '{dst_ext}'."})
101
+
102
+
103
+ def _build_response(results: List[Dict[str, Any]], start_time: float) -> ReconciliationResponse:
104
+ failed = sum(1 for r in results if r["status"] == "FAILED")
105
+ return ReconciliationResponse(
106
+ success=failed == 0,
107
+ total_pairs=len(results),
108
+ completed_pairs=len(results) - failed,
109
+ failed_pairs=failed,
110
+ processing_time_ms=round((time.time() - start_time) * 1000, 2),
111
+ results=results,
112
+ )
113
+
114
+
115
+ async def _process_file_pair(
116
+ source_file: bytes,
117
+ dest_file: bytes,
118
+ source_ext: str,
119
+ dest_ext: str,
120
+ numeric_keywords: Optional[set] = None,
121
+ pair_index: int = 0,
122
+ ) -> Dict[str, Any]:
123
+ if source_ext != dest_ext:
124
+ return _failed_result(pair_index, f"File type mismatch. Source: {source_ext}, Destination: {dest_ext}")
125
+
126
+ try:
127
+ df_src = read_to_dataframe(source_file, source_ext, f"pair-{pair_index}")
128
+ df_dst = read_to_dataframe(dest_file, dest_ext, f"pair-{pair_index}")
129
+
130
+ if df_src.empty or df_dst.empty:
131
+ raise ValueError("One or both datasets are empty.")
132
+
133
+ df_src = normalize_dataframe(df_src.copy())
134
+ df_dst = normalize_dataframe(df_dst.copy())
135
+ df_src.infer_objects()
136
+ df_dst.infer_objects()
137
+
138
+ schema_report = compare_schemas(df_src, df_dst)
139
+ status = "MATCH" if schema_report["fully_match"] else "SCHEMA_MISMATCH"
140
+
141
+ common_cols = df_src.columns.intersection(df_dst.columns).tolist()
142
+
143
+ _, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
144
+ if (missing_rows > 0 or extra_rows > 0) and status == "MATCH":
145
+ status = "PARTIAL_MATCH"
146
+
147
+ col_reports = reconcile_columns(df_src, df_dst, common_cols)
148
+ num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols, numeric_keywords)
149
+ date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
150
+ dup_report = analyze_duplicates(df_src, df_dst)
151
+ missing_report = analyze_missing_data(df_src, df_dst, common_cols)
152
+
153
+ return {
154
+ "pair_index": pair_index,
155
+ "status": status,
156
+ "error": None,
157
+ "schema_report": schema_report,
158
+ "columns": col_reports,
159
+ "numeric_columns": num_reports,
160
+ "date_columns": date_reports,
161
+ "duplicates": dup_report,
162
+ "missing_values": missing_report,
163
+ }
164
+ except Exception as e:
165
+ _logger.exception(f"Pair {pair_index} failed during processing.")
166
+ return _failed_result(pair_index, str(e))
167
+
168
+
169
+ @router.post(
170
+ "/reconcile/files",
171
+ response_model=ReconciliationResponse,
172
+ summary="Reconcile uploaded file pairs (up to 10 pairs)",
173
+ )
174
+ async def reconcile_files(
175
+ source_1: UploadFile = File(None, description="Source file for pair 1"),
176
+ destination_1: UploadFile = File(None, description="Destination file for pair 1"),
177
+ source_2: UploadFile = File(None, description="Source file for pair 2"),
178
+ destination_2: UploadFile = File(None, description="Destination file for pair 2"),
179
+ source_3: UploadFile = File(None, description="Source file for pair 3"),
180
+ destination_3: UploadFile = File(None, description="Destination file for pair 3"),
181
+ source_4: UploadFile = File(None, description="Source file for pair 4"),
182
+ destination_4: UploadFile = File(None, description="Destination file for pair 4"),
183
+ source_5: UploadFile = File(None, description="Source file for pair 5"),
184
+ destination_5: UploadFile = File(None, description="Destination file for pair 5"),
185
+ source_6: UploadFile = File(None, description="Source file for pair 6"),
186
+ destination_6: UploadFile = File(None, description="Destination file for pair 6"),
187
+ source_7: UploadFile = File(None, description="Source file for pair 7"),
188
+ destination_7: UploadFile = File(None, description="Destination file for pair 7"),
189
+ source_8: UploadFile = File(None, description="Source file for pair 8"),
190
+ destination_8: UploadFile = File(None, description="Destination file for pair 8"),
191
+ source_9: UploadFile = File(None, description="Source file for pair 9"),
192
+ destination_9: UploadFile = File(None, description="Destination file for pair 9"),
193
+ source_10: UploadFile = File(None, description="Source file for pair 10"),
194
+ destination_10: UploadFile = File(None, description="Destination file for pair 10"),
195
+ numeric_columns: Optional[str] = None,
196
+ token: str = Depends(require_auth),
197
+ ) -> ReconciliationResponse:
198
+ start_time = time.time()
199
+
200
+ numeric_keywords = None
201
+ if numeric_columns:
202
+ try:
203
+ numeric_keywords = _parse_numeric_keywords(json.loads(numeric_columns))
204
+ except json.JSONDecodeError:
205
+ pass
206
+
207
+ files = [
208
+ (source_1, destination_1), (source_2, destination_2), (source_3, destination_3),
209
+ (source_4, destination_4), (source_5, destination_5), (source_6, destination_6),
210
+ (source_7, destination_7), (source_8, destination_8), (source_9, destination_9),
211
+ (source_10, destination_10),
212
+ ]
213
+
214
+ pairs: List[tuple] = []
215
+ for idx, (src, dst) in enumerate(files, 1):
216
+ if src is None and dst is None:
217
+ continue
218
+ if src is None or dst is None:
219
+ raise HTTPException(status_code=400, detail={"success": False, "message": f"Pair {idx}: Both source and destination files required."})
220
+ src_ext = _extract_extension(src.filename or "")
221
+ dst_ext = _extract_extension(dst.filename or "")
222
+ _validate_extensions(src_ext, dst_ext, idx)
223
+ pairs.append((src, dst, idx, src_ext, dst_ext))
224
+
225
+ if not pairs:
226
+ raise HTTPException(status_code=400, detail={"success": False, "message": "At least one file pair is required."})
227
+
228
+ async def process_file_pair(src: UploadFile, dst: UploadFile, idx: int, src_ext: str, dst_ext: str) -> Dict[str, Any]:
229
+ async with semaphore_jobs:
230
+ src_data = await src.read()
231
+ dst_data = await dst.read()
232
+ if len(src_data) > _MAX_UPLOAD_BYTES or len(dst_data) > _MAX_UPLOAD_BYTES:
233
+ return _failed_result(idx, "File size exceeds maximum allowed limit")
234
+ return await _process_file_pair(src_data, dst_data, src_ext, dst_ext, numeric_keywords, idx)
235
+
236
+ results = await asyncio.gather(*[process_file_pair(*p) for p in pairs])
237
+ return _build_response(results, start_time)
238
+
239
+
240
+ @router.post(
241
+ "/reconcile/urls",
242
+ response_model=ReconciliationResponse,
243
+ summary="Reconcile file URL pairs (up to 10 pairs)",
244
+ )
245
+ async def reconcile_urls(
246
+ body: ReconciliationUrlRequest,
247
+ token: str = Depends(require_auth),
248
+ ) -> ReconciliationResponse:
249
+ start_time = time.time()
250
+
251
+ if len(body.pairs) > _MAX_PAIRS:
252
+ raise HTTPException(status_code=400, detail={"success": False, "message": f"Maximum {_MAX_PAIRS} pairs per request."})
253
+ if len(body.pairs) < 1:
254
+ raise HTTPException(status_code=400, detail={"success": False, "message": "At least 1 pair is required."})
255
+
256
+ for idx, pair in enumerate(body.pairs, 1):
257
+ _validate_extensions(_extract_extension(pair.source), _extract_extension(pair.destination), idx)
258
+
259
+ numeric_keywords = _parse_numeric_keywords(body.numeric_columns)
260
+
261
+ async def process_url_pair(pair: ReconciliationPair, idx: int) -> Dict[str, Any]:
262
+ async with semaphore_jobs:
263
+ job_id = str(uuid.uuid4())
264
+ src_ext = _extract_extension(pair.source)
265
+ dst_ext = _extract_extension(pair.destination)
266
+
267
+ async with aiohttp.ClientSession() as session:
268
+ try:
269
+ src_data, dst_data = await asyncio.gather(
270
+ download_file_with_retry(session, pair.source, job_id),
271
+ download_file_with_retry(session, pair.destination, job_id),
272
+ )
273
+ except Exception as e:
274
+ return _failed_result(idx, str(e))
275
+
276
+ if len(src_data) > _MAX_UPLOAD_BYTES or len(dst_data) > _MAX_UPLOAD_BYTES:
277
+ return _failed_result(idx, "File size exceeds maximum allowed limit")
278
+
279
+ return await _process_file_pair(src_data, dst_data, src_ext, dst_ext, numeric_keywords, idx)
280
+
281
+ results = await asyncio.gather(*[process_url_pair(pair, idx) for idx, pair in enumerate(body.pairs, 1)])
282
+ return _build_response(results, start_time)
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import batch, code_executor, convert, database, embeddings, system
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -13,3 +13,4 @@ api_v1_router.include_router(database.router, tags=["Database"])
13
  api_v1_router.include_router(embeddings.router, tags=["Embeddings"])
14
  api_v1_router.include_router(code_executor.router, tags=["Code Executor"])
15
  api_v1_router.include_router(verify_router, prefix="/verify", tags=["Verify"])
 
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import batch, code_executor, convert, database, embeddings, reconcile, system
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
13
  api_v1_router.include_router(embeddings.router, tags=["Embeddings"])
14
  api_v1_router.include_router(code_executor.router, tags=["Code Executor"])
15
  api_v1_router.include_router(verify_router, prefix="/verify", tags=["Verify"])
16
+ api_v1_router.include_router(reconcile.router, tags=["Reconcile"])
app/services/reconciliation_service.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import io
5
+ import os
6
+ import time
7
+ import unicodedata
8
+ from collections import Counter
9
+ from datetime import datetime
10
+ from typing import Any, Dict, List, Optional, Set, Tuple
11
+
12
+ import aiohttp
13
+ import pandas as pd
14
+
15
+ from app.config import get_settings
16
+ from app.core.logger import get_logger
17
+
18
+ _logger = get_logger(__name__)
19
+ _settings = get_settings()
20
+
21
+ MAX_CONCURRENT_DOWNLOADS = 10
22
+ MAX_CONCURRENT_JOBS = 5
23
+ DOWNLOAD_TIMEOUT = 60
24
+ DOWNLOAD_MAX_RETRIES = 3
25
+ DOWNLOAD_BACKOFF_FACTOR = 0.5
26
+ SUPPORTED_EXTENSIONS: Set[str] = {"csv", "xlsx", "xls", "tsv", "parquet"}
27
+ _MAX_FILE_SIZE = _settings.max_upload_bytes
28
+
29
+ DEFAULT_NUMERIC_KEYWORDS: Set[str] = {
30
+ "amount", "total", "debit", "credit", "tax",
31
+ "net amount", "gross amount", "balance", "quantity", "price", "rate", "value"
32
+ }
33
+
34
+
35
+ class ReconciliationError(Exception): pass
36
+ class DownloadError(ReconciliationError): pass
37
+ class FileTypeMismatchError(ReconciliationError): pass
38
+ class SchemaMismatchError(ReconciliationError): pass
39
+ class EmptyDatasetError(ReconciliationError): pass
40
+ class UnsupportedFormatError(ReconciliationError): pass
41
+
42
+
43
+ def extract_extension(url: str) -> str:
44
+ path = url.split("?")[0]
45
+ return os.path.splitext(path)[1].lstrip(".").lower()
46
+
47
+
48
+ def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
49
+ for col in df.columns:
50
+ if pd.api.types.is_string_dtype(df[col]):
51
+ s = df[col].astype(str).str.strip()
52
+ s = s.map(lambda x: unicodedata.normalize('NFKC', x) if x != 'nan' else x)
53
+ s = s.str.replace(r'\s+', ' ', regex=True)
54
+ df[col] = s.replace({'nan': pd.NA, 'None': pd.NA, '': pd.NA, 'null': pd.NA})
55
+ elif pd.api.types.is_numeric_dtype(df[col]):
56
+ df[col] = df[col].replace({pd.NA: None})
57
+ return df
58
+
59
+
60
+ async def download_file_with_retry(session: aiohttp.ClientSession, url: str, correlation_id: str) -> bytes:
61
+ for attempt in range(DOWNLOAD_MAX_RETRIES):
62
+ try:
63
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=DOWNLOAD_TIMEOUT)) as response:
64
+ response.raise_for_status()
65
+ return await response.read()
66
+ except aiohttp.ClientError as e:
67
+ wait_time = DOWNLOAD_BACKOFF_FACTOR * (2 ** attempt)
68
+ _logger.warning(
69
+ f"Download attempt {attempt+1} failed for {url}. Retrying in {wait_time}s. Error: {e}",
70
+ extra={"correlation_id": correlation_id}
71
+ )
72
+ await asyncio.sleep(wait_time)
73
+ raise DownloadError(f"Failed to download {url} after {DOWNLOAD_MAX_RETRIES} retries.")
74
+
75
+
76
+ def read_to_dataframe(data: bytes, ext: str, correlation_id: str) -> pd.DataFrame:
77
+ try:
78
+ if ext == "csv":
79
+ return pd.read_csv(io.BytesIO(data))
80
+ elif ext == "tsv":
81
+ return pd.read_csv(io.BytesIO(data), sep="\t")
82
+ elif ext == "xlsx":
83
+ return pd.read_excel(io.BytesIO(data), engine="openpyxl")
84
+ elif ext == "xls":
85
+ return pd.read_excel(io.BytesIO(data), engine="xlrd")
86
+ elif ext == "parquet":
87
+ return pd.read_parquet(io.BytesIO(data))
88
+ raise UnsupportedFormatError(f"File format '{ext}' is not supported.")
89
+ except Exception as e:
90
+ _logger.error(f"Failed to parse {ext} file: {str(e)}", extra={"correlation_id": correlation_id})
91
+ raise ReconciliationError(f"Corrupted or unparseable file: {str(e)}")
92
+
93
+
94
+ def compare_schemas(df_src: pd.DataFrame, df_dst: pd.DataFrame) -> Dict[str, Any]:
95
+ src_cols = set(df_src.columns)
96
+ dst_cols = set(df_dst.columns)
97
+ missing_in_src = list(dst_cols - src_cols)
98
+ missing_in_dst = list(src_cols - dst_cols)
99
+ type_mismatches = []
100
+
101
+ common_cols = src_cols & dst_cols
102
+ for col in common_cols:
103
+ if df_src[col].dtype != df_dst[col].dtype:
104
+ type_mismatches.append({
105
+ "column": col,
106
+ "source_type": str(df_src[col].dtype),
107
+ "destination_type": str(df_dst[col].dtype)
108
+ })
109
+
110
+ fully_match = not missing_in_src and not missing_in_dst and not type_mismatches
111
+ return {
112
+ "fully_match": fully_match,
113
+ "source_columns": len(df_src.columns),
114
+ "destination_columns": len(df_dst.columns),
115
+ "missing_in_source": missing_in_src,
116
+ "missing_in_destination": missing_in_dst,
117
+ "type_mismatches": type_mismatches
118
+ }
119
+
120
+
121
+ def compare_rows(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> Tuple[int, int, int, int]:
122
+ src_subset = df_src[common_cols].astype(str).agg('|'.join, axis=1)
123
+ dst_subset = df_dst[common_cols].astype(str).agg('|'.join, axis=1)
124
+
125
+ src_counts = Counter(src_subset)
126
+ dst_counts = Counter(dst_subset)
127
+
128
+ identical = sum(min(count, dst_counts.get(row, 0)) for row, count in src_counts.items())
129
+ missing = len(df_src) - identical
130
+ extra = len(df_dst) - identical
131
+
132
+ return identical, 0, missing, extra
133
+
134
+
135
+ def reconcile_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
136
+ results = []
137
+ for col in common_cols:
138
+ src_nn = int(df_src[col].count())
139
+ dst_nn = int(df_dst[col].count())
140
+ status = "fully_match" if src_nn == dst_nn else "partial_match"
141
+ results.append({
142
+ "column_name": col,
143
+ "status": status,
144
+ "source_non_null": src_nn,
145
+ "destination_non_null": dst_nn,
146
+ "matching_values": 0,
147
+ "mismatching_values": 0,
148
+ "difference_count": abs(src_nn - dst_nn)
149
+ })
150
+ return results
151
+
152
+
153
+ def reconcile_numeric_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str], numeric_keywords: Optional[Set[str]] = None) -> List[Dict[str, Any]]:
154
+ numeric_keywords = numeric_keywords or DEFAULT_NUMERIC_KEYWORDS
155
+ results = []
156
+
157
+ for col in common_cols:
158
+ if any(kw in col.lower() for kw in numeric_keywords):
159
+ if pd.api.types.is_numeric_dtype(df_src[col]) and pd.api.types.is_numeric_dtype(df_dst[col]):
160
+ src_sum = df_src[col].sum()
161
+ dst_sum = df_dst[col].sum()
162
+ diff = src_sum - dst_sum
163
+ abs_diff = abs(diff)
164
+ pct_diff = (abs_diff / src_sum * 100) if src_sum != 0 else 0.0
165
+
166
+ src_nn = int(df_src[col].count())
167
+ dst_nn = int(df_dst[col].count())
168
+ status = "fully_match" if abs_diff < 1e-9 else "partial_match"
169
+
170
+ results.append({
171
+ "column_name": col,
172
+ "status": status,
173
+ "source_non_null": src_nn,
174
+ "destination_non_null": dst_nn,
175
+ "matching_values": src_nn,
176
+ "mismatching_values": abs(src_nn - dst_nn),
177
+ "difference_count": abs(src_nn - dst_nn),
178
+ "source_total": round(float(src_sum), 2),
179
+ "destination_total": round(float(dst_sum), 2),
180
+ "total_difference": round(float(diff), 2),
181
+ "absolute_difference": round(float(abs_diff), 2),
182
+ "percentage_difference": round(float(pct_diff), 5),
183
+ "source_min": round(float(df_src[col].min()), 2),
184
+ "source_max": round(float(df_src[col].max()), 2),
185
+ "source_avg": round(float(df_src[col].mean()), 2),
186
+ "dest_min": round(float(df_dst[col].min()), 2),
187
+ "dest_max": round(float(df_dst[col].max()), 2),
188
+ "dest_avg": round(float(df_dst[col].mean()), 2)
189
+ })
190
+ return results
191
+
192
+
193
+ def reconcile_date_columns(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
194
+ results = []
195
+ for col in common_cols:
196
+ if pd.api.types.is_datetime64_any_dtype(df_src[col]) and pd.api.types.is_datetime64_any_dtype(df_dst[col]):
197
+ src_nn = int(df_src[col].count())
198
+ dst_nn = int(df_dst[col].count())
199
+ results.append({
200
+ "column_name": col,
201
+ "status": "fully_match" if df_src[col].equals(df_dst[col]) else "partial_match",
202
+ "source_non_null": src_nn,
203
+ "destination_non_null": dst_nn,
204
+ "matching_values": min(src_nn, dst_nn),
205
+ "mismatching_values": abs(src_nn - dst_nn),
206
+ "difference_count": abs(src_nn - dst_nn),
207
+ "source_min_date": str(df_src[col].min()),
208
+ "source_max_date": str(df_src[col].max()),
209
+ "destination_min_date": str(df_dst[col].min()),
210
+ "destination_max_date": str(df_dst[col].max()),
211
+ "missing_dates_source": int(df_src[col].isna().sum()),
212
+ "missing_dates_destination": int(df_dst[col].isna().sum())
213
+ })
214
+ return results
215
+
216
+
217
+ def analyze_duplicates(df_src: pd.DataFrame, df_dst: pd.DataFrame) -> Dict[str, int]:
218
+ src_dups = int(df_src.duplicated().sum())
219
+ dst_dups = int(df_dst.duplicated().sum())
220
+ return {
221
+ "duplicate_rows_source": src_dups,
222
+ "duplicate_rows_destination": dst_dups,
223
+ "duplicate_difference": abs(src_dups - dst_dups)
224
+ }
225
+
226
+
227
+ def analyze_missing_data(df_src: pd.DataFrame, df_dst: pd.DataFrame, common_cols: List[str]) -> List[Dict[str, Any]]:
228
+ results = []
229
+ for col in common_cols:
230
+ src_nulls = int(df_src[col].isna().sum())
231
+ dst_nulls = int(df_dst[col].isna().sum())
232
+ results.append({
233
+ "column_name": col,
234
+ "source_nulls": src_nulls,
235
+ "destination_nulls": dst_nulls,
236
+ "difference": abs(src_nulls - dst_nulls)
237
+ })
238
+ return results
239
+
240
+
241
+ def _build_failed_pair(job_id: str, src_url: str, dst_url: str, started_at: str, status: str, errors: List[str]) -> Dict[str, Any]:
242
+ return {
243
+ "job_id": job_id,
244
+ "source_file": src_url,
245
+ "destination_file": dst_url,
246
+ "started_at": started_at,
247
+ "completed_at": datetime.utcnow().isoformat(),
248
+ "processing_time_ms": 0,
249
+ "status": status,
250
+ "summary": {"overall_status": status, "overall_match_percentage": 0.0},
251
+ "schema": {"fully_match": False, "source_columns": 0, "destination_columns": 0},
252
+ "columns": [],
253
+ "numeric_columns": [],
254
+ "date_columns": [],
255
+ "duplicates": {},
256
+ "missing_values": [],
257
+ "errors": errors
258
+ }
259
+
260
+
261
+ async def process_pair(src_url: str, dst_url: str, src_data: bytes, dst_data: bytes, ext: str, job_id: str, numeric_keywords: Optional[Set[str]] = None) -> Dict[str, Any]:
262
+ start_time = time.time()
263
+ started_at = datetime.utcnow().isoformat()
264
+ errors = []
265
+ warnings = []
266
+ status = "MATCH"
267
+
268
+ try:
269
+ df_src = read_to_dataframe(src_data, ext, job_id)
270
+ df_dst = read_to_dataframe(dst_data, ext, job_id)
271
+
272
+ if df_src.empty or df_dst.empty:
273
+ raise EmptyDatasetError("One or both datasets are empty.")
274
+
275
+ df_src = normalize_dataframe(df_src.copy())
276
+ df_dst = normalize_dataframe(df_dst.copy())
277
+ df_src = df_src.infer_objects()
278
+ df_dst = df_dst.infer_objects()
279
+
280
+ schema_report = compare_schemas(df_src, df_dst)
281
+ if not schema_report["fully_match"]:
282
+ status = "SCHEMA_MISMATCH"
283
+ warnings.append("Schema mismatch detected.")
284
+
285
+ common_cols = list(df_src.columns.intersection(df_dst.columns))
286
+
287
+ identical_rows, _, missing_rows, extra_rows = compare_rows(df_src, df_dst, common_cols)
288
+
289
+ if missing_rows > 0 or extra_rows > 0:
290
+ status = "PARTIAL_MATCH" if status == "MATCH" else status
291
+
292
+ col_reports = reconcile_columns(df_src, df_dst, common_cols)
293
+ num_reports = reconcile_numeric_columns(df_src, df_dst, common_cols, numeric_keywords)
294
+ date_reports = reconcile_date_columns(df_src, df_dst, common_cols)
295
+
296
+ dup_report = analyze_duplicates(df_src, df_dst)
297
+ missing_report = analyze_missing_data(df_src, df_dst, common_cols)
298
+
299
+ full_cols = sum(1 for c in col_reports if c["status"] == "fully_match")
300
+ part_cols = sum(1 for c in col_reports if c["status"] == "partial_match")
301
+
302
+ if part_cols > 0 and status == "MATCH":
303
+ status = "PARTIAL_MATCH"
304
+
305
+ total_max_rows = max(len(df_src), len(df_dst))
306
+ match_pct = (identical_rows / total_max_rows * 100) if total_max_rows > 0 else 100.0
307
+
308
+ summary = {
309
+ "total_columns": len(common_cols),
310
+ "fully_matched_columns": full_cols,
311
+ "partially_matched_columns": part_cols,
312
+ "unmatched_columns": len(col_reports) - full_cols - part_cols,
313
+ "total_rows_source": len(df_src),
314
+ "total_rows_destination": len(df_dst),
315
+ "matching_rows": identical_rows,
316
+ "partial_rows": 0,
317
+ "unmatched_rows": missing_rows + extra_rows,
318
+ "duplicate_rows": dup_report["duplicate_difference"],
319
+ "overall_match_percentage": round(match_pct, 2),
320
+ "overall_status": status
321
+ }
322
+ except Exception as e:
323
+ _logger.exception(f"Job {job_id} failed during processing.", extra={"correlation_id": job_id})
324
+ status = "FAILED"
325
+ errors.append(str(e))
326
+ summary = {"overall_status": "FAILED", "overall_match_percentage": 0.0}
327
+ schema_report, col_reports, num_reports, date_reports, dup_report, missing_report = {}, [], [], [], {}, []
328
+
329
+ return {
330
+ "job_id": job_id,
331
+ "source_file": src_url,
332
+ "destination_file": dst_url,
333
+ "started_at": started_at,
334
+ "completed_at": datetime.utcnow().isoformat(),
335
+ "processing_time_ms": round((time.time() - start_time) * 1000, 2),
336
+ "status": status,
337
+ "summary": summary,
338
+ "schema": schema_report,
339
+ "columns": col_reports,
340
+ "numeric_columns": num_reports,
341
+ "date_columns": date_reports,
342
+ "duplicates": dup_report,
343
+ "missing_values": missing_report,
344
+ "errors": errors,
345
+ "warnings": warnings
346
+ }
347
+
348
+
349
+ async def reconcile_pair(pair: Dict[str, str], job_id: str, numeric_keywords: Optional[Set[str]] = None) -> Dict[str, Any]:
350
+ src_url = pair["source"]
351
+ dst_url = pair["destination"]
352
+ started_at = datetime.utcnow().isoformat()
353
+
354
+ src_ext = extract_extension(src_url)
355
+ dst_ext = extract_extension(dst_url)
356
+
357
+ if src_ext not in SUPPORTED_EXTENSIONS or dst_ext not in SUPPORTED_EXTENSIONS:
358
+ return _build_failed_pair(job_id, src_url, dst_url, started_at, "FAILED", [f"Unsupported format. Source: {src_ext}, Dest: {dst_ext}"])
359
+
360
+ if src_ext != dst_ext:
361
+ return _build_failed_pair(job_id, src_url, dst_url, started_at, "FILE_TYPE_MISMATCH", [f"File type mismatch. Source: {src_ext}, Dest: {dst_ext}"])
362
+
363
+ async with aiohttp.ClientSession() as session:
364
+ try:
365
+ src_data, dst_data = await asyncio.gather(
366
+ download_file_with_retry(session, src_url, job_id),
367
+ download_file_with_retry(session, dst_url, job_id)
368
+ )
369
+ except DownloadError as e:
370
+ _logger.error(f"Download failed for job {job_id}: {str(e)}", extra={"correlation_id": job_id})
371
+ return _build_failed_pair(job_id, src_url, dst_url, started_at, "FAILED", [str(e)])
372
+
373
+ if len(src_data) > _MAX_FILE_SIZE or len(dst_data) > _MAX_FILE_SIZE:
374
+ return _build_failed_pair(job_id, src_url, dst_url, started_at, "FAILED", ["File size exceeds maximum allowed limit"])
375
+
376
+ return await process_pair(src_url, dst_url, src_data, dst_data, src_ext, job_id, numeric_keywords)
requirements.txt CHANGED
@@ -13,6 +13,8 @@ pypdfium2>=4.30.0
13
  pandas>=2.0.0
14
  sentence-transformers==5.6.0
15
 
 
 
16
  # FIXED: Downgraded to 4.49.0. Transformers 5.x breaks Nomic Vision's trust_remote_code architecture.
17
  transformers==4.49.0
18
 
 
13
  pandas>=2.0.0
14
  sentence-transformers==5.6.0
15
 
16
+ aiohttp>=3.9.0
17
+
18
  # FIXED: Downgraded to 4.49.0. Transformers 5.x breaks Nomic Vision's trust_remote_code architecture.
19
  transformers==4.49.0
20