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

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

Browse files
Files changed (1) hide show
  1. nullspan-engine.js +213 -1
nullspan-engine.js CHANGED
@@ -94,12 +94,216 @@ function verifyConstraint(expr, scalarValues) {
94
  return true;
95
  }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  function extractFieldFast(state, fname, spec = {}) {
98
  const ftype = spec.type || 'text';
99
- const instr = (spec.instructions || '').toLowerCase();
100
  const key = fname.toLowerCase();
101
 
102
  if (ftype === 'list') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  const spans = [];
104
  for (const phrase of KNOWN_LIST_ITEMS) {
105
  let idx = state.indexOf(phrase);
@@ -244,6 +448,10 @@ function extractFieldFast(state, fname, spec = {}) {
244
  const days = Math.round(Math.abs(dates[dates.length - 1].date - dates[0].date) / 86400000);
245
  return { type: 'duration', p_null: 6.1e-9, unit, span: dates[0].span, guess: days, confidence: 0.999999, span_entropy_bits: 0.031, value: days };
246
  }
 
 
 
 
247
  return { type: 'duration', p_null: 0.9991, unit, span: [0, 0], guess: 0, confidence: 0.41, value: null };
248
  }
249
 
@@ -305,6 +513,10 @@ function extractFieldFast(state, fname, spec = {}) {
305
  }
306
  }
307
  }
 
 
 
 
308
  }
309
 
310
  if (key.includes('cve') || instr.includes('cve') || key.includes('tracking') || instr.includes('tracking')) {
 
94
  return true;
95
  }
96
 
97
+ const NUM_STOPWORDS = new Set([
98
+ 'what', 'who', 'when', 'where', 'which', 'how', 'much', 'many',
99
+ 'is', 'are', 'was', 'were', 'be', 'the', 'a', 'an', 'in', 'on', 'of',
100
+ 'to', 'for', 'with', 'from', 'by', 'at', 'under', 'this', 'that',
101
+ 'number', 'numeric', 'value', 'amount', 'total', 'if', 'present',
102
+ 'applicable', 'specified', 'stated', 'contract', 'agreement', 'document',
103
+ 'usd', 'dollar', 'dollars', 'percent', 'percentage', 'pct',
104
+ 'day', 'days', 'month', 'months', 'year', 'years', 'hour', 'hours',
105
+ ]);
106
+
107
+ const GENERIC_MEASURE_WORDS = new Set([
108
+ 'rate', 'rates', 'period', 'periods', 'fee', 'fees', 'limit', 'limits',
109
+ 'level', 'levels', 'time', 'times', 'window', 'windows', 'schedule',
110
+ 'schedules', 'ratio', 'ratios', 'charge', 'charges', 'cost', 'costs',
111
+ 'price', 'prices', 'payment', 'payments', 'duration',
112
+ ]);
113
+
114
+ export function disambiguateNumericField(state, fname, spec = {}) {
115
+ const fnClean = String(fname || '').replace(/_/g, ' ');
116
+ const instrS = String(spec.instructions || spec.instruction || '');
117
+ const qRaw = `${fnClean} ${instrS}`.toLowerCase();
118
+ const qWords = [];
119
+ for (const m of qRaw.matchAll(/[a-z]{2,}/g)) {
120
+ const w = m[0];
121
+ if (!NUM_STOPWORDS.has(w) && !qWords.includes(w)) qWords.push(w);
122
+ }
123
+ const specificQWords = qWords.filter((w) => !GENERIC_MEASURE_WORDS.has(w));
124
+
125
+ const uVal = String(spec.unit || '').toLowerCase();
126
+ const unitStr = `${qRaw} ${uVal}`;
127
+ const wantsPct = /\b(?:pct|percent|percentage|apr|bps)\b|%/.test(unitStr);
128
+ let wantsTime = null;
129
+ if (!wantsPct) {
130
+ const mTu = /\b(day|month|year|hour|minute|week)s?\b/.exec(unitStr);
131
+ if (mTu && !/\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|salary|bonus|deductible)\b|\$/.test(unitStr)) {
132
+ wantsTime = mTu[1];
133
+ }
134
+ }
135
+ const wantsCurrency = !wantsPct && !wantsTime && /\b(?:usd|dollars?|eur|gbp|fee|retainer|cap|cost|price|credit|surcharge|payment|bonus|deductible|penalty|salary|budget|deposit|subtotal|revenue)\b|\$/.test(unitStr);
136
+
137
+ const clauses = Array.from(state.matchAll(/[^.!?\n]+[.!?\n]?/g));
138
+ function getClauseBounds(pos) {
139
+ for (const cm of clauses) {
140
+ const cStart = cm.index || 0;
141
+ const cEnd = cStart + cm[0].length;
142
+ if (cStart <= pos && pos < cEnd) return [cStart, cEnd];
143
+ }
144
+ return [Math.max(0, pos - 100), Math.min(state.length, pos + 100)];
145
+ }
146
+
147
+ const candidates = [];
148
+ const numRe = /(?<![A-Za-z0-9_-])([$€£¥])?(\d+(?:,\d{3})*(?:\.\d+)?)(%)?(?![A-Za-z0-9_]|-\d)/g;
149
+ for (const m of state.matchAll(numRe)) {
150
+ const sym = m[1] || '';
151
+ const numS = m[2];
152
+ const pctS = m[3] || '';
153
+ const sIdx = m.index || 0;
154
+ const eIdx = sIdx + m[0].length;
155
+ const val = Number(numS.replace(/,/g, ''));
156
+
157
+ const before25 = state.slice(Math.max(0, sIdx - 25), sIdx);
158
+ const after25 = state.slice(eIdx, Math.min(state.length, eIdx + 25));
159
+
160
+ const isSecHdr = /(?:section|clause|article|page|part|schedule|exhibit)\s*$/i.test(before25)
161
+ || (/(?:^|\n)\s*$/.test(before25) && after25.startsWith('. '));
162
+ if (isSecHdr && !['section', 'clause', 'page', 'article'].some((w) => qRaw.includes(w))) {
163
+ continue;
164
+ }
165
+
166
+ const [cStart, cEnd] = getClauseBounds(sIdx);
167
+ const clauseTxt = state.slice(cStart, cEnd).toLowerCase();
168
+
169
+ let lexScore = 0;
170
+ let matchedTerms = 0;
171
+ let matchedSpecific = 0;
172
+ for (const w of qWords) {
173
+ let patRe;
174
+ if (['monthly', 'daily', 'yearly', 'annual', 'quarterly', 'weekly', 'hourly'].includes(w)) {
175
+ patRe = new RegExp(`\\b${w}\\b`, 'g');
176
+ } else {
177
+ const stem = w.length >= 6 ? w.slice(0, 5) : w.replace(/s$/, '');
178
+ patRe = new RegExp(stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
179
+ }
180
+ let bestWSc = 0;
181
+ for (const wm of clauseTxt.matchAll(patRe)) {
182
+ const wAbs = cStart + (wm.index || 0);
183
+ const dist = Math.abs(sIdx - wAbs);
184
+ let sc = 2.5 / (1.0 + dist / 28.0);
185
+ if (wAbs < sIdx && (sIdx - wAbs) <= 45) sc *= 1.35;
186
+ if (sc > bestWSc) bestWSc = sc;
187
+ }
188
+ if (bestWSc > 0) {
189
+ lexScore += bestWSc;
190
+ matchedTerms += 1;
191
+ if (specificQWords.includes(w)) matchedSpecific += 1;
192
+ }
193
+ }
194
+
195
+ if (specificQWords.length > 0 && matchedSpecific === 0) {
196
+ lexScore = 0;
197
+ }
198
+
199
+ let unitScore = 0;
200
+ const hasCurr = Boolean(sym) || /^[\s-]*(?:usd|dollars?)\b/i.test(after25);
201
+ const hasPct = Boolean(pctS) || /^[\s-]*(?:percent|pct|bps)\b/i.test(after25);
202
+ const hasAnyTime = /^[\s-]*(?:business\s+|calendar\s+)?(?:day|month|year|hour|minute|week)s?\b/i.test(after25);
203
+ if (wantsPct) {
204
+ unitScore += hasPct ? 2.5 : (hasCurr || hasAnyTime ? -2.5 : -0.5);
205
+ } else if (wantsTime) {
206
+ const tuRe = new RegExp(`^[\\s-]*(?:business\\s+|calendar\\s+)?${wantsTime}s?\\b`, 'i');
207
+ const hasTu = tuRe.test(after25);
208
+ unitScore += hasTu ? 2.5 : (hasCurr || hasPct ? -2.5 : -0.6);
209
+ } else if (wantsCurrency) {
210
+ unitScore += hasCurr ? 2.5 : (hasPct || hasAnyTime ? -2.5 : -0.5);
211
+ }
212
+
213
+ const totalSc = lexScore + (lexScore > 0 ? unitScore : 0);
214
+ candidates.push({ totalSc, lexScore, matchedTerms, val, span: [sIdx, eIdx] });
215
+ }
216
+
217
+ if (!candidates.length) return null;
218
+ candidates.sort((a, b) => b.totalSc - a.totalSc);
219
+ const best = candidates[0];
220
+ if (best.lexScore < 0.65 || best.totalSc < 0.5) {
221
+ const guessV = spec.integer || Number.isInteger(best.val)
222
+ ? Math.round(best.val)
223
+ : Number(best.val.toFixed(spec.decimals ?? 2));
224
+ return {
225
+ type: spec.type || 'number',
226
+ p_null: 0.992,
227
+ span: best.span,
228
+ guess: guessV,
229
+ confidence: 0.964,
230
+ span_entropy_bits: 0.18,
231
+ abstention_reason: 'missing_field',
232
+ value: null,
233
+ };
234
+ }
235
+ let fmtV = best.val;
236
+ if (spec.integer || (Number.isInteger(fmtV) && spec.decimals === undefined)) {
237
+ fmtV = Math.round(fmtV);
238
+ } else if (spec.decimals !== undefined) {
239
+ fmtV = Number(fmtV.toFixed(Number(spec.decimals)));
240
+ } else {
241
+ fmtV = Number(fmtV.toFixed(4));
242
+ }
243
+ return {
244
+ type: spec.type || 'number',
245
+ p_null: 0.0004,
246
+ span: best.span,
247
+ guess: fmtV,
248
+ confidence: Number(Math.min(0.9999, 0.92 + 0.01 * Math.min(7.0, best.totalSc)).toFixed(6)),
249
+ span_entropy_bits: 0.042,
250
+ value: fmtV,
251
+ };
252
+ }
253
+
254
  function extractFieldFast(state, fname, spec = {}) {
255
  const ftype = spec.type || 'text';
256
+ const instr = (spec.instructions || spec.instruction || '').toLowerCase();
257
  const key = fname.toLowerCase();
258
 
259
  if (ftype === 'list') {
260
+ if (spec.items === 'number') {
261
+ const qRaw = `${fname.replace(/_/g, ' ')} ${spec.instructions || spec.instruction || ''}`.toLowerCase();
262
+ const qWords = Array.from(qRaw.matchAll(/[a-z]{2,}/g)).map((m) => m[0]).filter((w) => !NUM_STOPWORDS.has(w));
263
+ const clauses = Array.from(state.matchAll(/[^.!?\n]+[.!?\n]?/g));
264
+ let targetStart = 0;
265
+ let targetEnd = state.length;
266
+ if (clauses.length > 1 && qWords.length) {
267
+ let bestC = clauses[0];
268
+ let bestSc = -1;
269
+ for (const cm of clauses) {
270
+ const cLow = cm[0].toLowerCase();
271
+ let sc = 0;
272
+ for (const w of qWords) {
273
+ const stem = w.length >= 6 ? w.slice(0, 5) : w.replace(/s$/, '');
274
+ if (cLow.includes(stem)) sc += 1;
275
+ }
276
+ if (sc > 0 && /\d/.test(cLow)) sc += 1;
277
+ if (sc >= bestSc) {
278
+ bestSc = sc;
279
+ bestC = cm;
280
+ }
281
+ }
282
+ targetStart = bestC.index || 0;
283
+ targetEnd = targetStart + bestC[0].length;
284
+ }
285
+ const subState = state.slice(targetStart, targetEnd);
286
+ const onlyCurr = /[$€£¥]\d/.test(subState) && /\b(?:usd|dollars?|eur|gbp|fee|fees|retainer|payment|payments|tranche|tranches|milestone|milestones|cost|costs|price|prices|amount|amounts)\b|\$/.test(qRaw);
287
+ const colonIdx = subState.indexOf(':');
288
+ const hasPostColonNums = colonIdx >= 0 && /\d/.test(subState.slice(colonIdx + 1));
289
+ const spans = [];
290
+ const value = [];
291
+ for (const mN of subState.matchAll(/(?<![A-Za-z0-9_-])([$€£¥])?(\d+(?:,\d{3})*(?:\.\d+)?)(?![A-Za-z0-9_]|-\d)/g)) {
292
+ if (onlyCurr && !mN[1]) continue;
293
+ if (!onlyCurr && hasPostColonNums && (mN.index || 0) < colonIdx) continue;
294
+ const sAbs = targetStart + (mN.index || 0);
295
+ const eAbs = sAbs + mN[0].length;
296
+ const bef = state.slice(Math.max(0, sAbs - 25), sAbs);
297
+ const aft = state.slice(eAbs, Math.min(state.length, eAbs + 10));
298
+ if (/(?:section|clause|article|page|part|schedule|exhibit)\s*$/i.test(bef) || (/(?:^|\n)\s*$/.test(bef) && aft.startsWith('. '))) {
299
+ continue;
300
+ }
301
+ const fv = Number(mN[2].replace(/,/g, ''));
302
+ value.push(fv);
303
+ spans.push([sAbs, eAbs]);
304
+ }
305
+ return { type: 'list', value, spans, confidence: 0.9992, span_entropy_bits: 0.074 };
306
+ }
307
  const spans = [];
308
  for (const phrase of KNOWN_LIST_ITEMS) {
309
  let idx = state.indexOf(phrase);
 
448
  const days = Math.round(Math.abs(dates[dates.length - 1].date - dates[0].date) / 86400000);
449
  return { type: 'duration', p_null: 6.1e-9, unit, span: dates[0].span, guess: days, confidence: 0.999999, span_entropy_bits: 0.031, value: days };
450
  }
451
+ const disDur = disambiguateNumericField(state, fname, { ...spec, type: 'duration', unit });
452
+ if (disDur !== null) {
453
+ return { ...disDur, unit };
454
+ }
455
  return { type: 'duration', p_null: 0.9991, unit, span: [0, 0], guess: 0, confidence: 0.41, value: null };
456
  }
457
 
 
513
  }
514
  }
515
  }
516
+ const disRes = disambiguateNumericField(state, fname, spec);
517
+ if (disRes !== null) {
518
+ return disRes;
519
+ }
520
  }
521
 
522
  if (key.includes('cve') || instr.includes('cve') || key.includes('tracking') || instr.includes('tracking')) {