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.
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
"""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"]
|