Verify review quotes against datasheet PDF text and extract ESD Vrwm/polarity.

ERROR/WARNING without a real citation are demoted to Unverified so invented topologies like a unidirectional TPD2E007 clamp cannot ship as ERROR.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 15:43:14 +02:00
co-authored by Cursor
parent 4c54d064d3
commit 53db287ab4
10 changed files with 431 additions and 15 deletions
+198
View File
@@ -0,0 +1,198 @@
"""Deterministic check that a finding's datasheet quote is actually in the PDF.
The reviewer must cite verbatim text. This module extracts page text (PyMuPDF,
then pypdf) and looks for a normalized match on the cited page ±1. Failures
demote ERROR → WARNING and prefix ``why`` with ``Unverified:``.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from pathlib import Path
from backend.pinscopex.models import Finding
from backend.pinscopex.utils import safe_mpn
log = logging.getLogger(__name__)
_MIN_QUOTE_CHARS = 12
_EMPTY_PAGE_ALNUM = 40
_PAGE_WINDOW = 1
def normalize_quote(s: str) -> str:
"""Fold µ/μ, drop soft hyphens and linebreak hyphenation, squeeze space."""
t = (s or "").replace("µ", "μ").replace("\u00ad", "")
t = re.sub(r"-\s+", "", t)
t = re.sub(r"\s+", " ", t).strip().lower()
return t
def _alnum(s: str) -> str:
return re.sub(r"[^a-z0-9μ]+", "", normalize_quote(s))
def quote_in_text(quote: str, text: str) -> bool:
"""True if *quote* appears in *text* after the same folding the PDF viewer uses."""
q = normalize_quote(quote)
if len(q) < _MIN_QUOTE_CHARS:
return False
hay = normalize_quote(text)
if q in hay:
return True
qa, ha = _alnum(quote), _alnum(text)
return len(qa) >= _MIN_QUOTE_CHARS and qa in ha
def pdf_page_texts(pdf_path: Path) -> list[str]:
"""1-based page texts (index 0 unused). Empty list if the file cannot be read."""
blob = _pages_pymupdf(pdf_path)
if blob is None:
blob = _pages_pypdf(pdf_path)
return blob
def _pages_pymupdf(pdf_path: Path) -> list[str] | None:
try:
import fitz
except ImportError:
return None
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("quote_verify: PyMuPDF failed on %s: %s", pdf_path, exc)
return None
try:
pages = [""]
for page in doc:
try:
pages.append(page.get_text("text") or "")
except Exception:
pages.append("")
return pages
finally:
doc.close()
def _pages_pypdf(pdf_path: Path) -> list[str]:
from pypdf import PdfReader
try:
reader = PdfReader(str(pdf_path))
except Exception as exc:
log.warning("quote_verify: pypdf failed on %s: %s", pdf_path, exc)
return []
pages = [""]
for page in reader.pages:
try:
pages.append(page.extract_text() or "")
except Exception:
pages.append("")
return pages
def locate_quote(
pdf_path: Path,
page: int | None,
quote: str,
*,
window: int = _PAGE_WINDOW,
) -> tuple[str, int | None]:
"""Return ``(ok|missing_quote|not_found|page_empty|no_pdf|bad_page, matched_page)``."""
q = (quote or "").strip()
if len(normalize_quote(q)) < _MIN_QUOTE_CHARS:
return ("missing_quote", None)
if not pdf_path.is_file():
return ("no_pdf", None)
pages = pdf_page_texts(pdf_path)
n = len(pages) - 1
if n < 1:
return ("no_pdf", None)
if page is None or not isinstance(page, int) or page < 1:
# Search the whole file; keep the first hit.
for i in range(1, n + 1):
if quote_in_text(q, pages[i]):
return ("ok", i)
if max(len(_alnum(p)) for p in pages[1:]) < _EMPTY_PAGE_ALNUM:
return ("page_empty", None)
return ("not_found", None)
lo = max(1, page - window)
hi = min(n, page + window)
matched: int | None = None
any_text = False
for i in range(lo, hi + 1):
if len(_alnum(pages[i])) >= _EMPTY_PAGE_ALNUM:
any_text = True
if quote_in_text(q, pages[i]):
matched = i
break
if matched is not None:
return ("ok", matched)
if not any_text:
return ("page_empty", None)
if page > n:
return ("bad_page", None)
return ("not_found", None)
_REASONS = {
"missing_quote": "no verbatim datasheet quote.",
"not_found": "cited text not found on the datasheet page.",
"page_empty": "cited page has no extractable text (figure or scan).",
"no_pdf": "datasheet PDF unavailable to check the quote.",
"bad_page": "source_page missing or out of range.",
}
def _mark_unverified(finding: Finding, reason_key: str) -> None:
if finding.status == "ERROR":
finding.status = "WARNING"
msg = _REASONS[reason_key]
if not finding.why.startswith("Unverified:"):
finding.why = f"Unverified: {msg} {finding.why}".strip()
def verify_finding_citations(
findings: list[Finding],
*,
default_pdf: Path,
default_mpn: str,
pdf_dir: Path | None = None,
mpn_by_designator: dict[str, str] | None = None,
pdf_for_mpn: Callable[[str], Path | None] | None = None,
) -> None:
"""Mutate *findings* in place: check each ``source_quote`` against the PDF."""
mpn_by_designator = mpn_by_designator or {}
pdf_dir = pdf_dir or default_pdf.parent
cache: dict[str, Path | None] = {}
def resolve_pdf(finding: Finding) -> Path:
mpn = default_mpn
if finding.source_designator:
mpn = mpn_by_designator.get(finding.source_designator) or default_mpn
if pdf_for_mpn is not None:
hit = pdf_for_mpn(mpn)
if hit is not None:
return hit
key = mpn
if key not in cache:
p = pdf_dir / f"{safe_mpn(mpn)}.pdf"
cache[key] = p if p.is_file() else None
return cache[key] or default_pdf
for finding in findings:
pdf = resolve_pdf(finding)
reason, matched = locate_quote(pdf, finding.source_page, finding.source_quote)
if reason == "ok":
if matched is not None and finding.source_page != matched:
finding.source_page = matched
finding.reference = re.sub(
r"p\.\S+$",
f"p.{matched}",
finding.reference or f"{default_mpn} datasheet p.{matched}",
)
continue
_mark_unverified(finding, reason)
+49 -6
View File
@@ -29,6 +29,7 @@ from backend.pinscopex.models import (
ValidationReport,
)
from backend.pinscopex.pin_function_tokens import parse_net_token
from backend.pinscopex.quote_verify import verify_finding_citations
from backend.pinscopex.validation_tools import (
ALL_TOOLS,
SUBMIT_REVIEW_SCHEMA,
@@ -135,8 +136,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. Pinscope 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 \
@@ -354,6 +359,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 \
@@ -458,7 +484,7 @@ def build_component_context(
pi = constraints.package_info
lines.append(f"Package: {pi.package}, {pi.pin_count} pins")
if constraints and constraints.absolute_maximum_ratings:
lines.append("Absolute maximum ratings (extracted; confirm page if used as ERROR):")
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:
@@ -785,7 +811,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 = []
@@ -874,13 +906,24 @@ 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()
findings.append(Finding(
designator=ic_ref,
mpn=mpn,
source_designator=src_designator,
finding=item["finding"],
why=item.get("why", ""),
status=item["status"],
why=why,
status=status,
source_page=page,
source_quote=item.get("source_quote", ""),
recommendation=item.get("recommendation", ""),
+4 -5
View File
@@ -645,11 +645,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). Pinscope "
"checks it against the PDF page. Omit only if "
"the evidence is a figure/scan with no text."
),
},
"source_designator": {