diff --git a/backend/pinscopex/quote_verify.py b/backend/pinscopex/quote_verify.py new file mode 100644 index 0000000..ace5ed0 --- /dev/null +++ b/backend/pinscopex/quote_verify.py @@ -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) diff --git a/backend/pinscopex/validate.py b/backend/pinscopex/validate.py index ff85427..70d8f12 100644 --- a/backend/pinscopex/validate.py +++ b/backend/pinscopex/validate.py @@ -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", ""), diff --git a/backend/pinscopex/validation_tools.py b/backend/pinscopex/validation_tools.py index c75461c..a1a7455 100644 --- a/backend/pinscopex/validation_tools.py +++ b/backend/pinscopex/validation_tools.py @@ -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": { diff --git a/backend/services/extraction.py b/backend/services/extraction.py index 7a967c3..b8b0b55 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -101,8 +101,11 @@ PINTABLE_TOOL = { "type": "array", "description": ( "Rows from the Absolute Maximum Ratings table: supplies, " - "pin voltages, current, temperature. Omit recommended-" - "operating values. Empty array if the table is unreadable." + "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", diff --git a/backend/services/validation.py b/backend/services/validation.py index 5727489..7149649 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -40,6 +40,7 @@ from backend.pinscopex.validate import ( build_component_context, _parse_review, ) +from backend.pinscopex.quote_verify import verify_finding_citations from backend.pinscopex.utils import safe_mpn from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility 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, 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({ "name": "submit_review", "input": tc.input, diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json index 8daa4b9..7e5670a 100644 --- a/backend/skills_manifest.json +++ b/backend/skills_manifest.json @@ -1,5 +1,5 @@ { - "default_model_version": "1.6.0", + "default_model_version": "1.7.0", "extract-pintable": { "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "latest_version": "1784798970179642", diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index b924053..8d5939f 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -53,7 +53,12 @@ Copy the **Absolute Maximum Ratings** table (not Recommended Operating Condition - `unit` (str) — `V`, `mA`, `°C`, … - `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) diff --git a/skills/extract-pintable/schema.json b/skills/extract-pintable/schema.json index b6145ec..2173af8 100644 --- a/skills/extract-pintable/schema.json +++ b/skills/extract-pintable/schema.json @@ -33,6 +33,7 @@ }, "absolute_maximum_ratings": { "type": "array", + "description": "Abs-max rows, plus Vrwm and polarity/topology for ESD/TVS ICs.", "items": { "type": "object", "properties": { diff --git a/tests/test_quote_verify.py b/tests/test_quote_verify.py new file mode 100644 index 0000000..c5dc9f3 --- /dev/null +++ b/tests/test_quote_verify.py @@ -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.") diff --git a/tests/test_review_parse.py b/tests/test_review_parse.py new file mode 100644 index 0000000..5f3e839 --- /dev/null +++ b/tests/test_review_parse.py @@ -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:")