panini-parser 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {panini_parser-0.2.0 → panini_parser-0.3.0}/PKG-INFO +1 -1
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/__init__.py +3 -2
- panini_parser-0.3.0/panini/insights.py +424 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/PKG-INFO +1 -1
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/SOURCES.txt +3 -1
- {panini_parser-0.2.0 → panini_parser-0.3.0}/pyproject.toml +1 -1
- panini_parser-0.3.0/tests/test_insights.py +60 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/MANIFEST.in +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/README.md +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/dates.py +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/engine.py +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/extract.py +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/formats/csv-layouts.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/formats/sms-patterns.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/archetypes.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/axis.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/bob.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/canara.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/categories.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/ifsc-codes.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/mcc-codes.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/merchants.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/vpa-handles.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/federal.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/generic.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/hdfc.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/icici.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/idfc.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/indusind.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/kotak.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/pnb.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/rbl.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/sbi.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/union.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/wallet.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/yes.json +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/tokenizer.py +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/dependency_links.txt +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/top_level.txt +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/setup.cfg +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/tests/test_bundled_rules.py +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/tests/test_conformance.py +0 -0
- {panini_parser-0.2.0 → panini_parser-0.3.0}/tests/test_extract.py +0 -0
|
@@ -11,10 +11,11 @@ from .extract import ( # noqa: F401
|
|
|
11
11
|
extract_sms,
|
|
12
12
|
parse_statement_date,
|
|
13
13
|
)
|
|
14
|
+
from .insights import analyze_transactions # noqa: F401
|
|
14
15
|
|
|
15
|
-
__version__ = "0.
|
|
16
|
+
__version__ = "0.3.0"
|
|
16
17
|
__all__ = [
|
|
17
18
|
"Normalizer", "parse_narration", "parse_indian_date",
|
|
18
19
|
"is_plausible_date", "tokenize", "extract_csv_statement",
|
|
19
|
-
"extract_sms", "parse_statement_date",
|
|
20
|
+
"extract_sms", "parse_statement_date", "analyze_transactions",
|
|
20
21
|
]
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# Copyright 2026 Kapil — Project Panini Contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Deterministic, explainable credit-relevant insights — Python port of
|
|
4
|
+
@paniniapi/insights. Output dicts mirror the TypeScript report shape
|
|
5
|
+
byte-for-byte (verified by the insights conformance fixture)."""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from datetime import date as _date
|
|
10
|
+
from typing import Any, Iterable, Optional
|
|
11
|
+
|
|
12
|
+
RULE_IDS = {
|
|
13
|
+
"SALARY_FLAG": "PANINI-INC-001",
|
|
14
|
+
"SALARY_KEYWORD": "PANINI-INC-002",
|
|
15
|
+
"REGULAR_CREDIT": "PANINI-INC-003",
|
|
16
|
+
"RECURRING_DEBIT": "PANINI-OBL-001",
|
|
17
|
+
"EMI": "PANINI-EMI-001",
|
|
18
|
+
"FOIR": "PANINI-FOIR-001",
|
|
19
|
+
"CASHFLOW": "PANINI-CF-001",
|
|
20
|
+
"BOUNCE": "PANINI-RSK-BOUNCE-001",
|
|
21
|
+
"ROUND_TRIP": "PANINI-RSK-RT-001",
|
|
22
|
+
"SELF_TRANSFER": "PANINI-RSK-SELF-001",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
SALARY_KEYWORD_RE = re.compile(r"\b(salary|payroll|sal\s*cr|wages?|stipend)\b", re.I)
|
|
26
|
+
EMI_KEYWORD_RE = re.compile(
|
|
27
|
+
r"\b(emi|loan\s*(?:repay|emi|pmt)|nach[\s-]|ecs[\s-]|bajaj\s*fin|finserv|home\s*credit"
|
|
28
|
+
r"|tata\s*capital|capital\s*first|cholamandalam|aditya\s*birla\s*fin|fullerton|muthoot"
|
|
29
|
+
r"|shriram\s*(?:fin|transport)|mahindra\s*fin|tvs\s*credit|credila|l&t\s*fin|piramal"
|
|
30
|
+
r"|idfc\s*loan|poonawalla|hdb\s*fin|kissht|zestmoney|axio|simpl|lazypay)\b",
|
|
31
|
+
re.I,
|
|
32
|
+
)
|
|
33
|
+
BOUNCE_RE = re.compile(
|
|
34
|
+
r"\b(bounce[ds]?|dishonou?r(?:ed)?|insufficient\s*funds?|unpaid|return\s*(?:chg|charge)"
|
|
35
|
+
r"|nach\s*(?:rtn|return)|ecs\s*(?:rtn|return)|chq\s*ret|cheque\s*ret"
|
|
36
|
+
r"|auto\s*debit\s*(?:fail|rej)|mandate\s*(?:fail|rej))\b",
|
|
37
|
+
re.I,
|
|
38
|
+
)
|
|
39
|
+
SELF_RE = re.compile(
|
|
40
|
+
r"\b(self|own\s*a\/?c|own\s*(?:acct|account|transfer|trf)|trf\s*to\s*self|self\s*trf"
|
|
41
|
+
r"|by\s*self|to\s*self)\b",
|
|
42
|
+
re.I,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_EPOCH = _date(1970, 1, 1).toordinal()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _js_round(x: float) -> int:
|
|
49
|
+
"""Match Math.round: halves round toward +infinity."""
|
|
50
|
+
return int(math.floor(x + 0.5))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def month_key(date: Optional[str]) -> Optional[str]:
|
|
54
|
+
if date is not None and re.match(r"^\d{4}-\d{2}", date):
|
|
55
|
+
return date[:7]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def epoch_day(date: Optional[str]) -> Optional[int]:
|
|
60
|
+
if date is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return _date.fromisoformat(date[:10]).toordinal() - _EPOCH
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def counterparty_key(txn: dict) -> Optional[str]:
|
|
69
|
+
raw = txn.get("merchant") or txn.get("counterparty")
|
|
70
|
+
if raw is not None and str(raw).strip() != "":
|
|
71
|
+
k = re.sub(r"[^A-Z0-9 ]", " ", str(raw).upper())
|
|
72
|
+
k = re.sub(r"\s+", " ", k).strip()
|
|
73
|
+
return k if k else None
|
|
74
|
+
narration = txn.get("narration") or ""
|
|
75
|
+
m = re.search(r"(?:to|cr|dr|from)\s+([A-Za-z][A-Za-z0-9 &]{2,30})", narration)
|
|
76
|
+
if m is not None:
|
|
77
|
+
return re.sub(r"\s+", " ", m.group(1).upper()).strip()
|
|
78
|
+
tail = re.split(r"[-/]", narration)[-1].strip()
|
|
79
|
+
cleaned = re.sub(r"[^A-Za-z0-9 &]", " ", tail)
|
|
80
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
81
|
+
return cleaned.upper() if re.match(r"^[A-Za-z][A-Za-z0-9 &]{2,30}$", cleaned) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def median(values: Iterable[float]) -> float:
|
|
85
|
+
s = sorted(values)
|
|
86
|
+
mid = len(s) // 2
|
|
87
|
+
return s[mid] if len(s) % 2 == 1 else (s[mid - 1] + s[mid]) / 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _within_tol(a: float, b: float, tol: float) -> bool:
|
|
91
|
+
return abs(a - b) <= max(a, b) * tol
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _detect_income(usable: list, warnings: list) -> tuple:
|
|
95
|
+
credits = [w for w in usable if w["txn"].get("direction") == "CREDIT" and w["txn"].get("amountPaise") is not None]
|
|
96
|
+
events: list = []
|
|
97
|
+
evidence: list = []
|
|
98
|
+
|
|
99
|
+
for w in credits:
|
|
100
|
+
t = w["txn"]
|
|
101
|
+
narration = t.get("narration") or ""
|
|
102
|
+
if t.get("is_salary") is True:
|
|
103
|
+
events.append({"index": w["index"], "date": t.get("date"),
|
|
104
|
+
"amountPaise": t["amountPaise"], "employer": counterparty_key(t),
|
|
105
|
+
"basis": "is_salary_flag"})
|
|
106
|
+
elif SALARY_KEYWORD_RE.search(narration):
|
|
107
|
+
events.append({"index": w["index"], "date": t.get("date"),
|
|
108
|
+
"amountPaise": t["amountPaise"], "employer": counterparty_key(t),
|
|
109
|
+
"basis": "salary_keyword"})
|
|
110
|
+
|
|
111
|
+
by_source: dict = {}
|
|
112
|
+
for w in credits:
|
|
113
|
+
key = counterparty_key(w["txn"])
|
|
114
|
+
if key is None:
|
|
115
|
+
continue
|
|
116
|
+
by_source.setdefault(key, []).append(w)
|
|
117
|
+
claimed = {e["index"] for e in events}
|
|
118
|
+
for source, lst in by_source.items():
|
|
119
|
+
months = {month_key(w["txn"].get("date")) for w in lst}
|
|
120
|
+
months.discard(None)
|
|
121
|
+
if len(months) < 2:
|
|
122
|
+
continue
|
|
123
|
+
amounts = [w["txn"]["amountPaise"] for w in lst]
|
|
124
|
+
med = median(amounts)
|
|
125
|
+
if not all(_within_tol(a, med, 0.2) for a in amounts):
|
|
126
|
+
continue
|
|
127
|
+
for w in lst:
|
|
128
|
+
if w["index"] in claimed:
|
|
129
|
+
continue
|
|
130
|
+
claimed.add(w["index"])
|
|
131
|
+
events.append({"index": w["index"], "date": w["txn"].get("date"),
|
|
132
|
+
"amountPaise": w["txn"]["amountPaise"], "employer": source,
|
|
133
|
+
"basis": "regular_credit"})
|
|
134
|
+
|
|
135
|
+
if events:
|
|
136
|
+
evidence.append({"ruleId": RULE_IDS["SALARY_FLAG"],
|
|
137
|
+
"note": f"{len(events)} income credit(s) detected",
|
|
138
|
+
"txnIndices": [e["index"] for e in events]})
|
|
139
|
+
|
|
140
|
+
months = {month_key(e.get("date")) for e in events}
|
|
141
|
+
months.discard(None)
|
|
142
|
+
sources = list(dict.fromkeys(e["employer"] for e in events if e["employer"] is not None))
|
|
143
|
+
by_month: dict = {}
|
|
144
|
+
for e in events:
|
|
145
|
+
mk = month_key(e.get("date"))
|
|
146
|
+
if mk is not None:
|
|
147
|
+
by_month[mk] = by_month.get(mk, 0) + e["amountPaise"]
|
|
148
|
+
monthly_avg = _js_round(sum(by_month.values()) / len(by_month)) if by_month else None
|
|
149
|
+
|
|
150
|
+
consistency = "insufficient_data"
|
|
151
|
+
if len(events) >= 2 and len(months) >= 2:
|
|
152
|
+
primary = by_source.get(sources[0]) if sources else None
|
|
153
|
+
amounts = ([w["txn"]["amountPaise"] for w in primary] if primary and len(primary) >= 2
|
|
154
|
+
else [e["amountPaise"] for e in events])
|
|
155
|
+
med = median(amounts)
|
|
156
|
+
consistency = "regular" if len(amounts) >= 2 and all(
|
|
157
|
+
_within_tol(a, med, 0.2) for a in amounts) else "irregular"
|
|
158
|
+
if not events and credits:
|
|
159
|
+
warnings.append("credits present but none identified as income — check enrichment/labels")
|
|
160
|
+
|
|
161
|
+
summary = {
|
|
162
|
+
"credits": sorted(events, key=lambda e: e.get("date") or ""),
|
|
163
|
+
"sources": sources,
|
|
164
|
+
"monthlyAveragePaise": monthly_avg,
|
|
165
|
+
"consistency": consistency,
|
|
166
|
+
"monthsObserved": len(months),
|
|
167
|
+
}
|
|
168
|
+
return summary, evidence
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _detect_obligations(usable: list, min_occurrences: int) -> tuple:
|
|
172
|
+
debits = [w for w in usable if w["txn"].get("direction") == "DEBIT" and w["txn"].get("amountPaise") is not None]
|
|
173
|
+
by_key: dict = {}
|
|
174
|
+
for w in debits:
|
|
175
|
+
key = counterparty_key(w["txn"])
|
|
176
|
+
if key is None:
|
|
177
|
+
continue
|
|
178
|
+
by_key.setdefault(key, []).append(w)
|
|
179
|
+
|
|
180
|
+
recurring: list = []
|
|
181
|
+
evidence: list = []
|
|
182
|
+
for key, lst in by_key.items():
|
|
183
|
+
months = {month_key(w["txn"].get("date")) for w in lst}
|
|
184
|
+
months.discard(None)
|
|
185
|
+
if len(months) < min_occurrences:
|
|
186
|
+
continue
|
|
187
|
+
amounts = [w["txn"]["amountPaise"] for w in lst]
|
|
188
|
+
typical = median(amounts)
|
|
189
|
+
if not all(_within_tol(a, typical, 0.1) for a in amounts):
|
|
190
|
+
continue
|
|
191
|
+
is_emi = any(
|
|
192
|
+
EMI_KEYWORD_RE.search(w["txn"].get("narration") or "")
|
|
193
|
+
or EMI_KEYWORD_RE.search(key)
|
|
194
|
+
or w["txn"].get("category") == "Loan & EMI"
|
|
195
|
+
for w in lst
|
|
196
|
+
)
|
|
197
|
+
recurring.append({
|
|
198
|
+
"counterparty": key,
|
|
199
|
+
"occurrences": [w["index"] for w in lst],
|
|
200
|
+
"typicalAmountPaise": _js_round(typical),
|
|
201
|
+
"monthsHit": sorted(m for m in months if m is not None),
|
|
202
|
+
"isEmi": is_emi,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
if recurring:
|
|
206
|
+
evidence.append({"ruleId": RULE_IDS["RECURRING_DEBIT"],
|
|
207
|
+
"note": f"{len(recurring)} recurring obligation(s) detected",
|
|
208
|
+
"txnIndices": [i for r in recurring for i in r["occurrences"]]})
|
|
209
|
+
emis = [r for r in recurring if r["isEmi"]]
|
|
210
|
+
if emis:
|
|
211
|
+
evidence.append({"ruleId": RULE_IDS["EMI"],
|
|
212
|
+
"note": f"{len(emis)} EMI pattern(s) detected",
|
|
213
|
+
"txnIndices": [i for r in emis for i in r["occurrences"]]})
|
|
214
|
+
|
|
215
|
+
summary = {
|
|
216
|
+
"recurring": recurring,
|
|
217
|
+
"emis": emis,
|
|
218
|
+
"monthlyObligationPaise": (sum(r["typicalAmountPaise"] for r in recurring)
|
|
219
|
+
if recurring else None),
|
|
220
|
+
}
|
|
221
|
+
return summary, evidence
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _compute_foir(income: dict, obligations: dict) -> dict:
|
|
225
|
+
monthly_income = income["monthlyAveragePaise"]
|
|
226
|
+
if monthly_income is None or monthly_income <= 0:
|
|
227
|
+
return {"ratio": None, "monthlyIncomePaise": monthly_income,
|
|
228
|
+
"monthlyObligationPaise": obligations["monthlyObligationPaise"],
|
|
229
|
+
"note": "income undetected — FOIR not computable"}
|
|
230
|
+
obligation = obligations["monthlyObligationPaise"] or 0
|
|
231
|
+
return {
|
|
232
|
+
"ratio": _js_round((obligation / monthly_income) * 1000) / 1000,
|
|
233
|
+
"monthlyIncomePaise": monthly_income,
|
|
234
|
+
"monthlyObligationPaise": obligations["monthlyObligationPaise"],
|
|
235
|
+
"note": ("no recurring obligations detected — FOIR floor is 0"
|
|
236
|
+
if obligations["monthlyObligationPaise"] is None
|
|
237
|
+
else f"obligations {obligation} / income {monthly_income}"),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _summarize_cashflow(usable: list) -> dict:
|
|
242
|
+
credits = debits = 0
|
|
243
|
+
min_bal = max_bal = closing = None
|
|
244
|
+
months = set()
|
|
245
|
+
for w in usable:
|
|
246
|
+
t = w["txn"]
|
|
247
|
+
if t.get("direction") == "CREDIT" and t.get("amountPaise") is not None:
|
|
248
|
+
credits += t["amountPaise"]
|
|
249
|
+
if t.get("direction") == "DEBIT" and t.get("amountPaise") is not None:
|
|
250
|
+
debits += t["amountPaise"]
|
|
251
|
+
mk = month_key(t.get("date"))
|
|
252
|
+
if mk is not None:
|
|
253
|
+
months.add(mk)
|
|
254
|
+
bal = t.get("balancePaise")
|
|
255
|
+
if bal is not None:
|
|
256
|
+
min_bal = bal if min_bal is None else min(min_bal, bal)
|
|
257
|
+
max_bal = bal if max_bal is None else max(max_bal, bal)
|
|
258
|
+
closing = bal
|
|
259
|
+
return {
|
|
260
|
+
"totalCreditsPaise": credits,
|
|
261
|
+
"totalDebitsPaise": debits,
|
|
262
|
+
"netPaise": credits - debits,
|
|
263
|
+
"monthsObserved": len(months),
|
|
264
|
+
"averageMonthlyNetPaise": _js_round((credits - debits) / len(months)) if months else None,
|
|
265
|
+
"minBalancePaise": min_bal,
|
|
266
|
+
"maxBalancePaise": max_bal,
|
|
267
|
+
"closingBalancePaise": closing,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _compute_trends(usable: list) -> dict:
|
|
272
|
+
by_month: dict = {}
|
|
273
|
+
for w in usable:
|
|
274
|
+
t = w["txn"]
|
|
275
|
+
mk = month_key(t.get("date"))
|
|
276
|
+
if mk is None or t.get("amountPaise") is None:
|
|
277
|
+
continue
|
|
278
|
+
cur = by_month.get(mk, {"month": mk, "creditsPaise": 0, "debitsPaise": 0, "netPaise": 0})
|
|
279
|
+
credits = cur["creditsPaise"] + (t["amountPaise"] if t.get("direction") == "CREDIT" else 0)
|
|
280
|
+
debits = cur["debitsPaise"] + (t["amountPaise"] if t.get("direction") == "DEBIT" else 0)
|
|
281
|
+
by_month[mk] = {"month": mk, "creditsPaise": credits, "debitsPaise": debits,
|
|
282
|
+
"netPaise": credits - debits}
|
|
283
|
+
monthly = sorted(by_month.values(), key=lambda m: m["month"])
|
|
284
|
+
|
|
285
|
+
direction = "insufficient_data"
|
|
286
|
+
if len(monthly) >= 3:
|
|
287
|
+
half = len(monthly) // 2
|
|
288
|
+
first, second = monthly[:half], monthly[-half:]
|
|
289
|
+
f = sum(r["netPaise"] for r in first) / len(first)
|
|
290
|
+
s = sum(r["netPaise"] for r in second) / len(second)
|
|
291
|
+
band = max(abs(f), 1) * 0.1
|
|
292
|
+
direction = "improving" if s - f > band else ("declining" if f - s > band else "stable")
|
|
293
|
+
|
|
294
|
+
dated = [w for w in usable
|
|
295
|
+
if w["txn"].get("date") is not None and w["txn"].get("balancePaise") is not None]
|
|
296
|
+
velocities = []
|
|
297
|
+
for i in range(1, len(dated)):
|
|
298
|
+
d2, d1 = epoch_day(dated[i]["txn"]["date"]), epoch_day(dated[i - 1]["txn"]["date"])
|
|
299
|
+
if d2 == d1:
|
|
300
|
+
continue
|
|
301
|
+
velocities.append((dated[i]["txn"]["balancePaise"] - dated[i - 1]["txn"]["balancePaise"]) / (d2 - d1))
|
|
302
|
+
|
|
303
|
+
def volatility(key: str) -> Optional[float]:
|
|
304
|
+
if len(monthly) < 2:
|
|
305
|
+
return None
|
|
306
|
+
values = [m[key] for m in monthly]
|
|
307
|
+
mean = sum(values) / len(values)
|
|
308
|
+
if mean == 0:
|
|
309
|
+
return None
|
|
310
|
+
var = sum((v - mean) ** 2 for v in values) / len(values)
|
|
311
|
+
return _js_round((math.sqrt(var) / mean) * 10000) / 100
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
"monthly": monthly,
|
|
315
|
+
"netFlowDirection": direction,
|
|
316
|
+
"balanceVelocityPaisePerDay": _js_round(median(velocities)) if velocities else None,
|
|
317
|
+
"creditVolatilityPct": volatility("creditsPaise"),
|
|
318
|
+
"debitVolatilityPct": volatility("debitsPaise"),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _detect_signals(usable: list, window_days: int, tolerance: float) -> list:
|
|
323
|
+
signals: list = []
|
|
324
|
+
|
|
325
|
+
bounce_idx = [w["index"] for w in usable if BOUNCE_RE.search(w["txn"].get("narration") or "")]
|
|
326
|
+
if bounce_idx:
|
|
327
|
+
signals.append({
|
|
328
|
+
"ruleId": RULE_IDS["BOUNCE"],
|
|
329
|
+
"severity": "high" if len(bounce_idx) >= 3 else "medium",
|
|
330
|
+
"note": f"{len(bounce_idx)} bounced/returned payment narration(s)",
|
|
331
|
+
"evidence": [{"ruleId": RULE_IDS["BOUNCE"],
|
|
332
|
+
"note": "narration carries bounce/return/dishonour keywords",
|
|
333
|
+
"txnIndices": bounce_idx}],
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
credits = [w for w in usable if w["txn"].get("direction") == "CREDIT"
|
|
337
|
+
and w["txn"].get("amountPaise") is not None and w["txn"].get("date") is not None]
|
|
338
|
+
debits = [w for w in usable if w["txn"].get("direction") == "DEBIT"
|
|
339
|
+
and w["txn"].get("amountPaise") is not None and w["txn"].get("date") is not None]
|
|
340
|
+
rt_evidence: list = []
|
|
341
|
+
used = set()
|
|
342
|
+
for c in credits:
|
|
343
|
+
key, day = counterparty_key(c["txn"]), epoch_day(c["txn"]["date"])
|
|
344
|
+
if key is None or day is None:
|
|
345
|
+
continue
|
|
346
|
+
for d in debits:
|
|
347
|
+
if d["index"] in used or d["index"] == c["index"]:
|
|
348
|
+
continue
|
|
349
|
+
if counterparty_key(d["txn"]) != key:
|
|
350
|
+
continue
|
|
351
|
+
d_day = epoch_day(d["txn"]["date"])
|
|
352
|
+
if d_day is None or d_day < day or d_day - day > window_days:
|
|
353
|
+
continue
|
|
354
|
+
if not _within_tol(c["txn"]["amountPaise"], d["txn"]["amountPaise"], tolerance):
|
|
355
|
+
continue
|
|
356
|
+
used.add(d["index"])
|
|
357
|
+
rt_evidence.append({
|
|
358
|
+
"ruleId": RULE_IDS["ROUND_TRIP"],
|
|
359
|
+
"note": (f"credit ₹{c['txn']['amountPaise'] / 100:.2f} mirrored by debit of "
|
|
360
|
+
f"₹{d['txn']['amountPaise'] / 100:.2f} to {key} within {d_day - day} day(s)"),
|
|
361
|
+
"txnIndices": [c["index"], d["index"]],
|
|
362
|
+
})
|
|
363
|
+
break
|
|
364
|
+
if rt_evidence:
|
|
365
|
+
signals.append({
|
|
366
|
+
"ruleId": RULE_IDS["ROUND_TRIP"],
|
|
367
|
+
"severity": "high" if len(rt_evidence) >= 3 else "medium",
|
|
368
|
+
"note": f"{len(rt_evidence)} round-trip pattern(s) — credit mirrored by matching debit",
|
|
369
|
+
"evidence": rt_evidence,
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
self_idx = [w["index"] for w in usable if SELF_RE.search(w["txn"].get("narration") or "")]
|
|
373
|
+
if self_idx:
|
|
374
|
+
signals.append({
|
|
375
|
+
"ruleId": RULE_IDS["SELF_TRANSFER"],
|
|
376
|
+
"severity": "low",
|
|
377
|
+
"note": f"{len(self_idx)} self/own-account transfer narration(s)",
|
|
378
|
+
"evidence": [{"ruleId": RULE_IDS["SELF_TRANSFER"],
|
|
379
|
+
"note": "narration carries self/own-account keywords",
|
|
380
|
+
"txnIndices": self_idx}],
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
return signals
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def analyze_transactions(transactions: list, options: Optional[dict] = None) -> dict:
|
|
387
|
+
"""Python port of `analyzeTransactions` — never raises; malformed rows are
|
|
388
|
+
dropped with a warning and every claim carries evidence indices."""
|
|
389
|
+
options = options or {}
|
|
390
|
+
warnings: list = []
|
|
391
|
+
usable: list = []
|
|
392
|
+
dropped = 0
|
|
393
|
+
for index, txn in enumerate(transactions):
|
|
394
|
+
if txn is None:
|
|
395
|
+
dropped += 1
|
|
396
|
+
continue
|
|
397
|
+
amt = txn.get("amountPaise")
|
|
398
|
+
if txn.get("direction") is None or amt is None or not isinstance(amt, (int, float)) or not math.isfinite(amt):
|
|
399
|
+
dropped += 1
|
|
400
|
+
continue
|
|
401
|
+
usable.append({"txn": txn, "index": index})
|
|
402
|
+
if dropped:
|
|
403
|
+
warnings.append(f"{dropped} transaction(s) dropped: missing direction or amount")
|
|
404
|
+
if not usable:
|
|
405
|
+
warnings.append("no usable transactions — report reflects an empty input")
|
|
406
|
+
|
|
407
|
+
income_summary, _ = _detect_income(usable, warnings)
|
|
408
|
+
obl_summary, _ = _detect_obligations(usable, options.get("minRecurringOccurrences", 3))
|
|
409
|
+
foir = _compute_foir(income_summary, obl_summary)
|
|
410
|
+
cashflow = _summarize_cashflow(usable)
|
|
411
|
+
trends = _compute_trends(usable)
|
|
412
|
+
signals = _detect_signals(usable, options.get("roundTripWindowDays", 5),
|
|
413
|
+
options.get("amountTolerance", 0.02))
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"income": income_summary,
|
|
417
|
+
"obligations": obl_summary,
|
|
418
|
+
"foir": foir,
|
|
419
|
+
"cashflow": cashflow,
|
|
420
|
+
"trends": trends,
|
|
421
|
+
"signals": signals,
|
|
422
|
+
"warnings": warnings,
|
|
423
|
+
"analyzedTransactions": len(usable),
|
|
424
|
+
}
|
|
@@ -5,6 +5,7 @@ panini/__init__.py
|
|
|
5
5
|
panini/dates.py
|
|
6
6
|
panini/engine.py
|
|
7
7
|
panini/extract.py
|
|
8
|
+
panini/insights.py
|
|
8
9
|
panini/tokenizer.py
|
|
9
10
|
panini/formats/csv-layouts.json
|
|
10
11
|
panini/formats/sms-patterns.json
|
|
@@ -36,4 +37,5 @@ panini_parser.egg-info/dependency_links.txt
|
|
|
36
37
|
panini_parser.egg-info/top_level.txt
|
|
37
38
|
tests/test_bundled_rules.py
|
|
38
39
|
tests/test_conformance.py
|
|
39
|
-
tests/test_extract.py
|
|
40
|
+
tests/test_extract.py
|
|
41
|
+
tests/test_insights.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "panini-parser"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Deterministic, privacy-preserving parser for Indian bank narrations — Python port of @panini/rules-engine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright 2026 Kapil — Project Panini Contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Insights conformance gate: panini.insights.analyze_transactions must
|
|
4
|
+
produce the exact report emitted by @paniniapi/insights analyzeTransactions.
|
|
5
|
+
Fixture regenerated by packages/insights — see fixtures/insights_conformance.json.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
16
|
+
|
|
17
|
+
from panini.insights import analyze_transactions # noqa: E402
|
|
18
|
+
|
|
19
|
+
FIXTURE = (
|
|
20
|
+
Path(__file__).resolve().parent / "fixtures" / "insights_conformance.json"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _norm(value):
|
|
25
|
+
"""JSON round-trip normalisation so int/float equality matches the
|
|
26
|
+
serialized contract (e.g. 500.0 in TS JSON vs 500 in Python)."""
|
|
27
|
+
return json.loads(json.dumps(value))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InsightsConformance(unittest.TestCase):
|
|
31
|
+
pass # cases generated below
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make(case):
|
|
35
|
+
def check(self):
|
|
36
|
+
result = _norm(analyze_transactions(case["input"]))
|
|
37
|
+
self.assertEqual(result, case["expected"],
|
|
38
|
+
f"insights drift on case '{case['name']}'")
|
|
39
|
+
return check
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_cases = json.loads(FIXTURE.read_text())["cases"]
|
|
43
|
+
for _c in _cases:
|
|
44
|
+
setattr(InsightsConformance, f"test_{_c['name']}", _make(_c))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InsightsUnit(unittest.TestCase):
|
|
48
|
+
def test_empty(self):
|
|
49
|
+
r = analyze_transactions([])
|
|
50
|
+
self.assertEqual(r["analyzedTransactions"], 0)
|
|
51
|
+
self.assertEqual(r["income"]["consistency"], "insufficient_data")
|
|
52
|
+
self.assertEqual(r["foir"]["ratio"], None)
|
|
53
|
+
|
|
54
|
+
def test_deterministic(self):
|
|
55
|
+
txns = _cases[0]["input"]
|
|
56
|
+
self.assertEqual(analyze_transactions(txns), analyze_transactions(txns))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|