vision-electronic-indexing-pi 0.1.6 → 0.1.7

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,7 @@ 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
20
 
21
21
  Credentials are stored at:
22
22
 
@@ -24,6 +24,8 @@ Credentials are stored at:
24
24
  ~/.pi/agent/vision-inventory/credentials.json
25
25
  ```
26
26
 
27
+ 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.
28
+
27
29
  Change them later with:
28
30
 
29
31
  ```text
@@ -66,15 +68,15 @@ Options are forwarded to `scripts/inventory_folder_to_csv.py`, such as `--recurs
66
68
 
67
69
  - `vision_inventory_process_image` — analyze one electronics/PCB image.
68
70
  - `vision_inventory_process_folder` — analyze all supported images in a folder.
69
- - `vision_inventory_save` — save inventory output as JSON or CSV.
71
+ - `vision_inventory_save` — save inventory output as JSON or quick CSV export. Use `/vision-inventory-bom` for the full BOM/evidence workflow.
70
72
 
71
73
  ## External dependencies not bundled
72
74
 
73
75
  This package intentionally does **not** bundle:
74
76
 
75
- - Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif`.
77
+ - 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
78
  - A Pi web-search/browser tool or skill for datasheet lookup.
77
- - Cloudflare Workers AI credentials.
79
+ - Cloudflare Workers AI API token credentials.
78
80
 
79
81
  ## Output
80
82
 
@@ -9,10 +9,12 @@ 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");
16
18
  const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
17
19
 
18
20
  type VisionCredentials = {
@@ -57,7 +59,8 @@ class McpStdioClient {
57
59
  throw new Error(`Cannot find ${SERVER_FILE} in ${this.packageRoot}`);
58
60
  }
59
61
 
60
- this.proc = spawn(PYTHON_COMMAND, [serverPath], {
62
+ const pythonCommand = await getPythonCommand();
63
+ this.proc = spawn(pythonCommand, [serverPath], {
61
64
  cwd: this.packageRoot,
62
65
  env: await buildPythonEnv(),
63
66
  stdio: ["pipe", "pipe", "pipe"],
@@ -72,7 +75,7 @@ class McpStdioClient {
72
75
  this.lastStderr = (this.lastStderr + chunk).slice(-10_000);
73
76
  });
74
77
  this.proc.on("error", (error) => {
75
- const err = new Error(`Failed to start Vision Inventory MCP server with ${PYTHON_COMMAND}: ${error.message}`);
78
+ const err = new Error(`Failed to start Vision Inventory MCP server: ${error.message}`);
76
79
  for (const pending of this.pending.values()) pending.reject(err);
77
80
  this.pending.clear();
78
81
  this.initialized = false;
@@ -215,6 +218,44 @@ async function buildPythonEnv(): Promise<NodeJS.ProcessEnv> {
215
218
  };
216
219
  }
217
220
 
221
+ async function getPythonCommand(): Promise<string> {
222
+ return existsSync(VENV_PYTHON) ? VENV_PYTHON : BASE_PYTHON_COMMAND;
223
+ }
224
+
225
+ async function ensurePythonEnvironment(pi: ExtensionAPI, packageRoot: string, ctx: { ui: any; hasUI: boolean }): Promise<boolean> {
226
+ if (!existsSync(VENV_PYTHON)) {
227
+ if (!ctx.hasUI) {
228
+ ctx.ui.notify(`Vision Inventory Python venv is missing. Run /vision-inventory-setup interactively, or install dependencies manually with ${BASE_PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
229
+ return false;
230
+ }
231
+
232
+ const create = await ctx.ui.confirm("Create Vision Inventory Python virtual environment?", `${BASE_PYTHON_COMMAND} -m venv ${VENV_DIR}`);
233
+ if (!create) {
234
+ ctx.ui.notify("Python environment setup skipped. Vision Inventory tools may fail until dependencies are installed.", "error");
235
+ return false;
236
+ }
237
+
238
+ await mkdir(CONFIG_DIR, { recursive: true });
239
+ const venvResult = await pi.exec(BASE_PYTHON_COMMAND, ["-m", "venv", VENV_DIR], { timeout: 120_000 });
240
+ if (venvResult.code !== 0) throw new Error(venvResult.stderr || venvResult.stdout || "Python venv creation failed");
241
+ }
242
+
243
+ const deps = await pi.exec(VENV_PYTHON, ["-c", "import mcp, requests, PIL, dotenv; print('ok')"], { timeout: 10_000 });
244
+ if (deps.code === 0) return true;
245
+
246
+ if (!ctx.hasUI) {
247
+ ctx.ui.notify(`Missing Python dependencies in ${VENV_DIR}. Run: ${VENV_PYTHON} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
248
+ return false;
249
+ }
250
+
251
+ const install = await ctx.ui.confirm("Install Python dependencies into the Vision Inventory venv?", `${VENV_PYTHON} -m pip install -r ${join(packageRoot, "requirements.txt")}`);
252
+ if (!install) return false;
253
+
254
+ const result = await pi.exec(VENV_PYTHON, ["-m", "pip", "install", "-r", join(packageRoot, "requirements.txt")], { timeout: 120_000 });
255
+ if (result.code !== 0) throw new Error(result.stderr || result.stdout || "pip install failed");
256
+ return true;
257
+ }
258
+
218
259
  function extractMcpPayload(result: unknown): unknown {
219
260
  const maybe = result as { content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown } | null;
220
261
  if (maybe && typeof maybe === "object") {
@@ -304,9 +345,10 @@ async function runBatchWorkflow(packageRoot: string, userCwd: string, argsLine:
304
345
  }
305
346
 
306
347
  const env = await buildPythonEnv();
348
+ const pythonCommand = await getPythonCommand();
307
349
 
308
350
  return new Promise((resolve, reject) => {
309
- const proc = spawn(PYTHON_COMMAND, [scriptPath, ...args], {
351
+ const proc = spawn(pythonCommand, [scriptPath, ...args], {
310
352
  cwd: packageRoot,
311
353
  env,
312
354
  stdio: ["ignore", "pipe", "pipe"],
@@ -421,14 +463,14 @@ export default function (pi: ExtensionAPI) {
421
463
  },
422
464
  });
423
465
 
424
- async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<void> {
466
+ async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<boolean> {
425
467
  const existing = await loadCredentials();
426
468
  const hasEffectiveAccountId = Boolean(process.env.CLOUDFLARE_ACCOUNT_ID || existing.cloudflareAccountId);
427
469
  const hasEffectiveToken = Boolean(process.env.CLOUDFLARE_AUTH_TOKEN || process.env.CLOUDFLARE_API_TOKEN || existing.cloudflareAuthToken);
428
470
  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");
471
+ 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
472
  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>" : "");
473
+ 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
474
  const workersAiModel = await ctx.ui.input("Workers AI model", existing.workersAiModel || "@cf/meta/llama-4-scout-17b-16e-instruct");
433
475
  await saveCredentials({
434
476
  cloudflareAccountId: cloudflareAccountId || existing.cloudflareAccountId,
@@ -439,31 +481,16 @@ export default function (pi: ExtensionAPI) {
439
481
  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
482
  }
441
483
 
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
- }
484
+ const pythonReady = await ensurePythonEnvironment(pi, packageRoot, ctx);
485
+ if (!pythonReady) return false;
454
486
 
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");
487
+ 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");
488
+ ctx.ui.notify(`Vision Inventory setup checked. Package root: ${packageRoot}. Python: ${await getPythonCommand()}`, "info");
489
+ return true;
463
490
  }
464
491
 
465
492
  pi.registerCommand("vision-inventory-setup", {
466
- description: "Configure Vision Inventory credentials and check Python/web-search dependencies",
493
+ description: "Configure Vision Inventory credentials and check Python dependencies",
467
494
  handler: async (args, ctx) => {
468
495
  try {
469
496
  await runSetup(ctx, args.includes("--reset") || args.includes("--credentials"));
@@ -510,10 +537,25 @@ export default function (pi: ExtensionAPI) {
510
537
  return;
511
538
  }
512
539
 
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.`;
540
+ const setupOk = await runSetup(ctx, false);
541
+ if (!setupOk) return;
542
+ const normalized = normalizeWorkflowArgs(ctx.cwd, parsed);
543
+ const normalizedArgs = normalized.map((arg) => JSON.stringify(arg)).join(" ");
544
+ const outputDir = normalized[1];
545
+ const pythonCommand = await getPythonCommand();
546
+
547
+ ctx.ui.setStatus("vision-inventory", "Running initial vision workflow...");
548
+ try {
549
+ const initialOutput = await runBatchWorkflow(packageRoot, ctx.cwd, args || "");
550
+ ctx.ui.notify(initialOutput.slice(-4000), "info");
551
+ } catch (error) {
552
+ ctx.ui.notify(error instanceof Error ? error.message.slice(-4000) : String(error), "error");
553
+ ctx.ui.setStatus("vision-inventory", "");
554
+ return;
555
+ }
556
+ ctx.ui.setStatus("vision-inventory", "");
557
+
558
+ 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
559
 
518
560
  await ctx.sendUserMessage(prompt);
519
561
  },
@@ -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,7 @@ 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.
44
50
 
45
51
  Credentials are stored at:
46
52
 
@@ -48,7 +54,7 @@ Credentials are stored at:
48
54
  ~/.pi/agent/vision-inventory/credentials.json
49
55
  ```
50
56
 
51
- The file is written with `chmod 600` when supported.
57
+ 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
58
 
53
59
  To change credentials later:
54
60
 
@@ -60,9 +66,9 @@ Environment variables also work and override stored credentials:
60
66
 
61
67
  ```bash
62
68
  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
69
+ export CLOUDFLARE_AUTH_TOKEN=your_workers_ai_api_token
70
+ # CLOUDFLARE_API_TOKEN is also accepted as an alias:
71
+ export CLOUDFLARE_API_TOKEN=your_workers_ai_api_token
66
72
  ```
67
73
 
68
74
  Optional model override:
@@ -71,6 +77,112 @@ Optional model override:
71
77
  export WORKERS_AI_MODEL=@cf/meta/llama-4-scout-17b-16e-instruct
72
78
  ```
73
79
 
80
+ ## Other harnesses / universal MCP compatibility
81
+
82
+ _Contributed by user [@Brun0-v](https://github.com/Brun0-V)_
83
+
84
+ 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.
85
+
86
+ The universal layer does **not** replace the Pi package integration. Pi users should keep using the commands above.
87
+
88
+ For other harnesses, use the universal installer:
89
+
90
+ ```bash
91
+ 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
92
+ ```
93
+
94
+ 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.).
95
+
96
+ Warning: some MCP clients store environment variables in plaintext JSON config files. Prefer shell environment variables or your agent's secret storage if available.
97
+
98
+ Other harnesses can also connect directly to the Python MCP server:
99
+
100
+ ```bash
101
+ python3 /path/to/vision-electronic-indexing-mcp/vision_inventory_mcp.py
102
+ ```
103
+
104
+ ### 1. Manual setup: install Python dependencies
105
+
106
+ From the repository root:
107
+
108
+ ```bash
109
+ python3 -m pip install -r requirements.txt
110
+ # Optional for iPhone HEIC/HEIF photos:
111
+ # python3 -m pip install pillow-heif
112
+ ```
113
+
114
+ ### 2. Configure Cloudflare credentials
115
+
116
+ Either copy `.env.example` to `.env` in the repository root:
117
+
118
+ ```bash
119
+ cp .env.example .env
120
+ # edit .env and set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN
121
+ # CLOUDFLARE_AUTH_TOKEN should be your Cloudflare Workers AI API token.
122
+ ```
123
+
124
+ or put the credentials directly in your harness MCP server configuration. Be aware that many harness config files store these values in plaintext.
125
+
126
+ ### 3. Add the MCP server to your harness
127
+
128
+ Example config snippets are provided in:
129
+
130
+ ```text
131
+ .universal/configs/opencode.json.example
132
+ .universal/configs/claude.json.example
133
+ .universal/configs/codex.json.example
134
+ .universal/configs/cursor.json.example
135
+ ```
136
+
137
+ Each config should point to the repository-root server file, for example:
138
+
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "vision-inventory": {
143
+ "command": "python3",
144
+ "args": ["/path/to/vision-electronic-indexing-mcp/vision_inventory_mcp.py"],
145
+ "env": {
146
+ "CLOUDFLARE_ACCOUNT_ID": "your_cloudflare_account_id",
147
+ "CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_api_token"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ 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`.
155
+
156
+ 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.
157
+
158
+ The raw MCP server exposes these tool names:
159
+
160
+ | Tool | Purpose |
161
+ |---|---|
162
+ | `process_image` | Analyze one electronics/PCB image. |
163
+ | `process_image_folder` | Analyze a folder of supported images. |
164
+ | `save_inventory` | Save inventory output as JSON or quick CSV export. Use the batch workflow for full BOM/evidence output. |
165
+
166
+ ### 4. Install the universal skill/prompt, if your harness supports them
167
+
168
+ Universal workflow assets are available at:
169
+
170
+ ```text
171
+ .universal/skills/vision-inventory-workflow/SKILL.md
172
+ .universal/prompts/vision-inventory-agent-bom.md
173
+ ```
174
+
175
+ 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.
176
+
177
+ The deterministic workflow command is the same as the manual shell workflow:
178
+
179
+ ```bash
180
+ python3 scripts/inventory_folder_to_csv.py ./photos ./output
181
+ python3 scripts/inventory_folder_to_csv.py ./photos ./output --skip-vision
182
+ ```
183
+
184
+ Datasheet enrichment still requires a separate web-search/browser capability in the agent. This package does not bundle one.
185
+
74
186
  ## Recommended workflow
75
187
 
76
188
  ### 1. Take photos
@@ -94,6 +206,13 @@ In Pi:
94
206
  /vision-inventory-agent-bom ./photos ./output
95
207
  ```
96
208
 
209
+ In other harnesses:
210
+
211
+ ```text
212
+ /vision-inventory-workflow ./photos ./output
213
+ ```
214
+
215
+
97
216
  Useful options:
98
217
 
99
218
  ```text
@@ -186,7 +305,7 @@ Command summary:
186
305
  |---|---|
187
306
  | `vision_inventory_process_image` | Analyze one electronics/PCB image. |
188
307
  | `vision_inventory_process_folder` | Analyze a folder of supported images. |
189
- | `vision_inventory_save` | Save inventory output as JSON or CSV. |
308
+ | `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
309
 
191
310
  ## Manual shell workflow
192
311
 
@@ -194,6 +313,7 @@ You can run the deterministic workflow without Pi commands:
194
313
 
195
314
  ```bash
196
315
  python3 -m pip install -r requirements.txt
316
+ python3 scripts/inventory_folder_to_csv.py ./photos ./output --validate-setup
197
317
  python3 scripts/inventory_folder_to_csv.py ./photos ./output
198
318
  ```
199
319
 
@@ -203,17 +323,42 @@ Then fill `output/datasheet_cache.json` manually or with an agent, and regenerat
203
323
  python3 scripts/inventory_folder_to_csv.py ./photos ./output --skip-vision
204
324
  ```
205
325
 
326
+ ## No-Cloudflare demo and sample output
327
+
328
+ A fixture-based demo is available at:
329
+
330
+ ```text
331
+ examples/no-cloudflare-demo/
332
+ ```
333
+
334
+ Run it without Cloudflare credentials:
335
+
336
+ ```bash
337
+ python3 scripts/inventory_folder_to_csv.py ./examples/no-cloudflare-demo/photos ./examples/no-cloudflare-demo/output --skip-vision
338
+ ```
339
+
340
+ The demo includes mock raw vision JSON, a small `datasheet_cache.json`, and generated sample outputs:
341
+
342
+ ```text
343
+ examples/no-cloudflare-demo/output/parts_to_lookup.json
344
+ examples/no-cloudflare-demo/output/datasheet_cache.json
345
+ examples/no-cloudflare-demo/output/inventory.csv
346
+ examples/no-cloudflare-demo/output/inventory_evidence.csv
347
+ ```
348
+
349
+ Use these files to understand the expected output shapes before running real image analysis.
350
+
206
351
  ## Python requirements
207
352
 
208
353
  Python 3.10 or newer is recommended.
209
354
 
210
- Required:
355
+ Required versions are constrained in `requirements.txt` for reproducible installs:
211
356
 
212
357
  ```text
213
- mcp
214
- requests
215
- pillow
216
- python-dotenv
358
+ mcp>=1.0,<2.0
359
+ requests>=2.31,<3.0
360
+ pillow>=10.0,<12.0
361
+ python-dotenv>=1.0,<2.0
217
362
  ```
218
363
 
219
364
  Install:
@@ -301,7 +446,7 @@ MCP tools:
301
446
  |---|---|
302
447
  | `process_image` | Analyze one image and return structured visible inventory data. |
303
448
  | `process_image_folder` | Analyze all supported images in a folder. |
304
- | `save_inventory` | Save inventory output to JSON or CSV. |
449
+ | `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
450
 
306
451
  Run directly for MCP-compatible clients:
307
452
 
@@ -319,7 +464,7 @@ Example MCP client configuration:
319
464
  "args": ["/path/to/vision_inventory_mcp.py"],
320
465
  "env": {
321
466
  "CLOUDFLARE_ACCOUNT_ID": "your_cloudflare_account_id",
322
- "CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_token"
467
+ "CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_api_token"
323
468
  }
324
469
  }
325
470
  }
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.7",
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)