Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console Skills, netlist/BOM design graph, per-IC direct datasheet review with page citations, capacitor derating, Next.js report UI. Extracted from the Pinscope cloud codebase. Auth and billing live in the private gateway repo behind stable seams (billing_hook.py, adapter files listed in CLAUDE.md).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Open-core auth switch.
|
||||
*
|
||||
* When NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is unset the app runs in local/OSS
|
||||
* mode: no ClerkProvider, pass-through middleware, a stubbed signed-in
|
||||
* "local" user (matching the backend's LOCAL_DEV_USER), and all credits /
|
||||
* billing UI hidden. Pairs with BILLING_ENABLED=false on the backend —
|
||||
* mixed modes (key set but billing off, or the inverse) are unsupported.
|
||||
*
|
||||
* NEXT_PUBLIC_* vars are inlined at build time, so this is a build-time
|
||||
* constant — changing it requires a rebuild / dev-server restart.
|
||||
*/
|
||||
export const authEnabled = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
|
||||
@@ -0,0 +1,10 @@
|
||||
// Open-core seam: third-party hosts allow-listed in the CSP
|
||||
// (next.config.ts). The cloud/gateway build adds Clerk (auth), Stripe
|
||||
// (billing), and its analytics hosts here; the open-source build needs
|
||||
// none.
|
||||
|
||||
export const CSP_SCRIPT_HOSTS: string[] = [];
|
||||
|
||||
export const CSP_CONNECT_HOSTS: string[] = [];
|
||||
|
||||
export const CSP_FRAME_HOSTS: string[] = [];
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Project, PipelineStep } from "./types";
|
||||
|
||||
export const PROJECTS: Project[] = [
|
||||
{
|
||||
id: "simple_project",
|
||||
name: "TI MSP Tutorial Board",
|
||||
created: "2026-04-09T00:00:00Z",
|
||||
status: "complete",
|
||||
summary: { total: 8, ERROR: 2, WARNING: 4, INFO: 2 },
|
||||
hasNetlist: true,
|
||||
hasBom: true,
|
||||
datasheetCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export function createPipelineSteps(): PipelineStep[] {
|
||||
return [
|
||||
{
|
||||
title: "IC Datasheet Extraction",
|
||||
description: "Parse component datasheets and extract constraints",
|
||||
status: "pending",
|
||||
substeps: [
|
||||
{ key: "SPX3819M5-L-3-3/TR", label: "SPX3819M5-L-3-3/TR (U1)", status: "pending" },
|
||||
{ key: "CH340E", label: "CH340E (U2)", status: "pending" },
|
||||
{ key: "MSPM0G3507SPTR", label: "MSPM0G3507SPTR (U3)", status: "pending" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Passive Pattern Extraction",
|
||||
description: "Resolve passive component values from MPN patterns",
|
||||
status: "pending",
|
||||
substeps: [
|
||||
{ key: "samsung", label: "Samsung capacitors", status: "pending" },
|
||||
{ key: "uniroyal", label: "Uniroyal resistors", status: "pending" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Build Design Graph",
|
||||
description: "Parse BOM and netlist into structured graph",
|
||||
status: "pending",
|
||||
substeps: [
|
||||
{ key: "parse-bom", label: "Parse BOM (17 components)", status: "pending" },
|
||||
{ key: "parse-netlist", label: "Parse netlist (36 nets)", status: "pending" },
|
||||
{ key: "enrich", label: "Enrich with datasheet data", status: "pending" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Review Design",
|
||||
description: "Review each IC against its datasheet",
|
||||
status: "pending",
|
||||
substeps: [
|
||||
{ key: "U1", label: "Review U1 — LDO", status: "pending" },
|
||||
{ key: "U2", label: "Review U2 — USB Bridge", status: "pending" },
|
||||
{ key: "U3", label: "Review U3 — MCU", status: "pending" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import type { DesignGraph, Finding, ValidationReport } from "./types";
|
||||
import { sortFindings } from "./utils";
|
||||
|
||||
const HEADER = [
|
||||
"Designator",
|
||||
"MPN",
|
||||
"ID",
|
||||
"Severity",
|
||||
"Title",
|
||||
"Description",
|
||||
"Recommendation",
|
||||
"Source",
|
||||
];
|
||||
|
||||
// Column widths (in characters), aligned with HEADER order.
|
||||
const COL_WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24];
|
||||
|
||||
// Fold the datasheet reference + page number into a single cell, mirroring the
|
||||
// finding card's reference button + "Automated check" tag logic.
|
||||
function formatSource(f: Finding): string {
|
||||
if (f.source && f.source !== "review") return "Automated check";
|
||||
const ds = f.source_designator || f.designator;
|
||||
return `${ds} datasheet` + (f.source_page ? ` p.${f.source_page}` : "");
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "report";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and download the validation report as an .xlsx workbook (client-side).
|
||||
* One "Findings" sheet, one row per finding, sorted ERROR -> WARNING -> INFO.
|
||||
*/
|
||||
export function exportReportToExcel(
|
||||
report: ValidationReport,
|
||||
graph: DesignGraph,
|
||||
projectName?: string,
|
||||
): void {
|
||||
const rows = sortFindings(report.findings).map((f) => [
|
||||
f.designator,
|
||||
f.mpn || graph.components[f.designator]?.mpn || "",
|
||||
f.finding_id ?? "",
|
||||
f.status,
|
||||
f.finding,
|
||||
f.why ?? "",
|
||||
f.recommendation ?? "",
|
||||
formatSource(f),
|
||||
]);
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([HEADER, ...rows]);
|
||||
ws["!cols"] = COL_WIDTHS.map((wch) => ({ wch }));
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Findings");
|
||||
|
||||
const filename = `${slugify(projectName || report.project || "report")}-findings.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export const SITE_URL = (
|
||||
process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, "") ||
|
||||
"https://pinscope.ai"
|
||||
);
|
||||
|
||||
export const SITE_NAME = "Pinscope";
|
||||
|
||||
export const SITE_DESCRIPTION =
|
||||
"Pinscope reviews your schematic against every datasheet and catches the errors that would otherwise surface at bring-up. Works with KiCad, Altium, OrCAD, Cadence, Siemens, EasyEDA, and EAGLE.";
|
||||
|
||||
export const SITE_TAGLINE = "Agentic schematic validation";
|
||||
|
||||
export const TWITTER_HANDLE = "@getFaradWorks";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* Build per-page metadata that preserves root openGraph/twitter fields.
|
||||
* Next.js shallow-overwrites the openGraph and twitter keys, so any page
|
||||
* that sets its own values must re-declare siteName/card/images/etc.
|
||||
*/
|
||||
export function pageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}): Metadata {
|
||||
const url = path.startsWith("/") ? path : `/${path}`;
|
||||
const fullTitle = `${title} · ${SITE_NAME}`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: SITE_NAME,
|
||||
locale: "en_US",
|
||||
url,
|
||||
title: fullTitle,
|
||||
description,
|
||||
images: [
|
||||
{
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: TWITTER_HANDLE,
|
||||
creator: TWITTER_HANDLE,
|
||||
title: fullTitle,
|
||||
description,
|
||||
images: ["/twitter-image"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
export type FindingStatus = "ERROR" | "WARNING" | "INFO";
|
||||
|
||||
export interface Finding {
|
||||
finding_id: string | null;
|
||||
designator: string;
|
||||
mpn: string;
|
||||
aspect: string | null;
|
||||
finding: string;
|
||||
why: string;
|
||||
source_page: number | null;
|
||||
source_quote?: string;
|
||||
source_designator?: string | null; // designator whose datasheet source_page refers to; null = this finding's own `designator` (evidence from a connected component's datasheet excerpt)
|
||||
status: FindingStatus;
|
||||
recommendation?: string;
|
||||
reference: string;
|
||||
source?: string | null; // "pin_mux_check"/"led_current_check" = deterministic; null/"review" = LLM
|
||||
}
|
||||
|
||||
export interface FindingComment {
|
||||
comment_id: string;
|
||||
finding_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
text: string;
|
||||
mentions: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
project: string;
|
||||
timestamp: string;
|
||||
findings: Finding[];
|
||||
summary: Record<string, number>;
|
||||
coverage: Record<string, string[]>;
|
||||
review_errors?: Record<string, string>;
|
||||
not_reviewed?: { designator: string; reason: string }[];
|
||||
comments?: Record<string, FindingComment[]>;
|
||||
}
|
||||
|
||||
export type NetType = "power" | "ground" | "signal" | "unknown";
|
||||
export type ComponentType =
|
||||
| "resistor"
|
||||
| "capacitor"
|
||||
| "inductor"
|
||||
| "ic"
|
||||
| "connector"
|
||||
| "crystal"
|
||||
| "discrete"
|
||||
| "transformer"
|
||||
| "fuse"
|
||||
| "switch"
|
||||
| "test_point"
|
||||
| "fiducial"
|
||||
| "mechanical"
|
||||
| "unknown";
|
||||
|
||||
export interface ComponentSpecs {
|
||||
specs_type: string;
|
||||
value_formatted?: string;
|
||||
values?: Record<string, string | number | null>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
reference: string;
|
||||
value: string;
|
||||
footprint: string;
|
||||
component_type: ComponentType;
|
||||
component_subtype: string | null;
|
||||
mpn: string | null;
|
||||
pins: Record<string, string>;
|
||||
specs: ComponentSpecs | null;
|
||||
}
|
||||
|
||||
export interface PinConnection {
|
||||
component_ref: string;
|
||||
pin_number: string;
|
||||
pin_name: string | null;
|
||||
}
|
||||
|
||||
export interface Net {
|
||||
name: string;
|
||||
net_type: NetType;
|
||||
voltage: number | null;
|
||||
pins: PinConnection[];
|
||||
}
|
||||
|
||||
export interface DesignGraph {
|
||||
components: Record<string, Component>;
|
||||
nets: Record<string, Net>;
|
||||
}
|
||||
|
||||
export interface BomSummaryRow {
|
||||
mpn: string | null;
|
||||
designators: string[];
|
||||
value: string;
|
||||
category: string | null;
|
||||
specs: Record<string, string | number> | null;
|
||||
description: string | null;
|
||||
hasDatasheet: boolean;
|
||||
}
|
||||
|
||||
export type ProjectStatus =
|
||||
| "draft"
|
||||
| "running"
|
||||
| "complete"
|
||||
| "error"
|
||||
| "cancelled"
|
||||
| "paused_insufficient_credits"
|
||||
| "paused_by_user";
|
||||
|
||||
export interface PauseCheckpoint {
|
||||
paused_at?: string | null;
|
||||
paused_stage?: string | null;
|
||||
last_completed_label?: string | null;
|
||||
completed_review_refs?: string[];
|
||||
pending_review_refs?: string[];
|
||||
}
|
||||
|
||||
export interface CreditSnapshot {
|
||||
user_id: string;
|
||||
balance: number;
|
||||
plan: string; // always "payg" post-migration; kept for backward compat
|
||||
last_entry_ts?: string | null;
|
||||
next_expiry?: string | null;
|
||||
}
|
||||
|
||||
export interface CreditGrant {
|
||||
grant_id: string;
|
||||
user_id: string;
|
||||
amount_usd: number;
|
||||
remaining: number;
|
||||
granted_at: string;
|
||||
expires_at: string | null;
|
||||
source:
|
||||
| "top_up"
|
||||
| "trial_grant"
|
||||
| "admin_adjust"
|
||||
| "refund_system_error"
|
||||
| "pre_migration";
|
||||
stripe_event_id?: string | null;
|
||||
expired?: boolean;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface AutoTopupConfig {
|
||||
enabled: boolean;
|
||||
threshold_credits: number;
|
||||
amount_usd: number;
|
||||
has_payment_method: boolean;
|
||||
last_attempt_ts?: string | null;
|
||||
last_attempt_status?: "ok" | "failed" | "pending" | "";
|
||||
last_failure_reason?: string | null;
|
||||
}
|
||||
|
||||
export interface CreditLedgerEntry {
|
||||
user_id: string;
|
||||
timestamp: string;
|
||||
delta: number;
|
||||
balance_after: number;
|
||||
reason: string;
|
||||
run_id?: string | null;
|
||||
unit_id?: string | null;
|
||||
stripe_event_id?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface CostItem {
|
||||
identifier: string;
|
||||
kind: string;
|
||||
api_cost_usd: number;
|
||||
source: "cache_hit" | "api_call" | "api_call_estimated";
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
api_cost_low: number;
|
||||
api_cost_high: number;
|
||||
api_cost_mid: number;
|
||||
credits_low: number;
|
||||
credits_high: number;
|
||||
credits_mid: number;
|
||||
breakdown: CostItem[];
|
||||
ic_count: number;
|
||||
simple_count: number;
|
||||
passive_count: number;
|
||||
cached_ic_count: number;
|
||||
cached_simple_count: number;
|
||||
cached_passive_count: number;
|
||||
review_ic_count: number;
|
||||
}
|
||||
|
||||
export interface SkippedComponent {
|
||||
identifier: string;
|
||||
stage: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached purple-parts payload for a single LCSC id. Populated server-side at
|
||||
* BOM upload when the MPN column was detected as entirely LCSC ids; consumed
|
||||
* by the wizard's LCSC passive-resolve step to render a description summary
|
||||
* and by the backend to synthesize the auto-resolve call.
|
||||
*/
|
||||
export interface LcscPayload {
|
||||
mpn?: string | null;
|
||||
manufacturer?: string | null;
|
||||
package?: string | null;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
subcategory?: string | null;
|
||||
}
|
||||
|
||||
export interface ComponentMpnBuckets {
|
||||
ic: string[];
|
||||
passive: string[];
|
||||
simple: string[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
created: string;
|
||||
status: ProjectStatus;
|
||||
summary?: Record<string, number>;
|
||||
hasNetlist: boolean;
|
||||
hasBom: boolean;
|
||||
datasheetCount: number;
|
||||
skippedComponents?: SkippedComponent[];
|
||||
userId?: string;
|
||||
collaborators?: string[];
|
||||
creditsSpent?: number;
|
||||
pauseCheckpoint?: PauseCheckpoint | null;
|
||||
pauseReason?: string | null;
|
||||
bomColumns?: { reference: string; mpn: string } | null;
|
||||
pipelineError?: string | null;
|
||||
// LCSC mapping captured at BOM upload time. `lcscToMpn` is the resolved
|
||||
// LCSC id → MPN map used by the wizard to render "C12044 → STM32F103C8T6";
|
||||
// `lcscPayloads` is the full purple-parts payload keyed by LCSC id used by
|
||||
// the per-row passive resolve step.
|
||||
lcscToMpn?: Record<string, string> | null;
|
||||
lcscPayloads?: Record<string, LcscPayload> | null;
|
||||
componentMpns?: ComponentMpnBuckets | null;
|
||||
pinscopeVersion?: string | null;
|
||||
// "pads" | "edif" — what kind of netlist file the user uploaded. null on
|
||||
// projects predating EDIF support; treat null as PADS for rendering.
|
||||
netlistFormat?: "pads" | "edif" | null;
|
||||
// Sub-design IDs (e.g. ["&0441"]) the user chose to include in the review.
|
||||
// null means "include every sub-design found in the file" — the default
|
||||
// for single-sub-design EDIFs and all PADS netlists.
|
||||
netlistSubdesigns?: string[] | null;
|
||||
}
|
||||
|
||||
// One entry per EDIF sub-design (`&NNNN` ID prefix). Returned by the upload
|
||||
// endpoint and the subdesigns inspection endpoint; consumed by the wizard's
|
||||
// sub-design picker step.
|
||||
export interface EdifSubDesign {
|
||||
id: string | null; // null → bare-named cells with no prefix
|
||||
instance_count: number;
|
||||
designators: string[];
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image_url: string | null;
|
||||
role: "owner" | "collaborator";
|
||||
}
|
||||
|
||||
export interface ApiLogEntry {
|
||||
timestamp: string;
|
||||
stage: string;
|
||||
identifier: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
duration_ms: number;
|
||||
stop_reason: string;
|
||||
skill_id?: string | null;
|
||||
turns?: number | null;
|
||||
error?: string | null;
|
||||
cost_usd?: number | null;
|
||||
}
|
||||
|
||||
export interface DeratingRow {
|
||||
designator: string;
|
||||
mpn: string | null;
|
||||
value_formatted: string | null;
|
||||
rated_voltage_v: number | null;
|
||||
operating_voltage_v: number | null;
|
||||
operating_voltage_source: string | null;
|
||||
net_plus: string | null;
|
||||
net_minus: string | null;
|
||||
dielectric_category: "ceramic" | "tantalum" | "electrolytic" | null;
|
||||
}
|
||||
|
||||
export interface DeratingSettings {
|
||||
ceramic: number;
|
||||
tantalum: number;
|
||||
electrolytic: number;
|
||||
}
|
||||
|
||||
export interface NetlistPreviewDesignator {
|
||||
ref: string;
|
||||
pins: { number: string; net_name: string }[];
|
||||
}
|
||||
|
||||
export interface PipelineSubstep {
|
||||
key: string;
|
||||
label: string;
|
||||
status: "pending" | "running" | "complete";
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
export interface PipelineStep {
|
||||
title: string;
|
||||
description: string;
|
||||
substeps: PipelineSubstep[];
|
||||
status: "pending" | "running" | "complete";
|
||||
totalNew?: number;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { Finding, FindingStatus } from "./types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function groupBy<T>(items: T[], key: (item: T) => string): Record<string, T[]> {
|
||||
const result: Record<string, T[]> = {};
|
||||
for (const item of items) {
|
||||
const k = key(item);
|
||||
(result[k] ??= []).push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||
|
||||
export function sortFindings(findings: Finding[]): Finding[] {
|
||||
return [...findings].sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
|
||||
}
|
||||
|
||||
export function getFindingKey(finding: Finding, index: number): string {
|
||||
return finding.finding_id ?? `${finding.designator}-idx-${index}`;
|
||||
}
|
||||
|
||||
export function subtypeLabel(subtype: string | null): string {
|
||||
if (!subtype) return "";
|
||||
const parts = subtype.split(".");
|
||||
return parts[parts.length - 1]
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
|
||||
// Do not edit by hand — change the top "## X.Y.Z" heading in the changelog.
|
||||
export const APP_VERSION = "2.6.0";
|
||||
export const APP_VERSION_DATE = "2026-07-12";
|
||||
Reference in New Issue
Block a user