vision-electronic-indexing-pi 0.1.6 → 0.1.8

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.
@@ -16,7 +16,9 @@ Then in Pi:
16
16
  /vision-inventory-setup
17
17
  ```
18
18
 
19
- Setup checks Python dependencies, checks whether a web-search/browser tool is available for datasheet lookup, and prompts for Cloudflare Workers AI credentials when needed.
19
+ Setup creates/checks a Pi-managed Python virtual environment at `~/.pi/agent/vision-inventory/.venv`, installs Python dependencies there when approved, warns that datasheet lookup needs a separate web-search/browser capability, and prompts for Cloudflare Workers AI API token credentials when needed.
20
+
21
+ On Debian/Ubuntu, Python venv creation may require `python3-venv` or a version-specific package such as `python3.10-venv`. If setup reports that `ensurepip` is unavailable, install the package, remove the incomplete venv with `rm -rf ~/.pi/agent/vision-inventory/.venv`, and rerun `/vision-inventory-setup`.
20
22
 
21
23
  Credentials are stored at:
22
24
 
@@ -24,6 +26,8 @@ Credentials are stored at:
24
26
  ~/.pi/agent/vision-inventory/credentials.json
25
27
  ```
26
28
 
29
+ The file is written with `chmod 600` when supported. Token input may be visible depending on your Pi UI; avoid entering credentials while screen sharing.
30
+
27
31
  Change them later with:
28
32
 
29
33
  ```text
@@ -66,15 +70,15 @@ Options are forwarded to `scripts/inventory_folder_to_csv.py`, such as `--recurs
66
70
 
67
71
  - `vision_inventory_process_image` — analyze one electronics/PCB image.
68
72
  - `vision_inventory_process_folder` — analyze all supported images in a folder.
69
- - `vision_inventory_save` — save inventory output as JSON or CSV.
73
+ - `vision_inventory_save` — save inventory output as JSON or quick CSV export. Use `/vision-inventory-bom` for the full BOM/evidence workflow.
70
74
 
71
75
  ## External dependencies not bundled
72
76
 
73
77
  This package intentionally does **not** bundle:
74
78
 
75
- - Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif`.
79
+ - Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif`. Pi setup installs these into the package-managed venv when approved.
76
80
  - A Pi web-search/browser tool or skill for datasheet lookup.
77
- - Cloudflare Workers AI credentials.
81
+ - Cloudflare Workers AI API token credentials.
78
82
 
79
83
  ## Output
80
84
 
@@ -3,16 +3,19 @@ import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
4
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
5
  import { existsSync } from "node:fs";
6
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { dirname, isAbsolute, join, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  const SERVER_FILE = "vision_inventory_mcp.py";
12
- const PYTHON_COMMAND = process.env.PI_VISION_INVENTORY_PYTHON || "python3";
12
+ const BASE_PYTHON_COMMAND = process.env.PI_VISION_INVENTORY_PYTHON || "python3";
13
13
  const MAX_RESULT_CHARS = 50_000;
14
14
  const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
15
15
  const CONFIG_DIR = join(homedir(), ".pi", "agent", "vision-inventory");
16
+ const VENV_DIR = join(CONFIG_DIR, ".venv");
17
+ const VENV_PYTHON = process.platform === "win32" ? join(VENV_DIR, "Scripts", "python.exe") : join(VENV_DIR, "bin", "python");
18
+ const VENV_PIP = process.platform === "win32" ? join(VENV_DIR, "Scripts", "pip.exe") : join(VENV_DIR, "bin", "pip");
16
19
  const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
17
20
 
18
21
  type VisionCredentials = {
@@ -57,7 +60,8 @@ class McpStdioClient {
57
60
  throw new Error(`Cannot find ${SERVER_FILE} in ${this.packageRoot}`);
58
61
  }
59
62
 
60
- this.proc = spawn(PYTHON_COMMAND, [serverPath], {
63
+ const pythonCommand = await getPythonCommand();
64
+ this.proc = spawn(pythonCommand, [serverPath], {
61
65
  cwd: this.packageRoot,
62
66
  env: await buildPythonEnv(),
63
67
  stdio: ["pipe", "pipe", "pipe"],
@@ -72,7 +76,7 @@ class McpStdioClient {
72
76
  this.lastStderr = (this.lastStderr + chunk).slice(-10_000);
73
77
  });
74
78
  this.proc.on("error", (error) => {
75
- const err = new Error(`Failed to start Vision Inventory MCP server with ${PYTHON_COMMAND}: ${error.message}`);
79
+ const err = new Error(`Failed to start Vision Inventory MCP server: ${error.message}`);
76
80
  for (const pending of this.pending.values()) pending.reject(err);
77
81
  this.pending.clear();
78
82
  this.initialized = false;
@@ -215,6 +219,82 @@ async function buildPythonEnv(): Promise<NodeJS.ProcessEnv> {
215
219
  };
216
220
  }
217
221
 
222
+ function hasCompleteVenv(): boolean {
223
+ return existsSync(VENV_PYTHON) && existsSync(VENV_PIP);
224
+ }
225
+
226
+ async function getPythonCommand(): Promise<string> {
227
+ return hasCompleteVenv() ? VENV_PYTHON : BASE_PYTHON_COMMAND;
228
+ }
229
+
230
+ function venvHelpText(): string {
231
+ return [
232
+ `Vision Inventory Python venv path: ${VENV_DIR}`,
233
+ "On Debian/Ubuntu, venv creation with pip requires python3-venv.",
234
+ "Install it with: sudo apt install python3-venv",
235
+ "If your system asks for a version-specific package, install that instead, e.g. sudo apt install python3.10-venv",
236
+ ].join("\n");
237
+ }
238
+
239
+ async function ensurePythonEnvironment(pi: ExtensionAPI, packageRoot: string, ctx: { ui: any; hasUI: boolean }): Promise<boolean> {
240
+ if (existsSync(VENV_PYTHON) && !existsSync(VENV_PIP)) {
241
+ const message = `Existing Vision Inventory venv is incomplete because pip is missing.\n${venvHelpText()}`;
242
+ if (!ctx.hasUI) {
243
+ ctx.ui.notify(message, "error");
244
+ return false;
245
+ }
246
+
247
+ const recreate = await ctx.ui.confirm(
248
+ "Recreate incomplete Vision Inventory Python virtual environment?",
249
+ `${message}\n\nChoose No if you have not installed python3-venv yet. After installing it, rerun /vision-inventory-setup.`
250
+ );
251
+ if (!recreate) {
252
+ ctx.ui.notify(message, "error");
253
+ return false;
254
+ }
255
+ await rm(VENV_DIR, { recursive: true, force: true });
256
+ }
257
+
258
+ if (!hasCompleteVenv()) {
259
+ if (!ctx.hasUI) {
260
+ ctx.ui.notify(`Vision Inventory Python venv is missing or incomplete. Run /vision-inventory-setup interactively, or install dependencies manually with ${BASE_PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}\n${venvHelpText()}`, "error");
261
+ return false;
262
+ }
263
+
264
+ const create = await ctx.ui.confirm("Create Vision Inventory Python virtual environment?", `${BASE_PYTHON_COMMAND} -m venv ${VENV_DIR}`);
265
+ if (!create) {
266
+ ctx.ui.notify("Python environment setup skipped. Vision Inventory tools may fail until dependencies are installed.", "error");
267
+ return false;
268
+ }
269
+
270
+ await mkdir(CONFIG_DIR, { recursive: true });
271
+ const venvResult = await pi.exec(BASE_PYTHON_COMMAND, ["-m", "venv", VENV_DIR], { timeout: 120_000 });
272
+ if (venvResult.code !== 0 || !hasCompleteVenv()) {
273
+ await rm(VENV_DIR, { recursive: true, force: true });
274
+ ctx.ui.notify(`${venvResult.stderr || venvResult.stdout || "Python venv creation failed"}\n${venvHelpText()}`, "error");
275
+ return false;
276
+ }
277
+ }
278
+
279
+ const deps = await pi.exec(VENV_PYTHON, ["-c", "import mcp, requests, PIL, dotenv; print('ok')"], { timeout: 10_000 });
280
+ if (deps.code === 0) return true;
281
+
282
+ if (!ctx.hasUI) {
283
+ ctx.ui.notify(`Missing Python dependencies in ${VENV_DIR}. Run: ${VENV_PYTHON} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
284
+ return false;
285
+ }
286
+
287
+ const install = await ctx.ui.confirm("Install Python dependencies into the Vision Inventory venv?", `${VENV_PYTHON} -m pip install -r ${join(packageRoot, "requirements.txt")}`);
288
+ if (!install) return false;
289
+
290
+ const result = await pi.exec(VENV_PYTHON, ["-m", "pip", "install", "-r", join(packageRoot, "requirements.txt")], { timeout: 120_000 });
291
+ if (result.code !== 0) {
292
+ ctx.ui.notify(result.stderr || result.stdout || "pip install failed", "error");
293
+ return false;
294
+ }
295
+ return true;
296
+ }
297
+
218
298
  function extractMcpPayload(result: unknown): unknown {
219
299
  const maybe = result as { content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown } | null;
220
300
  if (maybe && typeof maybe === "object") {
@@ -304,9 +384,10 @@ async function runBatchWorkflow(packageRoot: string, userCwd: string, argsLine:
304
384
  }
305
385
 
306
386
  const env = await buildPythonEnv();
387
+ const pythonCommand = await getPythonCommand();
307
388
 
308
389
  return new Promise((resolve, reject) => {
309
- const proc = spawn(PYTHON_COMMAND, [scriptPath, ...args], {
390
+ const proc = spawn(pythonCommand, [scriptPath, ...args], {
310
391
  cwd: packageRoot,
311
392
  env,
312
393
  stdio: ["ignore", "pipe", "pipe"],
@@ -421,14 +502,14 @@ export default function (pi: ExtensionAPI) {
421
502
  },
422
503
  });
423
504
 
424
- async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<void> {
505
+ async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<boolean> {
425
506
  const existing = await loadCredentials();
426
507
  const hasEffectiveAccountId = Boolean(process.env.CLOUDFLARE_ACCOUNT_ID || existing.cloudflareAccountId);
427
508
  const hasEffectiveToken = Boolean(process.env.CLOUDFLARE_AUTH_TOKEN || process.env.CLOUDFLARE_API_TOKEN || existing.cloudflareAuthToken);
428
509
  if ((!hasEffectiveAccountId || !hasEffectiveToken || forceCredentials) && ctx.hasUI) {
429
- ctx.ui.notify("Vision Inventory stores Cloudflare credentials in ~/.pi/agent/vision-inventory/credentials.json with chmod 600 when supported. Use /vision-inventory-credentials to change them.", "info");
510
+ ctx.ui.notify("Vision Inventory stores Cloudflare credentials in ~/.pi/agent/vision-inventory/credentials.json with chmod 600 when supported. Use /vision-inventory-credentials to change them. Token input may be visible depending on your Pi UI; avoid entering credentials while screen sharing.", "info");
430
511
  const cloudflareAccountId = await ctx.ui.input("Cloudflare account ID", existing.cloudflareAccountId || "");
431
- const cloudflareAuthToken = await ctx.ui.input("Cloudflare Workers AI token", existing.cloudflareAuthToken ? "<keep existing; paste new token to replace>" : "");
512
+ const cloudflareAuthToken = await ctx.ui.input("Cloudflare Workers AI API token (leave blank for default)", existing.cloudflareAuthToken ? "<keep existing; paste new token to replace>" : "");
432
513
  const workersAiModel = await ctx.ui.input("Workers AI model", existing.workersAiModel || "@cf/meta/llama-4-scout-17b-16e-instruct");
433
514
  await saveCredentials({
434
515
  cloudflareAccountId: cloudflareAccountId || existing.cloudflareAccountId,
@@ -439,31 +520,16 @@ export default function (pi: ExtensionAPI) {
439
520
  ctx.ui.notify("Missing Cloudflare credentials. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN/CLOUDFLARE_API_TOKEN, or run /vision-inventory-setup in interactive Pi.", "error");
440
521
  }
441
522
 
442
- const deps = await pi.exec(PYTHON_COMMAND, ["-c", "import mcp, requests, PIL, dotenv; print('ok')"], { timeout: 10_000 });
443
- if (deps.code !== 0) {
444
- if (!ctx.hasUI) {
445
- ctx.ui.notify(`Missing Python dependencies. Run: ${PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
446
- return;
447
- }
448
- const install = await ctx.ui.confirm("Install Python dependencies?", `${PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`);
449
- if (install) {
450
- const result = await pi.exec(PYTHON_COMMAND, ["-m", "pip", "install", "-r", join(packageRoot, "requirements.txt")], { timeout: 120_000 });
451
- if (result.code !== 0) throw new Error(result.stderr || result.stdout || "pip install failed");
452
- }
453
- }
523
+ const pythonReady = await ensurePythonEnvironment(pi, packageRoot, ctx);
524
+ if (!pythonReady) return false;
454
525
 
455
- const commands = pi.getCommands().map((command) => command.name.toLowerCase());
456
- const tools = pi.getAllTools().map((tool) => tool.name.toLowerCase());
457
- const hasWebDependency = [...commands, ...tools].some((name) => name.includes("search") || name.includes("brave") || name.includes("browser") || name.includes("web"));
458
- if (!hasWebDependency) {
459
- ctx.ui.notify("Agent datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle one; install/enable your preferred search dependency before using /vision-inventory-agent-bom.", "error");
460
- }
461
-
462
- ctx.ui.notify(`Vision Inventory setup checked. Package root: ${packageRoot}`, "info");
526
+ ctx.ui.notify("Datasheet enrichment requires a separate web-search/browser Pi tool or skill. This package does not provide one; if your agent cannot search the web, fill datasheet_cache.json manually.", "info");
527
+ ctx.ui.notify(`Vision Inventory setup checked. Package root: ${packageRoot}. Python: ${await getPythonCommand()}`, "info");
528
+ return true;
463
529
  }
464
530
 
465
531
  pi.registerCommand("vision-inventory-setup", {
466
- description: "Configure Vision Inventory credentials and check Python/web-search dependencies",
532
+ description: "Configure Vision Inventory credentials and check Python dependencies",
467
533
  handler: async (args, ctx) => {
468
534
  try {
469
535
  await runSetup(ctx, args.includes("--reset") || args.includes("--credentials"));
@@ -510,10 +576,25 @@ export default function (pi: ExtensionAPI) {
510
576
  return;
511
577
  }
512
578
 
513
- await runSetup(ctx, false);
514
- const normalizedArgs = normalizeWorkflowArgs(ctx.cwd, parsed).map((arg) => JSON.stringify(arg)).join(" ");
515
- const outputDir = normalizeWorkflowArgs(ctx.cwd, parsed)[1];
516
- const prompt = `Run the complete Vision Electronic Indexing workflow as an agent.\n\nPackage root containing the bundled Python workflow: ${packageRoot}\nCommand arguments, already resolved relative to the user's cwd: ${normalizedArgs}\nOutput directory: ${outputDir}\n\nImportant external agent dependency: datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle a web-search dependency. If no search/browser tool is available, stop after generating parts_to_lookup.json and tell the user which dependency is missing.\n\nDo these steps end-to-end:\n1. Run: ${PYTHON_COMMAND} ${join(packageRoot, "scripts", "inventory_folder_to_csv.py")} ${normalizedArgs}\n2. Read ${outputDir}/parts_to_lookup.json.\n3. For every part, web-search for a datasheet. Prefer official manufacturer pages/PDFs.\n4. Write ${outputDir}/datasheet_cache.json using ${outputDir}/datasheet_cache.template.json as the exact shape.\n5. Rerun: ${PYTHON_COMMAND} ${join(packageRoot, "scripts", "inventory_folder_to_csv.py")} ${normalizedArgs} --skip-vision\n6. Read ${outputDir}/inventory.csv and ${outputDir}/inventory_evidence.csv.\n7. Summarize final BOM rows and call out every uncertainty.\n\nRules:\n- Do not invent datasheets, manufacturers, or descriptions.\n- If an exact candidate part has no official datasheet but search results strongly indicate a likely OCR correction, keep the original candidate as the datasheet_cache key and set normalized_part to the official datasheet part number. Example: key SN74AS283N may normalize to SN74LS283N when official TI results match the family/function/package and the image could plausibly confuse A with 4/LS.\n- Only set verified=true for an OCR correction when official source evidence and visual/package context make the correction highly likely; otherwise set verified=false and explain in notes.\n- Include OCR correction notes such as: \"SN74AS283N appears to be OCR for SN74LS283N; verified against TI datasheet.\"\n- Set verified=false if the part or datasheet match is uncertain.\n- Keep descriptions short, like: \"74ls (4 bit) adder low power schottky ttl 5v DIP\".\n- Preserve raw JSON and evidence files.\n- Do not expose Cloudflare credentials.\n- If a command fails because credentials or Python dependencies are missing, tell the user to run /vision-inventory-setup or /vision-inventory-credentials.`;
579
+ const setupOk = await runSetup(ctx, false);
580
+ if (!setupOk) return;
581
+ const normalized = normalizeWorkflowArgs(ctx.cwd, parsed);
582
+ const normalizedArgs = normalized.map((arg) => JSON.stringify(arg)).join(" ");
583
+ const outputDir = normalized[1];
584
+ const pythonCommand = await getPythonCommand();
585
+
586
+ ctx.ui.setStatus("vision-inventory", "Running initial vision workflow...");
587
+ try {
588
+ const initialOutput = await runBatchWorkflow(packageRoot, ctx.cwd, args || "");
589
+ ctx.ui.notify(initialOutput.slice(-4000), "info");
590
+ } catch (error) {
591
+ ctx.ui.notify(error instanceof Error ? error.message.slice(-4000) : String(error), "error");
592
+ ctx.ui.setStatus("vision-inventory", "");
593
+ return;
594
+ }
595
+ ctx.ui.setStatus("vision-inventory", "");
596
+
597
+ const prompt = `Continue the Vision Electronic Indexing workflow as an agent.\n\nThe deterministic vision step has already been run by the Pi command.\nPackage root containing the bundled Python workflow: ${packageRoot}\nCommand arguments, already resolved relative to the user's cwd: ${normalizedArgs}\nOutput directory: ${outputDir}\n\nImportant external agent dependency: datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle a web-search dependency. If no search/browser tool is available, stop after reviewing ${outputDir}/parts_to_lookup.json and tell the user to fill ${outputDir}/datasheet_cache.json manually.\n\nDo these remaining steps end-to-end:\n1. Read ${outputDir}/parts_to_lookup.json.\n2. For every part, web-search for a datasheet. Prefer official manufacturer pages/PDFs.\n3. Write ${outputDir}/datasheet_cache.json using ${outputDir}/datasheet_cache.template.json as the exact shape.\n4. Rerun: ${pythonCommand} ${join(packageRoot, "scripts", "inventory_folder_to_csv.py")} ${normalizedArgs} --skip-vision\n5. Read ${outputDir}/inventory.csv and ${outputDir}/inventory_evidence.csv.\n6. Summarize final BOM rows and call out every uncertainty.\n\nRules:\n- Do not invent datasheets, manufacturers, or descriptions.\n- If an exact candidate part has no official datasheet but search results strongly indicate a likely OCR correction, keep the original candidate as the datasheet_cache key and set normalized_part to the official datasheet part number. Example: key SN74AS283N may normalize to SN74LS283N when official TI results match the family/function/package and the image could plausibly confuse A with 4/LS.\n- Only set verified=true for an OCR correction when official source evidence and visual/package context make the correction highly likely; otherwise set verified=false and explain in notes.\n- Include OCR correction notes such as: \"SN74AS283N appears to be OCR for SN74LS283N; verified against TI datasheet.\"\n- Set verified=false if the part or datasheet match is uncertain.\n- Keep descriptions short, like: \"74ls (4 bit) adder low power schottky ttl 5v DIP\".\n- Preserve raw JSON and evidence files.\n- Do not expose Cloudflare credentials.\n- If a command fails because credentials or Python dependencies are missing, tell the user to run /vision-inventory-setup or /vision-inventory-credentials.`;
517
598
 
518
599
  await ctx.sendUserMessage(prompt);
519
600
  },
@@ -13,9 +13,9 @@ This package intentionally does **not** bundle these dependencies:
13
13
 
14
14
  - Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif` for HEIC/HEIF.
15
15
  - A Pi web-search/browser tool or skill for datasheet enrichment.
16
- - Cloudflare Workers AI credentials.
16
+ - Cloudflare Workers AI API token credentials.
17
17
 
18
- Use `/vision-inventory-setup` to configure credentials and check/install Python dependencies. Use `/vision-inventory-credentials` to change stored Cloudflare credentials.
18
+ Use `/vision-inventory-setup` to configure credentials, create/check the Pi-managed Python venv, install Python dependencies when approved, and see the web-search/browser requirement warning. Use `/vision-inventory-credentials` to change stored Cloudflare credentials.
19
19
 
20
20
  ## Preferred Command
21
21
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vision Electronic Indexing for Pi
2
2
 
3
- Agent-assisted electronics/PCB photo indexing for Pi. The package processes images with Cloudflare Workers AI, extracts visible IC/package markings, prepares parts for datasheet lookup, and produces an enriched inventory CSV.
3
+ Agent-assisted electronics parts/PCB photo indexing for Pi. The package processes images with Cloudflare Workers AI, extracts visible IC/package markings, prepares parts for datasheet lookup, and produces an enriched inventory CSV.
4
4
 
5
5
  Typical flow:
6
6
 
@@ -10,6 +10,12 @@ photos -> vision extraction -> raw JSON + evidence -> agent datasheet verificati
10
10
 
11
11
  The vision step does **not** perform datasheet lookup or invent part details. Datasheet enrichment is handled by a Pi agent with a web-search/browser tool or by manual review.
12
12
 
13
+ ## Which setup should I use?
14
+
15
+ - **Using Pi?** Install the package with `pi install npm:vision-electronic-indexing-pi`, then run `/vision-inventory-setup`.
16
+ - **Using Claude Code, Codex CLI, OpenCode, Cursor, or another MCP-capable harness?** Use the recommended universal installer in `.universal/scripts/quick-install.sh`.
17
+ - **Using plain Python or manual MCP configuration?** Install `requirements.txt`, configure Cloudflare credentials, and run `vision_inventory_mcp.py` directly.
18
+
13
19
  ## Quick setup with Pi
14
20
 
15
21
  ### 1. Install the Pi package
@@ -20,9 +26,9 @@ pi install npm:vision-electronic-indexing-pi
20
26
 
21
27
  For local development from this repository, open the repo in Pi and trust the project. Do not also install the npm package while working inside this repo, because the project-local extension and npm package register the same tools.
22
28
 
23
- ### 2. Install/enable an agent web-search dependency
29
+ ### 2. Plan for datasheet web search
24
30
 
25
- Datasheet enrichment requires a Pi web-search or browser tool/skill. This package intentionally does **not** bundle one.
31
+ Datasheet enrichment requires a separate Pi web-search or browser tool/skill. This package intentionally does **not** bundle one and does not try to auto-detect one because detection is unreliable.
26
32
 
27
33
  Examples of acceptable capabilities:
28
34
 
@@ -30,7 +36,7 @@ Examples of acceptable capabilities:
30
36
  - a browser automation skill
31
37
  - another trusted web-search extension/tool
32
38
 
33
- If no search/browser capability is available, the agent workflow can still generate `parts_to_lookup.json`, but it cannot verify datasheets.
39
+ If no search/browser capability is available, the workflow can still generate `parts_to_lookup.json`, but it cannot verify datasheets. Fill `datasheet_cache.json` manually in that case.
34
40
 
35
41
  ### 3. Configure Cloudflare credentials
36
42
 
@@ -40,7 +46,9 @@ Start Pi and run:
40
46
  /vision-inventory-setup
41
47
  ```
42
48
 
43
- The setup command checks Python dependencies, checks for web-search/browser capability, and prompts for Cloudflare Workers AI credentials the first time.
49
+ The setup command creates/checks a Pi-managed Python virtual environment under `~/.pi/agent/vision-inventory/.venv`, installs Python dependencies there when approved, warns that datasheet enrichment needs a separate web-search/browser capability, and prompts for Cloudflare Workers AI API token credentials the first time.
50
+
51
+ On Debian/Ubuntu, Python venv creation may require the system package `python3-venv` or a version-specific package such as `python3.10-venv`. If setup reports that `ensurepip` is unavailable, install the package, remove the incomplete venv with `rm -rf ~/.pi/agent/vision-inventory/.venv`, and rerun `/vision-inventory-setup`.
44
52
 
45
53
  Credentials are stored at:
46
54
 
@@ -48,7 +56,7 @@ Credentials are stored at:
48
56
  ~/.pi/agent/vision-inventory/credentials.json
49
57
  ```
50
58
 
51
- The file is written with `chmod 600` when supported.
59
+ The file is written with `chmod 600` when supported. Token input may be visible depending on your Pi UI; avoid entering credentials while screen sharing.
52
60
 
53
61
  To change credentials later:
54
62
 
@@ -60,9 +68,9 @@ Environment variables also work and override stored credentials:
60
68
 
61
69
  ```bash
62
70
  export CLOUDFLARE_ACCOUNT_ID=your_account_id
63
- export CLOUDFLARE_AUTH_TOKEN=your_workers_ai_token
64
- # or
65
- export CLOUDFLARE_API_TOKEN=your_workers_ai_token
71
+ export CLOUDFLARE_AUTH_TOKEN=your_workers_ai_api_token
72
+ # CLOUDFLARE_API_TOKEN is also accepted as an alias:
73
+ export CLOUDFLARE_API_TOKEN=your_workers_ai_api_token
66
74
  ```
67
75
 
68
76
  Optional model override:
@@ -71,6 +79,112 @@ Optional model override:
71
79
  export WORKERS_AI_MODEL=@cf/meta/llama-4-scout-17b-16e-instruct
72
80
  ```
73
81
 
82
+ ## Other harnesses / universal MCP compatibility
83
+
84
+ _Contributed by user [@Brun0-v](https://github.com/Brun0-V)_
85
+
86
+ This repository also includes a harness-neutral compatibility layer in `.universal/` for MCP-capable coding agents such as OpenCode, Claude Code, Codex CLI, Cursor, and similar clients.
87
+
88
+ The universal layer does **not** replace the Pi package integration. Pi users should keep using the commands above.
89
+
90
+ For other harnesses, use the universal installer:
91
+
92
+ ```bash
93
+ curl -fsSL https://raw.githubusercontent.com/Pichi-Cell/vision-electronic-indexing-mcp/main/.universal/scripts/quick-install.sh -o /tmp/vei-install.sh && bash /tmp/vei-install.sh
94
+ ```
95
+
96
+ This installs to `~/.vei/`, sets up a Python venv, prompts for Cloudflare Workers AI API token credentials, installs the agent skill, and **automatically configures the MCP server** in your agent's settings. **Requires an MCP-capable agent** (OpenCode, Claude Code, Codex CLI, Cursor, etc.).
97
+
98
+ Warning: some MCP clients store environment variables in plaintext JSON config files. Prefer shell environment variables or your agent's secret storage if available.
99
+
100
+ Other harnesses can also connect directly to the Python MCP server:
101
+
102
+ ```bash
103
+ python3 /path/to/vision-electronic-indexing-mcp/vision_inventory_mcp.py
104
+ ```
105
+
106
+ ### 1. Manual setup: install Python dependencies
107
+
108
+ From the repository root:
109
+
110
+ ```bash
111
+ python3 -m pip install -r requirements.txt
112
+ # Optional for iPhone HEIC/HEIF photos:
113
+ # python3 -m pip install pillow-heif
114
+ ```
115
+
116
+ ### 2. Configure Cloudflare credentials
117
+
118
+ Either copy `.env.example` to `.env` in the repository root:
119
+
120
+ ```bash
121
+ cp .env.example .env
122
+ # edit .env and set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN
123
+ # CLOUDFLARE_AUTH_TOKEN should be your Cloudflare Workers AI API token.
124
+ ```
125
+
126
+ or put the credentials directly in your harness MCP server configuration. Be aware that many harness config files store these values in plaintext.
127
+
128
+ ### 3. Add the MCP server to your harness
129
+
130
+ Example config snippets are provided in:
131
+
132
+ ```text
133
+ .universal/configs/opencode.json.example
134
+ .universal/configs/claude.json.example
135
+ .universal/configs/codex.json.example
136
+ .universal/configs/cursor.json.example
137
+ ```
138
+
139
+ Each config should point to the repository-root server file, for example:
140
+
141
+ ```json
142
+ {
143
+ "mcpServers": {
144
+ "vision-inventory": {
145
+ "command": "python3",
146
+ "args": ["/path/to/vision-electronic-indexing-mcp/vision_inventory_mcp.py"],
147
+ "env": {
148
+ "CLOUDFLARE_ACCOUNT_ID": "your_cloudflare_account_id",
149
+ "CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_api_token"
150
+ }
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ The `.universal/configs/*.json.example` files are strict JSON; copy the appropriate file into your harness config location and adjust paths/credentials. OpenCode uses a harness-specific `mcp` shape; the other examples use `mcpServers`.
157
+
158
+ The older `.universal/setup/install.sh` and `.universal/setup/install.ps1` scripts are manual/legacy helpers. Prefer `.universal/scripts/quick-install.sh` for automated universal setup.
159
+
160
+ The raw MCP server exposes these tool names:
161
+
162
+ | Tool | Purpose |
163
+ |---|---|
164
+ | `process_image` | Analyze one electronics/PCB image. |
165
+ | `process_image_folder` | Analyze a folder of supported images. |
166
+ | `save_inventory` | Save inventory output as JSON or quick CSV export. Use the batch workflow for full BOM/evidence output. |
167
+
168
+ ### 4. Install the universal skill/prompt, if your harness supports them
169
+
170
+ Universal workflow assets are available at:
171
+
172
+ ```text
173
+ .universal/skills/vision-inventory-workflow/SKILL.md
174
+ .universal/prompts/vision-inventory-agent-bom.md
175
+ ```
176
+
177
+ Copy them into your harness-specific skills/prompts location. The skill instructs the agent to run the deterministic workflow, read `parts_to_lookup.json`, verify datasheets with web search, fill `datasheet_cache.json`, regenerate the CSV with `--skip-vision`, and summarize uncertainties.
178
+
179
+ The deterministic workflow command is the same as the manual shell workflow:
180
+
181
+ ```bash
182
+ python3 scripts/inventory_folder_to_csv.py ./photos ./output
183
+ python3 scripts/inventory_folder_to_csv.py ./photos ./output --skip-vision
184
+ ```
185
+
186
+ Datasheet enrichment still requires a separate web-search/browser capability in the agent. This package does not bundle one.
187
+
74
188
  ## Recommended workflow
75
189
 
76
190
  ### 1. Take photos
@@ -94,6 +208,13 @@ In Pi:
94
208
  /vision-inventory-agent-bom ./photos ./output
95
209
  ```
96
210
 
211
+ In other harnesses:
212
+
213
+ ```text
214
+ /vision-inventory-workflow ./photos ./output
215
+ ```
216
+
217
+
97
218
  Useful options:
98
219
 
99
220
  ```text
@@ -186,7 +307,7 @@ Command summary:
186
307
  |---|---|
187
308
  | `vision_inventory_process_image` | Analyze one electronics/PCB image. |
188
309
  | `vision_inventory_process_folder` | Analyze a folder of supported images. |
189
- | `vision_inventory_save` | Save inventory output as JSON or CSV. |
310
+ | `vision_inventory_save` | Save inventory output as JSON or quick CSV export. For the full BOM workflow, use `/vision-inventory-bom` or `scripts/inventory_folder_to_csv.py`. |
190
311
 
191
312
  ## Manual shell workflow
192
313
 
@@ -194,6 +315,7 @@ You can run the deterministic workflow without Pi commands:
194
315
 
195
316
  ```bash
196
317
  python3 -m pip install -r requirements.txt
318
+ python3 scripts/inventory_folder_to_csv.py ./photos ./output --validate-setup
197
319
  python3 scripts/inventory_folder_to_csv.py ./photos ./output
198
320
  ```
199
321
 
@@ -203,17 +325,42 @@ Then fill `output/datasheet_cache.json` manually or with an agent, and regenerat
203
325
  python3 scripts/inventory_folder_to_csv.py ./photos ./output --skip-vision
204
326
  ```
205
327
 
328
+ ## No-Cloudflare demo and sample output
329
+
330
+ A fixture-based demo is available at:
331
+
332
+ ```text
333
+ examples/no-cloudflare-demo/
334
+ ```
335
+
336
+ Run it without Cloudflare credentials:
337
+
338
+ ```bash
339
+ python3 scripts/inventory_folder_to_csv.py ./examples/no-cloudflare-demo/photos ./examples/no-cloudflare-demo/output --skip-vision
340
+ ```
341
+
342
+ The demo includes mock raw vision JSON, a small `datasheet_cache.json`, and generated sample outputs:
343
+
344
+ ```text
345
+ examples/no-cloudflare-demo/output/parts_to_lookup.json
346
+ examples/no-cloudflare-demo/output/datasheet_cache.json
347
+ examples/no-cloudflare-demo/output/inventory.csv
348
+ examples/no-cloudflare-demo/output/inventory_evidence.csv
349
+ ```
350
+
351
+ Use these files to understand the expected output shapes before running real image analysis.
352
+
206
353
  ## Python requirements
207
354
 
208
355
  Python 3.10 or newer is recommended.
209
356
 
210
- Required:
357
+ Required versions are constrained in `requirements.txt` for reproducible installs:
211
358
 
212
359
  ```text
213
- mcp
214
- requests
215
- pillow
216
- python-dotenv
360
+ mcp>=1.0,<2.0
361
+ requests>=2.31,<3.0
362
+ pillow>=10.0,<12.0
363
+ python-dotenv>=1.0,<2.0
217
364
  ```
218
365
 
219
366
  Install:
@@ -301,7 +448,7 @@ MCP tools:
301
448
  |---|---|
302
449
  | `process_image` | Analyze one image and return structured visible inventory data. |
303
450
  | `process_image_folder` | Analyze all supported images in a folder. |
304
- | `save_inventory` | Save inventory output to JSON or CSV. |
451
+ | `save_inventory` | Save inventory output to JSON or quick CSV export. Use `scripts/inventory_folder_to_csv.py` for the full BOM/evidence workflow. |
305
452
 
306
453
  Run directly for MCP-compatible clients:
307
454
 
@@ -319,7 +466,7 @@ Example MCP client configuration:
319
466
  "args": ["/path/to/vision_inventory_mcp.py"],
320
467
  "env": {
321
468
  "CLOUDFLARE_ACCOUNT_ID": "your_cloudflare_account_id",
322
- "CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_token"
469
+ "CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_api_token"
323
470
  }
324
471
  }
325
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vision-electronic-indexing-pi",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Pi package for agent-assisted electronics/PCB image inventory with Cloudflare Workers AI vision and datasheet enrichment.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
- mcp
2
- requests
3
- pillow
4
- python-dotenv
1
+ mcp>=1.0,<2.0
2
+ requests>=2.31,<3.0
3
+ pillow>=10.0,<12.0
4
+ python-dotenv>=1.0,<2.0
5
5
  # Optional: install for iPhone HEIC/HEIF image support
6
- # pillow-heif
6
+ # pillow-heif>=0.16,<1.0
@@ -18,9 +18,9 @@ import csv
18
18
  import json
19
19
  import re
20
20
  import sys
21
- from collections import defaultdict
21
+ from collections import Counter, defaultdict
22
22
  from pathlib import Path
23
- from typing import Any, Dict, Iterable, List, Optional
23
+ from typing import Any, Dict, List, Optional
24
24
 
25
25
  PROJECT_ROOT = Path(__file__).resolve().parents[1]
26
26
  if str(PROJECT_ROOT) not in sys.path:
@@ -133,13 +133,30 @@ def extract_part_evidence(image_name: str, result: Dict[str, Any]) -> List[Dict[
133
133
  return evidence
134
134
 
135
135
 
136
+ def preflight_credentials() -> None:
137
+ _account_id, _api_token, credential_error = vision.get_cloudflare_credentials()
138
+ if credential_error:
139
+ raise SystemExit(
140
+ f"{credential_error.get('message', 'Missing Cloudflare credentials.')} "
141
+ "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN/CLOUDFLARE_API_TOKEN, "
142
+ "or run /vision-inventory-setup in Pi."
143
+ )
144
+
145
+
136
146
  def process_images(args: argparse.Namespace, raw_dir: Path) -> List[Dict[str, Any]]:
137
147
  image_folder = Path(args.image_folder).expanduser().resolve()
138
148
  if not image_folder.is_dir():
139
149
  raise SystemExit(f"Image folder does not exist or is not a directory: {image_folder}")
140
150
 
151
+ images = iter_images(image_folder, args.recursive, args.limit)
152
+ if not images:
153
+ print(f"No supported image files found in {image_folder}.")
154
+ return []
155
+
156
+ preflight_credentials()
157
+
141
158
  results: List[Dict[str, Any]] = []
142
- for image_path in iter_images(image_folder, args.recursive, args.limit):
159
+ for image_path in images:
143
160
  print(f"Processing {image_path}")
144
161
  result = vision.process_image_impl(
145
162
  image_path=str(image_path),
@@ -161,7 +178,30 @@ def load_raw_results(raw_dir: Path) -> List[Dict[str, Any]]:
161
178
  return results
162
179
 
163
180
 
164
- def build_parts_to_lookup(results: List[Dict[str, Any]]) -> Dict[str, Any]:
181
+ def classify_error(result: Dict[str, Any]) -> str:
182
+ message = str(result.get("message") or result.get("error") or "Unknown error")
183
+ lowered = message.lower()
184
+ if "credential" in lowered or "cloudflare_account_id" in lowered or "api token" in lowered:
185
+ return "credential errors"
186
+ if "cloudflare" in lowered or "workers ai" in lowered:
187
+ return "cloudflare api errors"
188
+ if "prepare image" in lowered or "unsupported image" in lowered or "image file" in lowered:
189
+ return "image preprocessing/input errors"
190
+ if result.get("parse_error"):
191
+ return "model json parse errors"
192
+ return "other errors"
193
+
194
+
195
+ def result_error_summary(results: List[Dict[str, Any]]) -> Counter[str]:
196
+ summary: Counter[str] = Counter()
197
+ for entry in results:
198
+ result = entry.get("result", {})
199
+ if isinstance(result, dict) and (result.get("error") or result.get("parse_error")):
200
+ summary[classify_error(result)] += 1
201
+ return summary
202
+
203
+
204
+ def build_parts_to_lookup(results: List[Dict[str, Any]], output_dir: Path) -> Dict[str, Any]:
165
205
  grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
166
206
  all_evidence: List[Dict[str, Any]] = []
167
207
 
@@ -196,16 +236,27 @@ def build_parts_to_lookup(results: List[Dict[str, Any]]) -> Dict[str, Any]:
196
236
  }
197
237
  })
198
238
 
239
+ warnings: List[str] = []
240
+ if not parts:
241
+ warnings.append(
242
+ "No candidate IC parts were extracted. Inspect raw JSON files for unreadable markings, "
243
+ "image quality issues, or model/API errors."
244
+ )
245
+
199
246
  return {
247
+ "output_dir": str(output_dir),
248
+ "datasheet_cache_path": str(output_dir / "datasheet_cache.json"),
249
+ "datasheet_cache_template_path": str(output_dir / "datasheet_cache.template.json"),
200
250
  "instructions": [
201
251
  "Use web search to find each part datasheet, preferably from the manufacturer.",
202
- "Fill output/datasheet_cache.json using the template shape shown in datasheet_cache.template.json.",
252
+ "Fill datasheet_cache.json in this same output directory, using datasheet_cache.template.json as the shape.",
203
253
  "Keep descriptions short, e.g. '74ls (4 bit) adder low power schottky ttl 5v DIP'.",
204
254
  "If exact candidate search fails but official results strongly indicate a likely OCR correction, keep the original candidate as this cache key and set normalized_part to the official datasheet part number.",
205
255
  "Example: if SN74AS283N appears to be an OCR error for official SN74LS283N, use key SN74AS283N with normalized_part SN74LS283N and explain the correction in notes.",
206
256
  "Only mark verified=true for a correction when the official datasheet and visual/package context make the correction highly likely; otherwise set verified=false and explain in notes.",
207
257
  "If the visual marking is uncertain, set verified=false and explain in notes."
208
258
  ],
259
+ "warnings": warnings,
209
260
  "parts": parts,
210
261
  "all_evidence": all_evidence,
211
262
  }
@@ -228,13 +279,20 @@ def lookup_enrichment(part: str, cache: Dict[str, Any]) -> Dict[str, Any]:
228
279
  return {}
229
280
 
230
281
 
282
+ def positive_int(value: Any) -> Optional[int]:
283
+ try:
284
+ parsed = int(value)
285
+ except Exception:
286
+ return None
287
+ return parsed if parsed > 0 else None
288
+
289
+
231
290
  def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, evidence_count: int = 1) -> int:
232
291
  """Estimate physical IC quantity for one candidate in one image.
233
292
 
234
- Some vision results use count_index as a grouped visible count, while others
235
- use it as an ordinal. Use the maximum of matching item count, evidence count,
236
- and any numeric count_index values so grouped detections like count_index=4
237
- produce amount=4 without double-counting duplicate observations.
293
+ Prefer explicit visible_quantity when the model provides it. Older results only
294
+ have count_index, which may be either an ordinal index or a grouped count, so
295
+ the fallback remains heuristic and should be reviewed for important BOMs.
238
296
  """
239
297
  items = result.get("items", [])
240
298
  if not isinstance(items, list):
@@ -242,6 +300,7 @@ def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, eviden
242
300
 
243
301
  matched = 0
244
302
  count_values: List[int] = []
303
+ visible_quantities: List[int] = []
245
304
  for item in items:
246
305
  if not isinstance(item, dict):
247
306
  continue
@@ -250,10 +309,15 @@ def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, eviden
250
309
  if candidate_from_item(item).upper() != candidate.upper():
251
310
  continue
252
311
  matched += 1
253
- try:
254
- count_values.append(max(1, int(item.get("count_index", 1))))
255
- except Exception:
256
- pass
312
+ visible_quantity = positive_int(item.get("visible_quantity"))
313
+ if visible_quantity is not None:
314
+ visible_quantities.append(visible_quantity)
315
+ count_index = positive_int(item.get("count_index"))
316
+ if count_index is not None:
317
+ count_values.append(count_index)
318
+
319
+ if visible_quantities:
320
+ return max(1, sum(visible_quantities))
257
321
 
258
322
  return max([1, evidence_count, matched, *count_values])
259
323
 
@@ -265,6 +329,11 @@ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> Lis
265
329
  image_name = str(result.get("image") or Path(entry["image_path"]).name)
266
330
  evidence = extract_part_evidence(image_name, result)
267
331
  if not evidence:
332
+ notes = "No IC marking extracted"
333
+ if isinstance(result, dict) and result.get("error"):
334
+ notes = f"Vision processing error: {result.get('message', 'unknown error')}"
335
+ elif isinstance(result, dict) and result.get("warnings"):
336
+ notes = "; ".join(str(w) for w in result.get("warnings", []) if str(w).strip()) or notes
268
337
  rows.append({
269
338
  "image": image_name,
270
339
  "candidate_part": "",
@@ -279,7 +348,7 @@ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> Lis
279
348
  "observed_markings": "",
280
349
  "observations": "",
281
350
  "raw_json": entry["raw_json"],
282
- "notes": "No IC marking extracted",
351
+ "notes": notes,
283
352
  })
284
353
  continue
285
354
 
@@ -425,6 +494,63 @@ def write_final_csv(results: List[Dict[str, Any]], cache: Dict[str, Any], output
425
494
  write_csv(output_csv.with_name(f"{output_csv.stem}_evidence{output_csv.suffix}"), evidence_fieldnames, evidence_rows)
426
495
 
427
496
 
497
+ def validate_setup(args: argparse.Namespace, output_dir: Path) -> None:
498
+ image_folder = Path(args.image_folder).expanduser().resolve()
499
+ print("Setup validation:")
500
+ print(f"- Python executable: {sys.executable}")
501
+ print("- Required imports: ok")
502
+
503
+ if image_folder.is_dir():
504
+ images = iter_images(image_folder, args.recursive, args.limit)
505
+ print(f"- Image folder: ok ({image_folder})")
506
+ print(f"- Supported images found: {len(images)}")
507
+ heic_images = [p for p in images if p.suffix.lower() in {".heic", ".heif"}]
508
+ if heic_images:
509
+ try:
510
+ import pillow_heif # noqa: F401
511
+ print("- HEIC/HEIF support: ok")
512
+ except Exception:
513
+ print("- HEIC/HEIF support: missing pillow-heif; install it to process HEIC/HEIF images")
514
+ else:
515
+ print(f"- Image folder: missing or not a directory ({image_folder})")
516
+
517
+ try:
518
+ output_dir.mkdir(parents=True, exist_ok=True)
519
+ probe = output_dir / ".vision_inventory_write_test"
520
+ probe.write_text("ok", encoding="utf-8")
521
+ probe.unlink(missing_ok=True)
522
+ print(f"- Output directory writable: ok ({output_dir})")
523
+ except Exception as exc:
524
+ print(f"- Output directory writable: failed ({exc})")
525
+
526
+ if args.skip_vision:
527
+ raw_dir = output_dir / "raw"
528
+ raw_count = len(list(raw_dir.glob("*.json"))) if raw_dir.exists() else 0
529
+ print(f"- Raw JSON files for --skip-vision: {raw_count}")
530
+ else:
531
+ _account_id, _api_token, credential_error = vision.get_cloudflare_credentials()
532
+ if credential_error:
533
+ print(f"- Cloudflare credentials: missing ({credential_error.get('message')})")
534
+ else:
535
+ print("- Cloudflare credentials: present")
536
+
537
+
538
+ def print_workflow_summary(results: List[Dict[str, Any]], parts_to_lookup: Dict[str, Any]) -> None:
539
+ error_summary = result_error_summary(results)
540
+ evidence_count = len(parts_to_lookup.get("all_evidence", []))
541
+ part_count = len(parts_to_lookup.get("parts", []))
542
+ print("Workflow summary:")
543
+ print(f"- Processed/raw result files: {len(results)}")
544
+ print(f"- IC marking evidence rows: {evidence_count}")
545
+ print(f"- Candidate parts extracted: {part_count}")
546
+ if error_summary:
547
+ print("- Processing errors:")
548
+ for label, count in sorted(error_summary.items()):
549
+ print(f" - {label}: {count}")
550
+ if not part_count:
551
+ print("No candidate IC parts were extracted. Inspect output/raw/*.json for unreadable markings, image quality issues, or API errors.")
552
+
553
+
428
554
  def parse_args() -> argparse.Namespace:
429
555
  parser = argparse.ArgumentParser(description="Process electronics images and prepare datasheet-enriched CSV workflow.")
430
556
  parser.add_argument("image_folder", help="Folder containing electronics/PCB images")
@@ -433,6 +559,7 @@ def parse_args() -> argparse.Namespace:
433
559
  parser.add_argument("--recursive", action="store_true", help="Scan image_folder recursively")
434
560
  parser.add_argument("--limit", type=int, default=None, help="Maximum number of images to process")
435
561
  parser.add_argument("--skip-vision", action="store_true", help="Reuse existing output_dir/raw/*.json instead of calling vision AI")
562
+ parser.add_argument("--validate-setup", action="store_true", help="Check dependencies, paths, credentials, and image discovery without processing images")
436
563
  parser.add_argument("--max-side", type=int, default=vision.DEFAULT_MAX_SIDE, help="Maximum resized image side; use 0 for full resolution (default)")
437
564
  parser.add_argument("--jpeg-quality", type=int, default=vision.DEFAULT_JPEG_QUALITY, help="JPEG quality for model input")
438
565
  return parser.parse_args()
@@ -441,6 +568,11 @@ def parse_args() -> argparse.Namespace:
441
568
  def main() -> None:
442
569
  args = parse_args()
443
570
  output_dir = Path(args.output_dir).expanduser().resolve()
571
+
572
+ if args.validate_setup:
573
+ validate_setup(args, output_dir)
574
+ return
575
+
444
576
  raw_dir = output_dir / "raw"
445
577
  output_dir.mkdir(parents=True, exist_ok=True)
446
578
  raw_dir.mkdir(parents=True, exist_ok=True)
@@ -452,7 +584,7 @@ def main() -> None:
452
584
  else:
453
585
  results = process_images(args, raw_dir)
454
586
 
455
- parts_to_lookup = build_parts_to_lookup(results)
587
+ parts_to_lookup = build_parts_to_lookup(results, output_dir)
456
588
  parts_path = output_dir / "parts_to_lookup.json"
457
589
  template_path = output_dir / "datasheet_cache.template.json"
458
590
  cache_path = output_dir / "datasheet_cache.json"
@@ -470,8 +602,13 @@ def main() -> None:
470
602
  print(f"Datasheet cache template: {template_path}")
471
603
  print(f"Datasheet cache used: {cache_path if cache_path.exists() else 'not found yet'}")
472
604
  print(f"CSV written: {csv_path}")
605
+ print_workflow_summary(results, parts_to_lookup)
473
606
  if not cache_path.exists():
474
- print("Next step: copy datasheet_cache.template.json to datasheet_cache.json, enrich it via web search, then rerun with --skip-vision.")
607
+ print("Next step: copy datasheet_cache.template.json to datasheet_cache.json in the output directory, enrich it via web search, then rerun with --skip-vision.")
608
+
609
+ errors = result_error_summary(results)
610
+ if results and sum(errors.values()) == len(results):
611
+ raise SystemExit("All processed images returned errors; see the summary above and inspect raw JSON files.")
475
612
 
476
613
 
477
614
  if __name__ == "__main__":
@@ -105,6 +105,7 @@ Return only valid JSON using this schema:
105
105
  {{
106
106
  "item_type": "IC | connector | passive | module | switch | sensor | display | mechanical | unknown",
107
107
  "count_index": 1,
108
+ "visible_quantity": 1,
108
109
  "package_marking": "exact visible marking, unclear, unreadable, or [?]-marked partial text",
109
110
  "marking_confidence": "high | medium | low | unreadable",
110
111
  "likely_part": "visible part marking only, or unknown",
@@ -123,6 +124,7 @@ Rules:
123
124
  - Do not use web lookup.
124
125
  - If a marking is not readable, write "unreadable".
125
126
  - If a component is visible but not identifiable, item_type should be "unknown".
127
+ - count_index is an ordinal item number; visible_quantity is the estimated number of matching visible physical components.
126
128
  - needs_review must be true when marking_confidence is "low" or "unreadable".
127
129
  """.strip()
128
130
 
@@ -362,6 +364,7 @@ def normalize_item(item: Any, fallback_index: int) -> Dict[str, Any]:
362
364
  default_item: Dict[str, Any] = {
363
365
  "item_type": "unknown",
364
366
  "count_index": fallback_index,
367
+ "visible_quantity": 1,
365
368
  "package_marking": "unknown",
366
369
  "marking_confidence": "unreadable",
367
370
  "likely_part": "unknown",
@@ -381,6 +384,11 @@ def normalize_item(item: Any, fallback_index: int) -> Dict[str, Any]:
381
384
  except Exception:
382
385
  normalized["count_index"] = fallback_index
383
386
 
387
+ try:
388
+ normalized["visible_quantity"] = max(1, int(normalized.get("visible_quantity", 1)))
389
+ except Exception:
390
+ normalized["visible_quantity"] = 1
391
+
384
392
  confidence = str(normalized.get("marking_confidence", "unreadable")).strip().lower()
385
393
  if confidence not in {"high", "medium", "low", "unreadable"}:
386
394
  confidence = "low"
@@ -459,17 +467,6 @@ def normalize_inventory_result(result: Dict[str, Any], image_name: str) -> Dict[
459
467
  return normalized
460
468
 
461
469
 
462
- def visible_ic_items(result: Dict[str, Any]) -> List[Dict[str, Any]]:
463
- items = result.get("items", [])
464
- if not isinstance(items, list):
465
- return []
466
- return [
467
- item for item in items
468
- if isinstance(item, dict) and str(item.get("item_type", "")).strip().lower() == "ic"
469
- ]
470
-
471
-
472
-
473
470
  def process_image_impl(
474
471
  image_path: str,
475
472
  max_side: int = DEFAULT_MAX_SIDE,
@@ -512,10 +509,15 @@ def process_image_impl(
512
509
 
513
510
  parsed, parse_error = extract_json_object(response_text)
514
511
  if parse_error or parsed is None:
512
+ message = parse_error or "Model returned invalid JSON."
515
513
  return {
516
514
  "image": image_name,
517
515
  "items": [],
518
- "warnings": [parse_error or "Model returned invalid JSON."],
516
+ "warnings": [message],
517
+ "parse_error": True,
518
+ "parse_error_message": message,
519
+ "raw_response_preview": response_text[:2000],
520
+ "raw_response_length": len(response_text),
519
521
  "raw_response": response_text,
520
522
  }
521
523
 
@@ -699,7 +701,10 @@ def flatten_inventory_for_csv(inventory: Dict[str, Any], enrichment_cache: Optio
699
701
  # Keep the main part number as the observation, not the full package/date/lot marking.
700
702
  row["observed_markings"].add(normalized)
701
703
  try:
702
- row["amount"] = max(int(row["amount"]), int(item.get("count_index", 1)))
704
+ if "visible_quantity" in item:
705
+ row["amount"] = int(row["amount"]) + max(1, int(item.get("visible_quantity", 1)))
706
+ else:
707
+ row["amount"] = max(int(row["amount"]), int(item.get("count_index", 1)))
703
708
  except Exception:
704
709
  row["amount"] = max(int(row["amount"]), 1)
705
710
 
@@ -736,9 +741,12 @@ def save_inventory(
736
741
  format: str = "json",
737
742
  ) -> Dict[str, Any]:
738
743
  """
739
- Save inventory results to disk as JSON or CSV.
744
+ Save inventory results to disk as JSON or quick CSV export.
740
745
 
741
746
  The input inventory can be the result of process_image or process_image_folder.
747
+ CSV output from this tool is a quick export; use scripts/inventory_folder_to_csv.py
748
+ or /vision-inventory-bom for the full BOM workflow with raw evidence files,
749
+ parts_to_lookup.json, datasheet_cache.json, and inventory_evidence.csv.
742
750
  """
743
751
  if not isinstance(inventory, dict):
744
752
  return error_response("inventory must be an object/dict.")
@@ -795,12 +803,19 @@ def save_inventory(
795
803
 
796
804
  row_count = len(rows)
797
805
 
798
- return {
806
+ response = {
799
807
  "saved": True,
800
808
  "output_path": str(output),
801
809
  "format": fmt,
802
810
  "row_count": row_count,
803
811
  }
812
+ if fmt == "csv":
813
+ response["note"] = (
814
+ "This is a quick CSV export. Use scripts/inventory_folder_to_csv.py "
815
+ "or /vision-inventory-bom for the full BOM workflow with raw evidence, "
816
+ "parts_to_lookup.json, datasheet_cache.json, and inventory_evidence.csv."
817
+ )
818
+ return response
804
819
 
805
820
  except Exception as exc:
806
821
  return error_response(f"Failed to save inventory: {exc}", output_path=str(output), format=fmt)