panini-parser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- panini/__init__.py +12 -0
- panini/dates.py +50 -0
- panini/engine.py +497 -0
- panini/rules/archetypes.json +73 -0
- panini/rules/axis.json +62 -0
- panini/rules/bob.json +55 -0
- panini/rules/canara.json +39 -0
- panini/rules/categories.json +307 -0
- panini/rules/entities/ifsc-codes.json +53 -0
- panini/rules/entities/merchants.json +405 -0
- panini/rules/entities/vpa-handles.json +96 -0
- panini/rules/federal.json +41 -0
- panini/rules/generic.json +253 -0
- panini/rules/hdfc.json +53 -0
- panini/rules/icici.json +57 -0
- panini/rules/idfc.json +44 -0
- panini/rules/indusind.json +41 -0
- panini/rules/kotak.json +51 -0
- panini/rules/pnb.json +58 -0
- panini/rules/rbl.json +40 -0
- panini/rules/sbi.json +55 -0
- panini/rules/union.json +41 -0
- panini/rules/wallet.json +37 -0
- panini/rules/yes.json +40 -0
- panini/tokenizer.py +85 -0
- panini_parser-0.1.0.dist-info/METADATA +66 -0
- panini_parser-0.1.0.dist-info/RECORD +29 -0
- panini_parser-0.1.0.dist-info/WHEEL +5 -0
- panini_parser-0.1.0.dist-info/top_level.txt +1 -0
panini/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copyright 2026 Kapil — Project Panini Contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Panini — deterministic Indian bank-narration parsing. Python port of
|
|
4
|
+
@panini/rules-engine, sharing grammar JSONs and the conformance corpus."""
|
|
5
|
+
|
|
6
|
+
from .engine import Normalizer, parse_narration # noqa: F401
|
|
7
|
+
from .dates import parse_indian_date, is_plausible_date # noqa: F401
|
|
8
|
+
from .tokenizer import tokenize # noqa: F401
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["Normalizer", "parse_narration", "parse_indian_date",
|
|
12
|
+
"is_plausible_date", "tokenize"]
|
panini/dates.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2026 Kapil — Project Panini Contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Indian date-shape parsing — mirrors @panini/core dates.ts."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from datetime import date
|
|
9
|
+
|
|
10
|
+
MIN_YEAR = 1990
|
|
11
|
+
MAX_YEAR = 2100
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _iso(year: int, month: int, day: int) -> str | None:
|
|
15
|
+
if not (MIN_YEAR <= year <= MAX_YEAR and 1 <= month <= 12 and 1 <= day <= 31):
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return date(year, month, day).isoformat()
|
|
19
|
+
except ValueError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _yy(yy: int) -> int:
|
|
24
|
+
return 1900 + yy if yy >= 90 else 2000 + yy
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ATTEMPTS = [
|
|
28
|
+
(re.compile(r"^(\d{4})(\d{2})(\d{2})$"), lambda m: (int(m[1]), int(m[2]), int(m[3]))),
|
|
29
|
+
(re.compile(r"^(\d{2})(\d{2})(\d{4})$"), lambda m: (int(m[3]), int(m[2]), int(m[1]))),
|
|
30
|
+
(re.compile(r"^(\d{2})(\d{2})(\d{2})$"), lambda m: (_yy(int(m[3])), int(m[2]), int(m[1]))),
|
|
31
|
+
(re.compile(r"^(\d{4})[-/.](\d{2})[-/.](\d{2})$"), lambda m: (int(m[1]), int(m[2]), int(m[3]))),
|
|
32
|
+
(re.compile(r"^(\d{2})[-/.](\d{2})[-/.](\d{4})$"), lambda m: (int(m[3]), int(m[2]), int(m[1]))),
|
|
33
|
+
(re.compile(r"^(\d{2})[-/.](\d{2})[-/.](\d{2})$"), lambda m: (_yy(int(m[3])), int(m[2]), int(m[1]))),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_indian_date(value: str) -> str | None:
|
|
38
|
+
"""YYYYMMDD / DDMMYYYY / DDMMYY / YYYY-MM-DD / DD-MM-YY[YY] → ISO date."""
|
|
39
|
+
v = value.strip()
|
|
40
|
+
for pattern, extract in _ATTEMPTS:
|
|
41
|
+
m = pattern.match(v)
|
|
42
|
+
if m:
|
|
43
|
+
iso = _iso(*extract(m))
|
|
44
|
+
if iso is not None:
|
|
45
|
+
return iso
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_plausible_date(value: str) -> bool:
|
|
50
|
+
return parse_indian_date(value) is not None
|
panini/engine.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# Copyright 2026 Kapil — Project Panini Contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Deterministic narration engine — Python port of @panini/core Normalizer.
|
|
4
|
+
|
|
5
|
+
Shares the same grammar JSONs and corpus fixtures as the TypeScript
|
|
6
|
+
packages; the two runtimes are conformance-tested against each other.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from .dates import is_plausible_date, parse_indian_date
|
|
19
|
+
from .tokenizer import CHANNEL_KEYWORDS, DIRECTION_KEYWORDS, tokenize
|
|
20
|
+
|
|
21
|
+
# ── regex translation (JS → Python) ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def translate_flags(flags: str) -> int:
|
|
24
|
+
out = 0
|
|
25
|
+
for f in flags:
|
|
26
|
+
if f == "i":
|
|
27
|
+
out |= re.IGNORECASE
|
|
28
|
+
elif f == "m":
|
|
29
|
+
out |= re.MULTILINE
|
|
30
|
+
elif f == "s":
|
|
31
|
+
out |= re.DOTALL
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _translate(js_regex: str, flags: str = "") -> re.Pattern[str]:
|
|
36
|
+
"""JS named groups `(?<name>…)` → Python `(?P<name>…)`. Lookbehinds
|
|
37
|
+
`(?<=`/`(?<!` and non-capturing `(?:` are already valid Python."""
|
|
38
|
+
py = re.sub(r"\(\?<([A-Za-z])", r"(?P<\1", js_regex)
|
|
39
|
+
return re.compile(py, translate_flags(flags))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── data model ───────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Pattern:
|
|
46
|
+
name: str
|
|
47
|
+
rule_set_id: str
|
|
48
|
+
bank: str
|
|
49
|
+
regex: re.Pattern[str]
|
|
50
|
+
media: str
|
|
51
|
+
channel: Optional[str]
|
|
52
|
+
type: Optional[str]
|
|
53
|
+
category: Optional[str]
|
|
54
|
+
priority: int
|
|
55
|
+
confidence: float
|
|
56
|
+
flags: dict[str, str]
|
|
57
|
+
set_metadata: dict[str, str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class NormalizedTransaction:
|
|
62
|
+
channel: str
|
|
63
|
+
counterparty: Optional[str]
|
|
64
|
+
transaction_id: Optional[str]
|
|
65
|
+
vpa_handle: Optional[str]
|
|
66
|
+
type: str
|
|
67
|
+
category: str
|
|
68
|
+
date: Optional[str]
|
|
69
|
+
amount_paise: Optional[int]
|
|
70
|
+
balance_paise: Optional[int]
|
|
71
|
+
currency: str
|
|
72
|
+
account_mask: Optional[str]
|
|
73
|
+
raw: str
|
|
74
|
+
matched_rule: Optional[str]
|
|
75
|
+
bank: Optional[str]
|
|
76
|
+
confidence: float
|
|
77
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
78
|
+
warnings: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── casing ───────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
_CORPORATE_SUFFIXES = {"LTD", "PVT", "INC", "CORP", "CO"}
|
|
84
|
+
_ACRONYM_RE = re.compile(r"^[A-Z]{2,5}$")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _title(word: str) -> str:
|
|
88
|
+
return word[0].upper() + word[1:].lower() if word else word
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def preserve_acronym_case(value: str) -> str:
|
|
92
|
+
words = [w for w in value.strip().split() if w]
|
|
93
|
+
if len(words) <= 1:
|
|
94
|
+
word = words[0] if words else value
|
|
95
|
+
return word if re.fullmatch(r"[A-Z]{2,4}", word) else _title(word)
|
|
96
|
+
out = []
|
|
97
|
+
for w in words:
|
|
98
|
+
if w.upper() in _CORPORATE_SUFFIXES:
|
|
99
|
+
out.append(_title(w))
|
|
100
|
+
elif _ACRONYM_RE.match(w):
|
|
101
|
+
out.append(w)
|
|
102
|
+
else:
|
|
103
|
+
out.append(_title(w))
|
|
104
|
+
return " ".join(out)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
|
|
110
|
+
_SMS_SIGNAL = re.compile(r"\b(debited|credited|avl\s*bal|a/c\s*(?:no|xx|\*+))", re.IGNORECASE)
|
|
111
|
+
_CURRENCY_PREFIX = re.compile(r"^(?:rs\.?|inr|₹)", re.IGNORECASE)
|
|
112
|
+
_AMOUNT_BODY = re.compile(r"^\d+(?:\.\d{1,2})?$")
|
|
113
|
+
|
|
114
|
+
_DIRECTION_TYPES = {"DEBIT", "CREDIT"}
|
|
115
|
+
_REVERSAL_TYPES = {"REVERSAL", "REFUND"}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _sanitize(text: str) -> str:
|
|
119
|
+
return re.sub(r"\s+", " ", _CONTROL_CHARS.sub(" ", text)).strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _detect_source(text: str) -> str:
|
|
123
|
+
return "sms" if _SMS_SIGNAL.search(text) else "narration"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_paise(value: str) -> Optional[int]:
|
|
127
|
+
stripped = _CURRENCY_PREFIX.sub("", value.strip()).replace(",", "")
|
|
128
|
+
if not _AMOUNT_BODY.match(stripped):
|
|
129
|
+
return None
|
|
130
|
+
return round(float(stripped) * 100)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clean_counterparty(value: Optional[str]) -> Optional[str]:
|
|
134
|
+
if not value:
|
|
135
|
+
return None
|
|
136
|
+
cleaned = re.sub(
|
|
137
|
+
r"^(?:(?:M/S|TO|BY|FROM|AT|THE|UPI|IMPS|NEFT|RTGS|ECS|ACH|NACH)[\s\-/]+)+",
|
|
138
|
+
"", value, flags=re.IGNORECASE,
|
|
139
|
+
)
|
|
140
|
+
cleaned = re.sub(r"[\s\-./]+$", "", cleaned)
|
|
141
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
142
|
+
return preserve_acronym_case(cleaned) if cleaned else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _resolve_type(pattern_type, direction_group, flag_types) -> str:
|
|
146
|
+
if pattern_type:
|
|
147
|
+
return pattern_type
|
|
148
|
+
if direction_group:
|
|
149
|
+
mapped = DIRECTION_KEYWORDS.get(direction_group.upper())
|
|
150
|
+
if mapped:
|
|
151
|
+
return mapped
|
|
152
|
+
for t in flag_types:
|
|
153
|
+
if t in _DIRECTION_TYPES:
|
|
154
|
+
return t
|
|
155
|
+
for t in flag_types:
|
|
156
|
+
if t in _REVERSAL_TYPES:
|
|
157
|
+
return t
|
|
158
|
+
return "UNKNOWN"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── grammar compilation ──────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def compile_rule_sets(rule_sets: list[dict[str, Any]]) -> list[Pattern]:
|
|
164
|
+
"""Ordering: priority desc, bank-specific before GENERIC, stable."""
|
|
165
|
+
compiled: list[Pattern] = []
|
|
166
|
+
for rs in rule_sets:
|
|
167
|
+
for p in rs.get("patterns", []):
|
|
168
|
+
compiled.append(Pattern(
|
|
169
|
+
name=p["name"],
|
|
170
|
+
rule_set_id=rs["id"],
|
|
171
|
+
bank=rs["bank"],
|
|
172
|
+
regex=_translate(p["regex"], p.get("regexFlags", "")),
|
|
173
|
+
media=p.get("media", "any"),
|
|
174
|
+
channel=p.get("channel"),
|
|
175
|
+
type=p.get("type"),
|
|
176
|
+
category=p.get("category"),
|
|
177
|
+
priority=p.get("priority", 0),
|
|
178
|
+
confidence=p.get("confidence", 0.9),
|
|
179
|
+
flags=p.get("flags", {}),
|
|
180
|
+
set_metadata=p.get("setMetadata", {}),
|
|
181
|
+
))
|
|
182
|
+
return sorted(
|
|
183
|
+
compiled,
|
|
184
|
+
key=lambda p: (-p.priority, p.bank == "GENERIC"),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_builtin_rules(rules_dir: Path) -> tuple[list[dict], dict[str, str]]:
|
|
189
|
+
grammars, corpus = [], {}
|
|
190
|
+
for path in sorted(rules_dir.glob("*.json")):
|
|
191
|
+
if path.name == "categories.json":
|
|
192
|
+
corpus = json.loads(path.read_text())
|
|
193
|
+
continue
|
|
194
|
+
grammars.append(json.loads(path.read_text()))
|
|
195
|
+
return grammars, corpus
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── normalizer ───────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
class Normalizer:
|
|
201
|
+
def __init__(self, rule_sets: list[dict], categories: dict[str, str]):
|
|
202
|
+
self.patterns = compile_rule_sets(rule_sets)
|
|
203
|
+
merged: dict[str, str] = {}
|
|
204
|
+
for rs in rule_sets:
|
|
205
|
+
merged.update(rs.get("categories", {}))
|
|
206
|
+
merged.update(categories)
|
|
207
|
+
self.category_rules = [
|
|
208
|
+
(kw, cat, re.compile(r"\b" + re.escape(kw) + r"\b", re.IGNORECASE))
|
|
209
|
+
for kw, cat in sorted(merged.items(), key=lambda kv: -len(kv[0]))
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
def _candidates(self, source: str, bank_hint: Optional[str]) -> list[Pattern]:
|
|
213
|
+
applicable = [p for p in self.patterns if p.media in ("any", source)]
|
|
214
|
+
if not bank_hint:
|
|
215
|
+
return applicable
|
|
216
|
+
hint = bank_hint.upper()
|
|
217
|
+
preferred = [p for p in applicable if p.bank.upper() == hint]
|
|
218
|
+
rest = [p for p in applicable if p.bank.upper() != hint]
|
|
219
|
+
return preferred + rest
|
|
220
|
+
|
|
221
|
+
def _channel_from_tokens(self, tokens) -> Optional[str]:
|
|
222
|
+
for t in tokens:
|
|
223
|
+
ch = CHANNEL_KEYWORDS.get(t.value.upper())
|
|
224
|
+
if ch:
|
|
225
|
+
return ch
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def _categorize(self, text: str) -> Optional[str]:
|
|
229
|
+
for _, cat, matcher in self.category_rules:
|
|
230
|
+
if matcher.search(text):
|
|
231
|
+
return cat
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def _empty(self, raw: str, warnings: list[str]) -> NormalizedTransaction:
|
|
235
|
+
return NormalizedTransaction(
|
|
236
|
+
channel="UNKNOWN", counterparty=None, transaction_id=None,
|
|
237
|
+
vpa_handle=None, type="UNKNOWN", category="Uncategorized",
|
|
238
|
+
date=None, amount_paise=None, balance_paise=None,
|
|
239
|
+
currency="INR", account_mask=None, raw=raw,
|
|
240
|
+
matched_rule=None, bank=None, confidence=0.0,
|
|
241
|
+
warnings=list(warnings),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _from_match(self, pattern: Pattern, m: re.Match, text: str,
|
|
245
|
+
raw: str, warnings: list[str]) -> NormalizedTransaction:
|
|
246
|
+
groups = {k: v for k, v in (m.groupdict() or {}).items()}
|
|
247
|
+
metadata: dict[str, str] = {}
|
|
248
|
+
|
|
249
|
+
flag_types, applied = [], []
|
|
250
|
+
if groups.get("tail"):
|
|
251
|
+
for tok in tokenize(groups["tail"], is_plausible_date):
|
|
252
|
+
upper = tok.value.upper()
|
|
253
|
+
mapped = pattern.flags.get(upper) or DIRECTION_KEYWORDS.get(upper)
|
|
254
|
+
if mapped:
|
|
255
|
+
flag_types.append(mapped)
|
|
256
|
+
applied.append(upper)
|
|
257
|
+
if applied:
|
|
258
|
+
metadata["flags"] = ",".join(applied)
|
|
259
|
+
if groups.get("remarks"):
|
|
260
|
+
metadata["remarks"] = groups["remarks"].strip()
|
|
261
|
+
if groups.get("bankCode"):
|
|
262
|
+
metadata["bank_code"] = groups["bankCode"].strip()
|
|
263
|
+
metadata.update(pattern.set_metadata)
|
|
264
|
+
|
|
265
|
+
tokens = tokenize(text, is_plausible_date)
|
|
266
|
+
channel = pattern.channel or self._channel_from_tokens(tokens) or "UNKNOWN"
|
|
267
|
+
txn_type = _resolve_type(pattern.type, groups.get("direction"), flag_types)
|
|
268
|
+
|
|
269
|
+
counterparty = _clean_counterparty(groups.get("counterparty"))
|
|
270
|
+
txn_id = (groups.get("txnId") or "").strip() or None
|
|
271
|
+
|
|
272
|
+
vpa = groups.get("vpaHandle")
|
|
273
|
+
if vpa is not None:
|
|
274
|
+
vpa = vpa.lstrip("@").lower()
|
|
275
|
+
|
|
276
|
+
date_val = self._parse_field(groups.get("date"), parse_indian_date, "date", warnings)
|
|
277
|
+
amount = self._parse_field(groups.get("amount"), _parse_paise, "amount", warnings)
|
|
278
|
+
balance = self._parse_field(groups.get("balance"), _parse_paise, "balance", warnings)
|
|
279
|
+
account_mask = (groups.get("accountMask") or "").strip() or None
|
|
280
|
+
|
|
281
|
+
category = (
|
|
282
|
+
pattern.category
|
|
283
|
+
or self._categorize(f"{counterparty or ''} {text}")
|
|
284
|
+
or "Uncategorized"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return NormalizedTransaction(
|
|
288
|
+
channel=channel, counterparty=counterparty, transaction_id=txn_id,
|
|
289
|
+
vpa_handle=vpa, type=txn_type, category=category, date=date_val,
|
|
290
|
+
amount_paise=amount, balance_paise=balance, currency="INR",
|
|
291
|
+
account_mask=account_mask, raw=raw,
|
|
292
|
+
matched_rule=f"{pattern.rule_set_id}:{pattern.name}",
|
|
293
|
+
bank=None if pattern.bank == "GENERIC" else pattern.bank,
|
|
294
|
+
confidence=pattern.confidence, metadata=metadata,
|
|
295
|
+
warnings=list(warnings),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _parse_field(value, parser, label, warnings):
|
|
300
|
+
if value is None or not value.strip():
|
|
301
|
+
return None
|
|
302
|
+
parsed = parser(value.strip())
|
|
303
|
+
if parsed is None:
|
|
304
|
+
warnings.append(f"unparseable {label} '{value.strip()}'")
|
|
305
|
+
return parsed
|
|
306
|
+
|
|
307
|
+
def _heuristic(self, text: str, raw: str, bank_hint, warnings) -> NormalizedTransaction:
|
|
308
|
+
tokens = tokenize(text, is_plausible_date)
|
|
309
|
+
metadata: dict[str, str] = {}
|
|
310
|
+
flag_types, applied = [], []
|
|
311
|
+
for t in tokens:
|
|
312
|
+
mapped = DIRECTION_KEYWORDS.get(t.value.upper())
|
|
313
|
+
if mapped:
|
|
314
|
+
flag_types.append(mapped)
|
|
315
|
+
applied.append(t.value.upper())
|
|
316
|
+
if applied:
|
|
317
|
+
metadata["flags"] = ",".join(applied)
|
|
318
|
+
|
|
319
|
+
channel = self._channel_from_tokens(tokens) or "UNKNOWN"
|
|
320
|
+
txn_type = _resolve_type(None, None, flag_types)
|
|
321
|
+
|
|
322
|
+
txn_id, vpa = None, None
|
|
323
|
+
vpa_tok = next((t for t in tokens if t.kind == "VPA"), None)
|
|
324
|
+
if vpa_tok:
|
|
325
|
+
left, _, right = vpa_tok.value.partition("@")
|
|
326
|
+
if left.isdigit():
|
|
327
|
+
txn_id = left
|
|
328
|
+
if re.fullmatch(r"[a-zA-Z0-9]+", right or ""):
|
|
329
|
+
vpa = right.lower()
|
|
330
|
+
if txn_id is None:
|
|
331
|
+
nums = sorted(
|
|
332
|
+
(t for t in tokens if t.kind == "NUMERIC" and len(t.value) >= 6),
|
|
333
|
+
key=lambda t: -len(t.value),
|
|
334
|
+
)
|
|
335
|
+
txn_id = nums[0].value if nums else None
|
|
336
|
+
|
|
337
|
+
date_tok = next((t for t in tokens if t.kind == "DATE"), None)
|
|
338
|
+
date_val = parse_indian_date(date_tok.value) if date_tok else None
|
|
339
|
+
amt_tok = next((t for t in tokens if t.kind == "AMOUNT"), None)
|
|
340
|
+
amount = _parse_paise(amt_tok.value) if amt_tok else None
|
|
341
|
+
|
|
342
|
+
channel_pos = next(
|
|
343
|
+
(t.position for t in tokens if t.value.upper() in CHANNEL_KEYWORDS), None
|
|
344
|
+
)
|
|
345
|
+
cp_tok = next(
|
|
346
|
+
(t for t in tokens
|
|
347
|
+
if t.kind in ("ALPHA", "ALNUM") and len(t.value) >= 3
|
|
348
|
+
and t.value.upper() not in CHANNEL_KEYWORDS
|
|
349
|
+
and t.value.upper() not in DIRECTION_KEYWORDS
|
|
350
|
+
and (channel_pos is None or t.position > channel_pos)),
|
|
351
|
+
None,
|
|
352
|
+
)
|
|
353
|
+
counterparty = preserve_acronym_case(cp_tok.value) if cp_tok else None
|
|
354
|
+
|
|
355
|
+
category = self._categorize(f"{counterparty or ''} {text}") or "Uncategorized"
|
|
356
|
+
warnings.append("no grammar pattern matched; applied heuristic tokenization")
|
|
357
|
+
|
|
358
|
+
return NormalizedTransaction(
|
|
359
|
+
channel=channel, counterparty=counterparty, transaction_id=txn_id,
|
|
360
|
+
vpa_handle=vpa, type=txn_type, category=category, date=date_val,
|
|
361
|
+
amount_paise=amount, balance_paise=None, currency="INR",
|
|
362
|
+
account_mask=None, raw=raw, matched_rule=None,
|
|
363
|
+
bank=bank_hint, confidence=0.5, metadata=metadata,
|
|
364
|
+
warnings=list(warnings),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def normalize(self, input_: str | dict) -> NormalizedTransaction:
|
|
368
|
+
req = {"text": input_} if isinstance(input_, str) else dict(input_)
|
|
369
|
+
raw = req.get("text", "")
|
|
370
|
+
text = _sanitize(raw)
|
|
371
|
+
if not text:
|
|
372
|
+
return self._empty(raw, ["input is empty"])
|
|
373
|
+
source = req.get("source")
|
|
374
|
+
if source in (None, "auto"):
|
|
375
|
+
source = _detect_source(text)
|
|
376
|
+
bank_hint = req.get("bankHint")
|
|
377
|
+
warnings: list[str] = []
|
|
378
|
+
for pattern in self._candidates(source, bank_hint):
|
|
379
|
+
m = pattern.regex.search(text)
|
|
380
|
+
if m:
|
|
381
|
+
return self._from_match(pattern, m, text, raw, warnings)
|
|
382
|
+
return self._heuristic(text, raw, bank_hint, warnings)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── dictionaries + facade ────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
def _default_rules_dir() -> Path:
|
|
388
|
+
"""Resolution order: PANINI_RULES_DIR env → bundled package data →
|
|
389
|
+
repo layout (development checkout)."""
|
|
390
|
+
env = os.environ.get("PANINI_RULES_DIR")
|
|
391
|
+
if env:
|
|
392
|
+
return Path(env)
|
|
393
|
+
bundled = Path(__file__).resolve().parent / "rules"
|
|
394
|
+
if (bundled / "generic.json").exists():
|
|
395
|
+
return bundled
|
|
396
|
+
return Path(__file__).resolve().parents[2] / "packages" / "rules-engine" / "rules"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_RULES_DIR = _default_rules_dir()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _load_entities(rules_dir: Path) -> dict[str, Any]:
|
|
403
|
+
ent = rules_dir / "entities"
|
|
404
|
+
out: dict[str, Any] = {"merchants": [], "vpa-handles": {}, "ifsc-codes": {}}
|
|
405
|
+
for name in ("merchants", "vpa-handles", "ifsc-codes"):
|
|
406
|
+
p = ent / f"{name}.json"
|
|
407
|
+
if p.exists():
|
|
408
|
+
data = json.loads(p.read_text())
|
|
409
|
+
if name == "merchants":
|
|
410
|
+
out[name] = data.get("merchants", [])
|
|
411
|
+
elif name == "vpa-handles":
|
|
412
|
+
out[name] = data.get("handles", {})
|
|
413
|
+
else:
|
|
414
|
+
out[name] = data.get("banks", {})
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _normalize_key(value: str) -> str:
|
|
419
|
+
return re.sub(r"\s+", " ", re.sub(r"[^A-Z0-9 ]", " ", value.upper())).strip()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _lookup_merchant(counterparty: Optional[str], merchants: list[dict]) -> Optional[dict]:
|
|
423
|
+
if not counterparty:
|
|
424
|
+
return None
|
|
425
|
+
key = _normalize_key(counterparty)
|
|
426
|
+
for entry in merchants:
|
|
427
|
+
for alias in [entry["canonical"], *entry.get("aliases", [])]:
|
|
428
|
+
if _normalize_key(alias) == key:
|
|
429
|
+
return entry
|
|
430
|
+
best, best_len = None, 0
|
|
431
|
+
for entry in merchants:
|
|
432
|
+
for alias in [entry["canonical"], *entry.get("aliases", [])]:
|
|
433
|
+
norm = _normalize_key(alias)
|
|
434
|
+
if len(norm) > best_len and key.startswith(norm + " "):
|
|
435
|
+
best, best_len = entry, len(norm)
|
|
436
|
+
return best
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
_IFSC_RE = re.compile(r"([A-Z]{4})0[A-Z0-9]{6}")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _lookup_ifsc(value: Optional[str], banks: dict) -> Optional[dict]:
|
|
443
|
+
if not value:
|
|
444
|
+
return None
|
|
445
|
+
upper = value.upper()
|
|
446
|
+
m = _IFSC_RE.search(upper)
|
|
447
|
+
if m:
|
|
448
|
+
prefix = m.group(1)
|
|
449
|
+
elif re.fullmatch(r"[A-Z]{4}", upper.strip()):
|
|
450
|
+
prefix = upper.strip()
|
|
451
|
+
else:
|
|
452
|
+
return None
|
|
453
|
+
return banks.get(prefix)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def parse_narration(input_: str | dict, *, enrich: bool = True,
|
|
457
|
+
rules_dir: Optional[Path] = None) -> dict[str, Any]:
|
|
458
|
+
"""Parse a bank narration into the flattened ParsedBankNarration shape —
|
|
459
|
+
the same output contract as parseBankNarration() in TypeScript."""
|
|
460
|
+
rd = rules_dir or _RULES_DIR
|
|
461
|
+
rule_sets, categories = load_builtin_rules(rd)
|
|
462
|
+
norm = Normalizer(rule_sets, categories)
|
|
463
|
+
txn = norm.normalize(input_)
|
|
464
|
+
|
|
465
|
+
merchant_entry = vpa_info = ifsc_info = None
|
|
466
|
+
if enrich:
|
|
467
|
+
entities = _load_entities(rd)
|
|
468
|
+
merchant_entry = _lookup_merchant(txn.counterparty, entities["merchants"])
|
|
469
|
+
if txn.vpa_handle:
|
|
470
|
+
vpa_info = entities["vpa-handles"].get(txn.vpa_handle)
|
|
471
|
+
ifsc_src = txn.metadata.get("bank_code") or txn.raw
|
|
472
|
+
ifsc_info = _lookup_ifsc(ifsc_src, entities["ifsc-codes"])
|
|
473
|
+
|
|
474
|
+
merchant = (merchant_entry or {}).get("canonical") or txn.counterparty
|
|
475
|
+
category = (
|
|
476
|
+
merchant_entry["category"]
|
|
477
|
+
if txn.category == "Uncategorized" and merchant_entry
|
|
478
|
+
else txn.category
|
|
479
|
+
)
|
|
480
|
+
return {
|
|
481
|
+
"channel": txn.channel,
|
|
482
|
+
"type": txn.type,
|
|
483
|
+
"ref_no": txn.transaction_id,
|
|
484
|
+
"merchant": merchant,
|
|
485
|
+
"counterparty": txn.counterparty,
|
|
486
|
+
"vpa": txn.vpa_handle,
|
|
487
|
+
"is_salary": txn.metadata.get("is_salary") == "true",
|
|
488
|
+
"date": txn.date,
|
|
489
|
+
"category": category,
|
|
490
|
+
"confidence": txn.confidence,
|
|
491
|
+
"matched_rule": txn.matched_rule,
|
|
492
|
+
"warnings": txn.warnings,
|
|
493
|
+
"merchant_canonical": (merchant_entry or {}).get("canonical"),
|
|
494
|
+
"vpa_app": (vpa_info or {}).get("app"),
|
|
495
|
+
"vpa_bank": (vpa_info or {}).get("bank"),
|
|
496
|
+
"ifsc_bank": (ifsc_info or {}).get("name"),
|
|
497
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "bank-narration-archetypes",
|
|
3
|
+
"bank": "GENERIC",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"description": "Dominant Indian banking narration archetypes: pipeline UPI, INBOUND/OUTBOUND IMPS, salary NEFT credits, alphanumeric reference rails.",
|
|
6
|
+
"patterns": [
|
|
7
|
+
{
|
|
8
|
+
"name": "upi_full_pipeline",
|
|
9
|
+
"priority": 40,
|
|
10
|
+
"channel": "UPI",
|
|
11
|
+
"media": "narration",
|
|
12
|
+
"regexFlags": "i",
|
|
13
|
+
"confidence": 0.95,
|
|
14
|
+
"regex": "^UPI[\\/](?<date>\\d{8}|\\d{2}[-/.]\\d{2}[-/.]\\d{2,4})[\\/](?<direction>DR|CR)[\\/](?<txnId>\\d{6,18})[\\/](?<counterparty>[^\\/@]+?)(?:(?:@|[\\/])(?<vpaHandle>[a-z0-9._-]+))?(?:[\\/](?<remarks>[^\\/]*))?[\\/]?\\s*$"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "imps_directional",
|
|
18
|
+
"priority": 35,
|
|
19
|
+
"channel": "IMPS",
|
|
20
|
+
"media": "narration",
|
|
21
|
+
"regexFlags": "i",
|
|
22
|
+
"confidence": 0.93,
|
|
23
|
+
"regex": "^IMPS[-/](?<txnId>\\d{6,16})[-/](?<counterparty>[^-/]+?)[-/ ](?<bankCode>[A-Z]{4}\\d{0,7})[-/](?<direction>INBOUND|OUTBOUND|INWARD|OUTWARD)\\b(?<tail>.*)$"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "neft_salary_credit",
|
|
27
|
+
"priority": 40,
|
|
28
|
+
"channel": "NEFT",
|
|
29
|
+
"type": "CREDIT",
|
|
30
|
+
"category": "Income",
|
|
31
|
+
"media": "narration",
|
|
32
|
+
"regexFlags": "i",
|
|
33
|
+
"confidence": 0.95,
|
|
34
|
+
"regex": "^NEFT[-/](?<txnId>[A-Z]{0,3}\\d{5,16})[-/]SAL(?:ARY)?[-/ ](?<remarks>[A-Za-z]+\\s*\\d{4})[-/](?<counterparty>.+?)\\s*$",
|
|
35
|
+
"setMetadata": { "is_salary": "true" }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "neft_alnum_ref",
|
|
39
|
+
"priority": 30,
|
|
40
|
+
"channel": "NEFT",
|
|
41
|
+
"media": "narration",
|
|
42
|
+
"regexFlags": "i",
|
|
43
|
+
"confidence": 0.85,
|
|
44
|
+
"regex": "^NEFT[-/](?<txnId>[A-Z0-9]{6,18})[-/](?<counterparty>[^-]+)(?:[-/](?<remarks>.+?))?\\s*$"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "rtgs_alnum_ref",
|
|
48
|
+
"priority": 30,
|
|
49
|
+
"channel": "RTGS",
|
|
50
|
+
"media": "narration",
|
|
51
|
+
"regexFlags": "i",
|
|
52
|
+
"confidence": 0.85,
|
|
53
|
+
"regex": "^RTGS[-/](?<txnId>[A-Z0-9]{6,18})[-/](?<counterparty>[^-]+)(?:[-/](?<remarks>.+?))?\\s*$"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "ach_credit",
|
|
57
|
+
"priority": 30,
|
|
58
|
+
"channel": "ACH",
|
|
59
|
+
"media": "narration",
|
|
60
|
+
"regexFlags": "i",
|
|
61
|
+
"regex": "^(?:ACH|ECS)[-/ ](?<direction>CR|DR)[-/ ](?<txnId>[A-Z0-9]{6,18})[-/](?<counterparty>.+?)\\s*$"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "cash_transaction",
|
|
65
|
+
"priority": 30,
|
|
66
|
+
"channel": "ATM",
|
|
67
|
+
"media": "narration",
|
|
68
|
+
"regexFlags": "i",
|
|
69
|
+
"regex": "^CASH[-/ ](?<direction>WDL|DEP|WITHDRAWAL|DEPOSIT)[-/ ](?<counterparty>.+?)\\s*$",
|
|
70
|
+
"setMetadata": { "instrument": "CASH" }
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|