Compare commits
211
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32d581d27 | ||
|
|
d5fff7c6dd | ||
|
|
29e1a3033d | ||
|
|
bb83f63114 | ||
|
|
3c0b3e14dc | ||
|
|
1455e974ac | ||
|
|
25aa608052 | ||
|
|
d32af5627a | ||
|
|
8a93ffc1c5 | ||
|
|
421f659054 | ||
|
|
c4cd046ea9 | ||
|
|
f3be485b75 | ||
|
|
949f5f1541 | ||
|
|
7baffadd6b | ||
|
|
866c2e9242 | ||
|
|
14272d7446 | ||
|
|
24c4beecc9 | ||
|
|
0848eb8416 | ||
|
|
10d21cdc80 | ||
|
|
3cc2e5469b | ||
|
|
fc6524a2a9 | ||
|
|
36ad979612 | ||
|
|
c87adf11a9 | ||
|
|
8b4f7710cf | ||
|
|
8f434ffd31 | ||
|
|
cd7a7ca3a6 | ||
|
|
6b751522b7 | ||
|
|
6d9d21a023 | ||
|
|
4df04df5d4 | ||
|
|
20733f0ec6 | ||
|
|
69d5dd9313 | ||
|
|
309644e654 | ||
|
|
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 | ||
|
|
dc364f5926 | ||
|
|
8d2b85600f | ||
|
|
556306bf4d | ||
|
|
1d3cf12482 | ||
|
|
68af0fd432 | ||
|
|
d47ad91cee | ||
|
|
21e2be1cd0 | ||
|
|
86121e6547 | ||
|
|
54b0b07917 | ||
|
|
f5f21782d2 | ||
|
|
fc11df59fc | ||
|
|
e124ac2de7 | ||
|
|
4882c9206a | ||
|
|
d947128951 | ||
|
|
4c4b604cca | ||
|
|
2950446e12 | ||
|
|
30aaf55de6 | ||
|
|
2405093bbb | ||
|
|
5a69b380da | ||
|
|
c5b260b404 | ||
|
|
a1a2b2a944 | ||
|
|
aebe8f6cc3 | ||
|
|
14fd3ba890 | ||
|
|
b768cc95b1 | ||
|
|
a56dda2358 | ||
|
|
b3a47f0bec | ||
|
|
3f6082ae6d | ||
|
|
796cd9d8a2 | ||
|
|
412c32e9b2 | ||
|
|
ba2a5c1b29 | ||
|
|
0fafbbbb06 | ||
|
|
45e820fabc | ||
|
|
be0fed7979 | ||
|
|
de15402309 | ||
|
|
6fc2ac583d | ||
|
|
3e43bb6ecb | ||
|
|
7aed64cae5 | ||
|
|
aa375349bd | ||
|
|
78013bd404 | ||
|
|
a45a06e761 | ||
|
|
4403c38d96 | ||
|
|
4fca789517 | ||
|
|
e4fd6c3849 | ||
|
|
d31c04ba86 | ||
|
|
face8ec38d | ||
|
|
61f85f519b | ||
|
|
d454cf75af | ||
|
|
edbb47a08b | ||
|
|
2113e3975e | ||
|
|
7a255a9807 | ||
|
|
b1ee445da8 | ||
|
|
d6b8b0086c | ||
|
|
6bde06d2cc | ||
|
|
53db287ab4 | ||
|
|
4c54d064d3 | ||
|
|
cb001a8bbb | ||
|
|
96e2590a3c | ||
|
|
ea43b550ee | ||
|
|
f6eeb73cbf | ||
|
|
66ae877b7b | ||
|
|
a9238875dd | ||
|
|
e5e8c42966 | ||
|
|
90d8d3ed21 | ||
|
|
02d3cfdad7 | ||
|
|
ed75aabaf0 | ||
|
|
e84418f975 | ||
|
|
34bfe33cdf | ||
|
|
08cbbdc422 | ||
|
|
3be7d2fc21 | ||
|
|
19b4c11f62 | ||
|
|
dd46ce1da1 | ||
|
|
27a3ce8bd0 | ||
|
|
776d714754 | ||
|
|
946a7a67ab | ||
|
|
48246f31bd | ||
|
|
ab1c5b081c | ||
|
|
2c5d9d31b6 |
@@ -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
|
||||
|
||||
+11
-4
@@ -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
|
||||
@@ -61,3 +61,10 @@ frontend/out/
|
||||
|
||||
# Local-only sample inputs (client schematics, test files)
|
||||
edif-files/
|
||||
|
||||
# Cloud agent scratch
|
||||
agent-tools/
|
||||
|
||||
# Licensed protocol PDFs — keep on disk as the local archive; do not commit binaries
|
||||
standards/protocol-specs/*.pdf
|
||||
|
||||
|
||||
@@ -1,149 +1,15 @@
|
||||
# Pinscope — Agentic Schematic Validation
|
||||
# Periscope — Agentic Schematic Validation
|
||||
|
||||
Pinscope 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/pinscopex/` | 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/` — Claude Console Skills for datasheet extraction (pintable, patterns, specs).
|
||||
|
||||
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
|
||||
- **Claude API for PDF extraction** — Forced tool calls for structured output (pintable, passive patterns, specs)
|
||||
- **Prompt caching** — Extraction and review API calls use `cache_control={"type": "ephemeral"}` on system prompts and input context to reduce cost on repeated calls
|
||||
- **Claude Console Skills** — Extraction prompts deployed as managed skills; skill_ids and versions loaded from `backend/skills_manifest.json` (upload your own via `scripts/upload_skills.py`)
|
||||
- **Direct datasheet review** — Claude reads the IC datasheet PDF and circuit neighborhood together, compares to reference application circuit, and flags issues via graph query tools (`find_connected_components`, `get_net_for_pin`, `get_pintable`)
|
||||
- **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 pinscopex 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.pinscope_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/pinscopex/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 uses **Claude Console Skills** (required, via `skill_id` in `backend/skills_manifest.json`). No inline fallback — raises error if skill not configured. Skills are defined in `skills/` and uploaded via `scripts/upload_skills.py` — run it once against your own Anthropic Console account to populate the manifest with your skill IDs.
|
||||
|
||||
## 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` — Create, update, or list Claude Console Skills. Reads/writes skill IDs to `backend/skills_manifest.json`
|
||||
- `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, Anthropic SDK (async + sync), openpyxl (XLSX BOM support), pypdf (datasheet page trimming)
|
||||
- **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**: Claude API with forced tool calls for extraction, direct datasheet review for validation
|
||||
- **Model**: `claude-sonnet-4-6` default for extraction and review, `claude-haiku-4-5` for DigiKey auto-resolve and passive value fallback (per-stage overrides via `.env`)
|
||||
- **Skills**: Claude Console Skills API for managed extraction prompts (3 active skills: pintable, pattern, specs)
|
||||
- **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** — `default_model_version` in `skills_manifest.json` is incremented by `scripts/upload_skills.py --update`, so all new extractions after a skill update start at the new minor (e.g. `1.0.0` → `1.1.0`)
|
||||
|
||||
**Rule**: When committing or pushing changes under `skills/`, run `python3 scripts/upload_skills.py --update` before the commit/push to sync skill versions and bump `default_model_version`.
|
||||
|
||||
## 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/pinscopex/models.py`
|
||||
- Frontend types in `frontend/src/lib/types.ts` must stay in sync with `backend/pinscopex/models.py`
|
||||
- Extraction prompts live in `skills/` as Claude Console Skills (SKILL.md + schema.json + validate.py)
|
||||
- **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`.
|
||||
|
||||
@@ -1,49 +1,106 @@
|
||||
# Pinscope
|
||||
# Periscope
|
||||
|
||||
Pinscope reviews schematics the way a good senior engineer does: with the datasheets open.
|
||||
Periscope is full **PCB analysis** for a board you intend to send to fab with confidence. Schematic review is a stage, not the deliverable.
|
||||
|
||||
<img width="1912" height="1080" alt="pinscope-screenrecording" src="https://github.com/user-attachments/assets/7e9e4002-08df-423f-93c9-eefd52e88700" />
|
||||
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints once into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
|
||||
|
||||
Live instance: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
|
||||
|
||||
Give it a netlist, a BOM, and your datasheet PDFs. It builds a graph of your design, reads each IC's datasheet, and checks the circuit around every part against what the manufacturer actually specifies — reference application, pin functions, absolute maximums, recommended operating conditions. Every finding points at the datasheet page that backs it up, so you can judge the call yourself instead of trusting a black box.
|
||||
This tree is the Periscope product (operator: Michele Bigi). It is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope); Faradworks does not operate this instance.
|
||||
|
||||
The reason it exists: ERC passes boards that don't work. Your EDA tool has no idea that the CH340E you powered from 5 V drives its TXD at 4.5 V into an MCU pin that maxes out at 3.6 V, or that the net you labeled `UART5_TX` lands on a pin whose alternate-function table only offers `UART5_RX`, or that the LDO's bypass pin you left floating costs you an order of magnitude in output noise. None of that is an electrical *rule* violation. All of it is in the datasheet, and nobody has time to re-read 400 pages per part on every revision.
|
||||
## What it checks — and what it will not invent
|
||||
|
||||
## How it works
|
||||
USB pair impedance, Ethernet class, and trace current versus width are **examples** of gated checks. They run when that bus, connector, or datasheet number exists on the board. They are not a catalog of every interface in electronics.
|
||||
|
||||
<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.">
|
||||
</p>
|
||||
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net, sitting inside a courtyard, or having nearby coordinates does not change the object type.
|
||||
|
||||
1. **Parse** the BOM (CSV/XLSX) and netlist (PADS-PCB `.asc` or EDIF 2.0.0 `.edn` — exportable from KiCad, Altium, OrCAD, Allegro, Xpedition, EasyEDA, Eagle) into a queryable bipartite graph of components and nets.
|
||||
2. **Extract** pin tables and specs from the PDFs. Large datasheets are trimmed to the relevant pages first, and every extraction is cached in a shared library, so a given part number is only ever processed once.
|
||||
3. **Review** each IC in isolation. The model gets the trimmed datasheet plus that IC's circuit neighborhood, can query the graph (`find_connected_components`, `get_net_for_pin`, `get_pintable`) and pull pages from a *connected* part's datasheet when a finding spans an interface. It files findings with severity, reasoning, and page citations.
|
||||
4. **Compute** the deterministic parts deterministically — BOM roll-up and a capacitor voltage-derating table come straight from the graph, no model involved.
|
||||
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips the check). It does not invent current, impedance, copper weight, geometry, IEC creepage, or “typical USB 500 mA”.
|
||||
|
||||
A post-pass normalizes findings conservatively: it can merge duplicates and downgrade severity, never upgrade. If the reviewer hedged, the report hedges.
|
||||
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the datasheet reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip — not a guessed ampacity table, not via-IPC folklore.
|
||||
|
||||
It's a reviewer, not an oracle. It misses things, and it will occasionally question a choice you made on purpose — that's what the citations are for.
|
||||
**Interface class certifiers are gated on parts that are present.** USB-C, RJ45/Ethernet, PoE, and DDR-style certifiers fire only if that connector or device is on the graph. No USB-C receptacle → no USB-C findings (silence, not N/A). A bare RJ45 is Ethernet, not PoE. No DDR device → no DDR findings.
|
||||
|
||||
## Try it on the bundled design
|
||||
Periscope does **not** claim FEM, thermal spreading, OpenEMS, or a field solver. Closed-form Z0 comes from vendored ImpedenceFinder (`vendor/impedancefinder/`; upstream license UNKNOWN).
|
||||
|
||||
`simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO. Run it through and Pinscope flags, among other things, the LDO's bypass pin left unconnected (~300 µV<sub>RMS</sub> output noise instead of ~40) and the 5 V-powered CH340E driving the 3.3 V MCU directly — each with the page reference to check its work.
|
||||
Findings keep FACT, REQUIREMENT, and INFERENCE apart. Recommended datasheet notes are not errors. The same finding object is used for schematic (`MODE=run`) and PCB (`MODE=pcb`).
|
||||
|
||||
You need Python 3.12+, Node 20+, and an [Anthropic API key](https://console.anthropic.com/):
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| `periscope/src/` | Native Periscope (finding engine, PCB/placement, DeepSeek, Dockerfiles, KiCad plugin) |
|
||||
| `periscope/dependency/` | Inherited PinScope (in-tree AGPL dependency — do not delete) |
|
||||
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
|
||||
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
|
||||
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
|
||||
| `LICENSE` | GNU AGPL v3 |
|
||||
|
||||
See `periscope/README.md` for the physical split.
|
||||
|
||||
## Run locally
|
||||
|
||||
Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/) for live extraction and review. Offline graph/smoke does not need the key.
|
||||
|
||||
```bash
|
||||
pip install -r backend/requirements.txt
|
||||
cp backend/.env.example .env # set ANTHROPIC_API_KEY
|
||||
python3 scripts/upload_skills.py --update # one-time: registers the extraction prompts under your account
|
||||
python3 -m uvicorn backend.main:app --reload
|
||||
cd frontend && npm install && npm run dev
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
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
|
||||
```
|
||||
|
||||
Open http://localhost:3000, create a project, and feed it the netlist and BOM from `simple_project/` plus datasheet PDFs for the ICs — grab those from the manufacturers, or set the optional DigiKey API keys and let it fetch them. Everything runs locally against your own key; projects and the extraction library live in `data/`. Architecture notes are in [CLAUDE.md](CLAUDE.md).
|
||||
Frontend — overlay native UI onto the inherited shell, then start Next.js (this is what CI runs):
|
||||
|
||||
## Hosted version
|
||||
```bash
|
||||
./scripts/materialize-frontend.sh
|
||||
cd .merge/frontend && npm install
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostname 127.0.0.1
|
||||
```
|
||||
|
||||
This repo is the product minus accounts and billing. If you'd rather not run it yourself, [pinscope.ai](https://pinscope.ai) is the same code, hosted, with team workspaces and a shared parts library that's already warm.
|
||||
Open the frontend URL, create a project, and upload a netlist plus BOM. A `.kicad_pcb` is required for the PCB job; without it the project can complete schematic review only. `periscope/dependency/simple_project/` is a small bundled schematic fixture (MSPM0G3507 + CH340E + SPX3819).
|
||||
|
||||
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`. Do not run an Anthropic Console uploader.
|
||||
|
||||
Tests from the repo root:
|
||||
|
||||
```bash
|
||||
pip install pytest pytest-asyncio
|
||||
pytest tests/ -q
|
||||
python3 scripts/smoke_simple_project.py # offline
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Backend listens on port 8080, frontend on port 3000 (`docker-compose.yml`).
|
||||
|
||||
### Live host
|
||||
|
||||
The public site is rebuilt from **`/root/periscope`** with the script that actually ships:
|
||||
|
||||
```bash
|
||||
cd /root/periscope
|
||||
./scripts/update-periscope.sh
|
||||
```
|
||||
|
||||
`--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL. The script refuses to run unless the checkout is `/root/periscope` (override with `CANONICAL_ROOT` only if the host layout differs). It does not touch `data/`.
|
||||
|
||||
## Source
|
||||
|
||||
| Remote | URL |
|
||||
| --- | --- |
|
||||
| GitHub | [https://github.com/manvalan/pinscope](https://github.com/manvalan/pinscope) (`git@github.com:manvalan/pinscope.git`) |
|
||||
| Gitea | [http://192.168.1.71:3000/michele/periscope.git](http://192.168.1.71:3000/michele/periscope.git) |
|
||||
|
||||
Push both: `git push github && git push gitea` (no force).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0. For commercial licensing, write to dev@faradworks.com.
|
||||
[AGPL-3.0](LICENSE) — GNU Affero General Public License v3.0.
|
||||
|
||||
This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). For a commercial license of **upstream Pinscope**, contact Faradworks as they publish it. For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Pinscope Backend — Environment Variables
|
||||
# Copy to .env and fill in values. Only ANTHROPIC_API_KEY is required.
|
||||
|
||||
# -- AI ----------------------------------------------------------------------
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||
# Per-stage Anthropic model overrides (leave empty to use ANTHROPIC_MODEL)
|
||||
MODEL_PINTABLE=
|
||||
MODEL_PATTERN=
|
||||
MODEL_VALIDATION=
|
||||
|
||||
# -- AI provider routing -----------------------------------------------------
|
||||
# Default provider for every stage; per-stage env vars override.
|
||||
# Valid values: anthropic | gemini
|
||||
PROVIDER_DEFAULT=anthropic
|
||||
# Set a specific stage to "gemini" to route just that stage to Gemini
|
||||
# (leaves the rest on Anthropic). Skills-based extraction stages
|
||||
# (pintable / pattern / specs) require Anthropic — Gemini has no
|
||||
# equivalent of Anthropic Console Skills.
|
||||
# PROVIDER_VALIDATION=gemini
|
||||
# PROVIDER_POWER_TREE=gemini
|
||||
# PROVIDER_AUTO_RESOLVE=
|
||||
|
||||
# -- Gemini (required when any PROVIDER_* is set to "gemini") ----------------
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-3-flash-preview
|
||||
# Per-stage Gemini model overrides (leave empty to use GEMINI_MODEL)
|
||||
# MODEL_VALIDATION_GEMINI=
|
||||
# MODEL_POWER_TREE_GEMINI=
|
||||
|
||||
# -- Per-stage fallback ------------------------------------------------------
|
||||
# If set, the stage retries once with FALLBACK_PROVIDER_<STAGE> /
|
||||
# FALLBACK_MODEL_<STAGE> when the primary provider raises (e.g. Gemini 503
|
||||
# UNAVAILABLE). FALLBACK_MODEL_<STAGE> may be empty — defaults to that
|
||||
# provider's default model (ANTHROPIC_MODEL or GEMINI_MODEL). Leave
|
||||
# FALLBACK_PROVIDER_<STAGE> empty to disable fallback for that stage.
|
||||
# FALLBACK_PROVIDER_VALIDATION=anthropic
|
||||
# FALLBACK_MODEL_VALIDATION=claude-sonnet-4-6
|
||||
|
||||
# -- Storage -----------------------------------------------------------------
|
||||
# Set GCS_BUCKET to store projects/library in Google Cloud Storage.
|
||||
# Leave empty for local mode (uses the data/ directory).
|
||||
GCS_BUCKET=
|
||||
|
||||
# -- CORS --------------------------------------------------------------------
|
||||
# Frontend URL(s), JSON list
|
||||
CORS_ORIGINS=["http://localhost:3000"]
|
||||
|
||||
# -- DigiKey (optional) ------------------------------------------------------
|
||||
# Enables datasheet auto-fetch and parameter-based passive auto-resolve.
|
||||
# DIGIKEY_CLIENT_ID=
|
||||
# DIGIKEY_CLIENT_SECRET=
|
||||
# DIGIKEY_ENVIRONMENT=production
|
||||
# DIGIKEY_LOCALE_SITE=US
|
||||
# DIGIKEY_LOCALE_LANGUAGE=en
|
||||
# DIGIKEY_LOCALE_CURRENCY=USD
|
||||
|
||||
# -- Email notifications (optional) ------------------------------------------
|
||||
# Gmail API via domain-wide delegation. Leave EMAIL_SENDER empty to disable.
|
||||
# Service account credentials come from GOOGLE_APPLICATION_CREDENTIALS.
|
||||
EMAIL_SENDER=
|
||||
EMAIL_FRONTEND_URL=
|
||||
# Fixed admin email for pipeline-started notifications (leave empty to disable)
|
||||
EMAIL_ADMIN_NOTIFY=
|
||||
# Recipient for /api/contact form submissions (leave empty to disable)
|
||||
CONTACT_RECIPIENT=
|
||||
@@ -1,29 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
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/
|
||||
|
||||
# Changelog: single source of truth for the user-facing Pinscope version.
|
||||
# Read by backend/_version.py at startup and stamped onto each new pipeline run.
|
||||
# Staged into backend/ by cloudbuild before this step runs so the broad
|
||||
# `frontend/` exclude in .dockerignore doesn't block the COPY.
|
||||
COPY backend/_changelog.md /app/changelog.md
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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]
|
||||
_SRC = _REPO / "periscope" / "src"
|
||||
_DEP = _REPO / "periscope" / "dependency"
|
||||
# Last insert is searched first: native src overlays inherited dependency.
|
||||
for _p in (_DEP, _SRC):
|
||||
_s = str(_p)
|
||||
if not _p.is_dir():
|
||||
continue
|
||||
if _s in sys.path:
|
||||
sys.path.remove(_s)
|
||||
sys.path.insert(0, _s)
|
||||
|
||||
|
||||
def _prefer_native_backend(paths: list[str]) -> list[str]:
|
||||
src, other, dep = [], [], []
|
||||
for p in paths:
|
||||
norm = p.replace("\\", "/")
|
||||
if "/periscope/src/" in norm:
|
||||
src.append(p)
|
||||
elif "/periscope/dependency/" in norm:
|
||||
dep.append(p)
|
||||
else:
|
||||
other.append(p)
|
||||
return src + other + dep
|
||||
|
||||
|
||||
__path__ = _prefer_native_backend(list(extend_path(__path__, __name__)))
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Clerk JWT verification for FastAPI.
|
||||
|
||||
Validates JWT tokens from the Authorization header against Clerk's JWKS endpoint.
|
||||
Extracts user_id (sub claim) for per-user storage scoping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
# JWKS cache
|
||||
_jwks_client: jwt.PyJWKClient | None = None
|
||||
_SKIP_PATHS = {"/docs", "/openapi.json", "/redoc", "/health", "/api/billing/webhook"}
|
||||
|
||||
|
||||
def _get_jwks_client() -> jwt.PyJWKClient:
|
||||
global _jwks_client
|
||||
if _jwks_client is None:
|
||||
jwks_url = settings.clerk_jwks_url
|
||||
if not jwks_url:
|
||||
# Default Clerk JWKS URL derived from publishable key
|
||||
# Clerk publishable keys start with pk_test_ or pk_live_
|
||||
# JWKS is at https://{clerk-frontend-api}/.well-known/jwks.json
|
||||
# The user must set CLERK_JWKS_URL explicitly
|
||||
raise RuntimeError(
|
||||
"CLERK_JWKS_URL must be set for authentication. "
|
||||
"Find it in your Clerk dashboard under API Keys."
|
||||
)
|
||||
_jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True)
|
||||
return _jwks_client
|
||||
|
||||
|
||||
async def verify_clerk_token(request: Request) -> str | None:
|
||||
"""Verify Clerk JWT and return user_id, or None if invalid.
|
||||
|
||||
Returns None for:
|
||||
- Missing Authorization header
|
||||
- Invalid/expired token
|
||||
- Skip paths (docs, health)
|
||||
"""
|
||||
# Skip auth for docs/health endpoints
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return "anonymous"
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
# Fallback: check query param (EventSource/SSE can't send headers)
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
return None
|
||||
else:
|
||||
token = auth_header[7:]
|
||||
|
||||
try:
|
||||
client = _get_jwks_client()
|
||||
signing_key = client.get_signing_key_from_jwt(token)
|
||||
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
options={
|
||||
"verify_exp": True,
|
||||
"verify_aud": False, # Clerk doesn't always set aud
|
||||
"verify_iss": True,
|
||||
},
|
||||
# Clerk tokens use the Clerk instance URL as issuer
|
||||
# e.g. https://abc123.clerk.accounts.dev from https://abc123.clerk.accounts.dev/.well-known/jwks.json
|
||||
issuer=settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") if settings.clerk_jwks_url else None,
|
||||
leeway=10, # 10 second clock skew tolerance
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
return user_id
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,421 +0,0 @@
|
||||
"""Pipeline start, SSE events, and status endpoints.
|
||||
|
||||
Pipelines run in a Cloud Run Job worker (or, in local dev, a child
|
||||
subprocess). The API only enqueues, transitions status with
|
||||
``if-generation-match`` for idempotency, and tails the GCS-backed event
|
||||
log for SSE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.routers.deps import get_storage, resolve_or_404
|
||||
from backend.services import event_bridge, job_runner
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_REGEN_STAGES = {"derating"}
|
||||
|
||||
|
||||
class RegenRequest(BaseModel):
|
||||
stages: list[str]
|
||||
|
||||
|
||||
router = APIRouter(tags=["pipeline"])
|
||||
|
||||
|
||||
# Statuses from which a fresh ``/start`` is allowed to transition into queued.
|
||||
_START_OK_FROM = frozenset({
|
||||
proj_svc.STATUS_DRAFT,
|
||||
proj_svc.STATUS_COMPLETE,
|
||||
proj_svc.STATUS_ERROR,
|
||||
proj_svc.STATUS_CANCELLED,
|
||||
})
|
||||
|
||||
|
||||
def _project_active(meta: proj_svc.ProjectMeta) -> bool:
|
||||
"""A project is "active" if a worker is or could be running for it.
|
||||
|
||||
Used as the running-guard. We trust the meta status as the primary
|
||||
signal, and only fall back to the Cloud Run execution state when the
|
||||
status is one we expect a worker to be touching. This deliberately
|
||||
does NOT call get_execution_state on every request — it's an admin
|
||||
API call. The stale-running sweeper is responsible for clearing
|
||||
zombie ``running`` projects.
|
||||
"""
|
||||
return meta.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING)
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/start", status_code=202)
|
||||
async def start(project_id: str, request: Request):
|
||||
from backend.routers.deps import get_user_id
|
||||
from backend.services.billing_hook import get_billing
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting pipeline")
|
||||
|
||||
# Ensure the caller has at least their trial credits allocated. The
|
||||
# pipeline itself enforces pause-on-empty — this just makes sure a
|
||||
# brand-new user isn't blocked before their grant is issued.
|
||||
get_billing().ensure_trial_grant(storage, get_user_id(request))
|
||||
|
||||
# Idempotent enqueue: only one ``draft|complete|error|cancelled`` ->
|
||||
# ``queued`` transition can win. Concurrent /start clicks => 409.
|
||||
from backend._version import PINSCOPE_VERSION
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=_START_OK_FROM,
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
pinscope_version=PINSCOPE_VERSION,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline already running or queued")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=False, free=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline failed for %s", project_id)
|
||||
# Roll the meta back so the user can retry.
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/cancel")
|
||||
async def cancel(project_id: str, request: Request):
|
||||
"""Soft-cancel: set ``cancel_requested`` so the worker exits cleanly.
|
||||
|
||||
The worker re-reads this flag inside ``_charge_for_logs`` after every
|
||||
Claude API call (throttled). Cancellation latency is bounded by the
|
||||
in-flight call's duration, typ 1–60s.
|
||||
"""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not _project_active(meta):
|
||||
raise HTTPException(409, f"Pipeline is not running (status={meta.status})")
|
||||
proj_svc.request_cancel(storage, owner_user_id, project_id)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/estimate")
|
||||
async def estimate(project_id: str, request: Request):
|
||||
"""Pre-flight cost estimate — read-only, no side effects."""
|
||||
from backend.services.cost_estimator import estimate_pipeline_cost
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom:
|
||||
raise HTTPException(400, "Upload a BOM before requesting an estimate")
|
||||
try:
|
||||
est = estimate_pipeline_cost(storage, owner_user_id, project_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return est.model_dump()
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/resume", status_code=202)
|
||||
async def resume(project_id: str, request: Request):
|
||||
"""Resume a pipeline that was paused for insufficient credits."""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if meta.status != proj_svc.STATUS_PAUSED:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Project is not paused (status={meta.status}); nothing to resume.",
|
||||
)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Project is missing BOM or netlist")
|
||||
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=proj_svc.STATUS_PAUSED,
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Project state changed; refresh and retry")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=True, free=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline (resume) failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "resumed", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/restart", status_code=202)
|
||||
async def restart(project_id: str, request: Request):
|
||||
"""Admin-only: wipe per-project extractions and run the pipeline free."""
|
||||
from backend.routers.admin import _require_admin
|
||||
|
||||
await _require_admin(request)
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting pipeline")
|
||||
|
||||
# If something is currently running/queued, request cancel and wait
|
||||
# briefly for the worker to honour it (or exit on its own). Hard-kill
|
||||
# the execution as a last resort.
|
||||
if _project_active(meta):
|
||||
proj_svc.request_cancel(storage, owner_user_id, project_id)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0)
|
||||
# If still active, hard-kill via Cloud Run cancel.
|
||||
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
|
||||
if _project_active(meta) and meta.execution_name:
|
||||
job_runner.cancel_execution(meta.execution_name)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0)
|
||||
|
||||
proj_svc.clear_project_extractions(storage, owner_user_id, project_id)
|
||||
|
||||
# After clear_project_extractions the project is left in whatever
|
||||
# status it was; the transition below enforces queued.
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED},
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline is busy; cancel first then retry")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=False, free=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline (restart) failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "restarted", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/regen", status_code=202)
|
||||
async def regen(project_id: str, req: RegenRequest, request: Request):
|
||||
"""Rebuild graph and regenerate only the requested stages."""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before running regen")
|
||||
invalid = set(req.stages) - VALID_REGEN_STAGES
|
||||
if invalid:
|
||||
raise HTTPException(400, f"Invalid regen stages: {sorted(invalid)}. Valid: {sorted(VALID_REGEN_STAGES)}")
|
||||
if not req.stages:
|
||||
raise HTTPException(400, "At least one stage is required")
|
||||
|
||||
if _project_active(meta):
|
||||
proj_svc.request_cancel(storage, owner_user_id, project_id)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0)
|
||||
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
|
||||
if _project_active(meta) and meta.execution_name:
|
||||
job_runner.cancel_execution(meta.execution_name)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0)
|
||||
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED},
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline is busy; cancel first then retry")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline_regen(
|
||||
project_id, owner_user_id, stages=req.stages,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline_regen failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "regen_started", "project_id": project_id, "stages": req.stages}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/events")
|
||||
async def events(project_id: str, request: Request):
|
||||
"""SSE stream of pipeline progress events.
|
||||
|
||||
Tails the GCS-backed event log written by the worker. Stops on
|
||||
terminal events as today, but also has two hard-crash escape
|
||||
hatches: the project's status reaching a terminal value, and the
|
||||
Cloud Run execution reaching a terminal state. Either of those
|
||||
triggers a synthetic ``pipeline_error`` so the SSE doesn't hang
|
||||
forever when the worker dies without writing its terminal event.
|
||||
"""
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.execution_name
|
||||
# Drive the GCS tail and the escape-hatch poll concurrently. The
|
||||
# tail yields events; the escape hatch flips a flag.
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
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
|
||||
if cur.status in proj_svc.TERMINAL_STATUSES:
|
||||
crash_detected["reason"] = (
|
||||
f"project status={cur.status} (terminal)"
|
||||
)
|
||||
return
|
||||
# Cloud Run hard-crash detection
|
||||
if execution_name:
|
||||
try:
|
||||
state = job_runner.get_execution_state(execution_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
if state in _EXEC_TERMINAL:
|
||||
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,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
yield {
|
||||
"event": msg["event"],
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if msg["event"] in event_bridge.TERMINAL_EVENTS:
|
||||
return
|
||||
|
||||
# tail_events exited without a terminal event — escape hatch
|
||||
if crash_detected["reason"] is not None:
|
||||
# Re-read the current meta so the synthetic event has
|
||||
# the most up-to-date error information.
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
err = (
|
||||
(cur.pipeline_state or {}).get("error")
|
||||
if cur and cur.pipeline_state
|
||||
else crash_detected["reason"]
|
||||
)
|
||||
yield {
|
||||
"event": "pipeline_error",
|
||||
"data": json.dumps({
|
||||
"error": err or "worker terminated without writing a terminal event",
|
||||
"synthetic": True,
|
||||
}),
|
||||
}
|
||||
finally:
|
||||
watcher.cancel()
|
||||
try:
|
||||
await watcher
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/status")
|
||||
async def status(project_id: str, request: Request):
|
||||
"""Polling fallback — returns current project state."""
|
||||
_, meta = await resolve_or_404(request, project_id)
|
||||
return {
|
||||
"status": meta.status,
|
||||
"summary": meta.summary,
|
||||
"pipeline_state": meta.pipeline_state,
|
||||
"running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _await_terminal(
|
||||
storage, user_id: str, project_id: str, *, timeout_s: float,
|
||||
) -> None:
|
||||
"""Poll project status until it reaches a terminal state or the timeout
|
||||
elapses. Used by /restart and /regen between cancel and re-enqueue.
|
||||
"""
|
||||
poll = 0.5
|
||||
elapsed = 0.0
|
||||
while elapsed < timeout_s:
|
||||
try:
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
except Exception:
|
||||
meta = None
|
||||
if meta is None:
|
||||
return
|
||||
if meta.status in proj_svc.TERMINAL_STATUSES:
|
||||
return
|
||||
await asyncio.sleep(poll)
|
||||
elapsed += poll
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"default_model_version": "1.4.0",
|
||||
"extract-pintable": {
|
||||
"skill_id": "skill_013cTQFk8bqwJVemreNihQRW",
|
||||
"latest_version": "1777167199421424",
|
||||
"display_title": "Extract Pin Table"
|
||||
},
|
||||
"extract-pattern": {
|
||||
"skill_id": "skill_0195iVb55HeQgKHFkePC56hP",
|
||||
"latest_version": "1777167200857394",
|
||||
"display_title": "Extract Passive Pattern"
|
||||
},
|
||||
"extract-specs": {
|
||||
"skill_id": "skill_016sqcgvuVea95Nb4uJYBj7h",
|
||||
"latest_version": "1777167202182784",
|
||||
"display_title": "Extract Component Specs"
|
||||
}
|
||||
}
|
||||
+28
-12
@@ -1,37 +1,50 @@
|
||||
# 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: pinscope-backend
|
||||
container_name: periscope-backend
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./taxonomy:/app/taxonomy
|
||||
- ./periscope/src/taxonomy:/app/taxonomy
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
networks:
|
||||
- pinscope
|
||||
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: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-}
|
||||
|
||||
container_name: pinscope-frontend
|
||||
container_name: periscope-frontend
|
||||
restart: unless-stopped
|
||||
|
||||
depends_on:
|
||||
@@ -41,9 +54,12 @@ services:
|
||||
- "3000:3000"
|
||||
|
||||
networks:
|
||||
- pinscope
|
||||
|
||||
periscope:
|
||||
aliases:
|
||||
- periscope-frontend
|
||||
- pinscope
|
||||
- pinscope-frontend
|
||||
|
||||
networks:
|
||||
pinscope:
|
||||
periscope:
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
# Albero delle analisi Periscope
|
||||
|
||||
Versione codice **2.63.1** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa.
|
||||
|
||||
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
|
||||
|
||||
Due pipeline più una porta libreria (non è un esame):
|
||||
|
||||
0. **Libreria** — `POST /api/library/datasheets` registra un componente da PDF senza `MODE=run`/`MODE=pcb`.
|
||||
1. **Schematico / review AI** — `MODE=run`, `services/pipeline.py` → `validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
|
||||
2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`, lista 2.62.1 **invariata**) + sezione additiva AF Board+AI (`af_ai_hf.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
|
||||
|
||||
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
|
||||
|
||||
Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/AI). Test pytest citati sotto i nodi: nomi reali da `tests/`.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Periscope 2.63.1
|
||||
├── 0. Libreria (porta, non esame)
|
||||
│ └── PDF + MPN → scheda (inbox IC / passivo / discreto) senza MODE=run/pcb
|
||||
├── 1. Schematico / review AI (MODE=run)
|
||||
│ ├── ingest (non è analisi: parser → grafo)
|
||||
│ ├── estrazione datasheet
|
||||
│ ├── check deterministici schema
|
||||
│ ├── certifier di classe (condivisi con PCB)
|
||||
│ └── review AI per-IC + post-pass
|
||||
└── 2. Esame PCB (MODE=pcb)
|
||||
├── ingest layout (non è DRC)
|
||||
├── run_pcb_checks ← 2.62.1 invariato (SI + HF line inclusi)
|
||||
│ ├── net / footprint
|
||||
│ ├── placement vs layout_rules
|
||||
│ ├── SI + linee HF (2.62.0 Fase A)
|
||||
│ ├── DDR / CPU / FPGA (2.62.0)
|
||||
│ ├── potenza / via / termico
|
||||
│ ├── derating / timing / PI
|
||||
│ ├── ESD / return / EMI / SPOF
|
||||
│ ├── BOM ↔ PCB ↔ datasheet
|
||||
│ └── resto Fase B (2.62.1)
|
||||
├── AF Board + AI (additivo: flag → investigate_hf_hypotheses; non sostituisce SI/HF)
|
||||
├── review AI PCB
|
||||
└── tab antenna RF (non in run_pcb_checks)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Schematico / review AI (`MODE=run`)
|
||||
|
||||
Orchestrazione: `pipeline.py` stadi `bom_parse` → `ic_extraction` → `simple_extraction` → `passive_extraction` → `graph_build` → `validation`. Check deterministici: `_run_deterministic_checks` in `validation.py`. Finding ID schema `PS-{ref}-{nnn}` (non `PCB-`).
|
||||
|
||||
### 1.1 Ingest (prerequisito, non analisi)
|
||||
|
||||
Parser e grafo non emettono PE-*. Skip MPN: `ic_mpn_skip_reason` (silenzio onesto, niente MPN inventato).
|
||||
|
||||
- Test parser/grafo: `tests/test_bom_parser.py` (`test_parse_bom_uses_value_for_ic_when_mpn_column_empty`, `test_parse_bom_keeps_pnm_and_datasheet_url`); `tests/test_hubaudio_bom.py` (tutti e 4); `tests/test_netlist_parser.py` (tutti e 3); `tests/test_edif_parser.py` (14); `tests/test_kicad_parser.py` (17); `tests/test_kicad_project.py` (19); `tests/test_netlist_bundle.py` (9); `tests/test_functional_groups.py` (8).
|
||||
- Test overlay parser: `tests/test_periscope_parsers_rewrite.py`, `tests/test_periscope_graph_rewrite.py`, `tests/test_periscope_models_rewrite.py`.
|
||||
|
||||
### 1.2 Estrazione datasheet (processo)
|
||||
|
||||
Skip se manca PDF o lo skill non ha numero. Libreria `library/extracted` condivisa schema+PCB. PCB review non rilegge il PDF (`pdf_path=None`).
|
||||
|
||||
#### `extract-pintable` — skill `skills/extract-pintable/`
|
||||
|
||||
Modulo: `services/datasheet_extract.py` + `llm/local_skill.py`. Analizza: pin table, package, taxonomy IC.
|
||||
|
||||
- Skip se: niente PDF; IC già in libreria alla `model_version` minima.
|
||||
- Test: `tests/test_native_datasheet_extract.py` (`test_coerce_abs_max_matches_inherited`, `test_native_extract_has_no_anthropic_import`); `tests/test_pdf_ingest.py` (6); `tests/test_deepseek_provider.py` (`test_local_skills_load`, `test_extract_pdf_text_includes_page_markers`, `test_wroom_rejects_bare_soc_pin1_ant`); `tests/test_periscope_taxonomy_skills_src.py` (`test_pintable_skill_mentions_save_tool_and_wroom`, `test_skills_dir_is_src_and_local`).
|
||||
|
||||
#### `extract-specs` — skill `skills/extract-specs/`
|
||||
|
||||
Analizza: specs discreti/connettori/cristalli; parametri fuori taxonomy → scartati.
|
||||
|
||||
- Skip se: niente PDF o tipo non in `SIMPLE_TYPES`.
|
||||
- Test: `tests/test_pdf_ingest.py` (`test_coerce_abs_max_keeps_valid_drops_junk`); `tests/test_layout_rules.py` (5 — `layout_rules` numerici, niente mm inventati).
|
||||
|
||||
#### `extract-pattern` — skill `skills/extract-pattern/`
|
||||
|
||||
Analizza: pattern MPN passivi (R/C/L/FB).
|
||||
|
||||
- Skip se: MPN opaco; DigiKey/value fallback copre prima.
|
||||
- Test: `tests/test_inductor_specs.py` (13, Z ferrite solo se il PDF dice N Ω @ freq); `tests/test_pcb_software_bugs.py` (`test_lqw_decoder_and_unknown_skip`, `test_fb1_blm_is_bead_not_dcr_resistor`).
|
||||
|
||||
#### Resolve DigiKey / catalogo (esatto MPN)
|
||||
|
||||
Modulo: `services/digikey.py`, `passive_from_distributor.py`, `passive_from_mpn.py`. Analizza: parametri catalogo per auto-resolve. Hit fuzzy rifiutati.
|
||||
|
||||
- Skip se: MPN non esatto; API assente (fail-soft).
|
||||
- Test: `tests/test_digikey_match.py` (3); `tests/test_passive_from_distributor.py` (7); `tests/test_passive_from_mpn.py` (7); `tests/test_datasheet_finder.py` (17); `tests/test_library.py` (5).
|
||||
|
||||
#### Resolve da stringa Value (solo progetto)
|
||||
|
||||
Modulo: `services/passive_from_value.py`. Specs value-derived **non** in libreria condivisa.
|
||||
|
||||
- Skip se: stringa ambigua / placeholder.
|
||||
- Test: `tests/test_passive_from_value.py` (8).
|
||||
|
||||
### 1.3 Check deterministici schema
|
||||
|
||||
Fail-soft per modulo. Senza evidenza: skip (lista vuota), non folklore.
|
||||
|
||||
#### `PE-MUX-001` — `pin_mux_check.py`
|
||||
|
||||
Analizza: funzione pin vs net (UART/SPI/I2C mux).
|
||||
Skip se: `functions` vuote; link inter-device stesso peripheral; net opaco.
|
||||
|
||||
- Test: `tests/test_pin_mux_check.py` — `test_real_defect_uart5_swapped_is_error`, `test_correct_assignment_no_finding`, `test_inter_device_same_peripheral_link_is_skipped`, `test_transceiver_peer_without_peripheral_still_fires`, `test_empty_functions_skipped`, `test_pin_exposes_peripheral_but_not_signal_no_complement`, `test_opaque_net_not_flagged`, `test_token_parser_and_normalizer`, `test_spi_legacy_net_names_match_modern_pin_functions`, `test_spi_controller_peripheral_names_are_synonyms`, `test_simple_project_uart0_nets_are_feasible_on_mspm0_pins`, `test_simple_project_uart0_swapped_on_mspm0_is_error`, `test_spi_genuine_infeasibility_still_fires_with_modern_names`, `test_finding_prints_full_raw_capability_list_and_intent_caveat`, `test_legacy_report_without_source_validates`.
|
||||
|
||||
#### `PE-NC-001` — `nc_pin_check.py`
|
||||
|
||||
Analizza: pin NC collegato a net attivo.
|
||||
Skip se: niente pintable.
|
||||
|
||||
- Test: `tests/test_nc_pin_check.py` — `test_nc_pin_on_active_net_warns`, `test_nc_pin_on_nc_net_silent`, `test_no_pintable_silent`.
|
||||
|
||||
#### `PE-DEC-001` / `PE-DEC-002` — `passive_rail_check.py` `check_supply_decoupling`
|
||||
|
||||
Analizza: condensatore sul net di alimentazione IC (001 presenza; 002 valore vs datasheet).
|
||||
Skip se: net NC; pin enable strap; manca valore per 002.
|
||||
|
||||
- Test: `tests/test_passive_rail_check.py` — `test_missing_decoupling_is_warning`, `test_cap_to_gnd_clears_decoupling`, `test_enable_strapped_to_rail_is_not_decoupling`, `test_nc_supply_net_is_skipped`, `test_ldo_vout_needs_cout`, `test_ldo_vout_100n_only_is_value_warning`, `test_vdd_100n_is_not_a_value_warning`, `test_ki_cad_voltage_prefix_is_power`, `test_fb_and_rn_prefixes`.
|
||||
|
||||
#### `PE-I2C-001` / `PE-I2C-002` — `passive_rail_check.py` `check_i2c_pullups`
|
||||
|
||||
Analizza: pull-up I2C (001 presenza; 002 valore vs banda).
|
||||
Skip se: pin SDA è alias SPI; pull-up senza valore (002 non sizato); bus assente.
|
||||
|
||||
- Test: `tests/test_passive_rail_check.py` — `test_i2c_missing_pullup`, `test_i2c_pullup_present`, `test_i2c_pullup_to_3v3_digital_typed_as_signal`, `test_spi_pin_alias_sda_is_not_i2c`, `test_i2c_from_slash_alias_in_pin_name`, `test_i2c_4k7_pullup_is_in_nxp_wide_band`, `test_i2c_100ohm_pullup_is_too_stiff`, `test_i2c_100k_pullup_is_too_weak`, `test_i2c_pullup_without_value_is_not_sized`.
|
||||
|
||||
#### `PE-RST-001` / `PE-RST-002` — `passive_rail_check.py` `check_reset_pullups`
|
||||
|
||||
Analizza: reset/enable pull (001 presenza; 002 verso vs datasheet).
|
||||
Skip se: GPIO guida il reset.
|
||||
|
||||
- Test: `tests/test_passive_rail_check.py` — `test_reset_no_finding_when_gpio_drives`, `test_reset_floating_is_warning`, `test_nrst_pulldown_is_warning`, `test_nrst_pullup_is_not_pulldown`.
|
||||
|
||||
#### `PE-BOM-001` / `PE-BOM-002` — `bom_match_check.py`
|
||||
|
||||
Analizza: MPN schema vs BOM (001 mismatch; 002 riga BOM orfana).
|
||||
Skip se: mappa schematica vuota (PADS/EDIF senza properties).
|
||||
|
||||
- Test: `tests/test_bom_match_check.py` — `test_matching_mpns_produce_no_findings`, `test_mpn_mismatch_is_error_ps_bom_001`, `test_orphan_bom_ref_is_warning`, `test_mpn_case_and_whitespace_are_not_a_mismatch`, `test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch`, `test_empty_schematic_map_skips_check`, `test_legacy_design_graph_without_source_fields_still_validates`, `test_build_graph_kicad_mpn_mismatch_surfaces`.
|
||||
|
||||
#### `PE-DNP-001` — `dnp_check.py`
|
||||
|
||||
Analizza: DNP vs enable floating.
|
||||
Skip se: colonna DNP assente.
|
||||
|
||||
- Test: `tests/test_dnp_check.py` — `test_dnp_pull_leaves_enable_floating`, `test_fitted_pull_is_silent`, `test_no_dnp_column_skips_floating_enable`, `test_parse_bom_reads_dnp_and_skips_when_column_absent`.
|
||||
|
||||
#### LED corrente — `led_current_check.py` (nessun `PE-*`)
|
||||
|
||||
Analizza: I_f Ohm vs rating canale. Source `led_current_check`.
|
||||
Skip se: niente `forward_current_*`; tensione rail sconosciuta (non si indovina).
|
||||
|
||||
- Test: `tests/test_led_current_check.py` — `test_over_current_is_error`, `test_proper_resistor_no_finding`, `test_unknown_rail_no_error`, `test_no_rating_skipped`, `test_parse_resistance`.
|
||||
|
||||
#### `PE-TH-001` / `PE-TH-002` / `PE-TH-003` — `thermal_check.py`
|
||||
|
||||
Analizza: P = I_load × drop; Tj = Ta + P·θJA (001 INFO senza θJA o Tj ok; 002 WARNING Tj alto — **non in catalogo `finding_engine`**; 003 I²R shunt).
|
||||
Skip se: manca I_load (Iout_max non è carico).
|
||||
|
||||
- Test: `tests/test_thermal_check.py` — `test_ldo_without_theta_ja_is_info`, `test_iout_max_is_not_used_as_load`, `test_ldo_hot_tj_is_warning`, `test_shunt_over_rating_is_warning`, `test_led_resistor_within_rating_is_silent`.
|
||||
|
||||
#### `PE-PWR-001` (schema) — `power_margin_check.py`
|
||||
|
||||
Analizza: Σ IQ + I_load vs Iout regolatore; caduta su R serie. Source `power_margin_check`.
|
||||
Skip se: manca I_load; IQ assente non è fail.
|
||||
|
||||
- Test: `tests/test_power_margin_check.py` — `test_load_over_iout_max_is_ps_pwr_001`, `test_missing_iq_is_not_guessed_into_margin_fail`, `test_series_r_ir_drop_uses_i_load_not_trace`, `test_no_series_r_does_not_invent_trace_drop`.
|
||||
|
||||
#### `PE-SEQ-001` — `sequencing_check.py`
|
||||
|
||||
Analizza: PG → EN da note datasheet.
|
||||
Skip se: niente spec `power_sequence`.
|
||||
|
||||
- Test: `tests/test_sequencing_check.py` — `test_pg_not_tied_to_en_is_warning`, `test_pg_tied_to_en_is_silent`, `test_no_power_sequence_spec_skips_even_if_pg_open`.
|
||||
|
||||
#### `PE-FLT-001` / `PE-FLT-002` / `PE-FLT-003` — `filter_check.py`
|
||||
|
||||
Analizza: topologia filtro (001), pezzo (002), fc vs ADC / DCR ferrite (003).
|
||||
Skip se: manca C; pull-up+decoupling non è filtro; DCR ferrite senza limite datasheet.
|
||||
|
||||
- Test: `tests/test_filter_check.py` — `test_rc_reports_fc_info_without_adc_rate`, `test_rc_vs_adc_rate_is_warning`, `test_pullup_plus_decoupling_is_not_a_filter`, `test_missing_c_value_does_not_invent_fc_warning`, `test_pi_and_t_need_l_and_c`, `test_ferrite_dcr_warns_only_with_datasheet_limit`.
|
||||
|
||||
#### `PE-XTAL-001` / `PE-XTAL-002` / `PE-XTAL-003` — `crystal_cl_check.py`
|
||||
|
||||
Analizza: CL vs C1/C2 (001 coppia; 002 serie/stray; 003 drive). Cstray solo se in specs.
|
||||
Skip se: niente CL in specs.
|
||||
|
||||
- Test: `tests/test_crystal_cl_check.py` — `test_no_cl_in_specs_is_silent`, `test_series_above_cl_without_stray_warns`, `test_with_stray_mismatch_warns`, `test_simple_project_without_cl_silent`.
|
||||
|
||||
#### `PE-LF-001` / `PE-LF-002` / `PE-LF-003` — `lifecycle.py`
|
||||
|
||||
Analizza: Obsolete / NRND / RoHS non-compliant.
|
||||
Skip se: catalogo assente.
|
||||
|
||||
- Test: `tests/test_lifecycle_check.py` — `test_obsolete_is_warning_and_uses_distributor_replacement`, `test_nrnd_is_info`, `test_explicit_rohs_non_compliant_is_warning`, `test_active_is_silent`, `test_rohs_not_applicable_is_not_a_fail`, `test_missing_catalog_and_missing_substitute_are_not_guessed`.
|
||||
|
||||
#### `PE-INT-001` — `internal_features_check.py`
|
||||
|
||||
Analizza: ESD/pull interni vs net.
|
||||
Skip se: `internal_features` vuoto (non si indovina open-drain).
|
||||
|
||||
- Test: `tests/test_internal_features_check.py` — `test_listed_open_drain_without_pull_is_warning`, `test_listed_pin_with_pullup_is_silent`, `test_empty_features_does_not_guess_open_drain`.
|
||||
|
||||
#### `PE-ESR-001` — `hf_coverage_check.py`
|
||||
|
||||
Analizza: copertura HF/ESR (bulk vs 100 n).
|
||||
Skip se: valore C sconosciuto.
|
||||
|
||||
- Test: `tests/test_hf_coverage_check.py` — `test_bulk_only_is_info_ps_esr_001`, `test_bulk_plus_100n_is_silent`, `test_unknown_cap_value_is_not_guessed`.
|
||||
|
||||
#### `PE-ERRATA-001` — `errata_check.py`
|
||||
|
||||
Analizza: errata pubblicata vs pull sul pin.
|
||||
Skip se: MPN sconosciuto o entry senza URL.
|
||||
|
||||
- Test: `tests/test_errata_check.py` — `test_missing_errata_pullup_is_ps_errata_001`, `test_pullup_present_is_silent`, `test_unknown_mpn_and_url_less_entry_are_silent`.
|
||||
|
||||
Suite deterministica end-to-end: `tests/test_validation_deterministic_seed.py` `test_deterministic_findings_seeded_and_not_reviewed`; `tests/test_eval_report.py` `test_simple_project_eval_matches_committed_golden`.
|
||||
|
||||
### 1.4 Certifier di classe (grafo — schema **e** PCB)
|
||||
|
||||
Moduli: `interface_class_check.py` (schema **e** PCB), `hf_bus_class.py` (2.62.0, solo `run_pcb_checks`), `usb_pd_check.py` (2.62.1, solo PCB). Niente geometria. Niente connettore/dispositivo → **silenzio**.
|
||||
|
||||
#### USB-C — solo se receptacle USB-C sul grafo
|
||||
|
||||
- **`PE-USBC-001`** classe USB2 vs USB3 da BOM/footprint/MPN/net.
|
||||
- **`PE-USBC-002`** CC1 e CC2.
|
||||
- **`PE-USBC-003`** Rd 5.1 kΩ ±10% o Rp 56/22/10 kΩ. Skip/INSUFFICIENT se R ignota o CC verso controller senza R.
|
||||
- **`PE-USBC-004`** VBUS e GND.
|
||||
- **`PE-USBC-005`** SuperSpeed solo se classe USB3; USB2 = N/A (non ERROR).
|
||||
|
||||
Test: `tests/test_interface_class_check.py` — `test_no_connector_emits_nothing`, `test_usbc_rd_vbus_gnd_certified`, `test_usbc_missing_cc2_is_error`, `test_usbc_wrong_rd_is_error`, `test_usbc_cc_to_controller_without_r_is_insufficient_not_error`, `test_usbc_unknown_r_value_is_insufficient`, `test_usb3_class_without_ss_pairs_is_error`, `test_usb3_with_ss_pairs_is_certified`, `test_missing_vbus_is_error`, `test_simple_project_usbc_not_false_error`, `test_hubaudio_like_usbc_usb2_and_poe_magjack`, `test_usbc_vbus_does_not_invent_usb_500ma`, `test_does_not_use_layout_geometry`, `test_rule_catalog_shared_not_si`.
|
||||
|
||||
#### Ethernet — solo se RJ45/MagJack
|
||||
|
||||
- **`PE-ETH-001`** classe 10/100 vs GbE. Skip/INSUFFICIENT se velocità assente (non si inventa GbE).
|
||||
- **`PE-ETH-002`** TX/RX (10/100) o quattro MDI (GbE).
|
||||
- **`PE-ETH-003`** magnetics se GbE.
|
||||
- **`PE-ETH-004`** Bob Smith 75 Ω: REVIEW (spesso in MagJack).
|
||||
|
||||
Test: `tests/test_interface_class_check.py` — `test_ethernet_10_100_magjack_no_gbe_magnetics_error`, `test_gbe_without_magnetics_is_error`, `test_gbe_with_magnetics_is_certified`, `test_rj45_without_speed_is_insufficient_not_invented_gbe`.
|
||||
|
||||
#### PoE — solo con evidenza PoE (RJ45 nudo = skip)
|
||||
|
||||
- **`PE-POE-001`** evidenza PoE.
|
||||
- **`PE-POE-002`** classe/tipo (802.3af/at/bt, Class n) — mai inventata.
|
||||
- **`PE-POE-003`** magnetics/isolamento (MagJack o LAN transformer).
|
||||
- **`PE-POE-004`** (2.62.1) tensione di isolamento solo da numero sourced. Skip senza V.
|
||||
|
||||
Test: `tests/test_interface_class_check.py` — `test_bare_rj45_does_not_invent_poe`, `test_poe_with_evidence_requires_magnetics_isolation`, `test_poe_evidence_on_bare_jack_is_isolation_error`, `test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr`, `test_run_pcb_checks_skips_absent_interfaces`; `tests/test_pcb_phase_b.py` — `test_poe_isolation_skips_without_voltage_fact`.
|
||||
|
||||
#### DDR — solo se DRAM sul grafo (2.62.0)
|
||||
|
||||
- **`PE-DDR-001`** classe DRAM.
|
||||
- **`PE-DDR-002`** CK, DQS, ≥8 DQ.
|
||||
- **`PE-DDR-003`** VTT/VREF solo se quei net esistono (altrimenti skip, non VTT inventato).
|
||||
|
||||
Test: `tests/test_hf_line_check.py` — `test_no_ddr_cpu_fpga_on_usb_only_graph`, `test_ddr_present_class_and_nets_skip_invented_vtt`, `test_rule_catalog_hf_ids`.
|
||||
|
||||
#### CPU bus parallelo — solo se D/A/controllo esistono (2.62.0)
|
||||
|
||||
- **`PE-CPU-001`** presenza bus parallelo.
|
||||
- **`PE-CPU-002`** ≥8 D/DQ/AD.
|
||||
- **`PE-CPU-003`** address o AD mux.
|
||||
- **`PE-CPU-004`** CS/WE/OE (o FMC).
|
||||
|
||||
Test: `tests/test_hf_line_check.py` — `test_cpu_parallel_bus_only_when_data_addr_control_exist`.
|
||||
|
||||
#### FPGA — solo se FPGA sul grafo (2.62.0)
|
||||
|
||||
- **`PE-FPGA-001`** classe FPGA.
|
||||
- **`PE-FPGA-002`** flash di config se c’è un flash IC (altrimenti skip).
|
||||
- **`PE-FPGA-003`** VCCINT/VCCIO solo se net nominati.
|
||||
|
||||
Test: `tests/test_hf_line_check.py` — `test_fpga_only_when_present_config_flash_optional`.
|
||||
|
||||
#### USB-PD contratto — `usb_pd_check.py` (2.62.1)
|
||||
|
||||
- **`PE-PD-001`** controller PD sul grafo (FUSB302/TPS257/…). Non è USB2 Rd. PDO non inventati.
|
||||
|
||||
Test: `tests/test_pcb_phase_b.py` — `test_usb_pd_skips_without_pd_controller`.
|
||||
|
||||
### 1.5 Review AI per-IC + post-pass
|
||||
|
||||
Moduli: `review_session.py`, `pcb_review.py` non entra qui. DeepSeek, PDF una volta per IC, fail-soft. Prompt tools grafo (`find_connected_components`, `get_net_for_pin`, `get_pintable`, `get_datasheet_excerpt`). Finding LLM = REVIEW, mai RULE ERROR (`finding_engine`).
|
||||
|
||||
Skip se: niente MPN; niente PDF (`not_reviewed`); fingerprint invariato (`review_fingerprint.py`).
|
||||
|
||||
- Review loop: `tests/test_native_review_loop.py` (5); `tests/test_validation_no_tool_recovery.py` `test_no_tool_calls_triggers_forced_submit_next_turn`; `tests/test_validation_concurrency.py` (3); `tests/test_validation_trace_log.py` (3); `tests/test_review_parse.py` (2); `tests/test_quote_verify.py` (5); `tests/test_finding_engine.py` (`test_llm_review_is_never_rule`, `test_review_and_risk_cannot_stay_error`, `test_insufficient_evidence_does_not_stay_error`); `tests/test_fase_b_checks.py` `test_llm_error_review_clamped_in_complete_finding`; `tests/test_review_fingerprint.py` (2); `tests/test_reprocess.py` (4); `tests/test_deepseek_provider.py` (`test_thinking_only_echoes_reasoning_content_on_follow_up` e roundtrip tool).
|
||||
- Excerpt cross-IC: `tests/test_datasheet_excerpt_tool.py` (12); `tests/test_cache_breakpoint_limit.py` (3).
|
||||
- **Normalize** (downgrade-only): `tests/test_normalize_findings.py` (14); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_normalize_cannot_promote_warning`.
|
||||
- **Dedupe** cross-IC: `tests/test_dedupe_findings.py` (11); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_dedupe_collapses_both_endpoints`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Esame PCB (`MODE=pcb`)
|
||||
|
||||
Stadi: `ensure_graph` → `parse_pcb` → `classify` → `inventory` → **`run_pcb_checks`** → `ai_review` → `pcb_report.json`. ID finding `PCB-{ref}-{001}`. Mutex vs analisi schema e vs placement. Inventario net (`pcb_inventory.py`) è numeri, non finding.
|
||||
|
||||
KiCad DRC **non** è un nodo Periscope. `tests/test_pcb_software_bugs.py` `test_drc_unconnected_items_count` e `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude` servono solo a **non** copiare il DRC.
|
||||
|
||||
Suite orchestrazione: `tests/test_pcb_review.py` `test_run_pcb_checks_assigns_pcb_ids_and_recommendations`, `test_pcb_pipeline_imports_build_placement_plan`; `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`, `test_phase_b_rule_catalog`; `tests/test_interface_class_check.py` `test_merge_drops_duplicate_pcb_interface_findings`; `tests/test_pcb_sse_terminal.py` (4).
|
||||
|
||||
### 2.1 Layout ingest (non DRC)
|
||||
|
||||
`parsers_kicad_pcb.py` → `LayoutGraph`. Nessun default 1 oz.
|
||||
|
||||
- Test: `tests/test_kicad_pcb.py` (14); `tests/test_pcb_via_not_pad.py` (geometria via≠pad); `tests/test_pcb_software_bugs.py` `test_kicad10_name_only_nets_fill_index`.
|
||||
|
||||
### 2.2 Net / footprint — `pcb_net_match.py` + `pcb_checks._incomplete_layout_finding`
|
||||
|
||||
#### `PE-LAY-001`
|
||||
|
||||
Pad PCB su net diverso dallo schema (stesso pin).
|
||||
Skip se: layout assente.
|
||||
|
||||
- Test: `tests/test_pcb_review.py` — `test_pad_net_mismatch_is_pe_lay_001`, `test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001`.
|
||||
|
||||
#### `PE-LAY-002`
|
||||
|
||||
Ref in schema, nessun footprint (tranne #PWR). Distingue “manca sulla PCB”.
|
||||
|
||||
- Test: `tests/test_pcb_review.py` `test_missing_footprint_is_pe_lay_002`; `tests/test_incomplete_pcb.py` `test_unplaced_ref_is_lay_002_not_invented_xy`.
|
||||
|
||||
#### `PE-LAY-003`
|
||||
|
||||
Stesso net, solo prefisso `/sheet/` KiCad (INFO, una volta).
|
||||
|
||||
- Test: `tests/test_pcb_review.py` `test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad`.
|
||||
|
||||
#### `PE-LAY-004` — `pcb_checks.py` (**non in catalogo `finding_engine`**)
|
||||
|
||||
Pad net senza rame (track **o** via **o** zone). Skip NC `unconnected-(…)`. INFO INSUFFICIENT, non FAIL inventato.
|
||||
|
||||
- Test: `tests/test_incomplete_pcb.py` `test_run_pcb_checks_on_partial_layout_does_not_invent_tracks`; `tests/test_pcb_software_bugs.py` — `test_lay004_zone_counts_as_copper`, `test_lay004_skips_unconnected_nc_and_matches_sheet_prefix`, `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude`.
|
||||
|
||||
### 2.3 Placement vs `layout_rules` — `placement_check.py`
|
||||
|
||||
Senza mm in `layout_rules`: skip. Niente 3 mm default. Distanza euclidea pad-pad **non** usata.
|
||||
|
||||
#### `PE-PLC-001`
|
||||
|
||||
Path rame (segmenti) vs `max_distance_mm`.
|
||||
|
||||
- Test: `tests/test_placement_check.py` — `test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001`, `test_crystal_load_cap_within_max_distance_mm_is_silent`, `test_track_path_longer_than_max_distance_mm_is_ps_plc_001`; `tests/test_pcb_review.py` `test_close_decoupling_has_no_plc_001`; `tests/test_placement_check.py` `test_simple_project_without_pcb_has_no_ps_plc`.
|
||||
|
||||
#### `PE-PLC-002`
|
||||
|
||||
Via in courtyard vs `min_via_count`. Skip senza vertici courtyard e senza min.
|
||||
|
||||
- Test: `tests/test_placement_check.py` `test_via_count_is_calculated_from_courtyard_and_min_parameter` (geometria `_in_poly`; non emette ancora il finding end-to-end).
|
||||
|
||||
#### `PE-PLC-003`
|
||||
|
||||
`same_layer` decoupling.
|
||||
|
||||
- Test: `tests/test_placement_check.py` — `test_same_layer_param_opposite_layers_is_ps_plc_003`, `test_same_layer_param_same_copper_is_silent`, `test_opposite_layers_without_same_layer_param_is_silent`, `test_opposite_layers_with_via_in_courtyard_is_silent`.
|
||||
|
||||
#### `PE-PLC-004`
|
||||
|
||||
Keepout: net estraneo nel courtyard. Non è DRC rame-pad.
|
||||
|
||||
- Test: `tests/test_placement_check.py` — `test_keepout_foreign_track_in_courtyard_is_ps_plc_004`, `test_ic_courtyard_pad_nets_are_not_keepout_violations`, `test_keepout_own_net_in_courtyard_is_silent`, `test_keepout_without_courtyard_is_silent`.
|
||||
|
||||
#### `PE-PLC-005` (**non in catalogo `finding_engine`**)
|
||||
|
||||
Proximity non misurabile perché manca la **traccia**.
|
||||
|
||||
- Test: `tests/test_placement_check.py` `test_unrouted_crystal_cap_is_insufficient_not_euclidean`.
|
||||
|
||||
Schema runner omette i check layout: `tests/test_placement_check.py` `test_schema_deterministic_runner_omits_layout_checks`.
|
||||
|
||||
### 2.4 SI — `si_check.py` + ImpedenceFinder
|
||||
|
||||
Gate: `layout_rules` kind + `net_class`/quote sul **bus citato**. Skip I2C/GPIO/EN/CC/strap RC. Bus: USB2, USB3, ETH MDI, RGMII, SGMII, DDR3 CLK/DQS/DQ/ADDR, HDMI, PCIe, LVDS; 2.62.0 aggiunge MIPI e clock HF (`bus_class`); XTAL/OSCIN fuori.
|
||||
|
||||
Z0: `impedance.py` / `impedance_traces.py`. CPWG → errore, non numero inventato. Senza stackup: skip misura.
|
||||
|
||||
Suite gate: `tests/test_si_check.py` `test_skip_i2c_gpio_cc_regn`, `test_i2c_not_checked_as_50_ohm`, `test_simple_project_without_pcb_has_no_ps_si_001`; `tests/test_hf_line_check.py` `test_bus_class_mipi_hf_clk_not_xtal`. Calcolatrice: `tests/test_impedance.py` (20); misura net: `tests/test_impedance_traces.py` (10).
|
||||
|
||||
#### `PE-SI-001` length_match / skew coppia
|
||||
|
||||
Skip senza mm datasheet.
|
||||
|
||||
- Test: `tests/test_si_check.py` `test_length_match_2_5mm_vs_1mm_is_fail`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
|
||||
|
||||
#### `PE-SI-002` Zavg/min/max vs finestra datasheet
|
||||
|
||||
Mai folklore 90 Ω.
|
||||
|
||||
- Test: `tests/test_si_check.py` `test_emmaforo_usb_avg_in_window_min_out_is_margin`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
|
||||
|
||||
#### `PE-SI-003` lunghezza max mm
|
||||
|
||||
- Test: `tests/test_si_check.py` `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
|
||||
|
||||
#### `PE-SI-004` spacing intra-coppia mm
|
||||
|
||||
Skip senza mm. Copertura indiretta nella stessa suite SI gated.
|
||||
|
||||
#### `PE-SI-005` piano di riferimento / layer
|
||||
|
||||
Skip senza FACT datasheet.
|
||||
|
||||
#### `PE-SI-006` conteggio via HS vs min/max datasheet
|
||||
|
||||
Skip senza min/max.
|
||||
|
||||
#### `PE-SI-008` return via accanto alla coppia (`return_path`)
|
||||
|
||||
Skip senza kind.
|
||||
|
||||
#### `PE-SI-009` serie R ohm sul net HS (non EN-RC)
|
||||
|
||||
- Test: `tests/test_si_check.py` `test_en_rc_series_resistor_does_not_paint_usb`.
|
||||
|
||||
#### `PE-SI-010` bus HS misurato, libreria senza FACT Z → INFO, non FAIL 90 Ω
|
||||
|
||||
- Test: `tests/test_si_check.py` `test_usb_without_library_z_is_insufficient_not_90ohm_fail`, `test_empty_net_class_impedance_does_not_paint_usb`; `tests/test_hf_line_check.py` `test_existing_si_usb_not_polluted_by_hf_geometry`.
|
||||
|
||||
Refresh extract SI vecchio: `tests/test_pcb_plan_closeout.py` `test_si_refresh_when_old_extract_has_only_decoupling`, `test_si_extract_needed_skips_board_ics_only_not_shared_library`.
|
||||
|
||||
### 2.5 Linee HF extra — `hf_line_check.py` (2.62.0 Fase A)
|
||||
|
||||
Track ≠ via ≠ pad ≠ zona. Stub = traccia dangling.
|
||||
|
||||
#### `PE-SI-007` stub vs mm datasheet
|
||||
|
||||
INFO se misura c’è e mm mancano (INSUFFICIENT). Skip: pad-pad, via, zone.
|
||||
|
||||
- Test: `tests/test_hf_line_check.py` — `test_no_layout_skips_hf_geometry`, `test_pad_to_pad_usb_has_no_stub`, `test_via_is_not_a_stub`, `test_zone_is_not_a_track_stub`, `test_dangling_track_stub_vs_datasheet_mm_is_fail`, `test_measured_stub_without_datasheet_mm_is_insufficient`.
|
||||
|
||||
#### `PE-SI-011` gap pour GND sotto coppia HF (REVIEW)
|
||||
|
||||
Skip senza stackup/zone.
|
||||
|
||||
- Test: `tests/test_hf_line_check.py` `test_split_under_pair_needs_stackup_and_zone`.
|
||||
|
||||
#### `PE-SI-012` terminazione BOM serie/parallelo FACT
|
||||
|
||||
Skip se manca il terminatore (non si inventa 50 Ω).
|
||||
|
||||
- Test: `tests/test_hf_line_check.py` `test_bom_termination_is_fact_missing_is_skip`.
|
||||
|
||||
Catalogo HF: `tests/test_hf_line_check.py` `test_rule_catalog_hf_ids`. DDR/CPU/FPGA: §1.4 (stesso `check_memory_fpga_classes` in `run_pcb_checks`).
|
||||
|
||||
### 2.6 Potenza / via / termico sul board — `pcb_power_thermal.py`
|
||||
|
||||
#### `PE-PWR-001` (PCB) — `check_pcb_power_traces`
|
||||
|
||||
Larghezza **traccia** × spessore rame vs I_load/Imax/I_abs. IPC-2221 solo con thickness **e** ΔT da Tjmax. Iout_max non è carico. Niente 500 mA USB.
|
||||
|
||||
Skip se: manca I datasheet (**skip vero**, non INFO INSUFFICIENT — 2.61.1).
|
||||
|
||||
- Test: `tests/test_pcb_review.py` — `test_power_trace_ipc2221_errors_when_load_exceeds_ampacity`, `test_power_trace_uses_parsed_width_and_thickness`, `test_power_trace_skips_without_i_load`, `test_power_trace_skips_iout_max_as_load`, `test_power_trace_uses_imax_when_i_load_absent`, `test_power_trace_uses_i_abs_when_i_load_absent`; `tests/test_interface_class_check.py` `test_usbc_vbus_does_not_invent_usb_500ma`.
|
||||
|
||||
#### `PE-VIA-001` — `check_pcb_via_current`
|
||||
|
||||
Se I c’è e sul net **zero via**: INFO conteggio. Se le via ci sono: silenzio — **nessuna tabella ampacity**. Via ≠ traccia.
|
||||
|
||||
Skip se: manca I.
|
||||
|
||||
- Test: `tests/test_pcb_software_bugs.py` `test_via_current_skips_without_i_load`.
|
||||
|
||||
#### `PE-VIA-002` (2.62.1)
|
||||
|
||||
I + conteggio/drill FACT. Tabella IPC via **non** applicata.
|
||||
|
||||
Skip se: manca I.
|
||||
|
||||
- Test: `tests/test_pcb_phase_b.py` `test_via_current_skips_without_i_does_not_invent_ipc`.
|
||||
|
||||
#### `PE-THM-001` — `check_pcb_thermal_copper`
|
||||
|
||||
P = I_load × drop; courtyard senza pour/via.
|
||||
|
||||
Skip se: P sconosciuta.
|
||||
|
||||
- Test: `tests/test_pcb_review.py` `test_thermal_copper_warns_without_pour_or_vias`.
|
||||
|
||||
#### `PE-THM-002` — `check_pcb_junction_temp`
|
||||
|
||||
Tj = Ta + P·θJA solo con P, θJA, area rame, conteggio via. Non è FEM.
|
||||
|
||||
Skip/INSUFFICIENT se manca un fattore.
|
||||
|
||||
- Test: `tests/test_pcb_plan_closeout.py` — `test_tj_when_theta_copper_vias_exist`, `test_tj_insufficient_without_theta`.
|
||||
|
||||
#### `PE-KEL-001`
|
||||
|
||||
Pin Kelvin/sense sullo stesso net di carico. Ignora CRS/IN±.
|
||||
|
||||
- Test: `tests/test_pcb_review.py` `test_kelvin_sense_pin_on_shared_net`; `tests/test_pcb_software_bugs.py` `test_kelvin_ignores_crs_and_in_plus`.
|
||||
|
||||
#### `PE-STCH-001`
|
||||
|
||||
Segnale sopra pour GND senza via GND nel bbox. Skip GPIO minuscoli.
|
||||
|
||||
- Test: `tests/test_pcb_review.py` — `test_gnd_stitch_skips_tiny_gpio`, `test_gnd_stitch_info_when_signal_has_pour_but_no_via`.
|
||||
|
||||
### 2.7 Derating / gerarchia / timing / PI
|
||||
|
||||
#### `PE-DRT-001` / `PE-DRT-002` / `PE-DRT-003` — `pcb_checks.check_pcb_derating` + `derating.py`
|
||||
|
||||
Vop vs Vrated: ERROR / MARGIN / PASS INFO. Niente % dielettrico inventato. Skip se manca Vop o Vr.
|
||||
|
||||
- Test: `tests/test_fase_b_checks.py` `test_derating_pass_margin_risk`; `tests/test_derating.py` (7, C_eff stima — non è un PE-* a parte).
|
||||
|
||||
#### `PE-HIER-001` — `hierarchy.py`
|
||||
|
||||
Pin IC senza net nominato.
|
||||
|
||||
- Test: `tests/test_fase_b_checks.py` `test_hierarchy_component_pin_net_block`.
|
||||
|
||||
#### `PE-TIM-001` / `PE-TIM-002` — `timing_check.py`
|
||||
|
||||
001 RC reset vs t_reset; 002 strap vs Vih/Vil.
|
||||
Skip senza R/C e t_reset o Vih/Vil.
|
||||
|
||||
- Test: `tests/test_fase_b_checks.py` — `test_timing_skips_without_numbers`, `test_timing_reset_rc_vs_t_reset`. **`PE-TIM-002` non ha un test che fa scattare il finding** (solo skip condiviso).
|
||||
|
||||
#### `PE-PI-001` / `PE-PI-002` — `pi_check.py`
|
||||
|
||||
001 cap locale sul rail IC; 002 locale vs bulk se i valori C esistono. Distanza mm solo da `layout_rules` (altrimenti è PE-PLC-001). Skip net NC.
|
||||
|
||||
- Test: `tests/test_fase_b_checks.py` — `test_pi_missing_local_is_risk_not_error`, `test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local`, `test_pi_bulk_only_does_not_claim_no_local`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`. **`PE-PI-002` non ha un assert dedicato sul `rule_id`.**
|
||||
|
||||
### 2.8 ESD / return / EMI / SPOF
|
||||
|
||||
#### `PE-ESD-001` — `esd_return_check.check_esd`
|
||||
|
||||
Net J* ∩ IC senza parte ESD: REVIEW, non ERROR. Niente GPIO/NC/rail spam.
|
||||
|
||||
- Test: `tests/test_fase_b_checks.py` — `test_esd_without_part_is_review_not_error`, `test_esd_skips_gpio_nc_unconnected_and_onboard_power`, `test_esd_one_finding_per_connector_ic_path`, `test_esd_skips_when_protection_part_on_net`.
|
||||
|
||||
#### `PE-ESD-002` (2.62.1) — `check_esd_distance`
|
||||
|
||||
Distanza **pad** TVS–connettore (via ≠ pad). INFO se no mm.
|
||||
|
||||
- Test: `tests/test_pcb_phase_b.py` `test_esd_distance_uses_pads_not_vias`.
|
||||
|
||||
#### `PE-RET-001` — `check_return_path`
|
||||
|
||||
Net HS sopra pour GND senza via GND nel bbox: REVIEW.
|
||||
|
||||
- Test: `tests/test_fase_b_checks.py` `test_return_path_hs_is_review`.
|
||||
|
||||
#### `PE-EMI-001` — `emi_check.py`
|
||||
|
||||
Solo se `layout_rules` cita choke/ferrite/shield. Non IEC 61000. Skip NC.
|
||||
|
||||
- Test: `tests/test_pcb_plan_closeout.py` `test_emi_only_with_datasheet_fact`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`.
|
||||
|
||||
#### `PE-SPOF-001` — `spof_check.py`
|
||||
|
||||
Un regolatore o XTAL condiviso: REVIEW, mai ERROR.
|
||||
|
||||
- Test: `tests/test_pcb_plan_closeout.py` `test_spof_review_single_ldo`.
|
||||
|
||||
### 2.9 BOM ↔ PCB ↔ datasheet — `bom_pcb_check.py`
|
||||
|
||||
Skip se manca un lato (BOM, footprint, o numero datasheet).
|
||||
|
||||
#### `PE-BOM-010` famiglia package
|
||||
|
||||
VQFN/WQFN/HVQFN = QFN. LQFP ≠ QFN.
|
||||
|
||||
- Test: `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_bom_011_named_join.py` `test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010`.
|
||||
|
||||
#### `PE-BOM-011` pintable vs pad, join per nome
|
||||
|
||||
Join datasheet pintable × `.kicad_mod` lib × pad embedded. Non `pin_count` scalare. A5/B5 USB-C sono pin, non EP. EP/SH/mount classificati; via ≠ pad. Senza pintable: skip.
|
||||
|
||||
- Test: `tests/pcb/test_bom_011_named_join.py`; `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/pcb/test_pcb_via_not_pad.py`.
|
||||
|
||||
#### `PE-BOM-012` Vop vs rating/abs-max legato al **pin**
|
||||
|
||||
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`; `tests/test_pcb_software_bugs.py` `test_vddcr_absmax_does_not_bind_3v3_io`.
|
||||
|
||||
#### `PE-BOM-013` Top vs abs-max T
|
||||
|
||||
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`.
|
||||
|
||||
#### `PE-BOM-014` I_load vs rating
|
||||
|
||||
Skip se manca I.
|
||||
|
||||
- Test: `tests/test_pcb_plan_closeout.py` — `test_abs_max_voltage_and_temp_and_current`, `test_inductor_current_rating`.
|
||||
|
||||
### 2.10 Resto Fase B (2.62.1)
|
||||
|
||||
#### `PE-STK-001` — `stackup_check.py`
|
||||
|
||||
Spessore rame board vs stackup fab sourced. Mai 1 oz default.
|
||||
Skip se: niente stackup layout o niente FACT fab.
|
||||
|
||||
- Test: `tests/test_pcb_phase_b.py` — `test_stackup_skips_without_fab_spec`, `test_stackup_vs_fab_spec_is_review_not_invented_oz`.
|
||||
|
||||
#### `PE-ANT-001` / `PE-ANT-002` — `antenna_layout_check.py`
|
||||
|
||||
Keepout zona e matching L/C sul feed. Solo se antenna/net RF sul grafo. Niente 50 Ω inventati.
|
||||
Skip se: antenna assente; matching manca e non c’è FACT (002 non emette).
|
||||
|
||||
- Test: `tests/test_pcb_phase_b.py` — `test_antenna_skips_when_absent`, `test_antenna_keepout_and_match_are_facts`.
|
||||
|
||||
#### `PE-PDN-001` — `pdn_check.py`
|
||||
|
||||
Z(f) solo con FACT in frequenza. Z0 SI **non** è PDN.
|
||||
|
||||
- Test: `tests/test_pcb_phase_b.py` `test_pdn_skips_without_zf`.
|
||||
|
||||
Silenzio Fase B se assente: `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`. Catalogo: `test_phase_b_rule_catalog`.
|
||||
|
||||
### 2.11 Review AI PCB
|
||||
|
||||
Modulo: `pcb_review.py` + `services/pcb_validation.py`. Stesso oggetto finding. Spiega FACT deterministici; **non** rilegge il PDF; non inventa interfacce / PoE / SuperSpeed / mm / Z / I.
|
||||
|
||||
Skip se: niente JSON libreria per l’IC.
|
||||
|
||||
- Test: `tests/test_pcb_review.py` — `test_pcb_ai_finding_gets_action`, `test_parse_review_action_field_without_recommendation`, `test_layout_context_includes_via_counts`, `test_layout_context_includes_domain_and_group`, `test_pcb_ai_skips_without_library_extraction`, `test_library_constraints_fill_from_storage`, `test_inventory_lists_net_length_and_pair`; `tests/test_finding_engine.py` `test_pcb_review_ai_finding_action_without_recommendation`; `tests/test_pcb_software_bugs.py` `test_thinking_reasoning_content_echo_still_present`.
|
||||
|
||||
### 2.12 Tab antenna RF (non in `run_pcb_checks`)
|
||||
|
||||
Moduli: `antenna_rf.py`, `antenna_geometry.py`. Topologia matching + ricetta routing. No EM/VSWR/CPWG. Distinto da `PE-ANT-001/002`.
|
||||
|
||||
- Test: `tests/test_antenna_rf.py` — `test_verify_finds_matching_path_to_ant_footprint`, `test_verify_missing_matching_is_warning`, `test_design_recipe_needs_marker_without_ant`, `test_design_recipe_ready_with_ant_and_stackup`, `test_geometry_templates_produce_export`, `test_geometry_overflow_tiny_zone`, `test_design_recipe_meander_template`.
|
||||
|
||||
Placement/auto-place **non** è esame PCB: `tests/test_placement_pack.py`, `tests/test_placement_pipeline.py` (fuori da questo albero di analisi).
|
||||
|
||||
---
|
||||
|
||||
## Motore finding (condiviso)
|
||||
|
||||
`finding_engine.py`: FACT / REQUIREMENT / INFERENCE; ERROR solo RULE+MANDATORY; `decisions.json` non ri-naga.
|
||||
|
||||
- Test: `tests/test_finding_engine.py` (11); `tests/test_finding_schema.py` (7); `tests/test_fase_b_checks.py` `test_sort_findings_error_then_class`; `tests/test_review_workflow.py` (10 — stati finding UI, non check PE). CAD bridge: `tests/test_cad_bridge.py` (5).
|
||||
|
||||
---
|
||||
|
||||
## Test che esistono ma non sono analisi
|
||||
|
||||
`tests/` ha **114 file / ~730 test**. Oltre ai nodi sopra restano overlay nativi, upload, auth, billing seam, UI rewrite. Non sono check PE-*.
|
||||
|
||||
**Upload / ingest cartella** (non analisi): `tests/test_periscope_frontend_upload_rewrite.py` `test_upload_zone_is_src`; `tests/test_kicad_project.py` (picker cartella, `.history`, Safari stem); `tests/test_netlist_bundle.py` (zip, zip-slip); `tests/test_kicad_pcb.py` `test_upload_pcb_sets_has_pcb`, `test_upload_sch_as_pcb_is_rejected`; `tests/test_purple_parts_resolver.py` (upload BOM/LCSC); `tests/test_reopen_replace.py` `test_reopen_then_replace_bom_and_netlist`.
|
||||
|
||||
**Auth**: i test auth esistono (`tests/test_local_auth.py`, `tests/test_periscope_auth_rewrite.py`, `tests/test_pinscope_compat.py`, più overlay che tengono il contratto login). Non si descrivono segreti, JWT, password, o flussi di autenticazione.
|
||||
|
||||
Altri non-analisi: rewrite frontend/Docker (`test_periscope_*_rewrite.py`, `test_periscope_leftover_package_requirements.py`, `test_periscope_boot_without_dependency_product.py`); delete progetto; cost estimator; event bridge; job runner; `test_deepseek_only.py` (routing modello, non finding).
|
||||
|
||||
---
|
||||
|
||||
## Conteggio nodi
|
||||
|
||||
| Gruppo | Nodi |
|
||||
| --- | --- |
|
||||
| Check solo schema (27 PE-* + LED senza ID) | 28 |
|
||||
| USB-C / ETH / PoE (schema e PCB) | 13 |
|
||||
| `PE-PWR-001` (due source: schema e PCB) | 1 |
|
||||
| DDR / CPU / FPGA / PD (grafo, invocati da `run_pcb_checks`) | 11 |
|
||||
| Check solo PCB (layout, SI/HF, potenza, Fase B, …) | 47 |
|
||||
| Processo: 3 skill + DigiKey + value + IC AI + PCB AI + tab RF | 8 |
|
||||
| **Totale nodi analisi** | **108** |
|
||||
|
||||
Check PE-* in codice: **99** (tutti elencati). Più LED senza `rule_id`. `PE-LAY-004`, `PE-PLC-005`, `PE-TH-002` sono in codice; `PE-LAY-004` / `PE-PLC-005` non sono in `_seed()` di `finding_engine.py`.
|
||||
|
||||
Buchi di test noti (il check c’è, il fire test no): `PE-TIM-002`, `PE-PI-002` (nessun assert `rule_id`), `PE-PLC-002` (solo geometria `_in_poly`), `PE-SI-004` / `PE-SI-005` / `PE-SI-006` / `PE-SI-008` (gate SI, niente caso dedicato).
|
||||
@@ -0,0 +1,142 @@
|
||||
# Conformità coding — Periscope intero
|
||||
|
||||
Percorso locale: `/Users/michelebigi/Development/periscope/docs/conformita-coding.md`.
|
||||
|
||||
Costituzione: `docs/development/CODING_CONSTITUTION.md` (*code that fits in your head*). **Per sezione**, non per funzione: conforme sì / no e perché. Taglio 2.62.1 + questa macro-fase (libreria come porta, test riorganizzati, AF+AI extra). Auth/JWT/users: **non toccati**; la sezione esiste e viene giudicata, non modificata.
|
||||
|
||||
Giudizio: **conforme** = un ingegnere legge la sezione senza ricostruire un sistema nascosto. **Non conforme** = troppe responsabilità, stato implicito, eccezioni ingoiate, o semantica a rischio. Un file lungo può essere conforme se il flusso è ovvio; uno corto no se fa tre mestieri.
|
||||
|
||||
Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non si “passa” la costituzione dichiarandole OK.
|
||||
|
||||
---
|
||||
|
||||
## 1. Albero nativo / dependency
|
||||
|
||||
**Non conforme.** `periscope/src` (nativo) e `periscope/dependency` (PinScope ereditato) si fondono con `sys.path` + `pkgutil.extend_path`. Chi vince dipende dall’ordine di insert (`backend/__init__.py` vs `tests/conftest.py`). Due `projects.py`, due `pipeline.py`, due `extraction.py`. Per capire un import serve la mappa dei shadow, non il modulo. È un adattatore temporaneo con path di rimozione (Fase C) ma **oggi** non sta in testa.
|
||||
|
||||
## 2. Glue di root (Docker, compose, `backend/__init__.py`, script)
|
||||
|
||||
**Conforme a metà.** `backend/__init__.py` è corto e dice il mestiere. `scripts/update-periscope.sh` è una procedura esplicita (`/root/periscope`, niente `data/`). `docker-compose.yml` è piccolo. Il debito è il merge dei due tree a boot, non gli script.
|
||||
|
||||
## 3. Motore finding (`periscopex/finding_engine.py`, schema)
|
||||
|
||||
**Conforme.** Un oggetto finding, FACT / REQUIREMENT / INFERENCE, clamp verso il basso, `INSUFFICIENT`. Flusso visibile. Test stretti su tipo, codice, severity. ~450 righe: lungo ma un mestiere.
|
||||
|
||||
## 4. Modello semantico (`models.py`)
|
||||
|
||||
**Conforme.** Pad, via, track, zone, pin, net sono tipi distinti. Pydantic, niente god-class UI. ~534 righe di dati, non di orchestrazione.
|
||||
|
||||
## 5. Parser schematico (PADS, EDIF, KiCad sch)
|
||||
|
||||
**Conforme a metà.** Funzioni pure, fixture `simple_project`. `parsers_kicad.py` (~760) e `parsers_edif.py` (~465) sono grandi; il mestiere resta “testo → grafo”. Non classificano via PCB come pad (coperto da test rewrite). Debito: taglia dei file, non la semantica.
|
||||
|
||||
## 6. Parser PCB KiCad (`parsers_kicad_pcb.py`)
|
||||
|
||||
**Conforme.** Via ≠ pad è esplicito e testato. Un input `.kicad_pcb` → `LayoutGraph`. Non è DRC. ~446 righe.
|
||||
|
||||
## 7. Grafo (`graph.py`, `netlist_bundle.py`)
|
||||
|
||||
**Conforme.** Costruzione da BOM+netlist, helper di attraversamento. Ferrite Z è un innesto a parte (`ferrite_z.py`), non un if U1.
|
||||
|
||||
## 8. Check deterministici PCB (`pcb_checks.py` + moduli `PE-*`)
|
||||
|
||||
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
|
||||
|
||||
AF Board+AI (`af_ai_hf.py`) è **additivo**: `pcb_pipeline` chiama `append_investigated` dopo `run_pcb_checks`. Non entra nella lista 2.62.1. Non è DRC.
|
||||
|
||||
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
|
||||
|
||||
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
|
||||
|
||||
**Conforme.** Un file ≈ un check, grafo in → finding out, niente mm inventati. Alcuni file >400 righe (`passive_rail_check`, `filter_check`) ma il flusso è lo stesso.
|
||||
|
||||
## 10. Review AI (validate, pcb_review, review_tools)
|
||||
|
||||
**Non conforme come sezione unica.** L’autorità deterministica è nei check; l’LLM spiega — questo è il disegno giusto (costituzione §8). In pratica `review_tools.py` (~940) e `validation.py` (dependency + native) sono loop di tool + state. `pcb_review.py` (~327) è il prompt + vicinato: quello sta in testa. Il loop live dipende ancora da pezzi ereditati. AF+AI (ipotesi HF → indagine deterministica) è un modulo extra, non sostituisce `run_pcb_checks`.
|
||||
|
||||
## 11. Estrazione datasheet (skills, `datasheet_extract.py`, `extraction.py`)
|
||||
|
||||
**Non conforme.** `datasheet_extract.py` ~1322 righe e `extraction.py` ereditato ~1298: prompt, tool, coerce, taxonomy, auto-resolve nello stesso file. Il mestiere “PDF → JSON libreria” è uno, l’implementazione no. Native vs inherited duplicato finché il pipeline non switcha. **Conforme nel ruolo:** LLM estrae, non è il verificatore.
|
||||
|
||||
## 12. Libreria componenti (store + porta HTTP/UI)
|
||||
|
||||
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e l’empty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
|
||||
|
||||
**Dopo 2.63.1: conforme come porta.** `POST /api/library/datasheets` registra una scheda (inbox IC o modello passivo/discreto) senza esame. Pintable vuota non va in `library/extracted/` (`library_gate`). PUT promuove inbox → extracted. `projects.py` re-export resta debito. `list_library_catalog` logga e salta JSON rotti.
|
||||
|
||||
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
|
||||
|
||||
**Non conforme.** `pipeline.py` ~1522 righe: stage, storage, LLM, copy-in-library, graph. `pcb_pipeline.py` è più lineare (ensure_graph → parse → checks → **af_ai additivo** → AI → report) e si segue. Job/SSE sono un secondo asse di stato. Flusso reale: sì, ma non locale. `run_pcb_checks` 2.62.1 non è stato svuotato.
|
||||
|
||||
## 14. Storage (`storage.py`, GCS)
|
||||
|
||||
**Conforme.** Backend con chiavi stringa, locale vs GCS. Prefissi `users/…/projects/` e `library/` visibili.
|
||||
|
||||
## 15. HTTP — progetti, pipeline, report, impedance
|
||||
|
||||
**Non conforme per `routers/projects.py` (~1004)** e `pipeline.py` router (~783): troppi mestieri (CRUD, upload KiCad, DigiKey, LCSC). Report e impedance sono sezioni più piccole. La nuova `routers/library.py` è la porta libreria (un mestiere).
|
||||
|
||||
## 16. Auth / JWT / users
|
||||
|
||||
**Non toccata in questa macro-fase (vincolo).** La sezione **non è conforme** alla costituzione (Clerk + JWT locale + `admin.py` users nello stesso router, middleware che decide tre modi). Non si “sistema” qui. Non si aggiunge auth alla libreria.
|
||||
|
||||
## 17. Frontend — shell e pagina libreria
|
||||
|
||||
**Conforme a metà.** Overlay `src` su `dependency` (materialize) è un secondo albero da tenere in testa. Pagine progetto/report/dashboard sono lunghe ma a mestiere. `/library` era browse-only; ora è porta (import + modifica scheda) con form/editor spezzati. `admin/page.tsx` ~1780: **non conforme** (libreria + users + usage). Non toccato (users).
|
||||
|
||||
## 18. Frontend `api.ts` / types
|
||||
|
||||
**Non conforme.** `api.ts` nativo ~1179 righe, client unico per tutto. Types allineati ai modelli: sì. Un file non sta in testa. Nuove funzioni libreria restano lì per non inventare un secondo client.
|
||||
|
||||
## 19. Skills e taxonomy
|
||||
|
||||
**Conforme.** `skills/*/SKILL.md` + `validate.py` in-process; taxonomy JSON per tipo. `repo_paths` al posto di `Path(__file__)` magici (test rewrite). Non si chiama `upload_skills.py`.
|
||||
|
||||
## 20. Vendor ImpedenceFinder
|
||||
|
||||
**Conforme come confine, non come licenza.** Core Z0 chiuso, non FEM, non secondo set di formule (test). LICENSE UNKNOWN resta debito legale, non di forma del codice.
|
||||
|
||||
## 21. Test
|
||||
|
||||
**Prima: non conforme come mappa.** Un flat `tests/test_*.py` mescolava datasheet, libreria, schema, PCB, rewrite, auth. Copertura vera, ordine mentale no.
|
||||
|
||||
**Dopo: conforme come mappa, copertura tenuta.**
|
||||
|
||||
| Cartella | Mestiere |
|
||||
| --- | --- |
|
||||
| `tests/datasheet/` | preliminare PDF / pin / abs-max / I / Z / ferrite / DigiKey |
|
||||
| `tests/library/` | porta libreria (anche senza esame) |
|
||||
| `tests/schematic/` | net, BOM, review IC, check su grafo |
|
||||
| `tests/pcb/` | `run_pcb_checks`, geometria, SI/HF già in 2.62.1 |
|
||||
| `tests/impedancefinder/` | vendor Z0 (stesso nome package del vendor — non va sotto `pcb/`) |
|
||||
| `tests/af_ai/` | extra: ipotesi HF → indagine deterministica (non DRC, no Z inventata) |
|
||||
| `tests/` root | identity/rewrite/auth/job — non analisi |
|
||||
|
||||
`conftest.py` e `paths.py` restano in root. Test stretti (codice finding, via≠pad) restano la regola; i rewrite “il file sta in src” sono deboli ma sono recinti di albero, non di fisica.
|
||||
|
||||
## 22. Costituzioni Cursor (`.cursor/rules`, questo doc)
|
||||
|
||||
**Conforme.** Un master Markdown, `.mdc` operativi, niente Rust speculativo. Questo file è il giudizio, non una seconda costituzione.
|
||||
|
||||
## 23. Git / deploy
|
||||
|
||||
**Conforme al testo, fragile in pratica.** Commit+push a fine fase; deploy a fine macro-fase; no force. Il worktree locale era un pointer a `~/Development/pinscope` sparito: history recuperata da `github` `cursor/pcb-hf-analisi-675d` @ `20733f0` (2.62.1). Non si tocca `~/Development/pinscope` per edit di prodotto.
|
||||
|
||||
---
|
||||
|
||||
## Sintesi
|
||||
|
||||
| Sezione | Conforme |
|
||||
| --- | --- |
|
||||
| Finding engine + modelli + parser PCB | sì |
|
||||
| Check PE-* come moduli | sì (dispatcher fail-soft no) |
|
||||
| Skills/taxonomy/storage/Z0 vendor | sì |
|
||||
| Dual tree src/dependency | no |
|
||||
| Pipeline + extract ~1.3k + projects router | no |
|
||||
| Auth/admin users | no (non toccare) |
|
||||
| Libreria come porta | sì dopo questa fase (re-export debito) |
|
||||
| Test a cartelle di mestiere | sì dopo questa fase |
|
||||
|
||||
Priorità debito (non questa fase): spezzare `pipeline.py` / `datasheet_extract.py`; togliere lo shadow dependency quando un modulo nativo è provato; non ingoiare Exception nei check PCB (distinguere tool failure).
|
||||
|
||||
**Niente Rust in questa macro-fase** — vedi `docs/rust-criteri.md`. FEM fuori. DRC = KiCad.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
# Libreria componenti — porta prodotto
|
||||
|
||||
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi *usa* la libreria; non è l’unico modo per riempirla.
|
||||
|
||||
## Flusso
|
||||
|
||||
```text
|
||||
PDF + MPN + kind (ic | passive_part | simple)
|
||||
↓
|
||||
POST /api/library/datasheets
|
||||
↓
|
||||
blob MD5 + ref MPN
|
||||
↓
|
||||
scheda componente
|
||||
IC → library/inbox/{mpn}.json (niente pintable inventata)
|
||||
passive_part → library/passives/{mpn}.json
|
||||
simple → library/models/{mpn}.json
|
||||
```
|
||||
|
||||
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
|
||||
|
||||
`library/extracted/` si scrive **solo** con pintable valida (`library_gate`: ≥2 pin, almeno un nome). PUT `/api/library/components/ic/{mpn}` promuove inbox → extracted.
|
||||
|
||||
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
|
||||
|
||||
## Cosa non è
|
||||
|
||||
- Non è admin/Clerk/JWT (non si tocca auth).
|
||||
- Non è DRC, non è FEM.
|
||||
- Non inventa pin dal PDF. L’LLM prelim resta un mestiere a parte; la scheda c’è comunque.
|
||||
- Non sostituisce `library_has_*` usati dalla pipeline (`library_has_extraction` resta falso finché non c’è pintable).
|
||||
|
||||
## Test
|
||||
|
||||
`tests/library/` — catalogo, alias MPN, import PDF senza progetto (scheda IC in inbox, passivo in passives), GET/PUT scheda, promozione pintable, rifiuto PDF non valido / kind ignoto.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Piano di implementazione: analisi AF della traccia (v1)
|
||||
|
||||
DRC = KiCad. Periscope = analisi. **Stesura ora = Python.** Niente rustup, Auth/JWT, OpenEMS, FEM 3D, field solver 2.5D. Pad ≠ via ≠ track ≠ zone. Non si inventano Z, I, mm, εr, tr, f.
|
||||
|
||||
Michele 2026-09-22: piano ok con correzioni sotto. SI/HF 2.62 e `PE-AF-001` restano **additivi**. ImpedenceFinder vendored (`vendor/impedancefinder`) non si riscrive.
|
||||
|
||||
Repo: `~/Development/periscope`. Copia di questo file anche in `docs/piano-af-analisi.md`.
|
||||
|
||||
---
|
||||
|
||||
## Paletti obbligatori (Michele)
|
||||
|
||||
1. **Z target solo da datasheet** (`z0_ohm` / `zdiff_ohm` / finestra `z_min`–`z_max` in `layout_rules`). Manca → **skip del confronto Z**, finding visibile INSUFFICIENT, **niente 50/90 Ω**.
|
||||
2. **Coppia differenziale: i due membri insieme** (Zdiff, accoppiamento intra-coppia, lunghezze/skew). Mai una traccia sola se esiste il partner `_P/_N` o `+/-` sulla board.
|
||||
3. **Simulazione numerica sì, OpenEMS no.** Niente FEM 3D né solver di campo 2.5D della spec. Metodo contenuto: sezioni da ImpedenceFinder Z0(x) → RLGC lossless per sezione → cascade ABCD → S11/S21 **solo se** Z_ref (= target datasheet) è FACT.
|
||||
4. **C++ vs Rust:** valutati in §15. Implementazione v1 in Python. Niente rustup.
|
||||
5. **Skip AF non silenziosi.** In report: *«Pista ad alta frequenza non controllata per mancanza di …»* (tr, f, εr, stackup, Z target datasheet, span via). Finding **INSUFFICIENT / REVIEW**. Non INFO con Z/I inventati. **Se il trigger è falso** (calcolato con FACT e `l` sotto soglia) → **niente finding** AF su quella net/coppia.
|
||||
|
||||
---
|
||||
|
||||
## 1. Perimetro vs spec
|
||||
|
||||
La spec (`specifica-af.md`) descrive Gerber/ODB++, field solver 2.5D, S/TDR completi. Periscope esamina `.kicad_pcb` + grafo + libreria.
|
||||
|
||||
| Spec | v1 Periscope |
|
||||
| :--- | :--- |
|
||||
| Parser Gerber/ODB++ | No. KiCad `LayoutGraph`. |
|
||||
| Trigger λ/10 e tr/(6 tpd) | Sì, FACT only. |
|
||||
| Gomiti, taper, 3W, piano 3H | Sì, solo net/coppie **triggerate**. Non DRC. |
|
||||
| Via stub / L_via | Sì se drill + **span layer** + h. Altrimenti PE-AF-002 span via. |
|
||||
| Field solver 2.5D / OpenEMS | **Fuori.** |
|
||||
| RLGC + S-param | Cascade sezioni lossless + Z0 ImpedenceFinder. S11/S21 se Z_ref datasheet. |
|
||||
| TDR IFFT | Fuori. Profilo Z0(x) non si chiama TDR. |
|
||||
| Wheeler allegato B | Non usato. Hammerstad/Cohn del vendor. |
|
||||
|
||||
`run_pcb_checks` (SI/HF) **invariato**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Trigger
|
||||
|
||||
Candidati: net con rame **traccia**, non power/GND, non `skip_si_net` (I2C/GPIO/EN/CC/strap), non XTAL. Coppia = un’unità: `l = max(l_p, l_n)`; se un membro è candidato, lo è la coppia.
|
||||
|
||||
Costanti: c = 2.99792458×10⁸ m/s. `tpd = √εr_eff / c`. `λ = c / (f √εr_eff)`.
|
||||
|
||||
- λ/10 se `f` FACT oppure `f = 0.35/tr` con `tr` FACT, e εr_eff da stackup+geometria (ImpedenceFinder).
|
||||
- Rise time: `l ≥ tr / (6 tpd)` con `tr` FACT (non la forma tr·vp/2).
|
||||
|
||||
**Trigger vero** → analisi discontinuità / Z / cascade / via.
|
||||
|
||||
**Trigger falso** (entrambe le soglie calcolate e `l` sotto) → silenzio AF.
|
||||
|
||||
**Trigger non calcolabile** su un candidato (manca tr **e** f, o manca stackup/εr) → **PE-AF-002 visibile**, testo italiano con la lista di ciò che manca. Non è “trigger falso”.
|
||||
|
||||
---
|
||||
|
||||
## 3. Skip visibili (non silenziosi)
|
||||
|
||||
| Manca | Testo (es.) | Quando |
|
||||
| :--- | :--- | :--- |
|
||||
| stackup / εr | mancanza di stackup, εr | candidato, trigger non valutabile |
|
||||
| tr, f | mancanza di tr, f | candidato, serve almeno uno |
|
||||
| Z target datasheet | mancanza di Z target datasheet | trigger **vero**, niente confronto ±10% e niente S |
|
||||
| span via | mancanza di span via | trigger vero **e** c’è un via sulla net/coppia senza layers |
|
||||
|
||||
Niente ohm, niente ampere, niente 1 ns USB. Status WARNING, class REVIEW, `evidence_status=INSUFFICIENT` (il clamp del motore finding non deve promuovere RULE/ERROR).
|
||||
|
||||
`PE-AF-001` (AI Z0 senza stackup) resta; è già INSUFFICIENT visibile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Coppia differenziale
|
||||
|
||||
Partner: ImpedenceFinder + `partner_net`. Un finding per coppia, non due cloni. Zdiff da `diff_microstrip_z0` / `diff_stripline_z0`. Intra-coppia: lunghezze, non regola 3W (il mate non è aggressore). 3W solo vs **altre** net.
|
||||
|
||||
---
|
||||
|
||||
## 5. Simulazione numerica (non OpenEMS)
|
||||
|
||||
Per net/coppia triggerata con campioni Z0:
|
||||
|
||||
1. Sezioni di lunghezza campionata, Z0 ImpedenceFinder (flag `plane_broken` esclusi dal voto ohm).
|
||||
2. Lossless: `L' = Z0 · tpd`, `C' = tpd / Z0` per metro.
|
||||
3. ABCD di cascata; S11/S21 rispetto a **Z_ref = target datasheet**.
|
||||
4. Confronto ΔZ0 ±10% vs stessa finestra datasheet. Duplicato `PE-SI-002`: non secondo FAIL.
|
||||
|
||||
Senza Z_ref: PE-AF-002, cascade non pubblica S.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dati
|
||||
|
||||
- Stackup KiCad già parsato (`epsilon_r`, thickness; mai 1 oz default).
|
||||
- `LayoutVia.layers` da `(layers "F.Cu" "B.Cu")`. Size anulare ≠ drill ≠ track.
|
||||
- `tr`/`f` da `layout_rules` del driver sulla net (`rise_time`, `tr_ns`, `f_hz`, …) e `net_class` espanso come SI.
|
||||
|
||||
---
|
||||
|
||||
## 7. Finding PE-*
|
||||
|
||||
| ID | Ruolo |
|
||||
| :--- | :--- |
|
||||
| PE-AF-001 | AI Z0 senza evidenza (esistente) |
|
||||
| PE-AF-002 | Skip visibile “non controllata per mancanza di …” |
|
||||
| PE-AF-020 | Gomito ~90° |
|
||||
| PE-AF-021 | Gradino W / taper |
|
||||
| PE-AF-030 | `plane_broken` |
|
||||
| PE-AF-031 | Return 3H |
|
||||
| PE-AF-032 | `plane_split_nearby` |
|
||||
| PE-AF-040 | 3W vs aggressore (non il mate) |
|
||||
| PE-AF-050 | Z0/Zdiff vs finestra datasheet + cascade in `calculation` |
|
||||
| PE-AF-051 | CPWG/unknown: no ohm |
|
||||
| PE-AF-060 | L_via se drill+h+span |
|
||||
| PE-AF-061 | Via stub vs λ/20 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Moduli e pipeline
|
||||
|
||||
`af_trigger.py`, `af_rlgc.py`, `af_trace_check.py`. Dopo `run_pcb_checks`, prima `af_ai`. Non dentro la lista 2.62. Stage SSE `af_trace`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test HubAudio
|
||||
|
||||
Path opzionale `~/Development/HubAudio/.../HubAudio.kicad_pcb`. USB senza tr FACT → PE-AF-002 (tr/f), zero 90 Ω. GPIO → niente AF. Trigger sintetico corto con tr FACT → zero finding. Lunga + target datasheet → PE-AF-050. Coppia: un finding, non D+ solo. SI stub `PE-SI-007` resta.
|
||||
|
||||
---
|
||||
|
||||
## 10. Fasi v1 (questa implementazione)
|
||||
|
||||
Parser via layers; trigger; skip visibili; coppia; Z/cascade se target; gomiti/width; 3W; via se span; pipeline; changelog; pytest.
|
||||
|
||||
---
|
||||
|
||||
## 11. Fuori scope
|
||||
|
||||
OpenEMS, FEM, 2.5D, TDR IFFT, NEXT/FEXT in volt, 5W default, auto-miter sul PCB, Gerber/ODB++, Auth/JWT, rustup.
|
||||
|
||||
---
|
||||
|
||||
## 15. C++ vs Rust (valutazione; stesura Python)
|
||||
|
||||
Criteri (come `docs/rust-criteri.md`): geometria pad≠via≠track≠zone al confine; memoria/ownership su HubAudio; hot path profilato; determinismo; FFI grosso (struct in → struct out).
|
||||
|
||||
| Criterio | AF v1 (trigger + Shapely + cascade) | C++ | Rust |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| Geometria | Tipi già distinti in Python; Shapely nel vendor. Un port deve **non** fondere via/pad. | Possibile (CGAL/Clipper), confine pericoloso se si “semplifica” AABB. | Stesso rischio; ownership aiuta i buffer, non la semantica PCB. |
|
||||
| Memoria | HubAudio è un `.kicad_pcb` grande; il costo oggi è parse + zone Shapely, non la cascade (O(sezioni)). Nessuna misura che Python non basti. | Arena/SoA utili dopo profilo. | Stesso, dopo misura. |
|
||||
| Hot path | Non profilato. Candidati *futuri* restano parser PCB e point-in-poly, non PE-AF isolato. | Solo se il profilo lo dice. | Idem. |
|
||||
| Determinismo | Cascade ABCD e Z0 chiusi sono deterministici. | ok | ok (niente HashMap nel testo finding). |
|
||||
| FFI grosso | Un rewrite ora = due runtime + duplicare LayoutGraph. **Complicherebbe** (Michele). | pybind/nanobind, grosso se si passa tutta la board. | PyO3, stesso costo. ImpedenceFinder resterebbe Python/Shapely. |
|
||||
|
||||
**Decisione:** v1 Python. Nessun pezzo scelto per C++ o Rust. Michele può volere Rust un giorno; sequenza obbligatoria resta: Python corretto → misura HubAudio → profilo → scelta esplicita. ImpedenceFinder non si riscrive “per Rust”. OpenEMS non è candidato a nessun linguaggio in Periscope.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Criteri Rust — Periscope
|
||||
|
||||
Percorso locale: `/Users/michelebigi/Development/periscope/docs/rust-criteri.md`.
|
||||
|
||||
Costituzione: Python è il default. Rust **non** è un default. Questo documento dice *quando* un pezzo può entrare in Rust. Non sceglie ancora un pezzo. **Niente `rustup`, crate, né FFI finché una riga sotto non è scelta *e* misurata.**
|
||||
|
||||
FEM / OpenEMS / field solver: fuori dal prodotto. Non sono candidati Rust.
|
||||
|
||||
DRC: KiCad. Non è un candidato Rust in Periscope.
|
||||
|
||||
## Non solo velocità
|
||||
|
||||
Un bottleneck di CPU non basta. Un pezzo va in Rust solo se **tutti** i punti seguenti sono veri.
|
||||
|
||||
1. **Correttezza geometrica** — pad ≠ via ≠ traccia ≠ zona deve sopravvivere al confine. Se Rust collassa oggetti per “è più facile in un AABB”, il pezzo è rifiutato anche se è più veloce.
|
||||
2. **Memoria / ownership** — pressione reale (PCB grandi, segmenti, zone) o lifetime che Python non può possedere senza copie cieche. Non “magari un giorno”.
|
||||
3. **Parser o hot path** — il costo sta in un ciclo stretto (parse `.kicad_pcb`, walk segmenti, predicate geometriche) dopo profiling. Non nell’orchestrazione, non nell’I/O HTTP, non nell’LLM.
|
||||
4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dall’LLM.
|
||||
5. **Confine Python grosso** — una chiamata: struct in → struct out. Vietato un round-trip per pad, per via, per “is_point_in_poly” dal loop Python.
|
||||
|
||||
Se manca anche uno solo di questi, il pezzo resta in Python.
|
||||
|
||||
## Sequenza obbligatoria (costituzione §§33–36)
|
||||
|
||||
```text
|
||||
implementazione corretta in Python
|
||||
↓
|
||||
misura (wall + allocazioni, board reale: HubAudio / Emmaforo)
|
||||
↓
|
||||
profiling (il hot path ha un nome di funzione)
|
||||
↓
|
||||
scelta esplicita del pezzo in questo file
|
||||
↓
|
||||
Rust con confine grosso
|
||||
↓
|
||||
benchmark sulla stessa board
|
||||
↓
|
||||
accetta / rifiuta sui numeri e sulla semantica (via resta via)
|
||||
```
|
||||
|
||||
Finché la riga “Pezzo scelto” sotto è vuota: **nessun rustup**.
|
||||
|
||||
## Cosa non va in Rust
|
||||
|
||||
| Area | Perché |
|
||||
| --- | --- |
|
||||
| Pipeline, job, SSE, FastAPI | orchestrazione |
|
||||
| Estrazione datasheet, skills, LLM | I/O + modello |
|
||||
| Review AI, normalize, dedup | non deterministico in autorità |
|
||||
| Project CRUD, storage, report JSON | I/O |
|
||||
| Frontend | altro runtime |
|
||||
| Auth / JWT / users | fuori perimetro; non toccare |
|
||||
| FEM, OpenEMS, ampacity IPC via, DRC | non è Periscope |
|
||||
| ImpedenceFinder venduto | già un core chiuso; non riscrivere “per Rust” |
|
||||
| Un check `PE-*` isolato | il check è regola + evidenza; il costo è il parser/geometria a monte |
|
||||
|
||||
## Candidati *solo dopo misura* (nessuno scelto)
|
||||
|
||||
Questi sono i posti dove un profilo *potrebbe* mostrare un hot path. Non sono un piano di porting.
|
||||
|
||||
- Parser `.kicad_pcb` (`parsers_kicad_pcb.py`) se il wall su HubAudio è il parse, non i check.
|
||||
- Predicate geometriche usate da stub / zone / courtyard (track graph, point-in-poly) se il walk segmenti domina.
|
||||
- Walk grafo net→pin su BOM grandi, se misurato.
|
||||
|
||||
Non candidati: `run_pcb_checks` come god-object Rust; “tutto il core”; Z inventata più veloce.
|
||||
|
||||
## Pezzo scelto
|
||||
|
||||
Nessuno.
|
||||
|
||||
Nessuna misura registrata in questo file. Quindi: niente toolchain Rust in questa macro-fase.
|
||||
|
||||
## Come registrare una scelta (futuro)
|
||||
|
||||
Quando una misura esiste, aggiungere qui:
|
||||
|
||||
```text
|
||||
Pezzo: <modulo / funzione>
|
||||
Board: <HubAudio / Emmaforo / …>
|
||||
Python wall: <s> alloc: <MB>
|
||||
Hot path: <nome>
|
||||
Ipotesi Rust: correttezza geometrica | memoria | parser | determinismo | confine
|
||||
Benchmark dopo: <s> e test via≠pad ancora verdi
|
||||
Decisione: accetta | rifiuta
|
||||
```
|
||||
@@ -1,104 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.6.0 — 2026-07-12 — Export Report to Excel
|
||||
|
||||
Download a project's findings as an Excel spreadsheet straight from the report — one click, ready to share, filter, or archive outside Pinscope.
|
||||
|
||||
- [New] "Export Excel" button on the validation report. Every finding becomes a spreadsheet row — designator, part number, ID, severity, title, description, recommendation, and its datasheet source (page included) — sorted most-severe first.
|
||||
|
||||
## 2.5.1 — 2026-07-04 — More Thorough Reviews
|
||||
|
||||
Schematic review now works through every functional area of a component before finishing, so a part with several independent issues has all of them surfaced in one pass instead of just the first.
|
||||
|
||||
- [Improved] For each IC, the review covers power and decoupling, every signal interface, absolute-maximum ratings, and reset/boot/configuration and unused pins before reporting — catching multiple issues on the same component that could previously be missed.
|
||||
|
||||
## 2.5.0 — 2026-07-02 — Light Mode
|
||||
|
||||
Pinscope now has a light theme. Toggle between light and dark with the sun/moon button — in the sidebar next to your account menu, or in the header on the website.
|
||||
|
||||
- [New] Theme toggle. Switch between light and dark mode anywhere in the app; your choice is remembered on this device. Everything defaults to dark, exactly as before, until you flip it.
|
||||
- [Improved] Every status color — error, warning, and pass badges, finding cards, the progress view, billing — is tuned for both themes, so reports stay legible either way.
|
||||
- [Improved] The sign-in page and account menu now follow the app theme instead of always rendering light.
|
||||
|
||||
## 2.4.0 — 2026-07-01 — Automatic Pin & LED Current Checks
|
||||
|
||||
Two datasheet-grounded checks now run on every project, independent of the schematic review — catching a swapped-peripheral pin or an over-driven LED — plus a clear list of any components that had no datasheet to review against.
|
||||
|
||||
- [New] Pin-function feasibility check. Pinscope now flags when a net assigns an IC pin a peripheral function its silicon can't route — for example a `UART5_TX` net on a pin whose alternate-function table only offers `UART5_RX`. It's reported as an error straight from the datasheet's pin table and names the likely swap (TX↔RX, SDA↔SCL). It deliberately does not judge signal *direction* across an interface — a direct UART crosses TX↔RX while a transceiver runs straight through — so it only fires on physically impossible pin assignments, never on wiring style.
|
||||
- [New] LED forward-current check. For each LED, Pinscope computes the forward current from the supply rail, the series resistor, and the LED's rated forward voltage, and flags any channel whose current exceeds the LED's rated maximum. Each color of an RGB LED is checked separately, and a leg with no current-limiting resistor at all is called out as a caution.
|
||||
- [New] "Not reviewed" list on the report. Components with no datasheet on file — for instance a do-not-populate footprint that isn't in the BOM — are now called out explicitly, so a mis-wired pin on an unreviewed part shows up as a known gap instead of being silently absent.
|
||||
- [New] Findings from these automatic checks carry an "Automated check" badge, so they're easy to tell apart from datasheet-review findings.
|
||||
|
||||
## 2.3.3 — 2026-06-08 — Faster Reviews
|
||||
|
||||
Multi-chip designs now review several times faster — Pinscope works through ICs in parallel instead of one at a time.
|
||||
|
||||
- [Improved] Datasheet extraction and schematic review now process multiple ICs at once, so reports on multi-IC projects come back substantially faster. The findings are unchanged — only the wait is shorter.
|
||||
|
||||
## 2.3.2 — 2026-05-26 — Smarter RF Topology Review
|
||||
|
||||
Schematic review now reasons about *what each external part is for* before flagging it — catching valid bias, coupling, and matching circuits that previously looked like errors.
|
||||
|
||||
- [Improved] The reviewer states the role of every external part on an IC pin (choke, blocking cap, divider, decoupling, matching) before judging the connection. Common RF topologies like bias-T (DC injected onto a coax through a choke, with the chip protected by an internal DC block and a downstream load doing the actual draw) are no longer flagged as errors against the chip.
|
||||
- [Improved] Stricter absolute-maximum-rating checks: the cited limit must come from the same pin under stress (a Vdd abs-max no longer counts against an RF or signal pin), and the inequality must be a strict exceed — equal-to-abs-max is at most a Warning.
|
||||
- [Improved] Single-concern deep dives are capped at two follow-up queries; concerns that can't be resolved in that budget are reported as Warnings with the unresolved question stated, so one suspect finding can't starve the rest of the IC review.
|
||||
- [Improved] Inferred rail voltages from the power-tree pass are no longer treated as ground truth by the schematic reviewer. Voltages set by net name (`+5V`, `+3V3`) or by user power-source hints are trusted as before; voltages the power-tree LLM guessed for an adjustable regulator output or propagated through inference are kept on the power-tree view for reference but excluded from review reasoning, so a single misread rail can't anchor a false-positive Error.
|
||||
- [Improved] After each IC's review, a second pass normalizes findings against a fixed Error/Warning/Info rubric and merges any two findings that share a single root cause (e.g. "series resistor drops VIN" and "VOUT setpoint exceeds available VIN" are one defect, not two). Cuts run-to-run severity drift and avoids inflating the error count when one defect can be described from multiple angles.
|
||||
|
||||
## 2.3.1 — 2026-05-25 — EDIF Netlist Support
|
||||
|
||||
EDIF 2.0.0 netlists upload alongside PADS-PCB, with a sub-design picker for files that contain more than one design. Schematic review is also more cautious about polarity / direction-control findings.
|
||||
|
||||
- [New] Upload EDIF 2.0.0 (`.edn`) netlists directly. Format is auto-detected from the file contents — no need to convert to PADS-PCB first. Verified against Siemens xDX Designer exports.
|
||||
- [New] When an EDIF file contains multiple sub-designs, project setup shows a picker so you can choose which one to review. The picker auto-confirms when there's a single clean match against your BOM and only asks when it's ambiguous; unselected sub-designs are filtered out of the design graph.
|
||||
- [Improved] Stronger verification of differential and polarity pin assignments (USB D+/D−, TX/RX, IN+/IN−, anode/cathode) directly against the datasheet.
|
||||
- [Improved] Improved support for bidirectional buffers and level translators (74xx245 and friends) — direction-control truth tables are factored into bus-contention analysis.
|
||||
- [Improved] Findings that share a single root cause on the same chip are grouped into one combined finding.
|
||||
|
||||
## 2.3.0 — 2026-05-24 — LCSC Part Number Support
|
||||
|
||||
JLCPCB-style BOMs with LCSC part numbers (e.g. `C12044`) now work out of the box — Pinscope auto-detects the column, resolves each id to the real manufacturer part number, and shows you what it resolved to before the pipeline runs.
|
||||
|
||||
- [New] LCSC part numbers in the manufacturer part number column are auto-detected at BOM upload and converted to real MPNs. Works with JLCPCB / EasyEDA exports without any column renaming.
|
||||
- [New] Project setup now shows the LCSC → MPN mapping on each IC row in the datasheet step (e.g. `C12044 → TP4057-42-SOT26-R`), so you can see what each LCSC id became before the pipeline starts.
|
||||
- [New] Passive specs (value, voltage, tolerance, dielectric, package) are resolved from the LCSC catalog during project setup, with per-row progress and status — you see what's resolved before spending credits on the full pipeline.
|
||||
- [Improved] Datasheet auto-fetch hit rate is dramatically higher on LCSC BOMs, because DigiKey now sees real MPNs instead of `C…` ids.
|
||||
|
||||
## 2.2.1 — 2026-05-22 — Easier Netlist Uploads & Xpedition Support
|
||||
|
||||
Tabbed file upload guide with per-tool instructions, Xpedition coverage, and direct `.net` / `.txt` uploads.
|
||||
|
||||
- [New] Documentation for exporting a PADS-PCB netlist from Siemens Xpedition Designer / DxDesigner (VX.2.x, including VX.2.14).
|
||||
- [Improved] File upload guide reorganized into tabs — KiCad, Altium, OrCAD/Allegro, Xpedition, EasyEDA, and Eagle each get their own panel.
|
||||
- [Improved] Netlist uploads now accept `.asc`, `.net`, `.NET`, and `.txt` directly — no more renaming required before upload.
|
||||
- [Improved] File guide now calls out the difference between the PADS-PCB schematic netlist Pinscope needs and the `!PADS-POWERPCB` PCB-layout dump that some EDA tools also save as `.asc`.
|
||||
|
||||
## 2.2.0 — 2026-05-20 — Cross-chip Datasheet Review
|
||||
|
||||
The reviewer now reads neighbor-chip datasheets to verify cross-chip constraints, with fewer false errors when a spec can't be confirmed.
|
||||
|
||||
- [Improved] Schematic review now cross-references connected chips: when an issue depends on a neighbor's spec (5V tolerance, absolute-max, drive strength), the reviewer pulls the relevant pages from that chip's datasheet before flagging it.
|
||||
- [Improved] Fewer false errors on cross-chip findings: if a counterpart spec can't be confirmed from the datasheet, the issue is reported as a Warning with the unverified assumption stated — instead of being overstated as an Error.
|
||||
- [Fixed] Some review findings could occasionally fail to appear in the report.
|
||||
|
||||
## 2.1.0 — 2026-05-19 — Datasheet Reference Highlighting
|
||||
|
||||
Datasheet citations now highlight the exact supporting sentence on the PDF page, with more reliable page numbers on large datasheets.
|
||||
|
||||
- [Improved] Datasheet references now highlight the exact supporting sentence on the PDF page, not just the page number.
|
||||
- [Fixed] Datasheet citations landing on the wrong page for large (multi-hundred-page) datasheets.
|
||||
- [Fixed] Reviewed findings losing their checked state on page refresh.
|
||||
|
||||
## 2.0.1 — 2026-05-01 — Flagging & Onboarding
|
||||
|
||||
One-click flags on finding cards, an onboarding survey for new users, and small UI polish.
|
||||
|
||||
- [New] Report findings with one click via the flag button on any finding card.
|
||||
- [New] Onboarding survey for new users to help us improve the product.
|
||||
- [Improved] Comment input box now fills available width.
|
||||
|
||||
## 2.0.0 — 2026-04-29 — Public Changelog
|
||||
|
||||
- [New] Initial public changelog.
|
||||
@@ -1,185 +0,0 @@
|
||||
# Pinscope 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 Pinscope website (pinscope.ai), 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 OpenAI, 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 @@
|
||||
# Pinscope Terms of Service
|
||||
|
||||
**Last updated: April 5, 2026**
|
||||
|
||||
These Terms of Service ("Terms") govern access to and use of the Pinscope platform (pinscope.ai) 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 Pinscope platform (pinscope.ai), 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 Pinscope 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 Pinscope review services up to the applicable credited amount and may be used only for eligible Pinscope 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 Pinscope 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 Pinscope 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 Pinscope 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,39 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN echo "BUILD API URL=$NEXT_PUBLIC_API_URL"
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
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"]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 756 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Open-core seam: the cloud/gateway build replaces this file with the Clerk
|
||||
// user button and live credit balance. The open-source build has neither.
|
||||
|
||||
export function SidebarCredits() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SidebarUserButton() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Upload, FileCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
label: string;
|
||||
accept: string;
|
||||
multiple?: boolean;
|
||||
files: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
preloaded?: string[];
|
||||
}
|
||||
|
||||
export function FileUploadZone({
|
||||
label,
|
||||
accept,
|
||||
multiple = false,
|
||||
files,
|
||||
onFilesChange,
|
||||
preloaded,
|
||||
}: FileUploadZoneProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const dropped = Array.from(e.dataTransfer.files);
|
||||
onFilesChange(multiple ? [...files, ...dropped] : dropped.slice(0, 1));
|
||||
},
|
||||
[files, multiple, onFilesChange]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
onFilesChange(multiple ? [...files, ...selected] : selected.slice(0, 1));
|
||||
},
|
||||
[files, multiple, onFilesChange]
|
||||
);
|
||||
|
||||
const hasFiles = files.length > 0 || (preloaded && preloaded.length > 0);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
|
||||
dragOver ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
|
||||
hasFiles && "border-emerald-500/40 bg-emerald-500/5"
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{hasFiles ? (
|
||||
<FileCheck className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
) : (
|
||||
<Upload className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{preloaded && preloaded.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preloaded.map((f) => (
|
||||
<span key={f} className="font-mono block">{f}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{files.map((f) => (
|
||||
<span key={f.name} className="font-mono block">{f.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input type="file" accept={accept} multiple={multiple} className="hidden" onChange={handleChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Open-core seam: the cloud/gateway build replaces this file with wrappers
|
||||
* around Clerk's hooks. The open-source build always runs as the local
|
||||
* user, mirroring the backend: user_id "local", admin access granted
|
||||
* (backend is_admin() returns True when auth is disabled).
|
||||
*/
|
||||
|
||||
export interface AppUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface OptionalAuth {
|
||||
isSignedIn: boolean;
|
||||
getToken: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface OptionalUser {
|
||||
user: AppUser | null;
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
const LOCAL_USER: AppUser = {
|
||||
id: "local",
|
||||
name: "Local User",
|
||||
email: null,
|
||||
isAdmin: true,
|
||||
};
|
||||
const LOCAL_AUTH: OptionalAuth = { isSignedIn: true, getToken: async () => null };
|
||||
const LOCAL_USER_RESULT: OptionalUser = { user: LOCAL_USER, isLoaded: true };
|
||||
|
||||
export function useOptionalAuth(): OptionalAuth {
|
||||
return LOCAL_AUTH;
|
||||
}
|
||||
|
||||
export function useOptionalUser(): OptionalUser {
|
||||
return LOCAL_USER_RESULT;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ValidationReport, DesignGraph } from "@/lib/types";
|
||||
import { fetchReport, fetchGraph } from "@/lib/api";
|
||||
|
||||
export function useReport(projectId: string) {
|
||||
const [report, setReport] = useState<ValidationReport | null>(null);
|
||||
const [graph, setGraph] = useState<DesignGraph | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([fetchReport(projectId), fetchGraph(projectId)])
|
||||
.then(([r, g]) => {
|
||||
setReport(r);
|
||||
setGraph(g);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
return { report, graph, loading, error };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Open-core auth switch.
|
||||
*
|
||||
* When NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is unset the app runs in local/OSS
|
||||
* mode: no ClerkProvider, pass-through middleware, a stubbed signed-in
|
||||
* "local" user (matching the backend's LOCAL_DEV_USER), and all credits /
|
||||
* billing UI hidden. Pairs with BILLING_ENABLED=false on the backend —
|
||||
* mixed modes (key set but billing off, or the inverse) are unsupported.
|
||||
*
|
||||
* NEXT_PUBLIC_* vars are inlined at build time, so this is a build-time
|
||||
* constant — changing it requires a rebuild / dev-server restart.
|
||||
*/
|
||||
export const authEnabled = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
|
||||
@@ -0,0 +1,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,88 @@
|
||||
# Periscope Backend — Environment Variables
|
||||
# Copy to backend/.env and fill in values. Only DEEPSEEK_API_KEY is required.
|
||||
|
||||
# -- AI (DeepSeek, default) --------------------------------------------------
|
||||
# Key from https://platform.deepseek.com/
|
||||
DEEPSEEK_API_KEY=sk-...
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_MODEL=deepseek-flash
|
||||
DEEPSEEK_VISION_MODEL=deepseek-flash
|
||||
# enabled (default) | disabled — thinking mode on DeepSeek V4
|
||||
DEEPSEEK_THINKING=enabled
|
||||
# Official values: low | high | max
|
||||
DEEPSEEK_REASONING_EFFORT=high
|
||||
# PDF ingest (DeepSeek cannot take native PDFs)
|
||||
# DEEPSEEK_PDF_MAX_CHARS=500000
|
||||
# DEEPSEEK_PDF_IMAGE_PAGES=32
|
||||
|
||||
# Per-stage DeepSeek model overrides (leave empty to use the defaults below)
|
||||
# V4.1 Flash is natively multimodal — extraction and review share deepseek-flash.
|
||||
# MODEL_PINTABLE_DEEPSEEK=deepseek-flash
|
||||
# MODEL_PATTERN_DEEPSEEK=deepseek-flash
|
||||
# MODEL_SPECS_DEEPSEEK=deepseek-flash
|
||||
# MODEL_VALIDATION_DEEPSEEK=deepseek-flash
|
||||
# MODEL_AUTO_RESOLVE_DEEPSEEK=deepseek-flash
|
||||
# MODEL_NORMALIZE_DEEPSEEK=deepseek-flash
|
||||
|
||||
# -- AI provider routing -----------------------------------------------------
|
||||
# Default provider for every stage; per-stage env vars override.
|
||||
# Valid values: deepseek | gemini (anthropic is ignored and coerced to deepseek)
|
||||
PROVIDER_DEFAULT=deepseek
|
||||
# PROVIDER_VALIDATION=deepseek
|
||||
# PROVIDER_AUTO_RESOLVE=deepseek
|
||||
|
||||
# Anthropic is not used. Do not set ANTHROPIC_API_KEY.
|
||||
|
||||
# -- Gemini (optional) -------------------------------------------------------
|
||||
# GEMINI_API_KEY=
|
||||
# GEMINI_MODEL=gemini-3-flash-preview
|
||||
# MODEL_VALIDATION_GEMINI=
|
||||
|
||||
# -- Per-stage fallback ------------------------------------------------------
|
||||
# If set, the stage retries once. Anthropic is ignored (DeepSeek only).
|
||||
# FALLBACK_PROVIDER_VALIDATION=deepseek
|
||||
# FALLBACK_MODEL_VALIDATION=deepseek-flash
|
||||
|
||||
# -- Storage -----------------------------------------------------------------
|
||||
# Set GCS_BUCKET to store projects/library in Google Cloud Storage.
|
||||
# Leave empty for local mode (uses the data/ directory).
|
||||
GCS_BUCKET=
|
||||
|
||||
# -- CORS --------------------------------------------------------------------
|
||||
# Frontend URL(s), JSON list
|
||||
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000","http://localhost:18742","http://127.0.0.1:18742"]
|
||||
|
||||
# -- Auth (self-host) --------------------------------------------------------
|
||||
# Set AUTH_JWT_SECRET to enable Periscope email/password accounts and
|
||||
# multi-user project collaborators (invite by email). Clerk keys, if set,
|
||||
# take priority over local auth.
|
||||
# AUTH_JWT_SECRET=change-me-to-a-long-random-string
|
||||
# Comma-separated emails that are admin on register (first user is always admin)
|
||||
# AUTH_ADMIN_EMAILS=you@example.com
|
||||
|
||||
# -- Clerk (optional cloud auth) ---------------------------------------------
|
||||
# CLERK_SECRET_KEY=
|
||||
# CLERK_PUBLISHABLE_KEY=
|
||||
# CLERK_JWKS_URL=
|
||||
# Optional catalog datasheet source and parameter-based passive auto-resolve.
|
||||
# Order: BOM URL → LCSC → manufacturer PDF URLs → Mouser → DigiKey.
|
||||
# DIGIKEY_CLIENT_ID=
|
||||
# DIGIKEY_CLIENT_SECRET=
|
||||
# DIGIKEY_ENVIRONMENT=production
|
||||
# DIGIKEY_LOCALE_SITE=US
|
||||
# DIGIKEY_LOCALE_LANGUAGE=en
|
||||
# DIGIKEY_LOCALE_CURRENCY=USD
|
||||
|
||||
# -- Mouser (optional) -------------------------------------------------------
|
||||
# Search API key from mouser.com/api-hub. Same family/packing match as DigiKey.
|
||||
# MOUSER_API_KEY=
|
||||
|
||||
# -- Email notifications (optional) ------------------------------------------
|
||||
# Gmail API via domain-wide delegation. Leave EMAIL_SENDER empty to disable.
|
||||
# Service account credentials come from GOOGLE_APPLICATION_CREDENTIALS.
|
||||
EMAIL_SENDER=
|
||||
EMAIL_FRONTEND_URL=
|
||||
# Fixed admin email for pipeline-started notifications (leave empty to disable)
|
||||
EMAIL_ADMIN_NOTIFY=
|
||||
# Recipient for /api/contact form submissions (leave empty to disable)
|
||||
CONTACT_RECIPIENT=
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pinscope Backend
|
||||
# Periscope Backend
|
||||
|
||||
FastAPI application providing async pipeline orchestration, project storage, and SSE progress streaming. Wraps the `pinscopex/` core library — calls existing functions with local paths, adds no domain logic of its own.
|
||||
FastAPI application providing async pipeline orchestration, project storage, and SSE progress streaming. Wraps the `periscopex/` core library — calls existing functions with local paths, adds no domain logic of its own.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -9,7 +9,7 @@ FastAPI application providing async pipeline orchestration, project storage, and
|
||||
python3 -m uvicorn backend.main:app --reload # localhost:8000
|
||||
```
|
||||
|
||||
Config reads from `.env` at project root (see `config.py`). Key settings: `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` (default `claude-sonnet-4-6`), per-stage model overrides (`model_pintable`, `model_pattern`, `model_specs`, `model_validation`, `model_auto_resolve`), `CORS_ORIGINS`, `DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`, `DIGIKEY_ENVIRONMENT`.
|
||||
Config reads from `backend/.env` (see `config.py`). Key settings: `DEEPSEEK_API_KEY`, `DEEPSEEK_MODEL`, per-stage DeepSeek overrides (`model_pintable_deepseek`, `model_validation_deepseek`, …), `PROVIDER_DEFAULT` (default `deepseek`), optional `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`, `CORS_ORIGINS`, DigiKey keys.
|
||||
|
||||
For local mode, leave `GCS_BUCKET` empty — uses `LocalStorageBackend` (`data/` directory) and no auth (user_id defaults to `"local"`, admin access granted).
|
||||
|
||||
@@ -22,7 +22,7 @@ backend/
|
||||
├── _version.py # Reads app version from frontend/content/changelog.md (single source of truth)
|
||||
├── Dockerfile # Python 3.12-slim, copies taxonomy/ + changelog.md for runtime
|
||||
├── skills_manifest.json # Claude Console Skill IDs (extract-pintable, extract-pattern, extract-specs)
|
||||
├── pinscopex/ # Core library (models, parsers, graph, validator, taxonomy, derating)
|
||||
├── periscopex/ # Core library (models, parsers, graph, validator, taxonomy, derating)
|
||||
│ ├── utils.py # Shared utilities: safe_mpn(), natural_sort_key()
|
||||
│ └── resolve_passives.py # Passive MPN pattern matching + value decoders (R/C/L)
|
||||
├── middleware/
|
||||
@@ -63,7 +63,7 @@ All file I/O goes through `StorageBackend` (protocol in `services/storage.py`):
|
||||
|
||||
Storage keys follow GCS-style paths: `users/{user_id}/projects/{id}/uploads/bom.csv`
|
||||
|
||||
The `pinscopex/` core library is **unaware of storage** — it operates on local paths. During pipeline execution, `PipelineWorkspace` downloads files to a temp dir, runs `pinscopex/` functions locally, then uploads results back.
|
||||
The `periscopex/` core library is **unaware of storage** — it operates on local paths. During pipeline execution, `PipelineWorkspace` downloads files to a temp dir, runs `periscopex/` functions locally, then uploads results back.
|
||||
|
||||
## Project Storage
|
||||
|
||||
@@ -108,7 +108,7 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
3. **Extract Passives** — Pattern-based extraction per MPN group, then a specs fallback per MPN.
|
||||
3.5. **DigiKey Auto-Resolve (exact MPN)** — Fallback for unresolved passives; parameters mapped to taxonomy specs via Haiku. Requires exact MPN match so the shared `library/passives/` stays clean.
|
||||
3.6. **Value Fallback (R/C/L/FB only)** — When DigiKey misses, parse the BOM `Value` string via Haiku into typed passive specs. Per-project only; never written to the shared library.
|
||||
4. **Build Graph** — Call `pinscopex.graph.build_graph()` with local temp paths
|
||||
4. **Build Graph** — Call `periscopex.graph.build_graph()` with local temp paths
|
||||
5. **BOM Summary** — Collate components from design graph (no AI)
|
||||
6. **Derating Table** — Capacitor voltage derating computation (no AI)
|
||||
7. **Direct Datasheet Review** — Per-IC (isolated): Claude reads the datasheet PDF + circuit neighborhood from the graph, compares to reference application circuit, and submits findings via graph query tools. ICs are reviewed **concurrently**, up to `IC_CONCURRENCY` in flight at once.
|
||||
@@ -118,7 +118,7 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
## Key Patterns
|
||||
|
||||
- **StorageBackend protocol** — all file I/O is abstracted; swap local/GCS via `GCS_BUCKET` env var
|
||||
- **PipelineWorkspace** — downloads to temp dir, runs pinscopex locally, uploads results
|
||||
- **PipelineWorkspace** — downloads to temp dir, runs periscopex locally, uploads results
|
||||
- **BillingHook seam (open-core)** — core code reaches billing exclusively through `services/billing_hook.py:get_billing()`. In this repo that's `NullBilling`: every pipeline runs free and no billing routes are mounted. Never import billing modules directly from core code — go through the hook.
|
||||
- **Auth middleware** — JWT verification via a JWKS endpoint; disabled when `CLERK_JWKS_URL` is empty (local mode: `user_id="local"`, `is_admin()` returns True)
|
||||
- **AsyncAnthropic** for all Claude API calls — extraction and validation
|
||||
@@ -131,10 +131,10 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
- **Taxonomy specs schemas** — auto-generated via Claude per type/subtype; extraction discards parameters not in schema (`extra_specs`)
|
||||
- **Shared router deps** — `routers/deps.py` centralizes `get_storage()`, `get_user_id()`, `resolve_or_404()` across all routers
|
||||
- **DigiKey OAuth2** — Token caching in `services/digikey.py`; `_find_product` requires exact MPN (no silent first-match fallback)
|
||||
- **Version stamping** — `backend/_version.py` reads the latest `##` heading from `frontend/content/changelog.md` and exports `PINSCOPE_VERSION`; stamped onto `ProjectMeta.pinscope_version` at `/start`
|
||||
- **Version stamping** — `backend/_version.py` reads the latest `##` heading from `frontend/content/changelog.md` and exports `PERISCOPE_VERSION`; stamped onto `ProjectMeta.periscope_version` at `/start`
|
||||
- **Datasheet page trimming** — `_select_pages()` in `extraction.py` keyword-trims large PDFs to reduce token costs
|
||||
- **Content-addressed datasheets** — `datasheet_store.py` writes PDFs to `library/datasheets/blobs/{md5}.pdf` and maps MPNs via refs
|
||||
- **Passive value decoders** — `pinscopex/resolve_passives.py` decodes EIA-198, R-notation, letter-decimal, EIA3/EIA4 for R/C/L values
|
||||
- **Passive value decoders** — `periscopex/resolve_passives.py` decodes EIA-198, R-notation, letter-decimal, EIA3/EIA4 for R/C/L values
|
||||
- **Collaborator access** — `resolve_or_404()` grants access to both owner and collaborators
|
||||
- **Per-IC review isolation** — In `services/validation.py`, each IC review is wrapped so a single bad payload is captured as a skipped component rather than aborting the run
|
||||
|
||||
@@ -144,5 +144,5 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
- Keep all storage operations in `services/projects.py` (uses `StorageBackend`)
|
||||
- Routers are thin — validate input, call service, return response
|
||||
- Thread `user_id` from `request.state` through to all service calls
|
||||
- Don't import from `backend/` in `pinscopex/` — dependency flows one way
|
||||
- Don't import from `backend/` in `periscopex/` — dependency flows one way
|
||||
- CORS is configured for `localhost:3000` by default; override with `CORS_ORIGINS` env var
|
||||
@@ -0,0 +1,14 @@
|
||||
"""PinScope-inherited backend package (in-tree dependency)."""
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
_src, _other, _dep = [], [], []
|
||||
for _p in __path__:
|
||||
_n = str(_p).replace("\\", "/")
|
||||
if "/periscope/src/" in _n:
|
||||
_src.append(_p)
|
||||
elif "/periscope/dependency/" in _n:
|
||||
_dep.append(_p)
|
||||
else:
|
||||
_other.append(_p)
|
||||
__path__[:] = _src + _other + _dep
|
||||
@@ -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
|
||||
@@ -19,7 +24,32 @@ _SKILLS_MANIFEST: dict = (
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Anthropic
|
||||
# DeepSeek (default provider — OpenAI-compatible Chat Completions)
|
||||
deepseek_api_key: str = ""
|
||||
deepseek_base_url: str = "https://api.deepseek.com"
|
||||
deepseek_model: str = "deepseek-flash"
|
||||
deepseek_vision_model: str = "deepseek-flash"
|
||||
# "enabled" (default) or "disabled". DeepSeek V4 thinks by default;
|
||||
# disable to cut cost on simple mapping calls.
|
||||
deepseek_thinking: str = "enabled"
|
||||
# Official values: low | high | max. Review sessions with
|
||||
# max_tokens >= 16000 still bump to "high" in the provider.
|
||||
deepseek_reasoning_effort: str = "high"
|
||||
# PDF ingest: DeepSeek does not accept native PDFs. Text is always
|
||||
# extracted; page images are attached only when the stage model is a
|
||||
# vision model (see model_*_deepseek defaults below).
|
||||
deepseek_pdf_max_chars: int = 500_000
|
||||
deepseek_pdf_image_pages: int = 32
|
||||
|
||||
# Per-stage DeepSeek model overrides (fall back to deepseek_model)
|
||||
model_pintable_deepseek: str = "deepseek-flash"
|
||||
model_pattern_deepseek: str = "deepseek-flash"
|
||||
model_specs_deepseek: str = "deepseek-flash"
|
||||
model_validation_deepseek: str = "deepseek-flash"
|
||||
model_auto_resolve_deepseek: str = "deepseek-flash"
|
||||
model_normalize_deepseek: str = "deepseek-flash"
|
||||
|
||||
# Anthropic (optional fallback)
|
||||
anthropic_api_key: str = ""
|
||||
anthropic_model: str = "claude-sonnet-4-6"
|
||||
|
||||
@@ -44,9 +74,8 @@ class Settings(BaseSettings):
|
||||
model_normalize_gemini: str = ""
|
||||
|
||||
# Provider routing — provider_default is the global default; per-stage
|
||||
# overrides win when non-empty. Set provider_validation=gemini to route
|
||||
# the validation stage to Gemini while leaving extraction on Anthropic.
|
||||
provider_default: str = "anthropic"
|
||||
# overrides win when non-empty. Valid values: deepseek | anthropic | gemini.
|
||||
provider_default: str = "deepseek"
|
||||
provider_pintable: str = ""
|
||||
provider_pattern: str = ""
|
||||
provider_specs: str = ""
|
||||
@@ -55,10 +84,10 @@ class Settings(BaseSettings):
|
||||
provider_normalize: str = ""
|
||||
|
||||
# Per-stage fallback provider/model — used if the primary stage call
|
||||
# raises (e.g. Gemini 503 UNAVAILABLE). Leave empty to disable fallback
|
||||
# raises (e.g. DeepSeek 503). Leave empty to disable fallback
|
||||
# for that stage. If fallback_provider_<stage> is set but
|
||||
# fallback_model_<stage> is empty, the fallback uses that provider's
|
||||
# default model (anthropic_model or gemini_model).
|
||||
# default model (deepseek_model, anthropic_model, or gemini_model).
|
||||
fallback_provider_pintable: str = ""
|
||||
fallback_provider_pattern: str = ""
|
||||
fallback_provider_specs: str = ""
|
||||
@@ -87,18 +116,26 @@ 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"
|
||||
# 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 = ""
|
||||
|
||||
# Clerk authentication
|
||||
# Clerk authentication (cloud). When set, takes priority over local auth.
|
||||
clerk_secret_key: str = ""
|
||||
clerk_publishable_key: str = ""
|
||||
clerk_jwks_url: str = ""
|
||||
|
||||
# Local Periscope auth (self-host). Set AUTH_JWT_SECRET to enable email/password
|
||||
# accounts and multi-user project collaborators without Clerk.
|
||||
auth_jwt_secret: str = ""
|
||||
# Comma-separated emails that become admin on register (in addition to the
|
||||
# first account, which is always admin).
|
||||
auth_admin_emails: str = ""
|
||||
|
||||
# DigiKey API (optional — enables auto-fetch datasheets)
|
||||
digikey_client_id: str = ""
|
||||
digikey_client_secret: str = ""
|
||||
@@ -107,6 +144,9 @@ class Settings(BaseSettings):
|
||||
digikey_locale_language: str = "en"
|
||||
digikey_locale_currency: str = "USD"
|
||||
|
||||
# Mouser Search API (optional — fourth datasheet source)
|
||||
mouser_api_key: str = ""
|
||||
|
||||
# Purple Parts API (optional — converts LCSC codes to MPNs before DigiKey)
|
||||
purple_parts_url: str = ""
|
||||
purple_parts_api_key: str = ""
|
||||
@@ -139,10 +179,15 @@ class Settings(BaseSettings):
|
||||
survey_sheet_id: str = ""
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000"]
|
||||
cors_origins: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:18742",
|
||||
"http://127.0.0.1:18742",
|
||||
]
|
||||
|
||||
# Cloud Run Job worker (pipeline runner)
|
||||
pipeline_worker_job_name: str = "pinscopex-pipeline-worker"
|
||||
pipeline_worker_job_name: str = "periscopex-pipeline-worker"
|
||||
pipeline_worker_region: str = "us-central1"
|
||||
pipeline_worker_project: str = "" # GCP project id; defaults to GOOGLE_CLOUD_PROJECT or metadata
|
||||
pipeline_worker_timeout_seconds: int = 3600
|
||||
@@ -153,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",
|
||||
}
|
||||
@@ -166,6 +211,10 @@ class Settings(BaseSettings):
|
||||
def use_digikey(self) -> bool:
|
||||
return bool(self.digikey_client_id and self.digikey_client_secret)
|
||||
|
||||
@property
|
||||
def use_mouser(self) -> bool:
|
||||
return bool(self.mouser_api_key)
|
||||
|
||||
@property
|
||||
def use_purple_parts(self) -> bool:
|
||||
return bool(self.purple_parts_url and self.purple_parts_api_key)
|
||||
@@ -175,28 +224,48 @@ class Settings(BaseSettings):
|
||||
return bool(self.gcs_bucket)
|
||||
|
||||
@property
|
||||
def use_auth(self) -> bool:
|
||||
def use_clerk(self) -> bool:
|
||||
return bool(self.clerk_secret_key and self.clerk_jwks_url)
|
||||
|
||||
@property
|
||||
def use_local_auth(self) -> bool:
|
||||
"""Self-host email/password auth when JWT secret is set and Clerk is not."""
|
||||
return bool(self.auth_jwt_secret) and not self.use_clerk
|
||||
|
||||
@property
|
||||
def use_auth(self) -> bool:
|
||||
return self.use_clerk or self.use_local_auth
|
||||
|
||||
@property
|
||||
def use_email(self) -> bool:
|
||||
return bool(self.email_sender and self.email_frontend_url)
|
||||
|
||||
def provider_for_stage(self, stage: str) -> str:
|
||||
"""Return the LLM provider name for a pipeline stage."""
|
||||
"""Return the LLM provider name for a pipeline stage.
|
||||
|
||||
Anthropic is never used: any ``PROVIDER_*=anthropic`` override is
|
||||
coerced to DeepSeek.
|
||||
"""
|
||||
override = getattr(self, f"provider_{stage}", "")
|
||||
return override or self.provider_default
|
||||
name = override or self.provider_default
|
||||
if name == "anthropic":
|
||||
return "deepseek"
|
||||
return name
|
||||
|
||||
def model_for_stage(self, stage: str) -> str:
|
||||
"""Return the model for a pipeline stage, provider-aware.
|
||||
|
||||
For Anthropic: falls back to model_<stage>, then anthropic_model.
|
||||
For DeepSeek: falls back to model_<stage>_deepseek, then deepseek_model.
|
||||
For Gemini: falls back to model_<stage>_gemini, then gemini_model.
|
||||
For Anthropic: falls back to model_<stage>, then anthropic_model.
|
||||
"""
|
||||
provider = self.provider_for_stage(stage)
|
||||
if provider == "gemini":
|
||||
override = getattr(self, f"model_{stage}_gemini", "")
|
||||
return override or self.gemini_model
|
||||
if provider == "deepseek":
|
||||
override = getattr(self, f"model_{stage}_deepseek", "")
|
||||
return override or self.deepseek_model
|
||||
override = getattr(self, f"model_{stage}", "")
|
||||
return override or self.anthropic_model
|
||||
|
||||
@@ -206,13 +275,45 @@ class Settings(BaseSettings):
|
||||
when the primary provider raises.
|
||||
"""
|
||||
fb_provider = getattr(self, f"fallback_provider_{stage}", "")
|
||||
if not fb_provider:
|
||||
if not fb_provider or fb_provider == "anthropic":
|
||||
return None
|
||||
fb_model = getattr(self, f"fallback_model_{stage}", "")
|
||||
if not fb_model:
|
||||
fb_model = self.gemini_model if fb_provider == "gemini" else self.anthropic_model
|
||||
if fb_provider == "gemini":
|
||||
fb_model = self.gemini_model
|
||||
elif fb_provider == "deepseek":
|
||||
fb_model = self.deepseek_model
|
||||
else:
|
||||
fb_model = self.anthropic_model
|
||||
return (fb_provider, fb_model)
|
||||
|
||||
def default_model_for_provider(self, provider: str) -> str:
|
||||
if provider == "gemini":
|
||||
return self.gemini_model
|
||||
if provider == "deepseek":
|
||||
return self.deepseek_model
|
||||
return self.anthropic_model
|
||||
|
||||
def has_llm_credentials(self) -> bool:
|
||||
"""True if the configured default provider has an API key."""
|
||||
name = self.provider_default
|
||||
if name == "anthropic":
|
||||
name = "deepseek"
|
||||
if name == "deepseek":
|
||||
return bool(self.deepseek_api_key)
|
||||
if name == "gemini":
|
||||
return bool(self.gemini_api_key)
|
||||
return bool(self.deepseek_api_key)
|
||||
|
||||
def get_skill_or_none(self, name: str) -> tuple[str | None, str | None]:
|
||||
"""Return (skill_id, version) or (None, None) if the Anthropic
|
||||
Console skill is not in the manifest. DeepSeek/Gemini extraction
|
||||
inlines SKILL.md locally and does not need a skill_id."""
|
||||
entry = _SKILLS_MANIFEST.get(name)
|
||||
if not entry:
|
||||
return None, None
|
||||
return entry.get("skill_id"), entry.get("latest_version")
|
||||
|
||||
def get_default_model_version(self) -> str:
|
||||
"""Return the default model_version for new extractions from skills_manifest.json."""
|
||||
return _SKILLS_MANIFEST.get("default_model_version", "1.0.0")
|
||||
@@ -1,4 +1,4 @@
|
||||
"""PinscopeX backend — FastAPI application."""
|
||||
"""PeriscopeX backend — FastAPI application."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from backend.config import settings
|
||||
from backend.routers import admin, contact, feedback, pipeline, projects, reports, survey
|
||||
from backend.routers import admin, auth, contact, feedback, impedance, pipeline, projects, reports, survey
|
||||
from backend.services.projects import ProjectNotFound
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
@@ -35,14 +35,18 @@ async def lifespan(app: FastAPI):
|
||||
env = os.getenv("ENVIRONMENT", "").lower()
|
||||
if env == "production" and not settings.use_auth:
|
||||
raise RuntimeError(
|
||||
"CLERK_JWKS_URL and CLERK_SECRET_KEY must be set in production. "
|
||||
"Authentication cannot be disabled in production."
|
||||
"Production requires authentication: set AUTH_JWT_SECRET "
|
||||
"(local Periscope accounts) or CLERK_JWKS_URL + CLERK_SECRET_KEY."
|
||||
)
|
||||
if not settings.use_auth:
|
||||
logger.warning(
|
||||
"Authentication is DISABLED — all users have full access. "
|
||||
"This is only safe for local development."
|
||||
)
|
||||
elif settings.use_local_auth:
|
||||
logger.info("Local Periscope authentication enabled (AUTH_JWT_SECRET)")
|
||||
elif settings.use_clerk:
|
||||
logger.info("Clerk authentication enabled")
|
||||
if not settings.billing_enabled:
|
||||
logger.warning(
|
||||
"Billing is DISABLED — pipelines run free and the billing/credits "
|
||||
@@ -55,9 +59,14 @@ async def lifespan(app: FastAPI):
|
||||
if isinstance(app.state.storage, LocalStorageBackend):
|
||||
base = settings.data_dir
|
||||
(base / "users").mkdir(parents=True, exist_ok=True)
|
||||
(base / "auth" / "users").mkdir(parents=True, exist_ok=True)
|
||||
(base / "auth" / "by_email").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "extracted").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "patterns").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "models").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "passives").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "datasheets" / "refs").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "datasheets" / "blobs").mkdir(parents=True, exist_ok=True)
|
||||
yield
|
||||
# Pipelines run in a separate Cloud Run Job worker (or local
|
||||
# subprocess in dev), so the API process has nothing to clean up
|
||||
@@ -81,31 +90,37 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Extract user_id from Clerk JWT or default to local dev user."""
|
||||
"""Extract user_id from JWT (Clerk or local) or default to local dev user."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Let CORS preflight through — browsers send OPTIONS without credentials
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
# Public endpoints that don't require authentication
|
||||
if request.url.path == "/api/contact":
|
||||
if request.url.path in {
|
||||
"/api/contact",
|
||||
"/api/auth/mode",
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
}:
|
||||
request.state.user_id = LOCAL_DEV_USER
|
||||
return await call_next(request)
|
||||
if settings.use_auth:
|
||||
from backend.middleware.auth import verify_clerk_token
|
||||
from backend.middleware.auth import verify_request_user
|
||||
|
||||
user_id = await verify_clerk_token(request)
|
||||
user_id = await verify_request_user(request)
|
||||
if user_id is None:
|
||||
is_production = os.getenv("ENVIRONMENT", "").lower() == "production"
|
||||
if is_production:
|
||||
# Local auth (and production) require a valid token for API routes.
|
||||
if is_production or settings.use_local_auth:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Authentication required"},
|
||||
)
|
||||
# Non-production: fall back to local dev user so Clerk config
|
||||
# doesn't block local development when no token is present.
|
||||
# Non-production Clerk: fall back so missing token doesn't block
|
||||
# local development when Clerk is configured but unused.
|
||||
user_id = LOCAL_DEV_USER
|
||||
request.state.user_id = user_id
|
||||
else:
|
||||
@@ -115,7 +130,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="PinscopeX",
|
||||
title="PeriscopeX",
|
||||
description="Agentic schematic validation API",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
@@ -131,7 +146,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["content-type", "authorization"],
|
||||
expose_headers=["X-Datasheet-Url"],
|
||||
expose_headers=["X-Datasheet-Url", "X-Datasheet-Source"],
|
||||
)
|
||||
|
||||
@app.exception_handler(ProjectNotFound)
|
||||
@@ -144,7 +159,9 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
|
||||
app.include_router(projects.router, prefix="/api")
|
||||
app.include_router(pipeline.router, prefix="/api")
|
||||
app.include_router(reports.router, prefix="/api")
|
||||
app.include_router(impedance.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
if settings.billing_enabled:
|
||||
# Import guarded too: with billing disabled the core never loads the
|
||||
# billing/credits routers (or, transitively, the Stripe SDK).
|
||||
@@ -0,0 +1,115 @@
|
||||
"""JWT verification for FastAPI (Clerk JWKS or local Periscope HS256)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
# JWKS cache
|
||||
_jwks_client: jwt.PyJWKClient | None = None
|
||||
_SKIP_PATHS = {
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
"/health",
|
||||
"/api/billing/webhook",
|
||||
"/api/auth/mode",
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/contact",
|
||||
}
|
||||
|
||||
|
||||
def _get_jwks_client() -> jwt.PyJWKClient:
|
||||
global _jwks_client
|
||||
if _jwks_client is None:
|
||||
jwks_url = settings.clerk_jwks_url
|
||||
if not jwks_url:
|
||||
raise RuntimeError(
|
||||
"CLERK_JWKS_URL must be set for Clerk authentication. "
|
||||
"Find it in your Clerk dashboard under API Keys."
|
||||
)
|
||||
_jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True)
|
||||
return _jwks_client
|
||||
|
||||
|
||||
def _bearer_or_query_token(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header[7:]
|
||||
# EventSource/SSE can't send headers
|
||||
return request.query_params.get("token")
|
||||
|
||||
|
||||
async def verify_clerk_token(request: Request) -> str | None:
|
||||
"""Verify Clerk JWT and return user_id, or None if invalid."""
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return "anonymous"
|
||||
|
||||
token = _bearer_or_query_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
client = _get_jwks_client()
|
||||
signing_key = client.get_signing_key_from_jwt(token)
|
||||
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
options={
|
||||
"verify_exp": True,
|
||||
"verify_aud": False,
|
||||
"verify_iss": True,
|
||||
},
|
||||
issuer=(
|
||||
settings.clerk_jwks_url.replace("/.well-known/jwks.json", "")
|
||||
if settings.clerk_jwks_url
|
||||
else None
|
||||
),
|
||||
leeway=10,
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return None
|
||||
return user_id
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def verify_local_token(request: Request) -> str | None:
|
||||
"""Verify Periscope local JWT and return user_id, or None if invalid."""
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return "anonymous"
|
||||
|
||||
token = _bearer_or_query_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
from backend.services.local_jwt import decode_token
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
user_id = payload.get("sub")
|
||||
return str(user_id) if user_id else None
|
||||
|
||||
|
||||
async def verify_request_user(request: Request) -> str | None:
|
||||
"""Dispatch to Clerk or local JWT verification."""
|
||||
if settings.use_clerk:
|
||||
return await verify_clerk_token(request)
|
||||
if settings.use_local_auth:
|
||||
return await verify_local_token(request)
|
||||
return None
|
||||
@@ -0,0 +1,13 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
_src, _other, _dep = [], [], []
|
||||
for _p in __path__:
|
||||
_n = str(_p).replace("\\", "/")
|
||||
if "/periscope/src/" in _n:
|
||||
_src.append(_p)
|
||||
elif "/periscope/dependency/" in _n:
|
||||
_dep.append(_p)
|
||||
else:
|
||||
_other.append(_p)
|
||||
__path__[:] = _src + _other + _dep
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import ComponentType, DesignGraph
|
||||
from backend.pinscopex.utils import natural_sort_key
|
||||
from backend.periscopex.models import ComponentType, DesignGraph
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
|
||||
|
||||
def build_bom_summary(
|
||||
@@ -45,7 +45,7 @@ def build_bom_summary(
|
||||
# Drop None values and internal numeric fields
|
||||
raw = {
|
||||
k: v for k, v in raw.items()
|
||||
if v is not None and k not in ("value_ohms", "value_farads", "value_henries")
|
||||
if v is not None and k not in ("value_ohms", "value_farads", "value_henries", "impedance_ohm")
|
||||
}
|
||||
specs_dict = raw if raw else None
|
||||
|
||||
+78
-2
@@ -4,12 +4,63 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.pinscopex.models import ComponentType, DesignGraph, NetType
|
||||
from backend.pinscopex.utils import natural_sort_key
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, NetType
|
||||
from backend.periscopex.resolve_passives import _format_value
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
|
||||
# Dielectric strings that indicate ceramic capacitors
|
||||
_CERAMIC_DIELECTRICS = {"X7R", "X5R", "C0G", "NP0", "Y5V", "X7S", "X6S", "X8R", "C0G (NP0)"}
|
||||
|
||||
# Remaining C/C0 vs V/Vrated. Empirical stima, not a vendor lot curve.
|
||||
_BIAS_CURVES: dict[str, list[tuple[float, float]]] = {
|
||||
"c0g": [(0.0, 1.0), (1.2, 1.0)],
|
||||
"x7r": [(0.0, 1.0), (0.25, 0.90), (0.50, 0.70), (0.75, 0.45), (1.0, 0.30), (1.2, 0.22)],
|
||||
"x5r": [(0.0, 1.0), (0.25, 0.82), (0.50, 0.55), (0.75, 0.32), (1.0, 0.18), (1.2, 0.12)],
|
||||
"y5v": [(0.0, 1.0), (0.25, 0.50), (0.50, 0.20), (0.80, 0.12), (1.0, 0.10)],
|
||||
}
|
||||
|
||||
|
||||
def _lerp(curve: list[tuple[float, float]], x: float) -> float:
|
||||
if x <= curve[0][0]:
|
||||
return curve[0][1]
|
||||
for (x0, y0), (x1, y1) in zip(curve, curve[1:]):
|
||||
if x <= x1:
|
||||
if x1 == x0:
|
||||
return y1
|
||||
t = (x - x0) / (x1 - x0)
|
||||
return y0 + t * (y1 - y0)
|
||||
return curve[-1][1]
|
||||
|
||||
|
||||
def _bias_family(dielectric: str | None) -> str | None:
|
||||
if not dielectric:
|
||||
return None
|
||||
u = dielectric.upper()
|
||||
if "C0G" in u or "NP0" in u or "NPO" in u:
|
||||
return "c0g"
|
||||
if "Y5V" in u:
|
||||
return "y5v"
|
||||
if "X5R" in u or "X6S" in u:
|
||||
return "x5r"
|
||||
if "X7R" in u or "X7S" in u or "X8R" in u:
|
||||
return "x7r"
|
||||
return None
|
||||
|
||||
|
||||
def dc_bias_remaining(
|
||||
dielectric: str | None,
|
||||
v_op: float | None,
|
||||
rated_v: float | None,
|
||||
) -> float | None:
|
||||
"""Fraction of nominal C remaining under DC bias, or None if not modelled.
|
||||
|
||||
Labelled a *stima*: class-2 MLCC curves vary by lot, thickness and vendor.
|
||||
"""
|
||||
family = _bias_family(dielectric)
|
||||
if family is None or v_op is None or rated_v is None or rated_v <= 0:
|
||||
return None
|
||||
return _lerp(_BIAS_CURVES[family], max(0.0, v_op) / rated_v)
|
||||
|
||||
|
||||
def _parse_voltage_rating(s: str | None) -> float | None:
|
||||
"""Extract numeric voltage from a rating string like '16V', '25V', '2.5V'."""
|
||||
@@ -44,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.
|
||||
|
||||
@@ -64,10 +127,12 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
rated_v: float | None = None
|
||||
value_fmt: str | None = None
|
||||
dielectric: str | None = None
|
||||
c_nom: float | None = None
|
||||
if comp.specs and hasattr(comp.specs, "voltage_rating_v"):
|
||||
rated_v = _parse_voltage_rating(comp.specs.voltage_rating_v)
|
||||
value_fmt = getattr(comp.specs, "value_formatted", None)
|
||||
dielectric = getattr(comp.specs, "dielectric", None)
|
||||
c_nom = getattr(comp.specs, "value_farads", None)
|
||||
|
||||
# Operating voltage: max non-zero voltage among connected nets
|
||||
op_voltage: float | None = None
|
||||
@@ -107,6 +172,10 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
net_minus = by_v[0][0]
|
||||
net_plus = by_v[-1][0]
|
||||
|
||||
factor = dc_bias_remaining(dielectric, op_voltage, rated_v)
|
||||
c_eff = (c_nom * factor) if (c_nom is not None and factor is not None) else None
|
||||
c_eff_fmt = _format_value(c_eff, "F") if c_eff is not None else None
|
||||
|
||||
rows.append({
|
||||
"designator": comp.reference,
|
||||
"mpn": comp.mpn,
|
||||
@@ -117,6 +186,13 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
"net_plus": net_plus,
|
||||
"net_minus": net_minus,
|
||||
"dielectric_category": _dielectric_category(comp.component_subtype, dielectric),
|
||||
"dielectric": dielectric,
|
||||
"c_nominal_f": c_nom,
|
||||
"dc_bias_factor": factor,
|
||||
"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"]))
|
||||
@@ -6,8 +6,9 @@ import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.periscopex.models import (
|
||||
CadIndexEntry,
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentModel,
|
||||
@@ -22,8 +23,8 @@ from backend.pinscopex.models import (
|
||||
|
||||
# Datasheets are loaded here for pin-name enrichment during graph build,
|
||||
# but NOT embedded into the graph. The validator loads them separately.
|
||||
from backend.pinscopex.parsers import parse_bom, parse_netlist_any
|
||||
from backend.pinscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
|
||||
from backend.periscopex.parsers import parse_bom, parse_netlist_any
|
||||
from backend.periscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component type classification
|
||||
@@ -31,8 +32,10 @@ from backend.pinscopex.resolve_passives import SkippedItem, resolve_bom, resolve
|
||||
|
||||
_PREFIX_TYPE: dict[str, ComponentType] = {
|
||||
"R": ComponentType.RESISTOR,
|
||||
"RN": ComponentType.RESISTOR,
|
||||
"C": ComponentType.CAPACITOR,
|
||||
"L": ComponentType.INDUCTOR,
|
||||
"FB": ComponentType.INDUCTOR,
|
||||
"U": ComponentType.IC,
|
||||
"IC": ComponentType.IC,
|
||||
"J": ComponentType.CONNECTOR,
|
||||
@@ -154,6 +157,11 @@ def _infer_net_properties(name: str) -> tuple[NetType, float | None]:
|
||||
voltage = _parse_rail_voltage(name)
|
||||
return NetType.POWER, voltage
|
||||
|
||||
# KiCad-style rails: 3V3_DIGITAL, 1V8_SI4684, 5V_USB (not I2C1-SCL-3V3).
|
||||
if re.match(r"^\d+V\d*", upper):
|
||||
voltage = _parse_rail_voltage(name)
|
||||
return NetType.POWER, voltage
|
||||
|
||||
# Everything else is a signal
|
||||
return NetType.SIGNAL, None
|
||||
|
||||
@@ -246,6 +254,7 @@ def build_graph(
|
||||
mpn_col: str = "Manufacturer Part Number",
|
||||
skipped: list[SkippedItem] | None = None,
|
||||
include_subdesigns: set[str] | None = None,
|
||||
pcb_path: str | Path | None = None,
|
||||
) -> DesignGraph:
|
||||
"""Build a DesignGraph deterministically from project files.
|
||||
|
||||
@@ -256,17 +265,63 @@ def build_graph(
|
||||
4. Resolve passive specs from patterns + cached component models
|
||||
5. Assemble components with classified type, linked constraints, and specs
|
||||
6. Assemble nets with inferred type/voltage and enriched pin names
|
||||
|
||||
When ``pcb_path`` points at a ``.kicad_pcb``, pad nets from the board replace
|
||||
schematic-derived connectivity (KiCad board nets are authoritative).
|
||||
"""
|
||||
# Parse BOM first so we can feed known refs into the netlist parser —
|
||||
# PADS-PCB netlists allow multi-word designators (e.g. "CV GND"), which
|
||||
# only tokenise correctly with the BOM's ref list as a lookup. EDIF
|
||||
# netlists ignore known_refs (designators are unambiguous tokens).
|
||||
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
|
||||
parts, raw_nets, _ = parse_netlist_any(
|
||||
bom_fields = {}
|
||||
for ref, entry in bom.items():
|
||||
row = {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
|
||||
if "dnp" in entry:
|
||||
row["dnp"] = entry.get("dnp")
|
||||
if entry.get("variant") is not None:
|
||||
row["variant"] = entry.get("variant")
|
||||
bom_fields[ref] = row
|
||||
schematic_fields: dict[str, dict] = {}
|
||||
parts, raw_nets, fmt = parse_netlist_any(
|
||||
netlist_path,
|
||||
known_refs=set(bom.keys()),
|
||||
include_subdesigns=include_subdesigns,
|
||||
)
|
||||
if pcb_path is not None:
|
||||
pcb = Path(pcb_path)
|
||||
if pcb.is_file():
|
||||
from backend.periscopex.parsers_kicad_pcb import nets_from_pcb, parse_kicad_pcb
|
||||
|
||||
layout = parse_kicad_pcb(pcb)
|
||||
pcb_nets = nets_from_pcb(layout)
|
||||
if pcb_nets:
|
||||
raw_nets = pcb_nets
|
||||
for ref, fp in layout.footprints.items():
|
||||
parts.setdefault(ref, fp.footprint or "")
|
||||
if fmt.startswith("kicad"):
|
||||
from backend.periscopex.parsers_kicad import kicad_part_fields
|
||||
for ref, extra in kicad_part_fields(netlist_path).items():
|
||||
schematic_fields[ref] = {
|
||||
"mpn": extra.get("mpn"),
|
||||
"value": extra.get("value", ""),
|
||||
"cad_uuid": extra.get("cad_uuid") or "",
|
||||
"cad_sheet": extra.get("cad_sheet") or "",
|
||||
}
|
||||
entry = bom.setdefault(
|
||||
ref,
|
||||
{"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None},
|
||||
)
|
||||
if extra.get("mpn") and (
|
||||
not entry.get("mpn") or entry.get("mpn") == entry.get("value")
|
||||
):
|
||||
entry["mpn"] = extra["mpn"]
|
||||
if extra.get("lcsc") and not entry.get("lcsc"):
|
||||
entry["lcsc"] = extra["lcsc"]
|
||||
if extra.get("value") and not entry.get("value"):
|
||||
entry["value"] = extra["value"]
|
||||
if extra.get("footprint") and not entry.get("footprint"):
|
||||
entry["footprint"] = extra["footprint"]
|
||||
datasheets = _load_datasheets(datasheets_dir)
|
||||
|
||||
# --- Resolve passive specs ------------------------------------------------
|
||||
@@ -301,7 +356,9 @@ def build_graph(
|
||||
for ref, footprint in parts.items():
|
||||
bom_entry = bom.get(ref, {})
|
||||
value = bom_entry.get("value", "")
|
||||
mpn = bom_entry.get("mpn")
|
||||
mpn = bom_entry.get("mpn") or None
|
||||
if not mpn and _classify_component(ref, footprint) == ComponentType.IC:
|
||||
mpn = (value or "").strip() or None
|
||||
|
||||
components[ref] = Component(
|
||||
reference=ref,
|
||||
@@ -371,4 +428,17 @@ def build_graph(
|
||||
pins=pin_connections,
|
||||
)
|
||||
|
||||
return DesignGraph(components=components, nets=nets)
|
||||
cad_index: dict[str, CadIndexEntry] = {}
|
||||
for ref, extra in schematic_fields.items():
|
||||
uuid = extra.get("cad_uuid") or ""
|
||||
sheet = extra.get("cad_sheet") or ""
|
||||
if uuid or sheet:
|
||||
cad_index[ref] = CadIndexEntry(uuid=uuid, sheet=sheet)
|
||||
|
||||
return DesignGraph(
|
||||
components=components,
|
||||
nets=nets,
|
||||
bom_fields=bom_fields,
|
||||
schematic_fields=schematic_fields,
|
||||
cad_index=cad_index,
|
||||
)
|
||||
+2
-2
@@ -17,8 +17,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.pinscopex.models import ComponentType, DesignGraph, Finding, NetType
|
||||
from backend.pinscopex.resolve_passives import _parse_spice_value
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, NetType
|
||||
from backend.periscopex.resolve_passives import _parse_spice_value
|
||||
|
||||
_COLOR_TOKENS = {
|
||||
"R": "red", "RED": "red",
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Pydantic models for PinscopeX: datasheet constraints and design graph."""
|
||||
"""Pydantic models for PeriscopeX: datasheet constraints and design graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator, model_validator
|
||||
|
||||
|
||||
class Pin(BaseModel):
|
||||
@@ -40,10 +40,17 @@ def _check_subtype(v: object) -> str | None:
|
||||
"""Shared pre-validator for component_subtype fields."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
from backend.pinscopex.taxonomy import validate_subtype
|
||||
from backend.periscopex.taxonomy import validate_subtype
|
||||
return validate_subtype(str(v))
|
||||
|
||||
|
||||
class InternalFeatures(BaseModel):
|
||||
"""Block-diagram extras: ESD clamps, on-die pull-ups, analog switches."""
|
||||
esd_clamp_pins: list[str] = []
|
||||
pullup_pins: list[str] = []
|
||||
analog_switch: list[str] = []
|
||||
|
||||
|
||||
class ComponentConstraints(BaseModel):
|
||||
mpn: str
|
||||
model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
|
||||
@@ -52,6 +59,8 @@ class ComponentConstraints(BaseModel):
|
||||
pintable: list[Pin]
|
||||
absolute_maximum_ratings: list[AbsMaxRating]
|
||||
rules: list[Rule]
|
||||
internal_features: InternalFeatures | None = None
|
||||
layout_rules: list[dict] = []
|
||||
|
||||
_validate_subtype = field_validator("component_subtype", mode="before")(
|
||||
staticmethod(_check_subtype)
|
||||
@@ -131,20 +140,31 @@ class CapacitorSpecs(BaseModel):
|
||||
|
||||
|
||||
class InductorSpecs(BaseModel):
|
||||
"""Standardised inductor parameters. Value always in henries."""
|
||||
"""Standardised inductor / ferrite-bead parameters."""
|
||||
specs_type: Literal["inductor"] = "inductor"
|
||||
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
|
||||
value_henries: float
|
||||
value_henries: float | None = None
|
||||
value_formatted: str
|
||||
tolerance: str | None = None # "±5%" or "±0.1uH"
|
||||
package: str | None = None
|
||||
current_rating_a: str | None = None
|
||||
dcr_ohms: float | None = None
|
||||
impedance_ohm: float | None = None # ferrite beads: Z at test frequency
|
||||
|
||||
_validate_subtype = field_validator("component_subtype", mode="before")(
|
||||
staticmethod(_check_subtype)
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_primary_value(self) -> InductorSpecs:
|
||||
if self.component_subtype == "passive.ferrite_bead":
|
||||
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
|
||||
|
||||
|
||||
class SimpleComponentSpecs(BaseModel):
|
||||
"""Specs for discrete/simple components. Schema defined in taxonomy JSON."""
|
||||
@@ -223,6 +243,12 @@ class Component(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CadIndexEntry(BaseModel):
|
||||
"""KiCad symbol identity for plugin pan-and-zoom."""
|
||||
uuid: str = ""
|
||||
sheet: str = ""
|
||||
|
||||
|
||||
class DesignGraph(BaseModel):
|
||||
"""
|
||||
Bipartite design graph: Components <-> Nets.
|
||||
@@ -233,6 +259,10 @@ class DesignGraph(BaseModel):
|
||||
"""
|
||||
components: dict[str, Component] = {}
|
||||
nets: dict[str, Net] = {}
|
||||
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
|
||||
bom_fields: dict[str, dict] = {}
|
||||
schematic_fields: dict[str, dict] = {}
|
||||
cad_index: dict[str, CadIndexEntry] = {}
|
||||
|
||||
# -- Traversal helpers --------------------------------------------------
|
||||
|
||||
@@ -271,7 +301,8 @@ class DesignGraph(BaseModel):
|
||||
"""Capacitor refs connected to a net (useful for decoupling checks)."""
|
||||
return [
|
||||
r for r in self.components_on_net(net_name)
|
||||
if self.components[r].component_type == ComponentType.CAPACITOR
|
||||
if (c := self.components.get(r)) is not None
|
||||
and c.component_type == ComponentType.CAPACITOR
|
||||
]
|
||||
|
||||
def components_by_subtype(self, prefix: str) -> list[str]:
|
||||
@@ -318,7 +349,26 @@ class Finding(BaseModel):
|
||||
status: Literal["ERROR", "WARNING", "INFO"]
|
||||
recommendation: str = ""
|
||||
reference: str = ""
|
||||
source: str | None = None # None/"review" = LLM datasheet review; "pin_mux_check"/"led_current_check" = deterministic
|
||||
source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic
|
||||
net: str | None = None # net name for CAD telemetry / SI filters
|
||||
pins: list[str] = [] # e.g. ["U3.54"] for pan-and-zoom
|
||||
rule_id: str | None = None # deterministic id, e.g. PE-MUX-001
|
||||
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):
|
||||
@@ -330,6 +380,7 @@ class ValidationReport(BaseModel):
|
||||
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
|
||||
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
|
||||
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
|
||||
protocol_certification: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FindingComment(BaseModel):
|
||||
@@ -409,3 +460,68 @@ class ResolvedPassive(BaseModel):
|
||||
power_rating: str | None = None
|
||||
dielectric: str | None = None
|
||||
raw_fields: dict[str, str] = {}
|
||||
|
||||
|
||||
class LayoutPad(BaseModel):
|
||||
number: str
|
||||
x: float
|
||||
y: float
|
||||
net: str = ""
|
||||
pinfunction: str = ""
|
||||
|
||||
|
||||
class LayoutFootprint(BaseModel):
|
||||
reference: str
|
||||
footprint: str = ""
|
||||
x: float
|
||||
y: float
|
||||
layer: str = ""
|
||||
pads: list[LayoutPad] = []
|
||||
courtyard: list[tuple[float, float]] = []
|
||||
|
||||
|
||||
class LayoutSegment(BaseModel):
|
||||
start: tuple[float, float]
|
||||
end: tuple[float, float]
|
||||
width: float = 0.0
|
||||
layer: str = ""
|
||||
net: str = ""
|
||||
|
||||
|
||||
class LayoutVia(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
net: str = ""
|
||||
drill: float | None = None
|
||||
layers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class LayoutDielectric(BaseModel):
|
||||
name: str
|
||||
er: float
|
||||
height_mm: float
|
||||
|
||||
|
||||
class LayoutStackup(BaseModel):
|
||||
copper_layers: list[str]
|
||||
dielectrics: list[LayoutDielectric]
|
||||
copper_thickness_mm: float | None = None
|
||||
|
||||
|
||||
class LayoutZone(BaseModel):
|
||||
net: str
|
||||
layer: str
|
||||
outlines: list[list[tuple[float, float]]] = []
|
||||
keepout: bool = False
|
||||
name: str = ""
|
||||
|
||||
|
||||
class LayoutGraph(BaseModel):
|
||||
"""Parsed `.kicad_pcb` geometry. Optional; schema validation does not require it."""
|
||||
nets: dict[str, int] = {}
|
||||
footprints: dict[str, LayoutFootprint] = {}
|
||||
segments: list[LayoutSegment] = []
|
||||
vias: list[LayoutVia] = []
|
||||
stackup: LayoutStackup | None = None
|
||||
zones: list[LayoutZone] = []
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
NetlistFormat = Literal["pads", "edif"]
|
||||
NetlistFormat = Literal["pads", "edif", "kicad_xml", "kicad_sexp", "kicad_sch"]
|
||||
|
||||
|
||||
def parse_netlist(
|
||||
@@ -157,25 +158,26 @@ def _parse_pin_tokens(
|
||||
|
||||
|
||||
def detect_netlist_format(content: bytes | str) -> NetlistFormat:
|
||||
"""Sniff the first chunk of a netlist to decide whether it's PADS or EDIF.
|
||||
"""Sniff the first chunk of a netlist to decide the format.
|
||||
|
||||
EDIF s-expressions start with ``(edif …`` (with possible leading whitespace
|
||||
or BOM); PADS-PCB ASCII files start with ``*PADS-PCB*``. The "pads" branch
|
||||
is the default when no clear marker is found — preserves the old behavior
|
||||
where the parser raises a friendly error on unrecognised input.
|
||||
EDIF starts with ``(edif``; KiCad XML with ``<export`` / ``<?xml``;
|
||||
KiCad s-expr netlist with ``(export``; schematic with ``(kicad_sch``.
|
||||
PADS-PCB ASCII (``*PADS-PCB*``) is the default when no marker is found.
|
||||
"""
|
||||
if isinstance(content, bytes):
|
||||
try:
|
||||
text = content[:1024].decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
text = ""
|
||||
text = content[:2048].decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = content[:1024]
|
||||
head = text.lstrip("").lstrip()
|
||||
# Case-insensitive match — EDIF spec allows different capitalisations
|
||||
# (KiCad emits lowercase; xDX Designer emits lowercase too).
|
||||
if head[:5].lower() == "(edif":
|
||||
text = content[:2048]
|
||||
head = text.lstrip("\ufeff").lstrip()
|
||||
low = head[:40].lower()
|
||||
if low.startswith("(edif"):
|
||||
return "edif"
|
||||
if low.startswith("(kicad_sch"):
|
||||
return "kicad_sch"
|
||||
if low.startswith("(export"):
|
||||
return "kicad_sexp"
|
||||
if low.startswith("<?xml") or low.startswith("<export"):
|
||||
return "kicad_xml"
|
||||
return "pads"
|
||||
|
||||
|
||||
@@ -195,11 +197,14 @@ def parse_netlist_any(
|
||||
their nets land in the output (PADS netlists have no sub-design concept).
|
||||
"""
|
||||
p = Path(path)
|
||||
sample = p.read_bytes()[:1024]
|
||||
sample = p.read_bytes()[:2048]
|
||||
fmt = detect_netlist_format(sample)
|
||||
if fmt == "edif":
|
||||
from backend.pinscopex.parsers_edif import parse_edif_netlist
|
||||
from backend.periscopex.parsers_edif import parse_edif_netlist
|
||||
parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
|
||||
elif fmt.startswith("kicad"):
|
||||
from backend.periscopex.parsers_kicad import parse_kicad
|
||||
parts, nets, _ = parse_kicad(p)
|
||||
else:
|
||||
parts, nets = parse_netlist(p, known_refs=known_refs)
|
||||
return parts, nets, fmt
|
||||
@@ -210,7 +215,10 @@ def validate_netlist(parts: dict, nets: dict) -> list[str]:
|
||||
errors: list[str] = []
|
||||
|
||||
if not parts:
|
||||
errors.append("No components found — is this a PADS-PCB (.asc) or EDIF (.edn) netlist?")
|
||||
errors.append(
|
||||
"No components found — is this a PADS-PCB (.asc), EDIF (.edn), "
|
||||
"or KiCad netlist / .kicad_sch?"
|
||||
)
|
||||
return errors # further checks are meaningless without parts
|
||||
|
||||
if not nets:
|
||||
@@ -259,22 +267,47 @@ def parse_bom(
|
||||
result: dict[str, dict] = {}
|
||||
text = Path(path).read_text()
|
||||
reader = csv.DictReader(text.splitlines())
|
||||
colnames = {n.lower() for n in (reader.fieldnames or []) if n}
|
||||
has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
|
||||
has_variant_col = bool(colnames & {"variant"})
|
||||
|
||||
for row in reader:
|
||||
refs_raw = row.get(reference_col, "")
|
||||
value = row.get("Value", "") or row.get("Comment", "")
|
||||
footprint = row.get("Footprint", "")
|
||||
mpn = row.get(mpn_col, "") or None
|
||||
mpn = (row.get(mpn_col, "") or "").strip() or None
|
||||
lcsc = row.get("LCSC", "") or None
|
||||
datasheet_url = (row.get("Datasheet", "") or "").strip() or None
|
||||
|
||||
# Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
|
||||
for ref in (r.strip() for r in refs_raw.split(",")):
|
||||
if ref:
|
||||
result[ref] = {
|
||||
"value": value,
|
||||
"footprint": footprint,
|
||||
"mpn": mpn,
|
||||
"lcsc": lcsc,
|
||||
}
|
||||
refs = [r.strip() for r in refs_raw.split(",") if r.strip()]
|
||||
# KiCad exports often leave Manufacturer Part Number empty and put
|
||||
# the orderable code in Value (or PNM). Without this, U* never
|
||||
# enter ic_mpns and review reports "no datasheet PDF".
|
||||
if not mpn:
|
||||
mpn = (row.get("PNM", "") or "").strip() or None
|
||||
if not mpn and any(re.match(r"^U\d", r, re.I) for r in refs):
|
||||
mpn = (value or "").strip() or None
|
||||
|
||||
dnp_raw = (row.get("DNP") or row.get("DNI") or "").strip().lower()
|
||||
fitted_raw = (row.get("Fitted") or row.get("Populate") or "").strip().lower()
|
||||
variant = (row.get("Variant") or row.get("variant") or "").strip() or None
|
||||
is_dnp = dnp_raw in {"1", "y", "yes", "true", "dnp", "dni", "x"}
|
||||
if not is_dnp and fitted_raw in {"0", "n", "no", "false"}:
|
||||
is_dnp = True
|
||||
|
||||
for ref in refs:
|
||||
entry = {
|
||||
"value": value,
|
||||
"footprint": footprint,
|
||||
"mpn": mpn,
|
||||
"lcsc": lcsc,
|
||||
"datasheet_url": datasheet_url,
|
||||
}
|
||||
if has_dnp_col:
|
||||
entry["dnp"] = is_dnp
|
||||
if has_variant_col:
|
||||
entry["variant"] = variant
|
||||
result[ref] = entry
|
||||
|
||||
return result
|
||||
+6
-3
@@ -16,19 +16,19 @@ perspective is ambiguous).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
)
|
||||
from backend.pinscopex.pin_function_tokens import (
|
||||
from backend.periscopex.pin_function_tokens import (
|
||||
complement,
|
||||
normalize_functions,
|
||||
parse_net_token,
|
||||
signals_for_peripheral,
|
||||
)
|
||||
from backend.pinscopex.validate import _match_constraints
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
|
||||
def check_pin_mux_feasibility(
|
||||
@@ -169,4 +169,7 @@ def _feasibility_finding(
|
||||
),
|
||||
recommendation=rec,
|
||||
reference=f"{mpn or ref} alternate-function table",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-MUX-001",
|
||||
)
|
||||
+30
-3
@@ -8,7 +8,7 @@ import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
CapacitorSpecs,
|
||||
ComponentSpecs,
|
||||
ComponentType,
|
||||
@@ -19,7 +19,7 @@ from backend.pinscopex.models import (
|
||||
SimpleComponentSpecs,
|
||||
ValueDecoder,
|
||||
)
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
from backend.periscopex.parsers import parse_bom
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -304,7 +304,34 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
|
||||
dielectric=dielectric,
|
||||
)
|
||||
|
||||
if subtype.startswith("passive.inductor") or subtype == "passive.ferrite_bead":
|
||||
if subtype == "passive.ferrite_bead":
|
||||
raw = vals.get("impedance_ohm") or vals.get("value_ohms")
|
||||
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
|
||||
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 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")
|
||||
if raw is None:
|
||||
raise ValueError(f"Missing value_henries in auto-resolved inductor specs")
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared utility functions for the pinscopex core library."""
|
||||
"""Shared utility functions for the periscopex core library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+98
-18
@@ -20,7 +20,8 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.finding_engine import complete_findings
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
@@ -28,8 +29,9 @@ from backend.pinscopex.models import (
|
||||
NetType,
|
||||
ValidationReport,
|
||||
)
|
||||
from backend.pinscopex.pin_function_tokens import parse_net_token
|
||||
from backend.pinscopex.validation_tools import (
|
||||
from backend.periscopex.pin_function_tokens import parse_net_token
|
||||
from backend.periscopex.quote_verify import verify_finding_citations
|
||||
from backend.periscopex.validation_tools import (
|
||||
ALL_TOOLS,
|
||||
SUBMIT_REVIEW_SCHEMA,
|
||||
ConstraintsMap,
|
||||
@@ -54,13 +56,21 @@ how it's wired in the actual circuit.
|
||||
Treat this IC as a COVERAGE CHECKLIST, not a single investigation. Before \
|
||||
hunting for problems, enumerate every focus area this IC has — derive them \
|
||||
from its pins, nets, neighbors, and subtype. A typical checklist:
|
||||
- Power & decoupling on each supply pin.
|
||||
- Power & decoupling on each supply pin — recommended Cin/Cout values, \
|
||||
ESR, and placement notes, not just "a cap is present".
|
||||
- Each signal interface to each connected component — voltage \
|
||||
compatibility, direction, and correct cross-connection (e.g. TX↔RX).
|
||||
- Absolute-maximum ratings on each pin vs. the actual rail driving it.
|
||||
- Absolute-maximum ratings on each pin vs. the actual rail driving it. \
|
||||
Use the extracted abs-max table in the component context when present; \
|
||||
confirm against the datasheet page if a number is missing or ambiguous.
|
||||
- Recommended operating conditions and electrical characteristics \
|
||||
(VIH/VIL, VOL/VOH, input leakage, drive strength) where they change \
|
||||
whether the interface actually works.
|
||||
- Reset / enable / boot / mode-strap / configuration pins.
|
||||
- Clock or crystal circuit, if present.
|
||||
- Required external components named by the datasheet.
|
||||
- Clock or crystal circuit, if present — load capacitors and the \
|
||||
datasheet's recommended values.
|
||||
- Required external components named by the datasheet (bootstrap, \
|
||||
compensation, feedback divider, sense resistor).
|
||||
- Unused / no-connect pins.
|
||||
|
||||
Then work the areas one at a time. For EACH area, don't just confirm a \
|
||||
@@ -127,8 +137,12 @@ INFO (worth noting but unlikely to cause problems).
|
||||
- **source_quote**: The exact verbatim sentence or clause from the datasheet \
|
||||
that states the requirement. Copy it precisely, character-for-character (a \
|
||||
short span, ~200 chars max) so it can be located and highlighted in the PDF. \
|
||||
Omit this field when the requirement is shown only in a figure or a \
|
||||
rasterized table with no selectable text — do not paraphrase or invent a quote.
|
||||
ERROR and WARNING findings **must** include this field. Periscope checks the \
|
||||
quote against the extracted text of the cited page (±1); invented or \
|
||||
paraphrased quotes are demoted to Unverified WARNING. Omit the field only \
|
||||
when the requirement is shown solely in a figure or a rasterized table with \
|
||||
no selectable text — then status is WARNING at most and `why` must start \
|
||||
with `Unverified:`.
|
||||
- **source_designator**: Leave unset when `source_page`/`source_quote` come \
|
||||
from THIS component's datasheet (the default). Set it to a connected \
|
||||
component's designator (e.g. `U3`) only when the page/quote come from that \
|
||||
@@ -136,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 \
|
||||
@@ -231,10 +251,10 @@ whose purpose you have not identified.
|
||||
|
||||
### Budget per concern: cap ONE concern, not the whole review
|
||||
A single concern (one potential finding under investigation) gets at \
|
||||
most two follow-up tool calls beyond what was already in your initial \
|
||||
most three follow-up tool calls beyond what was already in your initial \
|
||||
context. If the concern is not resolved within that budget, submit it \
|
||||
as WARNING with `why` starting `Unverified: <what you could not \
|
||||
establish in two queries>` and move on to the next area. This per-concern \
|
||||
establish in three queries>` and move on to the next area. This per-concern \
|
||||
cap exists so one concern cannot swallow the whole review — NOT so you \
|
||||
finish early. Your total budget across all concerns is generous: spend it \
|
||||
on breadth. The failure mode to avoid is leaving focus areas of this IC \
|
||||
@@ -346,6 +366,27 @@ alternate-function list shown for peripheral-named-net pins is taken \
|
||||
verbatim from the datasheet pin table and is reliable even when the short \
|
||||
`(NAME)` label is not — prefer it when judging what a pin can be muxed to.
|
||||
|
||||
### ESD / TVS arrays — do not invent the diode topology
|
||||
An IO pin whose neighbor is GND (or whose pin name is IO/I/O) does NOT \
|
||||
mean a single steering diode from IO to GND that conducts at ~0.7 V. \
|
||||
Many 2-channel ESD arrays (audio, RS-232, RS-485) are *bidirectional \
|
||||
back-to-back* with a signed working voltage (Vrwm, often ±12 V or \
|
||||
±13 V). In that topology a 1 Vrms AC-coupled audio swing is inside the \
|
||||
standoff range and is not clipped.
|
||||
|
||||
Before claiming clipping, forward conduction, or "unidirectional clamp":
|
||||
1. Quote the datasheet topology (block diagram or "bidirectional" / \
|
||||
"unidirectional" / "back-to-back" wording) in `source_quote`.
|
||||
2. Quote Vrwm (or equivalent working-voltage row) with sign. Use that \
|
||||
number as the standoff, not a generic silicon Vf.
|
||||
3. If the block diagram or electrical table is unreadable, status is \
|
||||
WARNING at most and `why` must start with `Unverified:` — never ERROR \
|
||||
from "typical for this part" or from pin names alone.
|
||||
|
||||
A replacement recommendation must name a part whose topology matches \
|
||||
the signal (do not suggest a unidirectional array for a bipolar \
|
||||
AC-coupled audio net).
|
||||
|
||||
### Direction-control and transceiver function tables
|
||||
Bidirectional transceivers, level shifters, mux/demux, bus switches, and \
|
||||
analog switches (74xx245, 74xx125, 74xx157, TS3A-family, etc.) often \
|
||||
@@ -414,7 +455,7 @@ context if needed.
|
||||
|
||||
|
||||
# Maximum turns for the review agentic loop
|
||||
_MAX_REVIEW_TURNS = 10
|
||||
_MAX_REVIEW_TURNS = 16
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -449,6 +490,18 @@ def build_component_context(
|
||||
if constraints and constraints.package_info:
|
||||
pi = constraints.package_info
|
||||
lines.append(f"Package: {pi.package}, {pi.pin_count} pins")
|
||||
if constraints and constraints.absolute_maximum_ratings:
|
||||
lines.append("Extracted ratings (abs-max, plus Vrwm/polarity for ESD):")
|
||||
for r in constraints.absolute_maximum_ratings:
|
||||
bits = []
|
||||
if r.min is not None:
|
||||
bits.append(f"min {r.min:g}")
|
||||
if r.max is not None:
|
||||
bits.append(f"max {r.max:g}")
|
||||
span = " ".join(bits) if bits else "?"
|
||||
lines.append(
|
||||
f" {r.parameter}: {span} {r.unit} (datasheet p.{r.source_page})"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Build pin list — prefer extracted pintable order, fall back to netlist.
|
||||
@@ -765,7 +818,13 @@ def review_component(
|
||||
# Check for submit_review
|
||||
for block in response.content:
|
||||
if block.type == "tool_use" and block.name == "submit_review":
|
||||
return _parse_review(block.input, ic_ref, mpn)
|
||||
result = _parse_review(block.input, ic_ref, mpn)
|
||||
verify_finding_citations(
|
||||
result.findings,
|
||||
default_pdf=Path(pdf_path),
|
||||
default_mpn=mpn,
|
||||
)
|
||||
return result
|
||||
|
||||
# Process graph tool calls
|
||||
tool_results = []
|
||||
@@ -854,17 +913,37 @@ def _parse_review(
|
||||
else:
|
||||
src_designator = None
|
||||
src_mpn = mpn
|
||||
status = item["status"]
|
||||
why = str(item.get("why") or "")
|
||||
quote = str(item.get("source_quote") or "").strip()
|
||||
# ERROR/WARNING with no verbatim quote: demote before PDF check.
|
||||
if status in ("ERROR", "WARNING") and not quote:
|
||||
if status == "ERROR":
|
||||
status = "WARNING"
|
||||
if not why.startswith("Unverified:"):
|
||||
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"],
|
||||
why=item.get("why", ""),
|
||||
status=item["status"],
|
||||
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)
|
||||
@@ -874,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -933,7 +1013,7 @@ def validate_design(
|
||||
model: str = "claude-sonnet-4-6",
|
||||
) -> ValidationReport:
|
||||
"""Load graph, review every IC against its datasheet, write report."""
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
raw = json.loads(Path(graph_path).read_text())
|
||||
graph = DesignGraph.model_validate(raw)
|
||||
+172
-15
@@ -13,11 +13,11 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
DesignGraph,
|
||||
)
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -256,6 +256,104 @@ def get_net_for_pin(
|
||||
return f"Pin {pin}{pin_name} on {designator} -> {net_name} [{net.net_type.value}{voltage_str}]"
|
||||
|
||||
|
||||
def shortest_path(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
designator_a: str,
|
||||
pin_a: str,
|
||||
designator_b: str,
|
||||
pin_b: str,
|
||||
*,
|
||||
max_hops: int = 12,
|
||||
) -> str:
|
||||
"""BFS through the bipartite graph from A.pin to B.pin.
|
||||
|
||||
Hops alternate component→net→component. Returns the hop list or a
|
||||
clear miss message. Caps depth so the reviewer cannot explode memory
|
||||
on dense power nets.
|
||||
"""
|
||||
a = graph.components.get(designator_a)
|
||||
b = graph.components.get(designator_b)
|
||||
if not a:
|
||||
return f"Component '{designator_a}' not found."
|
||||
if not b:
|
||||
return f"Component '{designator_b}' not found."
|
||||
|
||||
net_a = a.pins.get(str(pin_a))
|
||||
net_b = b.pins.get(str(pin_b))
|
||||
if not net_a:
|
||||
return f"Pin {pin_a} on {designator_a} is not connected in the netlist."
|
||||
if not net_b:
|
||||
return f"Pin {pin_b} on {designator_b} is not connected in the netlist."
|
||||
|
||||
if designator_a == designator_b and str(pin_a) == str(pin_b):
|
||||
return f"Same endpoint: {designator_a}.{pin_a} on {net_a}."
|
||||
|
||||
if net_a == net_b:
|
||||
return (
|
||||
f"Direct (same net): {designator_a}.{pin_a} —[{net_a}]— "
|
||||
f"{designator_b}.{pin_b}"
|
||||
)
|
||||
|
||||
# BFS on component nodes; edges are nets shared between components.
|
||||
from collections import deque
|
||||
|
||||
start = designator_a
|
||||
goal = designator_b
|
||||
queue: deque[str] = deque([start])
|
||||
# prev[ref] = (previous_ref, via_net)
|
||||
prev: dict[str, tuple[str, str] | None] = {start: None}
|
||||
hops = 0
|
||||
found = False
|
||||
while queue and hops < max_hops:
|
||||
hops += 1
|
||||
for _ in range(len(queue)):
|
||||
cur = queue.popleft()
|
||||
for net_name, others in graph.neighbors(cur).items():
|
||||
for other in others:
|
||||
if other in prev:
|
||||
continue
|
||||
prev[other] = (cur, net_name)
|
||||
if other == goal:
|
||||
found = True
|
||||
queue.clear()
|
||||
break
|
||||
queue.append(other)
|
||||
if found:
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found or goal not in prev:
|
||||
return (
|
||||
f"No path within {max_hops} hops from "
|
||||
f"{designator_a}.{pin_a} ({net_a}) to "
|
||||
f"{designator_b}.{pin_b} ({net_b})."
|
||||
)
|
||||
|
||||
# Reconstruct component chain, then decorate endpoints with pins.
|
||||
chain_refs: list[str] = []
|
||||
via_nets: list[str] = []
|
||||
node = goal
|
||||
while node != start:
|
||||
chain_refs.append(node)
|
||||
parent, via = prev[node] # type: ignore[misc]
|
||||
via_nets.append(via)
|
||||
node = parent
|
||||
chain_refs.append(start)
|
||||
chain_refs.reverse()
|
||||
via_nets.reverse()
|
||||
|
||||
parts: list[str] = [f"{designator_a}.{pin_a}"]
|
||||
for i, via in enumerate(via_nets):
|
||||
nxt = chain_refs[i + 1]
|
||||
if nxt == designator_b:
|
||||
parts.append(f"—[{via}]— {designator_b}.{pin_b}")
|
||||
else:
|
||||
parts.append(f"—[{via}]— {nxt}")
|
||||
return f"Path ({len(via_nets)} hop(s)): " + " ".join(parts)
|
||||
|
||||
|
||||
def get_pintable(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
@@ -317,18 +415,24 @@ def _resolve_neighbor_pdf(
|
||||
Mirrors validation._find_pdf's local-then-library lookup so neighbor
|
||||
datasheets follow the same resolution rules as the IC under review.
|
||||
"""
|
||||
safe = safe_mpn(mpn)
|
||||
local = state.pdf_dir / f"{safe}.pdf"
|
||||
if local.is_file():
|
||||
from backend.services.datasheet_finder import find_local_pdf
|
||||
|
||||
local = find_local_pdf(state.pdf_dir, mpn)
|
||||
if local is not None and local.is_file():
|
||||
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
|
||||
if local.resolve() != wanted.resolve() and not wanted.is_file():
|
||||
wanted.write_bytes(local.read_bytes())
|
||||
return wanted
|
||||
return local
|
||||
if state.storage is not None:
|
||||
try:
|
||||
from backend.services import projects as proj_svc
|
||||
lib_key = proj_svc.library_has_datasheet(state.storage, mpn)
|
||||
if lib_key:
|
||||
state.storage.download_to_local(lib_key, local)
|
||||
if local.is_file():
|
||||
return local
|
||||
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
|
||||
state.storage.download_to_local(lib_key, wanted)
|
||||
if wanted.is_file():
|
||||
return wanted
|
||||
except Exception:
|
||||
log.exception("excerpt: library lookup failed for %s", mpn)
|
||||
return None
|
||||
@@ -405,7 +509,7 @@ def get_datasheet_excerpt(
|
||||
return ("get_datasheet_excerpt called without per-review state — "
|
||||
"this is a bug, no excerpt returned.", None)
|
||||
|
||||
# Lazy import to avoid backend↔pinscopex circular dependency at module load.
|
||||
# Lazy import to avoid backend↔periscopex circular dependency at module load.
|
||||
from backend.services.llm import PdfBlock
|
||||
|
||||
designator = (designator or "").strip()
|
||||
@@ -584,6 +688,38 @@ GET_NET_FOR_PIN_SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
SHORTEST_PATH_SCHEMA = {
|
||||
"name": "shortest_path",
|
||||
"description": (
|
||||
"Find the shortest hop path through the netlist between two pins "
|
||||
"(component.pin → nets → components). Use to verify whether two "
|
||||
"pins share a rail path, or how a signal reaches another IC, "
|
||||
"instead of guessing from neighborhood context."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"designator_a": {
|
||||
"type": "string",
|
||||
"description": "Start component reference, e.g. 'U1'",
|
||||
},
|
||||
"pin_a": {
|
||||
"type": "string",
|
||||
"description": "Start pin number, e.g. '12'",
|
||||
},
|
||||
"designator_b": {
|
||||
"type": "string",
|
||||
"description": "End component reference, e.g. 'U3'",
|
||||
},
|
||||
"pin_b": {
|
||||
"type": "string",
|
||||
"description": "End pin number, e.g. '5'",
|
||||
},
|
||||
},
|
||||
"required": ["designator_a", "pin_a", "designator_b", "pin_b"],
|
||||
},
|
||||
}
|
||||
|
||||
GET_PINTABLE_SCHEMA = {
|
||||
"name": "get_pintable",
|
||||
"description": (
|
||||
@@ -639,11 +775,10 @@ SUBMIT_REVIEW_SCHEMA = {
|
||||
"source_quote": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The exact verbatim text from the datasheet that "
|
||||
"states this requirement — copy it "
|
||||
"character-for-character (max ~200 chars). Omit "
|
||||
"if the evidence is only in a figure or a "
|
||||
"rasterized table with no selectable text."
|
||||
"Required for ERROR and WARNING. Exact verbatim "
|
||||
"datasheet text (max ~200 chars). Periscope "
|
||||
"checks it against the PDF page. Omit only if "
|
||||
"the evidence is a figure/scan with no text."
|
||||
),
|
||||
},
|
||||
"source_designator": {
|
||||
@@ -662,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"],
|
||||
@@ -722,6 +867,7 @@ GET_DATASHEET_EXCERPT_SCHEMA = {
|
||||
GRAPH_TOOLS = [
|
||||
FIND_CONNECTED_COMPONENTS_SCHEMA,
|
||||
GET_NET_FOR_PIN_SCHEMA,
|
||||
SHORTEST_PATH_SCHEMA,
|
||||
GET_PINTABLE_SCHEMA,
|
||||
GET_DATASHEET_EXCERPT_SCHEMA,
|
||||
]
|
||||
@@ -765,6 +911,17 @@ def execute_tool(
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tool_name == "shortest_path":
|
||||
return (
|
||||
shortest_path(
|
||||
graph, constraints_map,
|
||||
tool_input["designator_a"],
|
||||
tool_input["pin_a"],
|
||||
tool_input["designator_b"],
|
||||
tool_input["pin_b"],
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tool_name == "get_pintable":
|
||||
return (
|
||||
get_pintable(
|
||||
@@ -81,11 +81,13 @@ async def _run() -> None:
|
||||
# are visible to any API instance tailing the event log.
|
||||
pipeline_svc.set_broker(event_bridge.GCSEventBroker(storage, user_id))
|
||||
|
||||
# Fresh runs wipe the prior event log so the SSE consumer doesn't
|
||||
# mix old events into the new run. Resume keeps the prior log so
|
||||
# users see the full history.
|
||||
if not resume:
|
||||
pipeline_svc.broker.clear_history(project_id)
|
||||
# Always wipe the prior event log. Reprocess uses resume=True, and
|
||||
# the old log still contains ``pipeline_complete``; the SSE tail
|
||||
# would stop there and the UI would show a finished run with no live
|
||||
# log while the worker is still reviewing. Pause-resume also hits a
|
||||
# terminal ``pipeline_paused``. A fresh seq from 0 is the only safe
|
||||
# option — completed_review_refs still skip paid ICs.
|
||||
pipeline_svc.broker.clear_history(project_id)
|
||||
|
||||
if mode == "run":
|
||||
await pipeline_svc.run_pipeline(
|
||||
@@ -99,8 +101,16 @@ async def _run() -> None:
|
||||
await pipeline_svc.run_regen_pipeline(
|
||||
storage, user_id, project_id, stages,
|
||||
)
|
||||
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' or 'regen'")
|
||||
raise SystemExit(
|
||||
f"unknown MODE={mode!r}; expected 'run', 'regen', 'placement', or 'pcb'"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]
|
||||
openai>=1.60
|
||||
anthropic>=0.83
|
||||
google-genai>=1.59
|
||||
pydantic[email]>=2.0
|
||||
@@ -12,6 +13,10 @@ google-cloud-storage>=2.14
|
||||
google-cloud-run>=0.10
|
||||
google-api-python-client>=2.100
|
||||
pypdf>=4.0
|
||||
pymupdf>=1.24
|
||||
PyJWT[crypto]>=2.8
|
||||
cryptography>=42.0
|
||||
packaging>=23.0
|
||||
httpx>=0.27
|
||||
packaging>=24.0
|
||||
shapely>=2.0
|
||||
PyYAML>=6.0
|
||||
@@ -0,0 +1,3 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -15,7 +15,7 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.routers.deps import get_storage
|
||||
from backend.services import admin_settings as settings_svc
|
||||
from backend.services.billing_hook import get_billing
|
||||
@@ -41,6 +41,14 @@ async def is_admin(request: Request) -> bool:
|
||||
request.state._is_admin = True
|
||||
return True
|
||||
|
||||
if settings.use_local_auth:
|
||||
from backend.services import local_users
|
||||
|
||||
user = local_users.get_user(user_id)
|
||||
result = bool(user and user.is_admin)
|
||||
request.state._is_admin = result
|
||||
return result
|
||||
|
||||
# Fetch user from Clerk Backend API and check public_metadata.role
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -77,86 +85,13 @@ async def list_components(request: Request):
|
||||
"""List all extracted IC components and passive patterns in the library."""
|
||||
await _require_admin(request)
|
||||
storage = get_storage(request)
|
||||
|
||||
# IC extractions (deduplicate by MPN)
|
||||
ic_keys = [
|
||||
k for k in storage.list_prefix("library/extracted/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
ics = []
|
||||
seen_ic_mpns: set[str] = set()
|
||||
for key in ic_keys:
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ic_mpns:
|
||||
continue
|
||||
seen_ic_mpns.add(mpn)
|
||||
ics.append({
|
||||
"mpn": mpn,
|
||||
"type": "ic",
|
||||
"subtype": data.get("component_subtype", ""),
|
||||
"pin_count": len(data.get("pintable", [])),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Passive patterns
|
||||
pattern_keys = [
|
||||
k for k in storage.list_prefix("library/patterns/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
passives = []
|
||||
seen_passive_names: set[str] = set()
|
||||
for key in pattern_keys:
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if name in seen_passive_names:
|
||||
continue
|
||||
seen_passive_names.add(name)
|
||||
passives.append({
|
||||
"mpn": name,
|
||||
"type": "passive",
|
||||
"subtype": data.get("component_type", ""),
|
||||
"description": data.get("description", ""),
|
||||
"regex": data.get("regex", ""),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Simple component models (library/models/) + passive models (library/passives/)
|
||||
model_keys = [
|
||||
k for k in storage.list_prefix("library/models/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
passive_model_keys = [
|
||||
k for k in storage.list_prefix("library/passives/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
simple_models = []
|
||||
seen_model_mpns: set[str] = set()
|
||||
for key in model_keys + passive_model_keys:
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn", "")
|
||||
if mpn in seen_model_mpns:
|
||||
continue
|
||||
seen_model_mpns.add(mpn)
|
||||
specs = data.get("specs", {})
|
||||
simple_models.append({
|
||||
"mpn": mpn,
|
||||
"type": "simple",
|
||||
"specs_type": specs.get("specs_type", ""),
|
||||
"subtype": specs.get("component_subtype", ""),
|
||||
"param_count": len(specs.get("values", {})),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
catalog = proj_svc.list_library_catalog(storage)
|
||||
return JSONResponse(
|
||||
content={"ics": ics, "passives": passives, "simple": simple_models},
|
||||
content={
|
||||
"ics": catalog["ics"],
|
||||
"passives": catalog["passives"],
|
||||
"simple": catalog["simple"],
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@@ -562,13 +497,23 @@ async def list_running_pipelines(request: Request):
|
||||
if meta.status not in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING):
|
||||
continue
|
||||
|
||||
# Sweeper: if the execution is in a terminal Cloud Run state,
|
||||
# the worker is already gone. Flip status → error so the UI
|
||||
# stops lying. Skip the sweep when execution_name is missing
|
||||
# (worker may still be enqueueing).
|
||||
# Sweeper: if the execution is in a terminal Cloud Run / local
|
||||
# state, the worker is already gone. Flip status → error so the
|
||||
# UI stops lying. Also heal projects whose event log already
|
||||
# ends with pipeline_complete (finished, meta never flipped).
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, uid, meta.id)
|
||||
if healed is not None:
|
||||
continue
|
||||
|
||||
exec_state = "unknown"
|
||||
if meta.execution_name:
|
||||
exec_state = job_runner.get_execution_state(meta.execution_name)
|
||||
elif not job_runner.use_cloud_run_jobs():
|
||||
# Local zombie: no execution_name but a dead pid file, or
|
||||
# no live proc — treat as failed after the stale window.
|
||||
exec_state = job_runner.get_execution_state(
|
||||
f"local/projects/{meta.id}"
|
||||
)
|
||||
if exec_state in ("succeeded", "failed", "cancelled"):
|
||||
# Allow a short grace period so we don't race the worker
|
||||
# writing its own terminal status. updated may be stale
|
||||
@@ -41,10 +41,10 @@ class ContactResponse(BaseModel):
|
||||
def _build_contact_message(data: ContactRequest) -> MIMEMultipart:
|
||||
"""Build the contact form email."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = settings.contact_recipient
|
||||
msg["Reply-To"] = data.email
|
||||
msg["Subject"] = f"[Pinscope Contact] {data.subject or 'New message'} from {data.name}"
|
||||
msg["Subject"] = f"[Periscope Contact] {data.subject or 'New message'} from {data.name}"
|
||||
|
||||
# Plain text
|
||||
lines = [
|
||||
@@ -55,7 +55,7 @@ def _build_contact_message(data: ContactRequest) -> MIMEMultipart:
|
||||
lines.append(f"Company: {data.company}")
|
||||
if data.subject:
|
||||
lines.append(f"Subject: {data.subject}")
|
||||
lines += ["", data.message, "", "— Sent from the Pinscope contact form"]
|
||||
lines += ["", data.message, "", "— Sent from the Periscope contact form"]
|
||||
msg.attach(MIMEText("\n".join(lines), "plain"))
|
||||
|
||||
# HTML
|
||||
@@ -94,7 +94,7 @@ def _build_contact_message(data: ContactRequest) -> MIMEMultipart:
|
||||
{rows}
|
||||
</table>
|
||||
<div style="margin-top: 16px; padding: 16px; background: #f9fafb; border-radius: 8px; font-size: 14px; line-height: 1.6; white-space: pre-wrap;">{message}</div>
|
||||
<p style="margin-top: 24px; font-size: 12px; color: #888;">Sent from the Pinscope contact form</p>
|
||||
<p style="margin-top: 24px; font-size: 12px; color: #888;">Sent from the Periscope contact form</p>
|
||||
</div>"""
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
@@ -0,0 +1,977 @@
|
||||
"""Pipeline start, SSE events, and status endpoints.
|
||||
|
||||
Pipelines run in a Cloud Run Job worker (or, in local dev, a child
|
||||
subprocess). The API only enqueues, transitions status with
|
||||
``if-generation-match`` for idempotency, and tails the GCS-backed event
|
||||
log for SSE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from backend.routers.deps import get_storage, resolve_or_404
|
||||
from backend.services import event_bridge, job_runner
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_REGEN_STAGES = {"derating"}
|
||||
_REPROCESS_OK_FROM = frozenset({
|
||||
proj_svc.STATUS_COMPLETE,
|
||||
proj_svc.STATUS_ERROR,
|
||||
proj_svc.STATUS_CANCELLED,
|
||||
})
|
||||
|
||||
|
||||
class RegenRequest(BaseModel):
|
||||
stages: list[str]
|
||||
|
||||
|
||||
class ReprocessRequest(BaseModel):
|
||||
"""``failed`` retries skipped/errored reviews and ICs whose circuit
|
||||
neighborhood changed; ``all`` re-reviews every IC."""
|
||||
mode: Literal["failed", "all"] = "failed"
|
||||
|
||||
|
||||
router = APIRouter(tags=["pipeline"])
|
||||
|
||||
|
||||
# Statuses from which a fresh ``/start`` is allowed to transition into queued.
|
||||
_START_OK_FROM = frozenset({
|
||||
proj_svc.STATUS_DRAFT,
|
||||
proj_svc.STATUS_COMPLETE,
|
||||
proj_svc.STATUS_ERROR,
|
||||
proj_svc.STATUS_CANCELLED,
|
||||
})
|
||||
|
||||
|
||||
def _project_active(meta: proj_svc.ProjectMeta) -> bool:
|
||||
"""A project is "active" if a worker is or could be running for it.
|
||||
|
||||
Used as the running-guard. We trust the meta status as the primary
|
||||
signal, and only fall back to the Cloud Run execution state when the
|
||||
status is one we expect a worker to be touching. This deliberately
|
||||
does NOT call get_execution_state on every request — it's an admin
|
||||
API call. The stale-running sweeper is responsible for clearing
|
||||
zombie ``running`` projects.
|
||||
"""
|
||||
return meta.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING)
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/start", status_code=202)
|
||||
async def start(project_id: str, request: Request):
|
||||
from backend.routers.deps import get_user_id
|
||||
from backend.services.billing_hook import get_billing
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting pipeline")
|
||||
|
||||
# Ensure the caller has at least their trial credits allocated. The
|
||||
# pipeline itself enforces pause-on-empty — this just makes sure a
|
||||
# brand-new user isn't blocked before their grant is issued.
|
||||
get_billing().ensure_trial_grant(storage, get_user_id(request))
|
||||
|
||||
# Idempotent enqueue: only one ``draft|complete|error|cancelled`` ->
|
||||
# ``queued`` transition can win. Concurrent /start clicks => 409.
|
||||
from backend._version import PERISCOPE_VERSION
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=_START_OK_FROM,
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
periscope_version=PERISCOPE_VERSION,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline already running or queued")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=False, free=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline failed for %s", project_id)
|
||||
# Roll the meta back so the user can retry.
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/cancel")
|
||||
async def cancel(project_id: str, request: Request):
|
||||
"""Soft-cancel: set ``cancel_requested`` so the worker exits cleanly.
|
||||
|
||||
The worker re-reads this flag inside ``_charge_for_logs`` after every
|
||||
Claude API call (throttled). Cancellation latency is bounded by the
|
||||
in-flight call's duration, typ 1–60s.
|
||||
"""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not _project_active(meta):
|
||||
raise HTTPException(409, f"Pipeline is not running (status={meta.status})")
|
||||
proj_svc.request_cancel(storage, owner_user_id, project_id)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/estimate")
|
||||
async def estimate(project_id: str, request: Request):
|
||||
"""Pre-flight cost estimate — read-only, no side effects."""
|
||||
from backend.services.cost_estimator import estimate_pipeline_cost
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom:
|
||||
raise HTTPException(400, "Upload a BOM before requesting an estimate")
|
||||
try:
|
||||
est = estimate_pipeline_cost(storage, owner_user_id, project_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return est.model_dump()
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/resume", status_code=202)
|
||||
async def resume(project_id: str, request: Request):
|
||||
"""Resume a pipeline that was paused for insufficient credits."""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if meta.status != proj_svc.STATUS_PAUSED:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Project is not paused (status={meta.status}); nothing to resume.",
|
||||
)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Project is missing BOM or netlist")
|
||||
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=proj_svc.STATUS_PAUSED,
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Project state changed; refresh and retry")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=True, free=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline (resume) failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "resumed", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/reprocess", status_code=202)
|
||||
async def reprocess(project_id: str, request: Request, req: ReprocessRequest | None = None):
|
||||
"""Re-run a finished project without the create wizard.
|
||||
|
||||
Keeps BOM, netlist, datasheets, and library extractions. ``failed``
|
||||
(default) skips ICs that already produced a review; ``all`` re-reviews
|
||||
every IC.
|
||||
"""
|
||||
from backend._version import PERISCOPE_VERSION
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before reprocessing")
|
||||
|
||||
if _project_active(meta):
|
||||
await _interrupt_active_pipeline(storage, owner_user_id, project_id)
|
||||
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
|
||||
|
||||
if meta.status == proj_svc.STATUS_PAUSED:
|
||||
allowed = _REPROCESS_OK_FROM | {proj_svc.STATUS_PAUSED}
|
||||
else:
|
||||
allowed = _REPROCESS_OK_FROM
|
||||
|
||||
if meta.status not in allowed and not _project_active(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot reprocess from status={meta.status}.",
|
||||
)
|
||||
|
||||
body = req or ReprocessRequest()
|
||||
retry_failed = body.mode == "failed"
|
||||
keep_refs = (
|
||||
proj_svc.completed_review_refs_for_retry(storage, owner_user_id, project_id)
|
||||
if retry_failed else []
|
||||
)
|
||||
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=allowed | {
|
||||
proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING,
|
||||
},
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
pipeline_state=None,
|
||||
pause_checkpoint=None,
|
||||
pause_reason=None,
|
||||
completed_review_refs=keep_refs,
|
||||
periscope_version=PERISCOPE_VERSION,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline already running or queued")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=retry_failed, free=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline (reprocess) failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {
|
||||
"status": "reprocess_started",
|
||||
"project_id": project_id,
|
||||
"mode": body.mode,
|
||||
"resume": retry_failed,
|
||||
"kept_review_refs": keep_refs,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/restart", status_code=202)
|
||||
async def restart(project_id: str, request: Request):
|
||||
"""Admin-only: wipe per-project extractions and run the pipeline free."""
|
||||
from backend.routers.admin import _require_admin
|
||||
|
||||
await _require_admin(request)
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting pipeline")
|
||||
|
||||
# If something is currently running/queued, request cancel and wait
|
||||
# briefly for the worker to honour it (or exit on its own). Hard-kill
|
||||
# the execution as a last resort.
|
||||
if _project_active(meta):
|
||||
proj_svc.request_cancel(storage, owner_user_id, project_id)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0)
|
||||
# If still active, hard-kill via Cloud Run cancel.
|
||||
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
|
||||
if _project_active(meta) and meta.execution_name:
|
||||
job_runner.cancel_execution(meta.execution_name)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0)
|
||||
|
||||
proj_svc.clear_project_extractions(storage, owner_user_id, project_id)
|
||||
|
||||
# After clear_project_extractions the project is left in whatever
|
||||
# status it was; the transition below enforces queued.
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED},
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline is busy; cancel first then retry")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=False, free=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline (restart) failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "restarted", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/regen", status_code=202)
|
||||
async def regen(project_id: str, req: RegenRequest, request: Request):
|
||||
"""Rebuild graph and regenerate only the requested stages."""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before running regen")
|
||||
invalid = set(req.stages) - VALID_REGEN_STAGES
|
||||
if invalid:
|
||||
raise HTTPException(400, f"Invalid regen stages: {sorted(invalid)}. Valid: {sorted(VALID_REGEN_STAGES)}")
|
||||
if not req.stages:
|
||||
raise HTTPException(400, "At least one stage is required")
|
||||
|
||||
if _project_active(meta):
|
||||
proj_svc.request_cancel(storage, owner_user_id, project_id)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=10.0)
|
||||
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
|
||||
if _project_active(meta) and meta.execution_name:
|
||||
job_runner.cancel_execution(meta.execution_name)
|
||||
await _await_terminal(storage, owner_user_id, project_id, timeout_s=5.0)
|
||||
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=_START_OK_FROM | {proj_svc.STATUS_PAUSED},
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline is busy; cancel first then retry")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline_regen(
|
||||
project_id, owner_user_id, stages=req.stages,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline_regen failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {"status": "regen_started", "project_id": project_id, "stages": req.stages}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
||||
_ANALYSIS_SSE_TERMINAL = frozenset({
|
||||
"pipeline_complete",
|
||||
"pipeline_error",
|
||||
"pipeline_cancelled",
|
||||
"pipeline_paused",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/events")
|
||||
async def events(project_id: str, request: Request):
|
||||
"""SSE stream of pipeline progress events.
|
||||
|
||||
Tails the GCS-backed event log written by the worker. Stops on
|
||||
analysis terminal events, but also has two hard-crash escape
|
||||
hatches: the project's status reaching a terminal value *after*
|
||||
having been active, and the Cloud Run execution reaching a
|
||||
terminal state. Either of those triggers a synthetic
|
||||
``pipeline_error`` so the SSE doesn't hang forever when the worker
|
||||
dies without writing its terminal event.
|
||||
"""
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.execution_name
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
# Only treat a terminal status as a crash if we observed the
|
||||
# project as queued/running first — otherwise a finished project
|
||||
# reconnecting to /events would immediately synthesize an error
|
||||
# (or race with a historical pipeline_complete replay).
|
||||
active_state = {
|
||||
"saw": meta.status in (
|
||||
proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING,
|
||||
),
|
||||
}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
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
|
||||
if cur.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING):
|
||||
active_state["saw"] = True
|
||||
elif active_state["saw"] and cur.status in proj_svc.TERMINAL_STATUSES:
|
||||
crash_detected["reason"] = (
|
||||
f"project status={cur.status} (terminal)"
|
||||
)
|
||||
return
|
||||
# Cloud Run hard-crash detection
|
||||
if execution_name and (
|
||||
active_state["saw"]
|
||||
or cur.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING)
|
||||
):
|
||||
try:
|
||||
state = job_runner.get_execution_state(execution_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
if state in _EXEC_TERMINAL:
|
||||
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=_ANALYSIS_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip placement events in the shared log.
|
||||
if ev.startswith("placement_") or ev.startswith("pcb_"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if ev in _ANALYSIS_SSE_TERMINAL:
|
||||
return
|
||||
|
||||
# tail_events exited without a terminal event — escape hatch
|
||||
if crash_detected["reason"] is not None:
|
||||
# Re-read the current meta so the synthetic event has
|
||||
# the most up-to-date error information.
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
err = (
|
||||
(cur.pipeline_state or {}).get("error")
|
||||
if cur and cur.pipeline_state
|
||||
else crash_detected["reason"]
|
||||
)
|
||||
yield {
|
||||
"event": "pipeline_error",
|
||||
"data": json.dumps({
|
||||
"error": err or "worker terminated without writing a terminal event",
|
||||
"synthetic": True,
|
||||
}),
|
||||
}
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/status")
|
||||
async def status(project_id: str, request: Request):
|
||||
"""Polling fallback — returns current project state.
|
||||
|
||||
Also heals zombie ``running``/``queued`` projects whose event log
|
||||
already ends with ``pipeline_complete`` (worker died after finishing).
|
||||
"""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id)
|
||||
if healed is not None:
|
||||
meta = healed
|
||||
return {
|
||||
"status": meta.status,
|
||||
"summary": meta.summary,
|
||||
"pipeline_state": meta.pipeline_state,
|
||||
"running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placement pipeline (parallel — topology only, no LLM / no credits)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PLACEMENT_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
|
||||
_PLACEMENT_SSE_TERMINAL = frozenset({
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/placement/start", status_code=202)
|
||||
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)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting placement")
|
||||
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 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,
|
||||
f"Cannot start placement from placement_status={meta.placement_status}",
|
||||
)
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_status="queued",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=None,
|
||||
placement_execution_name=None,
|
||||
)
|
||||
# Clear before enqueue so the placement SSE client never stops on a
|
||||
# leftover analysis ``pipeline_complete`` in the shared event log.
|
||||
try:
|
||||
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
|
||||
except Exception:
|
||||
logger.exception("failed to clear events before placement start for %s", project_id)
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_placement_pipeline(
|
||||
project_id, owner_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_placement_pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_state={"error": "Failed to enqueue placement worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue placement worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/placement/cancel")
|
||||
async def cancel_placement(project_id: str, request: Request):
|
||||
"""Soft-cancel the Placement pipeline via ``placement_cancel_requested``."""
|
||||
from backend.services.placement_pipeline import placement_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not placement_busy(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Placement is not running (placement_status={meta.placement_status})",
|
||||
)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_cancel_requested=True,
|
||||
)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/plan")
|
||||
async def get_placement_plan(project_id: str, request: Request):
|
||||
"""Return ``placement_plan.json`` (F1 topology — no coordinates)."""
|
||||
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)}/placement_plan.json"
|
||||
if not storage.exists(key):
|
||||
# Fallback for plans written only as functional_groups during analysis.
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/functional_groups.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Placement plan not found — run placement first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/pack")
|
||||
async def get_placement_pack(project_id: str, request: Request):
|
||||
"""Return ``placement_pack.json`` (F2 — skipped without PCB + numeric rules)."""
|
||||
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)}/placement_pack.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Placement pack not found — run placement first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/events")
|
||||
async def placement_events(project_id: str, request: Request):
|
||||
"""SSE stream for Placement pipeline progress (watches placement_* only)."""
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.placement_execution_name
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
saw_active = (meta.placement_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.placement_status or "draft"
|
||||
if pst in ("queued", "running"):
|
||||
saw_active = True
|
||||
elif saw_active and pst in ("complete", "error", "cancelled"):
|
||||
# Worker wrote terminal status; if SSE missed the event,
|
||||
# surface a synthetic terminal after a short grace.
|
||||
crash_detected["reason"] = f"placement_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=_PLACEMENT_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip leftover analysis events if the log was not cleared yet.
|
||||
if not (
|
||||
ev.startswith("placement_")
|
||||
or ev == "heartbeat"
|
||||
) or ev.startswith("pcb_"):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if ev in _PLACEMENT_SSE_TERMINAL:
|
||||
return
|
||||
|
||||
if crash_detected["reason"] is not None:
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
err = None
|
||||
if cur and cur.placement_state:
|
||||
err = cur.placement_state.get("error")
|
||||
yield {
|
||||
"event": "placement_error",
|
||||
"data": json.dumps({
|
||||
"error": err or crash_detected["reason"]
|
||||
or "placement worker terminated without a terminal event",
|
||||
"synthetic": True,
|
||||
}),
|
||||
}
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _interrupt_active_pipeline(
|
||||
storage, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
"""Cancel a queued/running worker so a new run can be enqueued.
|
||||
|
||||
If the worker is already dead (docker rebuild, OOM) the status can
|
||||
stay ``running``; force it to cancelled after a short wait.
|
||||
"""
|
||||
proj_svc.request_cancel(storage, user_id, project_id)
|
||||
await _await_terminal(storage, user_id, project_id, timeout_s=10.0)
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
if meta is None:
|
||||
return
|
||||
if _project_active(meta) and meta.execution_name:
|
||||
job_runner.cancel_execution(meta.execution_name)
|
||||
await _await_terminal(storage, user_id, project_id, timeout_s=5.0)
|
||||
meta = proj_svc.get_project(storage, user_id, project_id) or meta
|
||||
if _project_active(meta):
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, user_id, project_id,
|
||||
from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED},
|
||||
to_status=proj_svc.STATUS_CANCELLED,
|
||||
pipeline_state={"error": "Superseded by reprocess"},
|
||||
cancel_requested=False,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
pass
|
||||
|
||||
|
||||
async def _await_terminal(
|
||||
storage, user_id: str, project_id: str, *, timeout_s: float,
|
||||
) -> None:
|
||||
"""Poll project status until it reaches a terminal state or the timeout
|
||||
elapses. Used by /restart and /regen between cancel and re-enqueue.
|
||||
"""
|
||||
poll = 0.5
|
||||
elapsed = 0.0
|
||||
while elapsed < timeout_s:
|
||||
try:
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
except Exception:
|
||||
meta = None
|
||||
if meta is None:
|
||||
return
|
||||
if meta.status in proj_svc.TERMINAL_STATUSES:
|
||||
return
|
||||
await asyncio.sleep(poll)
|
||||
elapsed += poll
|
||||
@@ -1,20 +1,49 @@
|
||||
"""Project CRUD and file upload endpoints."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
router = APIRouter(tags=["projects"])
|
||||
|
||||
|
||||
def _bom_file_to_csv_bytes(path: Path) -> bytes | None:
|
||||
"""Normalize a BOM found inside a KiCad zip/folder to CSV bytes."""
|
||||
suffix = path.suffix.lower()
|
||||
raw = path.read_bytes()
|
||||
if suffix == ".csv":
|
||||
return raw
|
||||
if suffix == ".xlsx":
|
||||
try:
|
||||
import csv as csv_mod
|
||||
import io
|
||||
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
out = io.StringIO()
|
||||
writer = csv_mod.writer(out)
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
writer.writerow([("" if c is None else str(c)) for c in row])
|
||||
wb.close()
|
||||
return out.getvalue().encode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# --- Library check ---
|
||||
|
||||
|
||||
@@ -34,7 +63,7 @@ async def check_library(req: LibraryCheckRequest, request: Request):
|
||||
|
||||
passive_resolved: list[str] = []
|
||||
if req.passive_mpns:
|
||||
from backend.pinscopex.resolve_passives import resolve_mpn
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
|
||||
passive_resolved = [
|
||||
mpn for mpn in req.passive_mpns
|
||||
@@ -59,6 +88,31 @@ async def check_library(req: LibraryCheckRequest, request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/library")
|
||||
async def get_library(request: Request):
|
||||
"""List chips, passives, discrete specs, and datasheets in the shared library."""
|
||||
storage = get_storage(request)
|
||||
return JSONResponse(
|
||||
content=proj_svc.list_library_catalog(storage),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/datasheet/{mpn:path}")
|
||||
async def get_library_datasheet(mpn: str, request: Request):
|
||||
"""Stream a datasheet PDF from the shared library."""
|
||||
storage = get_storage(request)
|
||||
key = proj_svc.library_has_datasheet(storage, mpn)
|
||||
if not key:
|
||||
raise HTTPException(404, f"Datasheet not in library: {mpn}")
|
||||
data = storage.read_bytes(key)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
@@ -86,7 +140,17 @@ async def list_projects(request: Request):
|
||||
|
||||
@router.get("/projects/{project_id}")
|
||||
async def get_project(project_id: str, request: Request):
|
||||
_, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id)
|
||||
if healed is not None:
|
||||
meta = healed
|
||||
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()
|
||||
|
||||
|
||||
@@ -195,7 +259,7 @@ async def get_netlist_subdesigns(project_id: str, request: Request):
|
||||
``selected`` list (None = "include everything") so the wizard can render
|
||||
the picker pre-populated.
|
||||
"""
|
||||
from backend.pinscopex.parsers_edif import list_edif_subdesigns
|
||||
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
||||
import tempfile, os
|
||||
|
||||
storage = get_storage(request)
|
||||
@@ -299,7 +363,7 @@ async def upload_bom(
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
from backend.periscopex.parsers import parse_bom
|
||||
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
||||
@@ -359,7 +423,7 @@ async def upload_bom(
|
||||
# simple → datasheet upload). Mirrors the bucket logic in
|
||||
# services/pipeline.py:_stage_bom_parse so the field is correct after
|
||||
# either path runs.
|
||||
from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
from backend.periscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
|
||||
ic_mpns: list[str] = []
|
||||
passive_mpns: list[str] = []
|
||||
@@ -437,48 +501,99 @@ async def upload_bom(
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/upload/netlist")
|
||||
async def upload_netlist(project_id: str, file: UploadFile, request: Request):
|
||||
async def upload_netlist(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
file: UploadFile | None = File(default=None),
|
||||
files: list[UploadFile] | None = File(default=None),
|
||||
paths: str | None = Form(default=None),
|
||||
):
|
||||
storage = get_storage(request)
|
||||
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
||||
if not result:
|
||||
raise HTTPException(404, "Project not found")
|
||||
user_id = result[0] # owner_user_id for storage paths
|
||||
data = await file.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
|
||||
|
||||
# Auto-detect PADS vs EDIF from the file's first bytes — users don't pick
|
||||
# a format, the wizard accepts either.
|
||||
from backend.pinscopex.parsers import (
|
||||
detect_netlist_format, parse_netlist_any, validate_netlist,
|
||||
)
|
||||
from backend.pinscopex.parsers_edif import list_edif_subdesigns
|
||||
import tempfile, os
|
||||
from backend.periscopex.netlist_bundle import materialize_netlist_upload
|
||||
from backend.periscopex.parsers import parse_netlist_any, validate_netlist
|
||||
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
||||
import tempfile
|
||||
|
||||
blobs: list[tuple[str, bytes]] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
uploads = list(files or []) if files else ([file] if file is not None else [])
|
||||
rels: list[str] | None = None
|
||||
if paths:
|
||||
try:
|
||||
parsed_paths = json.loads(paths)
|
||||
except json.JSONDecodeError:
|
||||
parsed_paths = None
|
||||
if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths):
|
||||
rels = parsed_paths
|
||||
for i, uf in enumerate(uploads):
|
||||
data = await uf.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
413,
|
||||
f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)",
|
||||
)
|
||||
name = (
|
||||
rels[i]
|
||||
if rels is not None and i < len(rels)
|
||||
else (uf.filename or "netlist")
|
||||
)
|
||||
mark = (name, len(data))
|
||||
if mark in seen:
|
||||
continue
|
||||
seen.add(mark)
|
||||
blobs.append((name, data))
|
||||
if not blobs:
|
||||
raise HTTPException(400, "No netlist file uploaded")
|
||||
|
||||
fmt = detect_netlist_format(data)
|
||||
suffix = ".edn" if fmt == "edif" else ".asc"
|
||||
sub_designs: list[dict] = []
|
||||
bom_saved = False
|
||||
pcb_saved = False
|
||||
sheets = 1
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
parts, nets, _ = parse_netlist_any(tmp.name)
|
||||
# For EDIF, also surface the sub-design layout so the wizard can
|
||||
# decide whether to prompt the user. Cheap second parse — same file.
|
||||
if fmt == "edif":
|
||||
sub_designs = list_edif_subdesigns(tmp.name)
|
||||
os.unlink(tmp.name)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
|
||||
parts, nets, fmt = parse_netlist_any(parsed.root)
|
||||
if fmt == "edif":
|
||||
sub_designs = list_edif_subdesigns(parsed.root)
|
||||
issues = validate_netlist(parts, nets)
|
||||
if issues:
|
||||
raise ValueError("; ".join(issues))
|
||||
root_bytes = parsed.root.read_bytes()
|
||||
key = proj_svc.save_netlist(storage, user_id, project_id, root_bytes, fmt=fmt)
|
||||
if fmt == "kicad_sch":
|
||||
proj_svc.save_companion_sheets(
|
||||
storage, user_id, project_id, parsed.root, parsed.extra_sch,
|
||||
)
|
||||
sheets = 1 + len(parsed.extra_sch)
|
||||
else:
|
||||
proj_svc.clear_companion_sheets(storage, user_id, project_id)
|
||||
if parsed.pcb is not None:
|
||||
proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes())
|
||||
pcb_saved = True
|
||||
if parsed.bom is not None:
|
||||
bom_bytes = _bom_file_to_csv_bytes(parsed.bom)
|
||||
if bom_bytes:
|
||||
proj_svc.save_bom(storage, user_id, project_id, bom_bytes)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
bom_columns={
|
||||
"reference": "Reference",
|
||||
"mpn": "Manufacturer Part Number",
|
||||
},
|
||||
)
|
||||
bom_saved = True
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid netlist: {e}")
|
||||
issues = validate_netlist(parts, nets)
|
||||
if issues:
|
||||
raise HTTPException(400, f"Netlist failed sanity check: {'; '.join(issues)}")
|
||||
key = proj_svc.save_netlist(storage, user_id, project_id, data, fmt=fmt)
|
||||
# EDIF: emit a designator→pins preview matching the PADS browser-side
|
||||
# shape, so the wizard's power-sources step can render its dropdowns
|
||||
# without re-parsing the (s-expression-heavy) file in the browser.
|
||||
raise HTTPException(400, f"Netlist failed sanity check: {e}") from e
|
||||
|
||||
designator_pins: list[dict] = []
|
||||
if fmt == "edif":
|
||||
if fmt != "pads":
|
||||
designator_pins = _build_designator_pins(parts, nets)
|
||||
return {
|
||||
"path": key,
|
||||
@@ -487,6 +602,40 @@ async def upload_netlist(project_id: str, file: UploadFile, request: Request):
|
||||
"format": fmt,
|
||||
"sub_designs": sub_designs,
|
||||
"designator_pins": designator_pins,
|
||||
"pcb_saved": pcb_saved,
|
||||
"bom_saved": bom_saved,
|
||||
"sheets": sheets,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/upload/pcb")
|
||||
async def upload_pcb(project_id: str, file: UploadFile, request: Request):
|
||||
storage = get_storage(request)
|
||||
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
||||
if not result:
|
||||
raise HTTPException(404, "Project not found")
|
||||
user_id = result[0]
|
||||
data = await file.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
|
||||
import tempfile, os
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
layout = parse_kicad_pcb(tmp.name)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid KiCad PCB: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
key = proj_svc.save_pcb(storage, user_id, project_id, data)
|
||||
return {
|
||||
"path": key,
|
||||
"footprints": len(layout.footprints),
|
||||
"nets": len(layout.nets),
|
||||
"segments": len(layout.segments),
|
||||
}
|
||||
|
||||
|
||||
@@ -501,7 +650,7 @@ def _build_designator_pins(
|
||||
(natural sort on refs and on pin numbers) so the wizard's dropdowns
|
||||
look identical regardless of netlist format.
|
||||
"""
|
||||
from backend.pinscopex.utils import natural_sort_key
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
|
||||
by_ref: dict[str, dict[str, str]] = {ref: {} for ref in parts}
|
||||
for net_name, pins in nets.items():
|
||||
@@ -577,27 +726,17 @@ async def list_collaborators(project_id: str, request: Request):
|
||||
all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id]
|
||||
collaborators = []
|
||||
if settings.use_auth:
|
||||
async with httpx.AsyncClient() as client:
|
||||
for uid in all_user_ids:
|
||||
entry: dict = {"user_id": uid, "name": None, "email": None, "image_url": None,
|
||||
"role": "owner" if uid == owner_user_id else "collaborator"}
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"https://api.clerk.com/v1/users/{uid}",
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
clerk = resp.json()
|
||||
first = clerk.get("first_name") or ""
|
||||
last = clerk.get("last_name") or ""
|
||||
entry["name"] = f"{first} {last}".strip() or None
|
||||
emails = clerk.get("email_addresses", [])
|
||||
if emails:
|
||||
entry["email"] = emails[0].get("email_address")
|
||||
entry["image_url"] = clerk.get("image_url")
|
||||
except Exception:
|
||||
pass
|
||||
collaborators.append(entry)
|
||||
from backend.services.user_directory import get_user_profile
|
||||
|
||||
for uid in all_user_ids:
|
||||
profile = await get_user_profile(uid)
|
||||
collaborators.append({
|
||||
"user_id": uid,
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"image_url": profile.get("image_url"),
|
||||
"role": "owner" if uid == owner_user_id else "collaborator",
|
||||
})
|
||||
else:
|
||||
# Local dev — just return user_ids without enrichment
|
||||
collaborators = [
|
||||
@@ -623,22 +762,9 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
|
||||
if not settings.use_auth:
|
||||
raise HTTPException(400, "Collaboration requires authentication to be enabled")
|
||||
|
||||
# Look up user by email via Clerk Backend API
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://api.clerk.com/v1/users",
|
||||
params={"email_address": [req.email]},
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, "Failed to look up user")
|
||||
from backend.services.user_directory import find_user_id_by_email, get_user_profile
|
||||
|
||||
users = resp.json()
|
||||
if not users:
|
||||
raise HTTPException(404, "No user found with that email")
|
||||
|
||||
clerk_user = users[0]
|
||||
collab_user_id = clerk_user.get("id")
|
||||
collab_user_id = await find_user_id_by_email(req.email)
|
||||
if not collab_user_id:
|
||||
raise HTTPException(404, "No user found with that email")
|
||||
|
||||
@@ -652,15 +778,12 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
|
||||
|
||||
proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id)
|
||||
|
||||
# Return the collaborator info
|
||||
first = clerk_user.get("first_name") or ""
|
||||
last = clerk_user.get("last_name") or ""
|
||||
emails = clerk_user.get("email_addresses", [])
|
||||
profile = await get_user_profile(collab_user_id)
|
||||
return {
|
||||
"user_id": collab_user_id,
|
||||
"name": f"{first} {last}".strip() or None,
|
||||
"email": emails[0].get("email_address") if emails else None,
|
||||
"image_url": clerk_user.get("image_url"),
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"image_url": profile.get("image_url"),
|
||||
}
|
||||
|
||||
|
||||
@@ -722,31 +845,41 @@ async def make_collaborator_owner(
|
||||
return {"ok": True, "owner_user_id": collaborator_user_id}
|
||||
|
||||
|
||||
# --- DigiKey auto-fetch ---
|
||||
# --- Datasheet auto-fetch ---
|
||||
|
||||
|
||||
@router.get("/digikey/datasheet")
|
||||
async def fetch_digikey_datasheet(mpn: str, request: Request):
|
||||
"""Fetch a datasheet PDF from DigiKey for the given MPN.
|
||||
@router.get("/datasheets/fetch")
|
||||
async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = None):
|
||||
"""Fetch a datasheet PDF for the given MPN.
|
||||
|
||||
Returns the PDF bytes on success, or a JSON error on failure.
|
||||
Tries LCSC (no API key), manufacturer PDF URLs, optional Mouser, then DigiKey
|
||||
if configured. ``/api/digikey/datasheet`` is kept as an alias.
|
||||
"""
|
||||
from backend.services.digikey import fetch_datasheet
|
||||
from backend.services.datasheet_finder import find_datasheet
|
||||
|
||||
result = await fetch_datasheet(mpn)
|
||||
result = await find_datasheet(mpn, lcsc_id=lcsc)
|
||||
if not result.ok:
|
||||
# 404, not 502: "DigiKey has no exact match" / "the manufacturer CDN
|
||||
# blocked the download" is an expected per-MPN miss the wizard handles
|
||||
# (it shows a "fetch failed — upload manually" row), not a broken
|
||||
# gateway. 502 made a board full of exotic parts read as a server
|
||||
# meltdown in the browser console.
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": result.error or "Failed to fetch datasheet", "url": result.url},
|
||||
content={
|
||||
"detail": result.error or "Failed to fetch datasheet",
|
||||
"url": result.url,
|
||||
"urls": result.suggested_urls or ([result.url] if result.url else []),
|
||||
"source": result.source,
|
||||
},
|
||||
)
|
||||
headers = {"Content-Disposition": f'attachment; filename="{mpn}.pdf"'}
|
||||
if result.url:
|
||||
headers["X-Datasheet-Url"] = result.url
|
||||
if result.source:
|
||||
headers["X-Datasheet-Source"] = result.source
|
||||
try:
|
||||
proj_svc.remember_datasheet(
|
||||
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
|
||||
|
||||
|
||||
@@ -773,12 +906,10 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
import asyncio
|
||||
|
||||
from backend.services.digikey import fetch_params
|
||||
from backend.services.extraction import 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")
|
||||
if not settings.anthropic_api_key:
|
||||
raise HTTPException(400, "Anthropic API key not configured")
|
||||
|
||||
storage = get_storage(request)
|
||||
sem = asyncio.Semaphore(10)
|
||||
@@ -805,14 +936,18 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
if not result.ok or not result.params:
|
||||
return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"}
|
||||
|
||||
# Map params to taxonomy via Haiku
|
||||
model = await auto_resolve_specs(
|
||||
mpn=item.mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
digikey_description=result.params.description,
|
||||
component_type=item.component_type,
|
||||
)
|
||||
# Map params: catalog parse first, LLM only if needed.
|
||||
try:
|
||||
model = await auto_resolve_specs(
|
||||
mpn=item.mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
digikey_description=result.params.description,
|
||||
component_type=item.component_type,
|
||||
use_llm=settings.has_llm_credentials(),
|
||||
)
|
||||
except CatalogResolveMiss as e:
|
||||
return {"mpn": item.mpn, "status": "failed", "error": str(e)}
|
||||
|
||||
# Save to library
|
||||
storage.write_json(lib_key, model.model_dump())
|
||||
@@ -861,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)
|
||||
@@ -928,8 +1063,26 @@ async def lcsc_resolve_passive(
|
||||
"cannot auto-resolve",
|
||||
)
|
||||
|
||||
from backend.services.passive_from_distributor import specs_from_lcsc_payload
|
||||
|
||||
catalog_model = specs_from_lcsc_payload(mpn, payload)
|
||||
if catalog_model is not None:
|
||||
storage.write_json(project_model_key, catalog_model.model_dump())
|
||||
proj_svc.save_to_library(
|
||||
storage, project_model_key, "passives", f"{safe}.json",
|
||||
)
|
||||
return {
|
||||
"mpn": mpn,
|
||||
"safe_mpn": safe,
|
||||
"model": catalog_model.model_dump(),
|
||||
"cached": False,
|
||||
"lcsc_id": req.lcsc_id,
|
||||
}
|
||||
|
||||
# Catalog miss (ferrite, odd text): LLM path, charged if the logger has tokens.
|
||||
|
||||
# Download taxonomy to a temp dir so auto_resolve_specs can read/write it.
|
||||
# Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths.
|
||||
# Mirrors the PipelineWorkspace pattern: periscopex operates on local paths.
|
||||
api_logger = ApiLogger()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tax_dir = Path(tmpdir) / "taxonomy"
|
||||
@@ -3,19 +3,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
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,
|
||||
apply_review_state,
|
||||
build_eco,
|
||||
eco_csv,
|
||||
sign_report,
|
||||
)
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
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_\-\.:/ +#,()]*$")
|
||||
@@ -32,9 +49,44 @@ 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")
|
||||
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")
|
||||
async def get_cad_bridge(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}/periscope-findings.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "CAD bridge not found — run the pipeline first")
|
||||
return JSONResponse(storage.read_json(key))
|
||||
|
||||
|
||||
@@ -89,8 +141,113 @@ async def delete_comment(project_id: str, comment_id: str, request: Request):
|
||||
comment_list.pop(i)
|
||||
if not comment_list:
|
||||
del comments[finding_id]
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse({"ok": True})
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
class ReviewBody(BaseModel):
|
||||
state: str
|
||||
reason: str = ""
|
||||
user_name: str = ""
|
||||
|
||||
|
||||
def _load_report(storage, owner_user_id: str, project_id: str) -> tuple[str, dict]:
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
key = f"{prefix}/report.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Report not found")
|
||||
return key, storage.read_json(key)
|
||||
|
||||
|
||||
def _findings_from_report(report_data: dict) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
for raw in report_data.get("findings") or []:
|
||||
try:
|
||||
out.append(Finding.model_validate(raw))
|
||||
except Exception:
|
||||
log.warning("Skipping malformed finding in report", exc_info=True)
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/report/{project_id}/findings/{finding_id}/review")
|
||||
async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody, request: Request):
|
||||
storage = get_storage(request)
|
||||
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)
|
||||
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:
|
||||
states = apply_review_state(
|
||||
report_data.get("review_states") or {},
|
||||
finding_id,
|
||||
state=body.state,
|
||||
reason=body.reason,
|
||||
user_id=user_id,
|
||||
user_name=body.user_name,
|
||||
)
|
||||
except ReviewError as exc:
|
||||
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": ""})
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/eco.json")
|
||||
async def get_eco_json(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
_, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
|
||||
return JSONResponse({"items": items})
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/eco.csv")
|
||||
async def get_eco_csv(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
_, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
|
||||
return Response(eco_csv(items), media_type="text/csv")
|
||||
|
||||
|
||||
@router.post("/report/{project_id}/sign")
|
||||
async def post_sign_report(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
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)
|
||||
release = sign_report(report_data, user_id=user_id)
|
||||
report_data["release"] = release
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse(release)
|
||||
raise HTTPException(404, "Comment not found")
|
||||
|
||||
|
||||
@@ -138,6 +295,19 @@ async def get_project_logs(project_id: str, request: Request):
|
||||
return JSONResponse([])
|
||||
text = storage.read_text(key)
|
||||
entries = [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
||||
from backend.services.llm.pricing import cost_for_entry
|
||||
|
||||
for entry in entries:
|
||||
if any(
|
||||
entry.get(k)
|
||||
for k in (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
)
|
||||
):
|
||||
entry["cost_usd"] = round(cost_for_entry(entry), 6)
|
||||
return JSONResponse(entries)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -22,7 +22,7 @@ class ApiLogEntry(BaseModel):
|
||||
stage: str # pintable | rules | pattern | validation | ...
|
||||
identifier: str # MPN or component designator
|
||||
model: str
|
||||
provider: str = "anthropic" # anthropic | gemini
|
||||
provider: str = "deepseek" # deepseek | anthropic | gemini
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int = 0
|
||||
@@ -42,7 +42,7 @@ class ApiLogEntry(BaseModel):
|
||||
|
||||
@dataclass
|
||||
class CallMeta:
|
||||
"""Metadata returned alongside every Claude API call result."""
|
||||
"""Metadata returned alongside every LLM API call result."""
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int
|
||||
@@ -104,3 +104,27 @@ class ApiLogger:
|
||||
|
||||
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
|
||||
storage.write_text(key, text)
|
||||
|
||||
|
||||
def cache_stats_by_stage(entries: list[dict]) -> dict[str, dict]:
|
||||
"""Roll up prompt-cache hit rate per pipeline stage.
|
||||
|
||||
Returns ``{stage: {calls, input_tokens, cache_read_tokens, hit_ratio}}``.
|
||||
``hit_ratio`` is cache_read / input when input > 0, else 0.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for e in entries:
|
||||
stage = str(e.get("stage") or "unknown")
|
||||
bucket = out.setdefault(
|
||||
stage,
|
||||
{"calls": 0, "input_tokens": 0, "cache_read_tokens": 0, "hit_ratio": 0.0},
|
||||
)
|
||||
bucket["calls"] += 1
|
||||
bucket["input_tokens"] += int(e.get("input_tokens") or 0)
|
||||
bucket["cache_read_tokens"] += int(e.get("cache_read_input_tokens") or 0)
|
||||
for bucket in out.values():
|
||||
inp = bucket["input_tokens"]
|
||||
bucket["hit_ratio"] = (
|
||||
round(bucket["cache_read_tokens"] / inp, 4) if inp else 0.0
|
||||
)
|
||||
return out
|
||||
+6
-6
@@ -27,10 +27,10 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
from backend.pinscopex.resolve_passives import resolve_mpn
|
||||
from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.parsers import parse_bom
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
from backend.periscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.billing_hook import get_billing
|
||||
from backend.services.llm.pricing import CACHE_RATES, PRICING
|
||||
@@ -125,9 +125,9 @@ def estimate_stage_cost_usd(stage: str) -> float:
|
||||
settings_stage = str(base["settings_stage"])
|
||||
provider = settings.provider_for_stage(settings_stage)
|
||||
model = settings.model_for_stage(settings_stage)
|
||||
table = PRICING.get(provider) or PRICING["anthropic"]
|
||||
table = PRICING.get(provider) or PRICING["deepseek"]
|
||||
rates = table.get(model, table["default"])
|
||||
cache = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
|
||||
cache = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
|
||||
return (
|
||||
int(base["input"]) * rates["input"]
|
||||
+ int(base["output"]) * rates["output"]
|
||||
+80
-11
@@ -16,11 +16,12 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.services.storage import StorageBackend
|
||||
|
||||
BLOB_PREFIX = "library/datasheets/blobs/"
|
||||
REF_PREFIX = "library/datasheets/refs/"
|
||||
ALIAS_KEY = "library/datasheets/aliases.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -73,7 +74,7 @@ def store_datasheet(
|
||||
bk = blob_key(md5)
|
||||
if not storage.exists(bk):
|
||||
storage.upload_from_local(local_path, bk)
|
||||
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk})
|
||||
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk, "mpn": mpn})
|
||||
return bk
|
||||
|
||||
|
||||
@@ -81,28 +82,96 @@ def store_datasheet_bytes(
|
||||
storage: StorageBackend,
|
||||
data: bytes,
|
||||
mpn: str,
|
||||
extra_mpns: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Same as :func:`store_datasheet` but from in-memory bytes."""
|
||||
"""Same as :func:`store_datasheet` but from in-memory bytes.
|
||||
|
||||
``extra_mpns`` are additional catalog/orderable codes that should point
|
||||
at the same blob (family MPN vs ``…-N16R16V``).
|
||||
"""
|
||||
md5 = compute_md5_from_bytes(data)
|
||||
bk = blob_key(md5)
|
||||
if not storage.exists(bk):
|
||||
storage.write_bytes(bk, data)
|
||||
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk})
|
||||
names = [mpn, *(extra_mpns or [])]
|
||||
seen: set[str] = set()
|
||||
for name in names:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.upper()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
storage.write_json(ref_key(name), {"hash": md5, "blob_key": bk, "mpn": name})
|
||||
_record_aliases(storage, mpn, extra_mpns or [])
|
||||
return bk
|
||||
|
||||
|
||||
def _record_aliases(storage: StorageBackend, mpn: str, extra_mpns: list[str]) -> None:
|
||||
from backend.services.datasheet_finder import _alnum, _MIN_FAMILY_LEN
|
||||
|
||||
names = [mpn, *extra_mpns]
|
||||
compact = {n: _alnum(n) for n in names if n and n.strip()}
|
||||
if len(set(compact.values())) < 2 and not extra_mpns:
|
||||
return
|
||||
table: dict[str, str] = {}
|
||||
if storage.exists(ALIAS_KEY):
|
||||
raw = storage.read_json(ALIAS_KEY)
|
||||
table = dict(raw.get("aliases") or {})
|
||||
canonical = extra_mpns[0].strip() if extra_mpns else mpn
|
||||
for name, key in compact.items():
|
||||
if len(key) >= _MIN_FAMILY_LEN:
|
||||
table[key] = canonical
|
||||
storage.write_json(ALIAS_KEY, {"aliases": table})
|
||||
|
||||
|
||||
def resolve_datasheet(storage: StorageBackend, mpn: str) -> str | None:
|
||||
"""Look up the blob key for an MPN via its ref file.
|
||||
|
||||
Returns the blob key if the ref exists *and* the blob exists, else None.
|
||||
Tries spelling variants, then the shared alias table (family MPN →
|
||||
orderable code stored in the library).
|
||||
"""
|
||||
rk = ref_key(mpn)
|
||||
if not storage.exists(rk):
|
||||
from backend.services.datasheet_finder import (
|
||||
_MIN_FAMILY_LEN,
|
||||
_alnum,
|
||||
mpn_query_variants,
|
||||
)
|
||||
|
||||
def _from_ref(name: str) -> str | None:
|
||||
rk = ref_key(name)
|
||||
if not storage.exists(rk):
|
||||
return None
|
||||
ref = storage.read_json(rk)
|
||||
bk = ref.get("blob_key")
|
||||
if bk and storage.exists(bk):
|
||||
return bk
|
||||
return None
|
||||
ref = storage.read_json(rk)
|
||||
bk = ref.get("blob_key")
|
||||
if bk and storage.exists(bk):
|
||||
return bk
|
||||
|
||||
for name in mpn_query_variants(mpn) or [mpn]:
|
||||
hit = _from_ref(name)
|
||||
if hit:
|
||||
return hit
|
||||
|
||||
if not storage.exists(ALIAS_KEY):
|
||||
return None
|
||||
table = (storage.read_json(ALIAS_KEY) or {}).get("aliases") or {}
|
||||
want = _alnum(mpn)
|
||||
if not want:
|
||||
return None
|
||||
target = table.get(want)
|
||||
if target:
|
||||
hit = _from_ref(target)
|
||||
if hit:
|
||||
return hit
|
||||
if len(want) >= _MIN_FAMILY_LEN:
|
||||
for key, target in table.items():
|
||||
if key.startswith(want) or (
|
||||
want.startswith(key) and len(key) >= _MIN_FAMILY_LEN
|
||||
):
|
||||
hit = _from_ref(target)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
+9
-16
@@ -25,7 +25,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.llm import Message, TextBlock
|
||||
from backend.services.llm.factory import call_with_fallback
|
||||
@@ -247,23 +247,16 @@ def _build_deduped(
|
||||
new_why = "Unverified: " + new_why
|
||||
|
||||
try:
|
||||
result.append(Finding(
|
||||
finding_id=canon.finding_id,
|
||||
designator=canon.designator,
|
||||
mpn=canon.mpn,
|
||||
aspect=canon.aspect,
|
||||
finding=str(group.get("finding") or canon.finding),
|
||||
why=new_why,
|
||||
source_page=group.get("source_page", canon.source_page),
|
||||
source_quote=canon.source_quote,
|
||||
source_designator=canon.source_designator,
|
||||
status=final_status,
|
||||
recommendation=str(
|
||||
result.append(canon.model_copy(update={
|
||||
"finding": str(group.get("finding") or canon.finding),
|
||||
"why": new_why,
|
||||
"source_page": group.get("source_page", canon.source_page),
|
||||
"status": final_status,
|
||||
"recommendation": str(
|
||||
group.get("recommendation") or canon.recommendation
|
||||
),
|
||||
reference=str(group.get("reference") or canon.reference),
|
||||
source=canon.source,
|
||||
))
|
||||
"reference": str(group.get("reference") or canon.reference),
|
||||
}))
|
||||
except Exception:
|
||||
log.exception("dedupe: failed to build merged Finding")
|
||||
return None
|
||||
@@ -12,6 +12,12 @@ from dataclasses import dataclass, field
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.datasheet_finder import (
|
||||
_alnum,
|
||||
mpn_catalog_match,
|
||||
mpn_matches,
|
||||
mpn_query_variants,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,31 +109,58 @@ async def _keyword_search(mpn: str) -> list[dict]:
|
||||
|
||||
|
||||
def _find_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
"""Find the product whose MPN exactly matches ``mpn`` (case/space-insensitive).
|
||||
"""Pick a DigiKey product for ``mpn``.
|
||||
|
||||
Returns None when no result has a matching MPN. We intentionally do NOT
|
||||
fall back to ``products[0]`` — keyword-search hits without an MPN match
|
||||
are usually for a different part, and silently returning them has
|
||||
polluted the library with wrong specs for non-MPN tokens like ``10uF``.
|
||||
Prefers punctuation-insensitive equality, then packing suffixes, then a
|
||||
longer orderable code that starts with the BOM MPN. Does not fall back
|
||||
to ``products[0]``. Tries BOM spelling variants (underscore, reel, extra
|
||||
description after an em dash).
|
||||
"""
|
||||
if not products:
|
||||
return None
|
||||
|
||||
mpn_upper = mpn.upper().replace(" ", "")
|
||||
for product in products:
|
||||
if _get_mpn(product).upper().replace(" ", "") == mpn_upper:
|
||||
return product
|
||||
for query in mpn_query_variants(mpn):
|
||||
hit = _find_product_one(query, products)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
async def _search_mpn(mpn: str) -> str | None:
|
||||
"""Search DigiKey for an MPN and return the primary datasheet URL, or None."""
|
||||
products = await _keyword_search(mpn)
|
||||
product = _find_product(mpn, products)
|
||||
if not product:
|
||||
return None
|
||||
url = _get_ds_url(product)
|
||||
return url or None
|
||||
def _find_product_one(mpn: str, products: list[dict]) -> dict | None:
|
||||
exact = None
|
||||
loose = None
|
||||
family = None
|
||||
want = _alnum(mpn)
|
||||
for product in products:
|
||||
cand = _get_mpn(product)
|
||||
if not cand:
|
||||
continue
|
||||
got = _alnum(cand)
|
||||
if got == want:
|
||||
exact = product
|
||||
break
|
||||
if loose is None and mpn_matches(mpn, cand):
|
||||
loose = product
|
||||
elif family is None and mpn_catalog_match(mpn, cand):
|
||||
family = product
|
||||
return exact or loose or family
|
||||
|
||||
|
||||
async def _search_mpn(mpn: str) -> tuple[str | None, str | None]:
|
||||
"""Search DigiKey; return (datasheet_url, catalog_mpn)."""
|
||||
tried: set[str] = set()
|
||||
for keyword in mpn_query_variants(mpn):
|
||||
key = keyword.upper()
|
||||
if key in tried:
|
||||
continue
|
||||
tried.add(key)
|
||||
products = await _keyword_search(keyword)
|
||||
product = _find_product(mpn, products)
|
||||
if not product:
|
||||
continue
|
||||
url = _get_ds_url(product)
|
||||
if url:
|
||||
return url, _get_mpn(product) or None
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,11 +205,13 @@ class DatasheetFetchResult:
|
||||
pdf_bytes: bytes | None = None,
|
||||
error: str | None = None,
|
||||
url: str | None = None,
|
||||
catalog_mpn: str | None = None,
|
||||
):
|
||||
self.mpn = mpn
|
||||
self.pdf_bytes = pdf_bytes
|
||||
self.error = error
|
||||
self.url = url # DigiKey datasheet URL (present even when PDF download fails)
|
||||
self.url = url
|
||||
self.catalog_mpn = catalog_mpn
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -195,7 +230,7 @@ async def fetch_datasheet(mpn: str) -> DatasheetFetchResult:
|
||||
return DatasheetFetchResult(mpn, error="DigiKey API not configured")
|
||||
|
||||
try:
|
||||
url = await _search_mpn(mpn)
|
||||
url, catalog_mpn = await _search_mpn(mpn)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("DigiKey search failed for %s: %s", mpn, e)
|
||||
return DatasheetFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
|
||||
@@ -211,20 +246,27 @@ async def fetch_datasheet(mpn: str) -> DatasheetFetchResult:
|
||||
pdf_bytes = await _download_pdf(url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Datasheet download blocked for %s (%s): %s", mpn, url, e)
|
||||
return DatasheetFetchResult(mpn, error=f"Download blocked ({e.response.status_code})", url=url)
|
||||
return DatasheetFetchResult(
|
||||
mpn, error=f"Download blocked ({e.response.status_code})", url=url,
|
||||
catalog_mpn=catalog_mpn,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid PDF for %s (%s): %s", mpn, url, e)
|
||||
return DatasheetFetchResult(mpn, error=str(e), url=url)
|
||||
return DatasheetFetchResult(mpn, error=str(e), url=url, catalog_mpn=catalog_mpn)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Datasheet download timed out for %s (%s)", mpn, url)
|
||||
return DatasheetFetchResult(mpn, error="Download timed out", url=url)
|
||||
return DatasheetFetchResult(mpn, error="Download timed out", url=url, catalog_mpn=catalog_mpn)
|
||||
except Exception as e:
|
||||
msg = str(e) or type(e).__name__
|
||||
logger.warning("Datasheet download failed for %s (%s): %s", mpn, url, msg)
|
||||
return DatasheetFetchResult(mpn, error=f"Download failed: {msg}", url=url)
|
||||
return DatasheetFetchResult(
|
||||
mpn, error=f"Download failed: {msg}", url=url, catalog_mpn=catalog_mpn,
|
||||
)
|
||||
|
||||
logger.info("Fetched datasheet for %s (%d KB)", mpn, len(pdf_bytes) // 1024)
|
||||
return DatasheetFetchResult(mpn, pdf_bytes=pdf_bytes, url=url)
|
||||
return DatasheetFetchResult(
|
||||
mpn, pdf_bytes=pdf_bytes, url=url, catalog_mpn=catalog_mpn,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -300,7 +342,16 @@ async def fetch_params(mpn: str) -> ParamsFetchResult:
|
||||
return ParamsFetchResult(mpn, error="DigiKey API not configured")
|
||||
|
||||
try:
|
||||
products = await _keyword_search(mpn)
|
||||
products: list[dict] = []
|
||||
tried: set[str] = set()
|
||||
for keyword in mpn_query_variants(mpn):
|
||||
key = keyword.upper()
|
||||
if key in tried:
|
||||
continue
|
||||
tried.add(key)
|
||||
products = await _keyword_search(keyword)
|
||||
if _find_product(mpn, products):
|
||||
break
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("DigiKey search failed for %s: %s", mpn, e)
|
||||
return ParamsFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
|
||||
@@ -174,7 +174,7 @@ def _render_report_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Report Ready
|
||||
@@ -247,7 +247,7 @@ def _render_report_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -292,7 +292,7 @@ def _render_pipeline_started_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Pipeline Started
|
||||
@@ -398,7 +398,7 @@ def _render_pipeline_started_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -448,7 +448,7 @@ def _build_report_message(
|
||||
) -> MIMEMultipart:
|
||||
"""Build the report-ready email message."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Report ready: {project_name}"
|
||||
|
||||
@@ -460,7 +460,7 @@ def _build_report_message(
|
||||
infos = summary.get("INFO", 0)
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"Your Pinscope validation report for \"{project_name}\" is ready.\n\n"
|
||||
f"Your Periscope validation report for \"{project_name}\" is ready.\n\n"
|
||||
f"Summary: {total} findings — {errors} errors, {warnings} warnings, {infos} info\n\n"
|
||||
f"View the report: {report_url}\n"
|
||||
)
|
||||
@@ -485,7 +485,7 @@ def _build_paused_message(
|
||||
credits_needed_low: float,
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Paused: {project_name} is waiting for credits"
|
||||
|
||||
@@ -495,7 +495,7 @@ def _build_paused_message(
|
||||
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"Your Pinscope run for \"{project_name}\" paused because you're low on credits.\n\n"
|
||||
f"Your Periscope run for \"{project_name}\" paused because you're low on credits.\n\n"
|
||||
f"{last_line}\n{stage_line}\n\n"
|
||||
f"Current balance: {balance:.2f} credits\n"
|
||||
f"Credits needed to finish (est): {credits_needed_low:.2f}+\n\n"
|
||||
@@ -510,13 +510,13 @@ def _build_topup_failed_message(
|
||||
amount_usd: float, reason: str,
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Pinscope: auto top-up failed"
|
||||
msg["Subject"] = "Periscope: auto top-up failed"
|
||||
manage_url = f"{settings.email_frontend_url}/credits"
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"We tried to auto top-up your Pinscope balance with "
|
||||
f"We tried to auto top-up your Periscope balance with "
|
||||
f"${amount_usd:.2f} but the charge failed.\n\n"
|
||||
f"Reason: {reason}\n\n"
|
||||
f"Auto top-up has been disabled until you update your payment method. "
|
||||
@@ -549,13 +549,13 @@ def _build_low_balance_message(
|
||||
to_email: str, recipient_name: str, balance: float, threshold: float,
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Pinscope: low credit balance"
|
||||
msg["Subject"] = "Periscope: low credit balance"
|
||||
credits_url = f"{settings.email_frontend_url}/credits"
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"Your Pinscope credit balance has dropped to "
|
||||
f"Your Periscope credit balance has dropped to "
|
||||
f"{balance:.2f} credits (below your threshold of {threshold:.2f}).\n\n"
|
||||
f"Top up here so your pipelines don't pause mid-run: {credits_url}\n"
|
||||
)
|
||||
@@ -691,10 +691,10 @@ async def send_test_email(to_email: str) -> dict:
|
||||
|
||||
result["step"] = "send"
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Pinscope email test"
|
||||
msg.attach(MIMEText(f"Test email from Pinscope. Sender: {settings.email_sender}. Creds: {cred_type}", "plain"))
|
||||
msg["Subject"] = "Periscope email test"
|
||||
msg.attach(MIMEText(f"Test email from Periscope. Sender: {settings.email_sender}. Creds: {cred_type}", "plain"))
|
||||
|
||||
import asyncio as _asyncio
|
||||
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii")
|
||||
@@ -741,7 +741,7 @@ async def send_pipeline_started_email(
|
||||
|
||||
# Build message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Pipeline started: {project_name} ({num_components} components)"
|
||||
|
||||
@@ -866,7 +866,7 @@ def _render_feedback_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Feedback Received
|
||||
@@ -953,7 +953,7 @@ def _render_feedback_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -1006,7 +1006,7 @@ async def send_feedback_received_email(
|
||||
to_email = settings.email_admin_notify
|
||||
subject_ctx = project_name or "general"
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Feedback ({type_label}): {subject_ctx}"
|
||||
|
||||
@@ -1095,7 +1095,7 @@ def _render_feedback_reply_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
New Reply
|
||||
@@ -1113,7 +1113,7 @@ def _render_feedback_reply_email(
|
||||
Hi {_esc(recipient_first_name)},
|
||||
</td></tr>
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; color: #374151; padding-bottom: 18px;">
|
||||
The Pinscope team just replied to your feedback.
|
||||
The Periscope team just replied to your feedback.
|
||||
</td></tr>
|
||||
|
||||
{context_line}
|
||||
@@ -1124,7 +1124,7 @@ def _render_feedback_reply_email(
|
||||
<tr><td style="padding: 18px 22px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11px; font-weight: 600; color: #047857; text-transform: uppercase; letter-spacing: 0.05em; padding-bottom: 10px;">
|
||||
Pinscope team
|
||||
Periscope team
|
||||
</td></tr>
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; color: #064e3b; line-height: 1.55; white-space: pre-wrap;">
|
||||
{_esc(reply_text)}
|
||||
@@ -1155,12 +1155,12 @@ def _render_feedback_reply_email(
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="{feedback_url}" style="height:48px;v-text-anchor:middle;width:240px;" arcsize="14%" fillcolor="#3b82f6" stroke="f">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:sans-serif;font-size:15px;font-weight:bold;">View in Pinscope →</center>
|
||||
<center style="color:#ffffff;font-family:sans-serif;font-size:15px;font-weight:bold;">View in Periscope →</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="{feedback_url}" target="_blank" style="display: inline-block; background-color: #3b82f6; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 600; text-decoration: none; padding: 12px 32px; border-radius: 8px; letter-spacing: -0.01em;">
|
||||
View in Pinscope →
|
||||
View in Periscope →
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td></tr>
|
||||
@@ -1170,7 +1170,7 @@ def _render_feedback_reply_email(
|
||||
Thank you so much for taking the time to share your feedback — we truly value it.
|
||||
</td></tr>
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; color: #374151; padding-top: 6px;">
|
||||
— The Pinscope team
|
||||
— The Periscope team
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
@@ -1180,7 +1180,7 @@ def _render_feedback_reply_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -1203,7 +1203,7 @@ async def send_feedback_reply_email(
|
||||
finding_designator: str | None = None,
|
||||
finding_mpn: str | None = None,
|
||||
) -> None:
|
||||
"""Notify the original submitter that the Pinscope team replied. Fire-and-forget."""
|
||||
"""Notify the original submitter that the Periscope team replied. Fire-and-forget."""
|
||||
if not settings.use_email:
|
||||
return
|
||||
|
||||
@@ -1229,15 +1229,15 @@ async def send_feedback_reply_email(
|
||||
first_name = full_name.split()[0] if full_name else "there"
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "The Pinscope team replied to your feedback"
|
||||
msg["Subject"] = "The Periscope team replied to your feedback"
|
||||
|
||||
# Plain text fallback
|
||||
text_lines = [
|
||||
f"Hi {first_name},",
|
||||
"",
|
||||
"The Pinscope team just replied to your feedback.",
|
||||
"The Periscope team just replied to your feedback.",
|
||||
"",
|
||||
"— Reply —",
|
||||
reply_text,
|
||||
@@ -1245,10 +1245,10 @@ async def send_feedback_reply_email(
|
||||
"— Your original message —",
|
||||
original_message,
|
||||
"",
|
||||
f"View in Pinscope: {settings.email_frontend_url}/feedback",
|
||||
f"View in Periscope: {settings.email_frontend_url}/feedback",
|
||||
"",
|
||||
"Thank you so much for taking the time to share your feedback — we truly value it.",
|
||||
"— The Pinscope team",
|
||||
"— The Periscope team",
|
||||
]
|
||||
msg.attach(MIMEText("\n".join(text_lines), "plain"))
|
||||
|
||||
+12
-6
@@ -46,6 +46,12 @@ TERMINAL_EVENTS = frozenset({
|
||||
"pipeline_error",
|
||||
"pipeline_cancelled",
|
||||
"pipeline_paused",
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@@ -131,19 +137,19 @@ async def tail_events(
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
heartbeat_interval: float = 15.0,
|
||||
terminal_events: frozenset[str] | None = None,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Yield events from the GCS-backed event log in order.
|
||||
|
||||
Stops yielding after a terminal event (``pipeline_complete``,
|
||||
``pipeline_error``, ``pipeline_cancelled``). Emits a
|
||||
``{"event": "heartbeat", "data": {}}`` synthetic event roughly every
|
||||
``heartbeat_interval`` seconds when no real events arrive, matching
|
||||
the behaviour of the in-memory broker's SSE loop.
|
||||
Stops yielding after a terminal event (default ``TERMINAL_EVENTS``).
|
||||
Emits a ``{"event": "heartbeat", "data": {}}`` synthetic event roughly
|
||||
every ``heartbeat_interval`` seconds when no real events arrive.
|
||||
|
||||
The caller is expected to handle disconnects/cancellations and
|
||||
secondary terminal-detection (``meta.status``, Cloud Run execution
|
||||
state) on top of this iterator.
|
||||
"""
|
||||
stop_on = terminal_events if terminal_events is not None else TERMINAL_EVENTS
|
||||
prefix = _events_prefix(user_id, project_id)
|
||||
last_seen_key: str | None = None
|
||||
last_emit_ts = 0.0
|
||||
@@ -166,7 +172,7 @@ async def tail_events(
|
||||
emitted_any = True
|
||||
last_seen_key = key
|
||||
last_emit_ts = asyncio.get_event_loop().time()
|
||||
if msg.get("event") in TERMINAL_EVENTS:
|
||||
if msg.get("event") in stop_on:
|
||||
return
|
||||
|
||||
now = asyncio.get_event_loop().time()
|
||||
+236
-19
@@ -1,4 +1,7 @@
|
||||
"""Async datasheet extraction using Claude API.
|
||||
"""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
|
||||
@@ -15,8 +18,8 @@ import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.periscopex.models import (
|
||||
CapacitorSpecs,
|
||||
ComponentConstraints,
|
||||
ComponentModel,
|
||||
@@ -25,7 +28,7 @@ from backend.pinscopex.models import (
|
||||
NetType,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
from backend.pinscopex.taxonomy import (
|
||||
from backend.periscopex.taxonomy import (
|
||||
TAXONOMY_DIR,
|
||||
add_subtype,
|
||||
format_for_prompt,
|
||||
@@ -55,7 +58,7 @@ from backend.services.llm import (
|
||||
|
||||
PINTABLE_TOOL = {
|
||||
"name": "save_pintable",
|
||||
"description": "Save the extracted pin table, package info, and component subtype.",
|
||||
"description": "Save the extracted pin table, package info, absolute-maximum ratings, and component subtype.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -94,6 +97,105 @@ PINTABLE_TOOL = {
|
||||
"required": ["number", "name"],
|
||||
},
|
||||
},
|
||||
"absolute_maximum_ratings": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Rows from the Absolute Maximum Ratings table: supplies, "
|
||||
"pin voltages, current, temperature. For ESD/TVS ICs also "
|
||||
"include Electrical Characteristics Vrwm (signed min/max) "
|
||||
"and a polarity/topology row (bidirectional vs "
|
||||
"unidirectional / back-to-back). Skip IEC/HBM kV rows. "
|
||||
"Empty array if the table is unreadable."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "As printed, e.g. 'VCC', 'VIN', 'Storage temperature'",
|
||||
},
|
||||
"min": {"type": ["number", "null"]},
|
||||
"max": {"type": ["number", "null"]},
|
||||
"unit": {"type": "string", "description": "V, mA, °C, …"},
|
||||
"source_page": {
|
||||
"type": "integer",
|
||||
"description": "1-based datasheet page of this row",
|
||||
},
|
||||
},
|
||||
"required": ["parameter", "unit", "source_page"],
|
||||
},
|
||||
},
|
||||
"internal_features": {
|
||||
"type": "object",
|
||||
"description": "Optional block-diagram extras. Omit or empty if not shown.",
|
||||
"properties": {
|
||||
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
|
||||
"pullup_pins": {"type": "array", "items": {"type": "string"}},
|
||||
"analog_switch": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
"layout_rules": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"PCB layout constraints from typical-application / PCB layout pages. "
|
||||
"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, "
|
||||
"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",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"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",
|
||||
],
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
"cap_value_hint": {"type": ["string", "null"]},
|
||||
"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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable"],
|
||||
},
|
||||
@@ -208,14 +310,22 @@ SPECS_TOOL = {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_MAX_PDF_PAGES = 90
|
||||
_MAX_PDF_PAGES = 120
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Keywords used to find relevant pages for each extraction stage.
|
||||
# Include PCB / typical-application pages so layout_rules can be extracted
|
||||
# when large datasheets are trimmed to ≤_MAX_PDF_PAGES.
|
||||
_PINTABLE_KEYWORDS = re.compile(
|
||||
r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)"
|
||||
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description",
|
||||
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description"
|
||||
r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
|
||||
r"|ordering\s+information|device\s+information"
|
||||
r"|pcb\s+layout|layout\s+(guideline|recommendation|consideration|hint)"
|
||||
r"|typical\s+application|application\s+(circuit|schematic|information|note)"
|
||||
r"|reference\s+design|decoupling|bypass\s+capacitor|thermal\s+via"
|
||||
r"|land\s+pattern|keep[\s\-]?out|place\s+(close|near|within)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -280,6 +390,63 @@ def _to_tool(d: dict) -> ToolSchema:
|
||||
)
|
||||
|
||||
|
||||
def _coerce_abs_max(raw: object) -> list[dict]:
|
||||
"""Keep well-formed abs-max rows; drop garbage rather than failing extraction."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
parameter = str(row.get("parameter") or "").strip()
|
||||
unit = str(row.get("unit") or "").strip()
|
||||
page = row.get("source_page")
|
||||
if not parameter or not unit:
|
||||
continue
|
||||
try:
|
||||
source_page = int(page)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if source_page < 1:
|
||||
continue
|
||||
|
||||
def _num(v: object) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
out.append({
|
||||
"parameter": parameter,
|
||||
"min": _num(row.get("min")),
|
||||
"max": _num(row.get("max")),
|
||||
"unit": unit,
|
||||
"source_page": source_page,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_layout_rules(raw: object) -> list[dict]:
|
||||
from backend.periscopex.layout_rules import validate_layout_rules
|
||||
rows, _errors = validate_layout_rules(raw if isinstance(raw, list) else [])
|
||||
return rows
|
||||
|
||||
|
||||
def _coerce_internal_features(raw: object):
|
||||
from backend.periscopex.models import InternalFeatures
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
feat = InternalFeatures.model_validate(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if not feat.esd_clamp_pins and not feat.pullup_pins and not feat.analog_switch:
|
||||
return None
|
||||
return feat
|
||||
|
||||
|
||||
_GENERATE_SPECS_TOOL = {
|
||||
"name": "save_specs_schema",
|
||||
"description": "Save the standardized parameter schema for a component type.",
|
||||
@@ -479,7 +646,7 @@ async def extract_pintable(
|
||||
taxonomy = format_for_prompt("ic", tax_dir)
|
||||
|
||||
trimmed = _select_pages(pdf_path, _PINTABLE_KEYWORDS)
|
||||
skill_id, version = settings.get_skill("extract-pintable")
|
||||
skill_id, version = settings.get_skill_or_none("extract-pintable")
|
||||
system = (
|
||||
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n"
|
||||
f"MPN: {mpn}\n\n"
|
||||
@@ -493,7 +660,12 @@ async def extract_pintable(
|
||||
skill_name="extract-pintable",
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=f"Extract pin table and package info for MPN: {mpn}",
|
||||
user_text=(
|
||||
f"Extract pin table, package info, absolute maximum ratings, "
|
||||
f"and layout_rules (scan PCB layout / typical application / "
|
||||
f"thermal pages; max_distance_mm only if the PDF states mm) "
|
||||
f"for MPN: {mpn}"
|
||||
),
|
||||
pdf_path=trimmed,
|
||||
output_tool=_to_tool(PINTABLE_TOOL),
|
||||
)
|
||||
@@ -548,8 +720,12 @@ async def extract_pintable(
|
||||
component_subtype=subtype,
|
||||
package_info=result["package_info"],
|
||||
pintable=result["pintable"],
|
||||
absolute_maximum_ratings=[],
|
||||
absolute_maximum_ratings=_coerce_abs_max(
|
||||
result.get("absolute_maximum_ratings") or [],
|
||||
),
|
||||
rules=[],
|
||||
internal_features=_coerce_internal_features(result.get("internal_features")),
|
||||
layout_rules=_coerce_layout_rules(result.get("layout_rules")),
|
||||
)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -576,7 +752,7 @@ async def extract_pattern(
|
||||
tax_dir = taxonomy_dir or settings.taxonomy_dir
|
||||
taxonomy = format_for_prompt("passive", tax_dir)
|
||||
|
||||
skill_id, version = settings.get_skill("extract-pattern")
|
||||
skill_id, version = settings.get_skill_or_none("extract-pattern")
|
||||
system = (
|
||||
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n\n"
|
||||
f"EXISTING PASSIVE TAXONOMY SUBTYPES:\n{taxonomy}\n\n"
|
||||
@@ -665,7 +841,7 @@ async def extract_specs(
|
||||
subtypes_text = format_for_prompt(component_type, tax_dir)
|
||||
specs_text = format_specs_for_prompt(component_type, tax_dir)
|
||||
|
||||
skill_id, version = settings.get_skill("extract-specs")
|
||||
skill_id, version = settings.get_skill_or_none("extract-specs")
|
||||
system = (
|
||||
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n"
|
||||
f"MPN: {mpn}\n"
|
||||
@@ -798,6 +974,10 @@ Call save_resolved_specs with the mapped values.\
|
||||
"""
|
||||
|
||||
|
||||
class CatalogResolveMiss(RuntimeError):
|
||||
"""Distributor params did not parse and ``use_llm`` was false."""
|
||||
|
||||
|
||||
async def auto_resolve_specs(
|
||||
mpn: str,
|
||||
digikey_params: list[dict[str, str]],
|
||||
@@ -806,13 +986,35 @@ async def auto_resolve_specs(
|
||||
component_type: str,
|
||||
taxonomy_dir: Path | None = None,
|
||||
api_logger: ApiLogger | None = None,
|
||||
*,
|
||||
use_llm: bool = True,
|
||||
) -> ComponentModel:
|
||||
"""Map DigiKey product parameters to taxonomy specs using a lightweight model.
|
||||
"""Map DigiKey/LCSC product parameters to taxonomy specs.
|
||||
|
||||
Returns a ComponentModel ready to persist. Raises on failure.
|
||||
Passives with a parseable value skip the model. ``use_llm=False`` returns
|
||||
only that catalog parse or raises :class:`CatalogResolveMiss`.
|
||||
"""
|
||||
tax_dir = taxonomy_dir or settings.taxonomy_dir
|
||||
|
||||
from backend.services.passive_from_distributor import specs_from_distributor
|
||||
|
||||
if component_type == "passive":
|
||||
direct = specs_from_distributor(
|
||||
mpn=mpn,
|
||||
params=digikey_params,
|
||||
category=digikey_category,
|
||||
description=digikey_description,
|
||||
)
|
||||
if direct is not None:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).info(
|
||||
"Auto-resolved %s from distributor params (no LLM)", mpn,
|
||||
)
|
||||
return direct
|
||||
|
||||
if not use_llm:
|
||||
raise CatalogResolveMiss(f"No catalog specs for {mpn}")
|
||||
|
||||
# Auto-generate type-level specs if none exist
|
||||
if not has_specs(component_type, tax_dir):
|
||||
try:
|
||||
@@ -916,7 +1118,7 @@ async def auto_resolve_specs(
|
||||
|
||||
# Convert passive SimpleComponentSpecs to typed models
|
||||
if component_type == "passive":
|
||||
from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
@@ -931,7 +1133,7 @@ _PASSIVE_PREFIX_HINT: dict[str, str] = {
|
||||
"C": "capacitor — populate value_farads",
|
||||
"R": "resistor — populate value_ohms",
|
||||
"L": "inductor — populate value_henries",
|
||||
"FB": "ferrite bead — populate value_ohms (impedance)",
|
||||
"FB": "ferrite bead — populate impedance_ohm (Z at test frequency, not henries)",
|
||||
}
|
||||
|
||||
_VALUE_RESOLVE_SYSTEM = """\
|
||||
@@ -952,8 +1154,9 @@ CRITICAL RULES:
|
||||
dielectric, package, or power rating. Never invent these.
|
||||
- Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable
|
||||
string) and the matching primary numeric field
|
||||
(``value_farads`` / ``value_ohms`` / ``value_henries``). Leave every other
|
||||
parameter out (do not include a null entry — omit the key entirely).
|
||||
(``value_farads`` / ``value_ohms`` / ``value_henries`` / ``impedance_ohm``
|
||||
for ferrite beads). Leave every other parameter out (do not include a null
|
||||
entry — omit the key entirely). Never invent henries for a ferrite bead.
|
||||
- Express numeric values with SPICE multiplier prefixes and units
|
||||
(u=1e-6, n=1e-9, p=1e-12, k=1e3, M=1e6). Examples: ``10uF``, ``4.7kohm``, ``100nH``.
|
||||
- Pick the GENERIC parent subtype — e.g. ``passive.capacitor``, ``passive.resistor``,
|
||||
@@ -987,6 +1190,20 @@ async def resolve_from_value(
|
||||
"""
|
||||
tax_dir = taxonomy_dir or settings.taxonomy_dir
|
||||
|
||||
from backend.services.passive_from_value import (
|
||||
is_placeholder_value,
|
||||
specs_from_bom_value,
|
||||
)
|
||||
|
||||
parsed = specs_from_bom_value(mpn, value, ref_prefix)
|
||||
if parsed is not None:
|
||||
logging.getLogger(__name__).info(
|
||||
"Resolved from value %s=%r without LLM", mpn, value,
|
||||
)
|
||||
return parsed
|
||||
if is_placeholder_value(value):
|
||||
raise ValueError(f"Placeholder BOM value {value!r} for {mpn}")
|
||||
|
||||
if not has_specs(component_type, tax_dir):
|
||||
try:
|
||||
await _generate_type_specs(component_type, tax_dir, api_logger=api_logger)
|
||||
@@ -1073,7 +1290,7 @@ async def resolve_from_value(
|
||||
)
|
||||
|
||||
if component_type == "passive":
|
||||
from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
+94
-16
@@ -16,6 +16,7 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from backend.config import settings
|
||||
@@ -57,8 +58,11 @@ def _spawn_local_subprocess(
|
||||
free: bool,
|
||||
mode: str = "run",
|
||||
regen_stages: list[str] | None = None,
|
||||
proc_key: str | None = None,
|
||||
execution_name: str | None = None,
|
||||
) -> str:
|
||||
name = _local_execution_name(project_id)
|
||||
key = proc_key or project_id
|
||||
name = execution_name or _local_execution_name(project_id)
|
||||
env = os.environ.copy()
|
||||
env["PROJECT_ID"] = project_id
|
||||
env["USER_ID"] = user_id
|
||||
@@ -71,36 +75,70 @@ def _spawn_local_subprocess(
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "backend.pipeline_worker"],
|
||||
env=env,
|
||||
# Inherit stdout/stderr so logs appear in the dev terminal
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
_write_pid(key, proc.pid)
|
||||
with _local_procs_lock:
|
||||
# Reap any old proc for the same project before tracking the new one.
|
||||
prior = _local_procs.pop(project_id, None)
|
||||
# Reap any old proc for the same key before tracking the new one.
|
||||
prior = _local_procs.pop(key, None)
|
||||
if prior is not None:
|
||||
try:
|
||||
prior.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
_local_procs[project_id] = proc
|
||||
logger.info("dev: spawned worker subprocess pid=%s for %s", proc.pid, project_id)
|
||||
_local_procs[key] = proc
|
||||
logger.info(
|
||||
"dev: spawned worker subprocess pid=%s for %s mode=%s",
|
||||
proc.pid, project_id, mode,
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _pid_path(project_id: str) -> Path:
|
||||
return settings.data_dir / "workers" / f"{project_id}.pid"
|
||||
|
||||
|
||||
def _write_pid(project_id: str, pid: int) -> None:
|
||||
path = _pid_path(project_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(str(pid))
|
||||
|
||||
|
||||
def _pid_alive(project_id: str) -> bool | None:
|
||||
"""True/False if a pid file exists; None if there is no file."""
|
||||
path = _pid_path(project_id)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
pid = int(path.read_text().strip())
|
||||
except ValueError:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _local_state(project_id: str) -> ExecutionState:
|
||||
with _local_procs_lock:
|
||||
proc = _local_procs.get(project_id)
|
||||
if proc is None:
|
||||
return "unknown"
|
||||
rc = proc.poll()
|
||||
if rc is None:
|
||||
if proc is not None:
|
||||
rc = proc.poll()
|
||||
if rc is None:
|
||||
return "running"
|
||||
if rc == 0:
|
||||
return "succeeded"
|
||||
if rc < 0:
|
||||
# Negative return = terminated by signal
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
alive = _pid_alive(project_id)
|
||||
if alive is True:
|
||||
return "running"
|
||||
if rc == 0:
|
||||
return "succeeded"
|
||||
if rc < 0:
|
||||
# Negative return = terminated by signal
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
if alive is False:
|
||||
return "failed"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _local_cancel(project_id: str) -> None:
|
||||
@@ -309,6 +347,12 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
|
||||
if execution_name.startswith("local/projects/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(project_id)
|
||||
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)
|
||||
|
||||
|
||||
@@ -324,4 +368,38 @@ def cancel_execution(execution_name: str | None) -> None:
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(project_id)
|
||||
return
|
||||
if execution_name.startswith("local/placement/"):
|
||||
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)
|
||||
|
||||
|
||||
def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
|
||||
"""Dispatch the parallel Placement pipeline (topology plan, no LLM)."""
|
||||
if use_cloud_run_jobs():
|
||||
return _enqueue_cloud_run_job(
|
||||
project_id, user_id, resume=False, free=True, mode="placement",
|
||||
)
|
||||
return _spawn_local_subprocess(
|
||||
project_id, user_id, resume=False, free=True, mode="placement",
|
||||
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}",
|
||||
)
|
||||
+6
-3
@@ -1,10 +1,13 @@
|
||||
"""Provider-agnostic LLM client layer.
|
||||
|
||||
All Claude API calls in the backend route through this package via the
|
||||
``LLMProvider`` interface. The default provider is Anthropic; per-stage
|
||||
All model calls in the backend route through this package via the
|
||||
``LLMProvider`` interface. The default provider is DeepSeek; per-stage
|
||||
overrides via ``Settings.provider_*`` env vars route specific stages to
|
||||
other providers (currently Anthropic + Gemini).
|
||||
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 (
|
||||
+16
-1
@@ -269,7 +269,22 @@ class AnthropicProvider(LLMProvider):
|
||||
runs ``validate.py`` server-side via code_execution, and voluntarily
|
||||
calls ``output_tool`` once it has well-formed data.
|
||||
"""
|
||||
try:\n skill_id, version = settings.get_skill(skill_name)\n except Exception:\n skill_id, version = None, None
|
||||
try:
|
||||
skill_id, version = settings.get_skill(skill_name)
|
||||
except Exception:
|
||||
skill_id, version = None, None
|
||||
|
||||
if not skill_id:
|
||||
from backend.services.llm.local_skill import run_skill_locally
|
||||
return await run_skill_locally(
|
||||
self,
|
||||
skill_name=skill_name,
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=user_text,
|
||||
pdf_path=pdf_path,
|
||||
output_tool=output_tool,
|
||||
)
|
||||
|
||||
# Build initial user content
|
||||
user_content: list[dict] = []
|
||||
@@ -92,9 +92,7 @@ class LLMProvider(Protocol):
|
||||
) -> tuple[dict, "Completion"]:
|
||||
"""Execute a managed Skill and return (forced-tool input, Completion).
|
||||
|
||||
Anthropic uses Console Skills (skill_id + container + code_execution
|
||||
beta). Gemini raises ``NotImplementedError`` — there is no
|
||||
Gemini-managed-Skill equivalent today; if you want a Gemini path for
|
||||
skill-style extraction, inline the SKILL.md content as ``system`` and
|
||||
run validation locally."""
|
||||
DeepSeek and Gemini inline ``skills/<name>/SKILL.md`` and run
|
||||
``validate.py`` locally. Anthropic uses Console Skills when a
|
||||
skill_id is configured, otherwise the same local path."""
|
||||
...
|
||||
+10
-5
@@ -15,13 +15,18 @@ log = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
@lru_cache(maxsize=8)
|
||||
def get_provider_by_name(name: str) -> LLMProvider:
|
||||
"""Return a singleton provider instance for ``name`` ("anthropic" |
|
||||
"gemini"). Used by :func:`get_provider` and :func:`call_with_fallback`."""
|
||||
"""Return a singleton provider instance for ``name`` ("deepseek" |
|
||||
"anthropic" | "gemini"). Used by :func:`get_provider` and
|
||||
:func:`call_with_fallback`."""
|
||||
if name == "deepseek":
|
||||
from backend.services.llm.deepseek_provider import DeepSeekProvider
|
||||
return DeepSeekProvider()
|
||||
if name == "anthropic":
|
||||
from backend.services.llm.anthropic_provider import AnthropicProvider
|
||||
return AnthropicProvider()
|
||||
log.warning("Anthropic is disabled — using DeepSeek instead")
|
||||
from backend.services.llm.deepseek_provider import DeepSeekProvider
|
||||
return DeepSeekProvider()
|
||||
if name == "gemini":
|
||||
from backend.services.llm.gemini_provider import GeminiProvider
|
||||
return GeminiProvider()
|
||||
+9
-5
@@ -371,9 +371,13 @@ class GeminiProvider(LLMProvider):
|
||||
pdf_path: str | None,
|
||||
output_tool: ToolSchema,
|
||||
) -> tuple[dict, Completion]:
|
||||
raise NotImplementedError(
|
||||
f"GeminiProvider.run_skill() not implemented (skill={skill_name!r}). "
|
||||
f"Anthropic Console Skills have no Gemini equivalent. To migrate "
|
||||
f"this skill to Gemini, inline its SKILL.md as the system prompt "
|
||||
f"and run validate.py locally."
|
||||
from backend.services.llm.local_skill import run_skill_locally
|
||||
return await run_skill_locally(
|
||||
self,
|
||||
skill_name=skill_name,
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=user_text,
|
||||
pdf_path=pdf_path,
|
||||
output_tool=output_tool,
|
||||
)
|
||||
+21
-4
@@ -8,10 +8,26 @@ from __future__ import annotations
|
||||
|
||||
|
||||
# Per-million-token USD rates. Source-of-truth links:
|
||||
# DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
|
||||
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
||||
# Google: https://ai.google.dev/pricing
|
||||
# Last updated: 2026-07-01
|
||||
# Last updated: 2026-09-10
|
||||
#
|
||||
# DeepSeek: peak weekday rates (conservative). Off-peak is 50% of these.
|
||||
# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"] as a
|
||||
# multiplier on the miss input rate (0.006 / 0.30 = 0.02).
|
||||
_DEEPSEEK_FLASH = {"input": 0.30, "output": 1.20}
|
||||
_DEEPSEEK_PRO = {"input": 1.32, "output": 3.96}
|
||||
|
||||
PRICING: dict[str, dict[str, dict[str, float]]] = {
|
||||
"deepseek": {
|
||||
"deepseek-flash": _DEEPSEEK_FLASH,
|
||||
"deepseek-v4-flash": _DEEPSEEK_FLASH,
|
||||
"deepseek-v4-flash-vision-exp": _DEEPSEEK_FLASH,
|
||||
# Billed at Pro until 2026-09-14 04:00 UTC, then routed to Flash.
|
||||
"deepseek-v4-pro": _DEEPSEEK_PRO,
|
||||
"default": _DEEPSEEK_FLASH,
|
||||
},
|
||||
"anthropic": {
|
||||
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
|
||||
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
|
||||
@@ -54,6 +70,7 @@ PRICING: dict[str, dict[str, dict[str, float]]] = {
|
||||
# normal input pass)
|
||||
# read: cost when a cached prefix is *reused* (much cheaper)
|
||||
CACHE_RATES: dict[str, dict[str, float]] = {
|
||||
"deepseek": {"create": 1.00, "read": 0.02},
|
||||
"anthropic": {"create": 1.25, "read": 0.10},
|
||||
"gemini": {"create": 1.00, "read": 0.25},
|
||||
}
|
||||
@@ -62,10 +79,10 @@ CACHE_RATES: dict[str, dict[str, float]] = {
|
||||
def cost_for_entry(entry: dict) -> float:
|
||||
"""USD cost for an api_logs entry. Reads ``provider`` (default
|
||||
``anthropic`` for legacy entries) and ``model`` to pick rates."""
|
||||
provider = entry.get("provider") or "anthropic"
|
||||
table = PRICING.get(provider) or PRICING["anthropic"]
|
||||
provider = entry.get("provider") or "deepseek"
|
||||
table = PRICING.get(provider) or PRICING["deepseek"]
|
||||
rates = table.get(entry.get("model", ""), table["default"])
|
||||
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
|
||||
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
|
||||
input_rate = rates["input"]
|
||||
output_rate = rates["output"]
|
||||
return (
|
||||
@@ -26,12 +26,17 @@ class TextBlock:
|
||||
# this turn is fed back into the conversation, or the next call 400s.
|
||||
# Anthropic: always None.
|
||||
thought_signature: bytes | None = None
|
||||
# DeepSeek thinking-mode: assistant ``reasoning_content`` that must be
|
||||
# replayed on the next turn or the API returns 400.
|
||||
reasoning_content: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfBlock:
|
||||
"""Inline PDF document. Provider encodes as base64 (Anthropic) or
|
||||
inline_data (Gemini) and applies caching policy if cacheable=True."""
|
||||
"""Inline PDF document. Anthropic encodes as base64, Gemini as
|
||||
inline_data. DeepSeek does not accept PDFs natively — the provider
|
||||
converts the file to extracted text (and page images on a vision
|
||||
model) before sending."""
|
||||
path: Path
|
||||
cacheable: bool = False
|
||||
|
||||
@@ -45,6 +50,7 @@ class ToolCall:
|
||||
# Same purpose as TextBlock.thought_signature — Gemini 3 attaches one
|
||||
# to every function_call part when thinking is on. Round-trip required.
|
||||
thought_signature: bytes | None = None
|
||||
reasoning_content: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
+10
-15
@@ -23,7 +23,7 @@ from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.llm import Message, TextBlock
|
||||
from backend.services.llm.factory import call_with_fallback
|
||||
@@ -394,20 +394,15 @@ def _build_normalized(
|
||||
new_why = "Unverified: " + new_why
|
||||
|
||||
try:
|
||||
result.append(Finding(
|
||||
finding_id=canon.finding_id,
|
||||
designator=canon.designator,
|
||||
mpn=canon.mpn,
|
||||
aspect=canon.aspect,
|
||||
finding=str(entry.get("finding") or canon.finding),
|
||||
why=new_why,
|
||||
source_page=entry.get("source_page", canon.source_page),
|
||||
source_quote=str(entry.get("source_quote") or canon.source_quote),
|
||||
source_designator=canon.source_designator,
|
||||
status=final_status,
|
||||
recommendation=str(entry.get("recommendation") or canon.recommendation),
|
||||
reference=str(entry.get("reference") or canon.reference),
|
||||
))
|
||||
result.append(canon.model_copy(update={
|
||||
"finding": str(entry.get("finding") or canon.finding),
|
||||
"why": new_why,
|
||||
"source_page": entry.get("source_page", canon.source_page),
|
||||
"source_quote": str(entry.get("source_quote") or canon.source_quote),
|
||||
"status": final_status,
|
||||
"recommendation": str(entry.get("recommendation") or canon.recommendation),
|
||||
"reference": str(entry.get("reference") or canon.reference),
|
||||
}))
|
||||
except Exception:
|
||||
log.exception("normalize: failed to build merged Finding")
|
||||
return None
|
||||
+634
-473
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,13 @@ Each project lives at users/{user_id}/projects/{id}/ with:
|
||||
uploads/bom.csv — uploaded BOM
|
||||
uploads/netlist.asc — uploaded netlist
|
||||
uploads/datasheets/*.pdf — uploaded datasheets
|
||||
uploads/pcb.kicad_pcb — optional KiCad board (layout checks)
|
||||
extracted/ — IC extraction output
|
||||
patterns/ — passive patterns
|
||||
models/ — cached component specs
|
||||
design_graph.json — graph output
|
||||
report.json — validation report
|
||||
periscope-findings.json — KiCad cad-bridge (plugin pan-and-zoom)
|
||||
|
||||
Library (global, shared across users):
|
||||
library/extracted/{mpn}.json
|
||||
@@ -21,13 +23,17 @@ Library (global, shared across users):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.services.storage import StaleGeneration, StorageBackend
|
||||
|
||||
|
||||
@@ -79,6 +85,7 @@ class ProjectMeta(BaseModel):
|
||||
updated: str = ""
|
||||
has_bom: bool = False
|
||||
has_netlist: bool = False
|
||||
has_pcb: bool = False
|
||||
# "pads" | "edif" | None — None for legacy projects (pre-EDIF-support).
|
||||
# Legacy reads fall back to looking for netlist.asc on disk.
|
||||
netlist_format: str | None = None
|
||||
@@ -113,9 +120,13 @@ class ProjectMeta(BaseModel):
|
||||
pause_reason: str | None = None
|
||||
completed_review_refs: list[str] = [] # IC refs already reviewed (persists across pauses)
|
||||
|
||||
# Pinscope app version that generated the project's report.
|
||||
# Periscope app version that generated the project's report.
|
||||
# Stamped on the first /start transition and preserved thereafter.
|
||||
pinscope_version: str | None = None
|
||||
# Dual-read pinscope_version (PinScope fence); serialization uses periscope_version.
|
||||
periscope_version: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("periscope_version", "pinscope_version"),
|
||||
)
|
||||
|
||||
# Worker bookkeeping (set by the API on enqueue, read by /events SSE
|
||||
# and by the stale-running sweeper).
|
||||
@@ -125,6 +136,50 @@ class ProjectMeta(BaseModel):
|
||||
# gate (inside _charge_for_logs) and exits cleanly.
|
||||
cancel_requested: bool = False
|
||||
|
||||
# Placement pipeline (parallel to analysis — does not overwrite status).
|
||||
# draft | queued | running | complete | error | cancelled
|
||||
placement_status: str = "draft"
|
||||
placement_state: dict[str, Any] | None = None
|
||||
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,
|
||||
) -> list[str]:
|
||||
"""ICs that already finished review and should be skipped on reprocess.
|
||||
|
||||
Drops refs that failed (skipped_components / report.review_errors) so
|
||||
those ICs are tried again.
|
||||
"""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if not meta:
|
||||
return []
|
||||
failed: set[str] = set()
|
||||
for item in meta.skipped_components or []:
|
||||
stage = (item.get("stage") or "")
|
||||
ident = (item.get("identifier") or "").strip()
|
||||
if ident and stage in ("validation", "review"):
|
||||
failed.add(ident)
|
||||
report_key = f"{_project_prefix(user_id, project_id)}/report.json"
|
||||
if storage.exists(report_key):
|
||||
try:
|
||||
report = storage.read_json(report_key)
|
||||
except Exception:
|
||||
report = {}
|
||||
for ref in (report.get("review_errors") or {}):
|
||||
if ref:
|
||||
failed.add(str(ref))
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
kept = [r for r in (meta.completed_review_refs or []) if r and r not in failed]
|
||||
return sorted(kept, key=natural_sort_key)
|
||||
|
||||
|
||||
def _project_prefix(user_id: str, project_id: str) -> str:
|
||||
return f"users/{user_id}/projects/{project_id}"
|
||||
@@ -146,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(),
|
||||
)
|
||||
|
||||
@@ -236,6 +305,208 @@ def mark_stale_running(
|
||||
return None
|
||||
|
||||
|
||||
def heal_if_pipeline_finished(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""Unstick analysis ``queued``/``running`` when the worker is gone.
|
||||
|
||||
- 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
|
||||
|
||||
prefix = _project_prefix(user_id, project_id)
|
||||
events_prefix = f"{prefix}/events/"
|
||||
last = None
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
state = job_runner.get_execution_state(exec_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
if state in ("pending", "running"):
|
||||
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(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""Unstick placement_status queued/running when the worker is gone.
|
||||
|
||||
- Last event ``placement_complete`` → ``complete``
|
||||
- Dead worker + plan artifact present → ``complete``
|
||||
- Dead worker otherwise → ``error``
|
||||
"""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if meta is None:
|
||||
return None
|
||||
pst = meta.placement_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") == "placement_complete":
|
||||
data = (last_event or {}).get("data") or {}
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="complete",
|
||||
placement_cancel_requested=False,
|
||||
placement_state={
|
||||
"domains": data.get("domains"),
|
||||
"groups": data.get("groups"),
|
||||
},
|
||||
)
|
||||
|
||||
from backend.services import job_runner
|
||||
|
||||
exec_name = meta.placement_execution_name or f"local/placement/{project_id}"
|
||||
try:
|
||||
state = job_runner.get_execution_state(exec_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
|
||||
if state in ("pending", "running"):
|
||||
return None
|
||||
|
||||
has_plan = (
|
||||
storage.exists(f"{prefix}/placement_plan.json")
|
||||
or storage.exists(f"{prefix}/functional_groups.json")
|
||||
)
|
||||
if has_plan:
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="complete",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=meta.placement_state,
|
||||
)
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_cancel_requested=False,
|
||||
placement_state={"error": f"Placement worker terminated ({state})"},
|
||||
)
|
||||
|
||||
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -281,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
|
||||
|
||||
|
||||
@@ -323,6 +594,8 @@ def clear_project_extractions(
|
||||
"bom_summary.json",
|
||||
"derating.json",
|
||||
"report.json",
|
||||
"periscope-findings.json",
|
||||
"review_fingerprints.json",
|
||||
"api_logs.jsonl",
|
||||
"graph_voltage_updates.json",
|
||||
):
|
||||
@@ -356,6 +629,8 @@ def reopen_project(
|
||||
"bom_summary.json",
|
||||
"derating.json",
|
||||
"report.json",
|
||||
"periscope-findings.json",
|
||||
"review_fingerprints.json",
|
||||
"api_logs.jsonl",
|
||||
"graph_voltage_updates.json",
|
||||
):
|
||||
@@ -452,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})
|
||||
@@ -465,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):
|
||||
@@ -577,7 +852,13 @@ def save_bom(
|
||||
return key
|
||||
|
||||
|
||||
_NETLIST_EXT = {"pads": "asc", "edif": "edn"}
|
||||
_NETLIST_EXT = {
|
||||
"pads": "asc",
|
||||
"edif": "edn",
|
||||
"kicad_xml": "xml",
|
||||
"kicad_sexp": "kicad_net",
|
||||
"kicad_sch": "kicad_sch",
|
||||
}
|
||||
|
||||
|
||||
def _netlist_key(user_id: str, project_id: str, fmt: str) -> str:
|
||||
@@ -595,16 +876,17 @@ def save_netlist(
|
||||
) -> str:
|
||||
"""Persist the uploaded netlist with the extension matching ``fmt``.
|
||||
|
||||
Also clears any previously-saved netlist in the *other* format so we
|
||||
never have stale ``.asc`` and ``.edn`` files side-by-side (e.g. user
|
||||
re-uploads with a different format).
|
||||
Also clears any previously-saved netlist in another format so we
|
||||
never have stale files side-by-side (e.g. user re-uploads KiCad after PADS).
|
||||
"""
|
||||
key = _netlist_key(user_id, project_id, fmt)
|
||||
storage.write_bytes(key, data)
|
||||
other_fmt = "edif" if fmt == "pads" else "pads"
|
||||
other_key = _netlist_key(user_id, project_id, other_fmt)
|
||||
if storage.exists(other_key):
|
||||
storage.delete_key(other_key)
|
||||
for other in _NETLIST_EXT:
|
||||
if other == fmt:
|
||||
continue
|
||||
other_key = _netlist_key(user_id, project_id, other)
|
||||
if storage.exists(other_key):
|
||||
storage.delete_key(other_key)
|
||||
# Reset sub-design selection on every upload — the prior selection may
|
||||
# reference IDs that no longer exist in the new file. Frontend resets
|
||||
# the picker after upload too; this keeps backend in sync.
|
||||
@@ -615,23 +897,73 @@ def save_netlist(
|
||||
return key
|
||||
|
||||
|
||||
def clear_companion_sheets(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
|
||||
for key in storage.list_recursive(prefix):
|
||||
rel = key[len(prefix):]
|
||||
if rel.endswith(".kicad_sch") and rel != "netlist.kicad_sch":
|
||||
storage.delete_key(key)
|
||||
|
||||
|
||||
def save_companion_sheets(
|
||||
storage: StorageBackend,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
root: Path,
|
||||
extras: list[Path],
|
||||
) -> None:
|
||||
"""Keep Sheetfile children next to ``uploads/netlist.kicad_sch``."""
|
||||
clear_companion_sheets(storage, user_id, project_id)
|
||||
parent = root.parent
|
||||
prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
|
||||
for extra in extras:
|
||||
rel = extra.relative_to(parent).as_posix()
|
||||
if rel == "netlist.kicad_sch":
|
||||
continue
|
||||
storage.write_bytes(prefix + rel, extra.read_bytes())
|
||||
|
||||
|
||||
def save_pcb(
|
||||
storage: StorageBackend, user_id: str, project_id: str, data: bytes
|
||||
) -> str:
|
||||
key = f"{_project_prefix(user_id, project_id)}/uploads/pcb.kicad_pcb"
|
||||
storage.write_bytes(key, data)
|
||||
update_project(storage, user_id, project_id, has_pcb=True)
|
||||
return key
|
||||
|
||||
|
||||
def save_datasheet(
|
||||
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes
|
||||
) -> str:
|
||||
"""Save a datasheet PDF to the project uploads directory.
|
||||
"""Save a datasheet PDF to the project and to the shared library.
|
||||
|
||||
Library writes happen during pattern extraction (one PDF per pattern series).
|
||||
The project copy is what the pipeline reads for this run. The library
|
||||
copy means a later project with the same MPN can skip the download.
|
||||
"""
|
||||
safe = safe_mpn(mpn)
|
||||
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe}.pdf"
|
||||
storage.write_bytes(key, data)
|
||||
# Count datasheets
|
||||
remember_datasheet(storage, mpn, data)
|
||||
ds_prefix = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/"
|
||||
count = sum(1 for k in storage.list_prefix(ds_prefix) if k.endswith(".pdf"))
|
||||
update_project(storage, user_id, project_id, datasheet_count=count)
|
||||
return key
|
||||
|
||||
|
||||
def remember_datasheet(
|
||||
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Write a datasheet into the shared library without failing the caller."""
|
||||
try:
|
||||
from backend.services.datasheet_store import store_datasheet_bytes
|
||||
|
||||
store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns)
|
||||
except Exception:
|
||||
log.exception("Failed to store datasheet for %s in the shared library", mpn)
|
||||
|
||||
|
||||
def get_bom_key(
|
||||
storage: StorageBackend, user_id: str, project_id: str
|
||||
) -> str | None:
|
||||
@@ -643,7 +975,7 @@ def get_netlist_key(
|
||||
storage: StorageBackend, user_id: str, project_id: str
|
||||
) -> str | None:
|
||||
"""Return the storage key of whichever netlist file exists (.asc or .edn)."""
|
||||
for fmt in ("pads", "edif"):
|
||||
for fmt in _NETLIST_EXT:
|
||||
key = _netlist_key(user_id, project_id, fmt)
|
||||
if storage.exists(key):
|
||||
return key
|
||||
@@ -714,7 +1046,7 @@ def library_has_datasheet(
|
||||
return key
|
||||
# 3. Pattern-based fallback for passives
|
||||
if patterns:
|
||||
from backend.pinscopex.resolve_passives import resolve_mpn
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
|
||||
match = resolve_mpn(mpn, patterns)
|
||||
if match is not None:
|
||||
@@ -759,6 +1091,137 @@ def save_to_library(
|
||||
return dst_key
|
||||
|
||||
|
||||
def _specs_param_count(specs: dict) -> int:
|
||||
if not isinstance(specs, dict):
|
||||
return 0
|
||||
values = specs.get("values")
|
||||
if isinstance(values, dict):
|
||||
return sum(1 for v in values.values() if v not in (None, "", []))
|
||||
skip = {"specs_type", "component_subtype"}
|
||||
return sum(
|
||||
1 for k, v in specs.items()
|
||||
if k not in skip and v not in (None, "", [])
|
||||
)
|
||||
|
||||
|
||||
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
|
||||
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
specs = data.get("specs", {}) or {}
|
||||
return {
|
||||
"mpn": mpn,
|
||||
"type": row_type,
|
||||
"specs_type": specs.get("specs_type", ""),
|
||||
"subtype": specs.get("component_subtype", ""),
|
||||
"param_count": _specs_param_count(specs),
|
||||
}
|
||||
|
||||
|
||||
def list_library_catalog(storage: StorageBackend) -> dict:
|
||||
"""List ICs, passive patterns, discrete specs, and datasheet refs.
|
||||
|
||||
Used by the user-facing library page and the admin components panel.
|
||||
"""
|
||||
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
|
||||
|
||||
ics: list[dict] = []
|
||||
seen_ic_mpns: set[str] = set()
|
||||
for key in storage.list_prefix("library/extracted/"):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ic_mpns:
|
||||
continue
|
||||
seen_ic_mpns.add(mpn)
|
||||
ics.append({
|
||||
"mpn": mpn,
|
||||
"type": "ic",
|
||||
"subtype": data.get("component_subtype", ""),
|
||||
"pin_count": len(data.get("pintable", [])),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
passives: list[dict] = []
|
||||
seen_passive_names: set[str] = set()
|
||||
for key in storage.list_prefix("library/patterns/"):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if name in seen_passive_names:
|
||||
continue
|
||||
seen_passive_names.add(name)
|
||||
passives.append({
|
||||
"mpn": name,
|
||||
"type": "passive",
|
||||
"subtype": data.get("component_type", ""),
|
||||
"description": data.get("description", ""),
|
||||
"regex": data.get("regex", ""),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
simple_models: list[dict] = []
|
||||
passive_parts: list[dict] = []
|
||||
seen_model_mpns: set[str] = set()
|
||||
for prefix, row_type, dest in (
|
||||
("library/passives/", "passive_part", passive_parts),
|
||||
("library/models/", "simple", simple_models),
|
||||
):
|
||||
for key in storage.list_prefix(prefix):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
row = _catalog_model_row(data, key, row_type=row_type)
|
||||
mpn = row["mpn"]
|
||||
if mpn in seen_model_mpns:
|
||||
continue
|
||||
seen_model_mpns.add(mpn)
|
||||
row["has_datasheet"] = bool(resolve_datasheet(storage, mpn))
|
||||
dest.append(row)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
datasheets: list[dict] = []
|
||||
seen_ds: set[str] = set()
|
||||
for key in storage.list_prefix(REF_PREFIX):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
ref = storage.read_json(key)
|
||||
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ds:
|
||||
continue
|
||||
seen_ds.add(mpn)
|
||||
datasheets.append({
|
||||
"mpn": mpn,
|
||||
"hash": ref.get("hash"),
|
||||
"has_extraction": mpn in seen_ic_mpns,
|
||||
"has_model": mpn in seen_model_mpns,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ics.sort(key=lambda r: r["mpn"].lower())
|
||||
passives.sort(key=lambda r: r["mpn"].lower())
|
||||
passive_parts.sort(key=lambda r: r["mpn"].lower())
|
||||
simple_models.sort(key=lambda r: r["mpn"].lower())
|
||||
datasheets.sort(key=lambda r: r["mpn"].lower())
|
||||
return {
|
||||
"ics": ics,
|
||||
"passives": passives,
|
||||
"passive_parts": passive_parts,
|
||||
"simple": simple_models,
|
||||
"datasheets": datasheets,
|
||||
}
|
||||
|
||||
|
||||
def list_library_patterns(storage: StorageBackend) -> list[str]:
|
||||
"""List all pattern keys in the library."""
|
||||
prefix = "library/patterns/"
|
||||
@@ -768,11 +1231,11 @@ def list_library_patterns(storage: StorageBackend) -> list[str]:
|
||||
def load_library_patterns(storage: StorageBackend):
|
||||
"""Load and parse all passive patterns from the library.
|
||||
|
||||
For local backend, delegates to pinscopex. For GCS, downloads to temp first.
|
||||
For local backend, delegates to periscopex. For GCS, downloads to temp first.
|
||||
This function is only used by the library/check endpoint — during pipeline
|
||||
execution, patterns are loaded from the workspace temp directory.
|
||||
"""
|
||||
from backend.pinscopex.resolve_passives import load_patterns
|
||||
from backend.periscopex.resolve_passives import load_patterns
|
||||
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user