owo-parse 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.
- owo/__init__.py +30 -0
- owo/_heuristic.py +439 -0
- owo/_prompt.py +43 -0
- owo/eval/__init__.py +1 -0
- owo/eval/__main__.py +247 -0
- owo/eval/fixtures/balance_en.yaml +26 -0
- owo/eval/fixtures/balance_ha.yaml +19 -0
- owo/eval/fixtures/balance_ig.yaml +19 -0
- owo/eval/fixtures/balance_pcm.yaml +29 -0
- owo/eval/fixtures/balance_yo.yaml +19 -0
- owo/eval/fixtures/bill_pay.yaml +49 -0
- owo/eval/fixtures/buy_airtime.yaml +40 -0
- owo/eval/fixtures/buy_data.yaml +38 -0
- owo/eval/fixtures/crypto_sell.yaml +35 -0
- owo/eval/fixtures/edge_cases.yaml +95 -0
- owo/eval/fixtures/fallback_unknown.yaml +7 -0
- owo/eval/fixtures/transfer_en_extended.yaml +47 -0
- owo/eval/fixtures/transfer_en_missing_amount.yaml +10 -0
- owo/eval/fixtures/transfer_en_sample.yaml +14 -0
- owo/eval/fixtures/transfer_ha.yaml +32 -0
- owo/eval/fixtures/transfer_ig.yaml +32 -0
- owo/eval/fixtures/transfer_pcm.yaml +55 -0
- owo/eval/fixtures/transfer_yo.yaml +32 -0
- owo/providers/__init__.py +21 -0
- owo/providers/anthropic.py +65 -0
- owo/providers/openai.py +67 -0
- owo/providers/openrouter.py +73 -0
- owo/py.typed +0 -0
- owo/result.py +3 -0
- owo/schema.py +39 -0
- owo_parse-0.1.0.dist-info/METADATA +292 -0
- owo_parse-0.1.0.dist-info/RECORD +35 -0
- owo_parse-0.1.0.dist-info/WHEEL +5 -0
- owo_parse-0.1.0.dist-info/licenses/LICENSE +21 -0
- owo_parse-0.1.0.dist-info/top_level.txt +1 -0
owo/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from owo._heuristic import heuristic_parse as _heuristic_parse
|
|
4
|
+
from owo._heuristic import result_from_provider_json as _result_from_provider_json
|
|
5
|
+
from owo.providers import BaseProvider
|
|
6
|
+
from owo.schema import Intent, Language, OwoResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse(text: str, *, provider: BaseProvider | None = None) -> OwoResult:
|
|
10
|
+
"""
|
|
11
|
+
Parse a free-form financial instruction into structured fields.
|
|
12
|
+
|
|
13
|
+
The heuristic runs first in all cases. When a *provider* is supplied,
|
|
14
|
+
it is called only for inputs the heuristic cannot confidently handle
|
|
15
|
+
(i.e. the result carries a ``needs_llm_provider`` flag).
|
|
16
|
+
"""
|
|
17
|
+
result = _heuristic_parse(text)
|
|
18
|
+
if provider is None or "needs_llm_provider" not in result.flags:
|
|
19
|
+
return result
|
|
20
|
+
out = provider.complete_messages(text)
|
|
21
|
+
return _result_from_provider_json(out, text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BaseProvider",
|
|
26
|
+
"Intent",
|
|
27
|
+
"Language",
|
|
28
|
+
"OwoResult",
|
|
29
|
+
"parse",
|
|
30
|
+
]
|
owo/_heuristic.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from owo.schema import Intent, Language, OwoResult
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# English (en)
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
_EN_TRANSFER_K = re.compile(
|
|
13
|
+
r"(?i)^(?:send|transfer)\s+(\d+)\s*k\s+to\s+(.+)$",
|
|
14
|
+
)
|
|
15
|
+
_EN_TRANSFER_NAIRA = re.compile(
|
|
16
|
+
r"(?i)^(?:send|transfer)\s+(?:₦|ngn\s*)?(\d{1,3}(?:,\d{3})*|\d+)\s*(?:naira)?\s+to\s+(.+)$",
|
|
17
|
+
)
|
|
18
|
+
_EN_TRANSFER_MONEY = re.compile(
|
|
19
|
+
r"(?i)^(?:send|transfer)\s+money\s+to\s+(.+)$",
|
|
20
|
+
)
|
|
21
|
+
_EN_TRANSFER_HALF_MILLI = re.compile(
|
|
22
|
+
r"(?i)^(?:send|transfer)\s+half\s+a\s+milli(?:on)?\s+to\s+(.+)$",
|
|
23
|
+
)
|
|
24
|
+
_EN_TRANSFER_MILLI = re.compile(
|
|
25
|
+
r"(?i)^(?:send|transfer)\s+(\d*\.?\d+)\s*milli(?:on)?\s+to\s+(.+)$",
|
|
26
|
+
)
|
|
27
|
+
_EN_BALANCE = re.compile(
|
|
28
|
+
r"(?i)^(?:"
|
|
29
|
+
r"how\s+much\s+do\s+i\s+(?:have|get)\??"
|
|
30
|
+
r"|what(?:'s|\s+is)\s+my\s+balance\??"
|
|
31
|
+
r"|check\s+my\s+balance"
|
|
32
|
+
r"|show\s+my\s+balance"
|
|
33
|
+
r"|account\s+balance(?:\s+please)?"
|
|
34
|
+
r")\s*$",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Nigerian Pidgin (pcm)
|
|
39
|
+
# Each pattern requires at least one unambiguous pcm marker:
|
|
40
|
+
# abeg (prefix), give (instead of "to"), or send am (object pronoun).
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
_PCM_ABEG_K = re.compile(
|
|
43
|
+
r"(?i)^abeg\s+(?:send|transfer)(?:\s+am)?\s+(\d+)\s*k\s+(?:to|give)\s+(.+)$",
|
|
44
|
+
)
|
|
45
|
+
_PCM_ABEG_NAIRA = re.compile(
|
|
46
|
+
r"(?i)^abeg\s+(?:send|transfer)\s+(?:₦|ngn\s*)?(\d{1,3}(?:,\d{3})*|\d+)\s*(?:naira)?\s+(?:to|give)\s+(.+)$",
|
|
47
|
+
)
|
|
48
|
+
_PCM_ABEG_HALF_MILLI = re.compile(
|
|
49
|
+
r"(?i)^abeg\s+(?:send|transfer)\s+half\s+a\s+milli(?:on)?\s+(?:to|give)\s+(.+)$",
|
|
50
|
+
)
|
|
51
|
+
_PCM_ABEG_MILLI = re.compile(
|
|
52
|
+
r"(?i)^abeg\s+(?:send|transfer)\s+(\d*\.?\d+)\s*milli(?:on)?\s+(?:to|give)\s+(.+)$",
|
|
53
|
+
)
|
|
54
|
+
_PCM_GIVE_K = re.compile(
|
|
55
|
+
r"(?i)^(?:send|transfer)(?:\s+am)?\s+(\d+)\s*k\s+give\s+(.+)$",
|
|
56
|
+
)
|
|
57
|
+
_PCM_GIVE_NAIRA = re.compile(
|
|
58
|
+
r"(?i)^(?:send|transfer)(?:\s+am)?\s+(?:₦|ngn\s*)?(\d{1,3}(?:,\d{3})*|\d+)\s*(?:naira)?\s+give\s+(.+)$",
|
|
59
|
+
)
|
|
60
|
+
_PCM_GIVE_HALF_MILLI = re.compile(
|
|
61
|
+
r"(?i)^(?:send|transfer)\s+half\s+a\s+milli(?:on)?\s+give\s+(.+)$",
|
|
62
|
+
)
|
|
63
|
+
_PCM_GIVE_MILLI = re.compile(
|
|
64
|
+
r"(?i)^(?:send|transfer)\s+(\d*\.?\d+)\s*milli(?:on)?\s+give\s+(.+)$",
|
|
65
|
+
)
|
|
66
|
+
_PCM_AM_K = re.compile(
|
|
67
|
+
r"(?i)^send\s+am\s+(\d+)\s*k\s+to\s+(.+)$",
|
|
68
|
+
)
|
|
69
|
+
_PCM_AM_NAIRA = re.compile(
|
|
70
|
+
r"(?i)^send\s+am\s+(?:₦|ngn\s*)?(\d{1,3}(?:,\d{3})*|\d+)\s*(?:naira)?\s+to\s+(.+)$",
|
|
71
|
+
)
|
|
72
|
+
_PCM_ABEG_MONEY = re.compile(
|
|
73
|
+
r"(?i)^abeg\s+(?:send|transfer)\s+money\s+(?:to|give)\s+(.+)$",
|
|
74
|
+
)
|
|
75
|
+
_PCM_GIVE_MONEY = re.compile(
|
|
76
|
+
r"(?i)^(?:send|transfer)\s+money\s+give\s+(.+)$",
|
|
77
|
+
)
|
|
78
|
+
_PCM_BALANCE = re.compile(
|
|
79
|
+
r"(?i)^(?:"
|
|
80
|
+
r"how\s+much\s+(?:i\s+)?get\??"
|
|
81
|
+
r"|how\s+much\s+dey\s+my\s+account\??"
|
|
82
|
+
r"|wetin\s+be\s+my\s+balance\??"
|
|
83
|
+
r")\s*$",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Hausa (ha)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Number words used after "dubu" (thousand) to form common amounts.
|
|
90
|
+
_HA_NUM_WORDS: dict[str, int] = {
|
|
91
|
+
"ɗaya": 1, "daya": 1,
|
|
92
|
+
"biyu": 2,
|
|
93
|
+
"uku": 3,
|
|
94
|
+
"huɗu": 4, "hudu": 4,
|
|
95
|
+
"biyar": 5,
|
|
96
|
+
"shida": 6,
|
|
97
|
+
"bakwai": 7,
|
|
98
|
+
"takwas": 8,
|
|
99
|
+
"tara": 9,
|
|
100
|
+
"goma": 10,
|
|
101
|
+
"ashirin": 20,
|
|
102
|
+
"talatin": 30,
|
|
103
|
+
}
|
|
104
|
+
_HA_TRANSFER_K = re.compile(
|
|
105
|
+
r"(?i)^(?:aika|mika)\s+(\d+)\s*k\s+zuwa\s+(?:ga\s+)?(.+)$",
|
|
106
|
+
)
|
|
107
|
+
_HA_TRANSFER_DUBU_WORD = re.compile(
|
|
108
|
+
r"(?i)^(?:aika|mika)\s+dubu\s+(\w+)\s+zuwa\s+(?:ga\s+)?(.+)$",
|
|
109
|
+
)
|
|
110
|
+
_HA_TRANSFER_DUBU_N = re.compile(
|
|
111
|
+
r"(?i)^(?:aika|mika)\s+dubu\s+(\d+)\s+zuwa\s+(?:ga\s+)?(.+)$",
|
|
112
|
+
)
|
|
113
|
+
_HA_BALANCE = re.compile(
|
|
114
|
+
r"(?i)^(?:"
|
|
115
|
+
r"nawa\s+ne\s+kudi(?:n\s+(?:da\s+ke\s+)?asusun?a?)?\??"
|
|
116
|
+
r"|duba\s+asusun?[ai]?"
|
|
117
|
+
r")\s*$",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Yoruba (yo)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
_YO_TRANSFER_K = re.compile(
|
|
124
|
+
r"(?i)^ran\s+(\d+)\s*k\s+si\s+(.+)$",
|
|
125
|
+
)
|
|
126
|
+
_YO_TRANSFER_NAIRA = re.compile(
|
|
127
|
+
r"^fi\s+[₦#]?(\d{1,3}(?:,\d{3})*|\d+)\s*(?:naira|₦)?\s+ran[sṣ][eẹ]\s+si\s+(.+)$",
|
|
128
|
+
re.IGNORECASE,
|
|
129
|
+
)
|
|
130
|
+
_YO_BALANCE = re.compile(
|
|
131
|
+
r"(?i)^(?:"
|
|
132
|
+
r"m[eẹ]lo\s+ni\s+owo\s+mi\??"
|
|
133
|
+
r"|iy[eẹ]\s+owo\s+mi\s+to\s+m[eẹ]lo\??"
|
|
134
|
+
r")\s*$",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Igbo (ig)
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
_IG_TRANSFER_K = re.compile(
|
|
141
|
+
r"(?i)^(?:zip[uụ]|ziga)\s+ego\s+(\d+)\s*k\s+nye\s+(.+)$",
|
|
142
|
+
)
|
|
143
|
+
_IG_TRANSFER_NAIRA = re.compile(
|
|
144
|
+
r"(?i)^(?:zip[uụ]|ziga)\s+ego\s+(?:₦|ngn\s*)?(\d{1,3}(?:,\d{3})*|\d+)\s*(?:naira)?\s+nye\s+(.+)$",
|
|
145
|
+
)
|
|
146
|
+
_IG_BALANCE = re.compile(
|
|
147
|
+
r"(?i)^(?:"
|
|
148
|
+
r"ego\s+m\s+ole\s+d[iị]\??"
|
|
149
|
+
r"|gwa\s+m\s+ego\s+m\s+nwere"
|
|
150
|
+
r")\s*$",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Bank name extraction
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
_BANK_NAMES: frozenset[str] = frozenset({
|
|
157
|
+
"gtbank", "gtb", "access bank", "access", "first bank", "firstbank",
|
|
158
|
+
"zenith bank", "zenith", "uba", "opay", "kuda", "moniepoint",
|
|
159
|
+
"palmpay", "fcmb", "stanbic ibtc", "stanbic", "fidelity bank",
|
|
160
|
+
"fidelity", "union bank", "sterling bank", "sterling",
|
|
161
|
+
"wema bank", "wema", "alat", "polaris bank", "polaris",
|
|
162
|
+
"ecobank", "eco bank",
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_bank(raw: str) -> tuple[str, str | None]:
|
|
167
|
+
"""Split 'Chidi, GTBank' → ('Chidi', 'GTBank') when bank name is recognised."""
|
|
168
|
+
if "," not in raw:
|
|
169
|
+
return raw, None
|
|
170
|
+
name_part, candidate = raw.rsplit(",", 1)
|
|
171
|
+
if candidate.strip().rstrip(".").lower() in _BANK_NAMES:
|
|
172
|
+
return name_part.strip(), candidate.strip().rstrip(".")
|
|
173
|
+
return raw, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Helpers
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _normalize_heuristic_input(text: str) -> str:
|
|
181
|
+
t = text.strip()
|
|
182
|
+
t = (
|
|
183
|
+
t.replace("’", "'")
|
|
184
|
+
.replace("‘", "'")
|
|
185
|
+
.replace("“", '"')
|
|
186
|
+
.replace("”", '"')
|
|
187
|
+
)
|
|
188
|
+
t = re.sub(r"[ \t\n\r ]+", " ", t)
|
|
189
|
+
return t
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_plain_amount(digits: str) -> float:
|
|
193
|
+
return float(digits.replace(",", ""))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _clean_recipient(name: str) -> str:
|
|
197
|
+
return name.strip().rstrip(",.").strip()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _transfer_result(
|
|
201
|
+
*,
|
|
202
|
+
amount: float | None,
|
|
203
|
+
recipient: str,
|
|
204
|
+
language: Language,
|
|
205
|
+
confidence: float,
|
|
206
|
+
flags: list[str],
|
|
207
|
+
pattern: str,
|
|
208
|
+
) -> OwoResult:
|
|
209
|
+
name, bank = _extract_bank(recipient)
|
|
210
|
+
return OwoResult(
|
|
211
|
+
intent=Intent.TRANSFER,
|
|
212
|
+
amount=amount,
|
|
213
|
+
currency="NGN",
|
|
214
|
+
recipient=_clean_recipient(name),
|
|
215
|
+
account_number=None,
|
|
216
|
+
bank=bank,
|
|
217
|
+
service=None,
|
|
218
|
+
language_detected=language,
|
|
219
|
+
confidence=confidence,
|
|
220
|
+
flags=list(flags),
|
|
221
|
+
raw={"parser": "heuristic", "pattern": pattern},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _balance_result(language: Language, pattern: str) -> OwoResult:
|
|
226
|
+
return OwoResult(
|
|
227
|
+
intent=Intent.BALANCE_CHECK,
|
|
228
|
+
amount=None,
|
|
229
|
+
currency="NGN",
|
|
230
|
+
recipient=None,
|
|
231
|
+
account_number=None,
|
|
232
|
+
bank=None,
|
|
233
|
+
service=None,
|
|
234
|
+
language_detected=language,
|
|
235
|
+
confidence=0.72,
|
|
236
|
+
flags=[],
|
|
237
|
+
raw={"parser": "heuristic", "pattern": pattern},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Main heuristic dispatcher
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def heuristic_parse(text: str) -> OwoResult:
|
|
246
|
+
"""Rule-based parse covering common patterns across all five languages."""
|
|
247
|
+
t = _normalize_heuristic_input(text)
|
|
248
|
+
|
|
249
|
+
# --- Nigerian Pidgin (pcm) ---
|
|
250
|
+
m = _PCM_ABEG_K.match(t)
|
|
251
|
+
if m:
|
|
252
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.PIDGIN, confidence=0.85, flags=[], pattern="pcm_abeg_Nk")
|
|
253
|
+
|
|
254
|
+
m = _PCM_ABEG_NAIRA.match(t)
|
|
255
|
+
if m:
|
|
256
|
+
return _transfer_result(amount=_parse_plain_amount(m.group(1)), recipient=m.group(2), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_abeg_naira")
|
|
257
|
+
|
|
258
|
+
m = _PCM_ABEG_HALF_MILLI.match(t)
|
|
259
|
+
if m:
|
|
260
|
+
return _transfer_result(amount=500_000.0, recipient=m.group(1), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_abeg_half_milli")
|
|
261
|
+
|
|
262
|
+
m = _PCM_ABEG_MILLI.match(t)
|
|
263
|
+
if m:
|
|
264
|
+
return _transfer_result(amount=float(m.group(1)) * 1_000_000, recipient=m.group(2), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_abeg_milli")
|
|
265
|
+
|
|
266
|
+
m = _PCM_GIVE_K.match(t)
|
|
267
|
+
if m:
|
|
268
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.PIDGIN, confidence=0.85, flags=[], pattern="pcm_give_Nk")
|
|
269
|
+
|
|
270
|
+
m = _PCM_GIVE_NAIRA.match(t)
|
|
271
|
+
if m:
|
|
272
|
+
return _transfer_result(amount=_parse_plain_amount(m.group(1)), recipient=m.group(2), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_give_naira")
|
|
273
|
+
|
|
274
|
+
m = _PCM_GIVE_HALF_MILLI.match(t)
|
|
275
|
+
if m:
|
|
276
|
+
return _transfer_result(amount=500_000.0, recipient=m.group(1), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_give_half_milli")
|
|
277
|
+
|
|
278
|
+
m = _PCM_GIVE_MILLI.match(t)
|
|
279
|
+
if m:
|
|
280
|
+
return _transfer_result(amount=float(m.group(1)) * 1_000_000, recipient=m.group(2), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_give_milli")
|
|
281
|
+
|
|
282
|
+
m = _PCM_AM_K.match(t)
|
|
283
|
+
if m:
|
|
284
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.PIDGIN, confidence=0.85, flags=[], pattern="pcm_am_Nk")
|
|
285
|
+
|
|
286
|
+
m = _PCM_AM_NAIRA.match(t)
|
|
287
|
+
if m:
|
|
288
|
+
return _transfer_result(amount=_parse_plain_amount(m.group(1)), recipient=m.group(2), language=Language.PIDGIN, confidence=0.83, flags=[], pattern="pcm_am_naira")
|
|
289
|
+
|
|
290
|
+
m = _PCM_ABEG_MONEY.match(t)
|
|
291
|
+
if m:
|
|
292
|
+
return _transfer_result(amount=None, recipient=m.group(1), language=Language.PIDGIN, confidence=0.61, flags=["missing_amount"], pattern="pcm_abeg_money")
|
|
293
|
+
|
|
294
|
+
m = _PCM_GIVE_MONEY.match(t)
|
|
295
|
+
if m:
|
|
296
|
+
return _transfer_result(amount=None, recipient=m.group(1), language=Language.PIDGIN, confidence=0.61, flags=["missing_amount"], pattern="pcm_give_money")
|
|
297
|
+
|
|
298
|
+
if _PCM_BALANCE.match(t):
|
|
299
|
+
return _balance_result(Language.PIDGIN, "pcm_balance")
|
|
300
|
+
|
|
301
|
+
# --- Hausa (ha) ---
|
|
302
|
+
m = _HA_TRANSFER_K.match(t)
|
|
303
|
+
if m:
|
|
304
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.HAUSA, confidence=0.83, flags=[], pattern="ha_aika_Nk")
|
|
305
|
+
|
|
306
|
+
m = _HA_TRANSFER_DUBU_WORD.match(t)
|
|
307
|
+
if m:
|
|
308
|
+
multiplier = _HA_NUM_WORDS.get(m.group(1).lower())
|
|
309
|
+
if multiplier is not None:
|
|
310
|
+
return _transfer_result(amount=float(multiplier * 1000), recipient=m.group(2), language=Language.HAUSA, confidence=0.80, flags=[], pattern="ha_aika_dubu_word")
|
|
311
|
+
|
|
312
|
+
m = _HA_TRANSFER_DUBU_N.match(t)
|
|
313
|
+
if m:
|
|
314
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.HAUSA, confidence=0.80, flags=[], pattern="ha_aika_dubu_N")
|
|
315
|
+
|
|
316
|
+
if _HA_BALANCE.match(t):
|
|
317
|
+
return _balance_result(Language.HAUSA, "ha_balance")
|
|
318
|
+
|
|
319
|
+
# --- Yoruba (yo) ---
|
|
320
|
+
m = _YO_TRANSFER_K.match(t)
|
|
321
|
+
if m:
|
|
322
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.YORUBA, confidence=0.83, flags=[], pattern="yo_ran_Nk_si")
|
|
323
|
+
|
|
324
|
+
m = _YO_TRANSFER_NAIRA.match(t)
|
|
325
|
+
if m:
|
|
326
|
+
return _transfer_result(amount=_parse_plain_amount(m.group(1)), recipient=m.group(2), language=Language.YORUBA, confidence=0.81, flags=[], pattern="yo_fi_naira_ransẹ_si")
|
|
327
|
+
|
|
328
|
+
if _YO_BALANCE.match(t):
|
|
329
|
+
return _balance_result(Language.YORUBA, "yo_balance")
|
|
330
|
+
|
|
331
|
+
# --- Igbo (ig) ---
|
|
332
|
+
m = _IG_TRANSFER_K.match(t)
|
|
333
|
+
if m:
|
|
334
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.IGBO, confidence=0.83, flags=[], pattern="ig_zipu_ego_Nk_nye")
|
|
335
|
+
|
|
336
|
+
m = _IG_TRANSFER_NAIRA.match(t)
|
|
337
|
+
if m:
|
|
338
|
+
return _transfer_result(amount=_parse_plain_amount(m.group(1)), recipient=m.group(2), language=Language.IGBO, confidence=0.81, flags=[], pattern="ig_zipu_ego_naira_nye")
|
|
339
|
+
|
|
340
|
+
if _IG_BALANCE.match(t):
|
|
341
|
+
return _balance_result(Language.IGBO, "ig_balance")
|
|
342
|
+
|
|
343
|
+
# --- English (en) ---
|
|
344
|
+
m = _EN_TRANSFER_K.match(t)
|
|
345
|
+
if m:
|
|
346
|
+
return _transfer_result(amount=float(m.group(1)) * 1000, recipient=m.group(2), language=Language.ENGLISH, confidence=0.85, flags=[], pattern="en_send_Nk_to")
|
|
347
|
+
|
|
348
|
+
m = _EN_TRANSFER_NAIRA.match(t)
|
|
349
|
+
if m:
|
|
350
|
+
return _transfer_result(amount=_parse_plain_amount(m.group(1)), recipient=m.group(2), language=Language.ENGLISH, confidence=0.83, flags=[], pattern="en_send_naira_to")
|
|
351
|
+
|
|
352
|
+
m = _EN_TRANSFER_HALF_MILLI.match(t)
|
|
353
|
+
if m:
|
|
354
|
+
return _transfer_result(amount=500_000.0, recipient=m.group(1), language=Language.ENGLISH, confidence=0.83, flags=[], pattern="en_send_half_milli_to")
|
|
355
|
+
|
|
356
|
+
m = _EN_TRANSFER_MILLI.match(t)
|
|
357
|
+
if m:
|
|
358
|
+
return _transfer_result(amount=float(m.group(1)) * 1_000_000, recipient=m.group(2), language=Language.ENGLISH, confidence=0.83, flags=[], pattern="en_send_milli_to")
|
|
359
|
+
|
|
360
|
+
m = _EN_TRANSFER_MONEY.match(t)
|
|
361
|
+
if m:
|
|
362
|
+
return _transfer_result(amount=None, recipient=m.group(1), language=Language.ENGLISH, confidence=0.61, flags=["missing_amount"], pattern="en_send_money_to")
|
|
363
|
+
|
|
364
|
+
if _EN_BALANCE.match(t):
|
|
365
|
+
return _balance_result(Language.ENGLISH, "en_balance")
|
|
366
|
+
|
|
367
|
+
return OwoResult(
|
|
368
|
+
intent=Intent.UNKNOWN,
|
|
369
|
+
amount=None,
|
|
370
|
+
currency="NGN",
|
|
371
|
+
recipient=None,
|
|
372
|
+
account_number=None,
|
|
373
|
+
bank=None,
|
|
374
|
+
service=None,
|
|
375
|
+
language_detected=Language.ENGLISH,
|
|
376
|
+
confidence=0.35,
|
|
377
|
+
flags=["needs_llm_provider"],
|
|
378
|
+
raw={"parser": "heuristic", "pattern": "fallback"},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
# Provider path
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def result_from_provider_json(payload: str, source_text: str) -> OwoResult:
|
|
387
|
+
"""Parse JSON from an LLM provider into :class:`OwoResult`."""
|
|
388
|
+
text = payload.strip()
|
|
389
|
+
if text.startswith("```"):
|
|
390
|
+
lines = text.splitlines()
|
|
391
|
+
if lines and lines[0].startswith("```"):
|
|
392
|
+
lines = lines[1:]
|
|
393
|
+
if lines and lines[-1].strip() == "```":
|
|
394
|
+
lines = lines[:-1]
|
|
395
|
+
text = "\n".join(lines).strip()
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
data: dict[str, Any] = json.loads(text)
|
|
399
|
+
except (json.JSONDecodeError, ValueError):
|
|
400
|
+
return OwoResult(
|
|
401
|
+
intent=Intent.UNKNOWN,
|
|
402
|
+
amount=None,
|
|
403
|
+
currency="NGN",
|
|
404
|
+
recipient=None,
|
|
405
|
+
account_number=None,
|
|
406
|
+
bank=None,
|
|
407
|
+
service=None,
|
|
408
|
+
language_detected=Language.ENGLISH,
|
|
409
|
+
confidence=0.0,
|
|
410
|
+
flags=["bad_provider_output"],
|
|
411
|
+
raw={"parser": "provider", "source_text": source_text, "raw_output": payload},
|
|
412
|
+
)
|
|
413
|
+
flags = data.get("flags") or []
|
|
414
|
+
if isinstance(flags, str):
|
|
415
|
+
flags = [flags]
|
|
416
|
+
raw_intent = data.get("intent")
|
|
417
|
+
try:
|
|
418
|
+
intent = Intent(raw_intent) if raw_intent is not None else Intent.BALANCE_CHECK
|
|
419
|
+
except ValueError:
|
|
420
|
+
intent = Intent.UNKNOWN
|
|
421
|
+
try:
|
|
422
|
+
language = Language(data.get("language_detected") or Language.ENGLISH)
|
|
423
|
+
except ValueError:
|
|
424
|
+
language = Language.ENGLISH
|
|
425
|
+
return OwoResult(
|
|
426
|
+
intent=intent,
|
|
427
|
+
amount=data.get("amount"),
|
|
428
|
+
currency=str(data.get("currency") or "NGN"),
|
|
429
|
+
recipient=data.get("recipient"),
|
|
430
|
+
account_number=data.get("account_number"),
|
|
431
|
+
bank=data.get("bank"),
|
|
432
|
+
service=data.get("service"),
|
|
433
|
+
language_detected=language,
|
|
434
|
+
confidence=float(data.get("confidence") or 0.5),
|
|
435
|
+
flags=list(flags),
|
|
436
|
+
raw={"parser": "provider", "source_text": source_text, "llm": data},
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
owo/_prompt.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SYSTEM_PROMPT = """\
|
|
4
|
+
You are a strict JSON emitter for Nigerian financial intents.
|
|
5
|
+
Reply with a single JSON object only — no markdown, no explanation.
|
|
6
|
+
|
|
7
|
+
Required keys: intent, amount, currency, recipient, account_number, bank, service,
|
|
8
|
+
language_detected, confidence, flags (array of strings).
|
|
9
|
+
|
|
10
|
+
intent must be one of:
|
|
11
|
+
transfer – send money to a person or account
|
|
12
|
+
bill_pay – pay a utility or subscription (DSTV, NEPA/EKEDC, internet, etc.)
|
|
13
|
+
buy_airtime – recharge airtime / credit for a phone number
|
|
14
|
+
buy_data – buy a mobile data bundle
|
|
15
|
+
crypto_sell – sell or convert cryptocurrency
|
|
16
|
+
balance_check – check account balance
|
|
17
|
+
unknown – use when the intent cannot be determined
|
|
18
|
+
|
|
19
|
+
language_detected must be one of: en, pcm, yo, ha, ig
|
|
20
|
+
currency is almost always NGN unless the user specifies otherwise.
|
|
21
|
+
confidence is a float 0.0–1.0 reflecting certainty.
|
|
22
|
+
flags is a list of strings for issues: missing_amount, missing_recipient, ambiguous_intent, etc.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
{"input":"Buy 1GB data for 08012345678 on MTN","output":{"intent":"buy_data","amount":null,"currency":"NGN","recipient":null,"account_number":"08012345678","bank":null,"service":"MTN","language_detected":"en","confidence":0.92,"flags":[]}}
|
|
26
|
+
{"input":"Recharge 500 naira for 08055559999","output":{"intent":"buy_airtime","amount":500.0,"currency":"NGN","recipient":null,"account_number":"08055559999","bank":null,"service":null,"language_detected":"en","confidence":0.90,"flags":[]}}
|
|
27
|
+
{"input":"Pay my DSTV subscription ₦4500","output":{"intent":"bill_pay","amount":4500.0,"currency":"NGN","recipient":null,"account_number":null,"bank":null,"service":"DSTV","language_detected":"en","confidence":0.91,"flags":[]}}
|
|
28
|
+
{"input":"Sell 0.05 BTC","output":{"intent":"crypto_sell","amount":0.05,"currency":"BTC","recipient":null,"account_number":null,"bank":null,"service":null,"language_detected":"en","confidence":0.88,"flags":[]}}
|
|
29
|
+
{"input":"Abeg buy me 2GB data for 09031112222 on Airtel","output":{"intent":"buy_data","amount":null,"currency":"NGN","recipient":null,"account_number":"09031112222","bank":null,"service":"Airtel","language_detected":"pcm","confidence":0.91,"flags":[]}}
|
|
30
|
+
{"input":"Ra airtime ₦200 fun 08099998888","output":{"intent":"buy_airtime","amount":200.0,"currency":"NGN","recipient":null,"account_number":"08099998888","bank":null,"service":null,"language_detected":"yo","confidence":0.87,"flags":[]}}
|
|
31
|
+
{"input":"Biya DSTV bill ₦6000","output":{"intent":"bill_pay","amount":6000.0,"currency":"NGN","recipient":null,"account_number":null,"bank":null,"service":"DSTV","language_detected":"ha","confidence":0.83,"flags":[]}}
|
|
32
|
+
{"input":"Zere ego crypto BTC 0.1","output":{"intent":"crypto_sell","amount":0.1,"currency":"BTC","recipient":null,"account_number":null,"bank":null,"service":null,"language_detected":"ig","confidence":0.82,"flags":[]}}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_user_message(user_text: str) -> str:
|
|
37
|
+
"""Return the user-turn content for a parse request."""
|
|
38
|
+
return f"User said:\n{user_text!r}\n"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parse_prompt(user_text: str) -> str:
|
|
42
|
+
"""Single-string prompt for providers that don't use system messages."""
|
|
43
|
+
return SYSTEM_PROMPT + "\n" + build_user_message(user_text)
|
owo/eval/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Eval runner package (fixtures live under ``owo.eval.fixtures``)."""
|