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:
@@ -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)
|
||||||
@@ -29,6 +29,7 @@ from backend.pinscopex.models import (
|
|||||||
ValidationReport,
|
ValidationReport,
|
||||||
)
|
)
|
||||||
from backend.pinscopex.pin_function_tokens import parse_net_token
|
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 (
|
from backend.pinscopex.validation_tools import (
|
||||||
ALL_TOOLS,
|
ALL_TOOLS,
|
||||||
SUBMIT_REVIEW_SCHEMA,
|
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 \
|
- **source_quote**: The exact verbatim sentence or clause from the datasheet \
|
||||||
that states the requirement. Copy it precisely, character-for-character (a \
|
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. \
|
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 \
|
ERROR and WARNING findings **must** include this field. Pinscope checks the \
|
||||||
rasterized table with no selectable text — do not paraphrase or invent a quote.
|
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 \
|
- **source_designator**: Leave unset when `source_page`/`source_quote` come \
|
||||||
from THIS component's datasheet (the default). Set it to a connected \
|
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 \
|
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 \
|
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.
|
`(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
|
### Direction-control and transceiver function tables
|
||||||
Bidirectional transceivers, level shifters, mux/demux, bus switches, and \
|
Bidirectional transceivers, level shifters, mux/demux, bus switches, and \
|
||||||
analog switches (74xx245, 74xx125, 74xx157, TS3A-family, etc.) often \
|
analog switches (74xx245, 74xx125, 74xx157, TS3A-family, etc.) often \
|
||||||
@@ -458,7 +484,7 @@ def build_component_context(
|
|||||||
pi = constraints.package_info
|
pi = constraints.package_info
|
||||||
lines.append(f"Package: {pi.package}, {pi.pin_count} pins")
|
lines.append(f"Package: {pi.package}, {pi.pin_count} pins")
|
||||||
if constraints and constraints.absolute_maximum_ratings:
|
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:
|
for r in constraints.absolute_maximum_ratings:
|
||||||
bits = []
|
bits = []
|
||||||
if r.min is not None:
|
if r.min is not None:
|
||||||
@@ -785,7 +811,13 @@ def review_component(
|
|||||||
# Check for submit_review
|
# Check for submit_review
|
||||||
for block in response.content:
|
for block in response.content:
|
||||||
if block.type == "tool_use" and block.name == "submit_review":
|
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
|
# Process graph tool calls
|
||||||
tool_results = []
|
tool_results = []
|
||||||
@@ -874,13 +906,24 @@ def _parse_review(
|
|||||||
else:
|
else:
|
||||||
src_designator = None
|
src_designator = None
|
||||||
src_mpn = mpn
|
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(
|
findings.append(Finding(
|
||||||
designator=ic_ref,
|
designator=ic_ref,
|
||||||
mpn=mpn,
|
mpn=mpn,
|
||||||
source_designator=src_designator,
|
source_designator=src_designator,
|
||||||
finding=item["finding"],
|
finding=item["finding"],
|
||||||
why=item.get("why", ""),
|
why=why,
|
||||||
status=item["status"],
|
status=status,
|
||||||
source_page=page,
|
source_page=page,
|
||||||
source_quote=item.get("source_quote", ""),
|
source_quote=item.get("source_quote", ""),
|
||||||
recommendation=item.get("recommendation", ""),
|
recommendation=item.get("recommendation", ""),
|
||||||
|
|||||||
@@ -645,11 +645,10 @@ SUBMIT_REVIEW_SCHEMA = {
|
|||||||
"source_quote": {
|
"source_quote": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"The exact verbatim text from the datasheet that "
|
"Required for ERROR and WARNING. Exact verbatim "
|
||||||
"states this requirement — copy it "
|
"datasheet text (max ~200 chars). Pinscope "
|
||||||
"character-for-character (max ~200 chars). Omit "
|
"checks it against the PDF page. Omit only if "
|
||||||
"if the evidence is only in a figure or a "
|
"the evidence is a figure/scan with no text."
|
||||||
"rasterized table with no selectable text."
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"source_designator": {
|
"source_designator": {
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ PINTABLE_TOOL = {
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"description": (
|
"description": (
|
||||||
"Rows from the Absolute Maximum Ratings table: supplies, "
|
"Rows from the Absolute Maximum Ratings table: supplies, "
|
||||||
"pin voltages, current, temperature. Omit recommended-"
|
"pin voltages, current, temperature. For ESD/TVS ICs also "
|
||||||
"operating values. Empty array if the table is unreadable."
|
"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": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from backend.pinscopex.validate import (
|
|||||||
build_component_context,
|
build_component_context,
|
||||||
_parse_review,
|
_parse_review,
|
||||||
)
|
)
|
||||||
|
from backend.pinscopex.quote_verify import verify_finding_citations
|
||||||
from backend.pinscopex.utils import safe_mpn
|
from backend.pinscopex.utils import safe_mpn
|
||||||
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
|
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
|
||||||
from backend.pinscopex.led_current_check import check_led_current
|
from backend.pinscopex.led_current_check import check_led_current
|
||||||
@@ -422,6 +423,13 @@ async def review_ic_async(
|
|||||||
mpn_by_designator=mpn_by_designator,
|
mpn_by_designator=mpn_by_designator,
|
||||||
connected=connected_designators,
|
connected=connected_designators,
|
||||||
)
|
)
|
||||||
|
verify_finding_citations(
|
||||||
|
result.findings,
|
||||||
|
default_pdf=Path(pdf_path),
|
||||||
|
default_mpn=mpn,
|
||||||
|
pdf_dir=excerpt_state.pdf_dir,
|
||||||
|
mpn_by_designator=mpn_by_designator,
|
||||||
|
)
|
||||||
turn_record["tool_calls"].append({
|
turn_record["tool_calls"].append({
|
||||||
"name": "submit_review",
|
"name": "submit_review",
|
||||||
"input": tc.input,
|
"input": tc.input,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"default_model_version": "1.6.0",
|
"default_model_version": "1.7.0",
|
||||||
"extract-pintable": {
|
"extract-pintable": {
|
||||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||||
"latest_version": "1784798970179642",
|
"latest_version": "1784798970179642",
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ Copy the **Absolute Maximum Ratings** table (not Recommended Operating Condition
|
|||||||
- `unit` (str) — `V`, `mA`, `°C`, …
|
- `unit` (str) — `V`, `mA`, `°C`, …
|
||||||
- `source_page` (int) — 1-based datasheet page of that row
|
- `source_page` (int) — 1-based datasheet page of that row
|
||||||
|
|
||||||
Include supply voltages, pin/input voltages, input current, and temperature. Skip ESD human-body-model rows unless they are the only voltage limit given. Do not invent numbers; if the table is a raster with no readable values, return an empty array.
|
Include supply voltages, pin/input voltages, input current, and temperature. Skip ESD *human-body-model / IEC contact-discharge kV* rows unless they are the only voltage limit given. Do not invent numbers; if the table is a raster with no readable values, return an empty array.
|
||||||
|
|
||||||
|
**ESD / TVS / protection ICs (`ic.protection.esd` and similar):** also copy from Electrical Characteristics (not only abs-max):
|
||||||
|
|
||||||
|
- Working / reverse working voltage **Vrwm** (or V_RWM / "operating voltage") as a **signed** min/max in volts — e.g. bidirectional ±13 V is `min: -13`, `max: 13`, `unit: "V"`.
|
||||||
|
- One extra row whose `parameter` states polarity/topology as printed (`bidirectional`, `unidirectional`, `back-to-back`), `unit: "—"`, min/max omitted. Do **not** infer unidirectional from "IO" pins that list GND as the reference pin.
|
||||||
|
|
||||||
### 5. Assign component subtype (taxonomy)
|
### 5. Assign component subtype (taxonomy)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"absolute_maximum_ratings": {
|
"absolute_maximum_ratings": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"description": "Abs-max rows, plus Vrwm and polarity/topology for ESD/TVS ICs.",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Datasheet quote must appear in the PDF text, not just in the model output."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.pinscopex.models import Finding
|
||||||
|
from backend.pinscopex.quote_verify import (
|
||||||
|
locate_quote,
|
||||||
|
quote_in_text,
|
||||||
|
verify_finding_citations,
|
||||||
|
)
|
||||||
|
from backend.pinscopex.validate import _parse_review
|
||||||
|
from backend.services.llm.pdf_ingest import make_text_pdf
|
||||||
|
|
||||||
|
|
||||||
|
def test_quote_in_text_folds_whitespace_and_mu():
|
||||||
|
assert quote_in_text(
|
||||||
|
"CIO = 12 pF typical",
|
||||||
|
"CIO = 12\npF typical",
|
||||||
|
)
|
||||||
|
assert quote_in_text("I/O capacitance 12 µF", "I/O capacitance 12 μF")
|
||||||
|
assert not quote_in_text(
|
||||||
|
"TPD2E007 IO-to-GND diode forward-conducts near -0.8 V",
|
||||||
|
"The TPD2E007 is a bidirectional ESD protection device.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_locate_quote_page_window(tmp_path: Path):
|
||||||
|
pdf = tmp_path / "part.pdf"
|
||||||
|
pdf.write_bytes(make_text_pdf([
|
||||||
|
"cover",
|
||||||
|
"Working voltage Vrwm is ±13 V bidirectional back-to-back diodes.",
|
||||||
|
"package drawing",
|
||||||
|
]))
|
||||||
|
reason, page = locate_quote(
|
||||||
|
pdf, 1,
|
||||||
|
"Working voltage Vrwm is ±13 V bidirectional back-to-back diodes.",
|
||||||
|
)
|
||||||
|
assert reason == "ok"
|
||||||
|
assert page == 2 # cited p.1, found on p.2 within ±1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_quote_demotes_error(tmp_path: Path):
|
||||||
|
pdf = tmp_path / "TPD2E007.pdf"
|
||||||
|
pdf.write_bytes(make_text_pdf([
|
||||||
|
"TPD2E007 2-channel ESD protection",
|
||||||
|
"Bidirectional working voltage ±13 V. Suitable for audio interfaces.",
|
||||||
|
]))
|
||||||
|
findings = [
|
||||||
|
Finding(
|
||||||
|
designator="U14",
|
||||||
|
mpn="TPD2E007",
|
||||||
|
finding="unidirectional clamp clips audio",
|
||||||
|
why="IO-to-GND diode conducts at -0.8 V.",
|
||||||
|
status="ERROR",
|
||||||
|
source_page=2,
|
||||||
|
source_quote="IO-to-GND diode forward-conducts near -0.8 V clipping audio",
|
||||||
|
reference="TPD2E007 datasheet p.2",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
verify_finding_citations(
|
||||||
|
findings,
|
||||||
|
default_pdf=pdf,
|
||||||
|
default_mpn="TPD2E007",
|
||||||
|
)
|
||||||
|
assert findings[0].status == "WARNING"
|
||||||
|
assert findings[0].why.startswith("Unverified: cited text not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_quote_keeps_error(tmp_path: Path):
|
||||||
|
pdf = tmp_path / "AXP.pdf"
|
||||||
|
pdf.write_bytes(make_text_pdf([
|
||||||
|
"Connect FB5 to the output sense node of DCDC5.",
|
||||||
|
]))
|
||||||
|
findings = [
|
||||||
|
Finding(
|
||||||
|
designator="U16",
|
||||||
|
mpn="AXP2101",
|
||||||
|
finding="FB5 floating",
|
||||||
|
why="FB5 is NC.",
|
||||||
|
status="ERROR",
|
||||||
|
source_page=1,
|
||||||
|
source_quote="Connect FB5 to the output sense node of DCDC5.",
|
||||||
|
reference="AXP2101 datasheet p.1",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
verify_finding_citations(
|
||||||
|
findings, default_pdf=pdf, default_mpn="AXP2101",
|
||||||
|
)
|
||||||
|
assert findings[0].status == "ERROR"
|
||||||
|
assert not findings[0].why.startswith("Unverified:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_warning_without_quote():
|
||||||
|
result = _parse_review(
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"finding": "maybe clip",
|
||||||
|
"why": "typical unidirectional array",
|
||||||
|
"status": "WARNING",
|
||||||
|
"source_page": 3,
|
||||||
|
"source_quote": "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checked_areas": [],
|
||||||
|
},
|
||||||
|
"U14",
|
||||||
|
"TPD2E007",
|
||||||
|
)
|
||||||
|
assert result.findings[0].status == "WARNING"
|
||||||
|
assert result.findings[0].why.startswith("Unverified: no verbatim datasheet quote.")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""submit_review parsing — ERROR without a datasheet quote is demoted."""
|
||||||
|
|
||||||
|
from backend.pinscopex.validate import _parse_review
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_without_quote_becomes_unverified_warning():
|
||||||
|
result = _parse_review(
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"finding": "U14 unidirectional clamp clips audio",
|
||||||
|
"why": "IO-to-GND diode conducts at -0.8 V.",
|
||||||
|
"status": "ERROR",
|
||||||
|
"source_page": 3,
|
||||||
|
"source_quote": "",
|
||||||
|
"recommendation": "Use a bidirectional array.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checked_areas": ["ESD"],
|
||||||
|
},
|
||||||
|
"U14",
|
||||||
|
"TPD2E007DCKR",
|
||||||
|
)
|
||||||
|
assert len(result.findings) == 1
|
||||||
|
f = result.findings[0]
|
||||||
|
assert f.status == "WARNING"
|
||||||
|
assert f.why.startswith("Unverified: no verbatim datasheet quote.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_with_quote_stays_error():
|
||||||
|
result = _parse_review(
|
||||||
|
{
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"finding": "FB5 floating",
|
||||||
|
"why": "FB5 is NC; DCDC5 SW is loaded.",
|
||||||
|
"status": "ERROR",
|
||||||
|
"source_page": 12,
|
||||||
|
"source_quote": "Connect FB5 to the output sense node.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checked_areas": [],
|
||||||
|
},
|
||||||
|
"U16",
|
||||||
|
"AXP2101",
|
||||||
|
)
|
||||||
|
assert result.findings[0].status == "ERROR"
|
||||||
|
assert not result.findings[0].why.startswith("Unverified:")
|
||||||
Reference in New Issue
Block a user