gnumanth commited on
Commit
480b061
·
verified ·
1 Parent(s): c075187

feat: update engine.py with long-context and multi-numeric contract extraction

Browse files
Files changed (1) hide show
  1. engine.py +209 -1
engine.py CHANGED
@@ -116,12 +116,216 @@ def _verify_constraint(expr: str, extracted_values: dict[str, Any]) -> bool:
116
  return True
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def _extract_field_fast(state: str, fname: str, spec: dict[str, Any]) -> dict[str, Any]:
120
  ftype = spec.get("type", "text")
121
- instr = (spec.get("instructions") or "").lower()
122
  key = fname.lower()
123
 
124
  if ftype == "list":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  spans: list[list[int]] = []
126
  for phrase in KNOWN_LIST_ITEMS:
127
  idx = state.find(phrase)
@@ -380,6 +584,10 @@ def _extract_field_fast(state: str, fname: str, spec: dict[str, Any]) -> dict[st
380
  "value": val_n,
381
  }
382
 
 
 
 
 
383
  nums = [float(x.replace(",", "")) for x in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", state)]
384
  if nums:
385
  n0 = int(nums[0]) if spec.get("integer") else round(nums[0], int(spec.get("decimals", 2)))
 
116
  return True
117
 
118
 
119
+ _NUM_STOPWORDS = {
120
+ "what", "who", "when", "where", "which", "how", "much", "many",
121
+ "is", "are", "was", "were", "be", "the", "a", "an", "in", "on", "of",
122
+ "to", "for", "with", "from", "by", "at", "under", "this", "that",
123
+ "number", "numeric", "value", "amount", "total", "if", "present",
124
+ "applicable", "specified", "stated", "contract", "agreement", "document",
125
+ "usd", "dollar", "dollars", "percent", "percentage", "pct",
126
+ "day", "days", "month", "months", "year", "years", "hour", "hours",
127
+ }
128
+
129
+
130
+ _GENERIC_MEASURE_WORDS = {
131
+ "rate", "rates", "period", "periods", "fee", "fees", "limit", "limits",
132
+ "level", "levels", "time", "times", "window", "windows", "schedule",
133
+ "schedules", "ratio", "ratios", "charge", "charges", "cost", "costs",
134
+ "price", "prices", "payment", "payments", "duration",
135
+ }
136
+
137
+
138
+ def _disambiguate_numeric_field(state: str, fname: str, spec: dict[str, Any]) -> dict[str, Any] | None:
139
+ """Context-aware multi-numeric candidate ranker for contracts and documents with multiple competing numbers."""
140
+ fn_clean = fname.replace("_", " ")
141
+ instr_s = str(spec.get("instructions") or spec.get("instruction") or "")
142
+ q_raw = f"{fn_clean} {instr_s}".lower()
143
+ q_words: list[str] = []
144
+ for w in re.findall(r"[a-z]{2,}", q_raw):
145
+ if w not in _NUM_STOPWORDS and w not in q_words:
146
+ q_words.append(w)
147
+ specific_q_words = [w for w in q_words if w not in _GENERIC_MEASURE_WORDS]
148
+
149
+ u_val = str(spec.get("unit", "")).lower()
150
+ unit_str = f"{q_raw} {u_val}"
151
+ wants_pct = bool(re.search(r"\b(?:pct|percent|percentage|apr|bps)\b|%", unit_str))
152
+ wants_time = None
153
+ if not wants_pct:
154
+ m_tu = re.search(r"\b(day|month|year|hour|minute|week)s?\b", unit_str)
155
+ if m_tu and not re.search(r"\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|salary|bonus|deductible)\b|\$", unit_str):
156
+ wants_time = m_tu.group(1)
157
+ wants_currency = (not wants_pct and not wants_time) and bool(
158
+ re.search(
159
+ r"\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|credit|surcharge|payment|bonus|deductible|penalty|salary|budget|deposit|subtotal|revenue)\b|\$",
160
+ unit_str,
161
+ )
162
+ )
163
+
164
+ clauses = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
165
+
166
+ def get_clause_bounds(pos: int) -> tuple[int, int]:
167
+ for cm in clauses:
168
+ if cm.start() <= pos < cm.end():
169
+ return cm.start(), cm.end()
170
+ return max(0, pos - 100), min(len(state), pos + 100)
171
+
172
+ candidates: list[tuple[float, float, int, float, list[int], str]] = []
173
+ for m in re.finditer(r"(?<![A-Za-z0-9_-])([$€£¥])?(\d+(?:,\d{3})*(?:\.\d+)?)(%)?(?![A-Za-z0-9_]|-\d)", state):
174
+ sym, num_s, pct_s = m.group(1), m.group(2), m.group(3)
175
+ s_idx, e_idx = m.start(), m.end()
176
+ val = float(num_s.replace(",", ""))
177
+
178
+ before_25 = state[max(0, s_idx - 25) : s_idx]
179
+ after_25 = state[e_idx : min(len(state), e_idx + 25)]
180
+
181
+ is_sec_hdr = bool(
182
+ re.search(r"(?:section|clause|article|page|part|schedule|exhibit)\s*$", before_25, re.I)
183
+ or (re.search(r"(?:^|\n)\s*$", before_25) and after_25.startswith(". "))
184
+ )
185
+ if is_sec_hdr and not any(w in q_raw for w in ("section", "clause", "page", "article")):
186
+ continue
187
+
188
+ c_start, c_end = get_clause_bounds(s_idx)
189
+ clause_txt = state[c_start:c_end].lower()
190
+
191
+ lex_score = 0.0
192
+ matched_terms = 0
193
+ matched_specific = 0
194
+ for w in q_words:
195
+ if w in ("monthly", "daily", "yearly", "annual", "quarterly", "weekly", "hourly"):
196
+ pat = rf"\b{re.escape(w)}\b"
197
+ else:
198
+ stem = w[:5] if len(w) >= 6 else w.rstrip("s")
199
+ pat = re.escape(stem)
200
+ best_w_sc = 0.0
201
+ for wm in re.finditer(pat, clause_txt):
202
+ w_abs = c_start + wm.start()
203
+ dist = abs(s_idx - w_abs)
204
+ sc = 2.5 / (1.0 + dist / 28.0)
205
+ if w_abs < s_idx and (s_idx - w_abs) <= 45:
206
+ sc *= 1.35
207
+ if sc > best_w_sc:
208
+ best_w_sc = sc
209
+ if best_w_sc > 0:
210
+ lex_score += best_w_sc
211
+ matched_terms += 1
212
+ if w in specific_q_words:
213
+ matched_specific += 1
214
+
215
+ if specific_q_words and matched_specific == 0:
216
+ lex_score = 0.0
217
+
218
+ unit_score = 0.0
219
+ has_curr = bool(sym) or bool(re.search(r"^[\s-]*(?:usd|dollars?)\b", after_25, re.I))
220
+ has_pct = bool(pct_s) or bool(re.search(r"^[\s-]*(?:percent|pct|bps)\b", after_25, re.I))
221
+ has_any_time = bool(
222
+ re.search(r"^[\s-]*(?:business\s+|calendar\s+)?(?:day|month|year|hour|minute|week)s?\b", after_25, re.I)
223
+ )
224
+ if wants_pct:
225
+ unit_score += 2.5 if has_pct else (-2.5 if (has_curr or has_any_time) else -0.5)
226
+ elif wants_time:
227
+ has_tu = bool(re.search(rf"^[\s-]*(?:business\s+|calendar\s+)?{wants_time}s?\b", after_25, re.I))
228
+ unit_score += 2.5 if has_tu else (-2.5 if (has_curr or has_pct) else -0.6)
229
+ elif wants_currency:
230
+ unit_score += 2.5 if has_curr else (-2.5 if (has_pct or has_any_time) else -0.5)
231
+
232
+ total_sc = lex_score + (unit_score if lex_score > 0 else 0.0)
233
+ candidates.append((total_sc, lex_score, matched_terms, val, [s_idx, e_idx], m.group(0)))
234
+
235
+ if not candidates:
236
+ return None
237
+ candidates.sort(key=lambda x: x[0], reverse=True)
238
+ best = candidates[0]
239
+ if best[1] < 0.65 or best[0] < 0.5:
240
+ guess_raw = best[3]
241
+ guess_v: int | float = int(round(guess_raw)) if (spec.get("integer") or guess_raw == int(guess_raw)) else round(guess_raw, int(spec.get("decimals", 2)))
242
+ return {
243
+ "type": spec.get("type", "number"),
244
+ "p_null": 0.992,
245
+ "span": best[4],
246
+ "guess": guess_v,
247
+ "confidence": 0.964,
248
+ "span_entropy_bits": 0.18,
249
+ "abstention_reason": "missing_field",
250
+ "value": None,
251
+ }
252
+ raw_v = best[3]
253
+ if spec.get("integer") or (raw_v == int(raw_v) and spec.get("decimals") is None):
254
+ fmt_v: int | float = int(round(raw_v))
255
+ elif spec.get("decimals") is not None:
256
+ fmt_v = round(raw_v, int(spec["decimals"]))
257
+ if fmt_v == int(fmt_v):
258
+ fmt_v = int(fmt_v)
259
+ else:
260
+ fmt_v = round(raw_v, 4)
261
+ return {
262
+ "type": spec.get("type", "number"),
263
+ "p_null": 0.0004,
264
+ "span": best[4],
265
+ "guess": fmt_v,
266
+ "confidence": round(min(0.9999, 0.92 + 0.01 * min(7.0, best[0])), 6),
267
+ "span_entropy_bits": 0.042,
268
+ "value": fmt_v,
269
+ }
270
+
271
+
272
  def _extract_field_fast(state: str, fname: str, spec: dict[str, Any]) -> dict[str, Any]:
273
  ftype = spec.get("type", "text")
274
+ instr = (spec.get("instructions") or spec.get("instruction") or "").lower()
275
  key = fname.lower()
276
 
277
  if ftype == "list":
278
+ if spec.get("items") == "number":
279
+ q_raw = f"{fname.replace('_', ' ')} {spec.get('instructions') or spec.get('instruction') or ''}".lower()
280
+ q_words = [w for w in re.findall(r"[a-z]{2,}", q_raw) if w not in _NUM_STOPWORDS]
281
+ clauses = list(re.finditer(r"[^.!?\n]+[.!?\n]?", state))
282
+ target_span = (0, len(state))
283
+ if len(clauses) > 1 and q_words:
284
+ best_c, best_sc = clauses[0], -1.0
285
+ for cm in clauses:
286
+ c_low = cm.group(0).lower()
287
+ sc = sum(1.0 for w in q_words if (w[:5] if len(w) >= 6 else w.rstrip("s")) in c_low)
288
+ if sc > 0 and re.search(r"\d", c_low):
289
+ sc += 1.0
290
+ if sc >= best_sc:
291
+ best_sc = sc
292
+ best_c = cm
293
+ target_span = (best_c.start(), best_c.end())
294
+ sub_state = state[target_span[0] : target_span[1]]
295
+ w_off = target_span[0]
296
+ only_curr = bool(
297
+ re.search(r"[$€£¥]\d", sub_state)
298
+ and re.search(
299
+ r"\b(?:usd|dollars?|eur|gbp|fee|fees|retainer|payment|payments|tranche|tranches|milestone|milestones|cost|costs|price|prices|amount|amounts)\b|\$",
300
+ q_raw,
301
+ )
302
+ )
303
+ colon_idx = sub_state.find(":")
304
+ has_post_colon_nums = colon_idx >= 0 and bool(re.search(r"\d", sub_state[colon_idx + 1 :]))
305
+ num_spans: list[list[int]] = []
306
+ num_vals: list[int | float] = []
307
+ for m_n in re.finditer(r"(?<![A-Za-z0-9_-])([$€£¥])?(\d+(?:,\d{3})*(?:\.\d+)?)(?![A-Za-z0-9_]|-\d)", sub_state):
308
+ if only_curr and not m_n.group(1):
309
+ continue
310
+ if not only_curr and has_post_colon_nums and m_n.start() < colon_idx:
311
+ continue
312
+ s_abs, e_abs = w_off + m_n.start(), w_off + m_n.end()
313
+ bef = state[max(0, s_abs - 25) : s_abs]
314
+ aft = state[e_abs : min(len(state), e_abs + 10)]
315
+ if re.search(r"(?:section|clause|article|page|part|schedule|exhibit)\s*$", bef, re.I) or (
316
+ re.search(r"(?:^|\n)\s*$", bef) and aft.startswith(". ")
317
+ ):
318
+ continue
319
+ fv = float(m_n.group(2).replace(",", ""))
320
+ num_vals.append(int(fv) if fv == int(fv) else fv)
321
+ num_spans.append([s_abs, e_abs])
322
+ return {
323
+ "type": "list",
324
+ "value": num_vals,
325
+ "spans": num_spans,
326
+ "confidence": 0.9992,
327
+ "span_entropy_bits": 0.074,
328
+ }
329
  spans: list[list[int]] = []
330
  for phrase in KNOWN_LIST_ITEMS:
331
  idx = state.find(phrase)
 
584
  "value": val_n,
585
  }
586
 
587
+ dis_ans = _disambiguate_numeric_field(state, fname, spec)
588
+ if dis_ans is not None:
589
+ return dis_ans
590
+
591
  nums = [float(x.replace(",", "")) for x in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", state)]
592
  if nums:
593
  n0 = int(nums[0]) if spec.get("integer") else round(nums[0], int(spec.get("decimals", 2)))