Files
NotesAgentic/backend/app/contracts.py
T
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

809 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
class Contract(BaseModel):
model_config = ConfigDict(extra="forbid")
class PageMeta(Contract):
total: int = 0
limit: int = 50
offset: int = 0
class ErrorDetail(Contract):
code: str
message: str
details: dict[str, Any] = Field(default_factory=dict)
class ErrorResponse(Contract):
error: ErrorDetail
class OperationResponse(Contract):
status: Literal["accepted", "completed"]
resource_id: str | None = None
message: str | None = None
# Workspace boundary (single configured Vault in Web development mode)
class WorkspaceInfo(Contract):
vault_id: str = "default"
name: str
path: str
file_count: int = 0
indexed_note_count: int = 0
requires_refresh: bool = False
class WorkspaceEntry(Contract):
entry_id: str
name: str
path: str
type: Literal["file", "folder"]
note_id: str | None = None
children: list["WorkspaceEntry"] = Field(default_factory=list)
class WorkspaceSnapshot(Contract):
workspace: WorkspaceInfo
items: list[WorkspaceEntry] = Field(default_factory=list)
class WorkspaceOpenRequest(Contract):
path: str | None = None
class FolderCreateRequest(Contract):
parent: str = ""
name: str = Field(min_length=1)
class FolderRenameRequest(Contract):
path: str
new_name: str = Field(min_length=1)
class FolderDeleteRequest(Contract):
path: str
# Notes and retrieval
class NoteBlock(Contract):
block_id: str
note_id: str
heading_path: list[str] = Field(default_factory=list)
start_offset: int
end_offset: int
content: str
content_hash: str
token_count: int
class NoteSummary(Contract):
note_id: str
title: str
file_path: str
tags: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class Note(NoteSummary):
markdown: str
blocks: list[NoteBlock] = Field(default_factory=list)
class NoteListResponse(Contract):
items: list[NoteSummary] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class NoteCreateRequest(Contract):
title: str = Field(min_length=1)
markdown: str = ""
folder: str | None = None
tags: list[str] = Field(default_factory=list)
class NoteUpdateRequest(Contract):
title: str | None = None
markdown: str | None = None
tags: list[str] | None = None
class NoteMoveRequest(Contract):
folder: str
class NoteRenameRequest(Contract):
file_name: str = Field(min_length=1)
class SearchMode(str, Enum):
fts = "fts"
vector = "vector"
hybrid = "hybrid"
class SearchRequest(Contract):
query: str = Field(min_length=1)
mode: SearchMode = SearchMode.hybrid
folders: list[str] = Field(default_factory=list)
note_ids: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
created_from: datetime | None = None
created_to: datetime | None = None
updated_from: datetime | None = None
updated_to: datetime | None = None
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):
citation_id: str
note_id: str
block_id: str
file_path: str
heading_path: list[str] = Field(default_factory=list)
start_offset: int | None = None
end_offset: int | None = None
source_audio: str | None = None
start_time: float | None = None
end_time: float | None = None
speaker: str | None = None
class SearchResult(Contract):
note_id: str
block_id: str
title: str
file_path: str
heading_path: list[str] = Field(default_factory=list)
snippet: str | None = None
score: float
citation: Citation
class SearchResponse(Contract):
query: str
mode: SearchMode
items: list[SearchResult] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
# Model, chat and tools
class MessageRole(str, Enum):
system = "system"
user = "user"
assistant = "assistant"
tool = "tool"
class Message(Contract):
role: MessageRole
content: str
name: str | None = None
tool_call_id: str | None = None
tool_calls: list["ToolCall"] = Field(default_factory=list)
class ToolDefinition(Contract):
name: str
description: str
parameters: dict[str, Any] = Field(default_factory=dict)
permission: str | None = None
source: Literal["builtin", "plugin"] = "builtin"
class ToolCall(Contract):
tool_call_id: str
name: str
arguments: dict[str, Any] = Field(default_factory=dict)
class ToolResult(Contract):
tool_call_id: str
name: str
success: bool
output: Any | None = None
error_code: str | None = None
error_message: str | None = None
duration_ms: int | None = None
class ToolListResponse(Contract):
items: list[ToolDefinition] = Field(default_factory=list)
class ModelCapability(str, Enum):
chat = "chat"
vision = "vision"
tool_calling = "tool_calling"
reasoning = "reasoning"
streaming = "streaming"
structured_output = "structured_output"
embedding = "embedding"
class ModelRequest(Contract):
provider_id: str
model: str
system: str | None = None
messages: list[Message]
tools: list[ToolDefinition] = Field(default_factory=list)
temperature: float | None = Field(default=None, ge=0, le=2)
max_tokens: int | None = Field(default=None, ge=1)
response_format: dict[str, Any] | None = None
attachments: list[str] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class ChatRequest(ModelRequest):
conversation_id: str | None = None
use_rag: bool = True
retrieval: SearchRequest | None = None
class ModelEventType(str, Enum):
text_delta = "TextDelta"
thinking_delta = "ThinkingDelta"
tool_call_start = "ToolCallStart"
tool_call_delta = "ToolCallDelta"
tool_call_end = "ToolCallEnd"
usage = "Usage"
error = "Error"
done = "Done"
class ModelEvent(Contract):
event: ModelEventType
sequence: int = 0
data: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime
# Agent
class AgentRunStatus(str, Enum):
queued = "queued"
running = "running"
waiting_permission = "waiting_permission"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class AgentRunCreateRequest(Contract):
input: str = Field(min_length=1)
provider_id: str
model: str
skill_id: str | None = None
allowed_tools: list[str] = Field(default_factory=list)
max_steps: int = Field(default=10, ge=1, le=100)
tool_timeout_seconds: int = Field(default=30, ge=1)
run_timeout_seconds: int = Field(default=300, ge=1)
token_budget: int | None = Field(default=None, ge=1)
max_concurrent_tools: int = Field(default=1, ge=1)
allow_network: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
class AgentRun(Contract):
run_id: str
status: AgentRunStatus
input: str
provider_id: str
model: str
skill_id: str | None = None
current_step: int = 0
max_steps: int
token_budget: int | None = None
cancelled: bool = False
output: str | None = None
error_code: str | None = None
error_message: str | None = None
token_usage: int = 0
tool_results: list[ToolResult] = Field(default_factory=list)
citations: list[Citation] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class AgentRunListResponse(Contract):
items: list[AgentRun] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class AgentEventType(str, Enum):
run_started = "RunStarted"
text_delta = "TextDelta"
thinking_delta = "ThinkingDelta"
tool_call = "ToolCall"
tool_result = "ToolResult"
permission_required = "PermissionRequired"
usage = "Usage"
citation = "Citation"
model_call_started = "ModelCallStarted"
model_call_completed = "ModelCallCompleted"
model_call_failed = "ModelCallFailed"
permission_resolved = "PermissionResolved"
run_completed = "RunCompleted"
run_failed = "RunFailed"
run_cancelled = "RunCancelled"
class AgentEvent(Contract):
event: AgentEventType
run_id: str
sequence: int
data: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime
class AgentTraceSummary(Contract):
model_calls: int = 0
tool_calls: int = 0
duration_ms: int = 0
token_usage: int = 0
errors: int = 0
class AgentTraceResponse(Contract):
run_id: str
status: AgentRunStatus
items: list[AgentEvent] = Field(default_factory=list)
next_sequence: int
has_more: bool = False
summary: AgentTraceSummary = Field(default_factory=AgentTraceSummary)
config_snapshot: dict[str, Any] = Field(default_factory=dict)
class PermissionDecisionRequest(Contract):
decision: Literal["allow_once", "allow_session", "deny"]
# Skills and plugins
class RetrievalConfig(Contract):
top_k: int = Field(default=10, ge=1, le=100)
rerank: bool = True
citation: bool = True
class SkillModelConfig(Contract):
required_capabilities: list[ModelCapability] = Field(default_factory=list)
class SkillManifest(Contract):
skill_id: str
name: str
version: str
description: str = ""
permissions: list[str] = Field(default_factory=list)
tools: list[str] = Field(default_factory=list)
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
model: SkillModelConfig = Field(default_factory=SkillModelConfig)
class SkillStatus(str, Enum):
installed = "installed"
disabled = "disabled"
ready = "ready"
dependency_missing = "dependency_missing"
permission_required = "permission_required"
error = "error"
class Skill(Contract):
manifest: SkillManifest
status: SkillStatus
enabled: bool = False
missing_dependencies: list[str] = Field(default_factory=list)
class SkillListResponse(Contract):
items: list[Skill] = Field(default_factory=list)
class ExtensionInstallRequest(Contract):
package_path: str
class PluginBackend(Contract):
type: Literal["mcp", "internal_rpc", "none"] = "none"
transport: Literal["stdio", "http", "none"] = "none"
command: str | None = None
args: list[str] = Field(default_factory=list)
startup_timeout_seconds: int = Field(default=10, ge=1, le=60)
tool_timeout_seconds: int = Field(default=30, ge=1, le=600)
class PluginContribution(Contract):
tools: list[str] = Field(default_factory=list)
commands: list[str] = Field(default_factory=list)
importers: list[str] = Field(default_factory=list)
exporters: list[str] = Field(default_factory=list)
panels: list[str] = Field(default_factory=list)
settings_sections: list[str] = Field(default_factory=list)
class PluginManifest(Contract):
plugin_id: str
name: str
version: str
description: str = ""
permissions: list[str] = Field(default_factory=list)
contributes: PluginContribution = Field(default_factory=PluginContribution)
backend: PluginBackend = Field(default_factory=PluginBackend)
class PluginStatus(str, Enum):
installed = "installed"
disabled = "disabled"
starting = "starting"
ready = "ready"
error = "error"
dependency_missing = "dependency_missing"
permission_required = "permission_required"
class Plugin(Contract):
manifest: PluginManifest
status: PluginStatus
enabled: bool = False
granted_permissions: list[str] = Field(default_factory=list)
error_message: str | None = None
class PluginListResponse(Contract):
items: list[Plugin] = Field(default_factory=list)
class PluginHostState(str, Enum):
stopped = "stopped"
starting = "starting"
ready = "ready"
unhealthy = "unhealthy"
error = "error"
class PluginHostStatus(Contract):
plugin_id: str
backend_type: Literal["mcp", "internal_rpc", "none"]
transport: Literal["stdio", "http", "none"]
status: PluginHostState
tools_count: int = 0
started_at: datetime | None = None
last_seen_at: datetime | None = None
protocol_version: str | None = None
server_name: str | None = None
server_version: str | None = None
error: str | None = None
class PluginPermissionGrantRequest(Contract):
permissions: list[str] = Field(default_factory=list)
# Providers
class ProviderType(str, Enum):
mock = "mock"
openai_responses = "openai_responses"
openai_chat = "openai_chat"
openai_compatible = "openai_compatible"
anthropic_messages = "anthropic_messages"
ollama = "ollama"
class ProviderConfig(Contract):
provider_id: str
provider_type: ProviderType
name: str
base_url: str | None = None
default_model: str | None = None
credential_id: str | None = None
enabled: bool = True
capabilities: list[ModelCapability] = Field(default_factory=list)
class ProviderCreateRequest(Contract):
provider_type: ProviderType
name: str
base_url: str | None = None
default_model: str | None = None
credential_id: str | None = None
enabled: bool = True
class ProviderUpdateRequest(Contract):
name: str | None = None
base_url: str | None = None
default_model: str | None = None
credential_id: str | None = None
enabled: bool | None = None
class ProviderListResponse(Contract):
items: list[ProviderConfig] = Field(default_factory=list)
class ProviderPreset(Contract):
preset_id: str
name: str
provider_type: ProviderType
base_url: str
default_credential_id: str | None = None
requires_credential: bool = True
class ProviderPresetListResponse(Contract):
items: list[ProviderPreset] = Field(default_factory=list)
class CredentialWriteRequest(Contract):
api_key: SecretStr = Field(min_length=1, max_length=8192)
class CredentialStatus(Contract):
credential_id: str
configured: bool
class ModelInfo(Contract):
model: str
display_name: str
capabilities: list[ModelCapability] = Field(default_factory=list)
class ProviderModelsResponse(Contract):
provider_id: str
items: list[ModelInfo] = Field(default_factory=list)
class ProviderTestRequest(Contract):
provider_id: str
model: str | None = None
credential_context_id: str | None = None
class ProviderTestResponse(Contract):
provider_id: str
success: bool
latency_ms: int | None = None
message: str
# Tasks, media and index
class TaskStatus(str, Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
cancelled = "cancelled"
class Task(Contract):
task_id: str
title: str
description: str = ""
status: TaskStatus = TaskStatus.todo
note_id: str | None = None
due_at: datetime | None = None
created_at: datetime
updated_at: datetime
class TaskCreateRequest(Contract):
title: str = Field(min_length=1)
description: str = ""
note_id: str | None = None
due_at: datetime | None = None
class TaskUpdateRequest(Contract):
title: str | None = None
description: str | None = None
status: TaskStatus | None = None
note_id: str | None = None
due_at: datetime | None = None
class TaskListResponse(Contract):
items: list[Task] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class TranscriptionRequest(Contract):
attachment_id: str
language: str | None = None
diarization: bool = False
class TranscriptionJob(Contract):
job_id: str
attachment_id: str
status: Literal["queued", "processing", "completed", "failed"]
text: str | None = None
error_code: str | None = None
error_message: str | None = None
created_at: datetime
class IndexStatus(Contract):
status: Literal["idle", "queued", "running", "failed"] = "idle"
pending_jobs: int = 0
active_job_id: str | None = None
last_completed_at: datetime | None = None
error_message: str | None = None
class IndexRebuildRequest(Contract):
scope: Literal["all", "notes", "vectors"] = "all"
note_ids: list[str] = Field(default_factory=list)
force: bool = False
class IndexJob(Contract):
job_id: str
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