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:
@@ -0,0 +1,184 @@
|
||||
"""DeepSeek provider: PDF ingest, OpenAI message translation, routing, pricing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.llm.pdf_ingest import extract_pdf_text, make_text_pdf
|
||||
from backend.services.llm.deepseek_provider import (
|
||||
_is_vision_model,
|
||||
_to_openai_tool,
|
||||
_to_openai_tool_choice,
|
||||
completion_from_openai,
|
||||
messages_to_openai,
|
||||
)
|
||||
from backend.services.llm.local_skill import load_skill_markdown, load_skill_validator
|
||||
from backend.services.llm.pricing import PRICING, cost_for_entry
|
||||
from backend.services.llm.types import (
|
||||
Message,
|
||||
PdfBlock,
|
||||
TextBlock,
|
||||
ToolCall,
|
||||
ToolResultBlock,
|
||||
ToolSchema,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_pdf(tmp_path: Path) -> Path:
|
||||
pdf = tmp_path / "ds.pdf"
|
||||
pdf.write_bytes(make_text_pdf([
|
||||
"Pin configuration\n1 VCC Power\n2 GND Ground\n3 TXD UART transmit",
|
||||
"Absolute maximum ratings\nVCC 6.0 V",
|
||||
]))
|
||||
return pdf
|
||||
|
||||
|
||||
def test_extract_pdf_text_includes_page_markers(sample_pdf: Path):
|
||||
text = extract_pdf_text(sample_pdf)
|
||||
assert "page 1" in text.lower() or "--- page 1 ---" in text
|
||||
assert "VCC" in text
|
||||
assert sample_pdf.name in text
|
||||
|
||||
|
||||
def test_vision_model_detection():
|
||||
assert _is_vision_model("deepseek-v4-flash-vision-exp")
|
||||
assert not _is_vision_model("deepseek-v4-pro")
|
||||
assert not _is_vision_model("deepseek-v4-flash")
|
||||
|
||||
|
||||
def test_messages_to_openai_pdf_becomes_text(sample_pdf: Path):
|
||||
messages = [
|
||||
Message("user", [
|
||||
PdfBlock(path=sample_pdf, cacheable=True),
|
||||
TextBlock("Extract the pin table."),
|
||||
]),
|
||||
]
|
||||
out = messages_to_openai(messages, vision=False)
|
||||
assert len(out) == 1
|
||||
assert out[0]["role"] == "user"
|
||||
content = out[0]["content"]
|
||||
if isinstance(content, str):
|
||||
blob = content
|
||||
else:
|
||||
blob = " ".join(p.get("text", "") for p in content if p.get("type") == "text")
|
||||
assert not any(p.get("type") == "image_url" for p in content)
|
||||
assert "VCC" in blob
|
||||
assert "Extract the pin table" in blob
|
||||
|
||||
|
||||
def test_messages_to_openai_tool_roundtrip():
|
||||
messages = [
|
||||
Message("assistant", [
|
||||
TextBlock("checking", reasoning_content="I should query the net."),
|
||||
ToolCall(id="call_1", name="get_net_for_pin", input={"ref": "U2", "pin": "3"}),
|
||||
]),
|
||||
Message("user", [
|
||||
ToolResultBlock(tool_use_id="call_1", name="get_net_for_pin", content="UART_TX"),
|
||||
TextBlock("continue"),
|
||||
]),
|
||||
]
|
||||
out = messages_to_openai(messages, vision=False)
|
||||
assert out[0]["role"] == "assistant"
|
||||
assert out[0]["reasoning_content"] == "I should query the net."
|
||||
assert out[0]["tool_calls"][0]["function"]["name"] == "get_net_for_pin"
|
||||
args = json.loads(out[0]["tool_calls"][0]["function"]["arguments"])
|
||||
assert args["ref"] == "U2"
|
||||
assert out[1]["role"] == "tool"
|
||||
assert out[1]["tool_call_id"] == "call_1"
|
||||
assert out[1]["content"] == "UART_TX"
|
||||
assert out[2]["role"] == "user"
|
||||
|
||||
|
||||
def test_tool_schema_and_choice():
|
||||
schema = ToolSchema(
|
||||
name="save_pintable",
|
||||
description="Save pins",
|
||||
input_schema={"type": "object", "properties": {}},
|
||||
)
|
||||
tool = _to_openai_tool(schema)
|
||||
assert tool["type"] == "function"
|
||||
assert tool["function"]["name"] == "save_pintable"
|
||||
assert _to_openai_tool_choice("auto") == "auto"
|
||||
forced = _to_openai_tool_choice({"name": "save_pintable"})
|
||||
assert forced["function"]["name"] == "save_pintable"
|
||||
|
||||
|
||||
def test_completion_from_openai_parses_tools_and_cache():
|
||||
fn = SimpleNamespace(name="submit_review", arguments='{"findings":[]}')
|
||||
tc = SimpleNamespace(id="c1", function=fn)
|
||||
msg = SimpleNamespace(
|
||||
content="done",
|
||||
reasoning_content="step by step",
|
||||
tool_calls=[tc],
|
||||
)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=50,
|
||||
prompt_cache_hit_tokens=400,
|
||||
prompt_tokens_details=None,
|
||||
)
|
||||
resp = SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=msg, finish_reason="tool_calls")],
|
||||
usage=usage,
|
||||
)
|
||||
completion = completion_from_openai(resp)
|
||||
assert completion.text == "done"
|
||||
assert completion.tool_calls[0].name == "submit_review"
|
||||
assert completion.tool_calls[0].input == {"findings": []}
|
||||
assert completion.usage.input_tokens == 600
|
||||
assert completion.usage.cache_read_tokens == 400
|
||||
assert completion.raw_assistant_blocks[0].reasoning_content == "step by step"
|
||||
|
||||
|
||||
def test_local_skills_load():
|
||||
md = load_skill_markdown("extract-pintable")
|
||||
assert "pin table" in md.lower()
|
||||
validate = load_skill_validator("extract-pintable")
|
||||
assert validate is not None
|
||||
errors = validate({
|
||||
"component_subtype": "ic.mcu",
|
||||
"component_subtype_description": "MCU",
|
||||
"package_info": {"base_family": "MSPM0", "package": "LQFP-48", "pin_count": 2},
|
||||
"pintable": [
|
||||
{"number": 1, "name": "VCC"},
|
||||
{"number": 2, "name": "GND"},
|
||||
],
|
||||
})
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_factory_routes_deepseek(monkeypatch):
|
||||
monkeypatch.setattr(settings, "deepseek_api_key", "sk-test")
|
||||
from backend.services.llm.factory import get_provider_by_name
|
||||
get_provider_by_name.cache_clear()
|
||||
try:
|
||||
provider = get_provider_by_name("deepseek")
|
||||
assert provider.name == "deepseek"
|
||||
finally:
|
||||
get_provider_by_name.cache_clear()
|
||||
|
||||
|
||||
def test_config_defaults_are_deepseek():
|
||||
assert settings.provider_default == "deepseek"
|
||||
assert settings.model_for_stage("validation") == settings.model_validation_deepseek
|
||||
assert "vision" in settings.model_for_stage("pintable")
|
||||
assert settings.provider_for_stage("pintable") == "deepseek"
|
||||
|
||||
|
||||
def test_deepseek_pricing_positive():
|
||||
cost = cost_for_entry({
|
||||
"provider": "deepseek",
|
||||
"model": "deepseek-v4-pro",
|
||||
"input_tokens": 1_000_000,
|
||||
"output_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
})
|
||||
assert cost == pytest.approx(1.32)
|
||||
assert "default" in PRICING["deepseek"]
|
||||
Reference in New Issue
Block a user