Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ Review flow (2026-10-28, owner feedback from the live HORS panel):
- **Fastener counts.** Onshape's OpenAPI (`BTRootAssemblyInfo`, `BTSubAssemblyInfo`) documents `instances` as "including those created by patterns and replicates"; `patterns` only maps seeds to copies and `features` holds the Replicate feature. The walk already counts every instance, so pattern and Replicate copies were always counted (the HORS scan's BOM quantities matched the definition on all 102 matched rows). `fixtures/assembly-patterns.json` pins this, including a suppressed pattern copy. No extra Onshape request is needed; the "can be low with Replicate or patterns" notes in the Buy list, workbook, and classifier now say copies are counted and only fasteners never modelled are missing. Not verified against a live assembly with a Replicate feature.
- Screenshots of the flow at 380 and 1440 px, from fixture mode with the saved HORS scan, are in `docs/screenshots/review-flow/`.

Control Point link v1.2 (2026-10-28, owner requests "i should also be able to download all the parts in control point itself, also show the preview images in control point too" and "when control point is linked it should work" for AI review; contract §6 and §7, built with Control Point's matching PR):
- **Files after a send.** The STEP zip is now always a `reference` in the bundle (`uploadedAfterSend`), never inline, and anything over 4 MB too; the panel uploads them right after the send (a duplicate included) through `POST /api/control-point/file`, which streams the bytes unread to Control Point's new `…/imports/{importId}/files` (64 MB cap, `X-Content-SHA256` checked there). The zip therefore left the idempotency key: adding STEP later reaches the same import. The route only accepts the caller's own latest send (`cp-sent:` names the import and workspace) and records each upload at `cp-upload:{userId}:{importId}:{zip|xlsx|csv}` (`{name, sha256, at}`, never merged into `cp-sent`; `/api/control-point/sent` returns them as `files`), so the Control Point card shows "STEP files … are in Control Point" or offers **Add STEP files** (exporting first when the session has no current zip). Control Point shows the robot's newest stored zip and workbook as downloads.
- **Part pictures.** After a send the panel reads Control Point's picture keys (`GET /api/control-point/pictures`), walks `pictureTargets` (one line per identity, parts to make first, Not needed and undrawable lines out), and sends PNGs in batches of at most 40 and 6 MB (`POST /api/control-point/pictures`, streamed unread, 8 MB cap). `controlPointPictureKey` is the revision the Worker caches the picture at plus a line hash, so a picture Control Point holds is skipped. `GET /api/preview?…&cached=1` returns an already drawn picture or 404 `not_cached` without an Onshape request. Only parts Control Point has no picture of are drawn, at most `PICTURE_FILL_PER_SEND` (30) per send, by one run at a time (a newer send stops the older run and waits for it); failures show as not arrived; a newer revision never drawn keeps the older picture there. Onshape cost: a first HORS send (about 120 unique parts) is at most 30 billable `shaded-view` requests (fewer for every part whose drawer was opened, which is cached), the whole robot is pictured after at most four sends (at most about 120 requests, once), and a send of an unchanged or edited robot costs nothing unless it gained parts. The robot picture is unchanged (the send's assembly PNG, one request per new revision, usually already drawn by the Overview).
- **AI review through Control Point.** Without this Worker's own keys, a person linked with a workspace gets AI review: `/api/ai/status` answers `{enabled, via: 'control-point'}` (the panel re-asks when the link or workspace changes), and `/api/ai/review` sends `aiReviewAsk(trusted)` (Control Kit's instructions, both schemas, and the `trustedAiRequest` rebuild) to Control Point's `…/workspaces/{id}/ai/review` with the link's token, 190-second bound, no retries (`reviewThroughControlPoint` in `ai-review.ts`). The answer goes through `parseAiReview`, is cached apart at `ai-review:{hash}:control-point` for 30 days, and still takes one of the person's and everyone's daily reviews from `AiAllowance`. Control Point's 429/501/502/504 pass on in plain words; a 401 ends the link and answers 409 `revoked`. Own keys, if set, still win. Control Point applies its own limit of 20 reviews an hour and 100 a day per workspace.
- **Tests.** `mock-control-point.ts` serves the new routes (uploads, pictures, `setAiAnswer`); `control-point.test.ts` covers streaming, refusals (another import, workspace, wrong hash, oversize or overlong body, revoked), pictures, `cached=1`, AI status and review through Control Point. The fixture API stands in for the new routes. No live Control Point, model, or Onshape request was made.

## Tuning, tests, and release

Match normalization, thresholds, and alternative ranking are in `packages/shared/src/matching.ts`; an exact SKU still forces Buy absent a manual override. Disposition rules (`decideDisposition`), word lists, and print folders are in `packages/shared/src/classifier.ts`; order quantities are in `packages/shared/src/purchasing.ts`. Vendor stale hours and cron schedule are in `wrangler.toml`; catalog crawl batch limits are in `apps/worker/src/vendor-ingest.ts`. Scan request flow, source cache, and backoff are in `apps/worker/src/scan.ts`; BOM column mapping is in `packages/shared/src/bom.ts`; STEP export job bounds (per-call request budget, 5-minute translation timeout, 20 MB file cap) are in `apps/worker/src/step-export.ts`. Change these with fixture tests and record any changed data shape here.
Expand Down
18 changes: 17 additions & 1 deletion apps/web/fixture-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function fixtureApi(fixturePath?: string, catalogOrigin?: string, preview
let cpLinked = cpMode === 'linked' || cpMode === 'none';
let cpWorkspace: number | null = cpMode === 'linked' ? 12 : null;
let cpSent: Record<string, unknown> | null = null;
const cpPictures = new Map<string, string>();
return {
name: 'control-kit-fixture-api',
configureServer(server) {
Expand Down Expand Up @@ -94,9 +95,24 @@ export function fixtureApi(fixturePath?: string, catalogOrigin?: string, preview
if (url.pathname === '/api/control-point/send') {
const payload = await body() as { idempotencyKey: string; cadParts: unknown[] };
const duplicate = cpSent?.idempotencyKey === payload.idempotencyKey;
cpSent = { importId: 41, workspaceId: cpWorkspace, sentAt: new Date().toISOString(), idempotencyKey: payload.idempotencyKey, url: 'https://tryctrlpoint.org/cad-parts?import=41' };
cpSent = { importId: 41, workspaceId: cpWorkspace, sentAt: new Date().toISOString(), idempotencyKey: payload.idempotencyKey, url: 'https://tryctrlpoint.org/cad-parts?import=41', files: duplicate ? cpSent?.files : [] };
return json({ importId: 41, duplicate, workspace: workspaces.find(item => item.id === cpWorkspace), created: duplicate ? 0 : payload.cadParts.length, updated: 0, markedRemoved: 0, filesStored: 1, url: cpSent.url, sentAt: cpSent.sentAt });
}
// Contract v1.2 §6: files after a send and part pictures, kept in memory like the send itself.
if (url.pathname === '/api/control-point/file') {
let size = 0;
for await (const chunk of request) size += (chunk as Buffer).length;
const name = url.searchParams.get('name') ?? 'file';
const sha256 = String(request.headers['x-content-sha256'] ?? '');
if (cpSent) cpSent = { ...cpSent, files: [...((cpSent.files as Array<{ name: string }> | undefined) ?? []).filter(item => item.name !== name), { name, sha256 }] };
return json({ name, url: 'https://tryctrlpoint.org/api/files/1', sizeBytes: size, sha256: request.headers['x-content-sha256'], duplicate: false });
}
if (url.pathname === '/api/control-point/pictures') {
if (request.method === 'GET') return json({ pictures: [...cpPictures].map(([identity, key]) => ({ identity, key })) });
const sent = await body() as { pictures: Array<{ identity: string; key: string }> };
for (const item of sent.pictures) cpPictures.set(item.identity, item.key);
return json({ stored: sent.pictures.length, unchanged: 0, unknown: 0, rejected: 0 });
}
if (url.pathname === '/control-point/start') { cpLinked = true; response.setHeader('content-type', 'text/html'); return response.end('<script>opener && opener.postMessage({ type: "control-point-linked" }, location.origin); close();</script>Linked.'); }
if (url.pathname === '/api/scan') {
// A new scan reads a newer revision, so its own pictures replace any redrawn ones, as in the Worker.
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/app/ControlPoint.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Check, ExternalLink, Send } from 'lucide-react';
import { Check, ExternalLink, FileArchive, Image, Send } from 'lucide-react';
import { buildControlPointPayload, CONTROL_POINT_CAD_URL, type ControlPointCadPart } from '@control-kit/shared';
import { Alert } from '../components/arc/alert/alert';
import { Button } from './bits';
Expand Down Expand Up @@ -118,19 +118,40 @@ function pulledText(pulled: { types: number; products: number }) {
return [types, products].filter(Boolean).join(' and ');
}

/** "38 part pictures sent, 30 newly drawn by Onshape; 12 more next send" */
function picturesText(sync: NonNullable<ControlPoint['pictures']>) {
if (sync.running) return 'Sending part pictures…';
if (sync.unreachable) return 'Part pictures could not reach Control Point. The next send tries again.';
const parts = [sync.sent ? `${sync.sent} part picture${sync.sent === 1 ? '' : 's'} sent` : 'Part pictures are up to date in Control Point'];
if (sync.drawn) parts.push(`${sync.drawn} newly drawn by Onshape`);
const text = parts.join(', ');
const later = sync.missing ? `; ${sync.missing} more arrive with later sends` : '';
const failed = sync.failed ? `. ${sync.failed} did not arrive; the next send tries again` : '';
if (!sync.sent && !sync.drawn && !sync.missing && sync.failed) return `${sync.failed} part picture${sync.failed === 1 ? '' : 's'} did not reach Control Point. The next send tries again.`;
return `${text}${later}${failed}.`;
}

function Linked({ kit, cp, now }: { kit: ControlKit; cp: ControlPoint; now: number | null }) {
const workspace = cp.workspace!;
const sent = cp.sent && cp.sent.workspaceId === workspace.id ? cp.sent : null;
const current = Boolean(sent) && cp.sentCurrent;
// A partial export (some parts failed) still makes a zip; the card never claims it holds every part.
const stepGaps = kit.step && !kit.step.running ? kit.step.failed : 0;
return <>
<div className="workspace-line"><WorkspaceMark workspace={workspace} size={28} /><div><strong>{workspace.name}</strong><span className="muted small">{workspace.ftcNumber ? `FTC ${workspace.ftcNumber}` : 'No FTC number'}</span></div></div>
{!workspace.canEditCad && <Alert tone="warning" title="You can't edit CAD parts in this workspace">Ask a workspace admin for CAD access, or switch workspace in Settings.</Alert>}
{sent && <p className="small cp-sent"><Check aria-hidden size={13} />{current ? 'This scan was sent' : 'An earlier scan was sent'}{now ? ` ${ago(sent.sentAt, now)}` : ''}{sent.created !== undefined ? `: ${sent.created} new, ${sent.updated ?? 0} updated` : ''}. <a className="link" href={sent.url} {...external}>View in Control Point <ExternalLink aria-hidden size={11} /></a></p>}
{cp.pulled && cp.pulled.types + cp.pulled.products > 0 && <p className="small cp-sent"><Check aria-hidden size={13} />Picked up {pulledText(cp.pulled)} made in Control Point.</p>}
{sent && current && cp.makeCount > 0 && (cp.stepAttached
? <p className="small cp-sent"><FileArchive aria-hidden size={13} />A STEP zip of this scan is in Control Point, ready to download.{stepGaps ? ` The last export left out ${stepGaps} part${stepGaps === 1 ? '' : 's'}; export again to add them.` : ''}</p>
: <p className="small cp-step"><FileArchive aria-hidden size={13} />STEP files are not in Control Point yet.</p>)}
{cp.pictures && <p className="small muted cp-pictures"><Image aria-hidden size={13} />{picturesText(cp.pictures)}</p>}
<div className="inline-actions">
<Button variant="secondary" disabled={!workspace.canEditCad || !kit.result!.scanId} loading={cp.busy === 'send'} onClick={() => void cp.send()}><Send aria-hidden size={14} />{current ? `Send again to ${workspace.name}` : `Send full BOM to ${workspace.name}`}</Button>
{sent && current && cp.makeCount > 0 && (!cp.stepAttached || stepGaps > 0) && <Button variant="ghost" disabled={!workspace.canEditCad || cp.busy !== null || Boolean(kit.step?.running)} loading={cp.busy === 'step'} onClick={() => void cp.attachStep()}>
<FileArchive aria-hidden size={14} />{cp.zipCurrent && !stepGaps ? 'Add STEP files' : cp.stepAttached ? 'Export again and add' : 'Export and add STEP files'}</Button>}
</div>
<p className="muted small">Sends every part with its quantity, vendor, price, and status, plus the Excel BOM, the assembly picture, and this session's STEP files. Sending the same scan again changes nothing; a new scan updates the same parts and keeps your team's statuses. Part types and products chosen in Control Point arrive with your next scan.</p>
<p className="muted small">Sends every part with its quantity, vendor, price, and status, plus the Excel BOM, the assembly picture, part pictures, and this session's STEP files, which your team can download from Control Point. Sending the same scan again changes nothing; a new scan updates the same parts and keeps your team's statuses. Part types and products chosen in Control Point arrive with your next scan.</p>
</>;
}

Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/app/useControlKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,10 +336,12 @@ export function useControlKit(context: URLSearchParams) {
} finally { markSaving(line.bomLineKey, false, 'product'); }
}

useEffect(() => {
/** Whether the AI review button shows: this Worker's own keys, or (contract v1.2 §7) a Control Point workspace linked. */
function refreshAiStatus() {
api('/api/ai/status').then(response => response.ok ? response.json() as Promise<{ enabled?: boolean }> : { enabled: false })
.then(status => setAi(current => ({ ...current, enabled: Boolean(status.enabled) }))).catch(() => { /* The review stays hidden. */ });
}, []);
}
useEffect(refreshAiStatus, []);

/** One batched request for every uncertain line. Suggestions only: nothing is saved until a person accepts one. */
async function runAiReview() {
Expand All @@ -353,11 +355,13 @@ export function useControlKit(context: URLSearchParams) {
setAi(state => ({ ...state, running: true, note: '' }));
try {
const response = await postJson('/api/ai/review', { documentId: result.documentId, scanId, request, keys });
const body = await response.json().catch(() => ({})) as { items?: AiReviewItem[]; provider?: AiProvider; cached?: boolean; error?: string };
const body = await response.json().catch(() => ({})) as { items?: AiReviewItem[]; provider?: AiProvider; cached?: boolean; error?: string; via?: string; code?: string };
// Control Point ended the link: the review goes away with it until the person links again.
if (body.code === 'revoked' || body.code === 'not_linked') refreshAiStatus();
if (!current()) return;
if (!response.ok || !body.items || !body.provider) { setAi(state => ({ ...state, note: body.error ?? 'The AI review failed. Your parts are unchanged.' })); return; }
setResult(state => state && state.scanId === scanId ? applyAiReview(state, keys, body.items!, body.provider!) : state);
const who = body.provider === 'gemini' ? 'Gemini' : 'Claude';
const who = `${body.provider === 'gemini' ? 'Gemini' : 'Claude'}${body.via === 'control-point' ? ' (through Control Point)' : ''}`;
setAi(state => ({ ...state, note: `${who} suggested ${body.items!.length} of ${request.lines.length} uncertain parts${body.cached ? ' (a saved answer, no new AI call)' : ''}${request.leftOut ? `; ${request.leftOut} more were not sent` : ''}. Nothing changes until you accept a suggestion.` }));
} catch { if (current()) setAi(state => ({ ...state, note: 'Could not reach the AI review. Your parts are unchanged.' })); }
finally { if (current()) setAi(state => ({ ...state, running: false })); }
Expand Down Expand Up @@ -678,7 +682,7 @@ export function useControlKit(context: URLSearchParams) {
return { url: URL.createObjectURL(await response.blob()), drawnAt: response.headers.get('x-picture-drawn'), view };
}

return { assemblyPicture, redrawAssemblyPicture, choiceTimes, refreshChoices, context, buildWorkbook, lastStepZip, previous, ai, runAiReview, acceptAi, dismissAi, saving, showInOnshape, partPicture, session, checkingSession, refreshSession, connectUrl, error, clearError: () => setError(''), result, progress, scan, cancelScan, decide, setDisposition,
return { assemblyPicture, redrawAssemblyPicture, choiceTimes, refreshChoices, context, buildWorkbook, lastStepZip, previous, ai, refreshAiStatus, runAiReview, acceptAi, dismissAi, saving, showInOnshape, partPicture, session, checkingSession, refreshSession, connectUrl, error, clearError: () => setError(''), result, progress, scan, cancelScan, decide, setDisposition,
acceptSuggestions, setDispositions, bulk, pending, settings, savingSettings, saveSettings, signOut, excel, downloadExcel, downloadCsv, step, downloadStep };
}
export type ControlKit = ReturnType<typeof useControlKit>;
Loading
Loading