@sitar_fiercer4c/skills 0.1.0

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 (50) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +75 -0
  3. package/bin/install.js +45 -0
  4. package/package.json +29 -0
  5. package/skills/architecture-walkthrough/SKILL.md +223 -0
  6. package/skills/architecture-walkthrough/references/sections.md +29 -0
  7. package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
  8. package/skills/autotest-webapp-ui/SKILL.md +58 -0
  9. package/skills/backend-code-review/SKILL.md +386 -0
  10. package/skills/backend-code-review/references/report-format.md +333 -0
  11. package/skills/backend-code-review/scripts/list_routes.py +269 -0
  12. package/skills/backend-code-review/scripts/sweep.py +550 -0
  13. package/skills/backend-code-review/scripts/verify_citations.py +201 -0
  14. package/skills/be-brief/SKILL.md +18 -0
  15. package/skills/clarke-list-excel/SKILL.md +51 -0
  16. package/skills/clarke-list-excel/references/output-schema.md +125 -0
  17. package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
  18. package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
  19. package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
  20. package/skills/clarke-list-excel/scripts/run_all.py +63 -0
  21. package/skills/datalab-api/SKILL.md +163 -0
  22. package/skills/datalab-api/references/parameters-and-payload.md +121 -0
  23. package/skills/datalab-api/references/table-selection.md +35 -0
  24. package/skills/datalab-api/scripts/datalab_tables.py +365 -0
  25. package/skills/find-test-seam/SKILL.md +41 -0
  26. package/skills/frontend-code-review/SKILL.md +247 -0
  27. package/skills/frontend-code-review-2/SKILL.md +192 -0
  28. package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
  29. package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
  30. package/skills/murtaza-breif/SKILL.md +143 -0
  31. package/skills/murtaza-breif/scripts/save_brief.py +128 -0
  32. package/skills/pdf-to-json/SKILL.md +42 -0
  33. package/skills/pdf-to-json/references/output-schema.md +168 -0
  34. package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
  35. package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
  36. package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
  37. package/skills/record-api-traffic/SKILL.md +434 -0
  38. package/skills/record-api-traffic/references/reading-recordings.md +224 -0
  39. package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
  40. package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
  41. package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
  42. package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
  43. package/skills/record-api-traffic/scripts/preflight.py +528 -0
  44. package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
  45. package/skills/refac-wrt-business-goal/SKILL.md +305 -0
  46. package/skills/refac-wrt-business-goal/references/critic.md +170 -0
  47. package/skills/system-resource-triage/SKILL.md +180 -0
  48. package/skills/system-resource-triage/scripts/reap.sh +116 -0
  49. package/skills/system-resource-triage/scripts/triage.sh +111 -0
  50. package/skills/using-git-worktrees/SKILL.md +167 -0
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """Upload the product photos to S3 and upsert the products into MongoDB.
3
+
4
+ python scripts/load_clarke.py <out-dir>/clarke-products.json # loads
5
+ python scripts/load_clarke.py <out-dir>/clarke-products.json --dry-run # preview
6
+ python scripts/load_clarke.py <out-dir>/clarke-products.json --check # where does it point?
7
+
8
+ **This writes by default.** The owner of the bucket and the cluster has given
9
+ standing permission for this pipeline, so making the load conditional on a
10
+ second flag just meant the data sat on disk while someone retyped the command.
11
+
12
+ What that removes is a prompt, not the care behind it. The run still prints
13
+ which .env it read and which host and database it reached, still refuses to
14
+ touch the database if any upload failed, still names duplicate part numbers
15
+ before collapsing them, and still counts the collection back through the
16
+ connection it wrote through rather than trusting the acknowledgement.
17
+
18
+ Order is deliberate: photos first, products second. Each product stores the key
19
+ of its image, so uploading first means a document never references an object
20
+ that is not there. If the upload half fails, no products are written and the
21
+ run can simply be repeated.
22
+
23
+ Products go to the `clarke` collection, which holds this catalogue and nothing
24
+ else. That is worth stating because it was not always so: these products were
25
+ first loaded into `uken-products` alongside the UKEN catalogue, and the
26
+ collision guard below was written for that arrangement. A dedicated collection
27
+ removes the risk rather than managing it — nothing else keys on partNo here, so
28
+ there is nothing to overwrite.
29
+
30
+ The guard is kept anyway. It costs one query, and `--collection` can still
31
+ point this at a shared collection, which is exactly when an upsert that
32
+ silently overwrote another catalogue's product would leave nothing behind to
33
+ notice.
34
+ """
35
+
36
+ import argparse
37
+ import json
38
+ import re
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
43
+ from clarke_common import credential, load_env, require
44
+
45
+ COLLECTION = "clarke"
46
+ # S3 keys have no leading slash. The brief says "/products/catalog"; written
47
+ # literally that creates a bucket entry whose first path segment is empty,
48
+ # which renders as a nameless folder in the console and is a nuisance to
49
+ # remove. The vendor segment keeps this catalogue's objects separable from the
50
+ # UKEN ones already under the same prefix.
51
+ PREFIX = "products/catalog/clarke"
52
+ SUPPLIER = "Clarke"
53
+
54
+ CONTENT_TYPES = {"jpeg": "image/jpeg", "jpg": "image/jpeg", "png": "image/png",
55
+ "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"}
56
+ # Formats a browser will not render. They are uploaded only if someone asks,
57
+ # because an <img> pointing at an EMF is a broken image on a product page and
58
+ # is indistinguishable from a product that simply has no photo.
59
+ UNRENDERABLE = {"emf", "wmf", "tiff", "tif", "unknown", "bin"}
60
+
61
+ UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
62
+
63
+
64
+ def object_key(part_no: str, extension: str) -> str:
65
+ """`products/catalog/clarke/<part no>.<ext>`, safe for a key.
66
+
67
+ Part numbers print with spaces, quotes and slashes — `1/2 DR` is a real
68
+ one. A slash left in place silently becomes a folder level, so the object
69
+ lands somewhere nobody will look for it; the rest are merely awkward. The
70
+ printed form survives on the document as `partNoRaw`, so nothing is lost
71
+ by flattening it here.
72
+ """
73
+ safe = UNSAFE.sub("-", part_no).strip("-") or "unnamed"
74
+ return f"{PREFIX}/{safe}.{extension}"
75
+
76
+
77
+ def document(product: dict, image_key: str | None, source: str) -> dict:
78
+ return {
79
+ "partNo": product["partNo"],
80
+ # Kept because the key is flattened for S3 and normalised for Mongo;
81
+ # this is what the price list actually printed.
82
+ "partNoRaw": product.get("partNoRaw"),
83
+ "description": product["description"],
84
+ "category": product.get("category"),
85
+ "subCategory": product.get("subCategory"),
86
+ "pktQty": product.get("pktQty"),
87
+ "ctnQty": product.get("ctnQty"),
88
+ "price": product.get("price"),
89
+ "priceLabel": product.get("priceLabel", "TRADER Price"),
90
+ "currency": product.get("currency", "AED"),
91
+ # Provenance rather than a discriminator now that the collection holds
92
+ # one catalogue — but it is what lets these documents be merged into a
93
+ # shared collection later without becoming anonymous, and it is what
94
+ # the collision guard reads.
95
+ "supplier": SUPPLIER,
96
+ "source": source,
97
+ "sheetRow": product.get("sheetRow"),
98
+ "imageKey": image_key,
99
+ # "span" means the photo's own extent covered this row; "nearest"
100
+ # means it covered no product row and was attached to the closest one.
101
+ # The second is inference and a reader should be able to see which.
102
+ "imageMatch": product.get("imageMatch"),
103
+ # True when the photo's span covered several product rows and this is
104
+ # one of them — the sheet illustrated the group, not this part number
105
+ # alone. Worth carrying: it is the difference between "here is this
106
+ # product" and "here is what this family looks like".
107
+ "imageShared": bool(product.get("imageShared")),
108
+ }
109
+
110
+
111
+ def resolve(args):
112
+ values, env_path = load_env(args.env)
113
+ return values, env_path
114
+
115
+
116
+ def check(values, env_path) -> int:
117
+ """Answer 'where does this point?' without writing anything."""
118
+ from pymongo import MongoClient
119
+ import boto3
120
+
121
+ print(f"credentials: {env_path}")
122
+ bucket = require(values, "S3_BUCKET")
123
+ region = credential(values, "AWS_REGION") or "us-east-1"
124
+ print(f"s3: s3://{bucket}/{PREFIX}/ (region {region})")
125
+ session = boto3.session.Session(
126
+ aws_access_key_id=require(values, "AWS_ACCESS_KEY_ID"),
127
+ aws_secret_access_key=require(values, "AWS_SECRET_ACCESS_KEY"),
128
+ region_name=region)
129
+ s3 = session.client("s3")
130
+ listing = s3.list_objects_v2(Bucket=bucket, Prefix=PREFIX + "/", MaxKeys=5)
131
+ print(f" objects already under that prefix: {listing.get('KeyCount', 0)}")
132
+
133
+ uri = require(values, "MONGODB_URI", "MONGO_URI")
134
+ client = MongoClient(uri, serverSelectionTimeoutMS=15000)
135
+ database = client.get_default_database()
136
+ print(f"mongo: {database.name} on "
137
+ f"{','.join(h[0] for h in client.topology_description.server_descriptions())}")
138
+ collection = database[COLLECTION]
139
+ total = collection.estimated_document_count()
140
+ mine = collection.count_documents({"supplier": SUPPLIER})
141
+ print(f" {COLLECTION}: {total:,} documents, {mine:,} of them {SUPPLIER}")
142
+ print(f" indexes: {[i['name'] for i in collection.list_indexes()]}")
143
+ return 0
144
+
145
+
146
+ def main() -> int:
147
+ parser = argparse.ArgumentParser(description=__doc__)
148
+ parser.add_argument("json_path")
149
+ parser.add_argument("--env")
150
+ parser.add_argument("--collection", default=COLLECTION)
151
+ parser.add_argument("--dry-run", action="store_true")
152
+ parser.add_argument("--check", action="store_true")
153
+ parser.add_argument("--upload-unrenderable", action="store_true",
154
+ help="also upload EMF/WMF media a browser cannot show")
155
+ args = parser.parse_args()
156
+
157
+ values, env_path = resolve(args)
158
+ if args.check:
159
+ return check(values, env_path)
160
+
161
+ payload = json.loads(Path(args.json_path).read_text())
162
+ products = payload["products"]
163
+ source = payload["source"]["file"]
164
+ image_dir = Path(args.json_path).resolve().parent / "images"
165
+
166
+ print(f"credentials: {env_path}")
167
+
168
+ duplicates = payload["summary"].get("duplicate_part_numbers") or {}
169
+ if duplicates:
170
+ print(f"note: {len(duplicates)} part numbers appear twice in the "
171
+ f"document and will collapse to one document each on upsert:")
172
+ for part, rows in list(duplicates.items())[:10]:
173
+ print(f" {part} at rows {', '.join(str(r) for r in rows)}")
174
+
175
+ # ---- work out which objects to upload
176
+ uploads, skipped_format = {}, []
177
+ for product in products:
178
+ digest = product.get("imageSha256")
179
+ if not digest:
180
+ continue
181
+ extension = (product.get("imageFormat") or "unknown").lower()
182
+ if extension in UNRENDERABLE and not args.upload_unrenderable:
183
+ skipped_format.append((product["partNo"], extension))
184
+ continue
185
+ path = image_dir / f"{digest}.{extension}"
186
+ if not path.is_file():
187
+ raise SystemExit(
188
+ f"{path} is missing — run clarke_extract.py first. Loading "
189
+ f"without it would write documents pointing at objects that "
190
+ f"do not exist, and every other field on them would be right.")
191
+ uploads[product["partNo"]] = (path, object_key(product["partNo"], extension))
192
+
193
+ if skipped_format:
194
+ print(f"note: {len(skipped_format)} photos are in a format browsers "
195
+ f"cannot display and were left out "
196
+ f"({', '.join(sorted({e for _, e in skipped_format}))}); "
197
+ f"pass --upload-unrenderable to send them anyway")
198
+
199
+ print(f"{len(products):,} products, {len(uploads):,} photos to upload")
200
+
201
+ if args.dry_run:
202
+ print("\n--dry-run: nothing was written")
203
+ for part, (path, key) in list(uploads.items())[:5]:
204
+ print(f" would PUT s3://{require(values, 'S3_BUCKET')}/{key} "
205
+ f"({path.stat().st_size:,} bytes)")
206
+ if len(uploads) > 5:
207
+ print(f" ... and {len(uploads) - 5:,} more")
208
+ sample = document(products[0], uploads.get(products[0]["partNo"], (None, None))[1],
209
+ source)
210
+ print(f" would upsert {len(products):,} documents into "
211
+ f"{args.collection}, keyed on partNo, e.g.:")
212
+ print(" " + json.dumps(sample, indent=2).replace("\n", "\n "))
213
+ return 0
214
+
215
+ import boto3
216
+ from pymongo import MongoClient, UpdateOne
217
+
218
+ # ---- stage one: the photos
219
+ bucket = require(values, "S3_BUCKET")
220
+ region = credential(values, "AWS_REGION") or "us-east-1"
221
+ session = boto3.session.Session(
222
+ aws_access_key_id=require(values, "AWS_ACCESS_KEY_ID"),
223
+ aws_secret_access_key=require(values, "AWS_SECRET_ACCESS_KEY"),
224
+ region_name=region)
225
+ s3 = session.client("s3")
226
+
227
+ existing = {}
228
+ token = None
229
+ while True:
230
+ page = s3.list_objects_v2(**{"Bucket": bucket, "Prefix": PREFIX + "/",
231
+ **({"ContinuationToken": token} if token else {})})
232
+ for item in page.get("Contents") or []:
233
+ existing[item["Key"]] = item["Size"]
234
+ token = page.get("NextContinuationToken")
235
+ if not token:
236
+ break
237
+
238
+ uploaded = reused = 0
239
+ failures = []
240
+ for part, (path, key) in sorted(uploads.items()):
241
+ size = path.stat().st_size
242
+ # Idempotent by construction: an object already present at the same
243
+ # size is the same photo, so a re-run after a parsing fix does not
244
+ # re-send 238 files.
245
+ if existing.get(key) == size:
246
+ reused += 1
247
+ continue
248
+ extension = path.suffix.lstrip(".").lower()
249
+ try:
250
+ s3.put_object(Bucket=bucket, Key=key, Body=path.read_bytes(),
251
+ ContentType=CONTENT_TYPES.get(extension,
252
+ "application/octet-stream"))
253
+ uploaded += 1
254
+ except Exception as error: # noqa: BLE001
255
+ failures.append((part, key, str(error)))
256
+
257
+ print(f"s3://{bucket}/{PREFIX}/ — {uploaded:,} uploaded, "
258
+ f"{reused:,} already present, {len(failures)} failed")
259
+
260
+ if failures:
261
+ for part, key, error in failures[:5]:
262
+ print(f" {part}: {error}")
263
+ raise SystemExit(
264
+ "uploads failed, so nothing was written to the database. A "
265
+ "document referencing a missing object is worse than no document "
266
+ "and is invisible from the database side. Fix and re-run.")
267
+
268
+ # ---- stage two: the products
269
+ uri = require(values, "MONGODB_URI", "MONGO_URI")
270
+ client = MongoClient(uri, serverSelectionTimeoutMS=20000)
271
+ database = client.get_default_database()
272
+ collection = database[args.collection]
273
+ hosts = ",".join(h[0] for h in client.topology_description.server_descriptions())
274
+ print(f"mongo: {database.name} on {hosts}, collection {args.collection}")
275
+
276
+ # The collision guard. Anything already in the collection under one of our
277
+ # part numbers but belonging to someone else would be overwritten in
278
+ # place, leaving no trace of what it was.
279
+ part_numbers = [p["partNo"] for p in products]
280
+ clashes = list(collection.find(
281
+ {"partNo": {"$in": part_numbers}, "supplier": {"$ne": SUPPLIER}},
282
+ {"partNo": 1, "supplier": 1, "source": 1}).limit(20))
283
+ if clashes:
284
+ print(f"\n{len(clashes)} part numbers already belong to another "
285
+ f"catalogue in {args.collection}:")
286
+ for clash in clashes[:10]:
287
+ print(f" {clash['partNo']} — supplier "
288
+ f"{clash.get('supplier') or '(none)'}, "
289
+ f"source {clash.get('source') or '(none)'}")
290
+ raise SystemExit(
291
+ "refusing to upsert: these would overwrite another catalogue's "
292
+ "products in place. Decide what should happen to them first — "
293
+ "prefixing this catalogue's keys is the usual answer.")
294
+
295
+ collection.create_index("partNo", unique=True)
296
+ operations = [
297
+ UpdateOne({"partNo": product["partNo"]},
298
+ {"$set": document(product,
299
+ uploads.get(product["partNo"], (None, None))[1],
300
+ source)},
301
+ upsert=True)
302
+ for product in products
303
+ ]
304
+ result = collection.bulk_write(operations, ordered=False)
305
+ print(f"upserted: {result.upserted_count:,} new, "
306
+ f"{result.modified_count:,} updated")
307
+
308
+ # An acknowledgement is not evidence. Count back through the same
309
+ # connection the write went through.
310
+ expected = len({p["partNo"] for p in products})
311
+ actual = collection.count_documents({"supplier": SUPPLIER})
312
+ total = collection.estimated_document_count()
313
+ print(f"read back: {actual:,} {SUPPLIER} documents in {args.collection} "
314
+ f"(expected {expected:,}); {total:,} documents in total")
315
+ if actual != expected:
316
+ print(f" mismatch of {abs(actual - expected):,} — investigate "
317
+ f"before treating this run as complete")
318
+ return 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ raise SystemExit(main())
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env python3
2
+ """Run the whole Clarke pipeline: extract, then upload and upsert.
3
+
4
+ python scripts/run_all.py "<xlsx>" --out-dir <dir>
5
+ python scripts/run_all.py "<xlsx>" --out-dir <dir> --dry-run
6
+
7
+ Stops at the first failure. Loading from a half-finished extraction writes
8
+ documents whose every field is right except that their photo is not there, and
9
+ nothing about that looks wrong until someone opens a product page.
10
+ """
11
+
12
+ import argparse
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ HERE = Path(__file__).resolve().parent
18
+
19
+
20
+ def run(command: list[str]) -> None:
21
+ print(f"\n$ {' '.join(command)}\n", flush=True)
22
+ result = subprocess.run(command)
23
+ if result.returncode != 0:
24
+ raise SystemExit(f"stage failed ({result.returncode}); nothing further ran")
25
+
26
+
27
+ def main() -> int:
28
+ parser = argparse.ArgumentParser(description=__doc__)
29
+ parser.add_argument("workbook")
30
+ parser.add_argument("--out-dir", default=".")
31
+ parser.add_argument("--env")
32
+ parser.add_argument("--sheet")
33
+ parser.add_argument("--refresh", action="store_true")
34
+ parser.add_argument("--no-datalab", action="store_true")
35
+ parser.add_argument("--dry-run", action="store_true")
36
+ args = parser.parse_args()
37
+
38
+ out_dir = Path(args.out_dir).expanduser()
39
+
40
+ extract = [sys.executable, str(HERE / "clarke_extract.py"), args.workbook,
41
+ "--out-dir", str(out_dir)]
42
+ if args.env:
43
+ extract += ["--env", args.env]
44
+ if args.sheet:
45
+ extract += ["--sheet", args.sheet]
46
+ if args.refresh:
47
+ extract.append("--refresh")
48
+ if args.no_datalab:
49
+ extract.append("--no-datalab")
50
+ run(extract)
51
+
52
+ load = [sys.executable, str(HERE / "load_clarke.py"),
53
+ str(out_dir / "clarke-products.json")]
54
+ if args.env:
55
+ load += ["--env", args.env]
56
+ if args.dry_run:
57
+ load.append("--dry-run")
58
+ run(load)
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ raise SystemExit(main())
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: datalab-api
3
+ description: >-
4
+ Extract tables from spreadsheets (xlsx, xls, csv, ods) using the Datalab
5
+ Convert API at datalab.to — get each table's source cell range in A1
6
+ notation, headers, and rows. Use this skill whenever the user mentions
7
+ Datalab, datalab.to, marker, or a DATALAB_API_KEY, and also whenever they
8
+ want to pull tables, line items, bills of quantities, BOQs, quotes, or
9
+ priced item lists out of vendor spreadsheets — even if they never name the
10
+ API. Covers the async submit-and-poll flow, the block tree, the undocumented
11
+ spreadsheet bbox format, which parameters silently do nothing on
12
+ spreadsheets, and how to pick the one table that matters out of many.
13
+ ---
14
+
15
+ # Datalab Convert API for spreadsheets
16
+
17
+ Datalab segments a messy sheet into discrete **table blocks** — a sheet holding a
18
+ title block, a line-item table, and terms-and-conditions comes back as three
19
+ tables, each with the cell range it occupies. That segmentation is the value. If
20
+ you only need cell values, `openpyxl` is free and instant. Reach for Datalab when
21
+ you need to know *where the tables are* in a sheet nobody has described to you.
22
+
23
+ ## Start here
24
+
25
+ The bundled script does the whole flow — submit, poll, walk the block tree,
26
+ convert bboxes to A1 ranges, cache responses:
27
+
28
+ ```bash
29
+ python scripts/datalab_tables.py <file-or-dir> --json tables.json --preview tables.md
30
+ ```
31
+
32
+ `--json` is a machine-readable index of every table; `--preview` renders each
33
+ table's headers plus first and last rows, which is what you want when a human
34
+ needs to eyeball whether extraction went right. Responses cache under
35
+ `./datalab_raw/` keyed by filename and mode, so re-runs are free — delete it to
36
+ force a fresh call. Needs `DATALAB_API_KEY` in the environment, or in a
37
+ `.env.local` / `.env` in any parent directory of the working directory.
38
+
39
+ Two references cover the paths the script doesn't:
40
+
41
+ - `references/parameters-and-payload.md` — the raw request, which parameters are
42
+ silently no-ops, how to prove it, and why responses are enormous. Read it
43
+ before setting any flag, especially anything about images or response size.
44
+ - `references/table-selection.md` — automating table choice with a model call.
45
+
46
+ ## Reading the output
47
+
48
+ `output_format=json` returns a recursive block tree: root → `Page` blocks (one
49
+ per worksheet, named after the sheet) → `Table`, `Picture`, `Text`. Each table
50
+ block carries `id` (a path within *this* response, e.g. `/page/BOH & FLS/Table/2`),
51
+ `bbox`, `html`, and `children`. Blocks nest arbitrarily, so recurse rather than
52
+ assuming depth — `find_tables()` in the script does it. Match `TableOfContents`
53
+ too; Datalab sometimes classifies a plain lead-in table that way.
54
+
55
+ **Block ids are not stable across runs.** The same workbook has come back as four
56
+ table blocks on one call and five on the next, so today's `Table/2` is tomorrow's
57
+ `Table/3`. The id is a handle within one response, nothing more. Resolve ids to
58
+ A1 ranges as soon as you have them, and report and persist the *range* — that's
59
+ the identifier that stays true to the source file.
60
+
61
+ Parse `html` with the stdlib `html.parser`; no BeautifulSoup needed, and
62
+ `to_grid()` already does it, padding ragged rows to equal width. Only `json`
63
+ carries `bbox` — markdown and HTML lose table boundaries, which defeats the point.
64
+
65
+ ### bbox is cell coordinates, not pixels
66
+
67
+ Undocumented, and the single most important thing to know:
68
+
69
+ ```
70
+ bbox = [col_start, row_start, col_end, row_end] 1-indexed, inclusive
71
+ ```
72
+
73
+ So `[1.0, 6.0, 8.0, 13.0]` means **A6:H13**. They arrive as floats but are whole
74
+ numbers. For PDFs and images the same field holds pixel coordinates, so don't
75
+ carry the assumption across file types. `excel_range()` converts, including
76
+ multi-letter columns past Z. This has held on every spreadsheet tested, but it's
77
+ inference, not contract — on a new workbook, open it with `openpyxl` and confirm
78
+ the cells at the reported range are the table you expect.
79
+
80
+ ### Three bbox conventions coexist in one response
81
+
82
+ The rule above is for `Table` blocks. Mixing these up silently produces wrong
83
+ ranges:
84
+
85
+ | Block | bbox | Meaning |
86
+ |---|---|---|
87
+ | `Table`, `Text` | `[1, 6, 9, 10]` | 1-indexed inclusive cell range → A6:I10 |
88
+ | `Page` | `[0, 0, 10, 19]` | sheet *extent*: 10 columns × 19 rows, not a range |
89
+ | `Picture` | `[6.29, 7.15, 6.64, 7.86]` | continuous — integer part is the 1-indexed cell, the fraction is the offset within it |
90
+
91
+ A `Page` bbox always starts `[0, 0, ...]`; that leading zero is the tell, and
92
+ `excel_range()` over it gives a bogus range at row 0. `Picture` bboxes floor to
93
+ the anchoring cell — `[6.29, 7.15, ...]` is a photo in **F7** — which is what lets
94
+ you map images back to rows.
95
+
96
+ ## Picking the right table
97
+
98
+ A sheet typically yields several tables and you want one. Header rows are often
99
+ blank, merged, or split across two rows, so structural heuristics are brittle;
100
+ describing the target in prose and choosing from short previews works better.
101
+
102
+ Describe it by columns and content, not size. "Each row is one physical item;
103
+ columns give a category, an item name or description, and a quantity; price cells
104
+ are sometimes blank for vendors to fill" selects reliably. "The biggest table"
105
+ does not — terms-and-conditions blocks are often larger. Naming the likely
106
+ distractors (title blocks, quote metadata, terms, delivery terms) helps more than
107
+ elaborating the target.
108
+
109
+ Five rows per table is enough signal, since what distinguishes tables is in the
110
+ headers and first rows. Truncate cells to ~40 characters so paragraph-length item
111
+ descriptions don't crowd out other tables. Allow a *list* of tables back — a
112
+ workbook can legitimately hold two line-item tables, and a scalar silently
113
+ discards one.
114
+
115
+ ## Checking extraction fidelity
116
+
117
+ Datalab finds the right table consistently, but its boundaries are a first draft.
118
+ Two drifts recur:
119
+
120
+ - **Trailing rows that aren't items** — `Total cost DDP`, `VAT (if applicable)`,
121
+ `SUBTOTAL - NET`, usually a `SUM()` over the rows above, often with the label in
122
+ one cell and the rest blank. Datalab pulls them inside the table.
123
+ - **A promoted header** — a sheet title one row above the real header gets
124
+ absorbed, so the reported range starts a row early.
125
+
126
+ So open the workbook and check the edges rather than reporting the bbox as-is.
127
+
128
+ Which range you *lead with* depends on what was asked. "Where does this block come
129
+ from?" wants the block's actual extent — answer A6:I10 and mention the trailer
130
+ separately. "Where is the line-item table?" wants the items — lead with A6:I9. Get
131
+ this backwards and the headline number doesn't answer the question, even though
132
+ both numbers appear somewhere in the reply.
133
+
134
+ When items are what's wanted, a range excluding the trailers is *more* accurate
135
+ than Datalab's, not a disagreement with it. Report the tighter range and say
136
+ plainly which rows you dropped and why — "rows 11–13 are total/VAT/grand-total,
137
+ not items" is the part the user actually needs, because it's the difference
138
+ between 3 line items and 6. Some rows are genuinely borderline: a `FREIGHT /
139
+ PACKING / DOCS` line with a quantity of 1 is arguably an item and arguably a cost
140
+ trailer. Don't resolve that silently — name it and let the user decide.
141
+
142
+ Trim rows, not columns. A sparse trailing column looks like padding but often
143
+ isn't: one sheet's last column held only three cells, and they were a note and two
144
+ vendor URLs. Narrowing the range to "tidy it up" deleted real data. Empty columns
145
+ are safe to drop; populated ones need saying out loud. To confirm nothing was
146
+ dropped, count non-empty rows per sheet with `openpyxl` and compare against the
147
+ sum of rows across extracted tables — they should match, or Datalab should have
148
+ slightly more.
149
+
150
+ ## Cost and docs
151
+
152
+ Billing is per page, where a worksheet counts as a page; small workbooks cost 1–2
153
+ credits. Datalab caches server-side, so re-uploading an identical file is free
154
+ unless you pass `skip_cache=true`. Cache raw responses to disk anyway —
155
+ experiments mean many re-runs over the same files.
156
+
157
+ `https://documentation.datalab.to/llms.txt` lists every docs page as markdown;
158
+ fetch it rather than guessing URLs, since the docs site 404s on plausible-looking
159
+ paths and appending `.md` to any page returns clean markdown. The OpenAPI spec at
160
+ `https://www.datalab.to/openapi.json` is the most complete parameter list but lags
161
+ the running API — its `extras` enum omits `extract_bookmarks`, which the API's own
162
+ 422 message lists. When docs and observed behaviour disagree, trust the behaviour
163
+ and verify with a deliberately invalid value.
@@ -0,0 +1,121 @@
1
+ # Parameters, no-ops, and payload size
2
+
3
+ Read this when the question is about request parameters, response size, or
4
+ images — e.g. "can we turn off image extraction", "why is the response 3.7 MB",
5
+ "does accurate mode help", or any time you're about to set a flag you haven't
6
+ personally verified on a spreadsheet.
7
+
8
+ ## The raw request
9
+
10
+ The script covers the normal path; write the request yourself only when you need
11
+ parameters it doesn't expose. Conversion is async: the POST returns a
12
+ `request_check_url` to poll every couple of seconds. Expect 13–25s for a typical
13
+ workbook. Auth is `X-API-Key`, not a bearer token. There is no table-extraction
14
+ endpoint — the old Table Recognition one was retired.
15
+
16
+ ```python
17
+ resp = requests.post("https://www.datalab.to/api/v1/convert",
18
+ headers={"X-API-Key": key},
19
+ files={"file": (path.name, fh, mime)},
20
+ data={"output_format": "json", "mode": "fast"})
21
+ check_url = resp.json()["request_check_url"]
22
+ while (r := requests.get(check_url, headers={"X-API-Key": key}).json())["status"] != "complete":
23
+ time.sleep(2)
24
+ ```
25
+
26
+ ## Only three parameters matter
27
+
28
+ `file` (multipart, correct MIME type per extension), `output_format=json` (the
29
+ only format carrying `bbox`), and `mode=fast`.
30
+
31
+ `mode` deserves emphasis because it's the one people reach for first. On
32
+ spreadsheets it changes nothing, so use `fast`. On PDFs and images it's a real
33
+ accuracy/latency tradeoff.
34
+
35
+ ## Most other parameters do nothing on spreadsheets
36
+
37
+ The API accepts them, returns 200, and bills normally — the output is
38
+ byte-identical to a control. All of these were tested that way on real xlsx
39
+ files:
40
+
41
+ | Parameter | Tested as |
42
+ |---|---|
43
+ | `mode` | `fast` / `balanced` / `accurate` — identical block trees, same cost and runtime |
44
+ | `add_block_ids` | `true`, with `output_format=json,html` — zero `data-block-id` attributes |
45
+ | `disable_image_extraction` | `true`, `True`, and with `disable_image_captions` — images still returned |
46
+ | `extras=table_cell_bboxes` | with `json` and with `json,html` plus `word_bboxes=true` — zero `data-bbox` |
47
+ | `extras=extract_links,new_block_types` | identical `json` |
48
+ | `word_bboxes` | `true` — zero annotations |
49
+
50
+ The bbox add-ons bill at $0.30/1K pages each and need `html` output to expose
51
+ attributes, so on spreadsheets you'd pay for nothing.
52
+
53
+ It's tempting to explain the no-ops as "spreadsheets skip the vision pipeline, so
54
+ vision options are inert." Resist it — that mechanism has not been tested, and a
55
+ PDF control showed `disable_image_extraction` doing nothing there either, which
56
+ the story doesn't predict. Report the measured no-op and leave the cause open. A
57
+ tidy explanation repeated confidently is how an untested guess ends up in an
58
+ answer that reads as measured.
59
+
60
+ One option does work: `additional_config={"keep_spreadsheet_formatting": true}`
61
+ adds per-cell `data-excel-col`, fill colours, fonts and number formats, roughly
62
+ 4.5x-ing the payload. Useful if you need header shading or currency formats as
63
+ signals. Skip it if you just need values — `data-excel-col` counts up
64
+ sequentially from the bbox start, so it's already derivable.
65
+
66
+ Asking for markdown instead of json doesn't shrink anything either: the json tree
67
+ comes back regardless and can't be suppressed, and it's the json that carries the
68
+ duplicated base64 images.
69
+
70
+ ## Proving a parameter is really a no-op
71
+
72
+ **An unknown parameter name returns HTTP 200 and is silently ignored**, so a typo
73
+ looks exactly like a feature that doesn't work. A known parameter with a bad
74
+ value errors clearly:
75
+
76
+ ```
77
+ disable_image_extractoin=true -> 200 (typo, silently dropped)
78
+ disable_image_extraction=banana -> 422 bool_parsing at body.disable_image_extraction
79
+ mode=banana -> 400 "Must be one of 'fast', 'balanced', 'accurate'"
80
+ extras=banana -> 400 lists all valid extras
81
+ ```
82
+
83
+ So when a parameter seems to do nothing, send a deliberately invalid value first:
84
+ a 400 or 422 naming your field proves it reached the server and the no-op is real
85
+ behaviour, while a 200 means you misspelled it. Then compare against a control
86
+ run with `skip_cache=true` on both sides and diff the actual payloads rather than
87
+ trusting that a flag did what its name suggests. Several documented parameters
88
+ turn out to be no-ops here with no caveat in the docs.
89
+
90
+ ## Images always come back
91
+
92
+ `disable_image_extraction` has no effect on spreadsheets — images return whether
93
+ you set it `true` or `false`. They arrive twice, under the top-level `images` key
94
+ and inside each `Picture` block, and on image-heavy sheets that is ~98% of the
95
+ payload: a 3.74 MB response can hold 10 KB of table data. Quote that figure
96
+ carefully — it's the decoded size, and the API gzips, so ~3.7 MB decoded moved
97
+ about 2.3 MB over the wire. Stripping images wins on storage, parsing and tokens
98
+ into a model, not on bandwidth.
99
+
100
+ **First ask whether the images are content.** Compare the `Picture` count to the
101
+ row count of the line-item table. On product and BOQ sheets they often match
102
+ nearly 1:1 — each row has a photo and the table has an `Image` column whose HTML
103
+ cells come back empty, so those images *are* that column's data. One test sheet
104
+ had 49 pictures against 48 item rows, with `Picture` bboxes landing on rows 4–51.
105
+
106
+ There are two places to strip, and that gate decides which:
107
+
108
+ - **After receipt** (default). Drop the `images` dict and each block's image
109
+ payload but keep the `Picture` blocks, so the bboxes survive and row↔photo
110
+ mapping stays recoverable if the images turn out to matter. The bundled script
111
+ does this by default; `--keep-images` opts out.
112
+ - **Before upload**, only once you've confirmed the images are decorative — logos,
113
+ letterheads. Deleting `xl/media/` and `xl/drawings/` from the xlsx zip took one
114
+ upload from 9.28 MB to 10.9 KB and its response from 3.73 MB to 43 KB, wall time
115
+ 21.3s → 16.4s, text fidelity 0.997. That's the only lever that shrinks the
116
+ *upload*, but it's destructive: you must also fix the sheet `.rels` and drawing
117
+ references or you hand Datalab a workbook it may reject, and the row↔photo link
118
+ is gone for good.
119
+
120
+ "There's no API-side lever, so nothing can be done" is wrong on both counts — the
121
+ client side is where the whole win is.