Compare commits

..
Author SHA1 Message Date
yxxandClaude Code 3898530585 docs: 补齐 PR #11 评审要求的文档同步
- 技术栈说明实施状态:RAG Benchmark 标记为已完成、Agent Benchmark 暂缓
- 新增 Benchmark 开发说明,并登记到文档索引
- README 回归基线更新为后端 157 / 前端 29

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 22:35:45 +08:00
yxxandClaude Code fcc601fcf3 fix(backend): 落实 PR #11 第二轮评审意见
- Benchmark 容量淘汰只删终态 run,满容量且全活动时返回 BENCHMARK_CAPACITY_EXCEEDED
- 创建 run 前校验索引兼容性(BENCHMARK_INDEX_INCOMPATIBLE)
- 取消 run 补发 RunCancelled 终止事件;失败分支脱敏(BENCHMARK_RUN_FAILED)
- 失败样本计入汇总分母,报告输出 total/successful/failed/failure_rate
- load_dataset 按文件名隔离无关损坏文件,顶层非对象拒绝
- FTS score_threshold 先于计数/分页,total 与 items 一致
- Benchmark SSE 支持 Last-Event-ID 游标
- 移除 Agent Benchmark 501 占位接口
- 同步第二阶段接口契约与开发说明文档

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 22:20:11 +08:00
yxxandClaude Code c6cde2500b fix(backend): 落实 PR #9 评审意见
- 检索调优参数(rrf_k/rerank/rerank_candidates/score_threshold)透传到引擎实际执行
- Recall 去重,避免同一 Note 多 Block 重复导致 Recall 超 1
- RAG 运行改为后台异步执行:创建即 queued + 202,支持取消与 SSE 实时事件
- 数据集元数据校验,坏文件隔离跳过;citation_required 语义修正
- modes 空/重复校验;配置快照记录模型版本与索引元信息

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-02 23:20:34 +08:00
yxxandClaude Code 0006e91e67 chore(backend): 移除误提交的验收笔记
验收笔记此前被误纳入 benchmark 提交,现摘除跟踪,文件保留在本地磁盘。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 23:35:11 +08:00
yxx 866febec21 feat: benchmark功能开发完成 2026-09-01 23:30:11 +08:00
yxx 9b50b8f0ce feat: 完成benchmark后端功能 2026-09-01 23:19:28 +08:00
58 changed files with 1851 additions and 3267 deletions
+1 -2
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test
```
当前回归基线为后端 126 项测试、前端 29 项测试,且 TypeScript 类型检查和生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
当前回归基线为后端 157 项测试、前端 29 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。
@@ -133,7 +133,6 @@ pnpm test
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
| [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 |
| [Plugin Command 与 Settings](docs/development/Plugin-Command与Settings开发说明.md) | Command Registry、Settings Schema、Secret 引用与联调边界 |
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
+2 -2
View File
@@ -2,7 +2,7 @@
FastAPI + Pydantic 的本地 AI Core / Agent Core。项目使用 uv 管理依赖和虚拟环境。
当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、stdio MCP Host、Plugin Command/Settings、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses、Anthropic Messages、操作系统级 Plugin 沙箱和真实语音模型仍属于后续阶段。
当前实现包含 Knowledge/Retrieval、Chat、Agent Runtime、Tool/Permission、Skill/Plugin、Provider Adapter、任务、索引和开发阶段凭据加密存储。Provider 支持 Mock、OpenAI Chat/OpenAI-Compatible 与 OllamaOpenAI Responses、Anthropic Messages、MCP 独立 Host 和真实语音模型仍属于后续阶段。
```powershell
uv sync
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
uv run pytest
```
当前基线为 126 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。`plugin.*` 是 Plugin Settings 的保留凭据命名空间,通用 Provider 凭据接口不能读写。
当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
-3
View File
@@ -11,7 +11,6 @@ from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
from app.contracts import ToolCall, ToolDefinition, ToolResult
from app.schema_security import reject_external_schema_references
ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any]]
@@ -55,8 +54,6 @@ class ToolRegistry:
arguments_model: type[BaseModel],
executor: ToolExecutor,
) -> None:
Draft202012Validator.check_schema(definition.parameters)
reject_external_schema_references(definition.parameters)
with self._lock:
if definition.name in self._tools:
raise ValueError(f"Tool already registered: {definition.name}")
+8
View File
@@ -0,0 +1,8 @@
"""Benchmark 服务:RAG / Agent 数据集注册、指标计算与运行管理。
模块划分:
- metrics.py 纯函数指标(Hit@K / Recall@K / MRR / CitationHit / 分位数)
- datasets.py 受控目录的 Dataset 注册与校验
- rag.py RAG Benchmark Runner(调用 retrieval.engine.search
- service.py 运行注册表、配置快照与报告组装
"""
+198
View File
@@ -0,0 +1,198 @@
"""Benchmark Dataset 注册:从受控目录加载 JSON 数据集并校验。
Dataset 只能来自配置目录(settings.benchmark_datasets_path),API 不接受调用方提交
任意文件路径。目录不存在或为空时按「无数据集」处理,不报错。
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from pydantic import BaseModel, Field, ValidationError
from app.config import get_settings
from app.contracts import (
BenchmarkDatasetInfo,
BenchmarkKind,
RAGDatasetCase,
)
from app.errors import ApiError
@dataclass
class RAGDataset:
"""内存中的 RAG 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
dataset_id: str
kind: BenchmarkKind
version: str
description: str
cases: list[RAGDatasetCase] = field(default_factory=list)
content_hash: str = ""
class _DatasetMeta(BaseModel):
"""Dataset 元数据的最小校验模型。
list_datasets 用它逐文件校验元信息字段结构,把「合法 JSON 但字段类型错误」
(如 cases: 42)这类损坏文件隔离掉,而不是让 len() 抛 TypeError 拖垮整个列表。
"""
dataset_id: str = Field(min_length=1)
kind: str = ""
version: str = ""
description: str = ""
cases: list = Field(default_factory=list)
def _datasets_dir() -> Path:
return get_settings().benchmark_datasets_path
def _dataset_files() -> list[Path]:
directory = _datasets_dir()
if not directory.is_dir():
return []
return sorted(directory.glob("*.json"))
def _content_hash(raw: bytes) -> str:
return "sha256:" + hashlib.sha256(raw).hexdigest()
def _read_json(path: Path) -> tuple[dict, bytes]:
"""读取并解析 JSON 文件,返回 (dict, 原始字节);非法 JSON 抛 BENCHMARK_DATASET_INVALID。"""
try:
raw_bytes = path.read_bytes()
return json.loads(raw_bytes.decode("utf-8")), raw_bytes
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset file is not valid JSON: {path.name}",
{"path": str(path)},
) from exc
def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDataset:
"""把单个数据集 JSON 解析为 RAGDataset,非法结构抛 BENCHMARK_DATASET_INVALID。"""
dataset_id = raw.get("dataset_id")
if not isinstance(dataset_id, str) or not dataset_id:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
"Dataset must declare a non-empty string 'dataset_id'.",
{},
)
file_kind = raw.get("kind", kind.value)
if file_kind != kind.value:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset kind mismatch: expected '{kind.value}', got '{file_kind}'.",
{"dataset_id": dataset_id},
)
raw_cases = raw.get("cases")
if not isinstance(raw_cases, list) or not raw_cases:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
"Dataset 'cases' must be a non-empty list.",
{"dataset_id": dataset_id},
)
cases: list[RAGDatasetCase] = []
for index, case in enumerate(raw_cases):
try:
parsed = RAGDatasetCase.model_validate(case)
except ValidationError as exc:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset case #{index} is invalid.",
{"dataset_id": dataset_id, "case_index": index, "errors": exc.errors()},
) from exc
# 每个 Case 至少要声明一个期望 id,否则无法计算命中/召回
if not parsed.expected_note_ids and not parsed.expected_block_ids:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset case '{parsed.case_id}' must declare expected_note_ids or expected_block_ids.",
{"dataset_id": dataset_id, "case_id": parsed.case_id},
)
# citation_required=true 时必须声明 expected_block_ids,否则无法计算 Citation Hit Rate
if parsed.citation_required and not parsed.expected_block_ids:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset case '{parsed.case_id}' requires expected_block_ids when citation_required is true.",
{"dataset_id": dataset_id, "case_id": parsed.case_id},
)
cases.append(parsed)
return RAGDataset(
dataset_id=dataset_id,
kind=kind,
version=str(raw.get("version", "")),
description=str(raw.get("description", "")),
cases=cases,
content_hash=_content_hash(raw_bytes),
)
def list_datasets(kind: BenchmarkKind) -> list[BenchmarkDatasetInfo]:
"""枚举受控目录下指定 kind 的数据集元信息(不含 Case 内容)。
逐文件用 _DatasetMeta 校验元信息字段结构,单个损坏文件隔离跳过而非整体失败,
保证列表接口健壮;损坏细节由 load_dataset 抛出。
"""
infos: list[BenchmarkDatasetInfo] = []
for path in _dataset_files():
try:
raw, raw_bytes = _read_json(path)
meta = _DatasetMeta.model_validate(raw)
except (ApiError, ValidationError):
continue
if meta.kind not in ("", kind.value):
continue
infos.append(
BenchmarkDatasetInfo(
dataset_id=meta.dataset_id,
kind=kind,
version=meta.version,
description=meta.description,
case_count=len(meta.cases),
content_hash=_content_hash(raw_bytes),
)
)
return infos
def load_dataset(dataset_id: str, kind: BenchmarkKind) -> RAGDataset:
"""按文件名加载并校验数据集;找不到抛 BENCHMARK_DATASET_NOT_FOUND。
只读取与请求 dataset_id 同名的文件({dataset_id}.json),无关文件的损坏(JSON 语法
错误、UTF-8 解码错误、顶层非对象)不会阻断目标数据集加载;只有目标文件本身损坏
才抛 BENCHMARK_DATASET_INVALID。按现有文件 stem 精确匹配,不拼接调用方传入的路径。
"""
for path in _dataset_files():
if path.stem != dataset_id:
continue
raw, raw_bytes = _read_json(path)
if not isinstance(raw, dict):
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
"Dataset top-level must be a JSON object.",
{"dataset_id": dataset_id, "path": path.name},
)
return _dataset_from_raw(raw, raw_bytes, kind)
raise ApiError(
404,
"BENCHMARK_DATASET_NOT_FOUND",
f"Benchmark dataset does not exist: {dataset_id}",
{"dataset_id": dataset_id, "kind": kind.value},
)
+58
View File
@@ -0,0 +1,58 @@
"""Benchmark 指标纯函数。
所有指标只依赖「按相关性降序的 retrieved id 列表」和「期望 id 集合」,不接触任何
外部状态,便于单元测试与未来 Agent Benchmark 复用。retrieved 顺序越靠前越相关。
"""
from __future__ import annotations
def hit_at_k(retrieved: list[str], expected: set[str], k: int) -> bool:
"""前 k 个结果里是否命中任意期望 id(用于 Hit@1 / Hit@5)。"""
return any(item in expected for item in retrieved[:k])
def recall_at_k(retrieved: list[str], expected: set[str], k: int) -> float:
"""前 k 个结果召回的期望 id 占比;期望为空时视为 0。
结果先去重:检索结果是 Block 级,同一 Note 可能经多个 Block 重复出现,
直接逐项计数会把同一 Note 算多次、导致 Recall 超过 1。
"""
if not expected:
return 0.0
return len(set(retrieved[:k]) & expected) / len(expected)
def reciprocal_rank(retrieved: list[str], expected: set[str]) -> float:
"""首个命中的倒数排名;未命中返回 0。rank 从 1 开始。"""
for rank, item in enumerate(retrieved, start=1):
if item in expected:
return 1.0 / rank
return 0.0
def citation_hit(retrieved_block_ids: list[str], expected: set[str]) -> bool:
"""首条结果的 block_id 是否为期望引用块(Citation Hit Rate 的逐 Case 判据)。"""
if not retrieved_block_ids or not expected:
return False
return retrieved_block_ids[0] in expected
def mean(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
def percentile(values: list[float], p: float) -> float:
"""线性插值分位数(p ∈ [0, 100]),用于 P50 / P95 延迟。空列表返回 0。"""
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * (p / 100.0)
lo = int(rank)
hi = lo + 1
if hi >= len(ordered):
return ordered[-1]
frac = rank - lo
return ordered[lo] + (ordered[hi] - ordered[lo]) * frac
+143
View File
@@ -0,0 +1,143 @@
"""RAG Benchmark Runner:调用检索引擎对数据集逐 Case 求值并聚合指标。
只读操作,直接复用 app.retrieval.engine 的 search(),不旁路检索链路。指标按
(mode, case, repeat) 逐样本计算,再按 mode 聚合;失败样本按零分计入质量指标分母,
避免把执行失败误判为检索质量(同时保留 total/successful/failed/failure_rate)。
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from app.benchmarks import metrics as m
from app.benchmarks.datasets import RAGDataset
from app.contracts import (
RAGCaseResult,
RAGDatasetCase,
RAGMetrics,
RAGRunRequest,
SearchMode,
SearchRequest,
)
from app.retrieval.engine import engine
logger = logging.getLogger(__name__)
class BenchmarkCancelled(Exception):
"""运行在 Case 之间被取消时抛出,用于中断后台执行并标记 cancelled。"""
async def run_rag(
dataset: RAGDataset,
request: RAGRunRequest,
on_case: Callable[[RAGCaseResult, int, int], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> tuple[dict[str, RAGMetrics], list[RAGCaseResult]]:
"""执行 RAG Benchmark,返回 (按 mode 聚合的指标, 全部逐样本结果)。
on_case 在每个样本求值完成后回调 (result, done, total),供上层更新进度与事件。
should_cancel 在每个样本开始前被检查;返回 True 时抛出 BenchmarkCancelled 中断运行。
"""
total = len(request.modes) * len(dataset.cases) * request.repeat
done = 0
results: list[RAGCaseResult] = []
for mode in request.modes:
for case in dataset.cases:
for repeat in range(request.repeat):
if should_cancel is not None and should_cancel():
raise BenchmarkCancelled()
result = await _evaluate_one(case, mode, request, repeat)
results.append(result)
done += 1
if on_case is not None:
on_case(result, done, total)
metrics_by_mode = {mode.value: _aggregate(results, mode) for mode in request.modes}
return metrics_by_mode, results
async def _evaluate_one(
case: RAGDatasetCase, mode: SearchMode, request: RAGRunRequest, repeat: int
) -> RAGCaseResult:
search_request = SearchRequest(
query=case.query,
mode=mode,
limit=request.retrieval.top_k,
include_snippet=False,
rrf_k=request.retrieval.rrf_k,
rerank=request.retrieval.rerank,
rerank_candidates=request.retrieval.rerank_candidates,
score_threshold=request.retrieval.score_threshold,
)
start = time.perf_counter()
try:
response = await engine.search(search_request)
latency_ms = (time.perf_counter() - start) * 1000.0
except Exception as exc: # 单个样本失败不中断整个 Benchmark
# 详细异常只进日志,公开响应只带项目错误码与安全消息,避免泄露路径/SQL 等敏感信息
logger.warning(
"RAG case evaluation failed: case=%s mode=%s", case.case_id, mode.value,
exc_info=exc,
)
return RAGCaseResult(
case_id=case.case_id,
mode=mode,
repeat=repeat,
latency_ms=(time.perf_counter() - start) * 1000.0,
citation_applicable=case.citation_required,
error="RAG case evaluation failed.",
error_code="BENCHMARK_CASE_EVALUATION_FAILED",
)
retrieved_note_ids = [item.note_id for item in response.items]
retrieved_block_ids = [item.block_id for item in response.items]
expected_notes = set(case.expected_note_ids)
expected_blocks = set(case.expected_block_ids)
k = request.retrieval.top_k
return RAGCaseResult(
case_id=case.case_id,
mode=mode,
repeat=repeat,
latency_ms=latency_ms,
retrieved_note_ids=retrieved_note_ids,
retrieved_block_ids=retrieved_block_ids,
hit_at_1=m.hit_at_k(retrieved_note_ids, expected_notes, 1),
hit_at_5=m.hit_at_k(retrieved_note_ids, expected_notes, 5),
recall=m.recall_at_k(retrieved_note_ids, expected_notes, k),
reciprocal_rank=m.reciprocal_rank(retrieved_note_ids, expected_notes),
citation_hit=m.citation_hit(retrieved_block_ids, expected_blocks),
citation_applicable=case.citation_required,
)
def _aggregate(cases: list[RAGCaseResult], mode: SearchMode) -> RAGMetrics:
samples = [c for c in cases if c.mode == mode]
total = len(samples)
failed = sum(1 for c in samples if c.error is not None)
successful = total - failed
if total == 0:
return RAGMetrics()
# 延迟只统计成功样本;失败样本按零分计入质量指标分母,避免汇总虚高
latencies = [c.latency_ms for c in samples if c.error is None]
citation_samples = [c for c in samples if c.citation_applicable]
return RAGMetrics(
hit_at_1=m.mean([1.0 if (c.error is None and c.hit_at_1) else 0.0 for c in samples]),
hit_at_5=m.mean([1.0 if (c.error is None and c.hit_at_5) else 0.0 for c in samples]),
recall_at_k=m.mean([c.recall if c.error is None else 0.0 for c in samples]),
mrr=m.mean([c.reciprocal_rank if c.error is None else 0.0 for c in samples]),
citation_hit_rate=m.mean(
[1.0 if (c.error is None and c.citation_hit) else 0.0 for c in citation_samples]
),
p50_latency_ms=m.percentile(latencies, 50.0),
p95_latency_ms=m.percentile(latencies, 95.0),
total_cases=total,
successful_cases=successful,
failed_cases=failed,
failure_rate=failed / total,
)
+348
View File
@@ -0,0 +1,348 @@
"""Benchmark 服务:运行注册表、配置快照与报告组装。
RAG Benchmark 采用「创建即返回 queued、后台 Task 异步执行」的模式(与 index_service
的 rebuild 一致):POST 创建后立即返回 202 queued 的 BenchmarkRun,由受管 asyncio.Task
在后台逐 Case 求值,进度与事件实时写入内存注册表,供 SSE 订阅。运行记录、事件与报告
暂存内存(_runs/_events/_reports),不持久化到 SQLite;后续接入异步任务队列时再落库。
"""
from __future__ import annotations
import asyncio
import logging
import sys
from datetime import datetime, timezone
from uuid import uuid4
from app import repository
from app.benchmarks import datasets
from app.benchmarks.datasets import RAGDataset
from app.benchmarks.rag import BenchmarkCancelled, run_rag
from app.config import get_settings
from app.contracts import (
BenchmarkEvent,
BenchmarkEventType,
BenchmarkKind,
BenchmarkReport,
BenchmarkRun,
BenchmarkStatus,
RAGCaseResult,
RAGMetrics,
RAGRunRequest,
SearchMode,
)
from app.errors import ApiError
from app.retrieval.engine import engine
logger = logging.getLogger(__name__)
_runs: dict[str, BenchmarkRun] = {}
_events: dict[str, list[BenchmarkEvent]] = {}
_reports: dict[str, BenchmarkReport] = {}
_tasks: dict[str, asyncio.Task] = {}
_subscribers: dict[str, list[asyncio.Queue[BenchmarkEvent]]] = {}
_cancel_flags: dict[str, asyncio.Event] = {}
MAX_RUNS = 100
def _now() -> datetime:
return datetime.now(timezone.utc)
def _forget(run_id: str) -> None:
"""移除一条 run 的全部内存态;仅在 run 处于终态时调用,避免打断活动任务。"""
_runs.pop(run_id, None)
_events.pop(run_id, None)
_reports.pop(run_id, None)
_tasks.pop(run_id, None)
_subscribers.pop(run_id, None)
_cancel_flags.pop(run_id, None)
def _evict_terminal() -> bool:
"""超过容量时淘汰最旧的终态 run;全部为活动 run 无法淘汰时返回 False。
绝不能删除仍在运行(queued/running)的 run:那会连带移除其 _cancel_flags 与
_subscribers,使后台 Task 访问时抛出 KeyError。
"""
terminal = (BenchmarkStatus.completed, BenchmarkStatus.failed, BenchmarkStatus.cancelled)
while len(_runs) >= MAX_RUNS:
victim = next(
(rid for rid, run in _runs.items() if run.status in terminal), None
)
if victim is None:
return False
_forget(victim)
return True
def _config_snapshot(request: RAGRunRequest, dataset: RAGDataset) -> dict:
"""记录运行时的模型 / 索引 / 环境信息,保证报告可解释、可复现。"""
settings = get_settings()
return {
"dataset_id": dataset.dataset_id,
"dataset_hash": dataset.content_hash,
"dataset_version": dataset.version,
"modes": [m.value for m in request.modes],
"retrieval": request.retrieval.model_dump(),
"repeat": request.repeat,
"embedding": {
"model_id": engine.embedding.model_id,
"version": engine.embedding.version,
"dim": engine.embedding.dim,
},
"reranker": {
"model_id": engine.reranker.model_id,
"version": engine.reranker.version,
},
"index_meta": repository.get_index_meta(),
"app": {"version": settings.version, "environment": settings.environment},
"python": sys.version.split()[0],
"metadata": request.metadata,
}
async def _validate_index_compatibility(request: RAGRunRequest) -> None:
"""创建 RAG Run 前校验索引已建立且与当前 Embedding 模型/维度兼容。
空索引或不兼容索引会让所有模式得到全 0 指标,把环境/索引错误误判为检索质量差,
故在创建时即拒绝,返回 BENCHMARK_INDEX_INCOMPATIBLE。
"""
stats = repository.stats()
meta = repository.get_index_meta()
needs_vector = any(m in (SearchMode.vector, SearchMode.hybrid) for m in request.modes)
reasons: list[str] = []
if stats["blocks"] == 0:
reasons.append("index is empty (no indexed blocks; run /api/index/rebuild first)")
if needs_vector:
if meta.get("embedding_model") != engine.embedding.model_id:
reasons.append(
f"embedding model mismatch: index={meta.get('embedding_model')!r}, "
f"engine={engine.embedding.model_id!r}"
)
if meta.get("embedding_dim") != str(engine.embedding.dim):
reasons.append(
f"embedding dimension mismatch: index={meta.get('embedding_dim')!r}, "
f"engine={engine.embedding.dim}"
)
if await engine.vector_store.count() == 0:
reasons.append("vector index is empty")
if reasons:
raise ApiError(
409,
"BENCHMARK_INDEX_INCOMPATIBLE",
"Benchmark index is not built or is incompatible with the current retrieval engine.",
{"reasons": reasons},
)
async def create_rag_run(request: RAGRunRequest) -> BenchmarkRun:
"""创建一次 RAG Benchmark,立即返回 queued 的 BenchmarkRun,由后台 Task 执行。"""
dataset = datasets.load_dataset(request.dataset_id, BenchmarkKind.rag)
await _validate_index_compatibility(request)
# 容量检查:先淘汰终态 run 腾空间;满容量且全为活动 run 时拒绝创建
if not _evict_terminal():
raise ApiError(
429,
"BENCHMARK_CAPACITY_EXCEEDED",
"Benchmark run capacity exceeded; wait for active runs to finish.",
{},
)
run_id = "benchmark_" + uuid4().hex[:12]
snapshot = _config_snapshot(request, dataset)
run = BenchmarkRun(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.queued,
progress=0.0,
config_snapshot=snapshot,
created_at=_now(),
)
_runs[run_id] = run
_events[run_id] = []
_subscribers[run_id] = []
_cancel_flags[run_id] = asyncio.Event()
_tasks[run_id] = asyncio.create_task(_execute_rag(run_id, request, dataset, snapshot))
return run
async def _execute_rag(
run_id: str, request: RAGRunRequest, dataset: RAGDataset, snapshot: dict
) -> None:
"""后台执行 RAG Benchmark,实时更新进度/事件,结束后写入报告并关闭订阅。"""
cancel_event = _cancel_flags[run_id]
def emit(event_type: BenchmarkEventType, data: dict) -> None:
sequence = len(_events[run_id])
event = BenchmarkEvent(
event=event_type, run_id=run_id, sequence=sequence, data=data, timestamp=_now()
)
_events[run_id].append(event)
for queue in _subscribers.get(run_id, []):
queue.put_nowait(event)
def finish() -> None:
_subscribers.pop(run_id, None)
_cancel_flags.pop(run_id, None)
_runs[run_id] = _runs[run_id].model_copy(
update={"status": BenchmarkStatus.running, "started_at": _now()}
)
emit(
BenchmarkEventType.run_started,
{"dataset_id": dataset.dataset_id, "modes": [m.value for m in request.modes]},
)
total = len(request.modes) * len(dataset.cases) * request.repeat
def on_case(result: RAGCaseResult, done: int, _total: int) -> None:
progress = done / total if total else 1.0
_runs[run_id] = _runs[run_id].model_copy(update={"progress": progress})
emit(BenchmarkEventType.case_completed, result.model_dump(mode="json"))
try:
metrics_by_mode, results = await run_rag(
dataset,
request,
on_case=on_case,
should_cancel=cancel_event.is_set,
)
except BenchmarkCancelled:
_runs[run_id] = _runs[run_id].model_copy(
update={
"status": BenchmarkStatus.cancelled,
"progress": 1.0,
"completed_at": _now(),
}
)
emit(BenchmarkEventType.run_cancelled, {"status": BenchmarkStatus.cancelled.value})
_reports[run_id] = BenchmarkReport(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.cancelled,
config_snapshot=snapshot,
)
finish()
return
except Exception as exc: # 单次运行失败不拖垮服务,记录错误后结束
# 详细异常只进日志,公开响应仅带项目错误码与安全消息,避免泄露路径/SQL 等敏感信息
logger.exception("Benchmark run failed: run_id=%s", run_id)
_runs[run_id] = _runs[run_id].model_copy(
update={
"status": BenchmarkStatus.failed,
"progress": 1.0,
"error": "Benchmark run failed.",
"error_code": "BENCHMARK_RUN_FAILED",
"completed_at": _now(),
}
)
emit(
BenchmarkEventType.run_failed,
{"error": "Benchmark run failed.", "error_code": "BENCHMARK_RUN_FAILED"},
)
_reports[run_id] = BenchmarkReport(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.failed,
config_snapshot=snapshot,
error="Benchmark run failed.",
error_code="BENCHMARK_RUN_FAILED",
)
finish()
return
metrics = {mode: m.model_dump() for mode, m in metrics_by_mode.items()}
_runs[run_id] = _runs[run_id].model_copy(
update={
"status": BenchmarkStatus.completed,
"progress": 1.0,
"metrics": metrics,
"completed_at": _now(),
}
)
emit(BenchmarkEventType.run_completed, {"metrics": metrics})
_reports[run_id] = BenchmarkReport(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.completed,
config_snapshot=snapshot,
metrics=metrics,
cases=results,
)
finish()
def list_runs(
kind: BenchmarkKind | None = None,
status: BenchmarkStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[BenchmarkRun], int]:
runs = list(_runs.values())
if kind is not None:
runs = [r for r in runs if r.kind == kind]
if status is not None:
runs = [r for r in runs if r.status == status]
runs.sort(key=lambda r: r.created_at, reverse=True)
total = len(runs)
return runs[offset : offset + limit], total
def get_run(run_id: str) -> BenchmarkRun | None:
return _runs.get(run_id)
def get_report(run_id: str) -> BenchmarkReport | None:
return _reports.get(run_id)
def get_events(run_id: str) -> list[BenchmarkEvent]:
return _events.get(run_id, [])
def cancel_run(run_id: str) -> BenchmarkRun | None:
"""取消运行:对 queued/running 设置取消标志,后台 Task 在 Case 边界检查后置为 cancelled。"""
run = _runs.get(run_id)
if run is None:
return None
if run.status in (BenchmarkStatus.queued, BenchmarkStatus.running):
_cancel_flags[run_id].set()
return run
def subscribe(run_id: str) -> asyncio.Queue[BenchmarkEvent] | None:
"""订阅运行事件流;运行已结束(completed/failed/cancelled)时返回 None。"""
run = _runs.get(run_id)
if run is None or run.status in (
BenchmarkStatus.completed,
BenchmarkStatus.failed,
BenchmarkStatus.cancelled,
):
return None
queue: asyncio.Queue[BenchmarkEvent] = asyncio.Queue()
_subscribers.setdefault(run_id, []).append(queue)
return queue
def unsubscribe(run_id: str, queue: asyncio.Queue[BenchmarkEvent]) -> None:
subscribers = _subscribers.get(run_id)
if subscribers and queue in subscribers:
subscribers.remove(queue)
async def wait_for_run(run_id: str) -> BenchmarkRun:
"""等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。"""
task = _tasks.get(run_id)
if task is not None:
await task
return _runs.get(run_id)
+4
View File
@@ -24,6 +24,7 @@ class Settings:
db_path: Path
vault_path: Path
attachments_path: Path
benchmark_datasets_path: Path
@lru_cache
@@ -41,4 +42,7 @@ def get_settings() -> Settings:
attachments_path=Path(
os.getenv("APP_ATTACHMENTS_PATH", str(data_dir / "attachments"))
),
benchmark_datasets_path=Path(
os.getenv("APP_BENCHMARK_DATASETS_PATH", str(data_dir / "benchmarks"))
),
)
-1
View File
@@ -53,7 +53,6 @@ def build_container() -> ApplicationContainer:
plugins = PluginRuntime(
tools,
credentials=credentials,
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
allow_unsandboxed_mcp=settings.environment == "development",
+156 -168
View File
@@ -1,8 +1,8 @@
from datetime import datetime
from enum import Enum
from typing import Annotated, Any, Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
class Contract(BaseModel):
@@ -144,6 +144,12 @@ class SearchRequest(Contract):
limit: int = Field(default=20, ge=1, le=100)
offset: int = Field(default=0, ge=0)
include_snippet: bool = True
# 检索调优参数(Benchmark 与 Skill 共用):控制 RRF / 精排 / 候选池 / 分数阈值。
# rerank_candidates=None 表示对全部候选精排(保留原有行为),Benchmark 传显式值。
rrf_k: int = Field(default=60, ge=1)
rerank: bool = True
rerank_candidates: int | None = Field(default=None, ge=1)
score_threshold: float = Field(default=0.0, ge=0.0)
class Citation(Contract):
@@ -486,172 +492,6 @@ class PluginHostStatus(Contract):
error: str | None = None
class PluginCommandLocation(str, Enum):
command_palette = "command_palette"
context_menu = "context_menu"
toolbar = "toolbar"
class PluginCommand(Contract):
command_id: str
plugin_id: str
title: str
description: str = ""
icon: str | None = None
locations: list[PluginCommandLocation] = Field(default_factory=list)
when: list[str] = Field(default_factory=list)
parameters: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
class PluginCommandListResponse(Contract):
items: list[PluginCommand] = Field(default_factory=list)
class PluginCommandContext(Contract):
vault_id: str | None = None
note_id: str | None = None
file_path: str | None = None
selection: str | None = None
class PluginCommandExecuteRequest(Contract):
arguments: dict[str, Any] = Field(default_factory=dict)
context: PluginCommandContext = Field(default_factory=PluginCommandContext)
class PluginNotificationEffectPayload(Contract):
level: Literal["info", "success", "warning", "error"] = "info"
message: str = Field(min_length=1, max_length=4096)
class PluginNavigateEffectPayload(Contract):
route: Literal[
"vault-entry",
"workspace",
"search",
"chat",
"agent",
"tasks",
"skills",
"plugins",
"themes",
"settings",
]
class PluginRefreshEffectPayload(Contract):
scope: Literal["workspace", "commands", "settings", "plugins"]
class PluginJobEffectPayload(Contract):
job_id: str = Field(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$",
)
class PluginNoEffectPayload(Contract):
pass
class PluginNoEffect(Contract):
type: Literal["none"] = "none"
payload: PluginNoEffectPayload = Field(default_factory=PluginNoEffectPayload)
class PluginNotificationEffect(Contract):
type: Literal["notification"] = "notification"
payload: PluginNotificationEffectPayload
class PluginNavigateEffect(Contract):
type: Literal["navigate"] = "navigate"
payload: PluginNavigateEffectPayload
class PluginRefreshEffect(Contract):
type: Literal["refresh"] = "refresh"
payload: PluginRefreshEffectPayload
class PluginJobEffect(Contract):
type: Literal["job"] = "job"
payload: PluginJobEffectPayload
PluginCommandEffect = Annotated[
PluginNoEffect
| PluginNotificationEffect
| PluginNavigateEffect
| PluginRefreshEffect
| PluginJobEffect,
Field(discriminator="type"),
]
PLUGIN_COMMAND_EFFECT_TYPES = (
PluginNoEffect,
PluginNotificationEffect,
PluginNavigateEffect,
PluginRefreshEffect,
PluginJobEffect,
)
class PluginCommandResult(Contract):
command_id: str
status: Literal["completed"] = "completed"
effect: PluginCommandEffect = Field(default_factory=PluginNoEffect)
class PluginSettingType(str, Enum):
string = "string"
number = "number"
boolean = "boolean"
select = "select"
secret = "secret"
class PluginSettingField(Contract):
key: str
label: str
description: str = ""
type: PluginSettingType
required: bool = False
default: Any | None = None
minimum: float | None = None
maximum: float | None = None
options: list[str] = Field(default_factory=list)
class PluginSecretState(Contract):
configured: bool = False
class PluginSettingsSchema(Contract):
plugin_id: str
schema_version: int = Field(ge=1)
fields: list[PluginSettingField] = Field(default_factory=list)
values: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, PluginSecretState] = Field(default_factory=dict)
class PluginSettingsUpdateRequest(Contract):
schema_version: int = Field(ge=1)
values: dict[str, Any] = Field(default_factory=dict)
class PluginSecretWriteRequest(Contract):
secret: SecretStr
class PluginSecretStatus(Contract):
plugin_id: str
key: str
configured: bool
class PluginPermissionGrantRequest(Contract):
permissions: list[str] = Field(default_factory=list)
@@ -818,3 +658,151 @@ class IndexJob(Contract):
status: Literal["queued", "running", "completed", "failed"]
scope: Literal["all", "notes", "vectors"]
created_at: datetime
# Benchmark
class BenchmarkKind(str, Enum):
rag = "rag"
agent = "agent"
class BenchmarkStatus(str, Enum):
queued = "queued"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class RAGDatasetCase(Contract):
case_id: str
query: str = Field(min_length=1)
expected_note_ids: list[str] = Field(default_factory=list)
expected_block_ids: list[str] = Field(default_factory=list)
citation_required: bool = False
tags: list[str] = Field(default_factory=list)
class RAGRetrievalConfig(Contract):
"""RAG Benchmark 的检索参数。top_k 映射到 SearchRequest.limit
其余参数透传到 SearchRequest,由检索引擎实际执行。"""
top_k: int = Field(default=10, ge=1, le=100)
rrf_k: int = Field(default=60, ge=1)
rerank: bool = True
rerank_candidates: int = Field(default=20, ge=1)
score_threshold: float = Field(default=0.0, ge=0.0)
class RAGRunRequest(Contract):
dataset_id: str = Field(min_length=1)
modes: list[SearchMode] = Field(
default_factory=lambda: [SearchMode.fts, SearchMode.vector, SearchMode.hybrid],
min_length=1,
)
retrieval: RAGRetrievalConfig = Field(default_factory=RAGRetrievalConfig)
repeat: int = Field(default=1, ge=1, le=10)
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("modes")
@classmethod
def _no_duplicate_modes(cls, value: list[SearchMode]) -> list[SearchMode]:
if len(value) != len(set(value)):
raise ValueError("modes must not contain duplicates")
return value
class RAGMetrics(Contract):
hit_at_1: float = 0.0
hit_at_5: float = 0.0
recall_at_k: float = 0.0
mrr: float = 0.0
citation_hit_rate: float = 0.0
p50_latency_ms: float = 0.0
p95_latency_ms: float = 0.0
# 样本构成:失败样本按零分计入质量指标,汇总不虚高;报告据此可知实际分母
total_cases: int = 0
successful_cases: int = 0
failed_cases: int = 0
failure_rate: float = 0.0
class BenchmarkDatasetInfo(Contract):
dataset_id: str
kind: BenchmarkKind
version: str
description: str = ""
case_count: int
content_hash: str
class BenchmarkDatasetListResponse(Contract):
items: list[BenchmarkDatasetInfo] = Field(default_factory=list)
class BenchmarkRun(Contract):
run_id: str
kind: BenchmarkKind
dataset_id: str
dataset_hash: str
status: BenchmarkStatus
progress: float | None = None
metrics: dict[str, Any] | None = None
config_snapshot: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
error_code: str | None = None
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
class BenchmarkRunListResponse(Contract):
items: list[BenchmarkRun] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class BenchmarkEventType(str, Enum):
run_started = "RunStarted"
case_completed = "CaseCompleted"
run_completed = "RunCompleted"
run_failed = "RunFailed"
run_cancelled = "RunCancelled"
class BenchmarkEvent(Contract):
event: BenchmarkEventType
run_id: str
sequence: int
data: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime
class RAGCaseResult(Contract):
case_id: str
mode: SearchMode
repeat: int
latency_ms: float
retrieved_note_ids: list[str] = Field(default_factory=list)
retrieved_block_ids: list[str] = Field(default_factory=list)
hit_at_1: bool = False
hit_at_5: bool = False
recall: float = 0.0
reciprocal_rank: float = 0.0
citation_hit: bool = False
# 该 Case 是否声明了 expected_block_ids(决定是否计入 citation_hit_rate 分母)
citation_applicable: bool = False
error: str | None = None
error_code: str | None = None
class BenchmarkReport(Contract):
run_id: str
kind: BenchmarkKind
dataset_id: str
dataset_hash: str
status: BenchmarkStatus
config_snapshot: dict[str, Any] = Field(default_factory=dict)
metrics: dict[str, Any] = Field(default_factory=dict)
cases: list[RAGCaseResult] = Field(default_factory=list)
error: str | None = None
error_code: str | None = None
+6 -2
View File
@@ -1,5 +1,9 @@
from app.extensions.errors import ExtensionError
from app.extensions.runtime import AgentConfiguration, PluginRuntime, SkillRuntime
from app.extensions.runtime import (
AgentConfiguration,
ExtensionError,
PluginRuntime,
SkillRuntime,
)
from app.extensions.mcp import McpBridge, McpBridgeError
__all__ = [
-847
View File
@@ -1,847 +0,0 @@
"""Plugin Command Registry 与 Settings/Secret 命名空间存储。"""
from __future__ import annotations
import asyncio
import hashlib
import inspect
import json
import math
import re
import threading
from collections import deque
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
from typing import Any, Awaitable, Callable, Literal
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError as JsonSchemaValidationError
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.contracts import (
PLUGIN_COMMAND_EFFECT_TYPES,
PluginCommand,
PluginCommandContext,
PluginCommandEffect,
PluginCommandLocation,
PluginCommandResult,
PluginSecretState,
PluginSecretStatus,
PluginSettingField,
PluginSettingType,
PluginSettingsSchema,
)
from app.extensions.errors import ExtensionError
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
_CONTRIBUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
_SETTING_KEY = re.compile(r"^[a-z][a-z0-9._-]{0,127}$")
_HOST_ICONS = {"bolt", "document", "edit", "link", "refresh", "search", "setting"}
_WHEN_TOKENS = {
"workspace.has_vault",
"editor.has_note",
"editor.has_selection",
}
_CONTEXT_KEYS = {"vault_id", "note_id", "file_path", "selection"}
_WHEN_CONTEXT = {
"workspace.has_vault": "vault_id",
"editor.has_note": "note_id",
"editor.has_selection": "selection",
}
class PluginCommandSpec(BaseModel):
"""包内 commands.yaml 的宿主侧声明,不直接暴露 handler。"""
model_config = ConfigDict(extra="forbid")
command_id: str
title: str
description: str = ""
icon: str | None = None
locations: list[PluginCommandLocation] = Field(default_factory=list)
when: list[str] = Field(default_factory=list)
context: list[Literal["vault_id", "note_id", "file_path", "selection"]] = Field(
default_factory=list
)
parameters: dict[str, Any] = Field(
default_factory=lambda: {
"type": "object",
"properties": {},
"additionalProperties": False,
}
)
permission: str | None = None
secrets: list[str] = Field(default_factory=list)
handler: Literal["echo", "uppercase_selection"] | None = None
mcp_tool: str | None = None
timeout_seconds: int = Field(default=30, ge=1, le=120)
@model_validator(mode="after")
def validate_execution_target(self) -> "PluginCommandSpec":
if (self.handler is None) == (self.mcp_tool is None):
raise ValueError("Command must declare exactly one handler or mcp_tool target.")
return self
CommandExecutor = Callable[
[dict[str, Any], dict[str, Any]],
PluginCommandEffect | Awaitable[PluginCommandEffect],
]
PluginSecretResolver = Callable[[str], str | None]
@dataclass(slots=True)
class _RegisteredCommand:
command: PluginCommand
spec: PluginCommandSpec
executor: CommandExecutor
@dataclass(frozen=True, slots=True)
class PluginCommandAuditEvent:
"""不记录参数与上下文的轻量审计事件,避免把正文或 Secret 写入日志。"""
command_id: str
plugin_id: str
status: Literal["completed", "failed"]
duration_ms: int
error_code: str | None
created_at: datetime
class CommandRegistry:
"""只发布已启用 Plugin 的受控 Command Contribution。"""
def __init__(self) -> None:
self._commands: dict[str, _RegisteredCommand] = {}
self._audit: deque[PluginCommandAuditEvent] = deque(maxlen=500)
self._lock = threading.RLock()
def register(
self,
plugin_id: str,
spec: PluginCommandSpec,
executor: CommandExecutor,
) -> None:
validate_command_spec(plugin_id, spec)
command = PluginCommand(
command_id=spec.command_id,
plugin_id=plugin_id,
title=spec.title,
description=spec.description,
icon=spec.icon,
locations=spec.locations,
when=spec.when,
parameters=spec.parameters,
enabled=True,
)
with self._lock:
if spec.command_id in self._commands:
raise ExtensionError(
"PLUGIN_COMMAND_CONFLICT",
f"Plugin command is already registered: {spec.command_id}",
status_code=409,
details={"command_id": spec.command_id},
)
self._commands[spec.command_id] = _RegisteredCommand(command, spec, executor)
def unregister(self, command_id: str) -> None:
with self._lock:
self._commands.pop(command_id, None)
def contains(self, command_id: str) -> bool:
with self._lock:
return command_id in self._commands
def list(self, location: PluginCommandLocation | None = None) -> list[PluginCommand]:
with self._lock:
items = [
item.command.model_copy(deep=True)
for item in self._commands.values()
if location is None or location in item.command.locations
]
return sorted(items, key=lambda item: item.command_id)
def audit_events(self) -> list[PluginCommandAuditEvent]:
"""返回有界审计快照;事件刻意不包含 arguments/context/effect。"""
with self._lock:
return list(self._audit)
async def execute(
self,
command_id: str,
arguments: dict[str, Any],
context: PluginCommandContext,
) -> PluginCommandResult:
with self._lock:
registered = self._commands.get(command_id)
if registered is None:
raise ExtensionError(
"PLUGIN_COMMAND_NOT_FOUND",
f"Plugin command is not registered or enabled: {command_id}",
status_code=404,
details={"command_id": command_id},
)
started_at = perf_counter()
try:
Draft202012Validator(registered.spec.parameters).validate(arguments)
except JsonSchemaValidationError as exc:
error = ExtensionError(
"PLUGIN_COMMAND_ARGUMENT_INVALID",
"Plugin command arguments do not match the declared schema.",
details={"command_id": command_id, "path": list(exc.path)},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
raw_context = context.model_dump(exclude_none=True)
missing = [
token
for token in registered.spec.when
if not raw_context.get(_WHEN_CONTEXT[token])
]
if missing:
error = ExtensionError(
"PLUGIN_COMMAND_CONTEXT_INVALID",
"Plugin command context does not satisfy its when conditions.",
details={"command_id": command_id, "missing": missing},
)
self._record_audit(registered, started_at, error.code)
raise error
scoped_context = {
key: raw_context[key]
for key in registered.spec.context
if key in raw_context
}
try:
effect = registered.executor(dict(arguments), scoped_context)
if inspect.isawaitable(effect):
effect = await asyncio.wait_for(
effect, timeout=registered.spec.timeout_seconds
)
except TimeoutError as exc:
error = ExtensionError(
"PLUGIN_COMMAND_TIMEOUT",
"Plugin command execution timed out.",
status_code=504,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
except ExtensionError as exc:
self._record_audit(registered, started_at, exc.code)
raise
except Exception as exc:
error = ExtensionError(
"PLUGIN_COMMAND_EXECUTION_FAILED",
"Plugin command execution failed.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
if not isinstance(effect, PLUGIN_COMMAND_EFFECT_TYPES):
error = ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"Plugin command returned an invalid effect.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error
try:
encoded_effect = json.dumps(effect.model_dump(mode="json"), ensure_ascii=False)
except (TypeError, ValueError) as exc:
error = ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"Plugin command returned a non-serializable effect.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error from exc
if len(encoded_effect.encode("utf-8")) > 64 * 1024:
error = ExtensionError(
"PLUGIN_COMMAND_RESULT_TOO_LARGE",
"Plugin command effect exceeds the 64 KiB response limit.",
status_code=502,
details={"command_id": command_id},
)
self._record_audit(registered, started_at, error.code)
raise error
self._record_audit(registered, started_at, None)
return PluginCommandResult(command_id=command_id, effect=effect)
def _record_audit(
self,
registered: _RegisteredCommand,
started_at: float,
error_code: str | None,
) -> None:
event = PluginCommandAuditEvent(
command_id=registered.command.command_id,
plugin_id=registered.command.plugin_id,
status="failed" if error_code else "completed",
duration_ms=max(0, round((perf_counter() - started_at) * 1000)),
error_code=error_code,
created_at=datetime.now(UTC),
)
with self._lock:
self._audit.append(event)
class PluginSettingsDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
section_id: str
schema_version: int = Field(ge=1)
fields: list[PluginSettingField] = Field(default_factory=list)
class PluginSettingsStore:
"""非敏感值写入插件命名空间;Secret 只保存加密凭据引用。"""
def __init__(self, credentials: EncryptedCredentialStore) -> None:
self.credentials = credentials
self._lock = threading.RLock()
@staticmethod
def _path() -> Path:
return get_settings().data_dir / "plugins" / "settings.json"
def get(
self, plugin_id: str, definition: PluginSettingsDefinition
) -> PluginSettingsSchema:
with self._lock:
entry = self._entry(self._read(), plugin_id)
stored_values = entry.get("values", {})
secret_refs = entry.get("secret_refs", {})
if not isinstance(stored_values, dict) or not isinstance(secret_refs, dict):
raise self._storage_format_error(plugin_id)
validated_refs = self._validate_secret_refs(plugin_id, secret_refs)
values = {
field.key: field.default
for field in definition.fields
if field.type != PluginSettingType.secret and field.default is not None
}
allowed_values = {
field.key
for field in definition.fields
if field.type != PluginSettingType.secret
}
fields = {field.key: field for field in definition.fields}
for key, value in stored_values.items():
if key not in allowed_values:
continue
try:
_validate_setting_value(fields[key], value)
except ExtensionError as exc:
raise self._storage_format_error(plugin_id) from exc
values[key] = value
secrets: dict[str, PluginSecretState] = {}
for field in definition.fields:
if field.type != PluginSettingType.secret:
continue
reference = validated_refs.get(field.key)
secrets[field.key] = PluginSecretState(
configured=isinstance(reference, str) and self._has_secret(reference)
)
return PluginSettingsSchema(
plugin_id=plugin_id,
schema_version=definition.schema_version,
fields=definition.fields,
values=values,
secrets=secrets,
)
def runtime_values(
self, plugin_id: str, definition: PluginSettingsDefinition
) -> dict[str, Any]:
"""返回可供 Command 使用的完整普通设置,并拦截未配置的必填项。"""
schema = self.get(plugin_id, definition)
missing = [
field.key
for field in definition.fields
if field.required
and field.type != PluginSettingType.secret
and field.key not in schema.values
]
if missing:
raise ExtensionError(
"PLUGIN_SETTINGS_REQUIRED",
"Required Plugin settings have not been configured.",
status_code=409,
details={"plugin_id": plugin_id, "fields": missing},
)
return schema.values
def update(
self,
plugin_id: str,
definition: PluginSettingsDefinition,
schema_version: int,
values: dict[str, Any],
) -> PluginSettingsSchema:
if schema_version != definition.schema_version:
raise ExtensionError(
"PLUGIN_SETTINGS_VERSION_CONFLICT",
"Plugin settings schema version is out of date.",
status_code=409,
details={
"plugin_id": plugin_id,
"requested_version": schema_version,
"current_version": definition.schema_version,
},
)
fields = {field.key: field for field in definition.fields}
unknown = sorted(set(values) - set(fields))
if unknown:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
"Plugin settings contain unknown fields.",
details={"plugin_id": plugin_id, "fields": unknown},
)
secret_keys = sorted(
key for key in values if fields[key].type == PluginSettingType.secret
)
if secret_keys:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
"Secret fields must use the dedicated Secret endpoint.",
details={"plugin_id": plugin_id, "fields": secret_keys},
)
for key, value in values.items():
_validate_setting_value(fields[key], value)
with self._lock:
data = self._read()
entry = self._entry(data, plugin_id, create=True)
current = entry.get("values", {})
if not isinstance(current, dict):
raise self._storage_format_error(plugin_id)
entry["values"] = current
current.update(values)
effective = {
field.key: field.default
for field in definition.fields
if field.type != PluginSettingType.secret and field.default is not None
}
effective.update(current)
missing = [
field.key
for field in definition.fields
if field.required
and field.type != PluginSettingType.secret
and field.key not in effective
]
if missing:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
"Required Plugin settings are missing.",
details={"plugin_id": plugin_id, "fields": missing},
)
entry["schema_version"] = definition.schema_version
self._write(data)
return self.get(plugin_id, definition)
def put_secret(
self,
plugin_id: str,
definition: PluginSettingsDefinition,
key: str,
secret: str,
) -> PluginSecretStatus:
_secret_field(definition, plugin_id, key)
if not secret:
raise ExtensionError(
"PLUGIN_SECRET_VALUE_INVALID",
"Plugin secret cannot be empty.",
details={"plugin_id": plugin_id, "key": key},
)
if len(secret.encode("utf-8")) > 64 * 1024:
raise ExtensionError(
"PLUGIN_SECRET_VALUE_INVALID",
"Plugin secret exceeds the 64 KiB limit.",
details={"plugin_id": plugin_id, "key": key},
)
reference = _secret_reference(plugin_id, key)
with self._lock:
data = self._read()
entry = self._entry(data, plugin_id, create=True)
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
self._validate_secret_refs(plugin_id, refs)
entry["secret_refs"] = refs
try:
previous = self.credentials.resolve(reference)
self.credentials.put(reference, secret)
except CredentialStoreError as exc:
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
refs[key] = reference
entry["schema_version"] = definition.schema_version
try:
self._write(data)
except ExtensionError:
# 普通设置落盘失败时恢复凭据旧值,避免产生不可达的新 Secret。
try:
if previous is None:
self.credentials.delete(reference)
else:
self.credentials.put(reference, previous)
except CredentialStoreError:
pass
raise
return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=True)
def delete_secret(
self,
plugin_id: str,
definition: PluginSettingsDefinition,
key: str,
) -> PluginSecretStatus:
_secret_field(definition, plugin_id, key)
with self._lock:
data = self._read()
entry = self._entry(data, plugin_id)
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
self._validate_secret_refs(plugin_id, refs)
reference = _secret_reference(plugin_id, key)
had_reference = refs.pop(key, None) is not None
if plugin_id in data and had_reference:
self._write(data)
try:
self.credentials.delete(reference)
except CredentialStoreError as exc:
if had_reference:
refs[key] = reference
try:
self._write(data)
except ExtensionError as rollback_exc:
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin Secret deletion failed and its reference could not be restored.",
status_code=500,
details={"plugin_id": plugin_id, "key": key},
) from rollback_exc
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
return PluginSecretStatus(plugin_id=plugin_id, key=key, configured=False)
def resolve_secret(
self, plugin_id: str, definition: PluginSettingsDefinition, key: str
) -> str | None:
_secret_field(definition, plugin_id, key)
with self._lock:
entry = self._entry(self._read(), plugin_id)
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
reference = self._validate_secret_refs(plugin_id, refs).get(key)
try:
return self.credentials.resolve(reference) if isinstance(reference, str) else None
except CredentialStoreError as exc:
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
def remove_plugin(self, plugin_id: str) -> None:
with self._lock:
data = self._read()
entry = data.pop(plugin_id, None)
references: list[str] = []
if entry is not None and not isinstance(entry, dict):
raise self._storage_format_error(plugin_id)
if entry is not None:
refs = entry.get("secret_refs", {})
if not isinstance(refs, dict):
raise self._storage_format_error(plugin_id)
references = list(self._validate_secret_refs(plugin_id, refs).values())
if entry is not None:
self._write(data)
try:
self.credentials.delete_many(references)
except CredentialStoreError as exc:
if entry is not None:
data[plugin_id] = entry
try:
self._write(data)
except ExtensionError as rollback_exc:
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin uninstall failed and its Settings namespace could not be restored.",
status_code=500,
details={"plugin_id": plugin_id},
) from rollback_exc
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
def _validate_secret_refs(
self, plugin_id: str, refs: dict[Any, Any]
) -> dict[str, str]:
validated: dict[str, str] = {}
for key, reference in refs.items():
if (
not isinstance(key, str)
or not _SETTING_KEY.fullmatch(key)
or not isinstance(reference, str)
or reference != _secret_reference(plugin_id, key)
):
raise self._storage_format_error(plugin_id)
validated[key] = reference
return validated
def _has_secret(self, reference: str) -> bool:
try:
return self.credentials.has(reference)
except CredentialStoreError as exc:
raise ExtensionError(
"PLUGIN_SECRET_STORE_ERROR", str(exc), status_code=500
) from exc
@staticmethod
def _storage_format_error(plugin_id: str) -> ExtensionError:
return ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings namespace has an invalid format.",
status_code=500,
details={"plugin_id": plugin_id},
)
def _entry(
self,
data: dict[str, dict[str, Any]],
plugin_id: str,
*,
create: bool = False,
) -> dict[str, Any]:
entry = data.get(plugin_id)
if entry is None:
if create:
data[plugin_id] = {}
return data[plugin_id]
return {}
if not isinstance(entry, dict):
raise self._storage_format_error(plugin_id)
return entry
def _read(self) -> dict[str, dict[str, Any]]:
path = self._path()
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings storage cannot be loaded.",
status_code=500,
) from exc
if not isinstance(value, dict):
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings storage has an invalid format.",
status_code=500,
)
return value
def _write(self, value: dict[str, dict[str, Any]]) -> None:
path = self._path()
temporary = path.with_suffix(".tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(
json.dumps(value, ensure_ascii=False, sort_keys=True),
encoding="utf-8",
)
temporary.replace(path)
except OSError as exc:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
raise ExtensionError(
"PLUGIN_STORAGE_ERROR",
"Plugin settings storage cannot be written.",
status_code=500,
) from exc
def validate_settings_definition(
plugin_id: str, definition: PluginSettingsDefinition
) -> None:
if not _CONTRIBUTION_ID.fullmatch(definition.section_id):
raise _settings_schema_error(plugin_id, "Settings section id is invalid.")
if not definition.section_id.startswith(f"{plugin_id}."):
raise _settings_schema_error(
plugin_id, "Settings section id must use the Plugin namespace."
)
keys: set[str] = set()
for field in definition.fields:
if not _SETTING_KEY.fullmatch(field.key) or field.key in keys:
raise _settings_schema_error(plugin_id, f"Invalid or duplicate setting key: {field.key}")
keys.add(field.key)
if field.type == PluginSettingType.select and not field.options:
raise _settings_schema_error(plugin_id, f"Select setting requires options: {field.key}")
if field.type != PluginSettingType.select and field.options:
raise _settings_schema_error(plugin_id, f"Only select settings accept options: {field.key}")
if field.type != PluginSettingType.number and (
field.minimum is not None or field.maximum is not None
):
raise _settings_schema_error(plugin_id, f"Only number settings accept bounds: {field.key}")
if any(
bound is not None and not math.isfinite(bound)
for bound in (field.minimum, field.maximum)
):
raise _settings_schema_error(
plugin_id, f"Number setting bounds must be finite: {field.key}"
)
if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum:
raise _settings_schema_error(plugin_id, f"Setting bounds are reversed: {field.key}")
if field.type == PluginSettingType.secret and field.default is not None:
raise _settings_schema_error(plugin_id, f"Secret settings cannot declare defaults: {field.key}")
if field.default is not None:
try:
_validate_setting_value(field, field.default)
except ExtensionError as exc:
raise _settings_schema_error(plugin_id, exc.message) from exc
def validate_command_spec(plugin_id: str, spec: PluginCommandSpec) -> None:
if not _CONTRIBUTION_ID.fullmatch(spec.command_id) or not spec.command_id.startswith(
f"{plugin_id}."
):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command id must be valid and use the Plugin namespace.",
details={"plugin_id": plugin_id, "command_id": spec.command_id},
)
if not spec.locations:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command must declare at least one location.",
details={"command_id": spec.command_id},
)
if len(spec.locations) != len(set(spec.locations)):
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command locations must be unique.")
if len(spec.when) != len(set(spec.when)) or len(spec.context) != len(set(spec.context)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command when/context entries must be unique.",
)
if len(spec.secrets) != len(set(spec.secrets)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command Secret entries must be unique.",
details={"command_id": spec.command_id},
)
unknown_when = sorted(set(spec.when) - _WHEN_TOKENS)
if unknown_when:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command declares unsupported when tokens.",
details={"command_id": spec.command_id, "when": unknown_when},
)
required_context = {_WHEN_CONTEXT[token] for token in spec.when}
if not required_context.issubset(set(spec.context)):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command context must include every field required by when.",
details={"command_id": spec.command_id},
)
if not set(spec.context).issubset(_CONTEXT_KEYS):
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Plugin command context is invalid.")
if spec.icon and spec.icon not in _HOST_ICONS:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command icon is not a supported Host icon.",
details={"command_id": spec.command_id, "icon": spec.icon},
)
if spec.parameters.get("type", "object") != "object":
raise ExtensionError("PLUGIN_COMMAND_INVALID", "Command parameters must be an object schema.")
try:
Draft202012Validator.check_schema(spec.parameters)
reject_external_schema_references(spec.parameters)
except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
f"Plugin command parameters contain invalid JSON Schema: {message}",
) from exc
def _validate_setting_value(field: PluginSettingField, value: Any) -> None:
valid = False
if field.type == PluginSettingType.string:
valid = isinstance(value, str) and len(value.encode("utf-8")) <= 64 * 1024
elif field.type == PluginSettingType.number:
valid = (
(isinstance(value, int) and not isinstance(value, bool))
or (isinstance(value, float) and math.isfinite(value))
)
elif field.type == PluginSettingType.boolean:
valid = isinstance(value, bool)
elif field.type == PluginSettingType.select:
valid = isinstance(value, str) and value in field.options
if not valid:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
f"Plugin setting has an invalid value: {field.key}",
details={"key": field.key},
)
if field.type == PluginSettingType.number:
if field.minimum is not None and value < field.minimum:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
f"Plugin setting is below its minimum: {field.key}",
details={"key": field.key, "minimum": field.minimum},
)
if field.maximum is not None and value > field.maximum:
raise ExtensionError(
"PLUGIN_SETTINGS_FIELD_INVALID",
f"Plugin setting is above its maximum: {field.key}",
details={"key": field.key, "maximum": field.maximum},
)
def _secret_field(
definition: PluginSettingsDefinition, plugin_id: str, key: str
) -> PluginSettingField:
field = next((item for item in definition.fields if item.key == key), None)
if field is None or field.type != PluginSettingType.secret:
raise ExtensionError(
"PLUGIN_SECRET_FIELD_NOT_FOUND",
f"Plugin secret field does not exist: {key}",
status_code=404,
details={"plugin_id": plugin_id, "key": key},
)
return field
def _secret_reference(plugin_id: str, key: str) -> str:
digest = hashlib.sha256(f"{plugin_id}\0{key}".encode("utf-8")).hexdigest()
return f"plugin.{digest}"
def _settings_schema_error(plugin_id: str, message: str) -> ExtensionError:
return ExtensionError(
"PLUGIN_SETTINGS_SCHEMA_INVALID",
message,
details={"plugin_id": plugin_id},
)
-21
View File
@@ -1,21 +0,0 @@
from __future__ import annotations
from typing import Any
class ExtensionError(RuntimeError):
"""Extension Core 对 API 暴露的稳定领域错误。"""
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 422,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
+2 -8
View File
@@ -29,10 +29,6 @@ from app.contracts import (
PluginHostStatus,
ToolDefinition,
)
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
MCP_PROTOCOL_VERSION = "2025-11-25"
SUPPORTED_PROTOCOL_VERSIONS = {
@@ -681,12 +677,10 @@ class McpBridge:
)
try:
Draft202012Validator.check_schema(schema)
reject_external_schema_references(schema)
except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
except SchemaError as exc:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"Invalid MCP tool schema for {remote_name}: {message}",
f"Invalid MCP tool schema for {remote_name}: {exc.message}",
) from exc
metadata = raw.get("_meta")
permission = (
+25 -475
View File
@@ -5,40 +5,19 @@ import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
import yaml
from jsonschema import Draft202012Validator
from jsonschema.exceptions import (
SchemaError,
ValidationError as JsonSchemaValidationError,
)
from pydantic import (
BaseModel,
ConfigDict,
Field,
TypeAdapter,
ValidationError,
create_model,
)
from jsonschema.exceptions import SchemaError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
from app.agent.tools import ToolExecutionContext, ToolExecutionError, ToolRegistry
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.agent.permissions import KNOWN_PERMISSIONS
from app.contracts import (
ModelCapability,
Plugin,
PluginCommand,
PluginCommandContext,
PluginCommandEffect,
PluginNoEffect,
PluginNotificationEffect,
PluginCommandLocation,
PluginCommandResult,
PluginManifest,
PluginHostStatus,
PluginSecretStatus,
PluginSettingType,
PluginSettingsSchema,
PluginStatus,
RetrievalConfig,
Skill,
@@ -46,26 +25,27 @@ from app.contracts import (
SkillStatus,
ToolDefinition,
)
from app.extensions.contributions import (
CommandRegistry,
PluginCommandSpec,
PluginSecretResolver,
PluginSettingsDefinition,
PluginSettingsStore,
validate_command_spec,
validate_settings_definition,
)
from app.extensions.errors import ExtensionError
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
from app.providers.credentials import EncryptedCredentialStore
from app.schema_security import (
SchemaReferenceError,
reject_external_schema_references,
)
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
class ExtensionError(RuntimeError):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 422,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
@dataclass(frozen=True, slots=True)
class AgentConfiguration:
skill_id: str
@@ -258,45 +238,14 @@ class DeclarativePluginHost:
return {"text": str(values.get("text", "")).upper()}
raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
async def execute_command(
self,
handler: str,
arguments: dict[str, Any],
context: dict[str, Any],
settings: dict[str, Any],
resolve_secret: PluginSecretResolver,
) -> PluginCommandEffect:
"""执行宿主内置的白名单 Command handler,不导入 Plugin Python 代码。"""
if handler == "echo":
message = str(arguments.get("message", context.get("selection", "")))
if not message:
return PluginNoEffect()
return PluginNotificationEffect(
payload={"level": "info", "message": message},
)
if handler == "uppercase_selection":
text = str(arguments.get("text", context.get("selection", "")))
limit = int(settings.get("result_limit", 100))
return PluginNotificationEffect(
payload={"level": "success", "message": text[:limit].upper()},
)
raise ExtensionError(
"PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported command handler: {handler}"
)
@dataclass(slots=True)
class _PluginRecord:
plugin: Plugin
tools: list[DeclarativeToolSpec]
commands: list[PluginCommandSpec]
settings_definition: PluginSettingsDefinition | None
package_path: Path
registered_tools: list[str]
registered_commands: list[str]
mcp_remote_names: dict[str, str]
mcp_command_schemas: dict[str, dict[str, Any]]
class PluginRuntime:
@@ -307,15 +256,12 @@ class PluginRuntime:
tools: ToolRegistry,
host: DeclarativePluginHost | None = None,
mcp_bridge: McpBridge | None = None,
credentials: EncryptedCredentialStore | None = None,
*,
allow_unsandboxed_mcp: bool = False,
) -> None:
self.registry = tools
self.host = host or DeclarativePluginHost()
self.mcp = mcp_bridge or McpBridge()
self.commands = CommandRegistry()
self.settings = PluginSettingsStore(credentials or EncryptedCredentialStore())
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
self._records: dict[str, _PluginRecord] = {}
self._lock = threading.RLock()
@@ -341,8 +287,6 @@ class PluginRuntime:
_validate_backend(manifest)
specs = [] if manifest.backend.type == "mcp" else self._load_tools(root)
command_specs = self._load_commands(root)
settings_definition = self._load_settings(root)
if manifest.backend.type != "mcp":
declared = set(manifest.contributes.tools)
actual = {spec.name for spec in specs}
@@ -361,89 +305,6 @@ class PluginRuntime:
f"Tool permission is not declared by Plugin: {spec.permission}",
details={"tool": spec.name, "permission": spec.permission},
)
declared_commands = set(manifest.contributes.commands)
actual_commands = {spec.command_id for spec in command_specs}
if (
declared_commands != actual_commands
or len(manifest.contributes.commands) != len(declared_commands)
or len(command_specs) != len(actual_commands)
):
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"plugin.yaml command contributions must exactly match commands.yaml",
details={
"declared": sorted(declared_commands),
"actual": sorted(actual_commands),
},
)
for spec in command_specs:
validate_command_spec(manifest.plugin_id, spec)
if spec.permission and spec.permission not in manifest.permissions:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
f"Command permission is not declared by Plugin: {spec.permission}",
details={"command": spec.command_id, "permission": spec.permission},
)
declared_sections = set(manifest.contributes.settings_sections)
actual_sections = (
{settings_definition.section_id} if settings_definition is not None else set()
)
if (
declared_sections != actual_sections
or len(manifest.contributes.settings_sections) != len(declared_sections)
):
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"plugin.yaml settings contributions must exactly match settings.yaml",
details={
"declared": sorted(declared_sections),
"actual": sorted(actual_sections),
},
)
if settings_definition is not None:
validate_settings_definition(manifest.plugin_id, settings_definition)
secret_fields = (
{
field.key
for field in settings_definition.fields
if field.type == PluginSettingType.secret
}
if settings_definition is not None
else set()
)
for spec in command_specs:
unknown_secrets = sorted(set(spec.secrets) - secret_fields)
if unknown_secrets:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"Plugin command references undeclared Secret settings.",
details={
"command_id": spec.command_id,
"secrets": unknown_secrets,
},
)
if spec.secrets and "secrets.use" not in manifest.permissions:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
"Commands using Secret settings require the secrets.use permission.",
details={"command_id": spec.command_id},
)
if spec.mcp_tool is not None:
_validate_id("MCP command target", spec.mcp_tool)
if manifest.backend.type != "mcp" or not spec.mcp_tool.startswith(
f"{manifest.plugin_id}."
):
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"MCP Command target must use the current Plugin namespace.",
details={"command_id": spec.command_id},
)
if spec.mcp_tool in manifest.contributes.tools:
raise ExtensionError(
"PLUGIN_COMMAND_INVALID",
"MCP Command target cannot also be exposed as an Agent Tool.",
details={"command_id": spec.command_id},
)
record = _PluginRecord(
plugin=Plugin(
@@ -455,13 +316,9 @@ class PluginRuntime:
),
),
tools=specs,
commands=command_specs,
settings_definition=settings_definition,
package_path=root,
registered_tools=[],
registered_commands=[],
mcp_remote_names={},
mcp_command_schemas={},
)
self._records[manifest.plugin_id] = record
return record.plugin.model_copy(deep=True)
@@ -502,8 +359,6 @@ class PluginRuntime:
status_code=403,
details={"plugin_id": plugin_id},
)
if record.settings_definition is not None:
self.settings.runtime_values(plugin_id, record.settings_definition)
declared_tools = list(record.plugin.manifest.contributes.tools)
conflicts = [name for name in declared_tools if self.registry.contains(name)]
if conflicts:
@@ -513,49 +368,20 @@ class PluginRuntime:
status_code=409,
details={"plugin_id": plugin_id, "tools": conflicts},
)
command_conflicts = [
spec.command_id for spec in record.commands if self.commands.contains(spec.command_id)
]
if command_conflicts:
raise ExtensionError(
"PLUGIN_COMMAND_CONFLICT",
"Plugin commands are already registered.",
status_code=409,
details={"plugin_id": plugin_id, "commands": command_conflicts},
)
record.plugin.status = PluginStatus.starting
try:
if record.plugin.manifest.backend.type == "mcp":
discovered = self._start_mcp(record)
actual = {item.definition.name for item in discovered}
declared = set(declared_tools)
command_targets = {
spec.mcp_tool for spec in record.commands if spec.mcp_tool is not None
}
expected = declared | command_targets
if actual != expected:
if actual != declared:
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"Discovered MCP tools must exactly match Tool and Command targets.",
details={"declared": sorted(expected), "actual": sorted(actual)},
"Discovered MCP tools must exactly match Plugin contributions.",
details={"declared": sorted(declared), "actual": sorted(actual)},
)
for item in discovered:
if item.definition.name in declared:
self._register_mcp_tool(record, item)
else:
record.mcp_remote_names[item.definition.name] = item.remote_name
record.mcp_command_schemas[item.definition.name] = (
item.definition.parameters
)
for spec in (
command
for command in record.commands
if command.mcp_tool == item.definition.name
):
_validate_mcp_command_target_schema(
item.definition.parameters,
spec.command_id,
)
self._register_mcp_tool(record, item)
else:
for spec in record.tools:
arguments_model = _arguments_model(spec)
@@ -579,135 +405,12 @@ class PluginRuntime:
executor,
)
record.registered_tools.append(spec.name)
for spec in record.commands:
async def command_executor(
arguments: dict[str, Any],
context: dict[str, Any],
_spec: PluginCommandSpec = spec,
_record: _PluginRecord = record,
) -> PluginCommandEffect:
if (
not _record.plugin.enabled
or _record.plugin.status != PluginStatus.ready
):
raise ExtensionError(
"PLUGIN_COMMAND_NOT_FOUND",
"Plugin command is not available while its Plugin is inactive.",
status_code=404,
details={"command_id": _spec.command_id},
)
settings = (
self.settings.runtime_values(
_record.plugin.manifest.plugin_id,
_record.settings_definition,
)
if _record.settings_definition is not None
else {}
)
def resolve_secret(key: str) -> str | None:
if key not in _spec.secrets:
raise ExtensionError(
"PLUGIN_SECRET_ACCESS_DENIED",
"Command cannot access an undeclared Plugin Secret.",
status_code=403,
details={
"command_id": _spec.command_id,
"key": key,
},
)
if "secrets.use" not in _record.plugin.granted_permissions:
raise ExtensionError(
"PLUGIN_SECRET_ACCESS_DENIED",
"Plugin no longer has permission to access Secret settings.",
status_code=403,
details={"command_id": _spec.command_id, "key": key},
)
if _record.settings_definition is None:
return None
value = self.settings.resolve_secret(
_record.plugin.manifest.plugin_id,
_record.settings_definition,
key,
)
field = next(
item
for item in _record.settings_definition.fields
if item.key == key
)
if field.required and value is None:
raise ExtensionError(
"PLUGIN_SECRET_REQUIRED",
"A required Plugin Secret has not been configured.",
status_code=409,
details={"command_id": _spec.command_id, "key": key},
)
return value
if _spec.mcp_tool is not None:
remote_name = _record.mcp_remote_names[_spec.mcp_tool]
secret_values = {
key: value
for key in _spec.secrets
if (value := resolve_secret(key)) is not None
}
envelope = _mcp_command_envelope(
_spec,
arguments=arguments,
context=context,
settings=settings,
secrets=secret_values,
)
_validate_mcp_command_envelope(
_record.mcp_command_schemas[_spec.mcp_tool],
envelope,
_spec.command_id,
)
try:
effect = await self.mcp.call_tool(
_record.plugin.manifest.plugin_id,
remote_name,
envelope,
request_id=f"command:{uuid4().hex}",
)
except ToolExecutionError as exc:
raise ExtensionError(
exc.code,
"MCP Command target execution failed.",
status_code=502,
details={"command_id": _spec.command_id},
) from exc
try:
return TypeAdapter(PluginCommandEffect).validate_python(effect)
except ValidationError as exc:
raise ExtensionError(
"PLUGIN_COMMAND_RESULT_INVALID",
"MCP Command target returned an invalid effect.",
status_code=502,
details={"command_id": _spec.command_id},
) from exc
return await self.host.execute_command(
_spec.handler,
arguments,
context,
settings,
resolve_secret,
)
self.commands.register(plugin_id, spec, command_executor)
record.registered_commands.append(spec.command_id)
except Exception as exc:
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
self.mcp.stop(plugin_id)
record.plugin.status = PluginStatus.error
record.plugin.error_message = _safe_extension_message(exc)
@@ -765,11 +468,7 @@ class PluginRuntime:
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id)
record.plugin.enabled = False
@@ -780,43 +479,6 @@ class PluginRuntime:
record = self._record(plugin_id)
return self.mcp.status(plugin_id, record.plugin.manifest.backend)
def list_commands(
self, location: PluginCommandLocation | None = None
) -> list[PluginCommand]:
return self.commands.list(location)
async def execute_command(
self,
command_id: str,
arguments: dict[str, Any],
context: PluginCommandContext,
) -> PluginCommandResult:
return await self.commands.execute(command_id, arguments, context)
def get_settings(self, plugin_id: str) -> PluginSettingsSchema:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.get(plugin_id, definition)
def update_settings(
self, plugin_id: str, schema_version: int, values: dict[str, Any]
) -> PluginSettingsSchema:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.update(plugin_id, definition, schema_version, values)
def put_setting_secret(
self, plugin_id: str, key: str, secret: str
) -> PluginSecretStatus:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.put_secret(plugin_id, definition, key, secret)
def delete_setting_secret(self, plugin_id: str, key: str) -> PluginSecretStatus:
record = self._record(plugin_id)
definition = self._settings_definition(record)
return self.settings.delete_secret(plugin_id, definition, key)
def restart_host(self, plugin_id: str) -> PluginHostStatus:
with self._lock:
return self._restart_host(plugin_id)
@@ -844,11 +506,7 @@ class PluginRuntime:
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
self.mcp.stop(plugin_id)
record.plugin.enabled = False
record.plugin.status = PluginStatus.installed
@@ -909,11 +567,7 @@ class PluginRuntime:
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
for command_id in record.registered_commands:
self.commands.unregister(command_id)
record.registered_commands.clear()
record.mcp_remote_names.clear()
record.mcp_command_schemas.clear()
record.plugin.enabled = False
record.plugin.status = PluginStatus.error
record.plugin.error_message = message
@@ -940,7 +594,6 @@ class PluginRuntime:
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
self.mcp.remove(plugin_id)
self.settings.remove_plugin(plugin_id)
del self._records[plugin_id]
def _record(self, plugin_id: str) -> _PluginRecord:
@@ -962,52 +615,6 @@ class PluginRuntime:
except ValidationError as exc:
raise _manifest_error("plugin tool", exc) from exc
@staticmethod
def _load_commands(root: Path) -> list[PluginCommandSpec]:
path = root / "commands.yaml"
if not path.exists():
return []
raw = _read_yaml(path)
items = raw.get("commands", [])
if not isinstance(items, list):
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"Invalid plugin command manifest: commands must be an array.",
)
try:
return [
PluginCommandSpec.model_validate(item)
for item in items
]
except ValidationError as exc:
raise _manifest_error("plugin command", exc) from exc
@staticmethod
def _load_settings(root: Path) -> PluginSettingsDefinition | None:
path = root / "settings.yaml"
if not path.exists():
return None
raw = _read_yaml(path)
try:
return PluginSettingsDefinition.model_validate(raw)
except ValidationError as exc:
raise ExtensionError(
"PLUGIN_SETTINGS_SCHEMA_INVALID",
"Invalid Plugin settings schema.",
details={"errors": exc.errors(include_url=False)},
) from exc
@staticmethod
def _settings_definition(record: _PluginRecord) -> PluginSettingsDefinition:
if record.settings_definition is None:
raise ExtensionError(
"PLUGIN_SETTINGS_NOT_FOUND",
"Plugin does not contribute a Settings section.",
status_code=404,
details={"plugin_id": record.plugin.manifest.plugin_id},
)
return record.settings_definition
def _package_dir(package_path: str | Path) -> Path:
root = Path(package_path).expanduser().resolve()
@@ -1066,61 +673,6 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
return _arguments_model_from_schema(spec.name, schema)
def _mcp_command_envelope(
spec: PluginCommandSpec,
*,
arguments: dict[str, Any],
context: dict[str, Any],
settings: dict[str, Any],
secrets: dict[str, str],
) -> dict[str, Any]:
return {
"_notesagent": {
"command_id": spec.command_id,
"arguments": arguments,
"context": context,
"settings": settings,
"secrets": secrets,
}
}
def _validate_mcp_command_envelope(
schema: dict[str, Any],
envelope: dict[str, Any],
command_id: str,
) -> None:
"""执行前用目标 Tool Schema 校验包含真实业务数据的宿主信封。"""
try:
Draft202012Validator(schema).validate(envelope)
except JsonSchemaValidationError as exc:
raise ExtensionError(
"PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH",
"MCP Command envelope does not match the target inputSchema.",
status_code=502,
details={"command_id": command_id, "path": list(exc.path)},
) from exc
def _validate_mcp_command_target_schema(
schema: dict[str, Any], command_id: str
) -> None:
"""启用时只检查稳定信封入口,避免用伪造业务值误判合法 Schema。"""
properties = schema.get("properties")
envelope_schema = (
properties.get("_notesagent") if isinstance(properties, dict) else None
)
if not isinstance(envelope_schema, dict) or envelope_schema.get("type") != "object":
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"MCP Command target inputSchema must directly declare "
"_notesagent with type object.",
details={"command_id": command_id},
)
def _arguments_model_from_schema(
tool_name: str, schema: dict[str, Any]
) -> type[BaseModel]:
@@ -1136,12 +688,10 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
schema = spec.parameters or {"type": "object", "properties": {}}
try:
Draft202012Validator.check_schema(schema)
reject_external_schema_references(schema)
except (SchemaReferenceError, SchemaError) as exc:
message = exc.message if isinstance(exc, SchemaError) else str(exc)
except SchemaError as exc:
raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID",
f"Invalid JSON Schema for tool {spec.name}: {message}",
f"Invalid JSON Schema for tool {spec.name}: {exc.message}",
details={"tool": spec.name},
) from exc
if schema.get("type", "object") != "object" or not isinstance(
+10 -58
View File
@@ -13,7 +13,6 @@ from app.config import get_settings
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_PLUGIN_CREDENTIAL_PREFIX = "plugin."
class CredentialStoreError(RuntimeError):
@@ -24,15 +23,6 @@ class CredentialResolver(Protocol):
def resolve(self, credential_id: str | None) -> str | None: ...
def validate_provider_credential_id(credential_id: str | None) -> None:
"""阻止 Provider 和通用凭据 API 跨入 Plugin 私有命名空间。"""
if credential_id and credential_id.casefold().startswith(
_PLUGIN_CREDENTIAL_PREFIX
):
raise CredentialStoreError("Credential namespace is reserved for Plugin settings.")
class EnvironmentCredentialResolver:
"""解析由桌面 Host 注入 Sidecar 进程的临时凭证上下文。"""
@@ -119,26 +109,17 @@ class EncryptedCredentialStore:
def _write_tokens(self, tokens: dict[str, str]) -> None:
_, store_path = self._paths()
store_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(store_path.parent, 0o700)
temporary = store_path.with_suffix(".tmp")
try:
store_path.parent.mkdir(parents=True, exist_ok=True)
self._restrict(store_path.parent, 0o700)
temporary.write_text(
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
encoding="utf-8",
)
self._restrict(temporary, 0o600)
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
temporary.replace(store_path)
self._restrict(store_path, 0o600)
except OSError as exc:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
raise CredentialStoreError(
"Encrypted credential store cannot be written."
) from exc
temporary.write_text(
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
encoding="utf-8",
)
self._restrict(temporary, 0o600)
# 凭据表同样使用原子替换,确保并发读取只会看到完整 JSON。
temporary.replace(store_path)
self._restrict(store_path, 0o600)
def put(self, credential_id: str, secret: str) -> None:
self._validate_id(credential_id)
@@ -177,24 +158,6 @@ class EncryptedCredentialStore:
self._write_tokens(tokens)
return removed
def delete_many(self, credential_ids: list[str]) -> set[str]:
"""用一次原子替换删除多个凭据,避免插件卸载只删除部分 Secret。"""
for credential_id in credential_ids:
self._validate_id(credential_id)
with self._lock:
tokens = self._read_tokens()
removed = {
credential_id
for credential_id in credential_ids
if credential_id in tokens
}
if removed:
for credential_id in removed:
del tokens[credential_id]
self._write_tokens(tokens)
return removed
class ChainedCredentialResolver:
def __init__(self, *resolvers: CredentialResolver) -> None:
@@ -207,14 +170,3 @@ class ChainedCredentialResolver:
if value:
return value
return None
class ProviderCredentialResolver:
"""Provider 专用防御层,避免配置绕过 HTTP 校验读取 Plugin Secret。"""
def __init__(self, delegate: CredentialResolver) -> None:
self._delegate = delegate
def resolve(self, credential_id: str | None) -> str | None:
validate_provider_credential_id(credential_id)
return self._delegate.resolve(credential_id)
+2 -4
View File
@@ -1,6 +1,6 @@
from app.contracts import ModelCapability, ProviderConfig, ProviderPreset, ProviderType
from app.providers.base import ModelProvider
from app.providers.credentials import CredentialResolver, ProviderCredentialResolver
from app.providers.credentials import CredentialResolver
from app.providers.ollama import OllamaProvider
from app.providers.openai_compatible import OpenAICompatibleProvider
@@ -11,9 +11,7 @@ class UnsupportedProviderError(ValueError):
class ProviderFactory:
def __init__(self, credentials: CredentialResolver) -> None:
# ProviderFactory 是所有可配置 Provider 的创建边界,在此统一禁止
# Provider 借用 Plugin Secret 引用,避免调用方漏包安全 Resolver。
self.credentials = ProviderCredentialResolver(credentials)
self.credentials = credentials
def build(self, config: ProviderConfig) -> ModelProvider:
if config.provider_type in {
+2
View File
@@ -19,6 +19,7 @@ class EmbeddingProvider(Protocol):
"""统一 Embedding 接口(与文档一致)。"""
model_id: str
version: str
dim: int
async def embed_documents(self, texts: list[str]) -> list[list[float]]: ...
@@ -33,6 +34,7 @@ class HashEmbeddingProvider:
"""
model_id = "hash-v1"
version = "1"
dim = EMBEDDING_DIM
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
+39 -22
View File
@@ -29,6 +29,8 @@ from app.textutils import make_snippet, match_query
CANDIDATE_POOL = 50
# 分页窗口上限:候选池至少覆盖 offset+limit,但设上限防止超大 offset 撑爆内存
MAX_CANDIDATE_POOL = 200
# FTS 全量取回上限:统一归一化 + 阈值过滤后再分页,保证阈值语义跨页一致
FTS_FETCH_LIMIT = 5000
# 带 metadata 过滤时放大召回倍数,缓解「先截断候选池再过滤」造成的漏召回
OVERSCAN_FACTOR = 4
@@ -84,7 +86,7 @@ class RetrievalEngine:
elif request.mode == SearchMode.vector:
candidate_scores = vec_scores
else: # hybridRRF 融合
candidate_scores = rrf_fuse([fts_ranked, vec_ranked])
candidate_scores = rrf_fuse([fts_ranked, vec_ranked], k=request.rrf_k)
if not candidate_scores:
return self._empty(request)
@@ -97,14 +99,23 @@ class RetrievalEngine:
if not filtered:
return self._empty(request)
# 4. 排序 / 精排
# 4. 排序 / 精排:hybrid 先按融合分预排序,再对前 rerank_candidates 个候选做精排,
# 剩余候选按融合分排在精排结果之后;rerank=False 时跳过精排直接按融合分排序。
if request.mode == SearchMode.hybrid:
candidates = [
RankedCandidate(block_id=h.block_id, score=candidate_scores[h.block_id], text=h.content)
for h in filtered
]
ranked = await self.reranker.rerank(request.query, candidates)
ordered = [(c.block_id, c.score) for c in ranked]
pre_sorted = sorted(filtered, key=lambda h: -candidate_scores[h.block_id])
if request.rerank:
limit = request.rerank_candidates
pool = pre_sorted if limit is None else pre_sorted[:limit]
rest = [] if limit is None else pre_sorted[limit:]
candidates = [
RankedCandidate(block_id=h.block_id, score=candidate_scores[h.block_id], text=h.content)
for h in pool
]
ranked = await self.reranker.rerank(request.query, candidates)
ordered = [(c.block_id, c.score) for c in ranked]
ordered += [(h.block_id, candidate_scores[h.block_id]) for h in rest]
else:
ordered = [(h.block_id, candidate_scores[h.block_id]) for h in pre_sorted]
else:
ordered = sorted(
((h.block_id, candidate_scores[h.block_id]) for h in filtered),
@@ -112,6 +123,8 @@ class RetrievalEngine:
)
ordered = normalize_scores(ordered)
# score_threshold:归一化后过滤低分结果(默认 0 不过滤)
ordered = [(bid, score) for bid, score in ordered if score >= request.score_threshold]
# 5. 分页:total = 过滤后候选集大小。fts 已取全量(≤FTS_FETCH_LIMIT)故为真实命中数;
# vector/hybrid 为 KNN 候选集,无全局 total。
@@ -126,15 +139,18 @@ class RetrievalEngine:
)
def _search_fts(self, request: SearchRequest) -> SearchResponse:
"""FTS 专用路径:过滤、COUNT 与分页全部在 SQLite 中完成。"""
"""FTS 专用路径:先取全量命中(≤FTS_FETCH_LIMIT),统一归一化 + 阈值过滤后再分页。
阈值过滤必须在计数与分页之前完成否则 score_threshold 只作用于当前页
且返回的 total items 数量不一致 items 为空但 total 非零"""
match = match_query(request.query)
if not match:
return self._empty(request)
fts_hits, total = repository.fts_search_page(
fts_hits, _ = repository.fts_search_page(
match=match,
limit=request.limit,
offset=request.offset,
limit=FTS_FETCH_LIMIT,
offset=0,
folders=request.folders,
note_ids=request.note_ids,
tags=request.tags,
@@ -144,17 +160,18 @@ class RetrievalEngine:
updated_to=request.updated_to,
)
if not fts_hits:
return SearchResponse(
query=request.query,
mode=request.mode,
page=PageMeta(total=total, limit=request.limit, offset=request.offset),
)
return self._empty(request)
hits = {h.block_id: h for h in repository.get_block_hits([hit.block_id for hit in fts_hits])}
ordered = normalize_scores(
[(hit.block_id, -hit.bm25) for hit in fts_hits if hit.block_id in hits]
)
items = [self._build_result(hits[block_id], request, score) for block_id, score in ordered]
ordered = normalize_scores([(hit.block_id, -hit.bm25) for hit in fts_hits])
ordered = [(bid, score) for bid, score in ordered if score >= request.score_threshold]
total = len(ordered)
page = ordered[request.offset : request.offset + request.limit]
hits = {h.block_id: h for h in repository.get_block_hits([bid for bid, _ in page])}
items = [
self._build_result(hits[block_id], request, score)
for block_id, score in page
if block_id in hits
]
return SearchResponse(
query=request.query,
mode=request.mode,
+2
View File
@@ -24,6 +24,7 @@ class RerankerProvider(Protocol):
"""统一 Reranker 接口:输入候选块,输出按相关性重排后的候选块。"""
model_id: str
version: str
async def rerank(self, query: str, candidates: list[RankedCandidate]) -> list[RankedCandidate]: ...
@@ -32,6 +33,7 @@ class LexicalReranker:
"""轻量精排:query 与块正文的词面重叠度,与归一化后的原始分数加权求和。"""
model_id = "lexical-v1"
version = "1"
def __init__(self, lexical_weight: float = 0.5) -> None:
self.lexical_weight = lexical_weight
+8
View File
@@ -35,6 +35,7 @@ class VectorStore(Protocol):
async def upsert(self, records: list[VectorRecord]) -> None: ...
async def delete(self, ids: list[str]) -> None: ...
async def search(self, vector: list[float], *, top_k: int) -> list[VectorHit]: ...
async def count(self) -> int: ...
class SqliteVecStore:
@@ -92,3 +93,10 @@ class SqliteVecStore:
conn.execute("DELETE FROM vec_blocks")
finally:
conn.close()
async def count(self) -> int:
conn = connect()
try:
return conn.execute("SELECT COUNT(*) FROM vec_blocks").fetchone()[0]
finally:
conn.close()
+168 -106
View File
@@ -12,6 +12,14 @@ from app.contracts import (
AgentRunListResponse,
AgentTraceResponse,
ChatRequest,
BenchmarkDatasetListResponse,
BenchmarkEventType,
BenchmarkKind,
BenchmarkReport,
BenchmarkRun,
BenchmarkRunListResponse,
BenchmarkStatus,
RAGRunRequest,
CredentialStatus,
CredentialWriteRequest,
ExtensionInstallRequest,
@@ -33,17 +41,9 @@ from app.contracts import (
PageMeta,
PermissionDecisionRequest,
Plugin,
PluginCommandExecuteRequest,
PluginCommandListResponse,
PluginCommandLocation,
PluginCommandResult,
PluginHostStatus,
PluginListResponse,
PluginPermissionGrantRequest,
PluginSecretStatus,
PluginSecretWriteRequest,
PluginSettingsSchema,
PluginSettingsUpdateRequest,
ProviderConfig,
ProviderCreateRequest,
ProviderListResponse,
@@ -69,16 +69,15 @@ from app.contracts import (
WorkspaceSnapshot,
)
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.benchmarks import datasets as benchmark_datasets
from app.benchmarks import service as benchmark_service
from app.container import container
from app.errors import ApiError
from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError
from app.providers.base import ProviderError
from app.providers.credentials import (
CredentialStoreError,
validate_provider_credential_id,
)
from app.providers.credentials import CredentialStoreError
from app.retrieval.engine import engine
from app.services import (
index_service,
@@ -95,13 +94,6 @@ def utc_now() -> datetime:
return datetime.now(timezone.utc)
def validate_public_credential_id(credential_id: str | None) -> None:
try:
validate_provider_credential_id(credential_id)
except CredentialStoreError as exc:
raise ApiError(422, "CREDENTIAL_NAMESPACE_RESERVED", str(exc)) from exc
def as_sse(event: str, payload: str, *, event_id: int | None = None) -> str:
id_line = f"id: {event_id}\n" if event_id is not None else ""
return f"{id_line}event: {event}\ndata: {payload}\n\n"
@@ -577,86 +569,6 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
# Plugin Command / Settings Contributions
@router.get(
"/plugin-contributions/commands",
response_model=PluginCommandListResponse,
tags=["Plugins"],
)
async def list_plugin_commands(
location: PluginCommandLocation | None = Query(default=None),
) -> PluginCommandListResponse:
return PluginCommandListResponse(items=container.plugins.list_commands(location))
@router.post(
"/plugin-contributions/commands/{command_id}/execute",
response_model=PluginCommandResult,
tags=["Plugins"],
)
async def execute_plugin_command(
command_id: str, request: PluginCommandExecuteRequest
) -> PluginCommandResult:
try:
return await container.plugins.execute_command(
command_id, request.arguments, request.context
)
except ExtensionError as exc:
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
@router.get(
"/plugins/{plugin_id}/settings",
response_model=PluginSettingsSchema,
tags=["Plugins"],
)
async def get_plugin_settings(plugin_id: str) -> PluginSettingsSchema:
return extension_call(lambda: container.plugins.get_settings(plugin_id))
@router.put(
"/plugins/{plugin_id}/settings",
response_model=PluginSettingsSchema,
tags=["Plugins"],
)
async def update_plugin_settings(
plugin_id: str, request: PluginSettingsUpdateRequest
) -> PluginSettingsSchema:
return extension_call(
lambda: container.plugins.update_settings(
plugin_id, request.schema_version, request.values
)
)
@router.put(
"/plugins/{plugin_id}/settings/{key}/secret",
response_model=PluginSecretStatus,
tags=["Plugins"],
)
async def put_plugin_setting_secret(
plugin_id: str, key: str, request: PluginSecretWriteRequest
) -> PluginSecretStatus:
return extension_call(
lambda: container.plugins.put_setting_secret(
plugin_id, key, request.secret.get_secret_value()
)
)
@router.delete(
"/plugins/{plugin_id}/settings/{key}/secret",
response_model=PluginSecretStatus,
tags=["Plugins"],
)
async def delete_plugin_setting_secret(
plugin_id: str, key: str
) -> PluginSecretStatus:
return extension_call(
lambda: container.plugins.delete_setting_secret(plugin_id, key)
)
# Providers
@router.get(
"/credentials/{credential_id}",
@@ -664,7 +576,6 @@ async def delete_plugin_setting_secret(
tags=["Providers"],
)
async def get_credential_status(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try:
configured = container.credentials.has(credential_id)
except CredentialStoreError as exc:
@@ -680,7 +591,6 @@ async def get_credential_status(credential_id: str) -> CredentialStatus:
async def put_credential(
credential_id: str, request: CredentialWriteRequest
) -> CredentialStatus:
validate_public_credential_id(credential_id)
try:
container.credentials.put(credential_id, request.api_key.get_secret_value())
except CredentialStoreError as exc:
@@ -694,7 +604,6 @@ async def put_credential(
tags=["Providers"],
)
async def delete_credential(credential_id: str) -> CredentialStatus:
validate_public_credential_id(credential_id)
try:
container.credentials.delete(credential_id)
except CredentialStoreError as exc:
@@ -731,7 +640,6 @@ async def get_provider(provider_id: str) -> ProviderConfig:
tags=["Providers"],
)
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
validate_public_credential_id(request.credential_id)
config = ProviderConfig(
provider_id=f"provider_{uuid4().hex}",
provider_type=request.provider_type,
@@ -775,8 +683,6 @@ async def update_provider(
"name and enabled cannot be null when explicitly provided.",
)
updates = {name: getattr(request, name) for name in fields}
if "credential_id" in fields:
validate_public_credential_id(request.credential_id)
config = ProviderConfig.model_validate(
{**current.model_dump(mode="python"), **updates}
)
@@ -836,7 +742,6 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
registered = configurable_provider_or_404(request.provider_id)
if request.credential_context_id:
validate_public_credential_id(request.credential_context_id)
temporary_config = registered.config.model_copy(
update={"credential_id": request.credential_context_id, "enabled": True}
)
@@ -939,3 +844,160 @@ async def get_index_job(job_id: str) -> IndexJob:
if job is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id})
return job
# Benchmark
@router.get(
"/benchmarks/datasets",
response_model=BenchmarkDatasetListResponse,
tags=["Benchmark"],
)
async def list_benchmark_datasets(
kind: BenchmarkKind = Query(default=BenchmarkKind.rag),
) -> BenchmarkDatasetListResponse:
return BenchmarkDatasetListResponse(items=benchmark_datasets.list_datasets(kind))
@router.post(
"/benchmarks/rag/runs",
response_model=BenchmarkRun,
status_code=202,
tags=["Benchmark"],
)
async def create_rag_benchmark(request: RAGRunRequest) -> BenchmarkRun:
return await benchmark_service.create_rag_run(request)
@router.get(
"/benchmarks/runs",
response_model=BenchmarkRunListResponse,
tags=["Benchmark"],
)
async def list_benchmark_runs(
kind: BenchmarkKind | None = Query(default=None),
status: BenchmarkStatus | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> BenchmarkRunListResponse:
items, total = benchmark_service.list_runs(
kind=kind, status=status, limit=limit, offset=offset
)
return BenchmarkRunListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@router.get(
"/benchmarks/runs/{run_id}",
response_model=BenchmarkRun,
tags=["Benchmark"],
)
async def get_benchmark_run(run_id: str) -> BenchmarkRun:
run = benchmark_service.get_run(run_id)
if run is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run not found", {"run_id": run_id}
)
return run
@router.post(
"/benchmarks/runs/{run_id}/cancel",
response_model=OperationResponse,
tags=["Benchmark"],
)
async def cancel_benchmark_run(run_id: str) -> OperationResponse:
run = benchmark_service.cancel_run(run_id)
if run is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run not found", {"run_id": run_id}
)
return OperationResponse(
status="accepted",
resource_id=run_id,
message=f"Benchmark run status: {run.status.value}",
)
@router.get(
"/benchmarks/runs/{run_id}/events",
response_class=StreamingResponse,
responses={
200: {
"description": "BenchmarkEvent Server-Sent Events stream",
"content": {"text/event-stream": {}},
}
},
tags=["Benchmark"],
)
async def benchmark_events(
run_id: str,
after_sequence: int = Query(default=-1, ge=-1),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
) -> StreamingResponse:
if benchmark_service.get_run(run_id) is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run not found", {"run_id": run_id}
)
# SSE 断线重连:Last-Event-ID 优先于 after_sequence,用于从上次收到的事件继续
cursor = after_sequence
if last_event_id is not None:
try:
cursor = int(last_event_id)
except ValueError as exc:
raise ApiError(
400,
"BENCHMARK_EVENT_CURSOR_INVALID",
"Last-Event-ID must be an integer sequence.",
{"last_event_id": last_event_id},
) from exc
if cursor < -1:
raise ApiError(
400,
"BENCHMARK_EVENT_CURSOR_INVALID",
"Last-Event-ID must be greater than or equal to -1.",
)
async def stream() -> AsyncIterator[str]:
# 先订阅(保证订阅之后产生的事件也能收到),再回放历史事件,最后实时输出新事件
queue = benchmark_service.subscribe(run_id)
last_sequence = cursor
for event in benchmark_service.get_events(run_id):
if event.sequence <= cursor:
continue
yield as_sse(event.event.value, event.model_dump_json(), event_id=event.sequence)
last_sequence = event.sequence
if queue is None:
return
try:
while True:
event = await queue.get()
if event.sequence <= last_sequence:
continue
yield as_sse(event.event.value, event.model_dump_json(), event_id=event.sequence)
last_sequence = event.sequence
if event.event in (
BenchmarkEventType.run_completed,
BenchmarkEventType.run_failed,
BenchmarkEventType.run_cancelled,
):
break
finally:
benchmark_service.unsubscribe(run_id, queue)
return StreamingResponse(stream(), media_type="text/event-stream")
@router.get(
"/benchmarks/runs/{run_id}/report",
response_model=BenchmarkReport,
tags=["Benchmark"],
)
async def get_benchmark_report(run_id: str) -> BenchmarkReport:
report = benchmark_service.get_report(run_id)
if report is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark report not found", {"run_id": run_id}
)
return report
-60
View File
@@ -1,60 +0,0 @@
"""共享 JSON Schema 安全约束。"""
from __future__ import annotations
from typing import Any
from urllib.parse import urljoin
from referencing import Registry
from referencing.exceptions import Unresolvable
from referencing.jsonschema import DRAFT202012
_SCHEMA_BASE_URI = "https://notesagent.invalid/local-schema"
class SchemaReferenceError(ValueError):
"""Schema 引用不符合宿主的离线、文档内解析约束。"""
class ExternalSchemaReferenceError(SchemaReferenceError):
def __init__(self, keyword: str, reference: Any) -> None:
super().__init__(f"External JSON Schema reference is not allowed: {reference!r}")
self.keyword = keyword
self.reference = reference
class UnresolvableLocalSchemaReferenceError(SchemaReferenceError):
def __init__(self, reference: str) -> None:
super().__init__(f"Local JSON Schema reference cannot be resolved: {reference!r}")
self.reference = reference
def reject_external_schema_references(schema: Any) -> None:
"""只允许可解析的文档内 Fragment,并按 JSON Schema Resource 作用域解析。"""
root = DRAFT202012.create_resource(schema)
root_uri = urljoin(_SCHEMA_BASE_URI, root.id() or "")
registry = Registry().with_resource(_SCHEMA_BASE_URI, root).crawl()
resolver = registry.resolver(root_uri)
_validate_resource_references(root, resolver)
def _validate_resource_references(resource, resolver: Any) -> None:
contents = resource.contents
if isinstance(contents, dict):
for keyword in ("$ref", "$dynamicRef"):
if keyword not in contents:
continue
reference = contents[keyword]
if not isinstance(reference, str) or not reference.startswith("#"):
raise ExternalSchemaReferenceError(keyword, reference)
try:
resolver.lookup(reference)
except Unresolvable as exc:
raise UnresolvableLocalSchemaReferenceError(reference) from exc
for subresource in resource.subresources():
_validate_resource_references(
subresource,
resolver.in_subresource(subresource),
)
+48
View File
@@ -0,0 +1,48 @@
{
"dataset_id": "rag-core-v1",
"kind": "rag",
"version": "1.0.0",
"description": "基础中文笔记检索集(对应 backend/data/vault 内置语料,重建索引后即可复现)",
"cases": [
{
"case_id": "rag-vector-sim",
"query": "向量数据库如何进行相似度检索",
"expected_note_ids": ["note_c1454740a0e55ef5"],
"expected_block_ids": ["blk_07c4c6bce0ec4d12", "blk_605fb3593809f224"],
"citation_required": true,
"tags": ["向量数据库", "检索"]
},
{
"case_id": "rag-python-func",
"query": "Python 如何定义函数",
"expected_note_ids": ["note_424c3742c6f0e555"],
"expected_block_ids": ["blk_45d48cae2fed40fe", "blk_0768d9c25c2ecf07"],
"citation_required": true,
"tags": ["python"]
},
{
"case_id": "rag-citation",
"query": "搜索结果如何定位到原文位置",
"expected_note_ids": ["note_0c619caa30b1614c"],
"expected_block_ids": ["blk_3f6fcead71c25fc6", "blk_9af7b12e9ce909fc"],
"citation_required": true,
"tags": ["RAG"]
},
{
"case_id": "rag-hybrid",
"query": "混合检索怎么融合全文和向量",
"expected_note_ids": ["note_c1454740a0e55ef5"],
"expected_block_ids": ["blk_82b45418dba9f720"],
"citation_required": true,
"tags": ["检索"]
},
{
"case_id": "rag-tech-stack",
"query": "这个项目用什么后端和检索技术",
"expected_note_ids": ["note_3327e6cf18f3701f"],
"expected_block_ids": ["blk_feb2a9c42e7d31ad"],
"citation_required": false,
"tags": ["项目"]
}
]
}
@@ -1,20 +0,0 @@
commands:
- command_id: mcp-fixture.notify
title: MCP 通知
description: 通过隔离 MCP Host 返回宿主白名单通知 effect。
icon: bolt
locations:
- command_palette
when:
- editor.has_selection
context:
- selection
secrets:
- api_key
mcp_tool: mcp-fixture.command
parameters:
type: object
properties:
message:
type: string
additionalProperties: false
@@ -1,10 +1,9 @@
id: mcp-fixture
name: MCP Fixture
version: 1.0.0
description: 阶段 C/D 离线联调 Fixture,覆盖 MCP Tool、Command 与错误边界。
description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。
permissions:
- notes.read
- secrets.use
contributes:
tools:
- mcp-fixture.echo
@@ -13,10 +12,6 @@ contributes:
- mcp-fixture.large
- mcp-fixture.environment
- mcp-fixture.exit
commands:
- mcp-fixture.notify
settings_sections:
- mcp-fixture.general
backend:
type: mcp
transport: stdio
+1 -45
View File
@@ -54,11 +54,6 @@ TOOLS = {
"large": tool("large", "Return a result larger than the host limit."),
"environment": tool("environment", "Report whether host secrets leaked into the process."),
"exit": tool("exit", "Terminate the fixture process."),
"command": tool(
"command",
"Execute a NotesAgent Plugin Command envelope.",
{"_notesagent": {"type": "object"}},
),
}
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
TOOLS["echo"]["inputSchema"]["required"] = ["text"]
@@ -67,38 +62,6 @@ TOOLS["echo"]["inputSchema"]["required"] = ["text"]
def call_tool(request_id: int, params: dict[str, Any]) -> None:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "command":
envelope = arguments.get("_notesagent") or {}
command_arguments = envelope.get("arguments") or {}
context = envelope.get("context") or {}
settings = envelope.get("settings") or {}
secrets = envelope.get("secrets") or {}
if not isinstance(secrets.get("api_key"), str):
respond(
request_id,
{
"content": [{"type": "text", "text": "declared secret missing"}],
"isError": True,
},
)
return
message = command_arguments.get("message") or context.get("selection") or ""
message = f"{settings.get('message_prefix', '')}{message}"
respond(
request_id,
{
"content": [{"type": "text", "text": "command completed"}],
"structuredContent": {
"type": "notification",
"payload": {
"level": "success",
"message": str(message),
},
},
"isError": False,
},
)
return
if name == "echo":
text = str(arguments.get("text", ""))
structured_content = {"echo": text}
@@ -220,14 +183,7 @@ def main() -> None:
elif params.get("cursor") == "page-2":
respond(
request_id,
{
"tools": [
TOOLS["large"],
TOOLS["environment"],
TOOLS["exit"],
TOOLS["command"],
]
},
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]},
)
else:
respond(
@@ -1,11 +0,0 @@
section_id: mcp-fixture.general
schema_version: 1
fields:
- key: message_prefix
label: Message Prefix
type: string
default: ""
- key: api_key
label: Fixture API Key
type: secret
required: true
@@ -1,19 +0,0 @@
commands:
- command_id: text-tools.uppercase-selection
title: 转为大写
description: 将当前选区或传入文本转换为大写并显示通知。
icon: edit
locations:
- command_palette
- context_menu
when:
- editor.has_selection
context:
- selection
handler: uppercase_selection
parameters:
type: object
properties:
text:
type: string
additionalProperties: false
@@ -6,10 +6,6 @@ permissions: []
contributes:
tools:
- text.uppercase
commands:
- text-tools.uppercase-selection
settings_sections:
- text-tools.general
backend:
type: internal_rpc
transport: none
@@ -1,31 +0,0 @@
section_id: text-tools.general
schema_version: 1
fields:
- key: result_limit
label: 结果字符数
description: Command 通知中最多保留的字符数。
type: number
required: true
default: 100
minimum: 1
maximum: 1000
- key: label_prefix
label: 标签前缀
type: string
default: ""
- key: output_style
label: 输出样式
type: select
default: notification
options:
- notification
- compact
- key: enabled_hint
label: 显示提示
type: boolean
default: true
- key: api_key
label: API Key
description: Secret 示例字段;普通 Settings API 永不返回明文。
type: secret
required: false
-1
View File
@@ -10,7 +10,6 @@ dependencies = [
"httpx>=0.28,<1.0",
"jsonschema>=4.25,<5.0",
"pyyaml>=6.0,<7.0",
"referencing>=0.36,<1.0",
"sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0",
]
-4
View File
@@ -95,10 +95,6 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
"/api/plugins/install",
"/api/plugins/{plugin_id}/host",
"/api/plugins/{plugin_id}/host/restart",
"/api/plugin-contributions/commands",
"/api/plugin-contributions/commands/{command_id}/execute",
"/api/plugins/{plugin_id}/settings",
"/api/plugins/{plugin_id}/settings/{key}/secret",
"/api/plugins/{plugin_id}/enable",
"/api/plugins/{plugin_id}/disable",
"/api/providers/test",
+441
View File
@@ -0,0 +1,441 @@
"""Benchmark 服务的单元与端到端测试。
沿用 conftest 的隔离机制APP_DATA_DIR / DB / Vault 都指向临时目录benchmark
数据集也落在临时目录settings.benchmark_datasets_path不读写真实数据
运行采用创建即 queued + 后台 Task 执行的异步模型测试通过 _run 在同一事件循环内
创建并等待后台任务结束得到终态 BenchmarkRun 后再断言
"""
from __future__ import annotations
import asyncio
import json
import pytest
from pydantic import ValidationError
from app.benchmarks import datasets, metrics as m, service
from app.config import get_settings
from app.contracts import (
BenchmarkKind,
BenchmarkRun,
BenchmarkStatus,
RAGRunRequest,
SearchMode,
)
from app.errors import ApiError
def _write_dataset(dataset_id: str, cases: list[dict], *, kind: str = "rag") -> None:
directory = get_settings().benchmark_datasets_path
directory.mkdir(parents=True, exist_ok=True)
payload = {
"dataset_id": dataset_id,
"kind": kind,
"version": "1.0.0",
"description": "test dataset",
"cases": cases,
}
(directory / f"{dataset_id}.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
def _write_raw(dataset_id: str, raw: dict) -> None:
directory = get_settings().benchmark_datasets_path
directory.mkdir(parents=True, exist_ok=True)
(directory / f"{dataset_id}.json").write_text(
json.dumps(raw, ensure_ascii=False), encoding="utf-8"
)
def _run(request: RAGRunRequest):
"""创建运行并在同一事件循环内等待后台任务结束,返回终态 BenchmarkRun。"""
from app.contracts import BenchmarkRun
async def _execute() -> BenchmarkRun:
run = await service.create_rag_run(request)
return await service.wait_for_run(run.run_id)
return asyncio.run(_execute())
# --------------------------------------------------------------------------- #
# 指标纯函数
# --------------------------------------------------------------------------- #
def test_hit_at_k_and_recall() -> None:
retrieved = ["a", "b", "c"]
expected = {"b", "z"}
assert m.hit_at_k(retrieved, expected, 1) is False
assert m.hit_at_k(retrieved, expected, 2) is True
assert m.recall_at_k(retrieved, expected, 5) == 0.5 # 只召回 b
def test_recall_at_k_dedups_duplicate_notes() -> None:
# 同一 Note 经多个 Block 重复出现,去重后 Recall 不应超过 1
assert m.recall_at_k(["note-a", "note-a"], {"note-a"}, 2) == 1.0
assert m.recall_at_k(["note-a", "note-a", "note-b"], {"note-a"}, 3) == 1.0
def test_reciprocal_rank_and_citation_hit() -> None:
assert m.reciprocal_rank(["x", "a", "b"], {"b"}) == 1 / 3
assert m.reciprocal_rank(["x"], {"b"}) == 0.0
assert m.citation_hit(["blk_1"], {"blk_1"}) is True
assert m.citation_hit(["blk_2"], {"blk_1"}) is False
assert m.citation_hit([], {"blk_1"}) is False
def test_percentile() -> None:
assert m.percentile([1.0, 2.0, 3.0, 4.0], 50.0) == 2.5
assert m.percentile([], 50.0) == 0.0
assert m.percentile([7.0], 95.0) == 7.0
# --------------------------------------------------------------------------- #
# Dataset 注册与校验
# --------------------------------------------------------------------------- #
def test_list_datasets_empty_by_default() -> None:
assert datasets.list_datasets(BenchmarkKind.rag) == []
def test_load_missing_dataset_raises() -> None:
with pytest.raises(ApiError) as exc:
datasets.load_dataset("does-not-exist", BenchmarkKind.rag)
assert exc.value.status_code == 404
assert exc.value.code == "BENCHMARK_DATASET_NOT_FOUND"
def test_dataset_without_expected_ids_is_invalid() -> None:
_write_dataset("bad-v1", [{"case_id": "x", "query": "q", "citation_required": False}])
with pytest.raises(ApiError) as exc:
datasets.load_dataset("bad-v1", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
def test_dataset_kind_mismatch_is_invalid() -> None:
_write_dataset("agent-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}], kind="agent")
with pytest.raises(ApiError) as exc:
datasets.load_dataset("agent-v1", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
def test_citation_required_requires_expected_block_ids() -> None:
# citation_required=true 却没有 expected_block_ids,无法计算 Citation Hit Rate,应拒绝
_write_dataset(
"cit-req-v1",
[{"case_id": "x", "query": "q", "expected_note_ids": ["n"], "citation_required": True}],
)
with pytest.raises(ApiError) as exc:
datasets.load_dataset("cit-req-v1", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
def test_list_datasets_skips_corrupted_structure() -> None:
# 合法 JSON 但字段结构错误(cases: 42),列表接口应隔离该文件而非整体 500
_write_raw("bad-structure", {"dataset_id": "bad-structure", "kind": "rag", "cases": 42})
_write_dataset("good-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}])
infos = datasets.list_datasets(BenchmarkKind.rag)
ids = {info.dataset_id for info in infos}
assert "good-v1" in ids
assert "bad-structure" not in ids
# --------------------------------------------------------------------------- #
# 请求校验(空 / 重复 modes)
# --------------------------------------------------------------------------- #
def test_empty_modes_rejected() -> None:
with pytest.raises(ValidationError):
RAGRunRequest(dataset_id="x", modes=[])
def test_duplicate_modes_rejected() -> None:
with pytest.raises(ValidationError):
RAGRunRequest(dataset_id="x", modes=[SearchMode.fts, SearchMode.fts])
# --------------------------------------------------------------------------- #
# RAG Benchmark 端到端
# --------------------------------------------------------------------------- #
def _single_note_case() -> tuple[str, str, dict]:
from app.services import note_service
note = asyncio.run(
note_service.create_note(
title="向量库",
markdown="向量数据库用于存储高维向量并支持近似最近邻检索。",
folder="",
tags=["向量"],
)
)
case = {
"case_id": "c1",
"query": "向量数据库相似度检索",
"expected_note_ids": [note.note_id],
"expected_block_ids": [note.blocks[0].block_id],
"citation_required": True,
"tags": ["向量"],
}
return note.note_id, note.blocks[0].block_id, case
def test_rag_benchmark_end_to_end() -> None:
_, _, case = _single_note_case()
_write_dataset("e2e-v1", [case])
run = _run(RAGRunRequest(dataset_id="e2e-v1", modes=[SearchMode.fts]))
assert run.status.value == "completed"
assert run.dataset_hash.startswith("sha256:")
assert run.metrics is not None
fts = run.metrics["fts"]
assert fts["hit_at_1"] == 1.0
assert fts["recall_at_k"] == 1.0
assert fts["mrr"] == 1.0
assert fts["citation_hit_rate"] == 1.0
assert fts["p50_latency_ms"] >= 0.0
assert fts["p95_latency_ms"] >= fts["p50_latency_ms"]
def test_rag_benchmark_all_modes_produce_metrics() -> None:
_, _, case = _single_note_case()
_write_dataset("e2e-modes-v1", [case])
run = _run(RAGRunRequest(dataset_id="e2e-modes-v1"))
assert run.status.value == "completed"
for mode in ("fts", "vector", "hybrid"):
assert mode in run.metrics
for key in ("hit_at_1", "hit_at_5", "recall_at_k", "mrr", "citation_hit_rate"):
assert 0.0 <= run.metrics[mode][key] <= 1.0
def test_config_snapshot_records_index_and_models() -> None:
_, _, case = _single_note_case()
_write_dataset("snapshot-v1", [case])
run = _run(RAGRunRequest(dataset_id="snapshot-v1", modes=[SearchMode.fts]))
snapshot = run.config_snapshot
assert snapshot["index_meta"] is not None
assert snapshot["embedding"]["version"]
assert snapshot["embedding"]["dim"]
assert snapshot["reranker"]["version"]
assert snapshot["retrieval"]["rrf_k"] == 60
def test_benchmark_report_and_events() -> None:
_, _, case = _single_note_case()
_write_dataset("report-v1", [case])
run = _run(RAGRunRequest(dataset_id="report-v1", modes=[SearchMode.fts]))
report = service.get_report(run.run_id)
events = service.get_events(run.run_id)
assert report is not None
assert report.run_id == run.run_id
assert len(report.cases) == 1
assert report.cases[0].case_id == "c1"
assert report.cases[0].hit_at_1 is True
assert events, "运行应产生事件"
assert events[0].event.value == "RunStarted"
assert events[-1].event.value == "RunCompleted"
def test_cancel_completed_run_keeps_status() -> None:
_, _, case = _single_note_case()
_write_dataset("cancel-v1", [case])
run = _run(RAGRunRequest(dataset_id="cancel-v1", modes=[SearchMode.fts]))
assert run.status.value == "completed"
cancelled = service.cancel_run(run.run_id)
assert cancelled.status.value == "completed" # 已结束,不再变 cancelled
def test_cancel_queued_run_marks_cancelled() -> None:
_, _, case = _single_note_case()
_write_dataset("cancel-queued-v1", [case])
async def _scenario():
run = await service.create_rag_run(
RAGRunRequest(dataset_id="cancel-queued-v1", modes=[SearchMode.fts])
)
service.cancel_run(run.run_id)
return await service.wait_for_run(run.run_id)
run = asyncio.run(_scenario())
assert run.status.value == "cancelled"
# --------------------------------------------------------------------------- #
# 指标聚合:Citation Hit Rate 只统计 citation_required 样本
# --------------------------------------------------------------------------- #
def test_citation_hit_rate_only_counts_citation_required() -> None:
from app.benchmarks import rag as rag_module
from app.contracts import RAGCaseResult
cases = [
RAGCaseResult(
case_id="a", mode=SearchMode.fts, repeat=0, latency_ms=1.0,
citation_hit=True, citation_applicable=True,
),
RAGCaseResult(
case_id="b", mode=SearchMode.fts, repeat=0, latency_ms=1.0,
citation_hit=False, citation_applicable=False,
),
]
metrics = rag_module._aggregate(cases, SearchMode.fts)
# 只有 citation_applicablecitation_required=true)的样本计入分母
assert metrics.citation_hit_rate == 1.0
# --------------------------------------------------------------------------- #
# 路由接入
# --------------------------------------------------------------------------- #
def test_benchmark_routes_wired() -> None:
from app import routes
_, _, case = _single_note_case()
_write_dataset("route-v1", [case])
async def _scenario():
listed = await routes.list_benchmark_datasets(BenchmarkKind.rag)
assert any(item.dataset_id == "route-v1" for item in listed.items)
run = await routes.create_rag_benchmark(
RAGRunRequest(dataset_id="route-v1", modes=[SearchMode.fts])
)
assert run.status.value == "queued"
return await service.wait_for_run(run.run_id)
run = asyncio.run(_scenario())
assert run.status.value == "completed"
got = asyncio.run(routes.get_benchmark_run(run.run_id))
assert got.run_id == run.run_id
report = asyncio.run(routes.get_benchmark_report(run.run_id))
assert report.cases[0].case_id == "c1"
def test_benchmark_run_not_found_raises() -> None:
from app import routes
with pytest.raises(ApiError) as exc:
asyncio.run(routes.get_benchmark_run("benchmark_missing"))
assert exc.value.code == "BENCHMARK_RUN_NOT_FOUND"
# --------------------------------------------------------------------------- #
# 审阅回归:索引兼容 / 容量 / 失败样本 / 取消事件 / 数据集隔离
# --------------------------------------------------------------------------- #
def test_create_rag_run_requires_built_index() -> None:
# 空索引(无已索引 block)会让所有模式得到全 0 指标,应在创建时拒绝而非跑出误导结果
_write_dataset("empty-index-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}])
with pytest.raises(ApiError) as exc:
asyncio.run(
service.create_rag_run(
RAGRunRequest(dataset_id="empty-index-v1", modes=[SearchMode.fts])
)
)
assert exc.value.status_code == 409
assert exc.value.code == "BENCHMARK_INDEX_INCOMPATIBLE"
def test_capacity_exceeded_when_all_runs_active(monkeypatch) -> None:
# 满容量且全为活动(非终态)run 时,无法淘汰,应拒绝创建而非删掉正在运行的 run
_, _, case = _single_note_case()
_write_dataset("capacity-v1", [case])
monkeypatch.setattr(service, "MAX_RUNS", 1)
fake_id = "benchmark_fake_active"
service._runs[fake_id] = BenchmarkRun(
run_id=fake_id,
kind=BenchmarkKind.rag,
dataset_id="capacity-v1",
dataset_hash="sha256:fake",
status=BenchmarkStatus.queued,
created_at=service._now(),
)
try:
with pytest.raises(ApiError) as exc:
asyncio.run(
service.create_rag_run(
RAGRunRequest(dataset_id="capacity-v1", modes=[SearchMode.fts])
)
)
assert exc.value.status_code == 429
assert exc.value.code == "BENCHMARK_CAPACITY_EXCEEDED"
finally:
service._runs.pop(fake_id, None)
def test_failed_samples_counted_as_zero_in_aggregate() -> None:
from app.benchmarks import rag as rag_module
from app.contracts import RAGCaseResult
cases = [
RAGCaseResult(
case_id="ok", mode=SearchMode.fts, repeat=0, latency_ms=10.0,
hit_at_1=True, recall=1.0, reciprocal_rank=1.0,
citation_hit=True, citation_applicable=True,
),
RAGCaseResult(
case_id="boom", mode=SearchMode.fts, repeat=0, latency_ms=0.0,
error="RAG case evaluation failed.",
error_code="BENCHMARK_CASE_EVALUATION_FAILED",
),
]
metrics = rag_module._aggregate(cases, SearchMode.fts)
assert metrics.total_cases == 2
assert metrics.successful_cases == 1
assert metrics.failed_cases == 1
assert metrics.failure_rate == 0.5
# 失败样本按零分计入质量指标分母,汇总不虚高
assert metrics.hit_at_1 == 0.5
assert metrics.recall_at_k == 0.5
# 延迟只统计成功样本
assert metrics.p50_latency_ms == 10.0
def test_cancel_emits_run_cancelled_event() -> None:
_, _, case = _single_note_case()
_write_dataset("cancel-event-v1", [case])
async def _scenario():
run = await service.create_rag_run(
RAGRunRequest(dataset_id="cancel-event-v1", modes=[SearchMode.fts])
)
service.cancel_run(run.run_id)
return await service.wait_for_run(run.run_id)
run = asyncio.run(_scenario())
assert run.status.value == "cancelled"
events = service.get_events(run.run_id)
assert events[-1].event.value == "RunCancelled"
def test_load_dataset_ignores_corrupted_unrelated_files() -> None:
# 无关文件损坏(非法 JSON / 顶层非对象)不应阻断目标数据集加载
directory = get_settings().benchmark_datasets_path
directory.mkdir(parents=True, exist_ok=True)
(directory / "broken.json").write_text("{ not valid json", encoding="utf-8")
(directory / "array.json").write_text('["a", "b"]', encoding="utf-8")
_write_dataset("ok-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}])
dataset = datasets.load_dataset("ok-v1", BenchmarkKind.rag)
assert dataset.dataset_id == "ok-v1"
assert len(dataset.cases) == 1
def test_load_dataset_top_level_must_be_object() -> None:
_write_raw("array-top", ["a", "b"])
with pytest.raises(ApiError) as exc:
datasets.load_dataset("array-top", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
+1 -59
View File
@@ -1,21 +1,16 @@
import asyncio
from pathlib import Path
import httpx
import pytest
from app.config import get_settings
from app.contracts import CredentialWriteRequest
from app.errors import ApiError
from app.providers.credentials import (
ChainedCredentialResolver,
CredentialStoreError,
EncryptedCredentialStore,
EnvironmentCredentialResolver,
)
from app.providers.factory import ProviderFactory
from app.providers.openai_compatible import OpenAICompatibleProvider
from app.routes import delete_credential, get_credential_status, put_credential
from app.routes import get_credential_status, put_credential
def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> None:
@@ -36,33 +31,6 @@ def test_encrypted_credential_store_round_trip_without_plaintext_on_disk() -> No
assert store.resolve("deepseek") is None
def test_encrypted_credential_store_deletes_multiple_credentials_atomically() -> None:
store = EncryptedCredentialStore()
store.put("plugin.first", "first")
store.put("plugin.second", "second")
store.put("openai", "keep")
removed = store.delete_many(["plugin.first", "plugin.second"])
assert removed == {"plugin.first", "plugin.second"}
assert store.resolve("plugin.first") is None
assert store.resolve("plugin.second") is None
assert store.resolve("openai") == "keep"
def test_credential_write_os_error_uses_stable_store_error(monkeypatch) -> None:
store = EncryptedCredentialStore()
store.put("existing", "value")
def fail_replace(_path: Path, _target: Path) -> Path:
raise OSError("injected replace failure")
monkeypatch.setattr(Path, "replace", fail_replace)
with pytest.raises(CredentialStoreError, match="cannot be written"):
store.put("new", "value")
def test_credential_api_never_returns_secret() -> None:
written = asyncio.run(
put_credential(
@@ -104,29 +72,3 @@ def test_saved_credential_takes_precedence_over_environment_fallback(monkeypatch
resolver = ChainedCredentialResolver(store, EnvironmentCredentialResolver())
assert resolver.resolve("deepseek") == "saved-key"
def test_public_credential_api_rejects_plugin_namespace() -> None:
operations = [
get_credential_status("plugin.text-tools.api_key"),
put_credential(
"plugin.text-tools.api_key",
CredentialWriteRequest(api_key="must-not-write"),
),
delete_credential("plugin.text-tools.api_key"),
]
for operation in operations:
with pytest.raises(ApiError) as exc:
asyncio.run(operation)
assert exc.value.code == "CREDENTIAL_NAMESPACE_RESERVED"
assert EncryptedCredentialStore().resolve("plugin.text-tools.api_key") is None
def test_provider_resolver_cannot_read_plugin_secret() -> None:
store = EncryptedCredentialStore()
store.put("plugin.text-tools.api_key", "private-plugin-secret")
resolver = ProviderFactory(store).credentials
with pytest.raises(CredentialStoreError, match="reserved for Plugin settings"):
resolver.resolve("plugin.text-tools.api_key")
+5 -165
View File
@@ -11,16 +11,12 @@ from app.container import build_container
from app.contracts import (
AgentRunCreateRequest,
AgentRunStatus,
PluginCommandContext,
SkillStatus,
ToolCall,
)
from app.extensions import ExtensionError
from app.extensions.mcp import McpStdioClient
from app.extensions.runtime import (
_arguments_model_from_schema,
_validate_mcp_command_target_schema,
)
from app.extensions.runtime import _arguments_model_from_schema
from app.services import note_service
from app.config import BACKEND_DIR, get_settings
@@ -37,7 +33,7 @@ def mcp_container():
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
try:
yield container
finally:
@@ -353,7 +349,7 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
ToolExecutionContext(run_id="run_mcp_fixture"),
)
assert status.tools_count == 7
assert status.tools_count == 6
assert status.protocol_version == "2025-11-25"
assert status.server_name == "notesagent-mcp-fixture"
assert definition.permission == "notes.read"
@@ -400,162 +396,6 @@ def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
run(scenario())
def test_mcp_command_target_receives_scoped_context_and_declared_secret(
mcp_container,
) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
assert not mcp_container.tools.contains("mcp-fixture.command")
with pytest.raises(ExtensionError) as missing:
await mcp_container.plugins.execute_command(
"mcp-fixture.notify",
{},
PluginCommandContext(selection="来自选区"),
)
assert missing.value.code == "PLUGIN_SECRET_REQUIRED"
mcp_container.plugins.put_setting_secret(
"mcp-fixture", "api_key", "mcp-command-secret"
)
mcp_container.plugins.update_settings(
"mcp-fixture", 1, {"message_prefix": "Fixture: "}
)
result = await mcp_container.plugins.execute_command(
"mcp-fixture.notify",
{},
PluginCommandContext(
note_id="must-not-enter-envelope",
selection="来自选区",
),
)
assert result.effect.type == "notification"
assert result.effect.payload.model_dump() == {
"level": "success",
"message": "Fixture: 来自选区",
}
assert "mcp-command-secret" not in repr(
mcp_container.plugins.commands.audit_events()
)
run(scenario())
def test_mcp_command_target_rejects_incompatible_envelope_schema(tmp_path) -> None:
package = tmp_path / "mcp-bad-command"
shutil.copytree(MCP_FIXTURE, package)
for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"):
path = package / filename
path.write_text(
path.read_text(encoding="utf-8").replace(
"mcp-fixture", "mcp-bad-command"
),
encoding="utf-8",
)
server_path = package / "server.py"
server_path.write_text(
server_path.read_text(encoding="utf-8").replace(
'{"_notesagent": {"type": "object"}}',
'{"unexpected": {"type": "string"}}',
),
encoding="utf-8",
)
container = build_container()
container.plugins.install(package)
container.plugins.set_permissions(
"mcp-bad-command", ["notes.read", "secrets.use"]
)
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-bad-command")
assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID"
assert container.plugins.get("mcp-bad-command").status == "error"
finally:
container.plugins.shutdown()
def test_mcp_command_target_enable_check_only_requires_protocol_marker() -> None:
# `not`/`oneOf` 等完整语义由实际调用前的官方 Validator 处理;启用检查
# 只确认不可被引用或组合隐藏的稳定宿主入口,避免维护不完整的求解器。
_validate_mcp_command_target_schema(
{
"type": "object",
"properties": {
"_notesagent": {
"type": "object",
"not": {"type": "object"},
}
},
},
"marker.run",
)
invalid_markers = [
{
"$defs": {"envelope": {"type": "object"}},
"properties": {"_notesagent": {"$ref": "#/$defs/envelope"}},
},
{
"allOf": [
{"properties": {"_notesagent": {"type": "object"}}},
]
},
]
for schema in invalid_markers:
with pytest.raises(ExtensionError) as exc:
_validate_mcp_command_target_schema(schema, "marker.run")
assert exc.value.code == "PLUGIN_CONTRIBUTION_INVALID"
def test_mcp_command_validates_actual_envelope_before_call(tmp_path) -> None:
package = tmp_path / "mcp-runtime-schema"
shutil.copytree(MCP_FIXTURE, package)
for filename in ("plugin.yaml", "commands.yaml", "settings.yaml"):
path = package / filename
path.write_text(
path.read_text(encoding="utf-8").replace(
"mcp-fixture", "mcp-runtime-schema"
),
encoding="utf-8",
)
server_path = package / "server.py"
server_path.write_text(
server_path.read_text(encoding="utf-8").replace(
'{"_notesagent": {"type": "object"}}',
'{"_notesagent": {"type": "object", "properties": '
'{"arguments": {"type": "object", "maxProperties": 0}, '
'"context": {"type": "object", "properties": '
'{"selection": {"type": "string"}}, "required": ["selection"]}}, '
'"required": ["arguments", "context"]}}',
),
encoding="utf-8",
)
container = build_container()
container.plugins.install(package)
container.plugins.set_permissions(
"mcp-runtime-schema", ["notes.read", "secrets.use"]
)
try:
# context.selection 是 Command 的 when/context 契约保证的真实字段;
# 启用期结构检查不得因没有伪造该业务值而拒绝目标 Schema。
container.plugins.enable("mcp-runtime-schema")
container.plugins.put_setting_secret(
"mcp-runtime-schema", "api_key", "configured"
)
with pytest.raises(ExtensionError) as exc:
run(
container.plugins.execute_command(
"mcp-runtime-schema.notify",
{"message": "must be rejected locally"},
PluginCommandContext(selection="visible"),
)
)
assert exc.value.code == "PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH"
finally:
container.plugins.shutdown()
def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
@@ -691,7 +531,7 @@ def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read", "secrets.use"])
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-fixture")
@@ -725,7 +565,7 @@ def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container)
restarted = mcp_container.plugins.restart_host("mcp-fixture")
assert restarted.status == "ready"
assert restarted.tools_count == 7
assert restarted.tools_count == 6
assert mcp_container.tools.contains("mcp-fixture.echo")
run(scenario())
-653
View File
@@ -1,653 +0,0 @@
import asyncio
import json
from pathlib import Path
import pytest
from pydantic import TypeAdapter, ValidationError
from app.agent import ToolRegistry
from app.config import BACKEND_DIR, get_settings
from app.container import build_container
from app.contracts import (
PluginCommandContext,
PluginCommandEffect,
PluginNoEffect,
PluginSettingType,
)
from app.extensions import ExtensionError, PluginRuntime
from app.extensions.contributions import _secret_reference
from app.extensions.runtime import DeclarativePluginHost
from app.providers.credentials import CredentialStoreError
TEXT_TOOLS = BACKEND_DIR / "extensions" / "plugins" / "text-tools"
def run(coroutine):
return asyncio.run(coroutine)
def test_command_list_filter_and_lifecycle() -> None:
container = build_container()
commands = container.plugins.list_commands()
palette = container.plugins.list_commands(location="command_palette")
assert [item.command_id for item in commands] == ["text-tools.uppercase-selection"]
assert palette[0].plugin_id == "text-tools"
assert palette[0].icon == "edit"
assert palette[0].when == ["editor.has_selection"]
container.plugins.disable("text-tools")
assert container.plugins.list_commands() == []
with pytest.raises(ExtensionError) as exc:
run(
container.plugins.execute_command(
"text-tools.uppercase-selection",
{},
PluginCommandContext(selection="hello"),
)
)
assert exc.value.code == "PLUGIN_COMMAND_NOT_FOUND"
container.plugins.enable("text-tools")
assert len(container.plugins.list_commands()) == 1
def test_command_executes_with_scoped_context_and_settings() -> None:
container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 4})
result = run(
container.plugins.execute_command(
"text-tools.uppercase-selection",
{},
PluginCommandContext(
vault_id="default",
note_id="note_private",
file_path="private.md",
selection="abcdef",
),
)
)
assert result.status == "completed"
assert result.effect.type == "notification"
assert result.effect.payload.model_dump() == {
"level": "success",
"message": "ABCD",
}
def test_echo_command_returns_none_for_empty_message() -> None:
host = DeclarativePluginHost()
empty = run(host.execute_command("echo", {}, {}, {}, lambda _: None))
populated = run(
host.execute_command("echo", {"message": "hello"}, {}, {}, lambda _: None)
)
assert isinstance(empty, PluginNoEffect)
assert populated.type == "notification"
assert populated.payload.message == "hello"
@pytest.mark.parametrize(
("effect_type", "payload"),
[
("none", {"unexpected": True}),
("notification", {"level": "debug", "message": "invalid"}),
("navigate", {"route": "https://example.com"}),
("refresh", {"scope": "everything"}),
("job", {"job_id": "invalid job id"}),
],
)
def test_command_effect_rejects_untrusted_payloads(effect_type, payload) -> None:
with pytest.raises(ValidationError):
TypeAdapter(PluginCommandEffect).validate_python(
{"type": effect_type, "payload": payload}
)
def test_command_rejects_missing_context_and_invalid_arguments() -> None:
container = build_container()
with pytest.raises(ExtensionError) as context_error:
run(
container.plugins.execute_command(
"text-tools.uppercase-selection", {}, PluginCommandContext()
)
)
assert context_error.value.code == "PLUGIN_COMMAND_CONTEXT_INVALID"
with pytest.raises(ExtensionError) as argument_error:
run(
container.plugins.execute_command(
"text-tools.uppercase-selection",
{"unknown": True},
PluginCommandContext(selection="hello"),
)
)
assert argument_error.value.code == "PLUGIN_COMMAND_ARGUMENT_INVALID"
audit = container.plugins.commands.audit_events()
assert [event.error_code for event in audit[-2:]] == [
"PLUGIN_COMMAND_CONTEXT_INVALID",
"PLUGIN_COMMAND_ARGUMENT_INVALID",
]
# 审计事件不得携带参数、正文选区或返回 effect。
assert "hello" not in repr(audit)
def test_command_only_receives_declared_context() -> None:
class CapturingHost(DeclarativePluginHost):
def __init__(self) -> None:
self.context = None
async def execute_command(
self, handler, arguments, context, settings, resolve_secret
):
self.context = context
return PluginNoEffect()
host = CapturingHost()
runtime = PluginRuntime(ToolRegistry(), host=host)
runtime.install(TEXT_TOOLS)
runtime.enable("text-tools")
run(
runtime.execute_command(
"text-tools.uppercase-selection",
{},
PluginCommandContext(
vault_id="default", note_id="note_private", selection="visible"
),
)
)
assert host.context == {"selection": "visible"}
def test_command_resolves_only_declared_plugin_secrets(tmp_path: Path) -> None:
class SecretHost(DeclarativePluginHost):
def __init__(self) -> None:
self.secret = None
self.denied_code = None
async def execute_command(
self, handler, arguments, context, settings, resolve_secret
):
self.secret = resolve_secret("api_key")
try:
resolve_secret("undeclared")
except ExtensionError as exc:
self.denied_code = exc.code
return PluginNoEffect()
host = SecretHost()
runtime = PluginRuntime(ToolRegistry(), host=host)
package = tmp_path / "secret-command"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: secret-command
name: Secret Command
version: 1.0.0
permissions: [secrets.use]
contributes:
commands: [secret-command.run]
settings_sections: [secret-command.general]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: secret-command.run
title: Secret Command
locations: [command_palette]
secrets: [api_key]
handler: echo
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
"""
section_id: secret-command.general
schema_version: 1
fields:
- key: api_key
label: API Key
type: secret
""".strip(),
encoding="utf-8",
)
runtime.install(package)
runtime.set_permissions("secret-command", ["secrets.use"])
runtime.enable("secret-command")
runtime.put_setting_secret("secret-command", "api_key", "runtime-only-secret")
run(
runtime.execute_command(
"secret-command.run",
{},
PluginCommandContext(selection="visible"),
)
)
assert host.secret == "runtime-only-secret"
assert host.denied_code == "PLUGIN_SECRET_ACCESS_DENIED"
assert "runtime-only-secret" not in repr(runtime.commands.audit_events())
def test_settings_schema_contains_defaults_and_hides_secret() -> None:
container = build_container()
schema = container.plugins.get_settings("text-tools")
by_key = {field.key: field for field in schema.fields}
assert schema.schema_version == 1
assert schema.values == {
"result_limit": 100,
"label_prefix": "",
"output_style": "notification",
"enabled_hint": True,
}
assert "api_key" not in schema.values
assert schema.secrets["api_key"].configured is False
assert by_key["api_key"].type == PluginSettingType.secret
def test_settings_update_validates_version_type_bounds_and_secret_boundary() -> None:
container = build_container()
updated = container.plugins.update_settings(
"text-tools", 1, {"result_limit": 20, "output_style": "compact"}
)
assert updated.values["result_limit"] == 20
assert updated.values["output_style"] == "compact"
cases = [
(2, {}, "PLUGIN_SETTINGS_VERSION_CONFLICT"),
(1, {"result_limit": 0}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"enabled_hint": "yes"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"output_style": "unknown"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"api_key": "plaintext"}, "PLUGIN_SETTINGS_FIELD_INVALID"),
(1, {"unknown": True}, "PLUGIN_SETTINGS_FIELD_INVALID"),
]
for version, values, code in cases:
with pytest.raises(ExtensionError) as exc:
container.plugins.update_settings("text-tools", version, values)
assert exc.value.code == code
def test_required_plain_setting_blocks_enable_until_configured(tmp_path: Path) -> None:
package = tmp_path / "required-setting"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: required-setting
name: Required Setting
version: 1.0.0
contributes:
commands: [required-setting.run]
settings_sections: [required-setting.general]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: required-setting.run
title: Required Setting
locations: [command_palette]
handler: echo
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
"""
section_id: required-setting.general
schema_version: 1
fields:
- key: endpoint
label: Endpoint
type: string
required: true
""".strip(),
encoding="utf-8",
)
runtime = PluginRuntime(ToolRegistry())
runtime.install(package)
with pytest.raises(ExtensionError) as exc:
runtime.enable("required-setting")
assert exc.value.code == "PLUGIN_SETTINGS_REQUIRED"
assert runtime.get("required-setting").status == "installed"
runtime.update_settings("required-setting", 1, {"endpoint": "local"})
assert runtime.enable("required-setting").status == "ready"
def test_secret_roundtrip_never_enters_plain_settings_storage() -> None:
container = build_container()
plaintext = "stage-d-secret-value"
status = container.plugins.put_setting_secret("text-tools", "api_key", plaintext)
schema = container.plugins.get_settings("text-tools")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
assert status.configured is True
assert schema.secrets["api_key"].configured is True
assert "api_key" not in schema.values
assert plaintext not in settings_path.read_text(encoding="utf-8")
assert plaintext not in credentials_path.read_text(encoding="utf-8")
stored_settings = json.loads(settings_path.read_text(encoding="utf-8"))
reference = stored_settings["text-tools"]["secret_refs"]["api_key"]
assert reference.startswith("plugin.")
assert len(reference) == 71
assert "text-tools" not in reference and "api_key" not in reference
assert container.credentials.resolve(reference) == plaintext
deleted = container.plugins.delete_setting_secret("text-tools", "api_key")
assert deleted.configured is False
assert container.credentials.resolve(reference) is None
def test_uninstall_removes_plugin_settings_and_secret_namespace() -> None:
container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
container.plugins.put_setting_secret("text-tools", "api_key", "temporary")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
reference = json.loads(settings_path.read_text(encoding="utf-8"))[
"text-tools"
]["secret_refs"]["api_key"]
container.plugins.uninstall("text-tools")
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert "text-tools" not in stored
assert container.credentials.resolve(reference) is None
def test_plugin_secret_reference_has_fixed_credential_safe_length() -> None:
reference = _secret_reference("p" * 512, "k" * 128)
assert reference.startswith("plugin.")
assert len(reference) <= 128
def test_tampered_secret_reference_cannot_cross_credential_namespace() -> None:
container = build_container()
container.credentials.put("openai", "provider-private-secret")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(
json.dumps(
{
"text-tools": {
"schema_version": 1,
"values": {},
"secret_refs": {"api_key": "openai"},
}
}
),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as read_error:
container.plugins.get_settings("text-tools")
with pytest.raises(ExtensionError) as uninstall_error:
container.plugins.uninstall("text-tools")
assert read_error.value.code == "PLUGIN_STORAGE_ERROR"
assert uninstall_error.value.code == "PLUGIN_STORAGE_ERROR"
assert container.credentials.resolve("openai") == "provider-private-secret"
def test_secret_delete_restores_reference_when_credential_delete_fails(
monkeypatch,
) -> None:
container = build_container()
container.plugins.put_setting_secret("text-tools", "api_key", "keep-me")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
original = settings_path.read_text(encoding="utf-8")
reference = _secret_reference("text-tools", "api_key")
def fail_delete(_credential_id: str) -> bool:
raise CredentialStoreError("injected delete failure")
monkeypatch.setattr(container.credentials, "delete", fail_delete)
with pytest.raises(ExtensionError) as exc:
container.plugins.delete_setting_secret("text-tools", "api_key")
assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR"
assert settings_path.read_text(encoding="utf-8") == original
assert container.credentials.resolve(reference) == "keep-me"
def test_uninstall_restores_settings_when_atomic_secret_delete_fails(
monkeypatch,
) -> None:
container = build_container()
container.plugins.update_settings("text-tools", 1, {"result_limit": 12})
container.plugins.put_setting_secret("text-tools", "api_key", "keep-me")
settings_path = get_settings().data_dir / "plugins" / "settings.json"
original = settings_path.read_text(encoding="utf-8")
reference = _secret_reference("text-tools", "api_key")
def fail_delete_many(_credential_ids: list[str]) -> set[str]:
raise CredentialStoreError("injected batch delete failure")
monkeypatch.setattr(container.credentials, "delete_many", fail_delete_many)
with pytest.raises(ExtensionError) as exc:
container.plugins.uninstall("text-tools")
assert exc.value.code == "PLUGIN_SECRET_STORE_ERROR"
assert settings_path.read_text(encoding="utf-8") == original
assert container.credentials.resolve(reference) == "keep-me"
assert container.plugins.get("text-tools").manifest.plugin_id == "text-tools"
def test_invalid_command_and_settings_manifest_are_rejected(tmp_path: Path) -> None:
invalid_command = tmp_path / "invalid-command"
invalid_command.mkdir()
(invalid_command / "plugin.yaml").write_text(
"""
id: invalid-command
name: Invalid Command
version: 1.0.0
contributes:
commands: [other.run]
""".strip(),
encoding="utf-8",
)
(invalid_command / "commands.yaml").write_text(
"""
commands:
- command_id: other.run
title: Invalid
locations: [command_palette]
handler: echo
""".strip(),
encoding="utf-8",
)
invalid_settings = tmp_path / "invalid-settings"
invalid_settings.mkdir()
(invalid_settings / "plugin.yaml").write_text(
"""
id: invalid-settings
name: Invalid Settings
version: 1.0.0
contributes:
settings_sections: [invalid-settings.general]
""".strip(),
encoding="utf-8",
)
(invalid_settings / "settings.yaml").write_text(
"""
section_id: invalid-settings.general
schema_version: 1
fields:
- key: token
label: Token
type: secret
default: leaked-default
""".strip(),
encoding="utf-8",
)
runtime = PluginRuntime(ToolRegistry())
with pytest.raises(ExtensionError) as command_error:
runtime.install(invalid_command)
assert command_error.value.code == "PLUGIN_COMMAND_INVALID"
with pytest.raises(ExtensionError) as settings_error:
runtime.install(invalid_settings)
assert settings_error.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
@pytest.mark.parametrize("bound", [".nan", ".inf", "-.inf"])
def test_non_finite_setting_bounds_are_rejected(tmp_path: Path, bound: str) -> None:
package = tmp_path / f"invalid-bound-{bound.replace('.', 'dot').replace('-', 'neg')}"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: invalid-bound
name: Invalid Bound
version: 1.0.0
contributes:
settings_sections: [invalid-bound.general]
backend:
type: none
transport: none
""".strip(),
encoding="utf-8",
)
(package / "settings.yaml").write_text(
f"""
section_id: invalid-bound.general
schema_version: 1
fields:
- key: limit
label: Limit
type: number
minimum: {bound}
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "PLUGIN_SETTINGS_SCHEMA_INVALID"
assert "must be finite" in exc.value.message
def test_null_command_list_returns_stable_manifest_error(tmp_path: Path) -> None:
package = tmp_path / "null-commands"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: null-commands
name: Null Commands
version: 1.0.0
contributes:
commands: []
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text("commands:\n", encoding="utf-8")
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "EXTENSION_MANIFEST_INVALID"
def test_external_command_schema_reference_is_rejected(tmp_path: Path) -> None:
package = tmp_path / "external-ref"
package.mkdir()
(package / "plugin.yaml").write_text(
"""
id: external-ref
name: External Ref
version: 1.0.0
contributes:
commands: [external-ref.run]
backend:
type: internal_rpc
transport: none
""".strip(),
encoding="utf-8",
)
(package / "commands.yaml").write_text(
"""
commands:
- command_id: external-ref.run
title: External Ref
locations: [command_palette]
handler: echo
parameters:
$ref: file:///host/private-schema.json
""".strip(),
encoding="utf-8",
)
with pytest.raises(ExtensionError) as exc:
PluginRuntime(ToolRegistry()).install(package)
assert exc.value.code == "PLUGIN_COMMAND_INVALID"
assert "External JSON Schema reference" in exc.value.message
def test_settings_missing_and_secret_field_errors_are_stable() -> None:
container = build_container()
with pytest.raises(ExtensionError) as missing:
container.plugins.get_settings("does-not-exist")
assert missing.value.code == "PLUGIN_NOT_FOUND"
with pytest.raises(ExtensionError) as field:
container.plugins.put_setting_secret("text-tools", "result_limit", "secret")
assert field.value.code == "PLUGIN_SECRET_FIELD_NOT_FOUND"
with pytest.raises(ExtensionError) as empty:
container.plugins.put_setting_secret("text-tools", "api_key", "")
assert empty.value.code == "PLUGIN_SECRET_VALUE_INVALID"
def test_corrupted_plugin_settings_namespace_returns_stable_error() -> None:
container = build_container()
settings_path = get_settings().data_dir / "plugins" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text('{"text-tools": []}', encoding="utf-8")
with pytest.raises(ExtensionError) as exc:
container.plugins.get_settings("text-tools")
assert exc.value.code == "PLUGIN_STORAGE_ERROR"
with pytest.raises(ExtensionError) as secret_exc:
container.plugins.put_setting_secret("text-tools", "api_key", "must-not-orphan")
assert secret_exc.value.code == "PLUGIN_STORAGE_ERROR"
credentials_path = get_settings().data_dir / "credentials" / "credentials.json"
credential_ids = (
json.loads(credentials_path.read_text(encoding="utf-8")).keys()
if credentials_path.exists()
else []
)
assert not any(item.startswith("plugin.") for item in credential_ids)
+38
View File
@@ -436,6 +436,44 @@ def test_fts_pagination_is_not_truncated_at_one_thousand(vault) -> None:
assert len(response.items) == 10
def test_fts_score_threshold_filters_before_total(vault) -> None:
"""score_threshold 先于计数与分页生效:total 反映过滤后数量,与 items 一致。
高阈值过滤掉全部结果时 total==0 items 为空杜绝空页但 total>0
不一致审阅 P2-7
"""
from app.retrieval.engine import engine
from app.services import note_service
# 10 个 block,含「目标」次数递增,bm25 分数各异,min-max 归一化后分数落在 [0,1]
markdown = "\n\n".join(f"{'目标' * i} 分隔内容" for i in range(1, 11))
asyncio.run(
note_service.create_note(title="阈值过滤", markdown=markdown, folder="", tags=[])
)
all_hits = asyncio.run(
engine.search(
SearchRequest(query="目标", mode=SearchMode.fts, limit=20, score_threshold=0.0)
)
)
filtered = asyncio.run(
engine.search(
SearchRequest(query="目标", mode=SearchMode.fts, limit=20, score_threshold=0.5)
)
)
none = asyncio.run(
engine.search(
SearchRequest(query="目标", mode=SearchMode.fts, limit=20, score_threshold=2.0)
)
)
assert all_hits.page.total >= 10
assert 0 < filtered.page.total < all_hits.page.total # 阈值过滤掉部分而非全部
assert filtered.page.total == len(filtered.items)
assert none.page.total == 0
assert none.items == []
# --------------------------------------------------------------------------- #
# 审阅回归:PATCH tags 语义 / 向量-块一致性 / 过滤漏召回 / rebuild 语义与回滚
# --------------------------------------------------------------------------- #
-65
View File
@@ -1,65 +0,0 @@
import pytest
from app.schema_security import (
ExternalSchemaReferenceError,
UnresolvableLocalSchemaReferenceError,
reject_external_schema_references,
)
@pytest.mark.parametrize(
"schema",
[
{"$ref": "file:///host/private-schema.json"},
{"properties": {"value": {"$ref": "https://schema.invalid/value.json"}}},
{"allOf": [{"$dynamicRef": "https://schema.invalid/dynamic"}]},
],
)
def test_external_json_schema_references_are_rejected(schema) -> None:
with pytest.raises(ExternalSchemaReferenceError):
reject_external_schema_references(schema)
def test_local_json_schema_fragment_reference_is_allowed() -> None:
reject_external_schema_references(
{
"$defs": {"value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/value"}},
}
)
@pytest.mark.parametrize("reference", ["#/$defs/missing", "#missing-anchor"])
def test_unresolvable_local_schema_reference_is_rejected(reference: str) -> None:
with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references({"type": "object", "$ref": reference})
def test_root_reference_cannot_use_anchor_from_nested_schema_resource() -> None:
schema = {
"$defs": {
"nested": {
"$id": "nested",
"$anchor": "inside",
"type": "string",
}
},
"properties": {"value": {"$ref": "#inside"}},
}
with pytest.raises(UnresolvableLocalSchemaReferenceError):
reject_external_schema_references(schema)
def test_nested_schema_resource_can_resolve_its_own_anchor() -> None:
schema = {
"$defs": {
"nested": {
"$id": "nested",
"$anchor": "inside",
"allOf": [{"$ref": "#inside"}],
}
}
}
reject_external_schema_references(schema)
-2
View File
@@ -374,7 +374,6 @@ dependencies = [
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "pyyaml" },
{ name = "referencing" },
{ name = "sqlite-vec" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -391,7 +390,6 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "referencing", specifier = ">=0.36,<1.0" },
{ name = "sqlite-vec", specifier = ">=0.1.9" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
]
+1 -1
View File
@@ -30,9 +30,9 @@
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
- [Benchmark 开发说明](development/Benchmark开发说明.md)
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
- [Plugin Command 与 Settings 开发说明](development/Plugin-Command与Settings开发说明.md)
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
- [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md)
@@ -5,7 +5,7 @@
> 适用范围:桌面客户端、本地知识库、RAG、Agent、Skill、多模型接入、多模态处理与可选云同步
> 目标读者:前端、Rust 桌面端、Python AI Core、算法、测试与后续接手项目的开发成员
> 实施状态更新:2026-09-02。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge隔离 Plugin Host、Plugin Command 与 Plugin Settings/Secret Contract 已完成。后续继续接入真实音频处理、Provider 协议增强、Benchmark、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
> 实施状态更新:2026-09-03。本文同时包含目标架构、当前实现和第二阶段接口基线。第一阶段已完成 Vue Web 联调前端、FastAPI、Knowledge/Retrieval、Agent/Tool/Permission、Skill/Plugin 声明式运行时、Mock/OpenAI-Compatible/Ollama Provider、DeepSeek/OpenAI 预设、模型发现及开发阶段 Fernet 凭据存储。Web Workspace 已通过 FastAPI 接入后端配置的真实单 Vault;第二阶段 Agent Trace 持久化、分页快照、可恢复 SSE、stdio MCP Bridge隔离 Plugin Host 已完成;RAG Benchmark 检索评测(Dataset 加载、异步运行、SSE 进度、指标聚合与报告)已完成,Agent Benchmark 暂缓。后续继续接入真实音频处理、Plugin Command/Settings、Provider 协议增强、文档导出、主题包、Trace 可视化、Mermaid 和函数图像。Tauri/Rust Host、Stronghold、原生多 Vault 文件系统和 Sync Server 仍未实现。
---
@@ -2100,9 +2100,13 @@ MRR
Citation Hit Rate
P50 Latency
P95 Latency
total_cases
successful_cases
failed_cases
failure_rate
```
Benchmark 参数、Embedding 模型、Reranker、数据集版本和运行环境需要一起记录,保证不同实验结果可以复现。
失败样本按零分计入质量指标分母,报告同时输出样本构成字段标明实际分母。Benchmark 参数、Embedding 模型、Reranker、数据集版本和运行环境需要一起记录,保证不同实验结果可以复现。
### 20.3 Agent Benchmark
@@ -2329,7 +2333,7 @@ Markdown Workspace
第一阶段 Plugin Runtime 已完成安装、启用、停用、权限和声明式 Tool 注册,建立 Skill 调用 Plugin Tool 的基础链路。Command、Settings 和 MCP 执行不计入第一阶段完成项。
截至 2026-09-02,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口stdio MCP Bridge / Plugin Host 以及 Plugin Command/Settings 后端 Contract 也已完成。当前验证基线为后端 126 项测试、前端 29 项测试、TypeScript 类型检查及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
截至 2026-09-01,上述第一阶段后端链路和 Web 联调前端均已完成;第二阶段前置的 Workspace 去 Mock 联调、Agent Trace 持久化/恢复接口以及 stdio MCP Bridge / Plugin Host 也已完成。当前验证基线为后端 92 项测试、前端 27 项测试及生产构建通过。向量链路当前使用 `HashEmbeddingProvider` 验证工程正确性,真实 Embedding 召回质量不属于该测试结论。
第二阶段在既有 Contract 上接入:
@@ -748,8 +748,8 @@ Markdown
- [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript
- [x] MCP Server 能通过 MCP Bridge 注册 Tool
- [x] Agent 能调用 MCP Tool
- [x] Plugin Command Contribution 后端可注册;
- [x] Plugin Settings Contribution 后端可解析;
- [ ] Plugin Command Contribution 后端可注册;
- [ ] Plugin Settings Contribution 后端可解析;
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
- [ ] 完成跨模块接口审阅和第二阶段集成。
@@ -176,7 +176,7 @@ RunCancelled
## 当前实现状态
更新至 2026-09-02:后端 126 项回归测试通过;第二阶段 Plugin Command 与 Plugin Settings/Secret 接口已实现,详细 DTO 和边界见《第二阶段接口契约-开发版》第 7 节
更新至 2026-09-01:后端 92 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
@@ -47,17 +47,17 @@
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool |
| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command |
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 已实现 | 更新非敏感配置 |
| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 已实现 | 写入或删除 Secret Reference |
| Plugin Command | GET | `/api/plugin-contributions/commands` | 计划新增 | 获取前端可展示的 Command |
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 计划新增 | 受控执行 Command |
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 计划新增 | 获取 Schema 与非敏感配置 |
| Plugin Settings | PUT | `/api/plugins/{plugin_id}/settings` | 计划新增 | 更新非敏感配置 |
| Plugin Settings | PUT/DELETE | `/api/plugins/{plugin_id}/settings/{key}/secret` | 计划新增 | 写入或删除 Secret Reference |
| Provider | 现有路径 | `/api/providers/*``POST /api/chat` | 扩展 | 补齐协议能力和统一行为 |
| Retrieval | GET/POST | `/api/index/status``/api/index/rebuild` | 扩展 | 暴露 Embedding 兼容状态并安全重建向量 |
| Benchmark | GET | `/api/benchmarks/datasets` | 计划新增 | 枚举受控 Dataset |
| Benchmark | POST | `/api/benchmarks/rag/runs` | 计划新增 | 创建 RAG Benchmark |
| Benchmark | POST | `/api/benchmarks/agent/runs` | 计划新增 | 创建 Agent Benchmark |
| Benchmark | GET | `/api/benchmarks/runs` | 计划新增 | 分页获取 Benchmark Run |
| Benchmark | POST | `/api/benchmarks/rag/runs` | 已实现 | 创建 RAG Benchmark |
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
| Export | POST | `/api/exports` | 计划新增 | 创建 HTML/PDF/DOCX 导出任务 |
| Export | GET | `/api/exports` | 计划新增 | 分页获取导出任务 |
@@ -540,13 +540,7 @@ error
}
```
允许的 effect 首批为 `none``notification``navigate``refresh``job`每类 payload 也是契约的一部分:`none` 必须为空;`notification` 只接受 `level``info/success/warning/error`)和非空 `message``navigate` 只接受宿主路由名 `route``refresh` 只接受 `workspace/commands/settings/plugins` 范围;`job` 只接受受限格式的 `job_id`。后端拒绝未知字段和不匹配的 payload,前端仍须按判别联合穷尽处理,不得把 effect 当作任意代码执行。
当前宿主只注册已启用且已满足权限授权的 Plugin Command。执行前按 JSON Schema 校验参数、按 `when` 校验上下文,再根据 Command 声明裁剪 Context;单次执行默认超时 30 秒,effect 的 JSON 编码结果不得超过 64 KiB。宿主保留最多 500 条轻量审计事件,仅记录 Command、Plugin、状态、耗时和错误码,不记录 arguments、Context、effect 或 Secret。
需要 Secret 的 Command 必须在 `commands.yaml` 的内部 `secrets` 数组中声明对应 Setting Key,并在 Plugin Manifest 声明 `secrets.use` 权限。安装时宿主校验该字段确实属于当前 Plugin Settings Schema 的 `secret` 类型;只有权限已授予并启用后,运行时才向受控 handler 或 MCP Command Target 提供声明过的 Secret。未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置返回 `PLUGIN_SECRET_REQUIRED``secrets` 不属于前端 `PluginCommand` DTOSecret 明文也不会并入普通 Settings 字典。
`commands.yaml` 中的执行目标必须在宿主白名单 `handler` 与当前 Plugin 命名空间的 `mcp_tool` 之间二选一。MCP Command Target 不注册为 Agent Tool;宿主用 `_notesagent` 保留包装传入 Command ID、参数、裁剪后的 Context、已校验的非敏感 Settings 和声明过的 Secret,并将 MCP structured result 再校验为白名单 effect。作为宿主协议标记,目标 MCP Tool 的 `inputSchema` 必须在顶层 `properties` 中直接声明 `_notesagent: { type: object }`;不得用顶层组合或引用替代该标记。`_notesagent` 对象内部仍可使用完整 Draft 2020-12 约束、文档内引用和组合 Schema。启用阶段只检查协议标记,不尝试求解 Schema 或伪造业务值;宿主会保留完整 Schema,并在每次调用前用官方 Validator 校验真实信封。Command 与 Tool Schema 仅允许 `#...` 文档内引用,任何通过 `$ref``$dynamicRef` 指向文件、HTTP 或其他外部资源的 Schema 都会在注册前被拒绝。文档内引用遵循 Draft 2020-12 的嵌套 `$id` 与 Anchor 资源作用域,不能解析的引用不得进入运行时。
允许的 effect 首批为 `none``notification``navigate``refresh``job`前端仅执行白名单 effect;未知类型显示结果但不执行。
### 7.5 Settings Schema
@@ -591,8 +585,6 @@ select
secret
```
Number 字段的 `minimum``maximum` 必须是有限数值;`NaN`、正无穷和负无穷均视为无效 Settings Schema。
### 7.6 更新 Settings 和 Secret
`PUT /api/plugins/{plugin_id}/settings`
@@ -604,7 +596,7 @@ Number 字段的 `minimum` 和 `maximum` 必须是有限数值;`NaN`、正无
}
```
该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。没有默认值的必填普通字段必须先通过该接口配置;否则 Plugin Enable 和 Command Execute 返回 `PLUGIN_SETTINGS_REQUIRED`MCP 或内部 handler 不会收到残缺配置。
该接口拒绝 secret 字段。Schema 版本过期返回 `PLUGIN_SETTINGS_VERSION_CONFLICT` 并附当前版本。
Secret 使用:
@@ -631,10 +623,6 @@ DELETE /api/plugins/{plugin_id}/settings/{key}/secret
Secret 明文不进入普通 Settings、日志、Trace、Benchmark Dataset 或前端持久化。
非敏感值按 `plugin_id` 写入 `APP_DATA_DIR/plugins/settings.json`。该文件只保存普通值、Schema 版本和确定性的定长 Secret Reference,格式为 `plugin.<sha256(plugin_id\\0setting_key)>`;Secret 本身由宿主凭据存储加密保存。卸载 Plugin 时同时清理它的 Settings 命名空间和 Secret Reference。当前开发阶段使用 Fernet 文件凭据存储,第三阶段接入桌面 Host 后应迁移到 Stronghold 或系统 Keychain。
`plugin.*` 为宿主保留凭据命名空间。`/api/credentials/{credential_id}`、Provider 持久配置、Provider 临时测试凭据和 Provider Resolver 均拒绝该前缀,防止通过 Provider 链路覆盖、删除或向外部 Base URL 发送 Plugin Secret。
### 7.7 Plugin/MCP 错误码
```text
@@ -648,26 +636,10 @@ MCP_TOOL_CALL_FAILED
MCP_TOOL_RESULT_TOO_LARGE
MCP_TRUST_APPROVAL_REQUIRED
PLUGIN_COMMAND_NOT_FOUND
PLUGIN_COMMAND_CONFLICT
PLUGIN_COMMAND_INVALID
PLUGIN_COMMAND_ARGUMENT_INVALID
PLUGIN_COMMAND_CONTEXT_INVALID
PLUGIN_COMMAND_TIMEOUT
PLUGIN_COMMAND_EXECUTION_FAILED
PLUGIN_COMMAND_RESULT_INVALID
PLUGIN_COMMAND_RESULT_TOO_LARGE
PLUGIN_COMMAND_TARGET_SCHEMA_MISMATCH
PLUGIN_SETTINGS_SCHEMA_INVALID
PLUGIN_SETTINGS_VERSION_CONFLICT
PLUGIN_SETTINGS_FIELD_INVALID
PLUGIN_SETTINGS_REQUIRED
PLUGIN_SECRET_FIELD_NOT_FOUND
PLUGIN_SECRET_ACCESS_DENIED
PLUGIN_SECRET_REQUIRED
PLUGIN_SECRET_VALUE_INVALID
PLUGIN_SECRET_STORE_ERROR
PLUGIN_STORAGE_ERROR
CREDENTIAL_NAMESPACE_RESERVED
```
---
@@ -845,7 +817,7 @@ Dataset 从仓库或受控导入目录注册。API 不接受调用方提交任
配置快照必须记录 Embedding model ID/version/dimension、Reranker、索引版本、Dataset Hash 和运行环境。
### 9.5 创建 Agent Benchmark
### 9.5 创建 Agent Benchmark(暂缓,未暴露接口)
`POST /api/benchmarks/agent/runs`
@@ -879,12 +851,16 @@ RAG 和 Agent 创建接口均返回 `202 BenchmarkRun`
"metrics": null,
"config_snapshot": {},
"error": null,
"error_code": null,
"created_at": "2026-08-31T10:30:00Z",
"started_at": null,
"completed_at": null
}
```
`status` 取值:`queued``running``completed` | `failed` | `cancelled`。失败/取消时 `error`
`error_code` 只返回项目错误码与安全消息,不暴露第三方堆栈。
公共接口:
| 方法 | 路径 | 用途 |
@@ -895,6 +871,10 @@ RAG 和 Agent 创建接口均返回 `202 BenchmarkRun`
| POST | `/api/benchmarks/runs/{run_id}/cancel` | 取消运行 |
| GET | `/api/benchmarks/runs/{run_id}/report` | 获取结构化完整报告 |
SSE 事件流(`RunStarted``CaseCompleted`* → `RunCompleted` | `RunFailed` | `RunCancelled`):
`GET /api/benchmarks/runs/{run_id}/events` 支持 `Last-Event-ID``?after_sequence=` 游标恢复,
`RunCompleted` / `RunFailed` / `RunCancelled` 为终止事件,收到后即断流。
### 9.7 指标 Contract
RAG
@@ -907,10 +887,17 @@ RAG
"mrr": 0.81,
"citation_hit_rate": 0.89,
"p50_latency_ms": 24.5,
"p95_latency_ms": 67.3
"p95_latency_ms": 67.3,
"total_cases": 50,
"successful_cases": 48,
"failed_cases": 2,
"failure_rate": 0.04
}
```
失败样本按零分计入质量指标分母,`total_cases` / `successful_cases` / `failed_cases` /
`failure_rate` 让报告明确实际分母;延迟仅统计成功样本。
Agent
```json
@@ -934,8 +921,10 @@ BENCHMARK_DATASET_NOT_FOUND
BENCHMARK_DATASET_INVALID
BENCHMARK_CONFIG_INVALID
BENCHMARK_INDEX_INCOMPATIBLE
BENCHMARK_CAPACITY_EXCEEDED
BENCHMARK_RUN_NOT_FOUND
BENCHMARK_RUN_FAILED
BENCHMARK_CASE_EVALUATION_FAILED
```
### 9.9 Retrieval Profile 与索引兼容
@@ -2,7 +2,7 @@
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
> 更新日期:2026-09-02。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge隔离 Plugin Host 以及 Plugin Command/Settings 已落地,后端当前回归基线为 126 项测试通过。
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。
## 当前实现
@@ -68,7 +68,7 @@ Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider
- Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供;
- FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供;
- 文件系统和 API Key 明文读取:由 Rust Host 提供;
- Frontend Extension Slot 与 Plugin Command/Settings UI:后端 Contract 与前端 Service 已完成,页面由前端后续联调
- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现
## Provider
@@ -349,4 +349,4 @@ Skill Manifest
- Task 已持久化到 SQLiteAttachment Tool 读取 Host 管理目录中的 UTF-8 文件。
- `audio.transcribe` 当前消费 Host 预生成的 transcriptfaster-whisper 与说话人分离仍按技术基线在第二阶段接入。
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
- 当前 Plugin Host 支持内置声明式 handler本地 stdio MCP Server 以及 Plugin Command/SettingsStreamable HTTP、OS 级沙箱与 UI Contribution 留在后续阶段。
- 当前 Plugin Host 支持内置声明式 handler本地 stdio MCP ServerStreamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。
+76
View File
@@ -0,0 +1,76 @@
# Benchmark 开发说明
> 所属模块:Knowledge / Retrieval Core(后端,负责人 yxx)。RAG Benchmark 已交付;Agent Benchmark 暂缓,待 Agent Runtime 完成后在同一契约下补齐。
## 定位
Benchmark Service 用受控 Dataset 对检索引擎做可复现评测:创建即返回 queued、后台 asyncio.Task 执行、SSE 实时推送进度、结束后产出结构化报告。CLI、测试与前端报告页复用同一 Service,不各自实现指标。
## 接口
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/benchmarks/datasets?kind=rag` | 枚举受控目录下的 Dataset 元信息 |
| POST | `/api/benchmarks/rag/runs` | 创建 RAG Benchmark202 |
| GET | `/api/benchmarks/runs?kind=&status=&limit=&offset=` | 分页获取运行记录 |
| GET | `/api/benchmarks/runs/{run_id}` | 状态与指标摘要 |
| GET | `/api/benchmarks/runs/{run_id}/events` | SSE 进度与 Case 结果 |
| POST | `/api/benchmarks/runs/{run_id}/cancel` | 取消运行 |
| GET | `/api/benchmarks/runs/{run_id}/report` | 结构化完整报告 |
Agent Benchmark 的 `/api/benchmarks/agent/runs` 未暴露(暂缓),不在 OpenAPI 注册占位接口。
## Dataset
Dataset 来自 `settings.benchmark_datasets_path`(默认 `backend/data/benchmarks`),API 不接受调用方提交任意路径。按文件名 stem 精确匹配 `{dataset_id}.json`,与请求无关文件的损坏(JSON 语法错误、UTF-8 解码错误、顶层非对象)不会阻断加载;只有目标文件本身损坏才返回 `BENCHMARK_DATASET_INVALID`
RAG Case 结构:`case_id``query``expected_note_ids``expected_block_ids``citation_required``tags``citation_required=true` 时必须声明 `expected_block_ids`,否则无法计算 Citation Hit Rate。
## 运行生命周期
`queued → running → completed | failed | cancelled`
- 创建时校验索引兼容性:索引非空、Embedding model/dim 与当前引擎一致、vector/hybrid 时向量索引非空;不满足返回 `BENCHMARK_INDEX_INCOMPATIBLE`(409),避免把环境/索引错误误判为检索质量差。
- 内存注册表上限 `MAX_RUNS=100`,超限只淘汰终态 run;满容量且全为活动 run 时返回 `BENCHMARK_CAPACITY_EXCEEDED`429)。
- 失败/取消只向公开响应暴露项目错误码与安全消息,详细异常进入日志,不通过 HTTP/SSE 返回。
## 指标
RAG 按 (mode, case, repeat) 逐样本计算,再按 mode 聚合:
- 质量:`hit_at_1``hit_at_5``recall_at_k``mrr``citation_hit_rate`
- 延迟:`p50_latency_ms``p95_latency_ms`(仅统计成功样本);
- 样本构成:`total_cases``successful_cases``failed_cases``failure_rate`
失败样本按零分计入质量指标分母,报告据此可知实际分母,避免把执行失败误判为检索质量差。
## 事件与 SSE
事件流:`RunStarted → CaseCompleted* → RunCompleted | RunFailed | RunCancelled`
`GET /api/benchmarks/runs/{run_id}/events` 支持 `Last-Event-ID``?after_sequence=` 游标恢复(复用 Agent SSE 的解析逻辑),`RunCompleted` / `RunFailed` / `RunCancelled` 为终止事件,收到后断流。
## 错误码
```text
BENCHMARK_DATASET_NOT_FOUND
BENCHMARK_DATASET_INVALID
BENCHMARK_INDEX_INCOMPATIBLE
BENCHMARK_CAPACITY_EXCEEDED
BENCHMARK_RUN_NOT_FOUND
BENCHMARK_RUN_FAILED
BENCHMARK_CASE_EVALUATION_FAILED
```
## 配置快照
报告与运行记录保存 `config_snapshot`dataset hash/version、modes、retrieval 参数、Embedding model/version/dim、Reranker、索引元数据、App 版本与环境、Python 版本,保证不同实验结果可复现。
## 测试
```powershell
cd backend
uv run pytest -q
```
`tests/test_benchmark.py` 覆盖数据集注册与校验、指标纯函数、端到端运行、取消、索引兼容、容量与失败样本聚合;`tests/test_retrieval.py` 覆盖 FTS 阈值与分页 total 一致性。
@@ -3,7 +3,7 @@
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
> 更新日期:2026-09-02。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 126 项测试通过。
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。
## 当前实现
@@ -198,7 +198,7 @@ cd backend
uv run pytest -q
```
当前后端完整测试共 71 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
当前后端完整测试共 120 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
数据目录/DB/Vault 重定向到临时目录,不读写真实 `backend/data`,任何本机状态下结果确定。
## 配置
@@ -232,4 +232,6 @@ rag.search
- Embedding / Reranker 为轻量实现,后续替换为真实模型(接口不变)。
- 小语料下 hybrid 检索召回偏宽(向量 Top-K 覆盖全部 block),可加相关性阈值收紧。
- 重建为同步 + 全量,后续接入增量索引与异步任务队列。
- 检索 Benchmark 建立
- RAG Benchmark 建立`POST /api/benchmarks/rag/runs` 创建即返回 queued、后台 Task 执行,
通过 SSE 实时推送进度,报告含逐 Case 结果与 `total_cases` / `successful_cases` / `failed_cases` / `failure_rate`
- Agent Benchmark 暂缓,待 Agent Runtime 完成后交付。
@@ -1,6 +1,6 @@
# MCP Bridge 与 Plugin Host 开发说明
> 更新日期:2026-09-02。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。阶段 D 的 Plugin Command 与 Settings 已在其独立开发说明中落地
> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内
## 1. 目标与实现状态
@@ -235,7 +235,6 @@ backend/extensions/fixtures/mcp-echo
- `mcp-fixture.large`:验证结果大小上限;
- `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程;
- `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。
- `mcp-fixture.command`:作为 Plugin Command 专用 MCP Target,验证 Context 裁剪、Secret 传递和与 Agent Tool 的隔离。
Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability、返回无效 Schema/initialize result,以及输出超长无换行 stdout 的变体。
@@ -280,4 +279,4 @@ pnpm build
- 一键安装前的完整命令展示与确认 UI;
- Tool 列表热更新的无中断替换。
阶段 D 在当前 Plugin Runtime 上增加 Command、Settings、Secret Contract 和命名空间 Storage且未修改 Agent 使用内部 Tool Contract 的原则。实现细节见《Plugin Command 与 Settings 开发说明》。
阶段 D 在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage修改 Agent 使用内部 Tool Contract 的原则。
@@ -1,115 +0,0 @@
# Plugin Command 与 Settings 开发说明
> 更新日期:2026-09-02。本文记录第二阶段阶段 D 已实现的 Plugin Command Contribution、Plugin Settings Contribution、Secret 边界和前端 Service Contract。当前回归基线为后端 126 项测试、前端 29 项测试,TypeScript 类型检查和生产构建通过。
## 1. 阶段目标
阶段 D 在阶段 C 的 Plugin Runtime 与隔离 MCP Host 上补齐两类宿主贡献:
- Command:插件声明命令,宿主负责注册、展示、校验、执行和返回白名单 effect;
- Settings:插件声明设置 Schema,宿主负责动态表单 Contract、非敏感值持久化和 Secret 加密引用;
- Frontend Contract:提供稳定的 TypeScript DTO 与 Service,供后续命令面板、右键菜单和插件设置页直接联调。
本阶段不实现前端页面,也不把第三方代码导入 FastAPI 进程。操作系统级安全沙箱仍按规划在第三阶段桌面基础集成完成后、Tauri/Rust 沙箱正式构建前处理。
## 2. 包内声明
Plugin 在 `plugin.yaml``contributes.commands``contributes.settings_sections` 声明贡献标识,并分别提供 `commands.yaml``settings.yaml`。安装时宿主要求声明集合与文件内容完全一致,拒绝重复项、越过 Plugin 命名空间的 ID、未声明权限和无效 Schema。
`commands.yaml` 的首批字段包括:
- `command_id`、标题、描述、宿主图标;
- `locations``command_palette``context_menu``toolbar`
- `when` 与允许传入执行器的 Context 字段;
- 参数 JSON Schema、可选权限、执行目标和超时;
- 执行目标必须在宿主白名单 `handler` 与当前插件命名空间的 `mcp_tool` 之间二选一。
- 可选 `secrets` 字段:只声明当前 Command 允许按需读取的 Secret Setting Key,不暴露给前端 DTO。
`settings.yaml` 采用递增 `schema_version`,首批字段类型固定为 `string``number``boolean``select``secret`。宿主会校验默认值、必填项、有限数值边界、Select 选项,以及 Secret 不得携带默认明文;`NaN` 与正负无穷不能用作上下界。
仓库内 `text-tools` 是联调 Fixture,覆盖 Command 和五种 Settings 字段类型。
## 3. Command 运行链路
`CommandRegistry` 只发布处于启用状态的 Plugin Command。Plugin 禁用、Host 不可用或重启时,Command 与 Tool 使用同样的注销/重新注册生命周期,避免前端看到实际不可执行的命令。
执行顺序如下:
1. 查找已注册 Command
2. 使用 Draft 2020-12 JSON Schema 校验 arguments
3. 根据 `when` 检查必要上下文;
4. 仅向执行器传递声明过的 Context 字段;
5. 在超时范围内调用宿主受控 handler,或调用独立的 MCP Command Target
6. 按 effect 类型校验专属 payload、可序列化性和 64 KiB 大小上限;
7. 返回统一 `PluginCommandResult`
首批 effect 为 `none``notification``navigate``refresh``job`。后端分别限制通知级别与消息、宿主路由名、刷新范围和 Job IDPydantic 与 TypeScript 均使用同一判别语义,前端不得把 effect 当作任意代码执行。
Command 执行器通过受控 Resolver 按需读取 `commands.yaml` 已声明且确实属于当前 Plugin Schema 的 Secret;使用 Secret 的 Plugin 还必须声明并获授 `secrets.use` 权限。读取未声明字段返回 `PLUGIN_SECRET_ACCESS_DENIED`,必填 Secret 未配置则返回 `PLUGIN_SECRET_REQUIRED`。Secret 不会并入普通 Settings 字典。Command 审计使用 500 条有界内存队列,仅保留 `command_id``plugin_id`、成功/失败状态、耗时、错误码和时间。arguments、正文选区、文件路径、effect 与 Secret 均不进入审计事件。
MCP Command Target 是专用执行目标,不注册进 Agent `ToolRegistry`,因此模型无法绕过 Command 权限与 Context 裁剪直接调用。宿主通过 `_notesagent` 保留包装传入 `command_id`、已校验 arguments、已裁剪 Context、已校验的非敏感 Settings 和声明过的 Secret。启用阶段只检查目标 `inputSchema` 在顶层 `properties` 中直接声明 `_notesagent: { type: object }`,不自行求解 JSON Schema,也不用空对象伪造业务数据;引用和组合约束可以放在 `_notesagent` 对象内部。执行阶段再用保留的完整 Schema 和官方 Draft 2020-12 Validator 校验真实信封。MCP Server 必须返回结构化的白名单 effect。远程原始错误不直接透传给 HTTP 调用方。插件仍不能把模块路径或 Shell 字符串作为执行器。
Command 与 Tool 的 JSON Schema 只允许当前文档内的 Fragment 引用(`#...`);宿主在注册前递归拒绝 `$ref` / `$dynamicRef` 指向的文件、HTTP 或其他外部资源,避免 Schema 校验触发未授权 I/O。文档内引用使用 Draft 2020-12 Resource Resolver 预检,嵌套 `$id` 创建的新资源及其 Anchor 按各自作用域解析,无法解析的引用在注册阶段返回稳定错误。
## 4. Settings 与 Secret 边界
普通 Settings 以 Plugin 为命名空间持久化到:
```text
APP_DATA_DIR/plugins/settings.json
```
该文件只包含:
- 当前 Schema 版本;
- 非敏感字段值;
- Secret 的确定性定长引用,格式为 `plugin.<sha256(plugin_id\\0setting_key)>`
Secret 写入必须调用专用端点。后端通过 `SecretStr` 接收明文,再交给现有 `EncryptedCredentialStore`;普通 Settings API 只返回 `{ configured: true|false }`,不会返回 Secret 值。`plugin.*` 是保留命名空间,通用凭据 API、Provider 配置、Provider 临时测试凭据和 Provider Resolver 均不得访问,防止覆盖、删除或外发 Plugin Secret。卸载 Plugin 时同时删除普通设置命名空间和对应加密凭据。
没有默认值的 `required` 普通字段必须在启用 Plugin 前配置。Enable 和每次 Command Execute 都会重新检查有效设置;缺失时返回 `PLUGIN_SETTINGS_REQUIRED`,不启动 MCP Host,也不调用 Command handler。
读取持久化引用时,宿主会重新计算并核对 `plugin.<sha256(...)>`,引用不匹配即按损坏存储拒绝处理,不能借由篡改 `settings.json` 读取或删除 Provider 等其他命名空间的凭据。删除单个 Secret 或卸载 Plugin 时先原子更新 Settings 引用,再删除加密凭据;底层删除失败会恢复原引用。多 Secret 卸载使用一次凭据表原子替换,避免分批删除部分删除。
开发阶段凭据文件由本机 Fernet Key 加密。桌面端落地后,应由 Tauri Host 将同一引用语义迁移到 Stronghold 或系统 KeychainHTTP Contract 无需因此改变。
## 5. HTTP 与前端 Service
后端已实现:
```text
GET /api/plugin-contributions/commands?location=command_palette
POST /api/plugin-contributions/commands/{command_id}/execute
GET /api/plugins/{plugin_id}/settings
PUT /api/plugins/{plugin_id}/settings
PUT /api/plugins/{plugin_id}/settings/{key}/secret
DELETE /api/plugins/{plugin_id}/settings/{key}/secret
```
前端 `pluginService` 已提供对应方法及 Wire DTO,但阶段 D 不创建命令面板或动态设置表单页面。调用方必须使用服务层,不自行拼接路径;Secret 不得写入 Pinia、LocalStorage 或调试日志。
## 6. 主要错误边界
- Command 未注册、冲突、参数或 Context 无效;
- 执行超时、执行器异常、effect 无效或过大;
- Settings Schema 无效、版本冲突、字段类型/边界错误或运行时必填值缺失;
- Secret 字段不存在、空 Secret、凭据存储异常;
- Settings JSON 根结构或 Plugin 命名空间损坏。
以上错误统一转换为 `ExtensionError` 和稳定业务错误码,HTTP 层不暴露内部堆栈、Secret 或插件返回的原始异常。
## 7. 验证
```powershell
cd backend
uv run pytest
cd ../frontend
pnpm test -- --run
pnpm type-check
pnpm build
```
阶段 D 测试覆盖注册/注销生命周期、位置过滤、参数与 Context 校验、上下文裁剪、设置影响命令执行、声明式 Secret Resolver 与越权拒绝、真实 MCP Command Target 与 Agent Tool 隔离、必填 Secret 传递、外部 Schema 引用拒绝、定长 Secret Reference、篡改引用的跨命名空间阻断、Secret 删除与卸载失败回滚、Provider/通用凭据命名空间隔离、五类设置字段、Schema 版本冲突、Secret 密文与清理、损坏存储、空 Command 列表等无效贡献文件、OpenAPI 路径和前端 Service 请求格式。
生产构建仍会报告现有大 Chunk 警告,不影响构建成功;该问题属于前端按路由和 Markdown 依赖拆包的后续性能任务。
@@ -186,13 +186,13 @@ pnpm build
```text
pnpm build passed
pnpm test 29 passed
uv run pytest 126 passed
pnpm test 27 passed
uv run pytest 92 passed
preview smoke HTTP 200
git diff --check passed
```
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、Plugin Command/Settings Service、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 126 项测试结果,也不涉及产品代码。
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 92 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
@@ -104,4 +104,4 @@ pnpm build
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 126 项测试、前端 29 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。`plugin.*` 为 Plugin Secret 保留命名空间,Provider 配置、临时测试凭据和通用凭据 API 均拒绝该前缀。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
@@ -4,7 +4,7 @@
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
> 2026-09-02 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化stdio MCP Plugin Host 和 Plugin Command/Settings,当前完整后端回归基线为 126 项测试通过。
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。
## 1. 审阅结论
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
-80
View File
@@ -271,86 +271,6 @@ export interface PluginHostStatus {
error?: string | null
}
export type PluginCommandLocation = 'command_palette' | 'context_menu' | 'toolbar'
export interface PluginCommand {
command_id: string
plugin_id: string
title: string
description: string
icon?: string | null
locations: PluginCommandLocation[]
when: string[]
parameters: Record<string, unknown>
enabled: boolean
}
export interface PluginCommandContext {
vault_id?: string | null
note_id?: string | null
file_path?: string | null
selection?: string | null
}
export type PluginCommandEffect =
| { type: 'none'; payload: Record<string, never> }
| {
type: 'notification'
payload: { level: 'info' | 'success' | 'warning' | 'error'; message: string }
}
| {
type: 'navigate'
payload: {
route:
| 'vault-entry'
| 'workspace'
| 'search'
| 'chat'
| 'agent'
| 'tasks'
| 'skills'
| 'plugins'
| 'themes'
| 'settings'
}
}
| { type: 'refresh'; payload: { scope: 'workspace' | 'commands' | 'settings' | 'plugins' } }
| { type: 'job'; payload: { job_id: string } }
export interface PluginCommandResult {
command_id: string
status: 'completed'
effect: PluginCommandEffect
}
export type PluginSettingType = 'string' | 'number' | 'boolean' | 'select' | 'secret'
export interface PluginSettingField {
key: string
label: string
description: string
type: PluginSettingType
required: boolean
default?: unknown
minimum?: number | null
maximum?: number | null
options: string[]
}
export interface PluginSettingsSchema {
plugin_id: string
schema_version: number
fields: PluginSettingField[]
values: Record<string, unknown>
secrets: Record<string, { configured: boolean }>
}
export interface PluginSecretStatus {
plugin_id: string
key: string
configured: boolean
}
export interface PluginContribution {
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
id: string
@@ -1,85 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as pluginService from './pluginService'
function jsonResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('pluginService contribution adapter', () => {
it('lists and executes Plugin Commands with scoped wire fields', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(jsonResponse({ items: [{ command_id: 'text-tools.uppercase-selection' }] }))
.mockResolvedValueOnce(jsonResponse({
command_id: 'text-tools.uppercase-selection',
status: 'completed',
effect: { type: 'notification', payload: { level: 'success', message: 'HELLO' } },
}))
const commands = await pluginService.listPluginCommands('command_palette')
const result = await pluginService.executePluginCommand(
'text-tools.uppercase-selection',
{},
{ note_id: 'note-1', selection: 'hello' },
)
expect(commands[0].command_id).toBe('text-tools.uppercase-selection')
if (result.effect.type !== 'notification') throw new Error('expected notification effect')
expect(result.effect.payload.message).toBe('HELLO')
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/plugin-contributions/commands?location=command_palette',
)
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({
arguments: {},
context: { note_id: 'note-1', selection: 'hello' },
})
})
it('uses separate Settings and Secret endpoints', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(jsonResponse({
plugin_id: 'text-tools', schema_version: 1, fields: [],
values: { result_limit: 10 }, secrets: { api_key: { configured: false } },
}))
.mockResolvedValueOnce(jsonResponse({
plugin_id: 'text-tools', schema_version: 1, fields: [],
values: { result_limit: 20 }, secrets: { api_key: { configured: false } },
}))
.mockResolvedValueOnce(jsonResponse({ plugin_id: 'text-tools', key: 'api_key', configured: true }))
.mockResolvedValueOnce(jsonResponse({ plugin_id: 'text-tools', key: 'api_key', configured: false }))
await pluginService.getPluginSettings('text-tools')
await pluginService.updatePluginSettings('text-tools', 1, { result_limit: 20 })
await pluginService.putPluginSecret('text-tools', 'api_key', 'request-only-secret')
await pluginService.deletePluginSecret('text-tools', 'api_key')
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/plugins/text-tools/settings',
'/api/plugins/text-tools/settings',
'/api/plugins/text-tools/settings/api_key/secret',
'/api/plugins/text-tools/settings/api_key/secret',
])
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({
schema_version: 1,
values: { result_limit: 20 },
})
expect(JSON.parse(String(fetchMock.mock.calls[2][1]?.body))).toEqual({
secret: 'request-only-secret',
})
expect(fetchMock.mock.calls[3][1]?.method).toBe('DELETE')
})
})
+1 -62
View File
@@ -1,17 +1,5 @@
import apiClient from './apiClient'
import type {
ApiPlugin,
OperationResponse,
Plugin,
PluginCommand,
PluginCommandContext,
PluginCommandLocation,
PluginCommandResult,
PluginContribution,
PluginHostStatus,
PluginSecretStatus,
PluginSettingsSchema,
} from '@/contracts'
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution, PluginHostStatus } from '@/contracts'
function toPlugin(plugin: ApiPlugin): Plugin {
const { manifest } = plugin
@@ -74,55 +62,6 @@ export async function restartPluginHost(pluginId: string): Promise<OperationResp
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
}
export async function listPluginCommands(location?: PluginCommandLocation): Promise<PluginCommand[]> {
const query = location ? `?location=${encodeURIComponent(location)}` : ''
const response = await apiClient.get<{ items: PluginCommand[] }>(`/api/plugin-contributions/commands${query}`)
return response.items
}
export async function executePluginCommand(
commandId: string,
argumentsValue: Record<string, unknown> = {},
context: PluginCommandContext = {},
): Promise<PluginCommandResult> {
return apiClient.post(`/api/plugin-contributions/commands/${encodeURIComponent(commandId)}/execute`, {
arguments: argumentsValue,
context,
})
}
export async function getPluginSettings(pluginId: string): Promise<PluginSettingsSchema> {
return apiClient.get(`/api/plugins/${encodeURIComponent(pluginId)}/settings`)
}
export async function updatePluginSettings(
pluginId: string,
schemaVersion: number,
values: Record<string, unknown>,
): Promise<PluginSettingsSchema> {
return apiClient.put(`/api/plugins/${encodeURIComponent(pluginId)}/settings`, {
schema_version: schemaVersion,
values,
})
}
export async function putPluginSecret(
pluginId: string,
key: string,
secret: string,
): Promise<PluginSecretStatus> {
return apiClient.put(
`/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`,
{ secret },
)
}
export async function deletePluginSecret(pluginId: string, key: string): Promise<PluginSecretStatus> {
return apiClient.delete(
`/api/plugins/${encodeURIComponent(pluginId)}/settings/${encodeURIComponent(key)}/secret`,
)
}
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}