Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd3a6fd009 | ||
|
|
c6abdae762 | ||
|
|
6dc04b5cc7 | ||
|
|
07ad5c72fa | ||
|
|
0d817c876f | ||
|
|
348f390cce | ||
|
|
027546b0bd | ||
|
|
a0b89365c2 | ||
|
|
278f176859 | ||
|
|
fbf2fc87a6 | ||
|
|
bfb781f94f | ||
|
|
ec2ffd6107 | ||
|
|
82d185ccf4 | ||
|
|
e31981c812 | ||
|
|
72bcab50a1 | ||
|
|
a2d5900b64 | ||
|
|
51c09b2176 | ||
|
|
9a2320f9e2 | ||
|
|
8040686e8b | ||
|
|
63c85a4319 | ||
|
|
6b605d1cf2 | ||
|
|
a001971248 | ||
|
|
39169b33b8 | ||
|
|
7a32c552bb | ||
|
|
ed3fff565d | ||
|
|
22bc075c58 | ||
|
|
3a666ea8ae | ||
|
|
7a377889ae | ||
|
|
6b1fe5be00 | ||
|
|
00e5df8a36 | ||
|
|
ee160a5df9 | ||
|
|
28a4dc8255 | ||
|
|
6f3e714a54 | ||
|
|
739e6f7f17 | ||
|
|
cdfe5812cf | ||
|
|
44e86e258e | ||
|
|
72b4b20665 | ||
|
|
7d82358e19 | ||
|
|
c5cd98587f | ||
|
|
b3566db828 | ||
|
|
d4070081c8 | ||
|
|
292d2e2077 | ||
|
|
93f6fad3e7 | ||
|
|
a9ce71f17a | ||
|
|
d21a303e8b | ||
|
|
a011351828 | ||
|
|
2613678442 | ||
|
|
bac75a2fba | ||
|
|
d0fd37c1b5 | ||
|
|
7c3c7d17d8 | ||
|
|
d0bbd582a8 | ||
|
|
40b251c387 | ||
|
|
82ffa755cd | ||
|
|
3d7fa21eb6 | ||
|
|
6a4903fa3a | ||
|
|
e240afce23 | ||
|
|
572b8d24f3 | ||
|
|
fe89851c10 | ||
|
|
9997f39718 | ||
|
|
24f9c952e2 | ||
|
|
19a1eef936 | ||
|
|
7ff049f6d0 | ||
|
|
5a1d31ce7b | ||
|
|
2f2e35e802 | ||
|
|
0533d4ebd2 | ||
|
|
6d6e70cec0 | ||
|
|
49a27d5951 | ||
|
|
0b01617b37 | ||
|
|
1389d832df | ||
|
|
31fd389b38 | ||
|
|
34a9b30a35 | ||
|
|
09c49f8529 | ||
|
|
ad21d00cd0 | ||
|
|
19eab09000 | ||
|
|
5c0c5184fb | ||
|
|
a96ee2e88a | ||
|
|
974923b2e6 | ||
|
|
a0a2f5bdbf | ||
|
|
c362ef8a56 | ||
|
|
0a75e5971a | ||
|
|
312057b6d3 | ||
|
|
a63e5bd7ab | ||
|
|
1b3529501f | ||
|
|
65a91419a2 | ||
|
|
187605763d | ||
|
|
1f3876eadc | ||
|
|
bee6369c1d | ||
|
|
342792df2d | ||
|
|
facbaa2305 | ||
|
|
f63c1c3411 | ||
|
|
1e379f6042 | ||
|
|
dc65e96a22 | ||
|
|
8f4ebc645c | ||
|
|
f3f96ea4d1 | ||
|
|
89a484830e | ||
|
|
3e2ba269f9 | ||
|
|
344ecb3389 | ||
|
|
ddab6917a3 | ||
|
|
6b0e44a50c | ||
|
|
16c606ae3d | ||
|
|
a2011dad91 | ||
|
|
721ede4243 |
@@ -0,0 +1,209 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## PURPOSE
|
||||
|
||||
This document defines the mandatory engineering rules for the Periscope project.
|
||||
|
||||
These rules are not suggestions.
|
||||
|
||||
They define:
|
||||
|
||||
* repository structure;
|
||||
* coding standards;
|
||||
* architectural principles;
|
||||
* semantic modeling rules;
|
||||
* Python/Rust language policy;
|
||||
* testing requirements;
|
||||
* verification requirements;
|
||||
* Git workflow;
|
||||
* commit/push/deployment policy;
|
||||
* development-phase discipline.
|
||||
|
||||
Cursor must treat these rules as **mandatory project constraints**.
|
||||
|
||||
If an implementation conflicts with these rules, the implementation must be changed.
|
||||
|
||||
Do not silently relax, bypass, reinterpret, or ignore these rules.
|
||||
|
||||
---
|
||||
|
||||
# 1. CREATE THE PROJECT RULE STRUCTURE
|
||||
|
||||
Create and maintain the following structure:
|
||||
|
||||
```text
|
||||
.cursor/
|
||||
└── rules/
|
||||
├── 00-coding-constitution.mdc
|
||||
├── 10-architecture.mdc
|
||||
├── 20-python.mdc
|
||||
├── 30-rust.mdc
|
||||
├── 40-testing.mdc
|
||||
├── 50-verification.mdc
|
||||
└── 60-git-workflow.mdc
|
||||
|
||||
docs/
|
||||
└── development/
|
||||
└── CODING_CONSTITUTION.md
|
||||
```
|
||||
|
||||
The Markdown document is the human-readable master document.
|
||||
|
||||
The `.mdc` files are the operational Cursor rules.
|
||||
|
||||
The rules must be version-controlled with the project.
|
||||
|
||||
Do not create contradictory rules in different files.
|
||||
|
||||
If a conflict exists, resolve the conflict explicitly before continuing development.
|
||||
|
||||
---
|
||||
|
||||
# 2. CORE PRINCIPLE
|
||||
|
||||
## CODE THAT FITS IN YOUR HEAD
|
||||
|
||||
This is the fundamental Periscope engineering principle.
|
||||
|
||||
> Code must be locally understandable by an engineer without requiring reconstruction of a large hidden abstraction system.
|
||||
|
||||
Prefer:
|
||||
|
||||
* small modules;
|
||||
* small functions;
|
||||
* one responsibility;
|
||||
* explicit dependencies;
|
||||
* explicit data flow;
|
||||
* simple data structures;
|
||||
* precise names;
|
||||
* deterministic behavior;
|
||||
* minimal public APIs;
|
||||
* composition over inheritance.
|
||||
|
||||
Avoid:
|
||||
|
||||
* huge functions;
|
||||
* god classes;
|
||||
* deep inheritance;
|
||||
* speculative abstractions;
|
||||
* hidden state;
|
||||
* magic behavior;
|
||||
* global mutable state;
|
||||
* unnecessary frameworks;
|
||||
* premature optimization;
|
||||
* unnecessary indirection.
|
||||
|
||||
A function around 40–60 lines is a warning signal, not an absolute limit.
|
||||
|
||||
The actual rule is local comprehensibility.
|
||||
|
||||
A 20-line function can violate the rule if it contains too many responsibilities.
|
||||
|
||||
A longer function may be acceptable if its structure remains obvious and justified.
|
||||
|
||||
---
|
||||
|
||||
# 3. ONE RESPONSIBILITY
|
||||
|
||||
Every function and module must have one primary responsibility.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
parse
|
||||
↓
|
||||
normalize
|
||||
↓
|
||||
build model
|
||||
↓
|
||||
analyze
|
||||
↓
|
||||
produce finding
|
||||
↓
|
||||
report
|
||||
```
|
||||
|
||||
Avoid a single function that:
|
||||
|
||||
* parses input;
|
||||
* modifies the model;
|
||||
* performs analysis;
|
||||
* accesses external services;
|
||||
* formats output;
|
||||
* writes files;
|
||||
* handles errors;
|
||||
* and generates reports.
|
||||
|
||||
Separate responsibilities.
|
||||
|
||||
---
|
||||
|
||||
# 4. EXPLICIT DATA FLOW
|
||||
|
||||
Periscope must have explicit data flow.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
SOURCE
|
||||
↓
|
||||
PARSER
|
||||
↓
|
||||
NORMALIZED MODEL
|
||||
↓
|
||||
ANALYZER
|
||||
↓
|
||||
FINDING
|
||||
↓
|
||||
REPORT
|
||||
```
|
||||
|
||||
Avoid hidden communication through:
|
||||
|
||||
* global variables;
|
||||
* singleton state;
|
||||
* implicit registries;
|
||||
* hidden caches;
|
||||
* ambient configuration;
|
||||
* side effects;
|
||||
* undocumented environment state.
|
||||
|
||||
If a function needs important information, that information must be visible in its inputs or clearly owned state.
|
||||
|
||||
---
|
||||
|
||||
# 5. NO SPECULATIVE ABSTRACTION
|
||||
|
||||
Do not introduce an abstraction merely because it might become useful later.
|
||||
|
||||
Do not create unnecessary:
|
||||
|
||||
* factories;
|
||||
* generic managers;
|
||||
* service layers;
|
||||
* repository layers;
|
||||
* plugin systems;
|
||||
* inheritance hierarchies;
|
||||
* generic object wrappers;
|
||||
* framework-like internal infrastructure.
|
||||
|
||||
Implement the actual requirement first.
|
||||
|
||||
Abstract only when there is a concrete and demonstrated reason.
|
||||
|
||||
---
|
||||
|
||||
# 6. COMPOSITION OVER INHERITANCE
|
||||
|
||||
Prefer composition.
|
||||
|
||||
Avoid deep class hierarchies.
|
||||
|
||||
Inheritance must have a clear semantic reason.
|
||||
|
||||
Do not use inheritance merely to share a few methods.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 7. DETERMINISTIC CORE
|
||||
|
||||
The verification core must be deterministic.
|
||||
|
||||
For the same:
|
||||
|
||||
```text
|
||||
input
|
||||
+
|
||||
configuration
|
||||
+
|
||||
source data
|
||||
```
|
||||
|
||||
Periscope must produce the same:
|
||||
|
||||
```text
|
||||
model
|
||||
calculations
|
||||
findings
|
||||
```
|
||||
|
||||
Do not make deterministic verification dependent on:
|
||||
|
||||
* LLM output;
|
||||
* prompts;
|
||||
* randomness;
|
||||
* execution order;
|
||||
* hidden state;
|
||||
* uncontrolled external state.
|
||||
|
||||
---
|
||||
|
||||
# 8. LLM ROLE
|
||||
|
||||
LLMs may be used for:
|
||||
|
||||
* extraction;
|
||||
* classification;
|
||||
* datasheet interpretation;
|
||||
* explanation;
|
||||
* hypothesis generation;
|
||||
* assistance.
|
||||
|
||||
LLMs must not be the authoritative deterministic verification engine.
|
||||
|
||||
The intended architecture is:
|
||||
|
||||
```text
|
||||
SOURCE
|
||||
↓
|
||||
STRUCTURED REQUIREMENT
|
||||
↓
|
||||
DETERMINISTIC ANALYZER
|
||||
↓
|
||||
FINDING
|
||||
↓
|
||||
OPTIONAL LLM EXPLANATION
|
||||
```
|
||||
|
||||
Not:
|
||||
|
||||
```text
|
||||
SOURCE
|
||||
↓
|
||||
LLM
|
||||
↓
|
||||
probably wrong
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. EVIDENCE BEFORE CONCLUSION
|
||||
|
||||
Every engineering conclusion must be traceable to evidence.
|
||||
|
||||
Where applicable, preserve:
|
||||
|
||||
```text
|
||||
observed fact
|
||||
requirement
|
||||
source
|
||||
calculation
|
||||
assumption
|
||||
conclusion
|
||||
confidence
|
||||
severity
|
||||
```
|
||||
|
||||
Do not silently transform assumptions into facts.
|
||||
|
||||
---
|
||||
|
||||
# 10. REQUIREMENT STRENGTH
|
||||
|
||||
Requirements must preserve their strength.
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
MANDATORY
|
||||
RECOMMENDED
|
||||
TYPICAL
|
||||
EXAMPLE
|
||||
ENGINEERING_INFERENCE
|
||||
```
|
||||
|
||||
Do not convert:
|
||||
|
||||
```text
|
||||
RECOMMENDED
|
||||
```
|
||||
|
||||
into:
|
||||
|
||||
```text
|
||||
MANDATORY
|
||||
```
|
||||
|
||||
Do not convert an example into an absolute rule.
|
||||
|
||||
Do not convert an engineering inference into documented manufacturer information.
|
||||
|
||||
---
|
||||
|
||||
# 11. SEVERITY AND CONFIDENCE ARE INDEPENDENT
|
||||
|
||||
A finding must be able to represent independent:
|
||||
|
||||
```text
|
||||
severity
|
||||
confidence
|
||||
```
|
||||
|
||||
Examples that are valid:
|
||||
|
||||
```text
|
||||
ERROR + LOW confidence
|
||||
RISK + HIGH confidence
|
||||
```
|
||||
|
||||
Do not use confidence as a substitute for severity.
|
||||
|
||||
---
|
||||
|
||||
# 12. INSUFFICIENT EVIDENCE
|
||||
|
||||
If the available information is insufficient to establish a conclusion:
|
||||
|
||||
```text
|
||||
INSUFFICIENT_EVIDENCE
|
||||
```
|
||||
|
||||
must be used.
|
||||
|
||||
Never invent missing engineering information.
|
||||
|
||||
Never invent:
|
||||
|
||||
* electrical limits;
|
||||
* timing values;
|
||||
* pin functions;
|
||||
* voltages;
|
||||
* currents;
|
||||
* tolerances;
|
||||
* PCB rules;
|
||||
* thermal limits;
|
||||
* datasheet requirements.
|
||||
|
||||
Missing evidence is not permission to guess.
|
||||
|
||||
---
|
||||
|
||||
# 13. SEMANTIC MODEL FIRST
|
||||
|
||||
Periscope must preserve domain semantics.
|
||||
|
||||
At minimum, distinguish concepts such as:
|
||||
|
||||
```text
|
||||
Project
|
||||
Component
|
||||
Pin
|
||||
Footprint
|
||||
Pad
|
||||
Net
|
||||
Via
|
||||
Track
|
||||
Zone
|
||||
PowerRail
|
||||
SignalGroup
|
||||
TimingEvent
|
||||
Requirement
|
||||
Constraint
|
||||
DesignIntent
|
||||
Evidence
|
||||
Finding
|
||||
```
|
||||
|
||||
Do not collapse semantically different physical objects into one generic object merely because they share coordinates, nets, or attributes.
|
||||
|
||||
---
|
||||
|
||||
# 14. SEMANTIC IDENTITY MUST SURVIVE TRANSFORMATIONS
|
||||
|
||||
A transformation must preserve the semantic identity of objects.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
PCB Via
|
||||
```
|
||||
|
||||
must never become:
|
||||
|
||||
```text
|
||||
Component Pad
|
||||
```
|
||||
|
||||
merely because:
|
||||
|
||||
* it is close to a footprint;
|
||||
* it is inside a footprint area;
|
||||
* it is connected to GND;
|
||||
* it shares a net;
|
||||
* it has coordinates similar to a pad.
|
||||
|
||||
Similarly:
|
||||
|
||||
```text
|
||||
Pad ≠ Via
|
||||
Track ≠ Via
|
||||
Zone ≠ Pad
|
||||
Component Pin ≠ PCB Via
|
||||
```
|
||||
|
||||
This is a fundamental Periscope rule.
|
||||
|
||||
---
|
||||
|
||||
# 15. ROOT CAUSE OVER SYMPTOM
|
||||
|
||||
When a false positive or incorrect result is discovered:
|
||||
|
||||
Do not simply suppress the finding.
|
||||
|
||||
Determine where the representation first becomes incorrect.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
PCB
|
||||
↓
|
||||
parser
|
||||
↓
|
||||
incorrect model
|
||||
↓
|
||||
correct analyzer operating on incorrect model
|
||||
↓
|
||||
incorrect finding
|
||||
```
|
||||
|
||||
The fix belongs in the model/parser boundary, not necessarily in the analyzer.
|
||||
|
||||
Always investigate the complete chain:
|
||||
|
||||
```text
|
||||
source
|
||||
→ parser
|
||||
→ normalized representation
|
||||
→ semantic model
|
||||
→ analyzer
|
||||
→ finding
|
||||
→ report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 16. NO COMPONENT-SPECIFIC HACKS
|
||||
|
||||
Do not fix architecture problems with:
|
||||
|
||||
```text
|
||||
if component == U1
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
if footprint == X
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
if net == GND
|
||||
```
|
||||
|
||||
or hard-coded corrections such as:
|
||||
|
||||
```text
|
||||
subtract N
|
||||
ignore N
|
||||
force expected count
|
||||
```
|
||||
|
||||
unless the condition represents a genuine documented engineering rule.
|
||||
|
||||
A bug in generic PCB semantics must receive a generic semantic fix.
|
||||
|
||||
---
|
||||
|
||||
# 17. PHYSICAL SEMANTICS
|
||||
|
||||
Periscope is an electronic design verification system.
|
||||
|
||||
Its internal model must reflect physical reality.
|
||||
|
||||
Geometric proximity does not establish semantic identity.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
Via located inside footprint
|
||||
```
|
||||
|
||||
does not imply:
|
||||
|
||||
```text
|
||||
Via belongs to component as a pad
|
||||
```
|
||||
|
||||
unless the PCB data model explicitly establishes that relationship.
|
||||
|
||||
Do not infer component relationships solely from proximity when stronger source information exists.
|
||||
|
||||
---
|
||||
|
||||
# 18. UNITS MUST BE EXPLICIT
|
||||
|
||||
Use explicit units.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
width_mm
|
||||
delay_ns
|
||||
frequency_hz
|
||||
voltage_v
|
||||
current_a
|
||||
temperature_c
|
||||
```
|
||||
|
||||
over ambiguous variables such as:
|
||||
|
||||
```text
|
||||
width
|
||||
delay
|
||||
value
|
||||
limit
|
||||
```
|
||||
|
||||
when unit ambiguity is possible.
|
||||
|
||||
Never rely on undocumented implicit units.
|
||||
|
||||
---
|
||||
|
||||
# 19. NAMED CONSTANTS
|
||||
|
||||
Avoid magic numbers.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
MIN_SUPPLY_V
|
||||
MAX_TRACE_LENGTH_MM
|
||||
DEFAULT_SPI_CLOCK_HZ
|
||||
```
|
||||
|
||||
over unexplained numeric literals.
|
||||
|
||||
Engineering constants must have traceable sources where appropriate.
|
||||
|
||||
---
|
||||
|
||||
# 20. ERROR CATEGORIES
|
||||
|
||||
Distinguish:
|
||||
|
||||
```text
|
||||
tool failure
|
||||
data failure
|
||||
analysis result
|
||||
insufficient evidence
|
||||
engineering violation
|
||||
```
|
||||
|
||||
Do not collapse fundamentally different failure modes into one generic error.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 32. PYTHON IS THE DEFAULT LANGUAGE
|
||||
|
||||
Python is the default implementation language for Periscope.
|
||||
|
||||
Use Python for:
|
||||
|
||||
* orchestration;
|
||||
* datasheet processing;
|
||||
* document parsing;
|
||||
* LLM integration;
|
||||
* requirement extraction;
|
||||
* evidence management;
|
||||
* project management;
|
||||
* CLI;
|
||||
* API integration;
|
||||
* reporting;
|
||||
* test orchestration;
|
||||
* external tool integration;
|
||||
* high-level analysis.
|
||||
|
||||
Do not rewrite Python into Rust merely because Rust exists in the project.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 33. RUST IS NOT A DEFAULT
|
||||
|
||||
Rust must not be introduced simply because code is new.
|
||||
|
||||
Rust is justified only when there is a demonstrated engineering requirement.
|
||||
|
||||
Valid reasons include:
|
||||
|
||||
* measured performance bottleneck;
|
||||
* computationally intensive geometry;
|
||||
* large-scale spatial processing;
|
||||
* numerical computation;
|
||||
* graph processing;
|
||||
* memory pressure;
|
||||
* deterministic high-performance computation.
|
||||
|
||||
Do not use Rust speculatively.
|
||||
|
||||
---
|
||||
|
||||
# 34. MEASURE BEFORE MOVING TO RUST
|
||||
|
||||
Required sequence:
|
||||
|
||||
```text
|
||||
correct implementation
|
||||
↓
|
||||
measurement
|
||||
↓
|
||||
profiling
|
||||
↓
|
||||
identified bottleneck
|
||||
↓
|
||||
Rust implementation
|
||||
↓
|
||||
benchmark
|
||||
↓
|
||||
accept/reject based on evidence
|
||||
```
|
||||
|
||||
Do not introduce Rust based on assumptions about performance.
|
||||
|
||||
---
|
||||
|
||||
# 35. RUST BOUNDARIES MUST BE COARSE-GRAINED
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
Python
|
||||
↓
|
||||
structured input
|
||||
↓
|
||||
Rust engine
|
||||
↓
|
||||
structured result
|
||||
↓
|
||||
Python
|
||||
```
|
||||
|
||||
Avoid excessive Python ↔ Rust calls for individual operations.
|
||||
|
||||
Rust should encapsulate meaningful computational workloads.
|
||||
|
||||
---
|
||||
|
||||
# 36. DO NOT PREMATURELY FREEZE THE CORE IN RUST
|
||||
|
||||
The semantic model should first become correct and well understood.
|
||||
|
||||
Prefer:
|
||||
|
||||
```text
|
||||
semantic model
|
||||
↓
|
||||
correctness
|
||||
↓
|
||||
tests
|
||||
↓
|
||||
architecture stabilization
|
||||
↓
|
||||
profiling
|
||||
↓
|
||||
Rust where justified
|
||||
```
|
||||
|
||||
Do not rewrite the entire Periscope core in Rust simply to establish a Rust architecture.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 21. TESTING IS MANDATORY
|
||||
|
||||
Every meaningful implementation change requires tests.
|
||||
|
||||
At minimum test:
|
||||
|
||||
```text
|
||||
normal case
|
||||
failure case
|
||||
boundary case
|
||||
insufficient evidence
|
||||
```
|
||||
|
||||
For semantic models, also test the boundaries between object types.
|
||||
|
||||
For PCB analysis, explicitly test:
|
||||
|
||||
```text
|
||||
Pad
|
||||
Via
|
||||
Track
|
||||
Zone
|
||||
```
|
||||
|
||||
as distinct semantic entities.
|
||||
|
||||
---
|
||||
|
||||
# 22. TESTS MUST BE STRICT AND ABSOLUTE
|
||||
|
||||
Periscope tests are not advisory.
|
||||
|
||||
A test must define the exact expected behavior.
|
||||
|
||||
Do not use vague assertions such as:
|
||||
|
||||
```text
|
||||
result is reasonable
|
||||
result is not empty
|
||||
analysis completed
|
||||
```
|
||||
|
||||
when an exact result can be established.
|
||||
|
||||
Prefer assertions such as:
|
||||
|
||||
```text
|
||||
expected object type == Via
|
||||
expected pad count == 24
|
||||
expected finding count == 0
|
||||
expected finding code == X
|
||||
expected severity == ERROR
|
||||
expected confidence == HIGH
|
||||
```
|
||||
|
||||
Where deterministic exact values are available, test them exactly.
|
||||
|
||||
Do not weaken tests merely to make an implementation pass.
|
||||
|
||||
Do not modify expected results to accommodate incorrect implementation behavior.
|
||||
|
||||
---
|
||||
|
||||
# 23. REGRESSION TEST FOR EVERY BUG
|
||||
|
||||
Every fixed bug must become a regression test.
|
||||
|
||||
Required sequence:
|
||||
|
||||
```text
|
||||
BUG
|
||||
↓
|
||||
REPRODUCE
|
||||
↓
|
||||
TEST FAILS
|
||||
↓
|
||||
FIX
|
||||
↓
|
||||
TEST PASSES
|
||||
↓
|
||||
FULL REGRESSION
|
||||
```
|
||||
|
||||
A bug fix is not complete until the original failure is permanently represented by a test.
|
||||
|
||||
---
|
||||
|
||||
# 24. TEST THE NEGATIVE CASE
|
||||
|
||||
Do not test only:
|
||||
|
||||
```text
|
||||
valid → pass
|
||||
```
|
||||
|
||||
Also test:
|
||||
|
||||
```text
|
||||
invalid → fail
|
||||
```
|
||||
|
||||
and ensure that genuine errors remain detectable.
|
||||
|
||||
For example, if fixing:
|
||||
|
||||
```text
|
||||
24 datasheet pins
|
||||
24 footprint pads
|
||||
N vias
|
||||
```
|
||||
|
||||
verify that:
|
||||
|
||||
```text
|
||||
24 datasheet pins
|
||||
23 footprint pads
|
||||
```
|
||||
|
||||
still produces a real mismatch.
|
||||
|
||||
The fix must remove false positives without suppressing true positives.
|
||||
|
||||
---
|
||||
|
||||
# 26. NO WEAKENING TESTS TO CLOSE A PHASE
|
||||
|
||||
Do not:
|
||||
|
||||
* delete failing tests;
|
||||
* weaken assertions;
|
||||
* skip tests;
|
||||
* mark tests expected-to-fail;
|
||||
* suppress failures;
|
||||
* change expected values without engineering justification;
|
||||
|
||||
simply to make a macro-phase pass.
|
||||
|
||||
If a test exposes a real implementation problem, fix the implementation.
|
||||
|
||||
If the requirement itself is wrong, change the requirement explicitly and document why.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 37. CORRECTNESS BEFORE PERFORMANCE
|
||||
|
||||
Priority order:
|
||||
|
||||
```text
|
||||
1. Correctness
|
||||
2. Semantic integrity
|
||||
3. Determinism
|
||||
4. Testability
|
||||
5. Readability
|
||||
6. Maintainability
|
||||
7. Performance
|
||||
8. Optimization
|
||||
```
|
||||
|
||||
Performance optimization must not compromise the first six without explicit justification.
|
||||
|
||||
---
|
||||
|
||||
# 38. MINIMAL PUBLIC APIs
|
||||
|
||||
Public APIs must be as small as practical.
|
||||
|
||||
Do not expose internal implementation details unnecessarily.
|
||||
|
||||
Prefer explicit interfaces.
|
||||
|
||||
Avoid API surface growth without a real requirement.
|
||||
|
||||
---
|
||||
|
||||
# 39. NO DEAD CODE
|
||||
|
||||
Remove or explicitly document:
|
||||
|
||||
* obsolete code;
|
||||
* commented-out implementations;
|
||||
* unused imports;
|
||||
* unused abstractions;
|
||||
* obsolete APIs;
|
||||
* temporary workarounds.
|
||||
|
||||
Temporary adapters must be clearly identified and have a removal path.
|
||||
|
||||
---
|
||||
|
||||
# 40. COMMENTS
|
||||
|
||||
Comments should explain:
|
||||
|
||||
```text
|
||||
WHY
|
||||
```
|
||||
|
||||
rather than simply:
|
||||
|
||||
```text
|
||||
WHAT
|
||||
```
|
||||
|
||||
Do not comment obvious code.
|
||||
|
||||
Do document:
|
||||
|
||||
* non-obvious engineering decisions;
|
||||
* semantic constraints;
|
||||
* source-specific behavior;
|
||||
* intentional limitations;
|
||||
* reasons for unusual algorithms;
|
||||
* compatibility constraints.
|
||||
|
||||
---
|
||||
|
||||
# 41. CHANGE MINIMIZATION
|
||||
|
||||
For bug fixes:
|
||||
|
||||
1. reproduce the problem;
|
||||
2. locate root cause;
|
||||
3. change the smallest appropriate architectural layer;
|
||||
4. add regression test;
|
||||
5. run relevant tests;
|
||||
6. run full regression;
|
||||
7. review the resulting design.
|
||||
|
||||
Do not combine unrelated refactoring with a bug fix unless necessary.
|
||||
|
||||
---
|
||||
|
||||
# 42. NO SPECULATIVE ENGINEERING
|
||||
|
||||
Do not invent engineering rules.
|
||||
|
||||
Every rule should be based on one of:
|
||||
|
||||
```text
|
||||
documented requirement
|
||||
datasheet/source evidence
|
||||
standard
|
||||
explicit design intent
|
||||
validated engineering inference
|
||||
```
|
||||
|
||||
If evidence is insufficient:
|
||||
|
||||
```text
|
||||
INSUFFICIENT_EVIDENCE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 43. DESIGN INTENT IS FIRST-CLASS
|
||||
|
||||
Do not assume that every unusual design decision is an error.
|
||||
|
||||
Periscope must be able to represent:
|
||||
|
||||
```text
|
||||
INTENTIONAL_DESIGN
|
||||
```
|
||||
|
||||
when the evidence supports that interpretation.
|
||||
|
||||
The system must distinguish:
|
||||
|
||||
```text
|
||||
actual violation
|
||||
intentional design
|
||||
engineering observation
|
||||
insufficient evidence
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 44. FINDING CATEGORIES
|
||||
|
||||
Findings may include:
|
||||
|
||||
```text
|
||||
ERROR
|
||||
RISK
|
||||
REFERENCE_DEVIATION
|
||||
ENGINEERING_OBSERVATION
|
||||
INTENTIONAL_DESIGN
|
||||
INSUFFICIENT_EVIDENCE
|
||||
```
|
||||
|
||||
Do not collapse all deviations into ERROR.
|
||||
|
||||
Severity and confidence remain independent.
|
||||
|
||||
---
|
||||
|
||||
# 45. REVIEW QUESTIONS BEFORE COMPLETING ANY CHANGE
|
||||
|
||||
Before declaring a change complete, Cursor must verify:
|
||||
|
||||
```text
|
||||
Is the implementation correct?
|
||||
|
||||
Is the root cause fixed?
|
||||
|
||||
Is the semantic model correct?
|
||||
|
||||
Is the data flow explicit?
|
||||
|
||||
Can the code fit in my head?
|
||||
|
||||
Is any abstraction unnecessary?
|
||||
|
||||
Is there hidden state?
|
||||
|
||||
Are units explicit?
|
||||
|
||||
Are engineering constants named?
|
||||
|
||||
Are assumptions distinguished from facts?
|
||||
|
||||
Are tests strict?
|
||||
|
||||
Are negative cases tested?
|
||||
|
||||
Are boundary cases tested?
|
||||
|
||||
Is insufficient evidence tested?
|
||||
|
||||
Could this change create a false positive?
|
||||
|
||||
Could this change suppress a true positive?
|
||||
|
||||
Is the full regression passing?
|
||||
|
||||
Is documentation updated?
|
||||
|
||||
Is the macro-phase actually complete?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 46. FINAL ENGINEERING PRINCIPLE
|
||||
|
||||
Periscope must not become a clever software system.
|
||||
|
||||
It must become a:
|
||||
|
||||
```text
|
||||
CORRECT
|
||||
DETERMINISTIC
|
||||
SEMANTICALLY ACCURATE
|
||||
TESTABLE
|
||||
TRACEABLE
|
||||
READABLE
|
||||
MAINTAINABLE
|
||||
ENGINEERING SYSTEM
|
||||
```
|
||||
|
||||
The preferred implementation is the simplest one that satisfies those properties.
|
||||
|
||||
When two implementations are technically correct, prefer the one with:
|
||||
|
||||
* fewer abstractions;
|
||||
* fewer dependencies;
|
||||
* less hidden state;
|
||||
* clearer data flow;
|
||||
* smaller modules;
|
||||
* smaller APIs;
|
||||
* easier testing;
|
||||
* easier debugging;
|
||||
* clearer semantics.
|
||||
|
||||
The governing principle is:
|
||||
|
||||
> **CODE THAT FITS IN YOUR HEAD.**
|
||||
|
||||
And the governing development discipline is:
|
||||
|
||||
> **NO PHASE IS COMPLETE UNTIL ITS TESTS PROVE IT.**
|
||||
|
||||
And the governing repository discipline is:
|
||||
|
||||
> **COMMIT AND PUSH AT THE PHASE BOUNDARY AFTER TESTS; DEPLOY ONLY AFTER A MACRO-PHASE.**
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 25. PHASE GATES
|
||||
|
||||
Development is divided into macro-phases.
|
||||
|
||||
A macro-phase is not complete until:
|
||||
|
||||
1. implementation is complete;
|
||||
2. all required tests exist;
|
||||
3. all required tests pass;
|
||||
4. full regression passes;
|
||||
5. relevant integration tests pass;
|
||||
6. static/type/lint checks pass where applicable;
|
||||
7. no known blocker remains;
|
||||
8. documentation is updated;
|
||||
9. results have been reviewed;
|
||||
10. the phase acceptance criteria are satisfied.
|
||||
|
||||
Only then is the macro-phase considered complete.
|
||||
|
||||
---
|
||||
|
||||
# 27. PHASE COMMIT AND PUSH POLICY
|
||||
|
||||
Do not commit after every small implementation step merely for convenience.
|
||||
|
||||
During a **phase**:
|
||||
|
||||
```text
|
||||
implement
|
||||
→ test
|
||||
→ fix
|
||||
→ test
|
||||
```
|
||||
|
||||
The repository may contain intermediate working-tree changes.
|
||||
|
||||
At the **end of each phase**, after tests pass:
|
||||
|
||||
```text
|
||||
PHASE TESTS
|
||||
↓
|
||||
COMMIT
|
||||
↓
|
||||
PUSH
|
||||
```
|
||||
|
||||
Commit and push belong to the **phase** boundary, not to every edit.
|
||||
|
||||
Do not push broken or half-completed phase work merely to synchronize the repository.
|
||||
|
||||
---
|
||||
|
||||
# 28. MACRO-PHASE DEPLOY POLICY
|
||||
|
||||
**Deploy** happens only after a **macro-phase**, not after every phase.
|
||||
|
||||
A macro-phase may contain several phases (each already committed and pushed after its tests).
|
||||
|
||||
Never deploy an incomplete macro-phase.
|
||||
|
||||
Required sequence for a macro-phase:
|
||||
|
||||
```text
|
||||
PHASES (each: implement → test → commit → push)
|
||||
↓
|
||||
FULL REGRESSION
|
||||
↓
|
||||
MACRO-PHASE ACCEPTANCE
|
||||
↓
|
||||
DEPLOY
|
||||
↓
|
||||
POST-DEPLOY VERIFICATION
|
||||
```
|
||||
|
||||
If deployment verification fails, stop and investigate.
|
||||
|
||||
---
|
||||
|
||||
# 29. NO DEPLOY AT PHASE BOUNDARY
|
||||
|
||||
Commit + push after phase tests: **yes**.
|
||||
|
||||
Deploy after a phase that is not a completed macro-phase: **no**.
|
||||
|
||||
---
|
||||
|
||||
# 30. NO AUTOMATIC COMMIT/PUSH/DEPLOY DURING DEVELOPMENT
|
||||
|
||||
Cursor must not create commits, push, or deploy merely because a small task has finished.
|
||||
|
||||
Commit and push are **phase-boundary** operations (after tests).
|
||||
|
||||
Deploy is a **macro-phase-boundary** operation.
|
||||
|
||||
If explicitly instructed to deploy before a macro-phase is complete, Cursor must identify the conflict with this policy rather than silently proceeding.
|
||||
|
||||
---
|
||||
|
||||
# 31. GIT HISTORY IS ENGINEERING EVIDENCE
|
||||
|
||||
Do not rewrite Git history casually.
|
||||
|
||||
Do not:
|
||||
|
||||
* force-push;
|
||||
* squash history;
|
||||
* rewrite commits;
|
||||
* detach repository history;
|
||||
|
||||
unless explicitly required by the project phase and explicitly authorized.
|
||||
|
||||
Git history may be relevant to provenance and licensing analysis.
|
||||
|
||||
Preserve it.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,3 +11,10 @@ data/
|
||||
.claude/plans/
|
||||
.claude/memory/
|
||||
simple_project/
|
||||
periscope/dependency/frontend/public/faradworks-logo-white.png
|
||||
periscope/dependency/frontend/public/power-tree.gif
|
||||
periscope/dependency/frontend/public/file.svg
|
||||
periscope/dependency/frontend/public/globe.svg
|
||||
periscope/dependency/frontend/public/next.svg
|
||||
periscope/dependency/frontend/public/vercel.svg
|
||||
periscope/dependency/frontend/public/window.svg
|
||||
|
||||
@@ -14,20 +14,20 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- run: pip install -r backend/requirements.txt pytest pytest-asyncio
|
||||
- run: pip install -r periscope/src/backend/requirements.txt pytest pytest-asyncio
|
||||
- run: pytest tests/ -q
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache-dependency-path: periscope/src/frontend/package-lock.json
|
||||
- run: chmod +x scripts/materialize-frontend.sh && scripts/materialize-frontend.sh
|
||||
- run: npm ci
|
||||
working-directory: .merge/frontend
|
||||
- run: npm run build
|
||||
working-directory: .merge/frontend
|
||||
|
||||
@@ -49,10 +49,10 @@ skills-lock.json
|
||||
data/
|
||||
backend/data/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
.merge/
|
||||
periscope/dependency/frontend/node_modules/
|
||||
periscope/dependency/frontend/.next/
|
||||
periscope/src/frontend/.next/
|
||||
.next/
|
||||
|
||||
# retrospective
|
||||
|
||||
@@ -1,149 +1,15 @@
|
||||
# Periscope — Agentic Schematic Validation
|
||||
|
||||
Periscope validates hardware schematics against component datasheets. It extracts constraints from PDFs, parses netlists and BOMs into a queryable graph, and runs an agentic validation loop to flag design violations.
|
||||
|
||||
> **Open-core note.** This is the open-source core. A small set of files are
|
||||
> "gateway-owned seams" — pass-through stubs here (`frontend/src/proxy.ts`,
|
||||
> `use-optional-auth.ts`, `clerk-theme-provider.tsx`,
|
||||
> `components/billing/*`, `sidebar-auth.tsx`, `pricing-section.tsx`,
|
||||
> `analytics/*`, `lib/csp-hosts.ts`) that the hosted-cloud repo replaces
|
||||
> with auth/billing implementations. Keep their export signatures stable,
|
||||
> and never import auth/billing SDKs anywhere else in the frontend. On the
|
||||
> backend, everything reaches billing only through
|
||||
> `backend/services/billing_hook.py:get_billing()` (a no-op here).
|
||||
|
||||
## System Overview
|
||||
|
||||
Three layers:
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
|-------|----------|---------|
|
||||
| **Core library** | `backend/periscopex/` | Models, parsers, graph builder, agentic validator, passive resolver, taxonomy, BOM summary, derating |
|
||||
| **Backend** | `backend/` | FastAPI app — async pipeline orchestration, SSE progress, project/file storage |
|
||||
| **Frontend** | `frontend/` | Next.js 16 app — project dashboard, pipeline progress, report viewer, derating, admin dashboard |
|
||||
|
||||
Plus `skills/` — extraction prompts (pintable, patterns, specs) inlined locally for DeepSeek. Do not upload to Anthropic Console.
|
||||
|
||||
The pipeline stages: Parse BOM → Extract IC Pintables → Extract Simple Components → Extract Passives → DigiKey Auto-Resolve + Value Fallback → Build Graph → Direct Datasheet Review. Pipeline runs can be cancelled mid-execution via `POST /api/pipeline/{id}/cancel`.
|
||||
|
||||
## Example Project
|
||||
|
||||
`simple_project/` is the reference design for development and testing:
|
||||
|
||||
- **MCU**: TI MSPM0G3507SPTR (U3) — 48-pin LQFP
|
||||
- **USB-UART Bridge**: CH340E (U2)
|
||||
- **LDO Regulator**: SPX3819M5-L-3-3 (U1) — 5V to 3.3V
|
||||
- **ESD Protection**: USBLC6-2SC6 (D1)
|
||||
- **Crystal**: 8 MHz (X1) with 18pF load caps (C9, C10)
|
||||
|
||||
Files: `.asc` (PADS-PCB netlist; `.edn` EDIF 2.0.0 also accepted), `.csv`/`.xlsx` (BOM), `design_graph.json` (committed reference fixture used by tests).
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
- **Modular extractors** — Domain-specific extraction per component type, unified constraint schema
|
||||
- **Netlist as graph** — Queryable bipartite graph (components + nets) with traversal helpers
|
||||
- **LLM API for PDF extraction** — Forced tool calls for structured output (pintable, passive patterns, specs). Default provider is DeepSeek.
|
||||
- **Prompt caching** — Anthropic stamps `cache_control`; Gemini uses CachedContent; DeepSeek uses automatic prefix cache (cache-hit tokens in usage).
|
||||
- **Local extraction skills** — `skills/*/SKILL.md` is inlined and `validate.py` runs in-process. Never call `scripts/upload_skills.py` (Anthropic Console).
|
||||
- **Direct datasheet review** — The model reads the IC datasheet plus circuit neighborhood, compares to the reference application circuit, and flags issues via graph query tools (`find_connected_components`, `get_net_for_pin`, `get_pintable`). DeepSeek converts PDFs to text (and page images on the vision model).
|
||||
- **Datasheet page trimming** — Large PDFs are keyword-trimmed to relevant pages before sending to Claude, reducing token cost (`pypdf`)
|
||||
- **DigiKey fallback (exact MPN only)** — When pattern-based and direct extraction fail, DigiKey API fetches product parameters for auto-resolve. DigiKey matches only on exact MPN; fuzzy hits are rejected to avoid polluting the shared library with wrong-dielectric / wrong-voltage parts.
|
||||
- **Value-string fallback** — When DigiKey misses an R/C/L/FB passive, a value-string resolver maps the BOM `Value` string to typed passive specs. Value-derived specs are persisted per-project only — never to the shared library.
|
||||
- **Per-IC review error isolation** — Direct datasheet review runs each IC independently; one malformed payload or bad response cannot kill the whole run. Failed ICs surface as skipped components with the error.
|
||||
- **Cross-IC excerpt budget (per-neighbor)** — To verify an interface finding the reviewer can pull a *connected* IC's datasheet pages (`get_datasheet_excerpt`). The budget is a global per-review page ceiling **plus a per-neighbor sub-budget**, so verifying one interface is never starved by pages already spent on other neighbors.
|
||||
- **Finding normalization is downgrade-only** — A post-review per-IC normalize pass (`services/normalize_findings.py`) drops self-cancelling findings, merges same-root-cause findings, and re-grades severity — but only ever *downward*. A deterministic clamp caps each finding at the reviewer's calibrated severity (and any `Unverified:` finding at WARNING, preserving the prefix).
|
||||
- **Cross-IC finding dedup** — After all per-IC reviews complete, a single pass (`services/dedupe_findings.py`) collapses one physical interface defect reported from both endpoints into a single finding. Gated by `cross_ic_dedup_enabled`; fail-soft.
|
||||
- **Capacitor voltage derating** — Deterministic derating table computed from graph (ceramic/tantalum/electrolytic percentages, pass/fail per capacitor)
|
||||
- **Deterministic checks over heuristics** — Exact checks where possible
|
||||
- **Zero coupling between layers** — Backend calls periscopex functions with paths; frontend talks to backend via REST + SSE
|
||||
- **Library deduplication** — Shared library (`library/extracted/`, `library/patterns/`, `library/models/`, `library/passives/`, `library/datasheets/`) caches extractions across projects
|
||||
- **Content-addressed datasheets** — `library/datasheets/blobs/{md5}.pdf` stores unique PDFs once; `library/datasheets/refs/{safe_mpn}.json` maps MPNs to blobs (dedupe + multi-MPN sharing)
|
||||
- **Taxonomy-driven extraction** — Living component taxonomy (`taxonomy/`) with per-subtype classification and specs schemas
|
||||
- **Per-stage model config** — Each pipeline stage can use a different Claude model (e.g., Sonnet for review, Haiku for auto-resolve)
|
||||
- **API call logging** — Every Claude API call is logged with token counts, cost, and timing per pipeline run
|
||||
- **Report versioning** — Each project run is stamped with the current app version on the first `/start` transition (`ProjectMeta.periscope_version`). The version comes from `frontend/content/changelog.md`'s latest `##` heading — single source of truth — read at backend startup via `backend/_version.py`.
|
||||
|
||||
## Datasheet Extraction
|
||||
|
||||
Extracted data lives in `library/extracted/` (shared) or per-project under the storage backend. One JSON per MPN, schema in `backend/periscopex/models.py`.
|
||||
|
||||
Per-MPN IC extraction captures:
|
||||
1. **Pintable** — Pin number + name (required), description + alt functions (optional)
|
||||
2. **Package info** — Base family, package, pin count, description
|
||||
3. **Component subtype** — Dotted taxonomy path (e.g., `ic.mcu`, `ic.power.ldo`)
|
||||
|
||||
For discrete/simple components:
|
||||
4. **Specs** — Component specs (value, tolerance, package, voltage rating, etc.); parameters are filtered against taxonomy specs schemas
|
||||
|
||||
Extraction inlines **local skills** (`skills/*/SKILL.md` + `validate.py`) against DeepSeek. Do not use Anthropic Console Skills.
|
||||
|
||||
## Claude Console Skills
|
||||
|
||||
```
|
||||
skills/
|
||||
├── extract-pintable/ # Pin table + package info + taxonomy
|
||||
│ ├── SKILL.md # System prompt (YAML frontmatter + markdown)
|
||||
│ ├── schema.json # Tool output schema
|
||||
│ └── validate.py # Validation script
|
||||
├── extract-pattern/ # Passive MPN pattern
|
||||
└── extract-specs/ # Component specs (discrete, connectors, crystals, etc.)
|
||||
```
|
||||
|
||||
## Taxonomy
|
||||
|
||||
Living component taxonomy in `taxonomy/` — one JSON file per top-level type (ic, passive, connector, crystal, discrete, fuse, switch, test_point, transformer). Each subtype entry includes `description` and `example_mpn`.
|
||||
|
||||
Key taxonomy features:
|
||||
- **Ref prefix mapping** — `U→ic`, `R/C/L→passive`, `D/Q→discrete`, `X→crystal`, etc.
|
||||
- **Dotted subtype paths** — e.g., `ic.mcu`, `passive.capacitor.ceramic`, `ic.protection.esd`
|
||||
- **Dynamic growth** — `add_subtype()` adds new entries; concurrent-safe JSON writes
|
||||
- **Specs schema auto-generation** — Type-level and subtype-level parameter specs schemas are auto-generated via Claude when a taxonomy entry has none; extraction discards parameters not in the schema (`extra_specs` field)
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/upload_skills.py` — leftover Claude Console uploader. **Do not run.** Skills are local + DeepSeek only.
|
||||
- `scripts/migrate_datasheets_to_library.py` — One-time migration: copy per-project datasheets to `library/datasheets/` (dry-run by default, `--apply` to execute)
|
||||
- `scripts/migrate_datasheets_to_blobs.py` — Migrate named-PDF datasheets into the content-addressed blobs/refs layout (dry-run by default, `--apply` to execute)
|
||||
- `scripts/dedup_library_datasheets.py` — Remove redundant per-MPN datasheet PDFs when a passive pattern already has a `datasheet_key` (dry-run by default, `--apply` to execute)
|
||||
- `scripts/gc_orphan_blobs.py` — Garbage-collect `library/datasheets/blobs/*.pdf` not referenced by any ref file
|
||||
- `scripts/clear_rules_from_extractions.py` — Strip deprecated `rules`/`absolute_maximum_ratings` from existing library extractions
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Core**: Python 3.12+, Pydantic 2.x, OpenAI SDK (DeepSeek), Anthropic SDK (optional), google-genai (optional), openpyxl, pypdf, PyMuPDF
|
||||
- **Backend**: FastAPI, uvicorn, sse-starlette, pydantic-settings
|
||||
- **Frontend**: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS v4, shadcn/ui (Base UI), react-pdf
|
||||
- **AI**: DeepSeek Chat Completions (OpenAI-compatible) with forced tool calls for extraction and agentic review. Do not route stages to Anthropic.
|
||||
- **Model**: `deepseek-flash` for extraction, review, auto-resolve, and normalize (per-stage overrides via `.env`)
|
||||
- **Skills**: Local SKILL.md + validate.py on DeepSeek
|
||||
- **External APIs**: DigiKey API v4 (OAuth2) — optional datasheet auto-fetch and parameter-based auto-resolve (`DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`)
|
||||
|
||||
## Extracted Model Versioning
|
||||
|
||||
All `ComponentConstraints` extracted JSON files carry a `model_version` semver field:
|
||||
|
||||
- **Initial value** — set from `default_model_version` in `backend/skills_manifest.json` (starts at `1.0.0`)
|
||||
- **Minor bump** — increment `default_model_version` in `skills_manifest.json` when extraction prompts change (do **not** run `upload_skills.py`).
|
||||
|
||||
**Rule**: When committing changes under `skills/`, bump `default_model_version` locally. Never call Anthropic.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- Write tests against `simple_project/` — it's the ground truth
|
||||
- Netlist parser and BOM parser are pure functions with no side effects
|
||||
- All data structures use Pydantic models in `backend/periscopex/models.py`
|
||||
- Frontend types in `frontend/src/lib/types.ts` must stay in sync with `backend/periscopex/models.py`
|
||||
- Extraction prompts live in `skills/` (SKILL.md + schema.json + validate.py) and run locally against DeepSeek
|
||||
- **Never swallow exceptions silently** — prefer logging or re-raising over bare `except: continue`. Silent failures hide real bugs.
|
||||
Open-core checkout. **Native code** lives in `periscope/src`. **Inherited PinScope** lives in `periscope/dependency` (in-tree AGPL dependency — do not delete). Root `LICENSE` is AGPL-3.0.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Backend (copy backend/.env.example to .env at repo root first)
|
||||
python3 -m uvicorn backend.main:app --reload # localhost:8000
|
||||
# Backend (copy periscope/dependency/backend/.env.example to .env at repo root first)
|
||||
python3 -m uvicorn backend.main:app --reload # localhost:8000
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev # localhost:3000
|
||||
cd periscope/dependency/frontend && npm run dev # localhost:3000
|
||||
```
|
||||
|
||||
Local mode needs no cloud services and no auth — projects are stored in `data/` and you are `user_id="local"` with admin access.
|
||||
See `periscope/README.md` and root `README.md`.
|
||||
|
||||
@@ -23,12 +23,21 @@ Default routing:
|
||||
| Per-IC datasheet review | `deepseek-flash` |
|
||||
| Auto-resolve / normalize | `deepseek-flash` |
|
||||
|
||||
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `backend/.env`. See `backend/.env.example`.
|
||||
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `.env`. See `periscope/src/backend/.env.example`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `periscope/src/` — native Periscope
|
||||
- `periscope/dependency/` — inherited PinScope (AGPL in-tree dependency; do not delete)
|
||||
- `LICENSE` — GNU AGPL v3 (visible at repo root)
|
||||
- `vendor/impedancefinder/` — third party (license UNKNOWN)
|
||||
|
||||
See `periscope/README.md` and `periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md`.
|
||||
|
||||
## How it works
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/how-it-works.svg" width="920" alt="Pipeline: the netlist and BOM are parsed into a design graph; datasheet PDFs are extracted into pin tables and specs; a per-IC review reads both and files findings cited to datasheet pages; the derating table and BOM roll-up are computed straight from the graph, no model involved.">
|
||||
<img src="periscope/dependency/docs/how-it-works.svg" width="920" alt="Pipeline: the netlist and BOM are parsed into a design graph; datasheet PDFs are extracted into pin tables and specs; a per-IC review reads both and files findings cited to datasheet pages; the derating table and BOM roll-up are computed straight from the graph, no model involved.">
|
||||
</p>
|
||||
|
||||
1. **Parse** the BOM (CSV/XLSX) and netlist (PADS-PCB `.asc` or EDIF 2.0.0 `.edn`) into a queryable bipartite graph of components and nets.
|
||||
@@ -38,29 +47,29 @@ Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `backend/.env`. See `backen
|
||||
|
||||
## Try it on the bundled design
|
||||
|
||||
`simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO.
|
||||
`periscope/dependency/simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO.
|
||||
|
||||
You need Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/):
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r backend/requirements.txt
|
||||
cp backend/.env.example backend/.env # set DEEPSEEK_API_KEY
|
||||
pip install -r periscope/src/backend/requirements.txt
|
||||
cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
|
||||
python3 -m uvicorn backend.main:app --reload --host 127.0.0.1 --port 18741
|
||||
|
||||
# in another terminal
|
||||
cd frontend && npm install
|
||||
cd periscope/src/frontend && npm install
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostname 127.0.0.1
|
||||
```
|
||||
|
||||
Open the frontend URL, create a project, and feed it the netlist and BOM from `simple_project/`. Datasheets are fetched automatically (LCSC, TI, optional DigiKey); you can still drop in PDFs by hand. Fetched PDFs and extracted pin tables land in the **Library** (sidebar) and are reused on later projects. Everything runs locally against your own DeepSeek key; projects and the extraction library live in `data/`. Skills are local `skills/*/SKILL.md` — do not run `scripts/upload_skills.py`.
|
||||
Open the frontend URL, create a project, and feed it the netlist and BOM from `periscope/dependency/simple_project/`. Datasheets are fetched automatically (LCSC, TI, optional DigiKey); you can still drop in PDFs by hand. Fetched PDFs and extracted pin tables land in the **Library** (sidebar) and are reused on later projects. Everything runs locally against your own DeepSeek key; projects and the extraction library live in `data/`. Skills are local `periscope/src/skills/*/SKILL.md` — do not run `scripts/upload_skills.py`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
cp backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
@@ -68,16 +77,23 @@ Backend on port 8080, frontend on port 3000.
|
||||
|
||||
### Update a live instance (e.g. periscope.michelebigi.it)
|
||||
|
||||
On the server, from the Periscope checkout:
|
||||
One checkout on the VPS: **`/root/periscope`**. The GitHub clone URL may still be `manvalan/pinscope`; clone into that path so the folder is not `pinscope`:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:manvalan/pinscope.git /root/periscope
|
||||
```
|
||||
|
||||
Do not keep a second live tree under `/root/pinscope` (or `/opt/pinscope`). Stop compose there, then deploy only from the canonical root:
|
||||
|
||||
```bash
|
||||
cd /root/periscope
|
||||
./scripts/update-periscope.sh
|
||||
```
|
||||
|
||||
The script pulls the current branch, writes `NEXT_PUBLIC_API_URL` / `CORS_ORIGINS` for `https://periscope.michelebigi.it`, rebuilds both Docker images, and leaves `data/` alone. First run: put `DEEPSEEK_API_KEY` in `.env` at the repo root (compose reads that file). `--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL.
|
||||
The script cds via `dirname "$0"/..` (no `find`). It refuses to run if the resolved root is not `/root/periscope`. Compose project name is `periscope`. After `up`, the script connects `periscope-frontend` / `periscope-backend` to Docker network **`pinscope_pinscope`** (where `railway-caddy` reverse_proxies `periscope-frontend:3000`). It pulls the current branch, writes `NEXT_PUBLIC_API_URL` / `CORS_ORIGINS` for `https://periscope.michelebigi.it`, rebuilds both Docker images, and leaves `data/` alone. First run: put `DEEPSEEK_API_KEY` in `.env` at the repo root (compose reads that file). `--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL.
|
||||
|
||||
Do not set `ENVIRONMENT=production` unless Clerk auth is configured — that flag refuses to boot with auth disabled.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0, same as upstream Pinscope (Faradworks). For commercial licensing of the original, write to dev@faradworks.com.
|
||||
AGPL-3.0 (see `LICENSE`). This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). Faradworks does not operate this instance. For a commercial license of **upstream Pinscope**, write to Faradworks as they publish it (`dev@faradworks.com` historically). For this Periscope instance, contact the operator (Michele Bigi, mikbigi@gmail.com).
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer caching).
|
||||
# Open-core: the private gateway repo adds backend/requirements-gateway.txt
|
||||
# (Stripe, etc.); a plain core checkout has no such file and skips that step.
|
||||
COPY backend/requirements*.txt /app/backend/
|
||||
RUN pip install --no-cache-dir -r /app/backend/requirements.txt \
|
||||
&& if [ -f /app/backend/requirements-gateway.txt ]; then \
|
||||
pip install --no-cache-dir -r /app/backend/requirements-gateway.txt; \
|
||||
fi
|
||||
|
||||
# Copy application code
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
# Copy runtime assets needed by the backend
|
||||
# Taxonomy: fallback for local mode; GCS mode downloads from bucket
|
||||
COPY taxonomy/ /app/taxonomy/
|
||||
|
||||
# Extraction skills (SKILL.md + validate.py) — required for DeepSeek/Gemini
|
||||
COPY skills/ /app/skills/
|
||||
|
||||
# Changelog: single source of truth for the user-facing Periscope version.
|
||||
COPY frontend/content/changelog.md /app/changelog.md
|
||||
|
||||
# ImpedenceFinder closed-form engine (no OpenEMS / pcbnew).
|
||||
COPY vendor/ /app/vendor/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Backend package facade: native Periscope + inherited PinScope.
|
||||
|
||||
``periscope/src/backend`` and ``periscope/dependency/backend`` are merged via
|
||||
pkgutil path extension. Do not put application modules in this directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pkgutil import extend_path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
for _p in (_REPO / "periscope" / "src", _REPO / "periscope" / "dependency"):
|
||||
_s = str(_p)
|
||||
if _p.is_dir() and _s not in sys.path:
|
||||
sys.path.insert(0, _s)
|
||||
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Validate datasheet layout_rules. Distances stay null unless numeric."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"})
|
||||
|
||||
|
||||
def _num(v: Any) -> float | None:
|
||||
if v is None or v is False:
|
||||
return None
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def has_any_layout_rule(raw: object) -> bool:
|
||||
"""True when extraction already produced at least one structured rule."""
|
||||
if not isinstance(raw, list):
|
||||
return False
|
||||
for row in raw:
|
||||
if isinstance(row, dict) and str(row.get("kind") or "").strip() in KNOWN_KINDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def needs_layout_rules_refresh(
|
||||
data: dict,
|
||||
*,
|
||||
min_scan_version: str,
|
||||
) -> bool:
|
||||
"""True when layout_rules are empty and the extract predates the scan version.
|
||||
|
||||
After a successful extract at ``min_scan_version`` or newer, an empty
|
||||
``layout_rules`` list means the datasheet had no guidance — do not loop.
|
||||
"""
|
||||
if has_any_layout_rule(data.get("layout_rules")):
|
||||
return False
|
||||
ver = str(data.get("model_version") or "0.0.0")
|
||||
if not min_scan_version or min_scan_version == "0.0.0":
|
||||
return False
|
||||
try:
|
||||
return Version(ver) < Version(min_scan_version)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
|
||||
"""Return (normalized rows, errors). Empty list is a valid skip."""
|
||||
if not raw:
|
||||
return [], []
|
||||
if not isinstance(raw, list):
|
||||
return [], ["layout_rules must be an array"]
|
||||
ok: list[dict] = []
|
||||
errors: list[str] = []
|
||||
for i, row in enumerate(raw):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"layout_rules[{i}] must be an object")
|
||||
continue
|
||||
kind = str(row.get("kind") or "").strip()
|
||||
if kind not in KNOWN_KINDS:
|
||||
errors.append(f"layout_rules[{i}] unknown kind {kind!r}")
|
||||
continue
|
||||
dist = _num(row.get("max_distance_mm"))
|
||||
via = row.get("min_via_count")
|
||||
via_i = None
|
||||
if isinstance(via, int) and not isinstance(via, bool):
|
||||
via_i = via
|
||||
elif via is not None:
|
||||
n = _num(via)
|
||||
via_i = int(n) if n is not None else None
|
||||
page = row.get("source_page")
|
||||
page_i = int(page) if isinstance(page, int) else None
|
||||
ok.append({
|
||||
"kind": kind,
|
||||
"pin": row.get("pin"),
|
||||
"cap_value_hint": row.get("cap_value_hint"),
|
||||
"max_distance_mm": dist,
|
||||
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
|
||||
"min_via_count": via_i,
|
||||
"net_class": row.get("net_class"),
|
||||
"note": row.get("note"),
|
||||
"source_page": page_i,
|
||||
})
|
||||
return ok, errors
|
||||
@@ -1,91 +0,0 @@
|
||||
"""G1 SI: intra-pair skew only when the datasheet gives millimetres.
|
||||
|
||||
Pair names (_DP/_DM, _P/_N) only identify which nets to compare. The
|
||||
limit is never 3W, USB spec folklore, or a default millimetre.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutSegment
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_PAIR_SUFFIXES = (("_DP", "_DM"), ("_P", "_N"), ("+", "-"))
|
||||
|
||||
|
||||
def _seg_len(seg: LayoutSegment) -> float:
|
||||
return math.hypot(seg.end[0] - seg.start[0], seg.end[1] - seg.start[1])
|
||||
|
||||
|
||||
def net_length_mm(layout: LayoutGraph, net: str) -> float:
|
||||
return sum(_seg_len(s) for s in layout.segments if s.net == net)
|
||||
|
||||
|
||||
def partner_net(name: str) -> str | None:
|
||||
for a, b in _PAIR_SUFFIXES:
|
||||
if name.endswith(a):
|
||||
return name[: -len(a)] + b
|
||||
if name.endswith(b):
|
||||
return name[: -len(b)] + a
|
||||
return None
|
||||
|
||||
|
||||
def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None:
|
||||
for comp in graph.components.values():
|
||||
cons = _match_constraints(comp.mpn, constraints_map)
|
||||
if not cons:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
if rule.get("kind") != "length_match":
|
||||
continue
|
||||
mm = rule.get("max_distance_mm")
|
||||
if mm is None:
|
||||
continue
|
||||
return float(mm), rule.get("source_page")
|
||||
return None
|
||||
|
||||
|
||||
def check_si(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or not layout.segments:
|
||||
return []
|
||||
limit = _length_match_limit_mm(constraints_map, graph)
|
||||
if limit is None:
|
||||
return []
|
||||
max_mm, page = limit
|
||||
seen: set[tuple[str, str]] = set()
|
||||
findings: list[Finding] = []
|
||||
names = {s.net for s in layout.segments if s.net}
|
||||
for net in names:
|
||||
partner = partner_net(net)
|
||||
if not partner or partner not in names:
|
||||
continue
|
||||
key = tuple(sorted((net, partner)))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner))
|
||||
if skew <= max_mm:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
aspect="si",
|
||||
finding=(
|
||||
f"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} "
|
||||
f"(datasheet max {max_mm:g} mm)."
|
||||
),
|
||||
why=f"length_match max_distance_mm={max_mm:g}.",
|
||||
status="ERROR",
|
||||
recommendation="Length-match the differential pair.",
|
||||
source="si_check",
|
||||
rule_id="PE-SI-001",
|
||||
net=net,
|
||||
pins=[],
|
||||
source_page=page,
|
||||
))
|
||||
return findings
|
||||
@@ -1,9 +1,16 @@
|
||||
# Canonical compose project on the VPS is "periscope" (not the GitHub
|
||||
# clone folder name "pinscope"). Bind mounts stay relative to the checkout.
|
||||
# Host Caddy (railway-caddy) lives on Docker network pinscope_pinscope and
|
||||
# reverse_proxies periscope-frontend:3000 / periscope-backend:8080. The
|
||||
# update script connects these containers to that network after up.
|
||||
name: periscope
|
||||
|
||||
services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
dockerfile: periscope/src/backend/Dockerfile
|
||||
|
||||
container_name: periscope-backend
|
||||
restart: unless-stopped
|
||||
@@ -18,18 +25,21 @@ services:
|
||||
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./taxonomy:/app/taxonomy
|
||||
- ./periscope/src/taxonomy:/app/taxonomy
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
networks:
|
||||
- periscope
|
||||
periscope:
|
||||
aliases:
|
||||
- periscope-backend
|
||||
- pinscope-backend
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: dockerfile
|
||||
context: .
|
||||
dockerfile: periscope/src/frontend/dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-}
|
||||
@@ -44,8 +54,11 @@ services:
|
||||
- "3000:3000"
|
||||
|
||||
networks:
|
||||
- periscope
|
||||
|
||||
periscope:
|
||||
aliases:
|
||||
- periscope-frontend
|
||||
- pinscope
|
||||
- pinscope-frontend
|
||||
|
||||
networks:
|
||||
periscope:
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# Periscope Privacy Policy
|
||||
|
||||
**Last updated: April 5, 2026**
|
||||
|
||||
This Privacy Policy explains how Faradworks, Inc. ("Faradworks," "we," "us," and "our") collects, uses, and discloses information in connection with the Periscope website (periscope.michelebigi.it), platform, and related services (the "Service").
|
||||
|
||||
This Privacy Policy is intended for free users and self-serve paid users. Enterprise customers typically use the Service under a separate agreement and (if applicable) a data processing agreement ("DPA"), which may include additional privacy and security terms.
|
||||
|
||||
## 1. Key definitions
|
||||
|
||||
**"Customer Content"** means any files, data, text, images, netlists, schematics, datasheets, design files, chat inputs, or other materials uploaded or submitted by you.
|
||||
|
||||
**"Outputs"** means analyses, checks, summaries, recommendations, or responses generated by the Service based on Customer Content.
|
||||
|
||||
**"Derived Data"** means technical artifacts generated solely to operate the Service, such as parsed text, indexes, embeddings, summaries, or extracted metadata.
|
||||
|
||||
**"Content"** refers collectively to Customer Content, Outputs, and Derived Data.
|
||||
|
||||
**"Personal Data"** (or "personal information") means information that identifies, relates to, describes, is reasonably capable of being associated with, or could reasonably be linked, directly or indirectly, with an individual, as defined under applicable law.
|
||||
|
||||
## 2. Roles: controller vs. processor (business customers)
|
||||
|
||||
Faradworks is the controller for Personal Data associated with operating the Service for users and prospective customers (for example account data, billing metadata, and website analytics).
|
||||
|
||||
If you upload Personal Data within Customer Content on behalf of a business, you may be the controller of that Personal Data and Faradworks may process it as a service provider/processor. Where required, we will make a DPA available upon request.
|
||||
|
||||
## 3. Information we collect
|
||||
|
||||
We collect only the information reasonably necessary to operate the Service.
|
||||
|
||||
### 3.1 Account information
|
||||
|
||||
- Name, email address, and organization (optional)
|
||||
- Authentication identifiers (for example password hashes, session tokens, and account IDs)
|
||||
- Subscription and plan metadata
|
||||
|
||||
### 3.2 Billing information
|
||||
|
||||
- Payments are processed by Stripe, a PCI-compliant third-party processor.
|
||||
- Faradworks receives billing status and subscription metadata (for example plan name, renewal date, and payment status).
|
||||
- We do not store payment card details.
|
||||
|
||||
### 3.3 Customer Content
|
||||
|
||||
- Engineering files (for example netlists, schematics, datasheets, and images)
|
||||
- Chat inputs and related context
|
||||
- Design rules and configuration preferences
|
||||
|
||||
### 3.4 Usage and operational data
|
||||
|
||||
Usage and performance telemetry (for example request timestamps, feature usage, response times, error rates, and billing/usage measurements). This data is generally operational in nature and is designed not to include the substance of your Customer Content, except as described in Section 6.3 (Support diagnostics).
|
||||
|
||||
### 3.5 Cookies and analytics preferences
|
||||
|
||||
- We use cookies and similar technologies needed for core site operations and security.
|
||||
- We store an analytics preference cookie that records whether analytics tracking is granted or denied.
|
||||
- When analytics is granted, we may collect site and product usage and performance telemetry through providers such as Vercel Analytics and Speed Insights.
|
||||
|
||||
### 3.6 Contact and enterprise inquiry data
|
||||
|
||||
- Contact and profile details submitted through contact or enterprise inquiry forms (for example name, email, business type, and capacity needs)
|
||||
- Interest signals (for example request for a demo, free review evaluation, or higher-limit self-serve access)
|
||||
- Inquiry notes and follow-up communications related to your request
|
||||
|
||||
## 4. How we use information
|
||||
|
||||
We use Personal Data and Content to:
|
||||
|
||||
- Provide, operate, and maintain the Service
|
||||
- Perform AI-assisted engineering analysis
|
||||
- Enforce usage limits and prevent abuse
|
||||
- Diagnose errors and improve reliability
|
||||
- Respond to support requests
|
||||
- Investigate support requests and Issue Reports and, where you explicitly opt in, use those submissions and the related context to evaluate, test, debug, monitor, develop, and improve the Service
|
||||
- Send service-related communications, including onboarding, feedback requests, product updates, and security notices
|
||||
- Review and respond to contact or enterprise inquiries, including demo requests and plan-fit conversations
|
||||
- Comply with legal obligations and enforce our Terms
|
||||
|
||||
## 5. AI model training and service improvement
|
||||
|
||||
### 5.1 No training on Customer Content for foundation models
|
||||
|
||||
Faradworks treats all Customer Content as confidential and does not use it to train, fine-tune, or improve any general-purpose or foundation AI models. Your content remains isolated to your account and is not shared across customers or included in public datasets.
|
||||
|
||||
### 5.2 Aggregated and de-identified service improvement
|
||||
|
||||
To improve and operate the Service, we rely primarily on aggregated or otherwise de-identified usage metrics (for example error rates and feature usage) that are not reasonably capable of being traced back to you.
|
||||
|
||||
## 6. How we share information
|
||||
|
||||
We do not sell Personal Data.
|
||||
|
||||
We may disclose information in the following circumstances:
|
||||
|
||||
### 6.1 Subprocessors and service providers
|
||||
|
||||
We use subprocessors and service providers to host and operate the Service (for example hosting, storage, authentication, billing, observability, and database providers). We may share Personal Data and Content with these providers only as necessary to provide the Service.
|
||||
|
||||
### 6.2 AI model providers
|
||||
|
||||
To generate Outputs, relevant portions of Customer Content are transmitted to AI model providers acting as subprocessors. Providers may include services such as DeepSeek, Anthropic, Google, or similar AI platforms.
|
||||
|
||||
These providers process Content to generate responses. Their data handling, retention, and caching practices are governed by their respective terms and our configuration.
|
||||
|
||||
### 6.3 Support and troubleshooting diagnostics
|
||||
|
||||
When needed for reliability, security, abuse prevention, or support troubleshooting, we may process account-linked technical diagnostics (for example request IDs, timestamps, stack traces, provider error payloads, and limited excerpts of inputs or outputs associated with a failing request).
|
||||
|
||||
If you submit a support request, submit an Issue Report, or otherwise ask us to investigate an issue with the Service, we may access and review the Customer Content, Outputs, and related Derived Data reasonably necessary to investigate, reproduce, resolve, remediate, and prevent recurrence of that issue. This may include the affected project, review results, uploaded files or excerpts, citations, configuration context, and associated diagnostics.
|
||||
|
||||
If, through the applicable in-product support or feedback flow, you explicitly opt in to broader improvement use, we may also use the submitted materials and the reasonably necessary related project/review context to evaluate, test, debug, monitor, support, secure, operate, develop, and improve the Service and related features, systems, and workflows, including for the benefit of other users. This may include quality evaluation, failure analysis, prompt and retrieval improvements, ranking or classification improvements, validation logic, reliability engineering, abuse prevention, and other product-quality, safety, and operational improvements.
|
||||
|
||||
We limit this access and use to authorized personnel and permitted service-related purposes. We do not use this content to train, fine-tune, or improve any general-purpose or foundation AI model unless you separately and expressly opt in to that use.
|
||||
|
||||
Outside of those circumstances, we do not access or review the substance of your Customer Content except: (a) at your request, (b) to investigate security issues or incidents, (c) if legally compelled, or (d) with your explicit consent.
|
||||
|
||||
### 6.4 Legal and safety
|
||||
|
||||
We may disclose information to comply with applicable law, lawful requests, and legal process; to protect the rights, property, and safety of Faradworks, our users, and others; and to enforce our agreements and policies.
|
||||
|
||||
### 6.5 Business transfers
|
||||
|
||||
If Faradworks is involved in a merger, acquisition, financing, reorganization, bankruptcy, or sale of assets, information may be transferred as part of that transaction, subject to standard confidentiality protections.
|
||||
|
||||
## 7. Cookies and analytics controls
|
||||
|
||||
You can control analytics cookies through the in-product or site controls (where available) and through your browser settings. If you deny analytics cookies, we will not run optional analytics tracking, but essential cookies may still be required for core functionality and security.
|
||||
|
||||
## 8. Data retention and deletion
|
||||
|
||||
### 8.1 User-controlled deletion
|
||||
|
||||
You may delete uploaded files, chats, and review history from within the Service (where available) and you may request closure of your account.
|
||||
|
||||
### 8.2 Retention periods
|
||||
|
||||
**Active accounts.** We retain Customer Content and Outputs for as long as your account remains active, unless you delete them earlier.
|
||||
|
||||
**Account closure.** When you close your account (or we close it at your request), we will delete Customer Content and Outputs associated with your account within 30 days, except where retention is required by law or for legitimate business purposes described below.
|
||||
|
||||
**Backups.** Residual metadata or encrypted backups may persist for a limited period (generally up to 90 days) for security, integrity, and disaster-recovery purposes.
|
||||
|
||||
**Operational artifacts.** We may retain limited technical artifacts (for example hashed identifiers, aggregated statistics, and anonymized error metadata) for security, abuse prevention, system integrity, and service improvement. These artifacts are not intended to and are not reasonably capable of reconstructing your original designs.
|
||||
|
||||
**Support and improvement records.** If you submit a support request or an Issue Report, we may retain the support record, related investigation notes, and the limited associated project/review context needed to document, resolve, and prevent recurrence of the issue. If you explicitly opt in to broader improvement use, we may also retain the submitted materials and related analyses for the service-improvement purposes described in this Policy, subject to the same confidentiality and deletion framework described in this Policy.
|
||||
|
||||
**Billing and accounting.** We retain billing records and aggregated usage statistics as required by law and for legitimate business purposes (for example taxes, accounting, and audit).
|
||||
|
||||
## 9. Security
|
||||
|
||||
We use commercially reasonable administrative, technical, and organizational safeguards designed to protect information, including encryption in transit (TLS) and at rest, per-user access isolation, secure credential management, and monitoring. No system is perfectly secure.
|
||||
|
||||
## 10. International data transfers
|
||||
|
||||
By default, the Service is hosted in the United States. If you access the Service from outside the United States, information may be transferred to, stored in, and processed in the United States and other countries where we or our subprocessors operate. Where required, we will use appropriate safeguards for international transfers (for example contractual protections in a DPA).
|
||||
|
||||
## 11. Your rights and choices
|
||||
|
||||
### 11.1 GDPR/UK GDPR
|
||||
|
||||
Depending on your jurisdiction, you may have rights to access, correct, delete, restrict, object to processing, and port your Personal Data. You may also have the right to withdraw consent where processing is based on consent.
|
||||
|
||||
### 11.2 California (CCPA/CPRA)
|
||||
|
||||
If you are a California resident, you may have rights to know, access, delete, correct, and opt out of the "sale" or "sharing" of Personal Data, and to limit the use of "sensitive personal information," as those terms are defined under California law. Faradworks does not sell Personal Data. We do not share Personal Data for cross-context behavioral advertising.
|
||||
|
||||
### 11.3 How to exercise rights
|
||||
|
||||
Privacy requests (data access, deletion, correction): dev@faradworks.com
|
||||
|
||||
For faster handling, use subject line: "Privacy Request (Access/Deletion/Correction)".
|
||||
|
||||
We will respond to verified privacy requests within applicable legal timelines (typically within 30 days for GDPR requests).
|
||||
|
||||
## 12. Children
|
||||
|
||||
The Service is not directed to children, and we do not knowingly collect Personal Data from children under 13 (or under the age threshold applicable in your jurisdiction).
|
||||
|
||||
## 13. Changes to this Privacy Policy
|
||||
|
||||
We may update this Privacy Policy from time to time. If we make material changes, we will provide reasonable notice by email or by posting a notice on the Service before the changes take effect. The updated policy will be effective as of the "Last updated" date unless otherwise stated.
|
||||
|
||||
## 14. Contact
|
||||
|
||||
Questions about this Privacy Policy: [dev@faradworks.com](mailto:dev@faradworks.com)
|
||||
@@ -1,355 +0,0 @@
|
||||
# Periscope Terms of Service
|
||||
|
||||
**Last updated: April 5, 2026**
|
||||
|
||||
These Terms of Service ("Terms") govern access to and use of the Periscope platform (periscope.michelebigi.it) and related services (the "Service"). These Terms apply to free users and self-serve paid users. If you have a separate written agreement signed by Faradworks, Inc. (for example, an enterprise agreement), that agreement governs your use of the Service to the extent it conflicts with these Terms.
|
||||
|
||||
These Terms incorporate Faradworks' [Privacy Policy](/privacy) and any policies referenced in the Service.
|
||||
|
||||
By creating an account, clicking to accept these Terms (for example, by clicking an "I agree" button in our signup flow), or otherwise accessing or using the Service, you agree to be bound by these Terms. If you do not agree, do not use the Service.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**"Customer Content"** means any files, data, text, images, netlists, schematics, datasheets, design files, chat inputs, or other materials uploaded or submitted by you.
|
||||
|
||||
**"Outputs"** means analyses, checks, summaries, recommendations, or responses generated by the Service based on Customer Content.
|
||||
|
||||
**"Derived Data"** means technical artifacts generated solely to operate the Service, such as parsed text, indexes, embeddings, summaries, or extracted metadata.
|
||||
|
||||
**"Service"** means the Periscope platform (periscope.michelebigi.it), including all features, APIs, and related services.
|
||||
|
||||
**"Content"** refers collectively to Customer Content, Outputs, and Derived Data.
|
||||
|
||||
**"Faradworks," "we," and "us"** means Faradworks, Inc., a Delaware corporation.
|
||||
|
||||
## 2. Acceptance; eligibility; authority; electronic communications
|
||||
|
||||
### 2.1 Authority.
|
||||
|
||||
If you use the Service on behalf of a company or other entity, you represent and warrant that you have authority to bind that entity. In that case, "you" and "your" refer to that entity.
|
||||
|
||||
### 2.2 Eligibility.
|
||||
|
||||
You must be at least 18 years old (or the age of legal majority where you live) to use the Service.
|
||||
|
||||
### 2.3 Electronic delivery and notices.
|
||||
|
||||
You consent to receive communications from Faradworks electronically (for example, by email and in-product notices). You agree that all agreements, notices, disclosures, and other communications we provide electronically satisfy any legal requirement that such communications be in writing.
|
||||
|
||||
## 3. Service description; informational use; no professional advice
|
||||
|
||||
### 3.1 Informational service.
|
||||
|
||||
The Service analyzes user-submitted electrical design files and datasheets using automated systems, including machine learning models, to generate informational Outputs. Outputs are provided on an "AS IS" and "AS AVAILABLE" basis.
|
||||
|
||||
### 3.2 No professional engineering advice.
|
||||
|
||||
The Service does not provide professional engineering, safety, regulatory, certification, legal, or compliance advice. You are solely responsible for independently reviewing and validating any Outputs before use in real-world designs, fabrication, procurement, manufacturing, or deployment. Outputs may be incomplete, inaccurate, or unsuitable for your specific application.
|
||||
|
||||
### 3.3 AI limitations.
|
||||
|
||||
You acknowledge that AI-generated Outputs are probabilistic and may be inaccurate, incomplete, or misleading; that Output quality depends on input quality; and that models and Outputs may change over time.
|
||||
|
||||
## 4. Accounts; security; administrators
|
||||
|
||||
### 4.1 Account security.
|
||||
|
||||
You are responsible for maintaining the confidentiality of your credentials and for all activity occurring under your account. You must promptly notify Faradworks if you suspect unauthorized access.
|
||||
|
||||
### 4.2 Administrators and workspace users.
|
||||
|
||||
If your account supports multiple users, administrators may be able to manage users, permissions, billing, and settings within your workspace. You are responsible for actions taken by anyone you allow to access the Service through your account.
|
||||
|
||||
### 4.3 Responsibility for systems and backups.
|
||||
|
||||
You are responsible for your own systems, networks, and devices used to access the Service, and for maintaining appropriate backups of your Customer Content.
|
||||
|
||||
## 5. Ownership; licenses; feedback
|
||||
|
||||
### 5.1 Your ownership.
|
||||
|
||||
You retain all right, title, and interest in and to your Customer Content.
|
||||
|
||||
### 5.2 Outputs.
|
||||
|
||||
To the extent permitted by law, you own the Outputs generated from your Customer Content. Ownership of Outputs does not confer ownership of the Service, software, models, or analytical methods used to generate them.
|
||||
|
||||
### 5.3 Faradworks ownership.
|
||||
|
||||
Faradworks retains all right, title, and interest in and to the Service (including its software, models, workflows, analytical methods, user interfaces, and underlying infrastructure), including all improvements and derivatives.
|
||||
|
||||
### 5.4 License to operate the Service.
|
||||
|
||||
You grant Faradworks a limited, non-exclusive, worldwide, royalty-free license to host, store, process, transmit, reproduce (as necessary for processing), and otherwise use your Customer Content and Derived Data solely to provide, maintain, secure, and support the Service. Any broader use of Customer Content, Outputs, support requests, Issue Reports, or related project/review context for service-improvement purposes is permitted only to the extent expressly described in these Terms, the Privacy Policy, and any opt-in choices you make in the Service.
|
||||
|
||||
### 5.5 No model training on Customer Content.
|
||||
|
||||
Faradworks will not use Customer Content to train, fine-tune, or improve any general-purpose or foundation AI model, and will not permit third parties to do so, unless you separately and explicitly agree to that use.
|
||||
|
||||
### 5.6 Support reports and optional improvement consent.
|
||||
|
||||
By default, Faradworks will not use Customer Content, Outputs, support requests, Issue Reports, or related project/review context to improve the Service beyond investigating, resolving, and preventing recurrence of the specific incident for which such materials were submitted.
|
||||
|
||||
If you explicitly opt in through the applicable in-product support or feedback flow, you grant Faradworks a limited, revocable, non-exclusive, worldwide, royalty-free license to access, review, retain, and use the submitted materials and the reasonably necessary related Customer Content, Outputs, Derived Data, and project/review context to evaluate, test, debug, monitor, support, secure, operate, develop, and improve the Service and related features, systems, and workflows. This may include quality evaluation, failure analysis, prompt and retrieval improvements, ranking or classification improvements, validation logic, reliability engineering, abuse prevention, and other product-quality, safety, and operational improvements for the benefit of current and future users.
|
||||
|
||||
This consent does not authorize Faradworks or any third party to train, fine-tune, or improve any general-purpose or foundation AI model unless you separately and expressly agree to that use.
|
||||
|
||||
You may withdraw this optional improvement consent at any time through the Service settings or by contacting Faradworks. Withdrawal will apply prospectively and will not require Faradworks to unwind or delete improvements, evaluations, or analyses already created or completed before withdrawal, subject to the Privacy Policy's retention and deletion terms.
|
||||
|
||||
### 5.7 Feedback.
|
||||
|
||||
If you provide suggestions or feedback, you grant Faradworks a perpetual, irrevocable, worldwide, royalty-free license to use and incorporate it without restriction or obligation.
|
||||
|
||||
## 6. Customer responsibilities; prohibited data; export and restricted data
|
||||
|
||||
### 6.1 Rights in content you upload.
|
||||
|
||||
You represent and warrant that you have all rights necessary to upload and use Customer Content with the Service and to grant the rights in these Terms.
|
||||
|
||||
### 6.2 Prohibited Data.
|
||||
|
||||
Unless Faradworks expressly agrees in writing, you will not submit or upload any of the following ("Prohibited Data"): (a) patient, medical, or other protected health information regulated by HIPAA or similar laws; (b) payment card data subject to PCI DSS; (c) bank account numbers, passwords, or authentication secrets intended to access financial accounts; (d) social security numbers, driver's license numbers, or other unique government ID numbers; (e) "special categories" of personal data under the GDPR (or similar sensitive personal data classifications); or (f) any other similarly sensitive personal information that would impose heightened legal or regulatory obligations on Faradworks.
|
||||
|
||||
### 6.3 GDPR / DPA.
|
||||
|
||||
If you are subject to GDPR/UK GDPR and need a data processing agreement ("DPA"), contact us at [dev@faradworks.com](mailto:dev@faradworks.com). If we provide a DPA for your use case, you must execute it before submitting personal data governed by GDPR/UK GDPR as Customer Content. If a DPA applies, it will govern the parties' rights and obligations with respect to such personal data and will control in the event of conflict with these Terms.
|
||||
|
||||
### 6.4 Export-controlled and restricted technical data.
|
||||
|
||||
You are responsible for compliance with all applicable export control laws and regulations, including U.S. EAR and ITAR. You agree not to upload export-controlled technical data, classified information, or government-restricted information without proper authorization. By default, the Service is hosted in the United States. By using the Service, you acknowledge that Content may be processed and stored in the U.S. or other regions where we or our subprocessors operate.
|
||||
|
||||
## 7. Acceptable use; restrictions
|
||||
|
||||
You agree not to:
|
||||
|
||||
- (a) upload content you do not have rights to use;
|
||||
- (b) upload malware, malicious code, or content intended to disrupt or compromise the Service;
|
||||
- (c) circumvent usage limits or security controls;
|
||||
- (d) interfere with service integrity or access others' data;
|
||||
- (e) systematically extract, scrape, benchmark, or reverse engineer the Service, Outputs, or underlying models for the purpose of building a competing product;
|
||||
- (f) resell, rent, or redistribute the Service without Faradworks' written consent; or
|
||||
- (g) use the Service for illegal purposes or in violation of applicable laws.
|
||||
|
||||
Faradworks may suspend or terminate access for violations of this section.
|
||||
|
||||
## 8. High-risk applications
|
||||
|
||||
The Service is not designed or intended for use in life-critical or safety-critical systems where failure could result in death, bodily injury, or significant property or environmental damage, including medical devices, automotive safety systems, aerospace systems, nuclear facilities, or weapons systems.
|
||||
|
||||
If you choose to use the Service in connection with such applications, you do so at your own risk. You are solely responsible for independently validating all Outputs and for ensuring that any resulting designs, products, or systems meet all applicable safety, regulatory, and certification requirements.
|
||||
|
||||
## 9. Free trials; beta features; no reliance
|
||||
|
||||
### 9.1 Free and trial access.
|
||||
|
||||
Faradworks may offer free plans, free trials, promotional credits, or other no-fee access ("Free Access"). Faradworks may modify, suspend, or end Free Access at any time.
|
||||
|
||||
### 9.2 Beta features.
|
||||
|
||||
Faradworks may make available features labeled alpha, beta, preview, or similar ("Beta Features"). Beta Features are experimental and may be changed or discontinued at any time.
|
||||
|
||||
### 9.3 No SLA; no reliance.
|
||||
|
||||
Free Access and Beta Features are provided "AS IS" and "AS AVAILABLE," without any service level commitment and without any obligation to provide support. You should not rely on Free Access or Beta Features for production use.
|
||||
|
||||
## 10. Third-party providers; subprocessors; third-party services
|
||||
|
||||
### 10.1 Subprocessors.
|
||||
|
||||
The Service relies on third-party providers (including AI model providers, hosting, storage, authentication, billing, and observability providers) to help deliver functionality. Faradworks' data handling practices are described in the Privacy Policy.
|
||||
|
||||
### 10.2 Third-party services/links.
|
||||
|
||||
The Service may integrate with or link to third-party services. Faradworks is not responsible for third-party services, and your use of them is governed by the third party's terms and policies.
|
||||
|
||||
## 11. Usage limits; billing; usage allocations; taxes; refunds
|
||||
|
||||
### 11.1 Usage limits.
|
||||
|
||||
Plans may include limits on reviews, tokens, files, API spend, usage allocations, or other usage metrics. Faradworks may throttle, restrict, or suspend usage to enforce limits or protect system stability.
|
||||
|
||||
### 11.2 Prepaid service credits.
|
||||
|
||||
Certain Services, plans, or features may allow or require you to prepay for future eligible Periscope review services for professional, business, or organizational use by purchasing prepaid service credits ("Usage Credits"). Usage Credits represent a prepaid, limited, revocable, non-transferable license to access eligible Periscope review services up to the applicable credited amount and may be used only for eligible Periscope review charges as described in the Service. Faradworks may also, in its sole discretion, provide free or promotional credits ("Promotional Credits"), which may be subject to additional restrictions or expiration dates stated when issued.
|
||||
|
||||
### 11.3 Credit characteristics and workspace scope.
|
||||
|
||||
Credits may be used only for eligible Periscope review charges and may not be used for any other product or service unless Faradworks expressly states otherwise in the Service. Credits are not legal tender, are not currency, are not redeemable for cash, are not refundable except as required by law or expressly stated by Faradworks, do not constitute or confer any personal property right, and do not constitute a bank account, deposit account, stored-value account, digital wallet, payment instrument, or other monetary account. Credits are an internal service accounting mechanism that measures the amount of eligible Periscope review services you have prepaid and are licensed to use. Any credit balance or similar amount displayed in the Service reflects only our record of remaining prepaid eligibility for future eligible review charges and does not represent money held on your behalf. Credits are non-transferable, may not be sold, assigned, gifted, or sublicensed, and may be used only by the workspace or account to which they are issued. If credits are issued to an organization or workspace, they belong to that workspace and may be consumed by authorized users acting within that workspace.
|
||||
|
||||
### 11.4 Credit purchases and application to charges.
|
||||
|
||||
Your order for Usage Credits constitutes an offer to purchase those Usage Credits. Faradworks may accept or reject any purchase request in its discretion. Credits are issued when Faradworks confirms the purchase or otherwise makes the credits available in your account or workspace. Credits are applied to eligible Periscope review charges in the manner described in the Service. Faradworks may reserve, deduct, reverse, release, or adjust credits to reflect quoted charges, completed usage, failed runs, duplicate requests, fraud checks, refunds, chargebacks, or billing corrections. Credit pricing, minimum purchase amounts, maximum purchase amounts, and applicable taxes will be shown in the Service or at checkout. Fees are exclusive of taxes unless stated otherwise.
|
||||
|
||||
### 11.5 Credit expiration, forfeiture, and promotional credits.
|
||||
|
||||
Unless otherwise stated in the Service or required by law, purchased Usage Credits expire 12 months after the date they are issued to your account or workspace. Expiration does not restart because credits are partially used, because you change plans, or because your workspace or account settings change, unless Faradworks expressly states otherwise. Promotional Credits expire on the date stated when they are issued, or if no date is stated, 12 months after issuance unless required otherwise by law. Credits may be forfeited if the applicable account or workspace is closed, terminated, or suspended, subject to applicable law. Faradworks may also withhold, void, or reverse credits associated with chargebacks, payment reversals, fraud, abuse, or violations of these Terms.
|
||||
|
||||
### 11.6 Per-review usage impact.
|
||||
|
||||
Paid plans may include monthly usage limits, included usage budgets, or similar usage allocations. These allocations reset each billing period unless otherwise stated. Each review has a usage impact based on scope, selected model, and your applicable plan or pricing structure. Before you run a review, Faradworks will show the usage impact of that review, which may be displayed as a dollar amount, credit amount, percentage of plan limit, or similar usage metric. That usage impact may change over time, including for the same or a similar review configuration, and prepaid credits do not lock in a future review price unless Faradworks expressly states otherwise in the Service. Unless Faradworks expressly states otherwise, the applicable review charge is the quoted amount or usage impact shown in the Service at the time you submit or start the review, and any quote may expire or require refresh before use.
|
||||
|
||||
### 11.7 Plan changes.
|
||||
|
||||
If you change plans mid-cycle, Faradworks may apply prorated or other adjustments to your available usage limits, credits, budget, or similar usage allocation for that active billing period.
|
||||
|
||||
### 11.8 Auto-renewal; cancellation.
|
||||
|
||||
Paid subscriptions renew automatically unless canceled. You may cancel at any time through your account settings. Cancellation stops future renewals; it does not retroactively refund fees already paid except where required by law.
|
||||
|
||||
### 11.9 Grandfathered plans.
|
||||
|
||||
Grandfathered plans may remain available only while the subscription stays active and in good standing. If a grandfathered plan is canceled, lapses, or is changed, reactivation may require enrolling in a then-current plan. Faradworks may retire grandfathered plans with reasonable notice where permitted by law.
|
||||
|
||||
### 11.10 Taxes.
|
||||
|
||||
Fees are exclusive of taxes unless stated otherwise. You are responsible for applicable taxes, except taxes based on Faradworks' net income.
|
||||
|
||||
### 11.11 Refunds and billing corrections.
|
||||
|
||||
You may request a refund for completely unused Usage Credits within 24 hours after purchase by contacting [dev@faradworks.com](mailto:dev@faradworks.com). If no refund request is received within 24 hours after purchase, unused Usage Credits become non-refundable except where required by law. Once any portion of purchased Usage Credits has been used, that purchase becomes non-refundable except where required by law or expressly authorized by Faradworks. Otherwise, refunds are provided at Faradworks' discretion or as required by law. Faradworks may correct pricing errors, mistaken issuances, duplicate grants, or accounting mistakes, including by adjusting, removing, or restoring credits where appropriate.
|
||||
|
||||
## 12. Confidentiality; security; support access
|
||||
|
||||
### 12.1 Confidential Customer Content.
|
||||
|
||||
Faradworks treats Customer Content as confidential and does not disclose it to third parties except as necessary to provide the Service (including to subprocessors), as required by law, or with your consent, as further described in the Privacy Policy.
|
||||
|
||||
### 12.2 Security.
|
||||
|
||||
Faradworks uses commercially reasonable administrative, technical, and organizational safeguards designed to protect Content. However, no system is perfectly secure and Faradworks does not guarantee that unauthorized access, hacking, data loss, or other security incidents will never occur.
|
||||
|
||||
### 12.3 Security incident communications.
|
||||
|
||||
Where required by applicable law, Faradworks will provide notice of a confirmed unauthorized access to personal data under our control.
|
||||
|
||||
### 12.4 Your responsibility for sensitive material.
|
||||
|
||||
You are responsible for evaluating whether the Service meets your confidentiality requirements before uploading sensitive or proprietary designs.
|
||||
|
||||
### 12.5 Support troubleshooting access.
|
||||
|
||||
If you submit a support request or an Issue Report, Faradworks may access account-linked diagnostic records and the Customer Content, Outputs, and related project/review context reasonably necessary to investigate, reproduce, resolve, remediate, and prevent recurrence of the reported issue. This may include the affected project, review results, uploaded files or excerpts, citations, and associated diagnostics.
|
||||
|
||||
If you explicitly opt in through the applicable support or feedback flow, Faradworks may also use those submitted materials and related context for the broader service-improvement purposes described in Section 5.6. Faradworks will limit such access and use to authorized personnel and to the permitted purposes described in these Terms and the Privacy Policy.
|
||||
|
||||
## 13. Suspension; termination; effect of termination
|
||||
|
||||
### 13.1 Suspension/termination by Faradworks.
|
||||
|
||||
Faradworks may suspend or terminate access for: (a) violations of these Terms; (b) excessive, abusive, or fraudulent usage; (c) non-payment; (d) legal, regulatory, or infrastructure constraints; or (e) security, abuse-prevention, or risk-management reasons.
|
||||
|
||||
### 13.2 Termination by you.
|
||||
|
||||
You may stop using the Service at any time and may request account closure.
|
||||
|
||||
### 13.3 Effect.
|
||||
|
||||
Upon termination, your right to access the Service ends immediately. Faradworks will delete your Content in accordance with the Privacy Policy's data retention and deletion terms, subject to legal retention requirements and limited residual technical artifacts described in the Privacy Policy.
|
||||
|
||||
### 13.4 Survival.
|
||||
|
||||
Sections that by their nature should survive will survive termination, including ownership, confidentiality, disclaimers, limitation of liability, indemnification, disputes, and general terms.
|
||||
|
||||
## 14. DISCLAIMERS
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SERVICE AND OUTPUTS ARE PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ACCURACY, AND QUIET ENJOYMENT.
|
||||
|
||||
FARADWORKS DOES NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, SECURE, OR FREE OF HARMFUL COMPONENTS, OR THAT OUTPUTS WILL MEET YOUR REQUIREMENTS OR BE CORRECT FOR ANY PARTICULAR USE.
|
||||
|
||||
## 15. LIMITATION OF LIABILITY
|
||||
|
||||
### 15.1 EXCLUSION OF CERTAIN DAMAGES.
|
||||
|
||||
TO THE FULLEST EXTENT PERMITTED BY LAW, IN NO EVENT WILL FARADWORKS (OR ITS AFFILIATES, OFFICERS, DIRECTORS, EMPLOYEES, CONTRACTORS, AGENTS, LICENSORS, SUPPLIERS, OR SUBPROCESSORS) BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, BUSINESS OPPORTUNITIES, GOODWILL, OR DATA, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
### 15.2 SPECIFIC EXCLUSIONS.
|
||||
|
||||
TO THE FULLEST EXTENT PERMITTED BY LAW, FARADWORKS IS NOT LIABLE FOR: (a) ERROR OR INTERRUPTION OF USE; (b) LOSS, INACCURACY, CORRUPTION, OR UNAUTHORIZED DISCLOSURE OF DATA OR CONTENT; (c) COST OF PROCUREMENT OF SUBSTITUTE GOODS, SERVICES, OR TECHNOLOGY; (d) HARDWARE FAILURES, DESIGN DEFECTS, MANUFACTURING ISSUES, OR SAFETY INCIDENTS RELATED TO DESIGNS REVIEWED USING THE SERVICE; OR (e) ANY MATTER BEYOND FARADWORKS' REASONABLE CONTROL (INCLUDING THIRD-PARTY PROVIDER FAILURES).
|
||||
|
||||
### 15.3 AGGREGATE CAP.
|
||||
|
||||
TO THE FULLEST EXTENT PERMITTED BY LAW, FARADWORKS' TOTAL AGGREGATE AND CUMULATIVE LIABILITY FOR ALL CLAIMS ARISING OUT OF OR RELATING TO THESE TERMS OR THE SERVICE, UNDER ANY THEORY OF LIABILITY (CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, STATUTE, OR OTHERWISE), WILL NOT EXCEED THE FEES PAID BY YOU TO FARADWORKS FOR THE SERVICE IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO THE CLAIM. IF YOU HAVE NOT PAID ANY FEES TO FARADWORKS IN THAT PERIOD (FOR EXAMPLE, DURING FREE ACCESS OR A FREE TRIAL), FARADWORKS' TOTAL LIABILITY WILL NOT EXCEED ONE HUNDRED U.S. DOLLARS (US$100).
|
||||
|
||||
### 15.4 BASIS OF THE BARGAIN; FAILURE OF ESSENTIAL PURPOSE.
|
||||
|
||||
YOU ACKNOWLEDGE THAT THE FEES (IF ANY) REFLECT THE ALLOCATION OF RISK AND THAT FARADWORKS WOULD NOT PROVIDE THE SERVICE WITHOUT THESE LIMITATIONS. THE LIMITATIONS IN THIS SECTION APPLY EVEN IF ANY LIMITED REMEDY FAILS OF ITS ESSENTIAL PURPOSE.
|
||||
|
||||
### 15.5 LIMITATIONS REQUIRED BY LAW.
|
||||
|
||||
NOTHING IN THESE TERMS LIMITS OR EXCLUDES LIABILITY TO THE EXTENT SUCH LIMITATION OR EXCLUSION IS PROHIBITED BY APPLICABLE LAW.
|
||||
|
||||
## 16. Indemnification
|
||||
|
||||
### 16.1 Your indemnity.
|
||||
|
||||
You agree to indemnify, defend, and hold harmless Faradworks and its affiliates, officers, directors, employees, contractors, and agents from and against any claims, damages, losses, liabilities, and expenses (including reasonable attorneys' fees) arising out of or relating to: (a) your use of the Service or Outputs; (b) your Customer Content; (c) your violation of these Terms; (d) your violation of any third-party rights (including IP rights); or (e) designs, products, or systems you create using or informed by Outputs.
|
||||
|
||||
### 16.2 Indemnification procedure.
|
||||
|
||||
Your obligations under this Section 16 are conditioned on Faradworks: (a) providing you prompt notice of the claim (provided that failure to provide prompt notice will relieve you only to the extent materially prejudiced); (b) providing reasonable assistance at your expense; and (c) allowing you sole control of the defense and settlement of the claim, except that you may not settle any claim in a manner that admits fault by Faradworks or imposes obligations on Faradworks without Faradworks' prior written consent. Faradworks may participate in the defense with counsel of its choosing at its own expense.
|
||||
|
||||
## 17. Force majeure
|
||||
|
||||
Faradworks will not be liable for any delay or failure to perform due to events beyond its reasonable control, including acts of God, natural disasters, war, terrorism, riots, labor disputes, pandemics, public utility failures, internet or cloud provider failures, or governmental actions ("Force Majeure Events"). A Force Majeure Event does not excuse your payment obligations for fees accrued prior to the Force Majeure Event.
|
||||
|
||||
## 18. Disputes; governing law; venue; time limit to bring claims
|
||||
|
||||
### 18.1 Informal resolution.
|
||||
|
||||
Before filing a claim (other than for injunctive relief), you agree to first contact Faradworks at [dev@faradworks.com](mailto:dev@faradworks.com) with a brief description of the dispute and your contact information. The parties will attempt in good faith to resolve the dispute for at least 30 days.
|
||||
|
||||
### 18.2 Governing law.
|
||||
|
||||
These Terms are governed by the laws of the State of California, without regard to conflict of law principles.
|
||||
|
||||
### 18.3 Venue.
|
||||
|
||||
Any disputes arising out of or relating to these Terms or the Service must be brought in the state or federal courts located in California, and each party consents to personal jurisdiction and venue there.
|
||||
|
||||
### 18.4 Injunctive relief.
|
||||
|
||||
Nothing in these Terms prevents either party from seeking injunctive or other equitable relief to protect its intellectual property or confidential information.
|
||||
|
||||
### 18.5 Time limit to bring claims.
|
||||
|
||||
To the fullest extent permitted by law, any claim arising out of or relating to these Terms or the Service must be filed within one (1) year after the cause of action accrues; otherwise, it is permanently barred.
|
||||
|
||||
## 19. Changes to Terms
|
||||
|
||||
We may update these Terms from time to time. If we make material changes, we will provide reasonable notice by email or by posting a notice on the Service before the changes take effect. The updated Terms will be effective as of the "Last updated" date unless otherwise stated. Your continued use of the Service after the effective date, including accepting the updated Terms through the Service, constitutes acceptance of the updated Terms. If you do not agree to the changes, you must stop using the Service and close your account.
|
||||
|
||||
## 20. General terms
|
||||
|
||||
### 20.1 Entire agreement; order of precedence.
|
||||
|
||||
These Terms (including the [Privacy Policy](/privacy) and any policies referenced in the Service) are the entire agreement between you and Faradworks regarding the Service and supersede prior or contemporaneous agreements or understandings. If you have a separate written agreement signed by Faradworks that expressly governs the Service, that agreement will control to the extent of conflict.
|
||||
|
||||
### 20.2 Severability.
|
||||
|
||||
If any provision of these Terms is held invalid or unenforceable, the remaining provisions will remain in full force and effect.
|
||||
|
||||
### 20.3 Waiver.
|
||||
|
||||
Failure to enforce any provision is not a waiver of future enforcement.
|
||||
|
||||
### 20.4 Assignment.
|
||||
|
||||
You may not assign these Terms without Faradworks' prior written consent. Faradworks may assign these Terms in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, or otherwise upon notice.
|
||||
|
||||
### 20.5 No third-party beneficiaries.
|
||||
|
||||
Except for Faradworks' affiliates, licensors, suppliers, and subprocessors as intended third-party beneficiaries of Sections 14 (Disclaimers) and 15 (Limitation of Liability), there are no third-party beneficiaries to these Terms.
|
||||
|
||||
### 20.6 Independent contractors.
|
||||
|
||||
The parties are independent contractors. Nothing in these Terms creates any agency, partnership, joint venture, or employment relationship.
|
||||
|
||||
### 20.7 Headings.
|
||||
|
||||
Headings are for convenience only and do not affect interpretation.
|
||||
|
||||
## 21. Contact
|
||||
|
||||
Faradworks, Inc.
|
||||
|
||||
Questions about these Terms or the Privacy Policy: [dev@faradworks.com](mailto:dev@faradworks.com)
|
||||
@@ -1,43 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_AUTH_MODE
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=$NEXT_PUBLIC_AUTH_MODE
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN echo "BUILD API URL=$NEXT_PUBLIC_API_URL AUTH_MODE=$NEXT_PUBLIC_AUTH_MODE"
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_AUTH_MODE
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=$NEXT_PUBLIC_AUTH_MODE
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder /app/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/src ./src
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm","run","start"]
|
||||
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 756 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,42 +0,0 @@
|
||||
/** Migrate localStorage keys from pre-rebrand `pinscopex:` prefix. */
|
||||
|
||||
export function migrateLocalKey(newKey: string, legacyKey: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const current = localStorage.getItem(newKey);
|
||||
if (current != null) return current;
|
||||
const legacy = localStorage.getItem(legacyKey);
|
||||
if (legacy != null) {
|
||||
localStorage.setItem(newKey, legacy);
|
||||
localStorage.removeItem(legacyKey);
|
||||
return legacy;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reviewedFindingsKey(projectId: string) {
|
||||
return `periscopex:reviewed-findings:${projectId}`;
|
||||
}
|
||||
|
||||
export function legacyReviewedFindingsKey(projectId: string) {
|
||||
return `pinscopex:reviewed-findings:${projectId}`;
|
||||
}
|
||||
|
||||
export function deratingSettingsKey(projectId: string) {
|
||||
return `periscopex:derating-settings:${projectId}`;
|
||||
}
|
||||
|
||||
export function legacyDeratingSettingsKey(projectId: string) {
|
||||
return `pinscopex:derating-settings:${projectId}`;
|
||||
}
|
||||
|
||||
export function deratingOverridesKey(projectId: string) {
|
||||
return `periscopex:derating-overrides:${projectId}`;
|
||||
}
|
||||
|
||||
export function legacyDeratingOverridesKey(projectId: string) {
|
||||
return `pinscopex:derating-overrides:${projectId}`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Periscope tree layout
|
||||
|
||||
Physical split (not a rewrite). AGPL `LICENSE` stays at the git root. The GitHub fork is not detached.
|
||||
|
||||
| Tree | Path | Role |
|
||||
| --- | --- | --- |
|
||||
| Native Periscope | `periscope/src/` | Finding engine clamp, PCB/placement/antenna, DeepSeek, local auth, KiCad plugin, deploy Dockerfiles |
|
||||
| Inherited PinScope | `periscope/dependency/` | Graph/parsers/`validate.py`, pipeline/extraction, OSS Next.js shell, skills, taxonomy, `simple_project` |
|
||||
| Third party | `vendor/impedancefinder/` | ImpedenceFinder (license UNKNOWN) |
|
||||
| Glue | `backend/__init__.py` | Merges the two `backend` packages for local imports |
|
||||
|
||||
**Do not empty-delete `periscope/dependency/`.** Later phases replace PinScope modules incrementally in `periscope/src` (Fase C). `validate.py` stays until a native reviewer exists.
|
||||
|
||||
Docker overlays `dependency` then `src` into `/app`. Local frontend: `cd periscope/dependency/frontend && npm run dev` (native files are symlinked from `periscope/src/frontend`).
|
||||
@@ -0,0 +1,149 @@
|
||||
# Periscope — Agentic Schematic Validation
|
||||
|
||||
Periscope validates hardware schematics against component datasheets. It extracts constraints from PDFs, parses netlists and BOMs into a queryable graph, and runs an agentic validation loop to flag design violations.
|
||||
|
||||
> **Open-core note.** This is the open-source core. A small set of files are
|
||||
> "gateway-owned seams" — pass-through stubs here (`frontend/src/proxy.ts`,
|
||||
> `use-optional-auth.ts`, `clerk-theme-provider.tsx`,
|
||||
> `components/billing/*`, `sidebar-auth.tsx`, `pricing-section.tsx`,
|
||||
> `analytics/*`, `lib/csp-hosts.ts`) that the hosted-cloud repo replaces
|
||||
> with auth/billing implementations. Keep their export signatures stable,
|
||||
> and never import auth/billing SDKs anywhere else in the frontend. On the
|
||||
> backend, everything reaches billing only through
|
||||
> `backend/services/billing_hook.py:get_billing()` (a no-op here).
|
||||
|
||||
## System Overview
|
||||
|
||||
Three layers:
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
|-------|----------|---------|
|
||||
| **Core library** | `backend/periscopex/` | Models, parsers, graph builder, agentic validator, passive resolver, taxonomy, BOM summary, derating |
|
||||
| **Backend** | `backend/` | FastAPI app — async pipeline orchestration, SSE progress, project/file storage |
|
||||
| **Frontend** | `frontend/` | Next.js 16 app — project dashboard, pipeline progress, report viewer, derating, admin dashboard |
|
||||
|
||||
Plus `skills/` — extraction prompts (pintable, patterns, specs) inlined locally for DeepSeek. Do not upload to Anthropic Console.
|
||||
|
||||
The pipeline stages: Parse BOM → Extract IC Pintables → Extract Simple Components → Extract Passives → DigiKey Auto-Resolve + Value Fallback → Build Graph → Direct Datasheet Review. Pipeline runs can be cancelled mid-execution via `POST /api/pipeline/{id}/cancel`.
|
||||
|
||||
## Example Project
|
||||
|
||||
`simple_project/` is the reference design for development and testing:
|
||||
|
||||
- **MCU**: TI MSPM0G3507SPTR (U3) — 48-pin LQFP
|
||||
- **USB-UART Bridge**: CH340E (U2)
|
||||
- **LDO Regulator**: SPX3819M5-L-3-3 (U1) — 5V to 3.3V
|
||||
- **ESD Protection**: USBLC6-2SC6 (D1)
|
||||
- **Crystal**: 8 MHz (X1) with 18pF load caps (C9, C10)
|
||||
|
||||
Files: `.asc` (PADS-PCB netlist; `.edn` EDIF 2.0.0 also accepted), `.csv`/`.xlsx` (BOM), `design_graph.json` (committed reference fixture used by tests).
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
- **Modular extractors** — Domain-specific extraction per component type, unified constraint schema
|
||||
- **Netlist as graph** — Queryable bipartite graph (components + nets) with traversal helpers
|
||||
- **LLM API for PDF extraction** — Forced tool calls for structured output (pintable, passive patterns, specs). Default provider is DeepSeek.
|
||||
- **Prompt caching** — Anthropic stamps `cache_control`; Gemini uses CachedContent; DeepSeek uses automatic prefix cache (cache-hit tokens in usage).
|
||||
- **Local extraction skills** — `skills/*/SKILL.md` is inlined and `validate.py` runs in-process. Never call `scripts/upload_skills.py` (Anthropic Console).
|
||||
- **Direct datasheet review** — The model reads the IC datasheet plus circuit neighborhood, compares to the reference application circuit, and flags issues via graph query tools (`find_connected_components`, `get_net_for_pin`, `get_pintable`). DeepSeek converts PDFs to text (and page images on the vision model).
|
||||
- **Datasheet page trimming** — Large PDFs are keyword-trimmed to relevant pages before sending to Claude, reducing token cost (`pypdf`)
|
||||
- **DigiKey fallback (exact MPN only)** — When pattern-based and direct extraction fail, DigiKey API fetches product parameters for auto-resolve. DigiKey matches only on exact MPN; fuzzy hits are rejected to avoid polluting the shared library with wrong-dielectric / wrong-voltage parts.
|
||||
- **Value-string fallback** — When DigiKey misses an R/C/L/FB passive, a value-string resolver maps the BOM `Value` string to typed passive specs. Value-derived specs are persisted per-project only — never to the shared library.
|
||||
- **Per-IC review error isolation** — Direct datasheet review runs each IC independently; one malformed payload or bad response cannot kill the whole run. Failed ICs surface as skipped components with the error.
|
||||
- **Cross-IC excerpt budget (per-neighbor)** — To verify an interface finding the reviewer can pull a *connected* IC's datasheet pages (`get_datasheet_excerpt`). The budget is a global per-review page ceiling **plus a per-neighbor sub-budget**, so verifying one interface is never starved by pages already spent on other neighbors.
|
||||
- **Finding normalization is downgrade-only** — A post-review per-IC normalize pass (`services/normalize_findings.py`) drops self-cancelling findings, merges same-root-cause findings, and re-grades severity — but only ever *downward*. A deterministic clamp caps each finding at the reviewer's calibrated severity (and any `Unverified:` finding at WARNING, preserving the prefix).
|
||||
- **Cross-IC finding dedup** — After all per-IC reviews complete, a single pass (`services/dedupe_findings.py`) collapses one physical interface defect reported from both endpoints into a single finding. Gated by `cross_ic_dedup_enabled`; fail-soft.
|
||||
- **Capacitor voltage derating** — Deterministic derating table computed from graph (ceramic/tantalum/electrolytic percentages, pass/fail per capacitor)
|
||||
- **Deterministic checks over heuristics** — Exact checks where possible
|
||||
- **Zero coupling between layers** — Backend calls periscopex functions with paths; frontend talks to backend via REST + SSE
|
||||
- **Library deduplication** — Shared library (`library/extracted/`, `library/patterns/`, `library/models/`, `library/passives/`, `library/datasheets/`) caches extractions across projects
|
||||
- **Content-addressed datasheets** — `library/datasheets/blobs/{md5}.pdf` stores unique PDFs once; `library/datasheets/refs/{safe_mpn}.json` maps MPNs to blobs (dedupe + multi-MPN sharing)
|
||||
- **Taxonomy-driven extraction** — Living component taxonomy (`taxonomy/`) with per-subtype classification and specs schemas
|
||||
- **Per-stage model config** — Each pipeline stage can use a different Claude model (e.g., Sonnet for review, Haiku for auto-resolve)
|
||||
- **API call logging** — Every Claude API call is logged with token counts, cost, and timing per pipeline run
|
||||
- **Report versioning** — Each project run is stamped with the current app version on the first `/start` transition (`ProjectMeta.periscope_version`). The version comes from `frontend/content/changelog.md`'s latest `##` heading — single source of truth — read at backend startup via `backend/_version.py`.
|
||||
|
||||
## Datasheet Extraction
|
||||
|
||||
Extracted data lives in `library/extracted/` (shared) or per-project under the storage backend. One JSON per MPN, schema in `backend/periscopex/models.py`.
|
||||
|
||||
Per-MPN IC extraction captures:
|
||||
1. **Pintable** — Pin number + name (required), description + alt functions (optional)
|
||||
2. **Package info** — Base family, package, pin count, description
|
||||
3. **Component subtype** — Dotted taxonomy path (e.g., `ic.mcu`, `ic.power.ldo`)
|
||||
|
||||
For discrete/simple components:
|
||||
4. **Specs** — Component specs (value, tolerance, package, voltage rating, etc.); parameters are filtered against taxonomy specs schemas
|
||||
|
||||
Extraction inlines **local skills** (`skills/*/SKILL.md` + `validate.py`) against DeepSeek. Do not use Anthropic Console Skills.
|
||||
|
||||
## Claude Console Skills
|
||||
|
||||
```
|
||||
skills/
|
||||
├── extract-pintable/ # Pin table + package info + taxonomy
|
||||
│ ├── SKILL.md # System prompt (YAML frontmatter + markdown)
|
||||
│ ├── schema.json # Tool output schema
|
||||
│ └── validate.py # Validation script
|
||||
├── extract-pattern/ # Passive MPN pattern
|
||||
└── extract-specs/ # Component specs (discrete, connectors, crystals, etc.)
|
||||
```
|
||||
|
||||
## Taxonomy
|
||||
|
||||
Living component taxonomy in `taxonomy/` — one JSON file per top-level type (ic, passive, connector, crystal, discrete, fuse, switch, test_point, transformer). Each subtype entry includes `description` and `example_mpn`.
|
||||
|
||||
Key taxonomy features:
|
||||
- **Ref prefix mapping** — `U→ic`, `R/C/L→passive`, `D/Q→discrete`, `X→crystal`, etc.
|
||||
- **Dotted subtype paths** — e.g., `ic.mcu`, `passive.capacitor.ceramic`, `ic.protection.esd`
|
||||
- **Dynamic growth** — `add_subtype()` adds new entries; concurrent-safe JSON writes
|
||||
- **Specs schema auto-generation** — Type-level and subtype-level parameter specs schemas are auto-generated via Claude when a taxonomy entry has none; extraction discards parameters not in the schema (`extra_specs` field)
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/upload_skills.py` — leftover Claude Console uploader. **Do not run.** Skills are local + DeepSeek only.
|
||||
- `scripts/migrate_datasheets_to_library.py` — One-time migration: copy per-project datasheets to `library/datasheets/` (dry-run by default, `--apply` to execute)
|
||||
- `scripts/migrate_datasheets_to_blobs.py` — Migrate named-PDF datasheets into the content-addressed blobs/refs layout (dry-run by default, `--apply` to execute)
|
||||
- `scripts/dedup_library_datasheets.py` — Remove redundant per-MPN datasheet PDFs when a passive pattern already has a `datasheet_key` (dry-run by default, `--apply` to execute)
|
||||
- `scripts/gc_orphan_blobs.py` — Garbage-collect `library/datasheets/blobs/*.pdf` not referenced by any ref file
|
||||
- `scripts/clear_rules_from_extractions.py` — Strip deprecated `rules`/`absolute_maximum_ratings` from existing library extractions
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Core**: Python 3.12+, Pydantic 2.x, OpenAI SDK (DeepSeek), Anthropic SDK (optional), google-genai (optional), openpyxl, pypdf, PyMuPDF
|
||||
- **Backend**: FastAPI, uvicorn, sse-starlette, pydantic-settings
|
||||
- **Frontend**: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS v4, shadcn/ui (Base UI), react-pdf
|
||||
- **AI**: DeepSeek Chat Completions (OpenAI-compatible) with forced tool calls for extraction and agentic review. Do not route stages to Anthropic.
|
||||
- **Model**: `deepseek-flash` for extraction, review, auto-resolve, and normalize (per-stage overrides via `.env`)
|
||||
- **Skills**: Local SKILL.md + validate.py on DeepSeek
|
||||
- **External APIs**: DigiKey API v4 (OAuth2) — optional datasheet auto-fetch and parameter-based auto-resolve (`DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`)
|
||||
|
||||
## Extracted Model Versioning
|
||||
|
||||
All `ComponentConstraints` extracted JSON files carry a `model_version` semver field:
|
||||
|
||||
- **Initial value** — set from `default_model_version` in `backend/skills_manifest.json` (starts at `1.0.0`)
|
||||
- **Minor bump** — increment `default_model_version` in `skills_manifest.json` when extraction prompts change (do **not** run `upload_skills.py`).
|
||||
|
||||
**Rule**: When committing changes under `skills/`, bump `default_model_version` locally. Never call Anthropic.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- Write tests against `simple_project/` — it's the ground truth
|
||||
- Netlist parser and BOM parser are pure functions with no side effects
|
||||
- All data structures use Pydantic models in `backend/periscopex/models.py`
|
||||
- Frontend types in `frontend/src/lib/types.ts` must stay in sync with `backend/periscopex/models.py`
|
||||
- Extraction prompts live in `skills/` (SKILL.md + schema.json + validate.py) and run locally against DeepSeek
|
||||
- **Never swallow exceptions silently** — prefer logging or re-raising over bare `except: continue`. Silent failures hide real bugs.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Backend (copy backend/.env.example to .env at repo root first)
|
||||
python3 -m uvicorn backend.main:app --reload # localhost:8000
|
||||
|
||||
# Frontend
|
||||
cd frontend && npm run dev # localhost:3000
|
||||
```
|
||||
|
||||
Local mode needs no cloud services and no auth — projects are stored in `data/` and you are `user_id="local"` with admin access.
|
||||
@@ -0,0 +1,4 @@
|
||||
"""PinScope-inherited backend package (in-tree dependency)."""
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -7,6 +7,11 @@ from pathlib import Path
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from backend.repo_paths import data_dir as _data_dir
|
||||
from backend.repo_paths import env_file as _env_file
|
||||
from backend.repo_paths import skills_dir as _skills_dir
|
||||
from backend.repo_paths import taxonomy_dir as _taxonomy_dir
|
||||
|
||||
# Resolve paths relative to the project root (one level up from backend/)
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent
|
||||
_PROJECT_ROOT = _BACKEND_DIR.parent
|
||||
@@ -111,10 +116,10 @@ class Settings(BaseSettings):
|
||||
# single finding. Runs once after all per-IC reviews complete.
|
||||
cross_ic_dedup_enabled: bool = True
|
||||
|
||||
# Paths (relative to project root, used by LocalStorageBackend)
|
||||
data_dir: Path = _PROJECT_ROOT / "data"
|
||||
taxonomy_dir: Path = _PROJECT_ROOT / "taxonomy"
|
||||
skills_dir: Path = _PROJECT_ROOT / "skills"
|
||||
# Paths (git split: data at repo root; taxonomy/skills under periscope/dependency)
|
||||
data_dir: Path = _data_dir()
|
||||
taxonomy_dir: Path = _taxonomy_dir()
|
||||
skills_dir: Path = _skills_dir()
|
||||
|
||||
# GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend)
|
||||
gcs_bucket: str = ""
|
||||
@@ -193,7 +198,7 @@ class Settings(BaseSettings):
|
||||
pipeline_sweeper_stale_seconds: int = 60
|
||||
|
||||
model_config = {
|
||||
"env_file": str(_BACKEND_DIR / ".env"),
|
||||
"env_file": str(_env_file()),
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -95,6 +95,18 @@ def _dielectric_category(component_subtype: str | None, dielectric: str | None)
|
||||
return "ceramic"
|
||||
|
||||
|
||||
def _stress(op: float | None, rated: float | None) -> str:
|
||||
"""PASS / MARGIN / RISK from Vop vs Vrated. No invented dielectric %."""
|
||||
if op is None or rated is None or rated <= 0:
|
||||
return "UNKNOWN"
|
||||
ratio = op / rated
|
||||
if ratio > 1.0:
|
||||
return "RISK"
|
||||
if ratio > 0.8:
|
||||
return "MARGIN"
|
||||
return "PASS"
|
||||
|
||||
|
||||
def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
"""Build a capacitor voltage derating table from the design graph.
|
||||
|
||||
@@ -180,6 +192,7 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
"c_eff_f": c_eff,
|
||||
"c_eff_formatted": c_eff_fmt,
|
||||
"dc_bias_model": "stima" if factor is not None else None,
|
||||
"stress": _stress(op_voltage, rated_v),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: natural_sort_key(r["designator"]))
|
||||
@@ -158,9 +158,9 @@ class InductorSpecs(BaseModel):
|
||||
@model_validator(mode="after")
|
||||
def _require_primary_value(self) -> InductorSpecs:
|
||||
if self.component_subtype == "passive.ferrite_bead":
|
||||
if self.impedance_ohm is None:
|
||||
raise ValueError("ferrite bead requires impedance_ohm")
|
||||
return self
|
||||
from backend.periscopex.ferrite_z import recover_bead_specs
|
||||
|
||||
return recover_bead_specs(self)
|
||||
if self.value_henries is None:
|
||||
raise ValueError("inductor requires value_henries")
|
||||
return self
|
||||
@@ -356,6 +356,19 @@ class Finding(BaseModel):
|
||||
cad_sheet: str | None = None # schematic sheet filename for plugin sync
|
||||
cad_uuid: str | None = None # KiCad symbol/pin uuid
|
||||
variant: str | None = None # DNP / ECO / assembly variant
|
||||
# Finding engine (docs/motore-finding.md) — optional for legacy JSON.
|
||||
facts: str = ""
|
||||
requirement: str = ""
|
||||
inference: str = ""
|
||||
provenance: Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"] | None = None
|
||||
finding_class: Literal["RULE", "RISK", "REVIEW", "INFO"] | None = None
|
||||
confidence: float | None = None
|
||||
evidence_status: Literal["SUFFICIENT", "INSUFFICIENT"] | None = None
|
||||
calculation: str = ""
|
||||
assumptions: list[str] = []
|
||||
action: str = ""
|
||||
decision_id: str | None = None
|
||||
suppressed: bool = False
|
||||
|
||||
|
||||
class ValidationReport(BaseModel):
|
||||
@@ -453,6 +466,7 @@ class LayoutPad(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
net: str = ""
|
||||
pinfunction: str = ""
|
||||
|
||||
|
||||
class LayoutFootprint(BaseModel):
|
||||
@@ -496,6 +510,8 @@ class LayoutZone(BaseModel):
|
||||
net: str
|
||||
layer: str
|
||||
outlines: list[list[tuple[float, float]]] = []
|
||||
keepout: bool = False
|
||||
name: str = ""
|
||||
|
||||
|
||||
class LayoutGraph(BaseModel):
|
||||
@@ -306,25 +306,30 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
|
||||
|
||||
if subtype == "passive.ferrite_bead":
|
||||
raw = vals.get("impedance_ohm") or vals.get("value_ohms")
|
||||
if raw is None:
|
||||
raise ValueError("Missing impedance_ohm in auto-resolved ferrite bead specs")
|
||||
impedance_ohm = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
|
||||
impedance_ohm = None
|
||||
if raw is not None:
|
||||
impedance_ohm = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
|
||||
current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None
|
||||
dcr_raw = vals.get("dcr_ohms")
|
||||
dcr_ohms: float | None = None
|
||||
if dcr_raw is not None:
|
||||
dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw)
|
||||
formatted = value_formatted or _format_value(impedance_ohm, "ohm")
|
||||
return InductorSpecs(
|
||||
formatted = value_formatted
|
||||
if not formatted and impedance_ohm is not None:
|
||||
formatted = _format_value(impedance_ohm, "ohm")
|
||||
spec = InductorSpecs(
|
||||
component_subtype=subtype_for_specs,
|
||||
value_henries=None,
|
||||
value_formatted=formatted,
|
||||
value_formatted=formatted or "FB",
|
||||
tolerance=tolerance,
|
||||
package=package,
|
||||
current_rating_a=current_rating_a,
|
||||
dcr_ohms=dcr_ohms,
|
||||
impedance_ohm=impedance_ohm,
|
||||
)
|
||||
from backend.periscopex.ferrite_z import recover_bead_specs
|
||||
extra = " ".join(str(v) for v in vals.values() if v is not None)
|
||||
return recover_bead_specs(spec, extra_text=extra)
|
||||
|
||||
if subtype.startswith("passive.inductor"):
|
||||
raw = vals.get("value_henries")
|
||||
@@ -20,6 +20,7 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from backend.periscopex.finding_engine import complete_findings
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
@@ -149,6 +150,12 @@ neighbor's datasheet that you fetched via `get_datasheet_excerpt` — this \
|
||||
links the page number to the right datasheet.
|
||||
- **recommendation**: What to change (for ERROR/WARNING only).
|
||||
|
||||
This is a **design review**, not a design rule. Put observations in \
|
||||
`finding` (FACT), datasheet text in `why` (REQUIREMENT), and judgment \
|
||||
only there — do not invent millimetres, IEC numbers, or typical values. \
|
||||
Recommended datasheet notes are never ERROR. If evidence is missing, say \
|
||||
so (Unverified) instead of guessing.
|
||||
|
||||
### Calibration
|
||||
ERROR only for clear violations: required pin floating, voltage exceeding \
|
||||
absolute max, required external component completely missing, wrong \
|
||||
@@ -917,17 +924,26 @@ def _parse_review(
|
||||
why = (
|
||||
"Unverified: no verbatim datasheet quote. " + why
|
||||
).strip()
|
||||
rec = str(item.get("recommendation") or item.get("action") or "").strip()
|
||||
act = str(item.get("action") or rec).strip()
|
||||
findings.append(Finding(
|
||||
designator=ic_ref,
|
||||
mpn=mpn,
|
||||
source_designator=src_designator,
|
||||
finding=item["finding"],
|
||||
facts=str(item.get("finding") or ""),
|
||||
requirement=why,
|
||||
inference=str(item.get("inference") or ""),
|
||||
why=why,
|
||||
status=status,
|
||||
source_page=page,
|
||||
source_quote=item.get("source_quote", ""),
|
||||
recommendation=item.get("recommendation", ""),
|
||||
recommendation=rec,
|
||||
action=act,
|
||||
reference=f"{src_mpn} datasheet p.{page if page is not None else '?'}",
|
||||
source="review",
|
||||
finding_class="REVIEW",
|
||||
evidence_status="SUFFICIENT" if quote else "INSUFFICIENT",
|
||||
))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
print(f"Skipping malformed finding for {ic_ref}: {exc}", file=sys.stderr)
|
||||
@@ -937,11 +953,12 @@ def _parse_review(
|
||||
|
||||
|
||||
def assign_finding_ids(findings: list[Finding]) -> None:
|
||||
"""Assign finding_id: {designator}-{001}, {002}, ..."""
|
||||
"""Assign finding_id: {designator}-{001}, {002}, ... then run the finding engine."""
|
||||
counter: Counter[str] = Counter()
|
||||
for f in findings:
|
||||
counter[f.designator] += 1
|
||||
f.finding_id = f"{f.designator}-{counter[f.designator]:03d}"
|
||||
complete_findings(findings)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -797,7 +797,17 @@ SUBMIT_REVIEW_SCHEMA = {
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string",
|
||||
"description": "What to change to fix the issue. Only for ERROR/WARNING.",
|
||||
"description": (
|
||||
"What to change on the board or schematic. "
|
||||
"Required for every finding, including INFO."
|
||||
),
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Same as recommendation if you prefer that name. "
|
||||
"Required for every finding when recommendation is empty."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["finding", "why", "status", "source_page"],
|
||||
@@ -104,8 +104,13 @@ async def _run() -> None:
|
||||
elif mode == "placement":
|
||||
from backend.services import placement_pipeline as placement_svc
|
||||
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
|
||||
elif mode == "pcb":
|
||||
from backend.services import pcb_pipeline as pcb_svc
|
||||
await pcb_svc.run_pcb_pipeline(storage, user_id, project_id)
|
||||
else:
|
||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run', 'regen', or 'placement'")
|
||||
raise SystemExit(
|
||||
f"unknown MODE={mode!r}; expected 'run', 'regen', 'placement', or 'pcb'"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -0,0 +1,3 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -463,7 +463,7 @@ async def events(project_id: str, request: Request):
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip placement events in the shared log.
|
||||
if ev.startswith("placement_"):
|
||||
if ev.startswith("placement_") or ev.startswith("pcb_"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
@@ -527,6 +527,9 @@ async def status(project_id: str, request: Request):
|
||||
"placement_status": meta.placement_status,
|
||||
"placement_state": meta.placement_state,
|
||||
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
|
||||
"pcb_status": meta.pcb_status,
|
||||
"pcb_state": meta.pcb_state,
|
||||
"pcb_running": (meta.pcb_status or "draft") in ("queued", "running"),
|
||||
"healed": healed is not None,
|
||||
}
|
||||
|
||||
@@ -548,6 +551,7 @@ _PLACEMENT_SSE_TERMINAL = frozenset({
|
||||
async def start_placement(project_id: str, request: Request):
|
||||
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
|
||||
from backend.services.placement_pipeline import analysis_busy, placement_busy
|
||||
from backend.services.pcb_pipeline import pcb_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
@@ -557,6 +561,8 @@ async def start_placement(project_id: str, request: Request):
|
||||
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||
if placement_busy(meta):
|
||||
raise HTTPException(409, "Placement pipeline already running or queued")
|
||||
if pcb_busy(meta):
|
||||
raise HTTPException(409, "PCB review is running; wait or cancel it first")
|
||||
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
|
||||
raise HTTPException(
|
||||
409,
|
||||
@@ -694,7 +700,7 @@ async def placement_events(project_id: str, request: Request):
|
||||
if not (
|
||||
ev.startswith("placement_")
|
||||
or ev == "heartbeat"
|
||||
):
|
||||
) or ev.startswith("pcb_"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
@@ -734,6 +740,187 @@ async def placement_events(project_id: str, request: Request):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PCB review pipeline (parallel — exam, not auto-place)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PCB_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
|
||||
_PCB_SSE_TERMINAL = frozenset({
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/pcb/start", status_code=202)
|
||||
async def start_pcb(project_id: str, request: Request):
|
||||
from backend.services.pcb_pipeline import analysis_busy, pcb_busy, placement_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_pcb:
|
||||
raise HTTPException(400, "Upload a .kicad_pcb before starting PCB review")
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting PCB review")
|
||||
if analysis_busy(meta):
|
||||
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||
if placement_busy(meta):
|
||||
raise HTTPException(409, "Placement pipeline is running; wait or cancel it first")
|
||||
if pcb_busy(meta):
|
||||
raise HTTPException(409, "PCB review already running or queued")
|
||||
if (meta.pcb_status or "draft") not in _PCB_START_OK:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot start PCB review from pcb_status={meta.pcb_status}",
|
||||
)
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_status="queued",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state=None,
|
||||
pcb_execution_name=None,
|
||||
)
|
||||
try:
|
||||
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
|
||||
except Exception:
|
||||
logger.exception("failed to clear events before PCB start for %s", project_id)
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pcb_pipeline(
|
||||
project_id, owner_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pcb_pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_state={"error": "Failed to enqueue PCB worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue PCB worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/pcb/cancel")
|
||||
async def cancel_pcb(project_id: str, request: Request):
|
||||
from backend.services.pcb_pipeline import pcb_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not pcb_busy(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"PCB review is not running (pcb_status={meta.pcb_status})",
|
||||
)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
pcb_cancel_requested=True,
|
||||
)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/pcb/inventory")
|
||||
async def get_pcb_inventory(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/pcb_inventory.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "PCB inventory not found — run PCB review first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/pcb/events")
|
||||
async def pcb_events(project_id: str, request: Request):
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.pcb_execution_name
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
saw_active = (meta.pcb_status or "draft") in ("queued", "running")
|
||||
while True:
|
||||
await asyncio.sleep(poll_interval)
|
||||
try:
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
except Exception:
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
pst = cur.pcb_status or "draft"
|
||||
if pst in ("queued", "running"):
|
||||
saw_active = True
|
||||
elif saw_active and pst in ("complete", "error", "cancelled"):
|
||||
crash_detected["reason"] = f"pcb_status={pst} (terminal)"
|
||||
return
|
||||
if execution_name:
|
||||
try:
|
||||
state = job_runner.get_execution_state(execution_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
if state in _EXEC_TERMINAL and (
|
||||
saw_active or pst in ("queued", "running")
|
||||
):
|
||||
crash_detected["reason"] = f"execution state={state}"
|
||||
return
|
||||
|
||||
watcher = asyncio.create_task(watch_status())
|
||||
try:
|
||||
async for msg in event_bridge.tail_events(
|
||||
storage, owner_user_id, project_id,
|
||||
terminal_events=_PCB_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
if not (ev.startswith("pcb_") or ev == "heartbeat"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if ev in _PCB_SSE_TERMINAL:
|
||||
return
|
||||
|
||||
if crash_detected["reason"] is not None:
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
from backend.services.pcb_pipeline import pcb_sse_terminal_from_status
|
||||
|
||||
ev, payload = pcb_sse_terminal_from_status(
|
||||
cur.pcb_status if cur else None,
|
||||
cur.pcb_state if cur else None,
|
||||
crash_detected["reason"],
|
||||
)
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(payload),
|
||||
}
|
||||
finally:
|
||||
watcher.cancel()
|
||||
try:
|
||||
await watcher
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
ping=15,
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -148,6 +148,9 @@ async def get_project(project_id: str, request: Request):
|
||||
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
|
||||
if healed_pl is not None:
|
||||
meta = healed_pl
|
||||
healed_pcb = proj_svc.heal_if_pcb_stuck(storage, owner_user_id, project_id)
|
||||
if healed_pcb is not None:
|
||||
meta = healed_pcb
|
||||
return meta.model_dump()
|
||||
|
||||
|
||||
@@ -903,7 +906,7 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
import asyncio
|
||||
|
||||
from backend.services.digikey import fetch_params
|
||||
from backend.services.extraction import CatalogResolveMiss, auto_resolve_specs
|
||||
from backend.services.datasheet_extract import CatalogResolveMiss, auto_resolve_specs
|
||||
|
||||
if not settings.use_digikey:
|
||||
raise HTTPException(400, "DigiKey API not configured")
|
||||
@@ -993,7 +996,7 @@ async def lcsc_resolve_passive(
|
||||
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.billing_hook import InsufficientCredits, get_billing
|
||||
from backend.services.extraction import auto_resolve_specs
|
||||
from backend.services.datasheet_extract import auto_resolve_specs
|
||||
|
||||
storage = get_storage(request)
|
||||
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -11,6 +12,13 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.finding_engine import (
|
||||
apply_decisions,
|
||||
complete_findings,
|
||||
decision_from_review,
|
||||
sort_findings,
|
||||
upsert_decision,
|
||||
)
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.periscopex.review_workflow import (
|
||||
ReviewError,
|
||||
@@ -24,6 +32,7 @@ from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
router = APIRouter(tags=["reports"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Allow alphanumeric, dash, underscore, dot, colon, forward-slash, plus, hash, space
|
||||
_SAFE_MPN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.:/ +#,()]*$")
|
||||
@@ -40,10 +49,34 @@ async def get_report(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
key = f"{prefix}/report.json"
|
||||
if not storage.exists(key):
|
||||
schema_key = f"{prefix}/report.json"
|
||||
pcb_key = f"{prefix}/pcb_report.json"
|
||||
schema = storage.read_json(schema_key) if storage.exists(schema_key) else None
|
||||
pcb = storage.read_json(pcb_key) if storage.exists(pcb_key) else None
|
||||
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
|
||||
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
if merged is None:
|
||||
raise HTTPException(404, "Report not found — run the pipeline first")
|
||||
return JSONResponse(storage.read_json(key))
|
||||
findings = _findings_from_report(merged)
|
||||
try:
|
||||
complete_findings(findings)
|
||||
except Exception:
|
||||
log.exception("complete_findings failed while serving report %s", project_id)
|
||||
sort_findings(findings)
|
||||
dec_key = f"{prefix}/decisions.json"
|
||||
if storage.exists(dec_key):
|
||||
try:
|
||||
apply_decisions(findings, storage.read_json(dec_key) or [])
|
||||
except Exception:
|
||||
pass
|
||||
merged["findings"] = [json.loads(f.model_dump_json()) for f in findings]
|
||||
summary = {"ERROR": 0, "WARNING": 0, "INFO": 0, "total": len(findings)}
|
||||
for f in findings:
|
||||
if f.status in summary:
|
||||
summary[f.status] += 1
|
||||
merged["summary"] = summary
|
||||
return JSONResponse(merged)
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/cad-bridge")
|
||||
@@ -132,7 +165,7 @@ def _findings_from_report(report_data: dict) -> list[Finding]:
|
||||
try:
|
||||
out.append(Finding.model_validate(raw))
|
||||
except Exception:
|
||||
continue
|
||||
log.warning("Skipping malformed finding in report", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
@@ -142,7 +175,17 @@ async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody,
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
user_id = get_user_id(request)
|
||||
key, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
findings = _findings_from_report(report_data)
|
||||
ids = {f.finding_id for f in findings if f.finding_id}
|
||||
if finding_id not in ids:
|
||||
pcb_key = f"{prefix}/pcb_report.json"
|
||||
if storage.exists(pcb_key):
|
||||
pcb_data = storage.read_json(pcb_key)
|
||||
pcb_findings = _findings_from_report(pcb_data)
|
||||
if finding_id in {f.finding_id for f in pcb_findings if f.finding_id}:
|
||||
key, report_data, findings = pcb_key, pcb_data, pcb_findings
|
||||
ids = {f.finding_id for f in findings if f.finding_id}
|
||||
if finding_id not in ids:
|
||||
raise HTTPException(404, "Finding not found")
|
||||
try:
|
||||
@@ -158,6 +201,22 @@ async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody,
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
report_data["review_states"] = states
|
||||
storage.write_json(key, report_data)
|
||||
if body.state in {"wontfix", "false_positive"}:
|
||||
found = next(
|
||||
(f for f in _findings_from_report(report_data) if f.finding_id == finding_id),
|
||||
None,
|
||||
)
|
||||
if found is not None:
|
||||
dec = decision_from_review(
|
||||
found, state=body.state, reason=body.reason, user_id=user_id,
|
||||
)
|
||||
if dec is not None:
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
dkey = f"{prefix}/decisions.json"
|
||||
existing = storage.read_json(dkey) if storage.exists(dkey) else []
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
storage.write_json(dkey, upsert_decision(existing, dec))
|
||||
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Async datasheet extraction using the configured LLM provider.
|
||||
"""Inherited PinScope datasheet extraction (fallback, not the live pipeline).
|
||||
|
||||
Live path since 2.40.0: ``backend.services.datasheet_extract``. This file
|
||||
stays in periscope/dependency/ — do not empty-delete it.
|
||||
|
||||
Ports the extraction steps from run_pipeline.py to async:
|
||||
- extract_pintable: Pin table + package info + taxonomy assignment
|
||||
- extract_pattern: Passive MPN pattern
|
||||
- extract_specs: Component specs (discrete, connectors, crystals, etc.)
|
||||
|
||||
Skills (SKILL.md + validate.py) run locally against DeepSeek. Do not use Anthropic Console Skills.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -137,10 +138,16 @@ PINTABLE_TOOL = {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"PCB layout constraints from typical-application / PCB layout pages. "
|
||||
"kind: decoupling_proximity | thermal_via | keepout | length_match. "
|
||||
"kind: decoupling_proximity | thermal_via | keepout | length_match | "
|
||||
"impedance | max_length | spacing | ref_plane | si_via | layer | "
|
||||
"series_resistor | return_path | si | emi | common_mode | shield. "
|
||||
"Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a "
|
||||
"number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, "
|
||||
"net_class, note, source_page. Empty array if the PDF has no layout guidance."
|
||||
"max_via_count, z0_ohm, zdiff_ohm, tolerance_pct, z_min_ohm, z_max_ohm, "
|
||||
"topology, min_spacing_mm, value_ohms, ref_plane, parameter, "
|
||||
"net_class (required for SI kinds: usb2 | usb3 | eth_mdi | rgmii | "
|
||||
"sgmii | ddr3 | hdmi | pcie | lvds — never map EN/CHIP_PU RC onto "
|
||||
"USB), note, source_page. Empty array if the PDF has no layout guidance."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -152,6 +159,18 @@ PINTABLE_TOOL = {
|
||||
"thermal_via",
|
||||
"keepout",
|
||||
"length_match",
|
||||
"impedance",
|
||||
"max_length",
|
||||
"spacing",
|
||||
"ref_plane",
|
||||
"si_via",
|
||||
"layer",
|
||||
"series_resistor",
|
||||
"return_path",
|
||||
"si",
|
||||
"emi",
|
||||
"common_mode",
|
||||
"shield",
|
||||
],
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
@@ -159,9 +178,20 @@ PINTABLE_TOOL = {
|
||||
"max_distance_mm": {"type": ["number", "null"]},
|
||||
"same_layer": {"type": ["boolean", "null"]},
|
||||
"min_via_count": {"type": ["integer", "null"]},
|
||||
"max_via_count": {"type": ["integer", "null"]},
|
||||
"net_class": {"type": ["string", "null"]},
|
||||
"note": {"type": ["string", "null"]},
|
||||
"source_page": {"type": ["integer", "null"]},
|
||||
"z0_ohm": {"type": ["number", "null"]},
|
||||
"zdiff_ohm": {"type": ["number", "null"]},
|
||||
"tolerance_pct": {"type": ["number", "null"]},
|
||||
"z_min_ohm": {"type": ["number", "null"]},
|
||||
"z_max_ohm": {"type": ["number", "null"]},
|
||||
"topology": {"type": ["string", "null"]},
|
||||
"min_spacing_mm": {"type": ["number", "null"]},
|
||||
"value_ohms": {"type": ["number", "null"]},
|
||||
"ref_plane": {"type": ["string", "null"]},
|
||||
"parameter": {"type": ["string", "null"]},
|
||||
},
|
||||
"required": ["kind"],
|
||||
},
|
||||
@@ -350,6 +350,9 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
|
||||
if execution_name.startswith("local/placement/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(f"placement:{project_id}")
|
||||
if execution_name.startswith("local/pcb/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(f"pcb:{project_id}")
|
||||
return _cloud_run_state(execution_name)
|
||||
|
||||
|
||||
@@ -369,6 +372,10 @@ def cancel_execution(execution_name: str | None) -> None:
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(f"placement:{project_id}")
|
||||
return
|
||||
if execution_name.startswith("local/pcb/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(f"pcb:{project_id}")
|
||||
return
|
||||
_cloud_run_cancel(execution_name)
|
||||
|
||||
|
||||
@@ -383,3 +390,16 @@ def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
|
||||
proc_key=f"placement:{project_id}",
|
||||
execution_name=f"local/placement/{project_id}",
|
||||
)
|
||||
|
||||
|
||||
def enqueue_pcb_pipeline(project_id: str, user_id: str) -> str:
|
||||
"""Dispatch the parallel PCB review pipeline (deterministic + AI exam)."""
|
||||
if use_cloud_run_jobs():
|
||||
return _enqueue_cloud_run_job(
|
||||
project_id, user_id, resume=False, free=False, mode="pcb",
|
||||
)
|
||||
return _spawn_local_subprocess(
|
||||
project_id, user_id, resume=False, free=False, mode="pcb",
|
||||
proc_key=f"pcb:{project_id}",
|
||||
execution_name=f"local/pcb/{project_id}",
|
||||
)
|
||||
@@ -5,6 +5,9 @@ All model calls in the backend route through this package via the
|
||||
overrides via ``Settings.provider_*`` env vars route specific stages to
|
||||
Anthropic or Gemini if those keys are configured.
|
||||
"""
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
from backend.services.llm.factory import call_with_fallback, get_provider
|
||||
from backend.services.llm.types import (
|
||||
@@ -47,7 +47,7 @@ from backend.config import settings
|
||||
from backend.services import admin_settings as settings_svc
|
||||
from backend.services.billing_hook import InsufficientCredits, get_billing
|
||||
from backend.services.datasheet_store import compute_md5_from_path, store_datasheet, store_datasheet_bytes
|
||||
from backend.services import extraction, projects as proj_svc
|
||||
from backend.services import datasheet_extract as extraction, projects as proj_svc
|
||||
from backend.services.api_logs import ApiLogger, total_cost
|
||||
from backend.services.cost_estimator import estimate_stage_cost_usd
|
||||
from backend.services.storage import StorageBackend
|
||||
@@ -95,58 +95,20 @@ def _git_commit() -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE Event Broker
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE Event Broker + workspace live in periscope/src job_workspace.py.
|
||||
# Re-export so analysis run_pipeline and pipeline_worker.set_broker stay
|
||||
# on one singleton. PinScope pipeline.py is not empty-deleted.
|
||||
from backend.services import job_workspace as _job_ws
|
||||
|
||||
class EventBroker:
|
||||
"""In-memory pub/sub for SSE events, keyed by project_id.
|
||||
|
||||
Buffers all events per project so late subscribers (e.g. after a page
|
||||
navigation) receive the full history before seeing live events.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._queues: dict[str, list[asyncio.Queue]] = {}
|
||||
self._history: dict[str, list[dict]] = {}
|
||||
|
||||
def subscribe(self, project_id: str) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
# Replay buffered events so the subscriber catches up
|
||||
for msg in self._history.get(project_id, []):
|
||||
q.put_nowait(msg)
|
||||
self._queues.setdefault(project_id, []).append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, project_id: str, q: asyncio.Queue) -> None:
|
||||
qs = self._queues.get(project_id, [])
|
||||
if q in qs:
|
||||
qs.remove(q)
|
||||
if not qs:
|
||||
self._queues.pop(project_id, None)
|
||||
|
||||
def clear_history(self, project_id: str) -> None:
|
||||
self._history.pop(project_id, None)
|
||||
|
||||
def publish(self, project_id: str, event: str, data: dict) -> None:
|
||||
msg = {"event": event, "data": data}
|
||||
self._history.setdefault(project_id, []).append(msg)
|
||||
for q in self._queues.get(project_id, []):
|
||||
q.put_nowait(msg)
|
||||
EventBroker = _job_ws.EventBroker
|
||||
PipelineWorkspace = _job_ws.PipelineWorkspace
|
||||
broker = _job_ws.broker
|
||||
|
||||
|
||||
broker: EventBroker = EventBroker()
|
||||
|
||||
|
||||
def set_broker(b: EventBroker) -> None:
|
||||
"""Replace the module-level broker.
|
||||
|
||||
Called by :mod:`backend.pipeline_worker` at startup to swap in the
|
||||
GCS-backed broker so events written from the worker are visible to
|
||||
the API's SSE handler. Must be called *before* :func:`run_pipeline`
|
||||
or :func:`run_regen_pipeline`.
|
||||
"""
|
||||
global broker
|
||||
broker = b
|
||||
def set_broker(b) -> None:
|
||||
"""Swap the worker event broker (GCS in prod). Updates native + this module."""
|
||||
_job_ws.set_broker(b)
|
||||
globals()["broker"] = b
|
||||
|
||||
|
||||
# Per-process cancel-flag cache: re-reading the project meta from GCS on
|
||||
@@ -189,158 +151,6 @@ def _cancel_gate_check(ctx: PipelineContext) -> None:
|
||||
raise CancelRequested(f"cancel requested for {ctx.project_id}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline Workspace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PipelineWorkspace:
|
||||
"""Downloads project files from storage to a temp dir for pipeline execution.
|
||||
|
||||
The periscopex core library operates on local paths. This context manager
|
||||
downloads inputs at enter, provides local paths, and uploads results at exit.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StorageBackend,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
) -> None:
|
||||
self.storage = storage
|
||||
self.user_id = user_id
|
||||
self.project_id = project_id
|
||||
self.prefix = proj_svc.project_prefix(user_id, project_id)
|
||||
self._tmpdir: tempfile.TemporaryDirectory | None = None
|
||||
self.local_dir: Path = Path()
|
||||
|
||||
async def __aenter__(self) -> PipelineWorkspace:
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self.local_dir = Path(self._tmpdir.name)
|
||||
|
||||
# Create subdirectories
|
||||
(self.local_dir / "uploads" / "datasheets").mkdir(parents=True)
|
||||
(self.local_dir / "extracted").mkdir(parents=True)
|
||||
(self.local_dir / "patterns").mkdir(parents=True)
|
||||
(self.local_dir / "models").mkdir(parents=True)
|
||||
(self.local_dir / "taxonomy").mkdir(parents=True)
|
||||
|
||||
# Download project files from storage
|
||||
all_keys = self.storage.list_recursive(self.prefix)
|
||||
for key in all_keys:
|
||||
# key is like users/{uid}/projects/{pid}/uploads/bom.csv
|
||||
# We want the relative part after the project prefix
|
||||
rel = key[len(self.prefix) + 1:] # strip prefix + trailing /
|
||||
local_path = self.local_dir / rel
|
||||
self.storage.download_to_local(key, local_path)
|
||||
|
||||
# Download taxonomy files from storage
|
||||
taxonomy_keys = self.storage.list_prefix("taxonomy/")
|
||||
for key in taxonomy_keys:
|
||||
if key.endswith(".json"):
|
||||
filename = key.rsplit("/", 1)[-1]
|
||||
self.storage.download_to_local(key, self.local_dir / "taxonomy" / filename)
|
||||
|
||||
# Seed from repo taxonomy if storage had no taxonomy files yet
|
||||
local_tax = self.local_dir / "taxonomy"
|
||||
if not any(local_tax.glob("*.json")):
|
||||
repo_tax = settings.taxonomy_dir
|
||||
if repo_tax.is_dir():
|
||||
for f in repo_tax.glob("*.json"):
|
||||
shutil.copy2(f, local_tax / f.name)
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if exc_type is None:
|
||||
# Upload outputs back to storage
|
||||
self._upload_dir("extracted")
|
||||
self._upload_dir("patterns")
|
||||
self._upload_dir("models")
|
||||
self._upload_file("design_graph.json")
|
||||
self._upload_file("layout_graph.json")
|
||||
self._upload_file("impedance_nets.json")
|
||||
self._upload_file("functional_groups.json")
|
||||
self._upload_file("placement_plan.json")
|
||||
self._upload_file("bom_summary.json")
|
||||
self._upload_file("derating.json")
|
||||
self._upload_file("report.json")
|
||||
self._upload_file("periscope-findings.json")
|
||||
self._upload_file("review_fingerprints.json")
|
||||
self._upload_file("api_logs.jsonl")
|
||||
|
||||
# Merge taxonomy: read current from storage, add any new entries
|
||||
# from this run, write back. This avoids clobbering subtypes
|
||||
# that a concurrent pipeline added while we were running.
|
||||
tax_dir = self.local_dir / "taxonomy"
|
||||
if tax_dir.is_dir():
|
||||
for f in tax_dir.iterdir():
|
||||
if f.is_file() and f.suffix == ".json":
|
||||
local_data = json.loads(f.read_text())
|
||||
local_subtypes = local_data.get("subtypes", {})
|
||||
storage_key = f"taxonomy/{f.name}"
|
||||
|
||||
if self.storage.exists(storage_key):
|
||||
current = self.storage.read_json(storage_key)
|
||||
merged = current.get("subtypes", {})
|
||||
for key, entry in local_subtypes.items():
|
||||
if key not in merged:
|
||||
merged[key] = entry
|
||||
else:
|
||||
# Backfill fields the local run generated
|
||||
# (e.g. specs_schema) that the
|
||||
# storage copy is missing.
|
||||
for field, value in entry.items():
|
||||
if field not in merged[key]:
|
||||
merged[key][field] = value
|
||||
current["subtypes"] = merged
|
||||
self.storage.write_json(storage_key, current)
|
||||
else:
|
||||
self.storage.write_json(storage_key, local_data)
|
||||
|
||||
if self._tmpdir:
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def _upload_dir(self, subdir: str) -> None:
|
||||
"""Upload all files in a subdirectory back to storage."""
|
||||
local = self.local_dir / subdir
|
||||
if not local.is_dir():
|
||||
return
|
||||
for f in local.rglob("*"):
|
||||
if f.is_file():
|
||||
rel = f.relative_to(self.local_dir)
|
||||
key = f"{self.prefix}/{rel}"
|
||||
self.storage.upload_from_local(f, key)
|
||||
|
||||
def _upload_file(self, name: str) -> None:
|
||||
"""Upload a single file back to storage if it exists."""
|
||||
local = self.local_dir / name
|
||||
if local.is_file():
|
||||
self.storage.upload_from_local(local, f"{self.prefix}/{name}")
|
||||
|
||||
def local_path(self, rel: str) -> Path:
|
||||
"""Get a local path within the workspace."""
|
||||
return self.local_dir / rel
|
||||
|
||||
def netlist_local_path(self) -> Path:
|
||||
"""Local path of whichever netlist file was synced (``.asc`` or ``.edn``).
|
||||
|
||||
Pipeline workspace mirrors the entire project prefix, so whichever
|
||||
format the user uploaded lands locally with its original extension.
|
||||
Falls back to ``uploads/netlist.asc`` if neither exists — downstream
|
||||
code will raise a clearer error when it tries to read the missing
|
||||
file than a ``None`` return would.
|
||||
"""
|
||||
for ext in ("asc", "edn", "xml", "kicad_net", "kicad_sch"):
|
||||
p = self.local_dir / "uploads" / f"netlist.{ext}"
|
||||
if p.exists():
|
||||
return p
|
||||
return self.local_dir / "uploads" / "netlist.asc"
|
||||
|
||||
@property
|
||||
def taxonomy_dir(self) -> Path:
|
||||
return self.local_dir / "taxonomy"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline context — shared state threaded through all stage functions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -122,7 +122,7 @@ class ProjectMeta(BaseModel):
|
||||
|
||||
# Periscope app version that generated the project's report.
|
||||
# Stamped on the first /start transition and preserved thereafter.
|
||||
# Accept legacy pinscope_version from project.json written before the rebrand.
|
||||
# Dual-read pinscope_version (PinScope fence); serialization uses periscope_version.
|
||||
periscope_version: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("periscope_version", "pinscope_version"),
|
||||
@@ -143,6 +143,12 @@ class ProjectMeta(BaseModel):
|
||||
placement_execution_name: str | None = None
|
||||
placement_cancel_requested: bool = False
|
||||
|
||||
# PCB review pipeline (parallel exam — does not overwrite analysis status).
|
||||
pcb_status: str = "draft"
|
||||
pcb_state: dict[str, Any] | None = None
|
||||
pcb_execution_name: str | None = None
|
||||
pcb_cancel_requested: bool = False
|
||||
|
||||
|
||||
def completed_review_refs_for_retry(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
@@ -195,10 +201,24 @@ def _read_meta_with_generation(
|
||||
return ProjectMeta.model_validate(data), gen
|
||||
|
||||
|
||||
def _write_meta(storage: StorageBackend, meta: ProjectMeta) -> None:
|
||||
def _write_meta(
|
||||
storage: StorageBackend,
|
||||
meta: ProjectMeta,
|
||||
*,
|
||||
owner_user_id: str | None = None,
|
||||
) -> None:
|
||||
"""Persist meta under ``users/{owner}/projects/{id}/``.
|
||||
|
||||
``meta.user_id`` can lag the storage prefix (local-auth accounts that
|
||||
still have ``user_id: "local"`` in JSON while files live under
|
||||
``users/usr_…/``). Writes must follow the prefix used to *read* the
|
||||
project, not the stale field — otherwise ``update_project(pcb_status=…)``
|
||||
lands in a different tree and the PCB worker still sees ``draft``.
|
||||
"""
|
||||
uid = owner_user_id or meta.user_id
|
||||
meta.updated = datetime.now(timezone.utc).isoformat()
|
||||
storage.write_json(
|
||||
_meta_key(meta.user_id, meta.id),
|
||||
_meta_key(uid, meta.id),
|
||||
meta.model_dump(),
|
||||
)
|
||||
|
||||
@@ -288,46 +308,68 @@ def mark_stale_running(
|
||||
def heal_if_pipeline_finished(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""If meta says queued/running but events already ended with
|
||||
``pipeline_complete``, flip status to ``complete``.
|
||||
"""Unstick analysis ``queued``/``running`` when the worker is gone.
|
||||
|
||||
Covers zombies where the worker wrote the terminal event (and often
|
||||
the report) then died before the meta transition — e.g. container
|
||||
rebuild mid-shutdown. Returns updated meta, or ``None`` if no heal.
|
||||
- Last event ``pipeline_complete`` → ``complete``
|
||||
- Dead worker + ``report.json`` → ``complete``
|
||||
- Dead worker otherwise → ``error`` (so PCB/placement can start)
|
||||
"""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if meta is None or meta.status not in (STATUS_RUNNING, STATUS_QUEUED):
|
||||
return None
|
||||
|
||||
events_prefix = f"{_project_prefix(user_id, project_id)}/events/"
|
||||
prefix = _project_prefix(user_id, project_id)
|
||||
events_prefix = f"{prefix}/events/"
|
||||
last = None
|
||||
try:
|
||||
keys = storage.list_prefix(events_prefix)
|
||||
event_keys = sorted(
|
||||
k for k in storage.list_prefix(events_prefix)
|
||||
if k.endswith(".json") and "/events/" in k
|
||||
)
|
||||
if event_keys:
|
||||
last = storage.read_json(event_keys[-1])
|
||||
except Exception:
|
||||
return None
|
||||
event_keys = sorted(
|
||||
k for k in keys if k.endswith(".json") and "/events/" in k
|
||||
)
|
||||
if not event_keys:
|
||||
return None
|
||||
last = None
|
||||
|
||||
if (last or {}).get("event") == "pipeline_complete":
|
||||
summary = (last.get("data") or {}).get("summary")
|
||||
try:
|
||||
return transition_status(
|
||||
storage, user_id, project_id,
|
||||
from_status={STATUS_RUNNING, STATUS_QUEUED},
|
||||
to_status=STATUS_COMPLETE,
|
||||
summary=summary if isinstance(summary, dict) else meta.summary,
|
||||
cancel_requested=False,
|
||||
pipeline_state=None,
|
||||
)
|
||||
except StatusConflict:
|
||||
return None
|
||||
|
||||
from backend.services import job_runner
|
||||
|
||||
exec_name = meta.execution_name or f"local/projects/{project_id}"
|
||||
try:
|
||||
last = storage.read_json(event_keys[-1])
|
||||
state = job_runner.get_execution_state(exec_name)
|
||||
except Exception:
|
||||
return None
|
||||
if (last or {}).get("event") != "pipeline_complete":
|
||||
state = "unknown"
|
||||
if state in ("pending", "running"):
|
||||
return None
|
||||
|
||||
summary = (last.get("data") or {}).get("summary")
|
||||
try:
|
||||
return transition_status(
|
||||
storage, user_id, project_id,
|
||||
from_status={STATUS_RUNNING, STATUS_QUEUED},
|
||||
to_status=STATUS_COMPLETE,
|
||||
summary=summary if isinstance(summary, dict) else meta.summary,
|
||||
cancel_requested=False,
|
||||
pipeline_state=None,
|
||||
)
|
||||
except StatusConflict:
|
||||
return None
|
||||
if storage.exists(f"{prefix}/report.json"):
|
||||
try:
|
||||
return transition_status(
|
||||
storage, user_id, project_id,
|
||||
from_status={STATUS_RUNNING, STATUS_QUEUED},
|
||||
to_status=STATUS_COMPLETE,
|
||||
cancel_requested=False,
|
||||
pipeline_state=None,
|
||||
)
|
||||
except StatusConflict:
|
||||
return None
|
||||
return mark_stale_running(
|
||||
storage, user_id, project_id,
|
||||
f"Analysis worker terminated ({state})",
|
||||
)
|
||||
|
||||
|
||||
def heal_if_placement_stuck(
|
||||
@@ -401,6 +443,70 @@ def heal_if_placement_stuck(
|
||||
)
|
||||
|
||||
|
||||
def heal_if_pcb_stuck(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""Unstick pcb_status queued/running when the worker is gone."""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if meta is None:
|
||||
return None
|
||||
pst = meta.pcb_status or "draft"
|
||||
if pst not in ("queued", "running"):
|
||||
return None
|
||||
|
||||
prefix = _project_prefix(user_id, project_id)
|
||||
events_prefix = f"{prefix}/events/"
|
||||
last_event = None
|
||||
try:
|
||||
keys = sorted(
|
||||
k for k in storage.list_prefix(events_prefix)
|
||||
if k.endswith(".json") and "/events/" in k
|
||||
)
|
||||
if keys:
|
||||
last_event = storage.read_json(keys[-1])
|
||||
except Exception:
|
||||
last_event = None
|
||||
|
||||
if (last_event or {}).get("event") == "pcb_complete":
|
||||
data = (last_event or {}).get("data") or {}
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="complete",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state={
|
||||
"findings": data.get("findings"),
|
||||
"domains": data.get("domains"),
|
||||
"groups": data.get("groups"),
|
||||
},
|
||||
)
|
||||
|
||||
from backend.services import job_runner
|
||||
|
||||
exec_name = meta.pcb_execution_name or f"local/pcb/{project_id}"
|
||||
try:
|
||||
state = job_runner.get_execution_state(exec_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
|
||||
if state in ("pending", "running"):
|
||||
return None
|
||||
|
||||
has_report = storage.exists(f"{prefix}/pcb_report.json")
|
||||
if has_report:
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="complete",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state=meta.pcb_state,
|
||||
)
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
pcb_status="error",
|
||||
pcb_cancel_requested=False,
|
||||
pcb_state={"error": f"PCB worker terminated ({state})"},
|
||||
)
|
||||
|
||||
|
||||
# --- CRUD ---
|
||||
|
||||
|
||||
@@ -446,7 +552,7 @@ def update_project(
|
||||
meta = _read_meta(storage, user_id, project_id)
|
||||
for k, v in fields.items():
|
||||
setattr(meta, k, v)
|
||||
_write_meta(storage, meta)
|
||||
_write_meta(storage, meta, owner_user_id=user_id)
|
||||
return meta
|
||||
|
||||
|
||||
@@ -621,7 +727,7 @@ def add_collaborator(
|
||||
meta = _read_meta(storage, owner_user_id, project_id)
|
||||
if collaborator_user_id not in meta.collaborators:
|
||||
meta.collaborators.append(collaborator_user_id)
|
||||
_write_meta(storage, meta)
|
||||
_write_meta(storage, meta, owner_user_id=owner_user_id)
|
||||
# Write reverse reference for the collaborator
|
||||
ref_key = _shared_ref_key(collaborator_user_id, project_id)
|
||||
storage.write_json(ref_key, {"owner_user_id": owner_user_id})
|
||||
@@ -634,7 +740,7 @@ def remove_collaborator(
|
||||
"""Remove a collaborator from a project and delete the shared reference."""
|
||||
meta = _read_meta(storage, owner_user_id, project_id)
|
||||
meta.collaborators = [c for c in meta.collaborators if c != collaborator_user_id]
|
||||
_write_meta(storage, meta)
|
||||
_write_meta(storage, meta, owner_user_id=owner_user_id)
|
||||
# Delete reverse reference
|
||||
ref_key = _shared_ref_key(collaborator_user_id, project_id)
|
||||
if storage.exists(ref_key):
|
||||
@@ -21,12 +21,12 @@ from typing import Awaitable, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from backend.periscopex.finding_engine import apply_decisions
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
NetType,
|
||||
ValidationReport,
|
||||
)
|
||||
@@ -61,12 +61,16 @@ from backend.periscopex.dnp_check import check_dnp_enables
|
||||
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
||||
from backend.periscopex.errata_check import check_errata
|
||||
from backend.periscopex.internal_features_check import check_internal_features
|
||||
from backend.periscopex.placement_check import check_placement
|
||||
from backend.periscopex.si_check import check_si
|
||||
from backend.periscopex.crystal_cl_check import check_crystal_cl
|
||||
from backend.periscopex.nc_pin_check import check_nc_pins
|
||||
|
||||
TRACE_VERSION = 1
|
||||
from backend.services.review_session import (
|
||||
TRACE_VERSION,
|
||||
review_ic_async,
|
||||
_signal_neighbors,
|
||||
_select_review_pages,
|
||||
_assistant_text,
|
||||
)
|
||||
|
||||
|
||||
def _is_deterministic(f: Finding) -> bool:
|
||||
@@ -77,7 +81,6 @@ def _is_deterministic(f: Finding) -> bool:
|
||||
def _run_deterministic_checks(
|
||||
graph: DesignGraph, constraints_map: dict,
|
||||
lifecycle_map: dict | None = None,
|
||||
layout: LayoutGraph | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
||||
can never break the review or the report."""
|
||||
@@ -100,8 +103,6 @@ def _run_deterministic_checks(
|
||||
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
|
||||
("errata_check", lambda: check_errata(graph, constraints_map)),
|
||||
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
|
||||
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
||||
("si_check", lambda: check_si(graph, constraints_map, layout)),
|
||||
("crystal_cl_check", lambda: check_crystal_cl(graph)),
|
||||
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
|
||||
):
|
||||
@@ -112,17 +113,6 @@ def _run_deterministic_checks(
|
||||
return out
|
||||
|
||||
|
||||
def _load_layout_graph(graph_path: str) -> LayoutGraph | None:
|
||||
path = Path(graph_path).with_name("layout_graph.json")
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return LayoutGraph.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
log.exception("layout_graph.json invalid — skipping placement_check")
|
||||
return None
|
||||
|
||||
|
||||
def _assistant_text(blocks) -> str:
|
||||
"""Best-effort extraction of text content from a completion's raw
|
||||
assistant blocks. Provider-agnostic and never raises."""
|
||||
@@ -301,35 +291,40 @@ def _select_review_pages(pdf_path: str) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def review_ic_async(
|
||||
# PinScope loop kept for rollback if live native smoke fails. Live path is
|
||||
# review_ic_async imported from backend.services.review_session.
|
||||
async def _inherited_review_ic_async(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
ic_ref: str,
|
||||
pdf_path: str,
|
||||
pdf_path: str | None,
|
||||
on_progress: ProgressCallback | None = None,
|
||||
api_logger: ApiLogger | None = None,
|
||||
trace_git_commit: str = "unknown",
|
||||
pdf_dir: Path | None = None,
|
||||
storage=None,
|
||||
excerpt_cache: dict | None = None,
|
||||
extra_context: str = "",
|
||||
system_prompt: str | None = None,
|
||||
log_stage: str = "review",
|
||||
) -> tuple[ReviewResult, dict]:
|
||||
"""Review one IC against its datasheet. Async, multi-turn.
|
||||
|
||||
Returns ``(ReviewResult, trace)`` — ``trace`` is a transcript dict of the
|
||||
full agentic loop (turns, tool calls + outputs, final submission) for
|
||||
offline inspection. Trace assembly is best-effort and never affects the
|
||||
review result.
|
||||
``pdf_path`` may be ``None`` when the caller already has library
|
||||
extraction (PCB layout-only exam) — no PDF is attached and citations
|
||||
are not re-verified against a PDF.
|
||||
"""
|
||||
comp = graph.components[ic_ref]
|
||||
mpn = comp.mpn or comp.value
|
||||
|
||||
# Datasheet identity for the trace — hash the original PDF, not the
|
||||
# trimmed copy, so the reference is stable across trim-heuristic changes.
|
||||
try:
|
||||
ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
|
||||
except Exception:
|
||||
log.exception("trace: datasheet md5 failed for %s", ic_ref)
|
||||
ds_md5 = None
|
||||
ds_md5 = None
|
||||
if pdf_path:
|
||||
try:
|
||||
ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
|
||||
except Exception:
|
||||
log.exception("trace: datasheet md5 failed for %s", ic_ref)
|
||||
|
||||
# Pre-compute which designators the excerpt tool will accept for this
|
||||
# review (neighbors via signal nets only — power/GND fan-out filtered).
|
||||
@@ -352,7 +347,7 @@ async def review_ic_async(
|
||||
current_ic=ic_ref,
|
||||
connected_designators=connected_designators,
|
||||
graph=graph,
|
||||
pdf_dir=pdf_dir or Path(pdf_path).parent,
|
||||
pdf_dir=pdf_dir or (Path(pdf_path).parent if pdf_path else Path(".")),
|
||||
storage=storage,
|
||||
cache=excerpt_cache if excerpt_cache is not None else {},
|
||||
fetch_budget=_PER_REVIEW_FETCH_BUDGET,
|
||||
@@ -361,7 +356,7 @@ async def review_ic_async(
|
||||
)
|
||||
|
||||
# Trim PDF up-front — both primary and fallback attempts share it.
|
||||
trimmed_pdf = _select_review_pages(pdf_path)
|
||||
trimmed_pdf = _select_review_pages(pdf_path) if pdf_path else None
|
||||
try:
|
||||
async def _run(provider, model) -> tuple[ReviewResult, dict]:
|
||||
t0 = time.monotonic()
|
||||
@@ -373,7 +368,7 @@ async def review_ic_async(
|
||||
|
||||
session = await provider.create_session(
|
||||
model=model,
|
||||
system=SYSTEM_PROMPT,
|
||||
system=system_prompt or SYSTEM_PROMPT,
|
||||
# Gemini 2.5/3 thinking models count thoughts against this cap.
|
||||
# 4096 was too tight: U3 (largest IC) burned the entire budget
|
||||
# on thinking and emitted zero visible output, dropping its
|
||||
@@ -387,16 +382,17 @@ async def review_ic_async(
|
||||
)
|
||||
try:
|
||||
context = build_component_context(graph, constraints_map, ic_ref)
|
||||
user_text = f"Review this component's usage:\n\n{context}"
|
||||
if extra_context.strip():
|
||||
user_text += "\n\n" + extra_context.strip()
|
||||
|
||||
user_blocks: list = []
|
||||
if trimmed_pdf:
|
||||
user_blocks.append(PdfBlock(path=Path(trimmed_pdf), cacheable=True))
|
||||
user_blocks.append(TextBlock(text=user_text, cacheable=True))
|
||||
initial_msg = Message(
|
||||
role="user",
|
||||
content=[
|
||||
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
|
||||
TextBlock(
|
||||
text=f"Review this component's usage:\n\n{context}",
|
||||
cacheable=True,
|
||||
),
|
||||
],
|
||||
content=user_blocks,
|
||||
)
|
||||
messages: list[Message] = [initial_msg]
|
||||
|
||||
@@ -476,13 +472,14 @@ async def review_ic_async(
|
||||
mpn_by_designator=mpn_by_designator,
|
||||
connected=connected_designators,
|
||||
)
|
||||
verify_finding_citations(
|
||||
result.findings,
|
||||
default_pdf=Path(pdf_path),
|
||||
default_mpn=mpn,
|
||||
pdf_dir=excerpt_state.pdf_dir,
|
||||
mpn_by_designator=mpn_by_designator,
|
||||
)
|
||||
if pdf_path:
|
||||
verify_finding_citations(
|
||||
result.findings,
|
||||
default_pdf=Path(pdf_path),
|
||||
default_mpn=mpn,
|
||||
pdf_dir=excerpt_state.pdf_dir,
|
||||
mpn_by_designator=mpn_by_designator,
|
||||
)
|
||||
turn_record["tool_calls"].append({
|
||||
"name": "submit_review",
|
||||
"input": tc.input,
|
||||
@@ -503,7 +500,7 @@ async def review_ic_async(
|
||||
)
|
||||
if api_logger:
|
||||
api_logger.log(
|
||||
stage="review", identifier=ic_ref,
|
||||
stage=log_stage, identifier=ic_ref,
|
||||
model=model, provider=provider.name,
|
||||
input_tokens=total_input, output_tokens=total_output,
|
||||
cache_creation_input_tokens=total_cache_creation,
|
||||
@@ -607,7 +604,7 @@ async def review_ic_async(
|
||||
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
|
||||
if api_logger:
|
||||
api_logger.log(
|
||||
stage="review", identifier=ic_ref,
|
||||
stage=log_stage, identifier=ic_ref,
|
||||
model=model, provider=provider.name,
|
||||
input_tokens=total_input, output_tokens=total_output,
|
||||
cache_creation_input_tokens=total_cache_creation,
|
||||
@@ -621,7 +618,7 @@ async def review_ic_async(
|
||||
|
||||
return await call_with_fallback("validation", _run)
|
||||
finally:
|
||||
if trimmed_pdf != pdf_path:
|
||||
if trimmed_pdf and pdf_path and trimmed_pdf != pdf_path:
|
||||
Path(trimmed_pdf).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@@ -730,7 +727,7 @@ async def validate_design_async(
|
||||
if loaded:
|
||||
lifecycle_map.update(loaded)
|
||||
deterministic_findings = _run_deterministic_checks(
|
||||
graph, constraints_map, lifecycle_map, layout=_load_layout_graph(graph_path),
|
||||
graph, constraints_map, lifecycle_map,
|
||||
)
|
||||
|
||||
pdf_dir_path = Path(pdf_dir)
|
||||
@@ -801,6 +798,12 @@ async def validate_design_async(
|
||||
def _write_report(paused: bool = False) -> ValidationReport:
|
||||
annotate_findings_cad(all_findings, graph.cad_index)
|
||||
assign_finding_ids(all_findings)
|
||||
dec_path = existing_path.with_name("decisions.json")
|
||||
if dec_path.is_file():
|
||||
try:
|
||||
apply_decisions(all_findings, json.loads(dec_path.read_text()))
|
||||
except Exception:
|
||||
log.exception("decisions.json apply failed")
|
||||
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||
for f in all_findings:
|
||||
summary[f.status] = summary.get(f.status, 0) + 1
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"default_model_version": "1.10.0",
|
||||
"default_model_version": "1.13.0",
|
||||
"extract-pintable": {
|
||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||
"latest_version": "1784798970179642",
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |