ecadlib 0.3.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 (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/ds_route.py ADDED
@@ -0,0 +1,541 @@
1
+ """Cheaper datasheet reads: only the pages that matter, a model per part, and what each part cost.
2
+
3
+ Used by datasheet.Reader (and so by the librarian, `ecadlib datasheet batch` and the workspace tools):
4
+
5
+ * pick_pages: for every PDF with a text layer, the pages most likely to hold the package drawing (for THIS part's
6
+ package), the land pattern, the pin table and the ordering information that maps the MPN suffix to a package,
7
+ within [datasheet] page_budget pages. levels() gives the widening steps: budget -> 3x budget -> the whole PDF.
8
+ * Router: [datasheet] route = "single" (one model for everything, as before) or "ladder" (a cheaper model for
9
+ simple packages, escalating up [datasheet] ladder). difficulty() picks the first rung from the package family,
10
+ pin count and page count.
11
+ * assess / next_step: after an answer, datasheet's own checks (schema_check, sanity, check_pins, self_check, the
12
+ page citations) say ok / incomplete / failed. Incomplete widens the pages first; failed moves to the next model;
13
+ the top of the ladder with every page is the last try, and its answer is kept with its review items.
14
+ * usage_usd / Ledger: USD per attempt from the API usage block (or an estimate), appended to
15
+ <out>/datasheet/cost.jsonl; summarize() feeds the librarian review, `ecadlib status` and the estimate.
16
+
17
+ Prices: datasheet.PRICES (from the claude-api skill's model table, cached 2026-10-06), plus the cache-read prices
18
+ and the Haiku 5.5 long-prompt tier listed here."""
19
+ import argparse, datetime, io, json, re, sys
20
+ from pathlib import Path
21
+
22
+ # Cache reads where the model table gives a price that is not 0.1x input; Haiku 5.5 is dearer above 100K prompt tokens.
23
+ CACHE_READ = {"claude-opus-5-5": 0.20, "claude-sonnet-5-5": 0.20, "claude-fable-5-1": 0.25}
24
+ LONG_PROMPT = {"claude-haiku-5-5": (100_000, (0.50, 2.50))}
25
+ LADDER = ["claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5"]
26
+ ROUTES = ("single", "ladder")
27
+
28
+ # ---------------------------------------------------------------- page picking
29
+ _DASH = str.maketrans({"−": "-", "–": "-", "—": "-", " ": " "})
30
+ FAMILY = { # package family words -> a canonical family (SOIC-8 / SO8 / SOP-8 are one package)
31
+ "SOIC": "SO", "SOP": "SO", "SO": "SO", "PSOP": "SO", "PDSO": "SO", "SSOP": "SSOP", "QSOP": "SSOP", "TSSOP": "TSSOP",
32
+ "HTSSOP": "TSSOP", "MSOP": "MSOP", "VSSOP": "MSOP", "TSOP": "TSOP", "SOT": "SOT", "TSOT": "SOT", "SOD": "SOD",
33
+ "SC": "SC", "TO": "TO", "DO": "DO", "DPAK": "TO252", "D2PAK": "TO263", "SMA": "SMA", "SMB": "SMB", "SMC": "SMC",
34
+ "QFN": "QFN", "VQFN": "QFN", "WQFN": "QFN", "UQFN": "QFN", "HVQFN": "QFN", "UFQFPN": "QFN", "VFQFPN": "QFN",
35
+ "DFN": "SON", "SON": "SON", "WSON": "SON", "VSON": "SON", "HVSON": "SON", "USON": "SON", "XSON": "SON",
36
+ "QFP": "QFP", "LQFP": "QFP", "TQFP": "QFP", "PQFP": "QFP", "HTQFP": "QFP", "BGA": "BGA", "UFBGA": "BGA",
37
+ "LFBGA": "BGA", "TFBGA": "BGA", "FBGA": "BGA", "WLCSP": "CSP", "CSP": "CSP", "DIP": "DIP", "PDIP": "DIP",
38
+ "SIP": "SIP", "LGA": "LGA"}
39
+ NEEDS_NUM = {"SO", "SC", "TO", "DO", "SOT", "SOD", "SIP", "SMA", "SMB", "SMC"} # too common as plain words
40
+ _FAM_RX = re.compile(r"(?<![A-Z0-9])(" + "|".join(sorted(FAMILY, key=len, reverse=True)) + r")(\s?-\s?|\s)?(\d{1,3})?(?:\s?-\s?(\d{1,3}))?(?![0-9])")
41
+ THREE_PIN = {"SOT23", "SOT323", "SOT523", "SOT416", "SOT346", "SC59", "SC70", "SC75", "SC89", "TO92", "SOT89"}
42
+ OUTLINE_FAMS = {"SOT", "SOD", "SC", "TO", "DO", "TO252", "TO263"} # first number names the outline, not pins
43
+ CATS = {
44
+ "drawing": re.compile(r"package (outline|drawing|dimensions?|information)|outline dimensions?|mechanical (data|drawing|"
45
+ r"dimensions?)|physical dimensions?|shape (&|and) dimensions|case outline|packaging information|"
46
+ r"dimensions\s*(-|in)\s*(mm|millimet)|chip resistor outlines|package profile|外形尺寸|封装尺寸", re.I),
47
+ "land": re.compile(r"land pattern|board layout|recommended (footprint|land|pcb|solder|pad)|(suggested|recommended) pad layout|"
48
+ r"soldering footprint|solder lands|\bfootprint\b|pad layout|soldering pad|焊盘", re.I),
49
+ "pinout": re.compile(r"pin (configurations?|functions?|descriptions?|assignments?|definitions?)|pinning information|"
50
+ r"\bpinouts?\b|terminal functions?|\(top view\)|top view|引脚", re.I),
51
+ "ordering": re.compile(r"ordering information|order(ing)? code|orderable|package option addendum|device comparison|"
52
+ r"ordering|part number|订购|订货", re.I)}
53
+ TABLE = {"drawing": re.compile(r"\b(min\.?|minimum)\b[\s\S]{0,80}\b(max\.?|maximum)\b", re.I),
54
+ "pinout": re.compile(r"\bpin\s*(no\.?|number|#)|\bno\.\s+name\b|\bname\s+(type|i/o)\b|pin name", re.I)}
55
+ DIM_SYMS = re.compile(r"(?<![A-Za-z0-9])(A1|A2|D1|E1|D2|E2)(?![A-Za-z0-9])")
56
+ NOISE = re.compile(r"tape and reel|reel dimensions|carrier tape|package materials information|revision history|"
57
+ r"important notice|tape dimensions|stencil design", re.I)
58
+ STOP = {"ACTIVE", "NRND", "OBSOLETE", "ROHS", "GREEN", "LEVEL", "TAPE", "REEL", "TUBE", "TRAY", "TBD", "YES", "NO",
59
+ "PB", "FREE", "MSL", "LIFEBUY", "PREVIEW", "NIPDAU", "SNAGCU", "SN", "CU", "AU", "MM", "MAX", "MIN", "TYP", "UNIT",
60
+ "PCS", "QTY", "PART", "NUMBER", "DEVICE", "PACKAGE", "PINS", "PIN", "TYPE", "TEMP", "RANGE"}
61
+
62
+
63
+ def norm_text(t):
64
+ return (t or "").translate(_DASH)
65
+
66
+
67
+ def package_tokens(text):
68
+ """'SOIC-8_3.9x4.9mm' -> {'SO8'}, 'SOT-223-3_TabPin2' -> {'SOT223'}, 'LQFP-32_7x7mm' -> {'QFP32'}; a family
69
+ without a number ('WSON') gives the family alone."""
70
+ out = set()
71
+ for m in _FAM_RX.finditer(norm_text(text).upper()):
72
+ fam, sep, n1 = FAMILY[m.group(1)], m.group(2) or "", m.group(3)
73
+ if fam in NEEDS_NUM and (not n1 or sep.strip() == "" and sep): # "TO-220", "SO8"; never "to 85" or "SO 2"
74
+ continue
75
+ out.add(fam + (n1 or ""))
76
+ return out
77
+
78
+
79
+ def _tok_match(own, page_toks):
80
+ """A page token matches when it is the same package (family + number), or the same family when one side has none."""
81
+ for a in own:
82
+ for b in page_toks:
83
+ if a == b:
84
+ return True
85
+ fa, fb = re.sub(r"\d+$", "", a), re.sub(r"\d+$", "", b)
86
+ if fa == fb and (fa == a or fb == b) and fa not in OUTLINE_FAMS:
87
+ return True
88
+ return False
89
+
90
+
91
+ def mpn_variants(part):
92
+ p = re.sub(r"\s+", "", str(part or "")).upper()
93
+ out = {p} if len(p) >= 4 else set()
94
+ if len(p) > 7 and p[-1] in "RTG": # a tape-and-reel / lead-free suffix the ordering table may leave off
95
+ out.add(p[:-1])
96
+ return out
97
+
98
+
99
+ def ordering_codes(texts, part):
100
+ """Package words and drawing codes printed on the ordering-table lines that name this MPN (or, failing that, under
101
+ the nearest package heading above them): 'AZ1117CH-3.3TRG1' -> {'SOT223'}, 'BQ771800DPJR' -> {'SON', 'DPJ'}."""
102
+ vs = mpn_variants(part)
103
+ toks, codes = set(), set()
104
+ if not vs:
105
+ return toks, codes
106
+ for t in texts:
107
+ last = set()
108
+ for ln in norm_text(t).upper().splitlines():
109
+ here = package_tokens(ln)
110
+ flat = re.sub(r"\s+", "", ln)
111
+ if any(v in flat for v in vs):
112
+ toks |= here or last
113
+ for w in re.findall(r"(?<![A-Z0-9])([A-Z]{2,4}|\d{2}[A-Z]\d?(?:-[A-Z])?|[A-Z]{1,4}\d{4}[A-Z])(?![A-Z0-9])", ln):
114
+ if w not in STOP and w not in FAMILY and not any(w == v for v in vs) and (
115
+ any(w in v for v in vs) or re.fullmatch(r"\d{2}[A-Z]\d?(?:-[A-Z])?", w)):
116
+ codes.add(w)
117
+ if here:
118
+ last = here
119
+ return toks, set(sorted(codes)[:6])
120
+
121
+
122
+ def page_scores(texts, part=None, package=None):
123
+ """Per page: {cat: score} plus 'own' (names this part's package or drawing code) and 'other' (names only another
124
+ package). Table-of-contents pages and tape/reel/revision pages score low."""
125
+ hint = package_tokens(package or "")
126
+ otoks, codes = ordering_codes(texts, part)
127
+ own = hint | otoks
128
+ vs = mpn_variants(part)
129
+ n, rows = len(texts), []
130
+ first_mech = None
131
+ for i, raw in enumerate(texts):
132
+ t = norm_text(raw)
133
+ s = {c: min(2, len(rx.findall(t))) * (3 if c != "ordering" else 2) for c, rx in CATS.items()}
134
+ for c, rx in TABLE.items():
135
+ if rx.search(t) and (c != "drawing" or (DIM_SYMS.search(t) and re.search(r"\bmm\b|millimet|\binch", t, re.I))):
136
+ s[c] += 2 # a dimension table: min/max with A1/D1/E1/b/e and a length unit
137
+ flat = re.sub(r"\s+", "", t).upper()
138
+ s["mpn"] = 4 if vs and any(v in flat for v in vs) else 0
139
+ if s["mpn"] and s["ordering"]:
140
+ s["ordering"] += 4
141
+ ptoks = package_tokens(t)
142
+ has_code = any(re.search(rf"(?<![A-Z0-9]){re.escape(c)}(?![A-Z]|\d(?![\dA-Z]))", t.upper()) for c in codes)
143
+ s["own"] = bool((own and _tok_match(own, ptoks)) or has_code)
144
+ s["other"] = bool(ptoks) and not s["own"] and bool(own or codes)
145
+ s["solo"] = s["own"] and len(ptoks) <= 1 # names this package and no other
146
+ if re.search(r"orderable|package option addendum|ordering information", t, re.I):
147
+ s["drawing"] //= 3 # "Package Drawing" is a column of the ordering table there
148
+ toc = len(re.findall(r"\.{5,}|(?:\. ){5,}", t)) >= 6
149
+ if toc or NOISE.search(t):
150
+ for c in CATS:
151
+ s[c] = s[c] // 4
152
+ s["toc"] = toc
153
+ if first_mech is None and (s["drawing"] or s["land"]) and i >= n // 3:
154
+ first_mech = i
155
+ s["blank"] = len(t.strip()) < 40
156
+ rows.append(s)
157
+ for i, s in enumerate(rows): # image-only drawing pages at the back of the document
158
+ if s["blank"] and first_mech is not None and i > first_mech - 3 and i >= n // 2 and i < n - 1:
159
+ s["drawing"] = max(s["drawing"], 3)
160
+ return rows, own, codes
161
+
162
+
163
+ def _rank(s, cat):
164
+ v = s[cat]
165
+ if not v:
166
+ return 0
167
+ if cat in ("drawing", "land") and (s["own"] or s["other"]):
168
+ v = v * (3 if s["solo"] else 1.5) if s["own"] else v * 0.3 # a page about this package alone beats a shared one
169
+ if cat == "pinout" and s["own"]:
170
+ v *= 1.5
171
+ return v
172
+
173
+
174
+ def pick_pages(texts, budget, part=None, package=None):
175
+ """-> (sorted 1-based pages, {page: [why]}) or (None, {}) when there is no text layer or nothing matched.
176
+ The best page for each need (drawing, pin table, land pattern, ordering) goes in first, then the page after the
177
+ drawing (drawings and their tables run over), then the rest by score, up to budget pages."""
178
+ if not texts or not any((t or "").strip() for t in texts) or budget <= 0:
179
+ return None, {}
180
+ rows, own, codes = page_scores(texts, part, package)
181
+ total = lambda i: (sum(_rank(rows[i], c) for c in CATS) + rows[i]["mpn"])
182
+ if not any(total(i) for i in range(len(rows))):
183
+ return None, {}
184
+ keep, why = [], {}
185
+
186
+ def add(i, reason):
187
+ if 0 <= i < len(rows) and len(keep) < budget and i + 1 not in keep:
188
+ keep.append(i + 1)
189
+ why.setdefault(i + 1, []).append(reason)
190
+ elif 0 <= i < len(rows) and i + 1 in keep:
191
+ why.setdefault(i + 1, []).append(reason)
192
+
193
+ best = {}
194
+ for cat in ("drawing", "pinout", "land", "ordering"):
195
+ cands = [i for i in range(len(rows)) if _rank(rows[i], cat) > 0]
196
+ if cands:
197
+ best[cat] = max(cands, key=lambda i: (_rank(rows[i], cat), total(i), -i))
198
+ add(best[cat], cat)
199
+ pins_own = [i for i in range(len(rows)) if rows[i]["own"] and _rank(rows[i], "pinout") > 0 and i != best.get("pinout")]
200
+ if pins_own: # a second pin page naming this package (pinout figure + pin table)
201
+ add(max(pins_own, key=lambda i: (rows[i]["solo"], _rank(rows[i], "pinout"), -i)), "pinout (this package)")
202
+ if "drawing" in best: # the drawing's continuation (table, notes, second view)
203
+ j = best["drawing"] + 1
204
+ if j < len(rows) and (rows[j]["drawing"] or rows[j]["land"] or rows[j]["blank"]) and not rows[j]["other"]:
205
+ add(j, "drawing (continued)")
206
+ for i in sorted(range(len(rows)), key=lambda i: (-total(i), i)):
207
+ if total(i) <= 0 or len(keep) >= budget:
208
+ break
209
+ if not rows[i]["other"]: # a page about another package only comes with the whole PDF
210
+ add(i, "score")
211
+ if len(keep) < budget and 1 not in keep: # page 1: summary, package list, often the pinout of small parts
212
+ add(0, "first page")
213
+ if own or codes:
214
+ why["_package"] = sorted(own | codes)
215
+ return sorted(keep), why
216
+
217
+
218
+ def levels(texts, budget, part=None, package=None, n_pages=None):
219
+ """The page sets to try, smallest first: [pages] for budget, 3x budget, then None (= the whole PDF). A PDF that
220
+ fits the budget, or has no text layer, gets [None] (whole, as before)."""
221
+ n = n_pages if n_pages is not None else len(texts or [])
222
+ if not budget or not n or n <= budget:
223
+ return [None], {}
224
+ out, why = [], {}
225
+ for b in (budget, budget * 3):
226
+ if b >= n:
227
+ break
228
+ got, w = pick_pages(texts, b, part, package)
229
+ if got and (not out or len(set(got) - set(out[-1])) >= max(2, budget // 2)): # a wider step must add pages
230
+ out.append(got)
231
+ why = why or w
232
+ return out + [None], why
233
+
234
+
235
+ # ---------------------------------------------------------------- routing
236
+ def difficulty(package=None, n_pages=None, sent_pages=None):
237
+ """0 simple / 1 medium / 2 hard, with the reason. From the package hint (KiCad-style footprint name or package
238
+ text): two- and three-terminal parts are simple; SOIC/SOT/QFN/DFN/DIP up to 32 pins medium; more pins, BGA/LGA/
239
+ CSP, connectors and unknown packages hard. A long PDF (over 40 pages) or many pages sent moves a part up one."""
240
+ h = norm_text(package or "")
241
+ up = h.upper()
242
+ toks = package_tokens(h)
243
+ m = re.search(r"(?<![A-Z0-9])(?:[A-Z]+)-(\d{1,3})(?![0-9])", up)
244
+ pins = None
245
+ for tk in toks:
246
+ fam, num = re.sub(r"\d+$", "", tk), re.sub(r"^\D+", "", tk)
247
+ if num and fam not in OUTLINE_FAMS:
248
+ pins = int(num)
249
+ if pins is None and m and not any(re.sub(r"\d+$", "", t) in OUTLINE_FAMS for t in toks):
250
+ pins = int(m.group(1))
251
+ sot = re.search(r"(?<![A-Z0-9])(SOT|SC|TO)-?\d+(?:-(\d+))?", up)
252
+ if sot and sot.group(2):
253
+ pins = int(sot.group(2))
254
+ elif pins is None and toks & THREE_PIN: # SOT-23, SOT-323, TO-92 ... with no count: three leads
255
+ pins = 3
256
+ if re.match(r"(R|C|L|D|LED|FB|CP|Fuse|Crystal|Resonator)_", h) or re.search(r"\b(0201|0402|0603|0805|1206|1210|2512)\b", h) \
257
+ or any(t.startswith(("SOD", "SMA", "SMB", "SMC", "DO")) for t in toks):
258
+ tier, why = 0, "two-terminal / chip package"
259
+ elif any(t.startswith(("BGA", "LGA", "CSP")) for t in toks) or re.search(r"connector|header|socket|terminal|usb|jack", h, re.I):
260
+ tier, why = 2, "BGA/LGA/CSP or connector"
261
+ elif pins is not None and pins <= 3:
262
+ tier, why = 0, f"{pins}-pin package"
263
+ elif pins is not None and pins <= 32 and toks:
264
+ tier, why = 1, f"{pins}-pin {sorted(toks)[0]}"
265
+ elif toks and pins is None and any(re.sub(r"\d+$", "", t) in OUTLINE_FAMS for t in toks):
266
+ tier, why = 1, f"{sorted(toks)[0]} outline"
267
+ elif pins is not None:
268
+ tier, why = 2, f"{pins} pins"
269
+ else:
270
+ tier, why = 2, "package unknown"
271
+ if tier < 2 and ((n_pages or 0) > 40 or (sent_pages or 0) > 12):
272
+ tier, why = tier + 1, why + f"; long datasheet ({n_pages or sent_pages} pages)"
273
+ return tier, why
274
+
275
+
276
+ class Router:
277
+ """[datasheet] route / ladder / escalate / max_attempts. A model given explicitly (--model) means one model."""
278
+
279
+ def __init__(self, cfg, model=None):
280
+ cfg = cfg or {}
281
+ self.route = cfg.get("route") or "single"
282
+ self.escalate = bool(cfg.get("escalate", True))
283
+ self.max_attempts = int(cfg.get("max_attempts") or 4)
284
+ if model or self.route != "ladder":
285
+ self.ladder = [model or cfg.get("model")]
286
+ self.route = "single"
287
+ else:
288
+ self.ladder = [m for m in (cfg.get("ladder") or LADDER) if m] or [cfg.get("model")]
289
+
290
+ def start(self, package=None, n_pages=None, sent_pages=None):
291
+ """-> (rung index, reason)."""
292
+ if len(self.ladder) == 1:
293
+ return 0, "one model for every part ([datasheet] route = \"single\")"
294
+ tier, why = difficulty(package, n_pages, sent_pages)
295
+ return round(tier * (len(self.ladder) - 1) / 2), why
296
+
297
+ def next_step(self, rung, level, n_levels, kind, attempts):
298
+ """After an answer judged `kind` (ok | incomplete | failed) at (rung, level): the next (rung, level) to try,
299
+ or None (keep this answer). Incomplete widens the pages before changing the model; failed does the opposite."""
300
+ if kind == "ok" or not self.escalate or attempts >= self.max_attempts:
301
+ return None
302
+ wider = (rung, level + 1) if level + 1 < n_levels else None
303
+ better = (rung + 1, level) if rung + 1 < len(self.ladder) else None
304
+ nxt = (wider or better) if kind == "incomplete" else (better or wider)
305
+ top = len(self.ladder) - 1
306
+ if nxt and attempts + 1 >= self.max_attempts and nxt[0] < top: # the last answer allowed goes to the top model:
307
+ nxt = (top, n_levels - 1 if kind == "incomplete" else level) # max_attempts never keeps a cheaper one's
308
+ return nxt
309
+
310
+
311
+ INCOMPLETE = re.compile(r"no value printed|no nominal value printed|cited page\(s\)|pins missing|pin_count missing|"
312
+ r"pin count: drawing says|has no source page", re.I)
313
+ FAILED = re.compile(r"^(schema:|self-check:|symbol:|footprint_gen\.build failed|altium_lib\.symbol_from_json failed|"
314
+ r"pad_table:)|min <= nom <= max violated|implausib|leads would touch| span \d|pins per side|is odd for a "
315
+ r"dual-row|lead_span min|^exposed_pad\.\w+ [\d.]+ >=|under \d+% of", re.I)
316
+
317
+
318
+ def assess(res):
319
+ """-> (ok | incomplete | failed, reasons) from datasheet.process()'s spec and review list."""
320
+ rev = list(res.get("needs_review") or [])
321
+ unres = list((res.get("spec") or {}).get("unresolved") or [])
322
+ inc = [f"unresolved: {', '.join(unres)}"] if unres else []
323
+ inc += [r for r in rev if INCOMPLETE.search(r)]
324
+ bad = [r for r in rev if FAILED.search(r) and r not in inc]
325
+ if inc:
326
+ return "incomplete", inc + bad
327
+ if bad:
328
+ return "failed", bad
329
+ return "ok", []
330
+
331
+
332
+ # ---------------------------------------------------------------- cost
333
+ def prices(model, prompt_tokens=0):
334
+ from . import datasheet as D
335
+ p = D.price_of(model) # Claude's table, else ecadlib.llm (a local endpoint is $0)
336
+ if not p:
337
+ return None
338
+ lp = LONG_PROMPT.get(model)
339
+ if lp and prompt_tokens > lp[0]:
340
+ p = lp[1]
341
+ return p
342
+
343
+
344
+ def usage_usd(model, usage, batch=False):
345
+ """USD of one response from its usage block (input, cache write 1.25x, cache read, output); half on the Batches
346
+ API. None when the model has no known price."""
347
+ from . import datasheet as D
348
+ u = usage or {}
349
+ tin, cw, cr, tout = (int(u.get(k) or 0) for k in ("input_tokens", "cache_creation_input_tokens",
350
+ "cache_read_input_tokens", "output_tokens"))
351
+ p = prices(model, tin + cw + cr)
352
+ if not p:
353
+ return None
354
+ read = CACHE_READ.get(model, p[0] * 0.1)
355
+ usd = (tin * p[0] + cw * p[0] * D.CACHE_WRITE + cr * read + tout * p[1]) / 1e6
356
+ return round(usd * (D.BATCH_DISCOUNT if batch else 1.0), 6)
357
+
358
+
359
+ def estimate_usd(model, pages, batch=False, out_tokens=None):
360
+ """Rough USD of a read without a usage block: pages x datasheet.PAGE_TOKENS in, a typical answer out."""
361
+ from . import datasheet as D
362
+ tin = (pages or D.URL_PAGES) * D.PAGE_TOKENS + 1500
363
+ tout = D.OUT_TOKENS if out_tokens is None else out_tokens
364
+ p = prices(model, tin)
365
+ if not p:
366
+ return tin, None
367
+ return tin, round((tin * p[0] * (1.0 if batch else D.CACHE_WRITE) + tout * p[1]) / 1e6 * (D.BATCH_DISCOUNT if batch else 1.0), 6)
368
+
369
+
370
+ class Ledger:
371
+ """<out>/datasheet/cost.jsonl: one line per answer that came back (synchronous or batch), with tokens and USD."""
372
+
373
+ def __init__(self, folder):
374
+ self.file = Path(folder) / "cost.jsonl"
375
+
376
+ def add(self, rec):
377
+ self.file.parent.mkdir(parents=True, exist_ok=True)
378
+ rec = {"at": datetime.datetime.now().isoformat(timespec="seconds"), **rec}
379
+ with open(self.file, "a", encoding="utf-8") as f:
380
+ f.write(json.dumps(rec, default=str) + "\n")
381
+ return rec
382
+
383
+
384
+ def billed_model(model, msg=None):
385
+ """The model to price a response at: the one it says served it (a server-side fallback re-runs a declined request
386
+ on another model, billed at that model's rates) when that model has a known price, else the one requested."""
387
+ from . import datasheet as D
388
+ served = (msg or {}).get("model")
389
+ return served if served and served != model and D.price_of(served) else model
390
+
391
+
392
+ def attempt_record(part, name, model, msg, via, pages_sent, pages_total, verdict, nxt=None, error=None):
393
+ batch = str(via or "").startswith("batch")
394
+ u = (msg or {}).get("usage") or {}
395
+ if u:
396
+ usd, how = usage_usd(billed_model(model, msg), u, batch), "usage"
397
+ tin = sum(int(u.get(k) or 0) for k in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
398
+ tout = int(u.get("output_tokens") or 0)
399
+ else:
400
+ tin, usd = estimate_usd(model, pages_sent or pages_total, batch)
401
+ from . import datasheet as D
402
+ tout, how = D.OUT_TOKENS, "estimate"
403
+ rec = {"part": part, "name": name or part, "model": model, "model_served": (msg or {}).get("model"), "via": via,
404
+ "pages_sent": pages_sent, "pages_total": pages_total, "input_tokens": tin, "output_tokens": tout,
405
+ "usd": usd, "how": how, "verdict": verdict}
406
+ if nxt:
407
+ rec["next"] = nxt
408
+ if error:
409
+ rec["error"] = str(error)[:300]
410
+ return rec
411
+
412
+
413
+ def read_ledger(path, problems=None):
414
+ out = []
415
+ try:
416
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
417
+ except OSError:
418
+ return out
419
+ for ln in lines:
420
+ try:
421
+ r = json.loads(ln)
422
+ except ValueError:
423
+ if problems is not None:
424
+ problems.append(f"{path}: a damaged line was skipped")
425
+ continue
426
+ if isinstance(r, dict):
427
+ out.append(r)
428
+ return out
429
+
430
+
431
+ def summarize(records):
432
+ """Totals over ledger records: answers, parts, tokens, USD (live = from usage blocks), per model and per part."""
433
+ s = {"answers": 0, "parts": 0, "input_tokens": 0, "output_tokens": 0, "usd": 0.0, "usd_estimated": 0.0,
434
+ "escalations": 0, "by_model": {}, "by_part": {}}
435
+ for r in records:
436
+ s["answers"] += 1
437
+ s["input_tokens"] += int(r.get("input_tokens") or 0)
438
+ s["output_tokens"] += int(r.get("output_tokens") or 0)
439
+ usd = r.get("usd") or 0.0
440
+ s["usd" if r.get("how") == "usage" else "usd_estimated"] += usd
441
+ s["escalations"] += 1 if r.get("next") else 0
442
+ m = s["by_model"].setdefault(r.get("model") or "?", {"answers": 0, "usd": 0.0})
443
+ m["answers"] += 1
444
+ m["usd"] += usd
445
+ p = s["by_part"].setdefault(r.get("name") or r.get("part") or "?", {"answers": 0, "usd": 0.0, "models": []})
446
+ p["answers"] += 1
447
+ p["usd"] += usd
448
+ if r.get("model") not in p["models"]:
449
+ p["models"].append(r.get("model"))
450
+ s["parts"] = len(s["by_part"])
451
+ for d in [s] + list(s["by_model"].values()) + list(s["by_part"].values()):
452
+ for k in ("usd", "usd_estimated"):
453
+ if k in d:
454
+ d[k] = round(d[k], 4)
455
+ return s
456
+
457
+
458
+ def review_lines(out):
459
+ """Markdown for the librarian's review.md: what the datasheet reads of this output folder cost."""
460
+ recs = read_ledger(Path(out) / "datasheet" / "cost.jsonl")
461
+ if not recs:
462
+ return []
463
+ s = summarize(recs)
464
+ md = ["", "## Datasheet reads: tokens and cost", "",
465
+ f"{s['answers']} answers for {s['parts']} parts, {s['input_tokens']:,} tokens in / {s['output_tokens']:,} out, "
466
+ f"${s['usd']:.4f} from the API usage" + (f" + ${s['usd_estimated']:.4f} estimated" if s["usd_estimated"] else "")
467
+ + (f"; {s['escalations']} answers led to a wider or stronger retry" if s["escalations"] else "") + ".", "",
468
+ "| Part | Model | Pages sent | Tokens in / out | USD | How | Verdict |", "|---|---|---|---|---|---|---|"]
469
+ for r in recs:
470
+ usd = f"{r['usd']:.4f}" if isinstance(r.get("usd"), (int, float)) else "n/a"
471
+ pages = f"{r.get('pages_sent') or 'all'} of {r.get('pages_total') or '?'}"
472
+ md.append(f"| {r.get('name')} | {r.get('model')} | {pages} | {r.get('input_tokens', 0):,} / {r.get('output_tokens', 0):,} "
473
+ f"| {usd} | {r.get('how')} | {r.get('verdict')}{' -> ' + r['next'] if r.get('next') else ''} |")
474
+ return md
475
+
476
+
477
+ # ---------------------------------------------------------------- `ecadlib datasheet pages`: what a read would send
478
+ def page_texts(data):
479
+ """Text of every page (pypdf), or None when there is no pypdf or the PDF can't be read."""
480
+ try:
481
+ import logging, pypdf
482
+ logging.getLogger("pypdf").setLevel(logging.ERROR)
483
+ rd = pypdf.PdfReader(io.BytesIO(data))
484
+ except Exception:
485
+ return None
486
+ out = []
487
+ for pg in rd.pages:
488
+ try:
489
+ out.append(pg.extract_text() or "")
490
+ except Exception:
491
+ out.append("")
492
+ return out
493
+
494
+
495
+ def pages_main(argv=None):
496
+ from . import datasheet as D, rules as RU
497
+ ap = argparse.ArgumentParser(prog="ecadlib datasheet pages",
498
+ description="Which pages of a datasheet PDF a read would send (first try, then wider), and "
499
+ "which model the route starts on. Reads the PDF locally; sends nothing.")
500
+ ap.add_argument("pdf", help="a local datasheet PDF")
501
+ ap.add_argument("--part", help="manufacturer part number (finds its row in the ordering table)")
502
+ ap.add_argument("--package", help="package hint, e.g. a KiCad footprint name such as SOT-223-3_TabPin2")
503
+ ap.add_argument("--budget", type=int, help="pages on the first try (default: [datasheet] page_budget)")
504
+ ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from here)")
505
+ ap.add_argument("--json", action="store_true")
506
+ a = ap.parse_args(argv)
507
+ try:
508
+ cfg = D.settings(RU.load(a.rules, start="."))
509
+ except RU.RulesError as e:
510
+ print(f"error: {e}", file=sys.stderr)
511
+ return 1
512
+ data = Path(a.pdf).read_bytes()
513
+ if not D.is_pdf(data):
514
+ print(f"error: {a.pdf} is not a PDF", file=sys.stderr)
515
+ return 1
516
+ budget = cfg["page_budget"] if a.budget is None else a.budget
517
+ texts, n = page_texts(data), D.pdf_pages(data) or 0
518
+ lv, why = levels(texts or [], budget, a.part, a.package, n) if texts else ([None], {})
519
+ router = Router(cfg)
520
+ rung, reason = router.start(a.package, n, len(lv[0]) if isinstance(lv[0], list) else None)
521
+ steps = [{"pages": p if p else "all", "count": len(p) if p else n} for p in lv]
522
+ out = {"pdf": a.pdf, "pages_total": n, "text_layer": bool(texts and any(t.strip() for t in texts)), "budget": budget,
523
+ "steps": steps, "why": {str(k): v for k, v in why.items()}, "model": router.ladder[rung], "route": router.route,
524
+ "start_reason": reason, "rough_tokens_first_try": steps[0]["count"] * D.PAGE_TOKENS,
525
+ "rough_tokens_whole": n * D.PAGE_TOKENS}
526
+ if a.json:
527
+ print(json.dumps(out, indent=2))
528
+ return 0
529
+ print(f"{a.pdf}: {n} pages{'' if out['text_layer'] else ' (no text layer: the whole PDF is sent)'}; "
530
+ f"[datasheet] page_budget = {budget}")
531
+ for i, st in enumerate(steps):
532
+ label = "first try" if i == 0 else ("then the whole PDF" if st["pages"] == "all" else "then")
533
+ print(f" {label:<18} {st['count']:>4} pages {st['pages'] if st['pages'] == 'all' else ','.join(map(str, st['pages']))}")
534
+ for k, v in why.items():
535
+ if k == "_package":
536
+ print(f" this part's package: {', '.join(v)}")
537
+ else:
538
+ print(f" p{k}: {', '.join(v)}")
539
+ print(f" model: {router.ladder[rung]} ({reason}); rough input {out['rough_tokens_first_try']:,} tokens vs "
540
+ f"{out['rough_tokens_whole']:,} for the whole PDF ({D.PAGE_TOKENS} per page, no key: not counted)")
541
+ return 0