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,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 "")
|
||||
)
|
||||
Reference in New Issue
Block a user