Files
periscope/backend/services/llm/pdf_ingest.py
T
micheleandCursor 66ae877b7b Do not stall Retry failed on re-extraction or serial datasheet lookups.
Resume reuses pintables, skips LCSC/DigiKey before review, and emits a waiting heartbeat so a long DeepSeek turn is visible instead of a freeze.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 08:34:17 +02:00

287 lines
9.2 KiB
Python

"""Convert datasheet PDFs into text (and optional page images).
DeepSeek's Chat Completions API does not accept native PDF documents.
Anthropic/Gemini providers send the file bytes; DeepSeek instead extracts
text with PyMuPDF (pypdf fallback) and, on a vision model, renders the
pages that actually matter (pin tables, abs-max, electrical, application)
rather than always the first N pages.
"""
from __future__ import annotations
import base64
import io
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_MAX_CHARS = 500_000
_DEFAULT_MAX_IMAGES = 32
_RENDER_ZOOM = 1.55
# Pages whose diagrams/tables the model must actually see.
_PAGE_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"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
r"|decoupling|bypass\s+capacitor|typical\s+application"
r"|application\s+(circuit|schematic|information|note)|reference\s+design"
r"|ordering\s+information|device\s+information",
re.IGNORECASE,
)
def extract_pdf_text(path: Path | str, *, max_chars: int = _DEFAULT_MAX_CHARS) -> str:
"""Return datasheet text with page markers, truncated to ``max_chars``.
Prefers PyMuPDF (better on datasheet tables) and falls back to pypdf.
"""
pdf_path = Path(path)
blob = _extract_text_pymupdf(pdf_path)
if blob is None:
blob = _extract_text_pypdf(pdf_path)
if len(blob) > max_chars:
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
return blob
def _extract_text_pymupdf(pdf_path: Path) -> str | None:
try:
import fitz
except ImportError:
return None
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return None
try:
parts: list[str] = [f"[PDF: {pdf_path.name}, {len(doc)} pages]"]
for i, page in enumerate(doc, start=1):
try:
text = page.get_text("text") or ""
except Exception:
text = ""
parts.append(f"--- page {i} ---\n{text.strip()}")
return "\n\n".join(parts)
finally:
doc.close()
def _extract_text_pypdf(pdf_path: Path) -> str:
from pypdf import PdfReader
try:
reader = PdfReader(str(pdf_path))
except Exception as exc:
log.warning("Failed to open PDF %s: %s", pdf_path, exc)
return f"[PDF {pdf_path.name}: unreadable ({exc})]"
parts: list[str] = [f"[PDF: {pdf_path.name}, {len(reader.pages)} pages]"]
for i, page in enumerate(reader.pages, start=1):
try:
text = page.extract_text() or ""
except Exception:
text = ""
parts.append(f"--- page {i} ---\n{text.strip()}")
return "\n\n".join(parts)
def relevant_page_indices(
path: Path | str,
*,
max_pages: int,
keywords: re.Pattern[str] = _PAGE_KEYWORDS,
) -> list[int]:
"""0-based page indices to send as images: front matter + keyword hits."""
pdf_path = Path(path)
try:
import fitz
doc = fitz.open(str(pdf_path))
except Exception:
return list(range(max_pages))
try:
total = len(doc)
if total <= max_pages:
return list(range(total))
hits: set[int] = set()
for i in range(total):
try:
text = doc[i].get_text("text") or ""
except Exception:
text = ""
if keywords.search(text):
for neighbor in (i - 1, i, i + 1):
if 0 <= neighbor < total:
hits.add(neighbor)
front = set(range(min(5, total)))
ranked_hits = sorted(hits)
if len(ranked_hits) >= max_pages:
keep_front = [i for i in ranked_hits if i < 5][:2]
rest = [i for i in ranked_hits if i not in keep_front]
need = max_pages - len(keep_front)
return sorted(keep_front + rest[-need:])
chosen = set(hits)
for i in sorted(front) + list(range(total)):
if len(chosen) >= max_pages:
break
chosen.add(i)
return sorted(chosen)
finally:
doc.close()
def render_pdf_page_jpegs(
path: Path | str,
*,
max_pages: int = _DEFAULT_MAX_IMAGES,
zoom: float = _RENDER_ZOOM,
page_indices: list[int] | None = None,
) -> list[tuple[int, bytes]]:
"""Render selected pages as JPEG bytes.
``page_indices`` is 0-based. When omitted, keyword-relevant pages are
chosen instead of always rendering the front of the PDF.
Returns a list of (1-based page number, jpeg bytes). Empty if PyMuPDF
is not installed or rendering fails — callers should still send text.
"""
try:
import fitz # PyMuPDF
except ImportError:
log.info("PyMuPDF not installed — DeepSeek vision page images skipped")
return []
pdf_path = Path(path)
if page_indices is None:
page_indices = relevant_page_indices(pdf_path, max_pages=max_pages)
out: list[tuple[int, bytes]] = []
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
return []
try:
matrix = fitz.Matrix(zoom, zoom)
for i in page_indices:
if i < 0 or i >= len(doc):
continue
page = doc[i]
pix = page.get_pixmap(matrix=matrix, alpha=False)
jpeg = pix.tobytes("jpeg")
out.append((i + 1, jpeg))
if len(out) >= max_pages:
break
except Exception as exc:
log.warning("PyMuPDF render failed for %s: %s", pdf_path, exc)
return out
finally:
doc.close()
return out
def jpeg_data_url(jpeg: bytes) -> str:
b64 = base64.standard_b64encode(jpeg).decode("ascii")
return f"data:image/jpeg;base64,{b64}"
def pdf_to_openai_content(
path: Path | str,
*,
vision: bool,
max_chars: int = _DEFAULT_MAX_CHARS,
max_images: int = _DEFAULT_MAX_IMAGES,
) -> list[dict]:
"""OpenAI-style content parts for one PDF: text, plus images if vision."""
pdf_path = Path(path)
try:
mtime = pdf_path.stat().st_mtime_ns
except OSError:
mtime = 0
key = (str(pdf_path.resolve()), mtime, vision, max_chars, max_images)
cached = _PDF_CONTENT_CACHE.get(key)
if cached is not None:
return cached
parts = _pdf_to_openai_content_uncached(
pdf_path, vision=vision, max_chars=max_chars, max_images=max_images,
)
if len(_PDF_CONTENT_CACHE) > 32:
_PDF_CONTENT_CACHE.clear()
_PDF_CONTENT_CACHE[key] = parts
return parts
_PDF_CONTENT_CACHE: dict[tuple, list[dict]] = {}
def _pdf_to_openai_content_uncached(
path: Path,
*,
vision: bool,
max_chars: int,
max_images: int,
) -> list[dict]:
text = extract_pdf_text(path, max_chars=max_chars)
parts: list[dict] = [{"type": "text", "text": text}]
if not vision:
return parts
images = render_pdf_page_jpegs(path, max_pages=max_images)
if not images:
return parts
parts.append({
"type": "text",
"text": (
f"The following {len(images)} image(s) are rendered pages of "
f"{path.name} (pin tables, abs-max, electrical, and "
f"application sections preferred over the front matter). "
f"Use them for diagrams and tables that text extraction may have missed."
),
})
for page_no, jpeg in images:
parts.append({
"type": "text",
"text": f"[page {page_no} image]",
})
parts.append({
"type": "image_url",
"image_url": {"url": jpeg_data_url(jpeg), "detail": "high"},
})
return parts
def make_text_pdf(pages: list[str]) -> bytes:
"""Build a tiny text-only PDF for tests. Uses PyMuPDF when available,
otherwise a hand-rolled one-page PDF."""
try:
import fitz
doc = fitz.open()
for body in pages:
page = doc.new_page()
page.insert_text((72, 72), body, fontsize=11)
buf = io.BytesIO()
doc.save(buf)
doc.close()
return buf.getvalue()
except ImportError:
pass
# Minimal one-page PDF with the first page's text.
payload = (pages[0] if pages else "test").encode("latin-1", "replace")
stream = b"BT /F1 12 Tf 72 720 Td (" + payload.replace(b"(", b"[").replace(b")", b"]") + b") Tj ET"
return (
b"%PDF-1.1\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
b"4 0 obj<</Length " + str(len(stream)).encode() + b">>stream\n"
+ stream + b"\nendstream\nendobj\n"
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"xref\n0 6\n0000000000 65535 f \n"
b"trailer<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"
)