Adapt Pinscope to DeepSeek, auto datasheets, and a shared library.
Based on manvalan/pinscope main. Default LLM is DeepSeek with local skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in the component library, and review extracts abs-max with a deeper checklist. Adds scripts/update-pinscope.sh for the production host.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"""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 backend.services.llm.factory import call_with_fallback, get_provider
|
||||
|
||||
@@ -274,6 +274,18 @@ class AnthropicProvider(LLMProvider):
|
||||
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] = []
|
||||
if pdf_path:
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""DeepSeek provider — OpenAI-compatible Chat Completions.
|
||||
|
||||
Translates the unified ``Message`` / ``Completion`` shapes into DeepSeek's
|
||||
OpenAI-style chat format. DeepSeek does not accept native PDF documents, so
|
||||
``PdfBlock`` is converted to extracted text (and page images when the
|
||||
session model is a vision model). Thinking-mode ``reasoning_content`` is
|
||||
round-tripped on subsequent turns.
|
||||
|
||||
Extraction skills run locally via :mod:`backend.services.llm.local_skill`
|
||||
(DeepSeek has no Anthropic Console Skills equivalent).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.llm.base import LLMProvider, LLMSession
|
||||
from backend.services.llm.local_skill import run_skill_locally
|
||||
from backend.services.llm.pdf_ingest import pdf_to_openai_content
|
||||
from backend.services.llm.types import (
|
||||
Completion,
|
||||
ContentBlock,
|
||||
Message,
|
||||
PdfBlock,
|
||||
TextBlock,
|
||||
ToolCall,
|
||||
ToolChoice,
|
||||
ToolResultBlock,
|
||||
ToolSchema,
|
||||
Usage,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_VISION_HINT = "vision"
|
||||
|
||||
|
||||
def _is_vision_model(model: str) -> bool:
|
||||
return _VISION_HINT in model.lower()
|
||||
|
||||
|
||||
def _to_openai_tool(t: ToolSchema) -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.input_schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _to_openai_tool_choice(c: ToolChoice) -> dict | str:
|
||||
if c == "auto":
|
||||
return "auto"
|
||||
if c == "none":
|
||||
return "none"
|
||||
if isinstance(c, dict) and "name" in c:
|
||||
return {"type": "function", "function": {"name": c["name"]}}
|
||||
raise ValueError(f"Invalid tool_choice: {c!r}")
|
||||
|
||||
|
||||
def _reasoning_from_blocks(blocks: list[ContentBlock]) -> str | None:
|
||||
for b in blocks:
|
||||
rc = getattr(b, "reasoning_content", None)
|
||||
if rc:
|
||||
return rc
|
||||
return None
|
||||
|
||||
|
||||
def _pdf_parts(path, *, vision: bool) -> list[dict]:
|
||||
return pdf_to_openai_content(
|
||||
path,
|
||||
vision=vision,
|
||||
max_chars=settings.deepseek_pdf_max_chars,
|
||||
max_images=settings.deepseek_pdf_image_pages,
|
||||
)
|
||||
|
||||
|
||||
def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dict]:
|
||||
"""Flatten user-side blocks (text / pdf) into OpenAI content parts."""
|
||||
parts: list[dict] = []
|
||||
for b in blocks:
|
||||
if isinstance(b, TextBlock):
|
||||
parts.append({"type": "text", "text": b.text})
|
||||
elif isinstance(b, PdfBlock):
|
||||
parts.extend(_pdf_parts(b.path, vision=vision))
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Unexpected block in user content: {type(b).__name__}"
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
|
||||
"""Convert unified messages into DeepSeek/OpenAI chat messages.
|
||||
|
||||
Tool results become ``role=tool`` messages (OpenAI does not mix
|
||||
``tool_result`` with documents in one user turn). Any PdfBlocks that
|
||||
accompanied tool results are emitted as a following user message.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for m in messages:
|
||||
if m.role == "assistant":
|
||||
text_parts = [b.text for b in m.content if isinstance(b, TextBlock)]
|
||||
tool_calls = [b for b in m.content if isinstance(b, ToolCall)]
|
||||
msg: dict[str, Any] = {"role": "assistant"}
|
||||
text = "".join(text_parts)
|
||||
msg["content"] = text if text else None
|
||||
if tool_calls:
|
||||
msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": json.dumps(tc.input),
|
||||
},
|
||||
}
|
||||
for tc in tool_calls
|
||||
]
|
||||
reasoning = _reasoning_from_blocks(m.content)
|
||||
if reasoning:
|
||||
msg["reasoning_content"] = reasoning
|
||||
out.append(msg)
|
||||
continue
|
||||
|
||||
# user
|
||||
tool_results = [b for b in m.content if isinstance(b, ToolResultBlock)]
|
||||
other = [b for b in m.content if not isinstance(b, ToolResultBlock)]
|
||||
for tr in tool_results:
|
||||
out.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tr.tool_use_id,
|
||||
"content": tr.content,
|
||||
})
|
||||
if other:
|
||||
parts = _user_content_parts(other, vision=vision)
|
||||
if len(parts) == 1 and parts[0].get("type") == "text":
|
||||
out.append({"role": "user", "content": parts[0]["text"]})
|
||||
else:
|
||||
out.append({"role": "user", "content": parts})
|
||||
elif not tool_results:
|
||||
out.append({"role": "user", "content": ""})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_tool_arguments(raw: str | None) -> dict:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
log.warning("DeepSeek tool arguments were not valid JSON: %s", raw[:200])
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _cache_hit_tokens(usage: Any) -> int:
|
||||
hit = getattr(usage, "prompt_cache_hit_tokens", None)
|
||||
if hit:
|
||||
return int(hit)
|
||||
details = getattr(usage, "prompt_tokens_details", None)
|
||||
if details is not None:
|
||||
cached = getattr(details, "cached_tokens", None)
|
||||
if cached:
|
||||
return int(cached)
|
||||
return 0
|
||||
|
||||
|
||||
def completion_from_openai(resp: Any) -> Completion:
|
||||
choice = resp.choices[0]
|
||||
msg = choice.message
|
||||
text = msg.content or ""
|
||||
reasoning = getattr(msg, "reasoning_content", None) or None
|
||||
|
||||
tool_calls: list[ToolCall] = []
|
||||
raw_blocks: list[ContentBlock] = []
|
||||
if text or reasoning:
|
||||
raw_blocks.append(TextBlock(text=text or "", reasoning_content=reasoning))
|
||||
for i, tc in enumerate(msg.tool_calls or []):
|
||||
fn = tc.function
|
||||
parsed = _parse_tool_arguments(getattr(fn, "arguments", None))
|
||||
call = ToolCall(
|
||||
id=tc.id or f"{fn.name}_{i}",
|
||||
name=fn.name,
|
||||
input=parsed,
|
||||
reasoning_content=reasoning if i == 0 and not text else None,
|
||||
)
|
||||
tool_calls.append(call)
|
||||
raw_blocks.append(call)
|
||||
|
||||
usage_md = getattr(resp, "usage", None)
|
||||
if usage_md is not None:
|
||||
prompt = int(usage_md.prompt_tokens or 0)
|
||||
cached = _cache_hit_tokens(usage_md)
|
||||
usage = Usage(
|
||||
input_tokens=max(0, prompt - cached),
|
||||
output_tokens=int(usage_md.completion_tokens or 0),
|
||||
cache_creation_tokens=0,
|
||||
cache_read_tokens=cached,
|
||||
)
|
||||
else:
|
||||
usage = Usage()
|
||||
|
||||
stop = choice.finish_reason or "unknown"
|
||||
return Completion(
|
||||
text=text,
|
||||
tool_calls=tool_calls,
|
||||
usage=usage,
|
||||
stop_reason=str(stop),
|
||||
raw_assistant_blocks=raw_blocks,
|
||||
)
|
||||
|
||||
|
||||
class DeepSeekSession(LLMSession):
|
||||
provider_name = "deepseek"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: AsyncOpenAI,
|
||||
model: str,
|
||||
system: str,
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
thinking: bool = True,
|
||||
reasoning_effort: str = "medium",
|
||||
) -> None:
|
||||
self._client = client
|
||||
self.model = model
|
||||
self._system = system
|
||||
self._max_tokens = max_tokens
|
||||
self._temperature = temperature
|
||||
self._thinking = thinking
|
||||
self._reasoning_effort = reasoning_effort
|
||||
self._vision = _is_vision_model(model)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSchema] | None = None,
|
||||
tool_choice: ToolChoice = "auto",
|
||||
) -> Completion:
|
||||
oai_messages: list[dict] = [
|
||||
{"role": "system", "content": self._system},
|
||||
]
|
||||
oai_messages.extend(messages_to_openai(messages, vision=self._vision))
|
||||
|
||||
extra_body: dict[str, Any] = {
|
||||
"thinking": {"type": "enabled" if self._thinking else "disabled"},
|
||||
}
|
||||
if self._thinking:
|
||||
extra_body["reasoning_effort"] = self._reasoning_effort
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": oai_messages,
|
||||
"max_tokens": self._max_tokens,
|
||||
"extra_body": extra_body,
|
||||
}
|
||||
if self._temperature is not None:
|
||||
kwargs["temperature"] = self._temperature
|
||||
if tools:
|
||||
kwargs["tools"] = [_to_openai_tool(t) for t in tools]
|
||||
kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice)
|
||||
|
||||
resp = await self._client.chat.completions.create(**kwargs)
|
||||
return completion_from_openai(resp)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DeepSeekProvider(LLMProvider):
|
||||
name = "deepseek"
|
||||
|
||||
def __init__(self) -> None:
|
||||
api_key = settings.deepseek_api_key
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"DEEPSEEK_API_KEY is not set. Copy backend/.env.example to "
|
||||
"backend/.env and add a key from https://platform.deepseek.com/"
|
||||
)
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=settings.deepseek_base_url,
|
||||
)
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
system: str,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float | None = None,
|
||||
) -> LLMSession:
|
||||
thinking = settings.deepseek_thinking.strip().lower() != "disabled"
|
||||
effort = settings.deepseek_reasoning_effort
|
||||
if max_tokens >= 16000:
|
||||
effort = "high"
|
||||
return DeepSeekSession(
|
||||
client=self._client,
|
||||
model=model,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
reasoning_effort=effort,
|
||||
)
|
||||
|
||||
async def run_skill(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
model: str,
|
||||
system: str,
|
||||
user_text: str,
|
||||
pdf_path: str | None,
|
||||
output_tool: ToolSchema,
|
||||
) -> tuple[dict, Completion]:
|
||||
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,
|
||||
)
|
||||
@@ -15,10 +15,14 @@ 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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Provider-agnostic local skill runner.
|
||||
|
||||
Anthropic Console Skills have no equivalent on DeepSeek (or Gemini). This
|
||||
module inlines ``skills/<name>/SKILL.md`` as the system prompt, drives a
|
||||
normal tool-calling session, and runs ``validate.py`` locally after each
|
||||
``output_tool`` call. Used by DeepSeek and Gemini; Anthropic falls back
|
||||
here when no Console skill id is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.llm.base import LLMProvider
|
||||
from backend.services.llm.types import (
|
||||
Completion,
|
||||
Message,
|
||||
PdfBlock,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolSchema,
|
||||
Usage,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SKILL_MAX_TURNS = 10
|
||||
_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
||||
|
||||
_LOCAL_SKILL_TAIL = """
|
||||
You cannot run shell commands or Python. Do not try to execute validate.py.
|
||||
After extracting the data, call the `{tool}` tool with the structured result.
|
||||
The server validates the payload. If validation fails you will receive the
|
||||
errors and must call `{tool}` again with a corrected payload.
|
||||
Do NOT write files to disk.
|
||||
"""
|
||||
|
||||
|
||||
def skills_dir() -> Path:
|
||||
return Path(settings.skills_dir)
|
||||
|
||||
|
||||
def load_skill_markdown(skill_name: str) -> str:
|
||||
path = skills_dir() / skill_name / "SKILL.md"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Skill {skill_name!r} not found at {path}. "
|
||||
f"Expected skills/{skill_name}/SKILL.md in the repo."
|
||||
)
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
return _FRONTMATTER.sub("", raw).strip()
|
||||
|
||||
|
||||
def load_skill_validator(skill_name: str):
|
||||
"""Import ``skills/<name>/validate.py`` and return its ``validate`` fn."""
|
||||
path = skills_dir() / skill_name / "validate.py"
|
||||
if not path.is_file():
|
||||
return None
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"pinscope_skill_{skill_name.replace('-', '_')}_validate", path,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
fn = getattr(mod, "validate", None)
|
||||
return fn if callable(fn) else None
|
||||
|
||||
|
||||
def _sum_usage(total: Usage, piece: Usage) -> Usage:
|
||||
return Usage(
|
||||
input_tokens=total.input_tokens + piece.input_tokens,
|
||||
output_tokens=total.output_tokens + piece.output_tokens,
|
||||
cache_creation_tokens=total.cache_creation_tokens + piece.cache_creation_tokens,
|
||||
cache_read_tokens=total.cache_read_tokens + piece.cache_read_tokens,
|
||||
)
|
||||
|
||||
|
||||
async def run_skill_locally(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
skill_name: str,
|
||||
model: str,
|
||||
system: str,
|
||||
user_text: str,
|
||||
pdf_path: str | None,
|
||||
output_tool: ToolSchema,
|
||||
max_turns: int = _SKILL_MAX_TURNS,
|
||||
) -> tuple[dict, Completion]:
|
||||
"""Run ``skill_name`` as an in-process tool loop on ``provider``."""
|
||||
skill_md = load_skill_markdown(skill_name)
|
||||
validator = load_skill_validator(skill_name)
|
||||
full_system = (
|
||||
skill_md
|
||||
+ "\n\n"
|
||||
+ system.strip()
|
||||
+ "\n"
|
||||
+ _LOCAL_SKILL_TAIL.format(tool=output_tool.name)
|
||||
)
|
||||
|
||||
user_blocks: list = []
|
||||
if pdf_path:
|
||||
user_blocks.append(PdfBlock(path=Path(pdf_path), cacheable=True))
|
||||
user_blocks.append(TextBlock(text=user_text, cacheable=True))
|
||||
messages: list[Message] = [Message(role="user", content=user_blocks)]
|
||||
|
||||
total = Usage()
|
||||
t0 = time.monotonic()
|
||||
last_completion: Completion | None = None
|
||||
|
||||
session = await provider.create_session(
|
||||
model=model, system=full_system, max_tokens=16384, temperature=0.0,
|
||||
)
|
||||
try:
|
||||
for turn in range(max_turns):
|
||||
force = turn >= max_turns - 2
|
||||
completion = await session.complete(
|
||||
messages=messages,
|
||||
tools=[output_tool],
|
||||
tool_choice={"name": output_tool.name} if force else "auto",
|
||||
)
|
||||
last_completion = completion
|
||||
total = _sum_usage(total, completion.usage)
|
||||
|
||||
payload: dict | None = None
|
||||
for tc in completion.tool_calls:
|
||||
if tc.name == output_tool.name:
|
||||
payload = dict(tc.input)
|
||||
break
|
||||
|
||||
messages.append(Message(
|
||||
role="assistant", content=completion.raw_assistant_blocks,
|
||||
))
|
||||
|
||||
if payload is None:
|
||||
messages.append(Message(role="user", content=[TextBlock(
|
||||
text=(
|
||||
f"You did not call {output_tool.name}. "
|
||||
f"Call it now with the extracted data."
|
||||
),
|
||||
)]))
|
||||
continue
|
||||
|
||||
errors: list[str] = []
|
||||
if validator is not None:
|
||||
try:
|
||||
errors = list(validator(payload) or [])
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Skill %s validate.py raised: %s", skill_name, exc,
|
||||
)
|
||||
errors = [f"validator crashed: {exc}"]
|
||||
|
||||
if not errors:
|
||||
completion.usage = total
|
||||
completion.turns = turn + 1 # type: ignore[attr-defined]
|
||||
completion.duration_ms = int((time.monotonic() - t0) * 1000) # type: ignore[attr-defined]
|
||||
return payload, completion
|
||||
|
||||
messages.append(Message(
|
||||
role="user",
|
||||
content=[
|
||||
ToolResultBlock(
|
||||
tool_use_id=completion.tool_calls[0].id,
|
||||
name=output_tool.name,
|
||||
content="VALIDATION FAILED:\n" + "\n".join(
|
||||
f"- {e}" for e in errors
|
||||
),
|
||||
),
|
||||
TextBlock(
|
||||
text=(
|
||||
"Fix the payload and call "
|
||||
f"{output_tool.name} again."
|
||||
),
|
||||
),
|
||||
],
|
||||
))
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Skill {skill_name!r} did not produce a valid {output_tool.name} "
|
||||
f"in {max_turns} turns"
|
||||
+ (f" (last stop_reason={last_completion.stop_reason})"
|
||||
if last_completion else "")
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""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."""
|
||||
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(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"
|
||||
)
|
||||
@@ -8,10 +8,19 @@ 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-08-27
|
||||
PRICING: dict[str, dict[str, dict[str, float]]] = {
|
||||
"deepseek": {
|
||||
# Peak-hour rates (conservative). Off-peak is 50% of these.
|
||||
# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"].
|
||||
"deepseek-v4-flash": {"input": 0.44, "output": 1.32},
|
||||
"deepseek-v4-flash-vision-exp": {"input": 0.44, "output": 1.32},
|
||||
"deepseek-v4-pro": {"input": 1.32, "output": 3.96},
|
||||
"default": {"input": 0.44, "output": 1.32},
|
||||
},
|
||||
"anthropic": {
|
||||
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
|
||||
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
|
||||
@@ -54,6 +63,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.032},
|
||||
"anthropic": {"create": 1.25, "read": 0.10},
|
||||
"gemini": {"create": 1.00, "read": 0.25},
|
||||
}
|
||||
@@ -62,10 +72,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
|
||||
|
||||
Reference in New Issue
Block a user