P1: host-agnostic quercus-core with agent loop, providers, and tool schemas
- Core types: Rational time, EntityId, ActionBatch, PngBytes (no Pillow dep) - Config: ~/.quercus/config.toml with 0600 perms, env var override for CI - LLMProvider: Claude (anthropic SDK) and OpenAI-compatible endpoint (covers custom gateways and llama.cpp server mode); graceful degradation without API keys - Tool schemas: 26 curated tools with JSON Schema and read_only flags - AgentLoop: sync dialogue loop with confirmation gate (read-only batches bypass, rejected mutations are reported back to the LLM), PNG frame feedback for multimodal providers - Session log: JSONL recording with frame sha256 hashes, replayable - HostAdapter ABC (signatures only, aligned with OPP/1 method families) - Mock LLM provider (scripted/replay/record) for network-free CI Tests: 69 passed via uv run pytest (no network access)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "quercus-core"
|
||||
version = "0.1.0"
|
||||
description = "Quercus AI 剪辑助手 — 宿主无关的 AI 核心(LLMProvider / Agent / 工具 schema / 会话)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27,<1.0",
|
||||
"anthropic>=0.40,<1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/quercus_core"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Quercus AI 剪辑助手 — 宿主无关的 AI 核心(P1 范围)。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Agent 编排:对话 loop、工具派发、确认门、视觉闭环。"""
|
||||
from quercus_core.agent.loop import AgentLoop, AgentResult, ToolExecutor
|
||||
|
||||
__all__ = ["AgentLoop", "AgentResult", "ToolExecutor"]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Agent 编排:同步对话 loop + 工具派发 + 确认门 + 视觉闭环。
|
||||
|
||||
流程:user 消息 → provider → 有 tool_calls 则整段包成 ActionBatch 进待确认
|
||||
清单 → 经注入的 confirm 回调批准后执行变更类工具(只读工具直接执行)→
|
||||
工具结果(可含 PNG 帧图)回喂 provider → 直到无 tool_call。
|
||||
LLM 看不到事务/快照细节(计划文档 §3.2–3.3)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from quercus_core.providers.base import AssistantTurn, Message, Role, ToolCall, ToolSchema
|
||||
from quercus_core.session.log import SessionLog
|
||||
from quercus_core.tools.schemas import TOOLS, tool_read_only
|
||||
from quercus_core.types import Action, ActionBatch, ToolResult
|
||||
|
||||
|
||||
class ToolExecutor(Protocol):
|
||||
"""工具执行协议。P1 不提供真实宿主实现,测试用内存 stub。"""
|
||||
|
||||
def execute(self, action: Action) -> ToolResult: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
final_text: str
|
||||
turns: int
|
||||
tool_calls_executed: int
|
||||
|
||||
|
||||
class AgentLoop:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider,
|
||||
executor: ToolExecutor,
|
||||
confirm: Callable[[ActionBatch], bool] | None = None,
|
||||
session: SessionLog | None = None,
|
||||
tool_schemas: list[ToolSchema] | None = None,
|
||||
max_turns: int = 12,
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
self._executor = executor
|
||||
self._confirm = confirm # None => 默认全开(计划文档 P1.3 "默认全开")
|
||||
self._session = session
|
||||
self._schemas = list(tool_schemas) if tool_schemas is not None else list(TOOLS.values())
|
||||
self._max_turns = max_turns
|
||||
# 供测试/审计使用
|
||||
self.batch_decisions: list[tuple[ActionBatch, bool]] = []
|
||||
|
||||
def run(self, user_text: str) -> AgentResult:
|
||||
"""执行一轮对话,返回最终助手文本与统计。"""
|
||||
messages: list[Message] = [Message(role=Role.USER, text=user_text)]
|
||||
if self._session:
|
||||
self._session.log_user(user_text)
|
||||
|
||||
executed = 0
|
||||
for turn_no in range(1, self._max_turns + 1):
|
||||
turn = self._provider.generate(messages, tools=self._schemas)
|
||||
if self._session:
|
||||
self._session.log_assistant(turn)
|
||||
if not turn.tool_calls:
|
||||
messages.append(Message(role=Role.ASSISTANT, text=turn.text))
|
||||
return AgentResult(final_text=turn.text, turns=turn_no, tool_calls_executed=executed)
|
||||
|
||||
messages.append(
|
||||
Message(role=Role.ASSISTANT, text=turn.text, tool_calls=turn.tool_calls)
|
||||
)
|
||||
batch = self._build_batch(turn.tool_calls)
|
||||
approved = self._approve(batch)
|
||||
|
||||
for action in batch.actions:
|
||||
result = self._dispatch(action, approved=approved)
|
||||
if self._session:
|
||||
self._session.log_tool_result(result)
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.TOOL,
|
||||
text=result.summary,
|
||||
images=result.images,
|
||||
tool_call_id=action.call_id,
|
||||
name=action.tool,
|
||||
)
|
||||
)
|
||||
executed += 1
|
||||
|
||||
# 达到 max_turns 仍未结束
|
||||
return AgentResult(final_text="", turns=self._max_turns, tool_calls_executed=executed)
|
||||
|
||||
# ---- 内部 ----
|
||||
|
||||
@staticmethod
|
||||
def _build_batch(calls: tuple[ToolCall, ...]) -> ActionBatch:
|
||||
actions = [
|
||||
Action(tool=tc.name, params=dict(tc.arguments), call_id=tc.id) for tc in calls
|
||||
]
|
||||
label = "、".join(sorted({a.tool for a in actions}))
|
||||
return ActionBatch(label=f"AI 动作:{label}", actions=actions, id=uuid4().hex)
|
||||
|
||||
def _approve(self, batch: ActionBatch) -> bool:
|
||||
"""确认门:整段 ActionBatch 经 confirm 回调批准后才执行变更类工具。
|
||||
|
||||
纯只读批次无需确认;未注入 confirm 回调时默认全开。
|
||||
"""
|
||||
if all(tool_read_only(a.tool) for a in batch.actions):
|
||||
approved = True
|
||||
elif self._confirm is None:
|
||||
approved = True
|
||||
else:
|
||||
approved = bool(self._confirm(batch))
|
||||
self.batch_decisions.append((batch, approved))
|
||||
if self._session:
|
||||
self._session.log_batch_decision(batch, approved)
|
||||
return approved
|
||||
|
||||
def _dispatch(self, action: Action, *, approved: bool) -> ToolResult:
|
||||
tool = TOOLS.get(action.tool)
|
||||
if tool is None:
|
||||
return ToolResult(
|
||||
tool=action.tool, ok=False, summary=f"未知工具 {action.tool!r}"
|
||||
)
|
||||
if tool.read_only:
|
||||
return self._executor.execute(action) # 只读工具可直接执行
|
||||
if not approved:
|
||||
return ToolResult(
|
||||
tool=action.tool,
|
||||
ok=False,
|
||||
summary=f"{action.tool} 未执行:动作被用户拒绝(已进入待确认清单)",
|
||||
)
|
||||
return self._executor.execute(action)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""配置与密钥管理。
|
||||
|
||||
- 配置文件:``~/.quercus/config.toml``(权限 0600,不入库)。
|
||||
- 环境变量仅作 CI/无头覆盖,优先级:环境变量 > 配置文件。
|
||||
- 密钥绝不写日志、绝不写任何宿主侧文件(设计铁律 4)。
|
||||
- 无 key 时优雅降级:provider 注册表报告 unavailable(见 providers/)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# 环境变量名(计划文档 P1 交付项 2)。
|
||||
ENV_ANTHROPIC_API_KEY = "QUERCUS_ANTHROPIC_API_KEY"
|
||||
ENV_OPENAI_API_KEY = "QUERCUS_OPENAI_API_KEY"
|
||||
ENV_OPENAI_BASE_URL = "QUERCUS_OPENAI_BASE_URL"
|
||||
|
||||
# QUERCUS_HOME 仅用于测试/CI 覆盖 ~ 的解析,与配置密钥无关。
|
||||
ENV_QUERCUS_HOME = "QUERCUS_HOME"
|
||||
|
||||
# 配置文件键(与 pyproject 无关,纯用户配置)。
|
||||
_KEY_ANTHROPIC = "anthropic_api_key"
|
||||
_KEY_OPENAI = "openai_api_key"
|
||||
_KEY_OPENAI_BASE_URL = "openai_base_url"
|
||||
|
||||
_CONFIG_KEYS = (_KEY_ANTHROPIC, _KEY_OPENAI, _KEY_OPENAI_BASE_URL)
|
||||
|
||||
# 环境变量 -> 配置键 的覆盖表(保持唯一来源)。
|
||||
_ENV_OVERRIDES = {
|
||||
ENV_ANTHROPIC_API_KEY: _KEY_ANTHROPIC,
|
||||
ENV_OPENAI_API_KEY: _KEY_OPENAI,
|
||||
ENV_OPENAI_BASE_URL: _KEY_OPENAI_BASE_URL,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""合并后的配置;密钥字段可为 None 表示未配置。"""
|
||||
|
||||
anthropic_api_key: str | None = None
|
||||
openai_api_key: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
path: Path | None = None # 来源文件(若存在)
|
||||
|
||||
@property
|
||||
def has_anthropic_key(self) -> bool:
|
||||
return bool(self.anthropic_api_key)
|
||||
|
||||
@property
|
||||
def has_openai_key(self) -> bool:
|
||||
return bool(self.openai_api_key)
|
||||
|
||||
|
||||
def config_home() -> Path:
|
||||
"""``~/.quercus`` 目录(QUERCUS_HOME 仅供测试覆盖 HOME)。"""
|
||||
base = os.environ.get(ENV_QUERCUS_HOME) or os.path.expanduser("~")
|
||||
return Path(base) / ".quercus"
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
return config_home() / "config.toml"
|
||||
|
||||
|
||||
def default_sessions_dir() -> Path:
|
||||
return config_home() / "sessions"
|
||||
|
||||
|
||||
def _enforce_0600(path: Path) -> None:
|
||||
"""把文件权限收敛为 0600(读与写都做,自愈)。"""
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def load_config(
|
||||
path: Path | None = None, env: dict[str, str] | None = None
|
||||
) -> Config:
|
||||
"""读取配置并叠加环境变量覆盖(环境变量 > 配置文件)。"""
|
||||
path = Path(path) if path else default_config_path()
|
||||
cfg = Config(path=path)
|
||||
if path.exists():
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
cfg.anthropic_api_key = data.get(_KEY_ANTHROPIC)
|
||||
cfg.openai_api_key = data.get(_KEY_OPENAI)
|
||||
cfg.openai_base_url = data.get(_KEY_OPENAI_BASE_URL)
|
||||
_enforce_0600(path)
|
||||
env = os.environ if env is None else env
|
||||
for env_key, cfg_key in _ENV_OVERRIDES.items():
|
||||
value = env.get(env_key)
|
||||
if value:
|
||||
setattr(cfg, cfg_key, value)
|
||||
return cfg
|
||||
|
||||
|
||||
def _dump_toml(cfg: Config) -> str:
|
||||
"""最小 TOML 写入器:扁平字符串键;None 值不落盘(读取时缺省为 None)。
|
||||
|
||||
TOML 无 null 字面量,故跳过未配置的键。
|
||||
"""
|
||||
lines = ["# Quercus 配置(权限 0600)。环境变量可覆盖以下键。"]
|
||||
for key in _CONFIG_KEYS:
|
||||
value = getattr(cfg, key)
|
||||
if value is not None:
|
||||
lines.append(f"{key} = {json.dumps(value)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def save_config(cfg: Config, path: Path | None = None) -> Path:
|
||||
"""写入配置(目录自建、权限 0600)。"""
|
||||
path = Path(path) if path else default_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(_dump_toml(cfg))
|
||||
_enforce_0600(path)
|
||||
return path
|
||||
|
||||
|
||||
def config_summary(cfg: Config) -> dict[str, str | bool]:
|
||||
"""只报告"配了哪些 provider",绝不泄露密钥值(用于日志/面板状态)。"""
|
||||
return {
|
||||
"anthropic_configured": cfg.has_anthropic_key,
|
||||
"openai_configured": cfg.has_openai_key,
|
||||
"openai_base_url": cfg.openai_base_url if cfg.openai_base_url else "(默认)",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"""HostAdapter 抽象接口(§3.1)。P1 只定义 ABC,不提供实现。"""
|
||||
from quercus_core.host.adapter import HostAdapter
|
||||
|
||||
__all__ = ["HostAdapter"]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""HostAdapter 抽象基类(计划文档 §3.1 接口形状)。
|
||||
|
||||
P1 只定义方法签名与 docstring,不含实现;MockHostAdapter 与真实宿主
|
||||
适配层(Resolve / Premiere / Oak)属 P2+。方法族刻意对齐 OPP/1 §8,
|
||||
使未来 Oak 适配层只是 oakxp SDK 的薄封装。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from quercus_core.types import (
|
||||
ActionBatch,
|
||||
Capability,
|
||||
Limits,
|
||||
PngBytes,
|
||||
Rational,
|
||||
Size,
|
||||
TimeRange,
|
||||
)
|
||||
from quercus_core.host.types import (
|
||||
BatchResult,
|
||||
FootageId,
|
||||
JobHandle,
|
||||
Levels,
|
||||
MediaInfo,
|
||||
PlaybackState,
|
||||
ProjectOverview,
|
||||
SeqId,
|
||||
Target,
|
||||
Timeline,
|
||||
)
|
||||
|
||||
|
||||
class HostAdapter(ABC):
|
||||
"""core 操作宿主的唯一边界(设计铁律 1:协议/接口是唯一边界)。"""
|
||||
|
||||
# ---- 会话与能力 ----
|
||||
|
||||
@abstractmethod
|
||||
def capabilities(self) -> set[Capability]:
|
||||
"""返回宿主能力位集合(如 PROJECT_READ / TIMELINE_EDIT)。"""
|
||||
|
||||
@abstractmethod
|
||||
def limits(self) -> Limits:
|
||||
"""自报资源预算:取帧分辨率/速率上限、轮询间隔、快照数量上限。"""
|
||||
|
||||
# ---- 工程 / 媒体 / 时间线 ----
|
||||
|
||||
@abstractmethod
|
||||
def open_project(self, path: str) -> None:
|
||||
"""打开(或新建)工程。"""
|
||||
|
||||
@abstractmethod
|
||||
def save_project(self, path: str | None = None) -> None:
|
||||
"""保存工程,可另存到 path。"""
|
||||
|
||||
@abstractmethod
|
||||
def get_project_overview(self) -> ProjectOverview:
|
||||
"""一次返回工程概览(序列/轨道/块树)。"""
|
||||
|
||||
@abstractmethod
|
||||
def probe_media(self, path: str) -> MediaInfo:
|
||||
"""探测媒体文件元数据。"""
|
||||
|
||||
@abstractmethod
|
||||
def import_footage(self, paths: list[str]) -> list[FootageId]:
|
||||
"""导入素材到媒体池,返回不透明 id。"""
|
||||
|
||||
@abstractmethod
|
||||
def get_timeline_structure(self, seq: SeqId) -> Timeline:
|
||||
"""获取时间线结构。"""
|
||||
|
||||
# ---- 取帧(视觉闭环生死通路) ----
|
||||
|
||||
@abstractmethod
|
||||
def get_frame(self, target: Target, time: Rational, max_size: Size) -> PngBytes:
|
||||
"""按时间点取一帧,返回 PNG 字节(core 不解析像素,只流转)。"""
|
||||
|
||||
@abstractmethod
|
||||
def get_thumbnails(
|
||||
self, target: Target, range: TimeRange, count: int
|
||||
) -> list[PngBytes]:
|
||||
"""在时间范围内等间隔采样多帧(contact sheet 输入)。"""
|
||||
|
||||
@abstractmethod
|
||||
def get_audio_levels(self, seq: SeqId, range: TimeRange, resolution: int) -> Levels:
|
||||
"""获取时间段内音频电平数据。"""
|
||||
|
||||
# ---- 回放 / 导出 ----
|
||||
|
||||
@abstractmethod
|
||||
def play(self, speed: float = 1.0) -> None:
|
||||
"""开始回放(可指定倍速)。"""
|
||||
|
||||
@abstractmethod
|
||||
def pause(self) -> None:
|
||||
"""暂停回放。"""
|
||||
|
||||
@abstractmethod
|
||||
def seek(self, time: Rational) -> None:
|
||||
"""移动播放头。"""
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self) -> PlaybackState:
|
||||
"""读取回放状态(播放中/播放头位置)。"""
|
||||
|
||||
@abstractmethod
|
||||
def export(self, seq: SeqId, output: str, preset: str) -> JobHandle:
|
||||
"""启动导出任务,进度经回调上报。"""
|
||||
|
||||
# ---- 编辑(ActionBatch 语义,计划文档 §3.3) ----
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, batch: ActionBatch) -> BatchResult:
|
||||
"""执行整段 ActionBatch(宿主有事务用事务,无事务用快照+补偿)。"""
|
||||
|
||||
@abstractmethod
|
||||
def undo_last(self) -> None:
|
||||
"""撤销上一个 AI 动作(仅用户明确要求时)。"""
|
||||
@@ -0,0 +1,110 @@
|
||||
"""HostAdapter 接口依赖的宿主侧领域类型(§3.1)。
|
||||
|
||||
P1 只定义形状;MockHostAdapter 与真实适配层属 P2/P3+。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from quercus_core.types import EntityId, PngBytes, Rational, TimeRange, ToolResult
|
||||
|
||||
# 素材 / 序列的不透明 id。
|
||||
FootageId = EntityId
|
||||
SeqId = EntityId
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectOverview:
|
||||
"""工程概览:一次返回序列/轨道/块树,供 LLM 建立上下文。"""
|
||||
|
||||
project_id: str
|
||||
name: str
|
||||
fps: float
|
||||
timeline_ids: list[SeqId] = field(default_factory=list)
|
||||
duration: Rational | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
"""媒体探测结果。"""
|
||||
|
||||
path: str
|
||||
duration: Rational | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
codec: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Clip:
|
||||
"""时间线上的一个片段(块)。"""
|
||||
|
||||
id: EntityId
|
||||
name: str
|
||||
source: str | None = None
|
||||
start: Rational = Rational(0)
|
||||
duration: Rational = Rational(0)
|
||||
in_point: Rational | None = None
|
||||
out_point: Rational | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
"""时间线轨道。"""
|
||||
|
||||
index: int
|
||||
kind: str = "video" # video | audio | subtitle
|
||||
clips: list[Clip] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Timeline:
|
||||
"""时间线结构。"""
|
||||
|
||||
id: SeqId
|
||||
name: str
|
||||
fps: float
|
||||
duration: Rational = Rational(0)
|
||||
tracks: list[Track] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""取帧目标:时间线或片段。"""
|
||||
|
||||
kind: str # "timeline" | "clip"
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Levels:
|
||||
"""音频电平数据(等间隔采样,单位 dB)。"""
|
||||
|
||||
values: list[float] = field(default_factory=list)
|
||||
min_db: float = -60.0
|
||||
max_db: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobHandle:
|
||||
"""导出任务句柄;进度经回调上报。"""
|
||||
|
||||
job_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchResult:
|
||||
"""ActionBatch 执行结果(编辑侧)。"""
|
||||
|
||||
ok: bool
|
||||
message: str = ""
|
||||
results: list[ToolResult] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaybackState:
|
||||
"""回放状态。"""
|
||||
|
||||
playing: bool = False
|
||||
position: Rational = Rational(0)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Provider 注册表:报告可用性(无 key 优雅降级)并按名创建 provider。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from quercus_core.config import Config
|
||||
from quercus_core.providers.base import LLMProvider, ProviderUnavailable
|
||||
from quercus_core.providers.claude import DEFAULT_CLAUDE_MODEL, ClaudeProvider
|
||||
from quercus_core.providers.openai_compat import (
|
||||
DEFAULT_OPENAI_BASE_URL,
|
||||
DEFAULT_OPENAI_MODEL,
|
||||
OpenAICompatProvider,
|
||||
)
|
||||
|
||||
CLAUDE = "claude"
|
||||
OPENAI_COMPAT = "openai_compat"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderInfo:
|
||||
"""注册表条目:可用性 + 原因(原因不含密钥值)。"""
|
||||
|
||||
name: str
|
||||
available: bool
|
||||
reason: str = ""
|
||||
model: str = ""
|
||||
|
||||
|
||||
def provider_status(config: Config) -> dict[str, ProviderInfo]:
|
||||
"""返回各 provider 的可用状态,供面板/CLI 优雅降级展示。"""
|
||||
return {
|
||||
CLAUDE: ProviderInfo(
|
||||
name=CLAUDE,
|
||||
available=config.has_anthropic_key,
|
||||
reason="" if config.has_anthropic_key else f"{config.path or 'config'}: 未配置 QUERCUS_ANTHROPIC_API_KEY",
|
||||
model=DEFAULT_CLAUDE_MODEL if config.has_anthropic_key else "",
|
||||
),
|
||||
OPENAI_COMPAT: ProviderInfo(
|
||||
name=OPENAI_COMPAT,
|
||||
available=config.has_openai_key,
|
||||
reason="" if config.has_openai_key else "未配置 QUERCUS_OPENAI_API_KEY",
|
||||
model=DEFAULT_OPENAI_MODEL if config.has_openai_key else "",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_provider(
|
||||
name: str,
|
||||
config: Config,
|
||||
*,
|
||||
http_client: httpx.Client | None = None,
|
||||
) -> LLMProvider:
|
||||
"""按名创建 provider;未配置密钥时抛 ProviderUnavailable。"""
|
||||
if name == CLAUDE:
|
||||
if not config.has_anthropic_key:
|
||||
raise ProviderUnavailable("claude: 未配置 QUERCUS_ANTHROPIC_API_KEY")
|
||||
return ClaudeProvider(
|
||||
api_key=config.anthropic_api_key or "", http_client=http_client
|
||||
)
|
||||
if name == OPENAI_COMPAT:
|
||||
if not config.has_openai_key:
|
||||
raise ProviderUnavailable("openai_compat: 未配置 QUERCUS_OPENAI_API_KEY")
|
||||
return OpenAICompatProvider(
|
||||
api_key=config.openai_api_key or "",
|
||||
base_url=config.openai_base_url or DEFAULT_OPENAI_BASE_URL,
|
||||
client=http_client,
|
||||
)
|
||||
raise ValueError(f"未知 provider: {name!r}(可选: {CLAUDE}, {OPENAI_COMPAT})")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""LLMProvider 抽象与消息/工具调用类型。
|
||||
|
||||
输入:消息(文本 + PNG 图片);输出:文本 + tool_calls(设计文档 §5.1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from quercus_core.types import PngBytes
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""provider 调用失败(网络、协议、限额等)。"""
|
||||
|
||||
|
||||
class ProviderUnavailable(ProviderError):
|
||||
"""provider 因缺少密钥等配置不可用。"""
|
||||
|
||||
|
||||
# 消息角色常量(避免魔法串)。
|
||||
class Role:
|
||||
USER = "user"
|
||||
ASSISTANT = "assistant"
|
||||
TOOL = "tool"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Message:
|
||||
"""对话消息;``role="tool"`` 时经 tool_call_id 关联对应的 tool_call。
|
||||
|
||||
``images`` 为 PNG 字节(core 内唯一的图片载体)。
|
||||
"""
|
||||
|
||||
role: str
|
||||
text: str = ""
|
||||
images: tuple[PngBytes, ...] = ()
|
||||
tool_call_id: str | None = None # role="tool" 时必须指向某次 tool_call
|
||||
tool_calls: tuple[ToolCall, ...] = () # role="assistant" 时携带已发出的 tool_call
|
||||
name: str | None = None # 工具名(role="tool" 时)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCall:
|
||||
"""模型发起的工具调用。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolSchema:
|
||||
"""暴露给 LLM 的工具声明(name/description/JSON Schema parameters)。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any] # JSON Schema 对象(有理秒用 {num, den})
|
||||
read_only: bool = False # 只读工具可自动执行,变更类进待确认清单
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantTurn:
|
||||
"""provider 单次生成的回复:文本 + 若干 tool_calls。"""
|
||||
|
||||
text: str = ""
|
||||
tool_calls: tuple[ToolCall, ...] = ()
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
"""LLM 后端统一接口。实现须为同步、可注入 HTTP 客户端(便于测试)。"""
|
||||
|
||||
name: str = "provider"
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSchema] | None = None,
|
||||
) -> AssistantTurn:
|
||||
"""给定消息历史(可含 PNG 图片)与工具声明,返回文本与 tool_calls。"""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Claude(anthropic SDK)后端:多模态图片消息 + tool use。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from quercus_core.providers.base import (
|
||||
AssistantTurn,
|
||||
LLMProvider,
|
||||
Message,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolSchema,
|
||||
)
|
||||
|
||||
DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-5"
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError: # pragma: no cover - 依赖缺失时给出明确错误
|
||||
anthropic = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class ClaudeProvider(LLMProvider):
|
||||
"""经 anthropic SDK 调用 Claude Messages API(图片用 base64 image 块)。"""
|
||||
|
||||
name = "claude"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
model: str = DEFAULT_CLAUDE_MODEL,
|
||||
max_tokens: int = 4096,
|
||||
http_client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
if anthropic is None:
|
||||
raise ImportError("缺少依赖 anthropic,请先 uv pip install anthropic")
|
||||
self._model = model
|
||||
self._max_tokens = max_tokens
|
||||
self._client = anthropic.Anthropic(api_key=api_key, http_client=http_client)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSchema] | None = None,
|
||||
) -> AssistantTurn:
|
||||
system = "\n".join(m.text for m in messages if m.role == Role.SYSTEM)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"max_tokens": self._max_tokens,
|
||||
"messages": self._to_anthropic_messages(messages),
|
||||
}
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
if tools:
|
||||
kwargs["tools"] = [self._to_tool(t) for t in tools]
|
||||
resp = self._client.messages.create(**kwargs)
|
||||
text = "".join(b.text for b in resp.content if b.type == "text")
|
||||
calls = tuple(
|
||||
ToolCall(id=b.id, name=b.name, arguments=dict(b.input))
|
||||
for b in resp.content
|
||||
if b.type == "tool_use"
|
||||
)
|
||||
return AssistantTurn(text=text, tool_calls=calls)
|
||||
|
||||
# ---- 转换 ----
|
||||
|
||||
def _to_tool(self, t: ToolSchema) -> dict[str, Any]:
|
||||
return {"name": t.name, "description": t.description, "input_schema": t.parameters}
|
||||
|
||||
@staticmethod
|
||||
def _image_block(png: bytes) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": base64.b64encode(png).decode("ascii"),
|
||||
},
|
||||
}
|
||||
|
||||
def _to_anthropic_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
pending_tool_results: list[dict[str, Any]] | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal pending_tool_results
|
||||
if pending_tool_results is not None:
|
||||
out.append({"role": Role.USER, "content": pending_tool_results})
|
||||
pending_tool_results = None
|
||||
|
||||
for m in messages:
|
||||
if m.role == Role.SYSTEM:
|
||||
continue
|
||||
if m.role == Role.TOOL:
|
||||
if pending_tool_results is None:
|
||||
pending_tool_results = []
|
||||
content: list[dict[str, Any]] = []
|
||||
if m.text:
|
||||
content.append({"type": "text", "text": m.text})
|
||||
for img in m.images:
|
||||
content.append(self._image_block(img))
|
||||
pending_tool_results.append(
|
||||
{"type": "tool_result", "tool_use_id": m.tool_call_id, "content": content}
|
||||
)
|
||||
continue
|
||||
flush()
|
||||
if m.role == Role.USER:
|
||||
blocks: list[dict[str, Any]] = []
|
||||
if m.text:
|
||||
blocks.append({"type": "text", "text": m.text})
|
||||
for img in m.images:
|
||||
blocks.append(self._image_block(img))
|
||||
out.append({"role": Role.USER, "content": blocks})
|
||||
elif m.role == Role.ASSISTANT:
|
||||
blocks = []
|
||||
if m.text:
|
||||
blocks.append({"type": "text", "text": m.text})
|
||||
for tc in m.tool_calls:
|
||||
blocks.append(
|
||||
{"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments}
|
||||
)
|
||||
out.append({"role": Role.ASSISTANT, "content": blocks})
|
||||
flush()
|
||||
return out
|
||||
@@ -0,0 +1,163 @@
|
||||
"""OpenAI 兼容 ``/v1/chat/completions`` 后端。
|
||||
|
||||
同一套通路覆盖:官方 OpenAI、自定义网关/企业代理、llama.cpp 本地 server
|
||||
(OpenAI 兼容接口,不直接绑定 llama.cpp C API)。HTTP 层直接用 httpx,
|
||||
不引入 openai SDK,保持依赖最小。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from quercus_core.providers.base import (
|
||||
AssistantTurn,
|
||||
LLMProvider,
|
||||
Message,
|
||||
ProviderError,
|
||||
Role,
|
||||
ToolCall,
|
||||
ToolSchema,
|
||||
)
|
||||
|
||||
DEFAULT_OPENAI_MODEL = "gpt-4o"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
class OpenAICompatProvider(LLMProvider):
|
||||
name = "openai_compat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str = DEFAULT_OPENAI_BASE_URL,
|
||||
model: str = DEFAULT_OPENAI_MODEL,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._model = model
|
||||
# 外部注入的 client(如 MockTransport)自带 base_url,勿覆盖其配置。
|
||||
self._client = client or httpx.Client(timeout=60.0)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
return f"{self._base_url}/chat/completions"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSchema] | None = None,
|
||||
) -> AssistantTurn:
|
||||
payload: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": self._to_openai_messages(messages),
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.parameters,
|
||||
},
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
payload["tool_choice"] = "auto"
|
||||
resp = self._client.post(
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"OpenAI 兼容端点返回 {resp.status_code}: {resp.text[:300]!r}"
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
choice = data["choices"][0]
|
||||
message = choice.get("message", {})
|
||||
except (KeyError, IndexError, ValueError) as exc:
|
||||
raise ProviderError(f"无法解析 OpenAI 兼容响应: {exc}") from exc
|
||||
text = self._extract_text(message.get("content"))
|
||||
calls = tuple(self._parse_tool_call(tc) for tc in message.get("tool_calls") or [])
|
||||
return AssistantTurn(text=text, tool_calls=calls)
|
||||
|
||||
# ---- 解析 ----
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list): # 新规范:content 为 content-parts 列表
|
||||
parts = [p.get("text", "") for p in content if isinstance(p, dict)]
|
||||
return "".join(parts)
|
||||
return str(content)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_call(tc: dict[str, Any]) -> ToolCall:
|
||||
fn = tc.get("function", {}) or {}
|
||||
raw = fn.get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
return ToolCall(id=tc.get("id", ""), name=fn.get("name", ""), arguments=args)
|
||||
|
||||
# ---- 转换 ----
|
||||
|
||||
@staticmethod
|
||||
def _image_url_part(png: bytes) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}"
|
||||
},
|
||||
}
|
||||
|
||||
def _to_openai_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages:
|
||||
if m.role == Role.SYSTEM:
|
||||
out.append({"role": Role.SYSTEM, "content": m.text})
|
||||
elif m.role == Role.USER:
|
||||
parts: list[dict[str, Any]] = []
|
||||
if m.text:
|
||||
parts.append({"type": "text", "text": m.text})
|
||||
for img in m.images:
|
||||
parts.append(self._image_url_part(img))
|
||||
out.append({"role": Role.USER, "content": parts})
|
||||
elif m.role == Role.ASSISTANT:
|
||||
msg: dict[str, Any] = {"role": Role.ASSISTANT, "content": m.text or None}
|
||||
if m.tool_calls:
|
||||
msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": json.dumps(tc.arguments),
|
||||
},
|
||||
}
|
||||
for tc in m.tool_calls
|
||||
]
|
||||
out.append(msg)
|
||||
elif m.role == Role.TOOL:
|
||||
out.append(
|
||||
{"role": Role.TOOL, "tool_call_id": m.tool_call_id, "content": m.text}
|
||||
)
|
||||
# OpenAI tool 消息只允许文本;帧图以随后的 user 消息回喂,
|
||||
# 维持视觉闭环(llama.cpp 等本地端点不支持图片时可忽略此步)。
|
||||
if m.images:
|
||||
parts = [self._image_url_part(img) for img in m.images]
|
||||
if m.text:
|
||||
parts.insert(0, {"type": "text", "text": f"工具 {m.name} 返回的帧图"})
|
||||
out.append({"role": Role.USER, "content": parts})
|
||||
return out
|
||||
@@ -0,0 +1,4 @@
|
||||
"""会话状态与回放日志。"""
|
||||
from quercus_core.session.log import SessionEvent, SessionLog, frame_sha256, replay
|
||||
|
||||
__all__ = ["SessionEvent", "SessionLog", "frame_sha256", "replay"]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""会话状态与回放:事件 JSONL 落盘 + ``replay`` 读回(测试夹具同格式)。
|
||||
|
||||
- 落盘位置:``~/.quercus/sessions/<id>.jsonl``。
|
||||
- 记录:user/assistant 消息、tool_call、tool_result 摘要、帧图 sha256 哈希
|
||||
(不落图片本体,计划文档 §7.5)。
|
||||
- 密钥绝不入日志;本模块只接收摘要文本,不接触配置密钥。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from quercus_core.config import default_sessions_dir
|
||||
from quercus_core.providers.base import AssistantTurn
|
||||
from quercus_core.types import ActionBatch, PngBytes, ToolResult
|
||||
|
||||
_SUMMARY_MAX = 2000
|
||||
|
||||
|
||||
def frame_sha256(png: PngBytes) -> str:
|
||||
"""帧图 sha256 十六进制(仅记录哈希,不落图片本体)。"""
|
||||
return hashlib.sha256(png).hexdigest()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionEvent:
|
||||
"""一条会话事件。payload 为该事件的自有字段。"""
|
||||
|
||||
session_id: str
|
||||
kind: str
|
||||
at: str
|
||||
payload: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"session_id": self.session_id, "kind": self.kind, "at": self.at, **self.payload}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> SessionEvent:
|
||||
reserved = {"session_id", "kind", "at"}
|
||||
payload = {k: v for k, v in data.items() if k not in reserved}
|
||||
return cls(
|
||||
session_id=data["session_id"],
|
||||
kind=data["kind"],
|
||||
at=data["at"],
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
class SessionLog:
|
||||
"""追加式 JSONL 会话日志。"""
|
||||
|
||||
def __init__(self, path: Path | str, session_id: str | None = None) -> None:
|
||||
self.path = Path(path)
|
||||
self.session_id = session_id or uuid.uuid4().hex
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def create_default(cls) -> SessionLog:
|
||||
"""在默认会话目录新建一个会话日志。"""
|
||||
return cls(default_sessions_dir() / f"{uuid.uuid4().hex}.jsonl")
|
||||
|
||||
# ---- 事件 ----
|
||||
|
||||
def _append(self, kind: str, **payload) -> None:
|
||||
event = SessionEvent(
|
||||
session_id=self.session_id, kind=kind, at=_now(), payload=payload
|
||||
)
|
||||
with open(self.path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
|
||||
|
||||
def log_user(self, text: str) -> None:
|
||||
self._append("user_message", text=text)
|
||||
|
||||
def log_assistant(self, turn: AssistantTurn) -> None:
|
||||
calls = [
|
||||
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
|
||||
for tc in turn.tool_calls
|
||||
]
|
||||
self._append("assistant_turn", text=turn.text, tool_calls=calls)
|
||||
|
||||
def log_tool_result(self, result: ToolResult) -> None:
|
||||
self._append(
|
||||
"tool_result",
|
||||
tool=result.tool,
|
||||
ok=result.ok,
|
||||
summary=result.summary[:_SUMMARY_MAX],
|
||||
image_hashes=[frame_sha256(img) for img in result.images],
|
||||
)
|
||||
|
||||
def log_batch_decision(self, batch: ActionBatch, approved: bool) -> None:
|
||||
self._append(
|
||||
"batch_decision",
|
||||
batch_id=batch.id,
|
||||
label=batch.label,
|
||||
approved=approved,
|
||||
action_count=len(batch.actions),
|
||||
)
|
||||
|
||||
|
||||
def replay(path: Path | str) -> list[SessionEvent]:
|
||||
"""读回会话日志(每行一条 JSON 事件)。"""
|
||||
events: list[SessionEvent] = []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
events.append(SessionEvent.from_dict(json.loads(line)))
|
||||
return events
|
||||
@@ -0,0 +1,4 @@
|
||||
"""工具 schema 注册表与只读判定。"""
|
||||
from quercus_core.tools.schemas import TOOLS, TOOL_LIST, get_tool, tool_read_only
|
||||
|
||||
__all__ = ["TOOLS", "TOOL_LIST", "get_tool", "tool_read_only"]
|
||||
@@ -0,0 +1,330 @@
|
||||
"""工具 schema 注册表:约 26 个策展工具的 JSON Schema(设计文档 §3 表)。
|
||||
|
||||
每个工具标注 ``read_only``:只读工具(get_*/probe/list/scan/play/pause/seek)
|
||||
可自动执行;变更类工具整段进"待确认清单"(计划文档 §3.2)。
|
||||
有理秒参数一律用 ``{num, den}`` 对象(决策 D5)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quercus_core.providers.base import ToolSchema
|
||||
|
||||
# 有理秒参数的 JSON Schema 片段。
|
||||
RATIONAL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {"num": {"type": "integer"}, "den": {"type": "integer"}},
|
||||
"required": ["num", "den"],
|
||||
"additionalProperties": False,
|
||||
"description": "有理秒,num/den",
|
||||
}
|
||||
|
||||
TIME_RANGE_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {"start": RATIONAL_SCHEMA, "end": RATIONAL_SCHEMA},
|
||||
"required": ["start", "end"],
|
||||
"additionalProperties": False,
|
||||
"description": "闭区间时间段(有理秒)",
|
||||
}
|
||||
|
||||
|
||||
def _t(
|
||||
name: str,
|
||||
description: str,
|
||||
parameters: dict,
|
||||
read_only: bool,
|
||||
) -> ToolSchema:
|
||||
return ToolSchema(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
|
||||
def _obj(properties: dict, required: list[str] | None = None) -> dict:
|
||||
"""构造 JSON Schema 对象:type=object + properties + required。"""
|
||||
schema: dict = {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
if required:
|
||||
schema["required"] = required
|
||||
return schema
|
||||
|
||||
|
||||
TOOLS: dict[str, ToolSchema] = {}
|
||||
|
||||
|
||||
def _reg(tool: ToolSchema) -> None:
|
||||
TOOLS[tool.name] = tool
|
||||
|
||||
|
||||
# ---- 工程 ----
|
||||
_reg(_t(
|
||||
"open_project",
|
||||
"打开(或新建)一个工程。变更类工具。",
|
||||
_obj({"path": {"type": "string", "description": "工程路径;省略则为默认工程"}}, ["path"]),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"save_project",
|
||||
"保存当前工程。变更类工具。",
|
||||
_obj({"path": {"type": "string", "description": "另存路径(可选)"}}),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"get_project_overview",
|
||||
"获取工程概览:序列/轨道/块树,供 LLM 建立上下文。只读。",
|
||||
_obj({}),
|
||||
read_only=True,
|
||||
))
|
||||
|
||||
# ---- 媒体 ----
|
||||
_reg(_t(
|
||||
"probe_media",
|
||||
"探测媒体文件的时长/分辨率/帧率等信息。只读。",
|
||||
_obj({"path": {"type": "string"}}, ["path"]),
|
||||
read_only=True,
|
||||
))
|
||||
_reg(_t(
|
||||
"import_footage",
|
||||
"把媒体文件导入工程媒体池。变更类工具。",
|
||||
_obj({"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1}}, ["paths"]),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"list_footage",
|
||||
"列出媒体池中的素材。只读。",
|
||||
_obj({}),
|
||||
read_only=True,
|
||||
))
|
||||
|
||||
# ---- 时间线 ----
|
||||
_reg(_t(
|
||||
"add_track",
|
||||
"向时间线添加轨道。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"type": {"type": "string", "enum": ["video", "audio", "subtitle"]},
|
||||
"index": {"type": "integer", "description": "目标轨道序号(可选)"},
|
||||
},
|
||||
["type"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"place_clip",
|
||||
"把素材放到指定轨道与时间点。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"clip_id": {"type": "string"},
|
||||
"track_index": {"type": "integer"},
|
||||
"time": RATIONAL_SCHEMA,
|
||||
"in_point": RATIONAL_SCHEMA,
|
||||
"out_point": RATIONAL_SCHEMA,
|
||||
},
|
||||
["clip_id", "track_index", "time"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"split_clip",
|
||||
"在给定时间点把片段一分为二。变更类工具。",
|
||||
_obj({"clip_id": {"type": "string"}, "time": RATIONAL_SCHEMA}, ["clip_id", "time"]),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"trim_clip",
|
||||
"调整片段入点/出点或时长。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"clip_id": {"type": "string"},
|
||||
"in_point": RATIONAL_SCHEMA,
|
||||
"out_point": RATIONAL_SCHEMA,
|
||||
"duration": RATIONAL_SCHEMA,
|
||||
},
|
||||
["clip_id"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"move_clip",
|
||||
"把片段移动到另一轨道/时间点。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"clip_id": {"type": "string"},
|
||||
"track_index": {"type": "integer"},
|
||||
"time": RATIONAL_SCHEMA,
|
||||
},
|
||||
["clip_id", "track_index", "time"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"ripple_delete",
|
||||
"波纹删除片段(删除后闭合空隙)。变更类工具,破坏性操作。",
|
||||
_obj(
|
||||
{
|
||||
"clip_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
"range": TIME_RANGE_SCHEMA,
|
||||
},
|
||||
["clip_ids"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"add_transition",
|
||||
"为片段添加转场。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"clip_id": {"type": "string"},
|
||||
"transition_type": {"type": "string"},
|
||||
"duration": RATIONAL_SCHEMA,
|
||||
},
|
||||
["clip_id", "transition_type", "duration"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"add_marker",
|
||||
"在时间线添加标记(可携带 AI 元数据)。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"time": RATIONAL_SCHEMA,
|
||||
"label": {"type": "string"},
|
||||
"color": {"type": "string", "enum": ["red", "orange", "yellow", "green", "blue", "violet"]},
|
||||
"custom_data": {"type": "object"},
|
||||
},
|
||||
["time"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
|
||||
# ---- 效果 / 关键帧 ----
|
||||
_reg(_t(
|
||||
"add_effect",
|
||||
"为片段添加效果。变更类工具。",
|
||||
_obj(
|
||||
{"clip_id": {"type": "string"}, "effect_type": {"type": "string"}, "preset": {"type": "string"}},
|
||||
["clip_id", "effect_type"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"set_param",
|
||||
"设置效果参数(数值/字符串/布尔)。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"effect_id": {"type": "string"},
|
||||
"param": {"type": "string"},
|
||||
"value": {"oneOf": [{"type": "number"}, {"type": "string"}, {"type": "boolean"}]},
|
||||
},
|
||||
["effect_id", "param", "value"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"set_keyframe",
|
||||
"为效果参数在指定时间点打关键帧。变更类工具。",
|
||||
_obj(
|
||||
{
|
||||
"effect_id": {"type": "string"},
|
||||
"param": {"type": "string"},
|
||||
"time": RATIONAL_SCHEMA,
|
||||
"value": {"type": "number"},
|
||||
},
|
||||
["effect_id", "param", "time", "value"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"list_effects",
|
||||
"列出可用效果类型(或某片段已用效果)。只读。",
|
||||
_obj({"clip_id": {"type": "string"}, "category": {"type": "string"}}),
|
||||
read_only=True,
|
||||
))
|
||||
|
||||
# ---- 取帧(视觉闭环口) ----
|
||||
_reg(_t(
|
||||
"get_frame",
|
||||
"按时间点取一帧,返回 PNG 图给模型看图判断。只读。",
|
||||
_obj(
|
||||
{
|
||||
"time": RATIONAL_SCHEMA,
|
||||
"max_size": {
|
||||
"type": "object",
|
||||
"properties": {"width": {"type": "integer"}, "height": {"type": "integer"}},
|
||||
"required": ["width", "height"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"target": {"type": "string", "description": "目标对象 id(缺省为当前时间线)"},
|
||||
},
|
||||
["time", "max_size"],
|
||||
),
|
||||
read_only=True,
|
||||
))
|
||||
_reg(_t(
|
||||
"scan_timeline",
|
||||
"在时间范围等间隔采样 n 帧,拼成 contact sheet 回喂。只读。",
|
||||
_obj(
|
||||
{"range": TIME_RANGE_SCHEMA, "count": {"type": "integer", "minimum": 1, "maximum": 64}},
|
||||
["range", "count"],
|
||||
),
|
||||
read_only=True,
|
||||
))
|
||||
_reg(_t(
|
||||
"get_audio_levels",
|
||||
"获取时间段内的音频电平数据。只读。",
|
||||
_obj({"range": TIME_RANGE_SCHEMA, "resolution": {"type": "integer", "minimum": 1}}),
|
||||
read_only=True,
|
||||
))
|
||||
|
||||
# ---- 回放 ----
|
||||
_reg(_t(
|
||||
"play",
|
||||
"播放(可指定倍速)。只读(不改动编辑内容)。",
|
||||
_obj({"speed": {"type": "number", "description": "倍速,1.0 为正常"}}),
|
||||
read_only=True,
|
||||
))
|
||||
_reg(_t(
|
||||
"pause",
|
||||
"暂停回放。只读。",
|
||||
_obj({}),
|
||||
read_only=True,
|
||||
))
|
||||
_reg(_t(
|
||||
"seek",
|
||||
"移动播放头到指定时间。只读。",
|
||||
_obj({"time": RATIONAL_SCHEMA}, ["time"]),
|
||||
read_only=True,
|
||||
))
|
||||
|
||||
# ---- 导出 / 撤销 ----
|
||||
_reg(_t(
|
||||
"export_render",
|
||||
"按预设导出渲染。变更类工具(确认类)。",
|
||||
_obj(
|
||||
{"output": {"type": "string"}, "preset": {"type": "string"}},
|
||||
["output"],
|
||||
),
|
||||
read_only=False,
|
||||
))
|
||||
_reg(_t(
|
||||
"undo_last_action",
|
||||
"撤销上一个 AI 动作(仅用户明确要求时)。变更类工具。",
|
||||
_obj({}),
|
||||
read_only=False,
|
||||
))
|
||||
|
||||
# 保持注册顺序稳定(列表形态)。
|
||||
TOOL_LIST: list[ToolSchema] = list(TOOLS.values())
|
||||
|
||||
|
||||
def get_tool(name: str) -> ToolSchema | None:
|
||||
return TOOLS.get(name)
|
||||
|
||||
|
||||
def tool_read_only(name: str) -> bool:
|
||||
"""变更类工具是否需要确认门。未知工具保守视为变更类。"""
|
||||
tool = TOOLS.get(name)
|
||||
return tool.read_only if tool else False
|
||||
@@ -0,0 +1,231 @@
|
||||
"""核心类型:有理秒时间模型、不透明 id、能力与限制、ActionBatch。
|
||||
|
||||
全系统唯一时间表示是有理秒 ``Rational``(num/den),宿主侧的帧号/ticks
|
||||
一律在适配层换算(计划文档 §2.3 决策 D5)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from fractions import Fraction
|
||||
from typing import Any, NewType
|
||||
|
||||
# 帧图在 core 内始终以 PNG 字节流转;core 不依赖 Pillow。
|
||||
PngBytes = NewType("PngBytes", bytes)
|
||||
|
||||
# 不透明字符串 id(对齐 OPP/1 §5),适配层维护 id ↔ 宿主对象映射。
|
||||
EntityId = NewType("EntityId", str)
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=True)
|
||||
class Rational:
|
||||
"""有理秒。构造后自动约分并保证分母为正,作为字典键安全。
|
||||
|
||||
排序按数值(num1*den2 交叉相乘),非字段字典序。
|
||||
"""
|
||||
|
||||
num: int
|
||||
den: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.den == 0:
|
||||
raise ValueError("Rational 分母不能为 0")
|
||||
if self.num == 0:
|
||||
object.__setattr__(self, "den", 1)
|
||||
return
|
||||
if self.den < 0:
|
||||
object.__setattr__(self, "num", -self.num)
|
||||
object.__setattr__(self, "den", -self.den)
|
||||
g = math.gcd(abs(self.num), self.den)
|
||||
if g > 1:
|
||||
object.__setattr__(self, "num", self.num // g)
|
||||
object.__setattr__(self, "den", self.den // g)
|
||||
|
||||
@classmethod
|
||||
def from_float(cls, seconds: float) -> Rational:
|
||||
"""从浮点秒构造(有限十进制即可精确)。"""
|
||||
f = Fraction(seconds).limit_denominator(1_000_000)
|
||||
return cls(f.numerator, f.denominator)
|
||||
|
||||
def to_float(self) -> float:
|
||||
return self.num / self.den
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
"""按数值比较(num1*den2 < num2*den1)。"""
|
||||
if not isinstance(other, Rational):
|
||||
return NotImplemented
|
||||
return self.num * other.den < other.num * self.den
|
||||
|
||||
def __le__(self, other: object) -> bool:
|
||||
"""按数值比较(<=)。``>=``/``>`` 经反射由 ``__le__``/``__lt__`` 派生。"""
|
||||
if not isinstance(other, Rational):
|
||||
return NotImplemented
|
||||
return self.num * other.den <= other.num * self.den
|
||||
|
||||
def __add__(self, other: Rational) -> Rational:
|
||||
return Rational(
|
||||
self.num * other.den + other.num * self.den, self.den * other.den
|
||||
)
|
||||
|
||||
def __sub__(self, other: Rational) -> Rational:
|
||||
return Rational(
|
||||
self.num * other.den - other.num * self.den, self.den * other.den
|
||||
)
|
||||
|
||||
def __mul__(self, other: Rational) -> Rational:
|
||||
return Rational(self.num * other.num, self.den * other.den)
|
||||
|
||||
def __truediv__(self, other: Rational) -> Rational:
|
||||
if other.num == 0:
|
||||
raise ZeroDivisionError("Rational 不能除以 0")
|
||||
return Rational(self.num * other.den, self.den * other.num)
|
||||
|
||||
def __repr__(self) -> str: # noqa: D105 - 简短调试表示
|
||||
return f"Rational({self.num}, {self.den})"
|
||||
|
||||
def to_json(self) -> dict[str, int]:
|
||||
"""序列化为 ``{num, den}``(工具 schema 与日志共用此形状)。"""
|
||||
return {"num": self.num, "den": self.den}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any] | Any) -> Rational:
|
||||
if isinstance(data, Rational):
|
||||
return data
|
||||
if not isinstance(data, dict) or "num" not in data or "den" not in data:
|
||||
raise ValueError(f"无法从 {data!r} 解析 Rational")
|
||||
return cls(int(data["num"]), int(data["den"]))
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class TimeRange:
|
||||
"""闭区间 [start, end] 的有理秒时间段。"""
|
||||
|
||||
start: Rational
|
||||
end: Rational
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.end < self.start:
|
||||
raise ValueError(f"TimeRange 起点不得晚于终点: {self.start} > {self.end}")
|
||||
|
||||
@property
|
||||
def duration(self) -> Rational:
|
||||
return self.end - self.start
|
||||
|
||||
def contains(self, t: Rational) -> bool:
|
||||
return self.start <= t <= self.end
|
||||
|
||||
def to_json(self) -> dict[str, dict[str, int]]:
|
||||
return {"start": self.start.to_json(), "end": self.end.to_json()}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> TimeRange:
|
||||
return cls(Rational.from_json(data["start"]), Rational.from_json(data["end"]))
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Size:
|
||||
"""像素尺寸。"""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.width <= 0 or self.height <= 0:
|
||||
raise ValueError(f"Size 必须为正: {self}")
|
||||
|
||||
@property
|
||||
def max_side(self) -> int:
|
||||
return max(self.width, self.height)
|
||||
|
||||
def to_json(self) -> dict[str, int]:
|
||||
return {"width": self.width, "height": self.height}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> Size:
|
||||
return cls(int(data["width"]), int(data["height"]))
|
||||
|
||||
|
||||
# 能力位(对齐设计文档 §5.2 的最小必要集)。
|
||||
Capability = NewType("Capability", str)
|
||||
|
||||
|
||||
class Capabilities:
|
||||
"""能力位常量(字符串即 JSON-RPC 能力名,避免魔法串)。"""
|
||||
|
||||
PROJECT_READ = Capability("project.read")
|
||||
MEDIA_READ = Capability("media.read")
|
||||
MEDIA_IMPORT = Capability("media.import")
|
||||
TIMELINE_READ = Capability("timeline.read")
|
||||
TIMELINE_EDIT = Capability("timeline.edit")
|
||||
NODE_READ = Capability("node.read")
|
||||
NODE_EDIT = Capability("node.edit")
|
||||
RENDER_FRAME = Capability("render.frame")
|
||||
PLAYBACK = Capability("playback")
|
||||
EXPORT = Capability("export")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limits:
|
||||
"""宿主自报的资源预算,core 侧据此限流(计划文档 §3.1 / §6 约束 4)。"""
|
||||
|
||||
max_frame_width: int = 1920
|
||||
max_frame_height: int = 1080
|
||||
max_frame_rate: float = 8.0 # 每秒取帧上限
|
||||
max_scan_frames: int = 64 # 单次 scan_timeline 的采样数上限
|
||||
max_snapshot_count: int = 16 # 快照时间线数量上限
|
||||
poll_interval_seconds: float = 0.5 # 宿主无事件源时适配层轮询间隔
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"max_frame_width": self.max_frame_width,
|
||||
"max_frame_height": self.max_frame_height,
|
||||
"max_frame_rate": self.max_frame_rate,
|
||||
"max_scan_frames": self.max_scan_frames,
|
||||
"max_snapshot_count": self.max_snapshot_count,
|
||||
"poll_interval_seconds": self.poll_interval_seconds,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""单次工具调用(一次 LLM 动作的组成部分)。"""
|
||||
|
||||
tool: str
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
call_id: str = "" # provider 返回的 tool_call id,用于结果回喂
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {"tool": self.tool, "params": self.params, "call_id": self.call_id}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionBatch:
|
||||
"""一次 LLM 动作 = 一组工具调用 + 标签。
|
||||
|
||||
整段先入"待确认清单",经确认回调批准后才执行变更类工具;
|
||||
LLM 看不到任何事务/快照细节(计划文档 §3.2–3.3)。
|
||||
"""
|
||||
|
||||
label: str
|
||||
actions: list[Action]
|
||||
id: str = "" # 由调用方(loop)分配
|
||||
created_at: str = "" # ISO 时间戳
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"actions": [a.to_json() for a in self.actions],
|
||||
"id": self.id,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResult:
|
||||
"""工具执行结果:给 LLM 的文本摘要 + 可选的 PNG 帧图(回喂多模态)。"""
|
||||
|
||||
tool: str
|
||||
ok: bool
|
||||
summary: str
|
||||
images: tuple[PngBytes, ...] = ()
|
||||
error: str | None = None
|
||||
Reference in New Issue
Block a user