langparse 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.
Files changed (101) hide show
  1. langparse/__init__.py +55 -0
  2. langparse/autoparser.py +25 -0
  3. langparse/chunkers/__init__.py +12 -0
  4. langparse/chunkers/blocks.py +151 -0
  5. langparse/chunkers/profiles.py +53 -0
  6. langparse/chunkers/registry.py +38 -0
  7. langparse/chunkers/semantic.py +242 -0
  8. langparse/chunkers/text.py +96 -0
  9. langparse/chunkers/workbook.py +942 -0
  10. langparse/cli.py +329 -0
  11. langparse/config.py +169 -0
  12. langparse/core/__init__.py +0 -0
  13. langparse/core/chunker.py +16 -0
  14. langparse/core/engine.py +37 -0
  15. langparse/core/parser.py +35 -0
  16. langparse/core/rendering.py +49 -0
  17. langparse/engines/__init__.py +1 -0
  18. langparse/engines/pdf/__init__.py +1 -0
  19. langparse/engines/pdf/deepdoc/__init__.py +55 -0
  20. langparse/engines/pdf/deepdoc/layout_recognizer.py +235 -0
  21. langparse/engines/pdf/deepdoc/model_loader.py +101 -0
  22. langparse/engines/pdf/deepdoc/ocr.py +641 -0
  23. langparse/engines/pdf/deepdoc/operators.py +684 -0
  24. langparse/engines/pdf/deepdoc/pdf_parser.py +1894 -0
  25. langparse/engines/pdf/deepdoc/postprocess.py +339 -0
  26. langparse/engines/pdf/deepdoc/recognizer.py +418 -0
  27. langparse/engines/pdf/deepdoc/rendering.py +210 -0
  28. langparse/engines/pdf/deepdoc/table_structure_recognizer.py +559 -0
  29. langparse/engines/pdf/deepdoc/tokenizer.py +30 -0
  30. langparse/engines/pdf/deepdoc/utils.py +36 -0
  31. langparse/engines/pdf/deepdoc_engine.py +164 -0
  32. langparse/engines/pdf/mineru.py +259 -0
  33. langparse/engines/pdf/mineru_client.py +318 -0
  34. langparse/engines/pdf/mineru_service.py +225 -0
  35. langparse/engines/pdf/ocr.py +101 -0
  36. langparse/engines/pdf/other.py +20 -0
  37. langparse/engines/pdf/simple.py +134 -0
  38. langparse/engines/pdf/vision_llm.py +27 -0
  39. langparse/errors.py +70 -0
  40. langparse/logging.py +27 -0
  41. langparse/metrics.py +129 -0
  42. langparse/parsers/__init__.py +0 -0
  43. langparse/parsers/docx_parser.py +114 -0
  44. langparse/parsers/excel_parser.py +220 -0
  45. langparse/parsers/markdown_parser.py +34 -0
  46. langparse/parsers/pdf_parser.py +31 -0
  47. langparse/parsers/registry.py +48 -0
  48. langparse/parsers/sniff.py +72 -0
  49. langparse/progress.py +77 -0
  50. langparse/py.typed +0 -0
  51. langparse/services/__init__.py +11 -0
  52. langparse/services/batch_service.py +339 -0
  53. langparse/services/benchmark_service.py +202 -0
  54. langparse/services/fidelity.py +154 -0
  55. langparse/services/output_paths.py +86 -0
  56. langparse/services/parse_service.py +523 -0
  57. langparse/services/quality.py +65 -0
  58. langparse/services/workbook_ambiguity_benchmark.py +563 -0
  59. langparse/services/workbook_quality_benchmark.py +230 -0
  60. langparse/types.py +97 -0
  61. langparse/workbooks/__init__.py +103 -0
  62. langparse/workbooks/adapters.py +474 -0
  63. langparse/workbooks/assembly.py +993 -0
  64. langparse/workbooks/blocks.py +209 -0
  65. langparse/workbooks/bundle-v1.schema.json +71 -0
  66. langparse/workbooks/bundle.py +341 -0
  67. langparse/workbooks/classification.py +393 -0
  68. langparse/workbooks/continuation.py +577 -0
  69. langparse/workbooks/evaluation/__init__.py +45 -0
  70. langparse/workbooks/evaluation/evaluator.py +381 -0
  71. langparse/workbooks/evaluation/schema.py +419 -0
  72. langparse/workbooks/labels.py +14 -0
  73. langparse/workbooks/lineage.py +117 -0
  74. langparse/workbooks/modeling/__init__.py +52 -0
  75. langparse/workbooks/modeling/cache.py +20 -0
  76. langparse/workbooks/modeling/config.py +87 -0
  77. langparse/workbooks/modeling/contract.py +628 -0
  78. langparse/workbooks/modeling/disambiguation.py +800 -0
  79. langparse/workbooks/modeling/openai_adapter.py +192 -0
  80. langparse/workbooks/modeling/policy.py +79 -0
  81. langparse/workbooks/modeling/ports.py +44 -0
  82. langparse/workbooks/modeling/pricing.py +17 -0
  83. langparse/workbooks/modeling/types.py +251 -0
  84. langparse/workbooks/objects.py +229 -0
  85. langparse/workbooks/quality/__init__.py +23 -0
  86. langparse/workbooks/quality/bundle.py +53 -0
  87. langparse/workbooks/quality/evaluator.py +266 -0
  88. langparse/workbooks/quality/facts.py +142 -0
  89. langparse/workbooks/quality/schema.py +462 -0
  90. langparse/workbooks/reference_types.py +73 -0
  91. langparse/workbooks/references.py +178 -0
  92. langparse/workbooks/regions.py +932 -0
  93. langparse/workbooks/rendering.py +222 -0
  94. langparse/workbooks/tables.py +477 -0
  95. langparse/workbooks/types.py +257 -0
  96. langparse-0.1.0.dist-info/METADATA +790 -0
  97. langparse-0.1.0.dist-info/RECORD +101 -0
  98. langparse-0.1.0.dist-info/WHEEL +5 -0
  99. langparse-0.1.0.dist-info/entry_points.txt +2 -0
  100. langparse-0.1.0.dist-info/licenses/LICENSE +192 -0
  101. langparse-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,800 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from dataclasses import fields
7
+ from threading import RLock
8
+ from typing import Any
9
+
10
+ from langparse.types import ParseDiagnostics
11
+
12
+ from .cache import MemoryDecisionCache
13
+ from .contract import (
14
+ _copy_provider_reply,
15
+ _validate_case,
16
+ _validate_model_identity,
17
+ build_model_request,
18
+ decode_model_reply,
19
+ response_checksum,
20
+ )
21
+ from .policy import WorkbookDisambiguation
22
+ from .ports import (
23
+ RequiredWorkbookDisambiguationError,
24
+ WorkbookModelResponseError,
25
+ WorkbookStructureModelAdapter,
26
+ )
27
+ from .types import (
28
+ REGION_PRIVACY_VERSION,
29
+ REGION_PROMPT_VERSION,
30
+ REGION_RULE_VERSION,
31
+ REGION_SCHEMA_VERSION,
32
+ REGION_VALIDATOR_VERSION,
33
+ ModelCallAudit,
34
+ ModelIdentity,
35
+ ProviderReply,
36
+ RegionAmbiguityCase,
37
+ RegionModelDecision,
38
+ RegionResolution,
39
+ RegionResolutionBatch,
40
+ WorkbookModelMode,
41
+ WorkbookModelPolicy,
42
+ WorkbookModelRequest,
43
+ )
44
+
45
+
46
+ class WorkbookRegionDisambiguator:
47
+ """Resolve workbook region ambiguity behind the local model contract."""
48
+
49
+ def __init__(
50
+ self,
51
+ cache: MemoryDecisionCache | None = None,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ ) -> None:
54
+ self._cache = MemoryDecisionCache() if cache is None else cache
55
+ self._clock = clock
56
+ self._resolve_lock = RLock()
57
+
58
+ def resolve(
59
+ self,
60
+ cases: Iterable[RegionAmbiguityCase],
61
+ configured: WorkbookDisambiguation,
62
+ ) -> RegionResolutionBatch:
63
+ with self._resolve_lock:
64
+ return self._resolve(cases, configured)
65
+
66
+ def _resolve(
67
+ self,
68
+ cases: Iterable[RegionAmbiguityCase],
69
+ configured: WorkbookDisambiguation,
70
+ ) -> RegionResolutionBatch:
71
+ ordered_cases = tuple(cases)
72
+ if not ordered_cases:
73
+ return RegionResolutionBatch(resolutions=())
74
+
75
+ self._validate_cases(ordered_cases)
76
+ if configured.mode is WorkbookModelMode.OFF:
77
+ return RegionResolutionBatch(
78
+ resolutions=tuple(_fallback_resolution(case) for case in ordered_cases)
79
+ )
80
+
81
+ policy = configured.policy
82
+ visible_limit = policy.max_cases
83
+ eligible_visible_cases = tuple(
84
+ case
85
+ for index, case in enumerate(_visible_cases(ordered_cases))
86
+ if index < visible_limit
87
+ )
88
+
89
+ kill_switch_active = policy.kill_switch or os.environ.get("LANGPARSE_DISABLE_MODEL") in (
90
+ "1",
91
+ "true",
92
+ "TRUE",
93
+ "yes",
94
+ )
95
+
96
+ identity, identity_error = (
97
+ (None, None)
98
+ if kill_switch_active
99
+ else self._read_identity(configured, eligible_visible_cases)
100
+ )
101
+ adapter = configured.adapter
102
+ workbook_started = self._clock() if eligible_visible_cases else None
103
+ deadline = (
104
+ workbook_started + policy.workbook_timeout_seconds
105
+ if workbook_started is not None
106
+ else None
107
+ )
108
+
109
+ resolutions: list[RegionResolution] = []
110
+ audits: list[ModelCallAudit] = []
111
+ unresolved_case_ids: list[str] = []
112
+ call_budget = [0]
113
+ quota_tracker = {"tokens": 0, "cost_usd": 0.0}
114
+ visible_index = 0
115
+ for case in ordered_cases:
116
+ if case.sheet_visibility != "visible":
117
+ resolution, audit = self._local_failure(
118
+ case,
119
+ configured.mode,
120
+ outcome="hidden_sheet",
121
+ )
122
+ elif kill_switch_active:
123
+ resolution, audit = self._local_failure(
124
+ case,
125
+ configured.mode,
126
+ outcome="kill_switch_activated",
127
+ )
128
+ visible_index += 1
129
+ elif visible_index >= visible_limit:
130
+ resolution, audit = self._local_failure(
131
+ case,
132
+ configured.mode,
133
+ identity=identity,
134
+ outcome="limit_exceeded",
135
+ )
136
+ visible_index += 1
137
+ elif identity_error is not None:
138
+ resolution, audit = self._local_failure(
139
+ case,
140
+ configured.mode,
141
+ outcome="adapter_error",
142
+ error=identity_error,
143
+ elapsed_ms=self._elapsed_ms(workbook_started),
144
+ )
145
+ visible_index += 1
146
+ else:
147
+ assert identity is not None
148
+ assert deadline is not None
149
+ assert adapter is not None
150
+ resolution, audit = self._resolve_visible_case(
151
+ case,
152
+ configured.mode,
153
+ adapter,
154
+ identity,
155
+ policy,
156
+ workbook_started,
157
+ deadline,
158
+ call_budget,
159
+ quota_tracker,
160
+ )
161
+ visible_index += 1
162
+
163
+ resolutions.append(resolution)
164
+ audits.append(audit)
165
+ if (
166
+ configured.mode is WorkbookModelMode.REQUIRED
167
+ and resolution.status == "local_fallback"
168
+ ):
169
+ unresolved_case_ids.append(case.case_id)
170
+
171
+ if unresolved_case_ids:
172
+ raise RequiredWorkbookDisambiguationError(
173
+ tuple(unresolved_case_ids),
174
+ ParseDiagnostics(
175
+ status="failed",
176
+ model_calls=[_audit_payload(audit) for audit in audits],
177
+ ),
178
+ )
179
+ return RegionResolutionBatch(resolutions=tuple(resolutions))
180
+
181
+ def _validate_cases(self, cases: tuple[RegionAmbiguityCase, ...]) -> None:
182
+ for case in cases:
183
+ _validate_case(case, allow_hidden_sheet=True)
184
+
185
+ def _read_identity(
186
+ self,
187
+ configured: WorkbookDisambiguation,
188
+ eligible_visible_cases: tuple[RegionAmbiguityCase, ...],
189
+ ) -> tuple[ModelIdentity | None, Exception | None]:
190
+ if not eligible_visible_cases:
191
+ return None, None
192
+ assert configured.adapter is not None
193
+ try:
194
+ identity = configured.adapter.identity
195
+ _validate_model_identity(identity)
196
+ return identity, None
197
+ except Exception as error:
198
+ return None, error
199
+
200
+ def _resolve_visible_case(
201
+ self,
202
+ case: RegionAmbiguityCase,
203
+ mode: WorkbookModelMode,
204
+ adapter: WorkbookStructureModelAdapter,
205
+ identity: ModelIdentity,
206
+ policy: WorkbookModelPolicy,
207
+ workbook_started: float | None,
208
+ deadline: float,
209
+ call_budget: list[int],
210
+ quota_tracker: dict[str, Any],
211
+ ) -> tuple[RegionResolution, ModelCallAudit]:
212
+ if (
213
+ policy.max_tokens_per_workbook is not None
214
+ and quota_tracker["tokens"] >= policy.max_tokens_per_workbook
215
+ ):
216
+ return self._local_failure(
217
+ case,
218
+ mode,
219
+ identity=identity,
220
+ outcome="quota_exceeded",
221
+ elapsed_ms=self._elapsed_ms(workbook_started),
222
+ )
223
+ if (
224
+ policy.max_cost_usd_per_workbook is not None
225
+ and quota_tracker["cost_usd"] >= policy.max_cost_usd_per_workbook
226
+ ):
227
+ return self._local_failure(
228
+ case,
229
+ mode,
230
+ identity=identity,
231
+ outcome="quota_exceeded",
232
+ elapsed_ms=self._elapsed_ms(workbook_started),
233
+ )
234
+
235
+ if len(case.cells) > policy.max_cells_per_case:
236
+ return self._local_failure(
237
+ case,
238
+ mode,
239
+ identity=identity,
240
+ outcome="cell_limit_exceeded",
241
+ elapsed_ms=self._elapsed_ms(workbook_started),
242
+ )
243
+
244
+ try:
245
+ request = build_model_request(case, identity)
246
+ except Exception as error:
247
+ return self._local_failure(
248
+ case,
249
+ mode,
250
+ identity=identity,
251
+ outcome="request_error",
252
+ error=error,
253
+ elapsed_ms=self._elapsed_ms(workbook_started),
254
+ )
255
+ request_bytes = len(request.body)
256
+ if request_bytes > policy.max_request_bytes:
257
+ return self._local_failure(
258
+ case,
259
+ mode,
260
+ identity=identity,
261
+ request=request,
262
+ request_bytes=request_bytes,
263
+ outcome="request_too_large",
264
+ elapsed_ms=self._elapsed_ms(workbook_started),
265
+ )
266
+
267
+ try:
268
+ cached_body = self._cache.get(request.request_checksum)
269
+ except Exception as error:
270
+ return self._local_failure(
271
+ case,
272
+ mode,
273
+ identity=identity,
274
+ request=request,
275
+ request_bytes=request_bytes,
276
+ cache_status="error",
277
+ outcome="cache_error",
278
+ error=error,
279
+ elapsed_ms=self._elapsed_ms(workbook_started),
280
+ )
281
+ if cached_body is not None:
282
+ return self._resolve_cached(
283
+ case,
284
+ mode,
285
+ identity,
286
+ request,
287
+ cached_body,
288
+ policy,
289
+ workbook_started,
290
+ )
291
+ return self._call_adapter(
292
+ case,
293
+ mode,
294
+ adapter,
295
+ identity,
296
+ request,
297
+ policy,
298
+ workbook_started,
299
+ deadline,
300
+ call_budget,
301
+ quota_tracker,
302
+ )
303
+
304
+ def _resolve_cached(
305
+ self,
306
+ case: RegionAmbiguityCase,
307
+ mode: WorkbookModelMode,
308
+ identity: ModelIdentity,
309
+ request: WorkbookModelRequest,
310
+ cached_body: bytes,
311
+ policy: WorkbookModelPolicy,
312
+ workbook_started: float | None,
313
+ ) -> tuple[RegionResolution, ModelCallAudit]:
314
+ try:
315
+ reply = ProviderReply(body=cached_body, provider_request_id=None)
316
+ decision = decode_model_reply(
317
+ reply,
318
+ request,
319
+ max_response_bytes=policy.max_response_bytes,
320
+ )
321
+ except Exception as error:
322
+ safe_cached_body = cached_body if type(cached_body) is bytes else None
323
+ return self._local_failure(
324
+ case,
325
+ mode,
326
+ identity=identity,
327
+ request=request,
328
+ request_bytes=len(request.body),
329
+ response_body=safe_cached_body,
330
+ cache_status="corrupt",
331
+ outcome="corrupt_cache",
332
+ error=error,
333
+ elapsed_ms=self._elapsed_ms(workbook_started),
334
+ )
335
+ return self._decision_result(
336
+ case,
337
+ mode,
338
+ identity,
339
+ request,
340
+ cached_body,
341
+ decision,
342
+ cache_status="hit",
343
+ attempts=0,
344
+ elapsed_ms=self._elapsed_ms(workbook_started),
345
+ )
346
+
347
+ def _call_adapter(
348
+ self,
349
+ case: RegionAmbiguityCase,
350
+ mode: WorkbookModelMode,
351
+ adapter: WorkbookStructureModelAdapter,
352
+ identity: ModelIdentity,
353
+ request: WorkbookModelRequest,
354
+ policy: WorkbookModelPolicy,
355
+ workbook_started: float | None,
356
+ deadline: float,
357
+ call_budget: list[int],
358
+ quota_tracker: dict[str, Any],
359
+ ) -> tuple[RegionResolution, ModelCallAudit]:
360
+ attempts = 0
361
+ last_error: Exception | None = None
362
+ last_body: bytes | None = None
363
+ assert mode is not WorkbookModelMode.OFF
364
+
365
+ while attempts < policy.max_attempts:
366
+ if _quota_limit_reached(policy, quota_tracker):
367
+ return self._local_failure(
368
+ case,
369
+ mode,
370
+ identity=identity,
371
+ request=request,
372
+ request_bytes=len(request.body),
373
+ response_body=last_body,
374
+ cache_status="miss",
375
+ attempts=attempts,
376
+ outcome="quota_exceeded",
377
+ error=last_error,
378
+ elapsed_ms=self._elapsed_ms(workbook_started),
379
+ )
380
+ remaining = deadline - self._clock()
381
+ if remaining <= 0:
382
+ return self._local_failure(
383
+ case,
384
+ mode,
385
+ identity=identity,
386
+ request=request,
387
+ request_bytes=len(request.body),
388
+ response_body=last_body,
389
+ cache_status="miss",
390
+ attempts=attempts,
391
+ outcome="deadline_exceeded",
392
+ error=last_error,
393
+ elapsed_ms=self._elapsed_ms(workbook_started),
394
+ )
395
+ if call_budget[0] >= policy.max_calls:
396
+ return self._local_failure(
397
+ case,
398
+ mode,
399
+ identity=identity,
400
+ request=request,
401
+ request_bytes=len(request.body),
402
+ response_body=last_body,
403
+ cache_status="miss",
404
+ attempts=attempts,
405
+ outcome="limit_exceeded",
406
+ error=last_error,
407
+ elapsed_ms=self._elapsed_ms(workbook_started),
408
+ )
409
+ attempts += 1
410
+ call_budget[0] += 1
411
+ try:
412
+ reply = _copy_provider_reply(
413
+ adapter.complete(
414
+ request,
415
+ timeout_seconds=min(policy.timeout_seconds, remaining),
416
+ )
417
+ )
418
+ if self._clock() >= deadline:
419
+ return self._local_failure(
420
+ case,
421
+ mode,
422
+ identity=identity,
423
+ request=request,
424
+ request_bytes=len(request.body),
425
+ cache_status="miss",
426
+ attempts=attempts,
427
+ outcome="deadline_exceeded",
428
+ error=TimeoutError(),
429
+ elapsed_ms=self._elapsed_ms(workbook_started),
430
+ )
431
+ last_body = reply.body
432
+ from .pricing import calculate_cost_usd
433
+
434
+ usage = reply.usage
435
+ if _quota_usage_unavailable(policy, usage):
436
+ return self._local_failure(
437
+ case,
438
+ mode,
439
+ identity=identity,
440
+ request=request,
441
+ request_bytes=len(request.body),
442
+ response_body=reply.body,
443
+ cache_status="miss",
444
+ attempts=attempts,
445
+ outcome="quota_unavailable",
446
+ elapsed_ms=self._elapsed_ms(workbook_started),
447
+ )
448
+ p_tok = usage.get("prompt_tokens", 0)
449
+ c_tok = usage.get("completion_tokens", 0)
450
+ t_tok = max(usage.get("total_tokens", 0), p_tok + c_tok)
451
+ quota_tracker["tokens"] += t_tok
452
+ if policy.max_cost_usd_per_workbook is not None:
453
+ assert policy.input_cost_usd_per_million is not None
454
+ assert policy.output_cost_usd_per_million is not None
455
+ quota_tracker["cost_usd"] += calculate_cost_usd(
456
+ p_tok,
457
+ c_tok,
458
+ input_cost_usd_per_million=policy.input_cost_usd_per_million,
459
+ output_cost_usd_per_million=policy.output_cost_usd_per_million,
460
+ )
461
+ decision = decode_model_reply(
462
+ reply,
463
+ request,
464
+ max_response_bytes=policy.max_response_bytes,
465
+ )
466
+ except Exception as error:
467
+ last_error = error
468
+ continue
469
+
470
+ try:
471
+ response_checksum(reply.body)
472
+ except Exception as error:
473
+ return self._local_failure(
474
+ case,
475
+ mode,
476
+ identity=identity,
477
+ request=request,
478
+ request_bytes=len(request.body),
479
+ response_body=reply.body,
480
+ cache_status="miss",
481
+ attempts=attempts,
482
+ outcome="checksum_error",
483
+ error=error,
484
+ elapsed_ms=self._elapsed_ms(workbook_started),
485
+ )
486
+ try:
487
+ self._cache.put(request.request_checksum, reply.body)
488
+ except Exception as error:
489
+ return self._local_failure(
490
+ case,
491
+ mode,
492
+ identity=identity,
493
+ request=request,
494
+ request_bytes=len(request.body),
495
+ response_body=reply.body,
496
+ cache_status="error",
497
+ attempts=attempts,
498
+ outcome="cache_error",
499
+ error=error,
500
+ elapsed_ms=self._elapsed_ms(workbook_started),
501
+ )
502
+ return self._decision_result(
503
+ case,
504
+ mode,
505
+ identity,
506
+ request,
507
+ reply.body,
508
+ decision,
509
+ cache_status="miss",
510
+ attempts=attempts,
511
+ elapsed_ms=self._elapsed_ms(workbook_started),
512
+ )
513
+
514
+ outcome = "timeout" if isinstance(last_error, TimeoutError) else "invalid_response"
515
+ if last_error is not None and not _is_response_error(last_error):
516
+ outcome = "timeout" if isinstance(last_error, TimeoutError) else "adapter_error"
517
+ return self._local_failure(
518
+ case,
519
+ mode,
520
+ identity=identity,
521
+ request=request,
522
+ request_bytes=len(request.body),
523
+ response_body=last_body,
524
+ cache_status="miss",
525
+ attempts=attempts,
526
+ outcome=outcome,
527
+ error=last_error,
528
+ elapsed_ms=self._elapsed_ms(workbook_started),
529
+ )
530
+
531
+ def _decision_result(
532
+ self,
533
+ case: RegionAmbiguityCase,
534
+ mode: WorkbookModelMode,
535
+ identity: ModelIdentity,
536
+ request: WorkbookModelRequest,
537
+ response_body: bytes,
538
+ decision: RegionModelDecision,
539
+ *,
540
+ cache_status: str,
541
+ attempts: int,
542
+ elapsed_ms: int,
543
+ ) -> tuple[RegionResolution, ModelCallAudit]:
544
+ try:
545
+ response_checksum(response_body)
546
+ except Exception as error:
547
+ return self._local_failure(
548
+ case,
549
+ mode,
550
+ identity=identity,
551
+ request=request,
552
+ request_bytes=len(request.body),
553
+ response_body=response_body,
554
+ cache_status=cache_status,
555
+ attempts=attempts,
556
+ outcome="checksum_error",
557
+ error=error,
558
+ elapsed_ms=elapsed_ms,
559
+ )
560
+ if decision.status == "abstained":
561
+ return self._local_failure(
562
+ case,
563
+ mode,
564
+ identity=identity,
565
+ request=request,
566
+ request_bytes=len(request.body),
567
+ response_body=response_body,
568
+ cache_status=cache_status,
569
+ attempts=attempts,
570
+ outcome="abstained",
571
+ reported_confidence=decision.reported_confidence,
572
+ elapsed_ms=elapsed_ms,
573
+ )
574
+
575
+ assert decision.choice_id is not None
576
+ audit = _audit(
577
+ case,
578
+ identity=identity,
579
+ request=request,
580
+ request_bytes=len(request.body),
581
+ response_body=response_body,
582
+ cache_status=cache_status,
583
+ attempts=attempts,
584
+ outcome="selected",
585
+ selected_choice_id=decision.choice_id,
586
+ reported_confidence=decision.reported_confidence,
587
+ elapsed_ms=elapsed_ms,
588
+ mode=mode,
589
+ )
590
+ status = "cache_selected" if cache_status == "hit" else "model_selected"
591
+ return (
592
+ RegionResolution(
593
+ case_id=case.case_id,
594
+ choice_id=decision.choice_id,
595
+ status=status,
596
+ audit=audit,
597
+ ),
598
+ audit,
599
+ )
600
+
601
+ def _local_failure(
602
+ self,
603
+ case: RegionAmbiguityCase,
604
+ mode: WorkbookModelMode,
605
+ *,
606
+ identity: ModelIdentity | None = None,
607
+ request: WorkbookModelRequest | None = None,
608
+ request_bytes: int = 0,
609
+ response_body: bytes | None = None,
610
+ cache_status: str = "not_checked",
611
+ attempts: int = 0,
612
+ elapsed_ms: int = 0,
613
+ outcome: str,
614
+ selected_choice_id: str | None = None,
615
+ reported_confidence: float | None = None,
616
+ error: Exception | None = None,
617
+ ) -> tuple[RegionResolution, ModelCallAudit]:
618
+ audit = _audit(
619
+ case,
620
+ identity=identity,
621
+ request=request,
622
+ request_bytes=request_bytes,
623
+ response_body=response_body,
624
+ cache_status=cache_status,
625
+ attempts=attempts,
626
+ elapsed_ms=elapsed_ms,
627
+ outcome=outcome,
628
+ selected_choice_id=selected_choice_id,
629
+ reported_confidence=reported_confidence,
630
+ error=error,
631
+ mode=mode,
632
+ )
633
+ return _fallback_resolution(case, audit), audit
634
+
635
+ def _elapsed_ms(self, workbook_started: float | None) -> int:
636
+ if workbook_started is None:
637
+ return 0
638
+ return max(0, int(round((self._clock() - workbook_started) * 1000)))
639
+
640
+
641
+ def _visible_cases(
642
+ cases: tuple[RegionAmbiguityCase, ...],
643
+ ) -> Iterable[RegionAmbiguityCase]:
644
+ return (case for case in cases if case.sheet_visibility == "visible")
645
+
646
+
647
+ def _fallback_resolution(
648
+ case: RegionAmbiguityCase,
649
+ audit: ModelCallAudit | None = None,
650
+ ) -> RegionResolution:
651
+ return RegionResolution(
652
+ case_id=case.case_id,
653
+ choice_id=case.fallback_choice_id,
654
+ status="local_fallback",
655
+ audit=audit,
656
+ )
657
+
658
+
659
+ def _audit(
660
+ case: RegionAmbiguityCase,
661
+ *,
662
+ mode: WorkbookModelMode,
663
+ identity: ModelIdentity | None,
664
+ request: WorkbookModelRequest | None,
665
+ request_bytes: int,
666
+ response_body: bytes | None,
667
+ cache_status: str,
668
+ attempts: int,
669
+ elapsed_ms: int,
670
+ outcome: str,
671
+ selected_choice_id: str | None = None,
672
+ reported_confidence: float | None = None,
673
+ error: Exception | None = None,
674
+ ) -> ModelCallAudit:
675
+ fallback_choice = next(
676
+ choice for choice in case.choices if choice.choice_id == case.fallback_choice_id
677
+ )
678
+ provider, provider_redacted = _safe_identity_token(
679
+ identity.provider if identity is not None else None,
680
+ required=identity is not None,
681
+ )
682
+ model, model_redacted = _safe_identity_token(
683
+ identity.model if identity is not None else None,
684
+ required=identity is not None,
685
+ )
686
+ model_revision, revision_redacted = _safe_identity_token(
687
+ identity.revision if identity is not None else None,
688
+ required=False,
689
+ )
690
+ validation_codes = []
691
+ if provider_redacted or model_redacted or revision_redacted:
692
+ validation_codes.append("unsafe_identity_redacted")
693
+ checksum = None
694
+ checksum_error = None
695
+ if response_body is not None:
696
+ try:
697
+ checksum = response_checksum(response_body)
698
+ except Exception as caught:
699
+ checksum_error = caught
700
+ validation_codes.append("response_checksum_error")
701
+ effective_error = error if error is not None else checksum_error
702
+ return ModelCallAudit(
703
+ case_id=case.case_id,
704
+ source_range=case.source_range,
705
+ mode=mode.value,
706
+ schema_version=REGION_SCHEMA_VERSION,
707
+ prompt_version=REGION_PROMPT_VERSION,
708
+ rule_version=REGION_RULE_VERSION,
709
+ validator_version=REGION_VALIDATOR_VERSION,
710
+ privacy_version=REGION_PRIVACY_VERSION,
711
+ rule_confidence=fallback_choice.local_score,
712
+ provider=provider,
713
+ model=model,
714
+ model_revision=model_revision,
715
+ request_checksum=request.request_checksum if request is not None else None,
716
+ response_checksum=checksum,
717
+ cache_status=cache_status,
718
+ attempts=attempts,
719
+ elapsed_ms=elapsed_ms,
720
+ request_bytes=request_bytes,
721
+ response_bytes=len(response_body) if response_body is not None else 0,
722
+ outcome=outcome,
723
+ selected_choice_id=selected_choice_id,
724
+ reported_confidence=reported_confidence,
725
+ validation_codes=tuple(validation_codes),
726
+ reason_codes=(),
727
+ error_type=_safe_error_type(effective_error),
728
+ )
729
+
730
+
731
+ def _audit_payload(audit: ModelCallAudit) -> dict[str, object]:
732
+ return {field.name: getattr(audit, field.name) for field in fields(ModelCallAudit)}
733
+
734
+
735
+ def _quota_limit_reached(
736
+ policy: WorkbookModelPolicy,
737
+ quota_tracker: dict[str, Any],
738
+ ) -> bool:
739
+ return bool(
740
+ (
741
+ policy.max_tokens_per_workbook is not None
742
+ and quota_tracker["tokens"] >= policy.max_tokens_per_workbook
743
+ )
744
+ or (
745
+ policy.max_cost_usd_per_workbook is not None
746
+ and quota_tracker["cost_usd"] >= policy.max_cost_usd_per_workbook
747
+ )
748
+ )
749
+
750
+
751
+ def _quota_usage_unavailable(
752
+ policy: WorkbookModelPolicy,
753
+ usage: Mapping[str, int],
754
+ ) -> bool:
755
+ token_usage_available = any(
756
+ key in usage for key in ("prompt_tokens", "completion_tokens", "total_tokens")
757
+ )
758
+ cost_usage_available = "prompt_tokens" in usage and "completion_tokens" in usage
759
+ return bool(
760
+ (policy.max_tokens_per_workbook is not None and not token_usage_available)
761
+ or (policy.max_cost_usd_per_workbook is not None and not cost_usage_available)
762
+ )
763
+
764
+
765
+ def _is_response_error(error: Exception) -> bool:
766
+ return isinstance(error, WorkbookModelResponseError)
767
+
768
+
769
+ def _safe_identity_token(
770
+ value: object,
771
+ *,
772
+ required: bool,
773
+ ) -> tuple[str | None, bool]:
774
+ if value is None and not required:
775
+ return None, False
776
+ if (
777
+ isinstance(value, str)
778
+ and 0 < len(value) <= 64
779
+ and value.isascii()
780
+ and all(character.isalnum() or character in "._-/" for character in value)
781
+ ):
782
+ return value, False
783
+ return None, True
784
+
785
+
786
+ def _safe_error_type(error: Exception | None) -> str | None:
787
+ if error is None:
788
+ return None
789
+ try:
790
+ name = type(error).__name__
791
+ except Exception:
792
+ return "ExternalError"
793
+ if (
794
+ type(name) is str
795
+ and 0 < len(name) <= 64
796
+ and name.isascii()
797
+ and all(character.isalnum() or character in "._-" for character in name)
798
+ ):
799
+ return name
800
+ return "ExternalError"