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.
Files changed (43) hide show
  1. {panini_parser-0.2.0 → panini_parser-0.3.0}/PKG-INFO +1 -1
  2. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/__init__.py +3 -2
  3. panini_parser-0.3.0/panini/insights.py +424 -0
  4. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/PKG-INFO +1 -1
  5. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/SOURCES.txt +3 -1
  6. {panini_parser-0.2.0 → panini_parser-0.3.0}/pyproject.toml +1 -1
  7. panini_parser-0.3.0/tests/test_insights.py +60 -0
  8. {panini_parser-0.2.0 → panini_parser-0.3.0}/MANIFEST.in +0 -0
  9. {panini_parser-0.2.0 → panini_parser-0.3.0}/README.md +0 -0
  10. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/dates.py +0 -0
  11. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/engine.py +0 -0
  12. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/extract.py +0 -0
  13. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/formats/csv-layouts.json +0 -0
  14. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/formats/sms-patterns.json +0 -0
  15. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/archetypes.json +0 -0
  16. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/axis.json +0 -0
  17. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/bob.json +0 -0
  18. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/canara.json +0 -0
  19. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/categories.json +0 -0
  20. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/ifsc-codes.json +0 -0
  21. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/mcc-codes.json +0 -0
  22. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/merchants.json +0 -0
  23. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/entities/vpa-handles.json +0 -0
  24. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/federal.json +0 -0
  25. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/generic.json +0 -0
  26. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/hdfc.json +0 -0
  27. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/icici.json +0 -0
  28. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/idfc.json +0 -0
  29. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/indusind.json +0 -0
  30. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/kotak.json +0 -0
  31. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/pnb.json +0 -0
  32. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/rbl.json +0 -0
  33. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/sbi.json +0 -0
  34. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/union.json +0 -0
  35. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/wallet.json +0 -0
  36. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/rules/yes.json +0 -0
  37. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini/tokenizer.py +0 -0
  38. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/dependency_links.txt +0 -0
  39. {panini_parser-0.2.0 → panini_parser-0.3.0}/panini_parser.egg-info/top_level.txt +0 -0
  40. {panini_parser-0.2.0 → panini_parser-0.3.0}/setup.cfg +0 -0
  41. {panini_parser-0.2.0 → panini_parser-0.3.0}/tests/test_bundled_rules.py +0 -0
  42. {panini_parser-0.2.0 → panini_parser-0.3.0}/tests/test_conformance.py +0 -0
  43. {panini_parser-0.2.0 → panini_parser-0.3.0}/tests/test_extract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panini-parser
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Deterministic, privacy-preserving parser for Indian bank narrations — Python port of @panini/rules-engine
5
5
  Author: People of Product
6
6
  License: Apache-2.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.2.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
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panini-parser
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Deterministic, privacy-preserving parser for Indian bank narrations — Python port of @panini/rules-engine
5
5
  Author: People of Product
6
6
  License: Apache-2.0
@@ -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.2.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