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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- 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
|