feat: update engine.py with long-context and multi-numeric contract extraction
Browse files
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)))
|