Files
NotesAgentic/backend/app/contracts.py
T

1257 lines
33 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 Annotated, Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
SecretStr,
field_validator,
model_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", "mcp_server"] = "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"
transcription = "transcription"
speaker_matching = "speaker_matching"
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
# Independent user-managed MCP Server Registry. This is deliberately separate
# from Plugin manifests: a server can contribute tools without being a Plugin.
class McpServerTransport(str, Enum):
stdio = "stdio"
streamable_http = "streamable_http"
sse = "sse"
class McpServerConfig(Contract):
name: str = Field(min_length=1, max_length=80)
transport: McpServerTransport = McpServerTransport.stdio
command: str | None = Field(default=None, max_length=1024)
args: list[str] = Field(default_factory=list, max_length=64)
url: str | None = Field(default=None, max_length=4096)
headers: dict[str, str] = Field(default_factory=dict)
environment: dict[str, str] = Field(default_factory=dict)
secret_environment_keys: list[str] = Field(default_factory=list)
secret_header_keys: list[str] = Field(default_factory=list)
permissions: list[str] = Field(default_factory=list)
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
class McpServerCreateRequest(McpServerConfig):
pass
class McpServerUpdateRequest(McpServerConfig):
version: int = Field(ge=1)
class McpServerSecretWriteRequest(Contract):
secret: SecretStr = Field(min_length=1, max_length=32768)
class McpServerSecretStatus(Contract):
key: str
configured: bool
class McpServerTrustRequest(Contract):
command_digest: str = Field(min_length=64, max_length=64)
class McpServerStatus(Contract):
enabled: bool = False
status: PluginHostState = PluginHostState.stopped
tools_count: int = 0
protocol_version: str | None = None
remote_server_name: str | None = None
remote_server_version: str | None = None
error: str | None = None
last_tested_at: datetime | None = None
last_test_succeeded: bool | None = None
class McpServer(McpServerStatus):
server_id: str
version: int
name: str
transport: McpServerTransport
command: str | None = None
args: list[str] = Field(default_factory=list)
url: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
environment: dict[str, str] = Field(default_factory=dict)
permissions: list[str] = Field(default_factory=list)
startup_timeout_seconds: float
tool_timeout_seconds: float
secret_environment: dict[str, bool] = Field(default_factory=dict)
secret_headers: dict[str, bool] = Field(default_factory=dict)
trusted: bool = False
command_digest: str
command_summary: str
class McpServerListResponse(Contract):
items: list[McpServer] = Field(default_factory=list)
class McpToolSummary(Contract):
name: str
remote_name: str
description: str
permission: str | None = None
class McpToolSummaryListResponse(Contract):
items: list[McpToolSummary] = Field(default_factory=list)
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)
# 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 ProviderConnectionFields(Contract):
base_url: str | None = None
credential_id: str | None = None
@field_validator("base_url")
@classmethod
def provider_url(cls, value: str | None) -> str | None:
if value is None:
return value
from urllib.parse import urlsplit
parsed = urlsplit(value)
if (parsed.scheme not in {"http", "https"} or not parsed.hostname or
parsed.username or parsed.password or parsed.query or parsed.fragment):
raise ValueError("Base URL requires HTTP(S), without credentials, query or fragment")
return value.rstrip("/")
class ProviderConfig(ProviderConnectionFields):
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(ProviderConnectionFields):
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(ProviderConnectionFields):
provider_type: ProviderType | None = None
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
logo_id: str = "custom"
description: str = ""
capabilities: list[ModelCapability] = Field(default_factory=list)
class ModelBinding(Contract):
provider_id: str = Field(min_length=1, max_length=128)
model: str = Field(min_length=1, max_length=256)
endpoint: str = Field(min_length=1, max_length=256)
dimensions: int | None = Field(default=None, ge=1, le=16384)
@field_validator("endpoint")
@classmethod
def relative_endpoint(cls, value: str) -> str:
# An endpoint is a path on the selected provider, never a second origin.
import re
if not re.fullmatch(r"/[A-Za-z0-9_/-]+", value) or value.startswith("//"):
raise ValueError("endpoint must be an absolute API path on the provider")
return value
@field_validator("model", "provider_id")
@classmethod
def non_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("value must not be blank")
return value.strip()
class ModelRoutingConfig(Contract):
version: int = Field(default=0, ge=0)
embedding: ModelBinding | None = None
transcription: ModelBinding | None = None
speaker_matching: ModelBinding | None = None
class LocalBackendStatus(Contract):
capability: Literal["embedding", "transcription", "speaker_matching"]
status: Literal["placeholder", "not_installed", "ready"]
message: str
class ModelRoutingResponse(Contract):
config: ModelRoutingConfig
local_backends: list[LocalBackendStatus]
class EmbeddingRequest(Contract):
texts: list[str] = Field(min_length=1, max_length=256)
@field_validator("texts")
@classmethod
def bound_texts(cls, value: list[str]) -> list[str]:
if sum(len(text) for text in value) > 200_000:
raise ValueError("embedding input is too large")
return value
class EmbeddingResult(Contract):
vectors: list[list[float]]
source: Literal["api", "local"]
model_id: str
dimensions: int
fallback_reason: str | None = None
class SpeakerMatchRequest(Contract):
attachment_id: str
reference_attachment_id: str
class SpeakerMatchResult(Contract):
score: float = Field(ge=0, le=1, allow_inf_nan=False)
source: Literal["api", "local"]
fallback_reason: str | None = None
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
source: Literal["api", "local", "sidecar"] | None = None
fallback_reason: str | None = None
class IndexStatus(Contract):
total_notes: int = 0
total_blocks: int = 0
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):
embedding: dict[str, Any] = Field(default_factory=dict)
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
# Export(多格式文档导出)
class ExportStatus(str, Enum):
queued = "queued"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class ExportFormat(str, Enum):
html = "html"
pdf = "pdf"
docx = "docx"
class ExportSourceType(str, Enum):
note = "note"
markdown = "markdown"
class ExportSource(Contract):
"""导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。"""
type: ExportSourceType
note_id: str | None = None
markdown: str | None = None
@model_validator(mode="after")
def _validate_source(self) -> "ExportSource":
if self.type == ExportSourceType.note and not self.note_id:
raise ValueError("note source requires note_id")
if self.type == ExportSourceType.markdown and not self.markdown:
raise ValueError("markdown source requires markdown")
return self
class ExportOptions(Contract):
theme_id: str = "light"
include_title: bool = True
include_metadata: bool = False
page_size: str = "A4"
code_theme: str = "github-light"
class ExportRequest(Contract):
source: ExportSource
format: ExportFormat
options: ExportOptions = Field(default_factory=ExportOptions)
class ExportProgress(Contract):
phase: str
current: int
total: int
percent: float | None = None
message: str | None = None
class ExportFile(Contract):
file_name: str
mime_type: str
size: int
sha256: str
expires_at: datetime
class ExportJob(Contract):
job_id: str
status: ExportStatus
format: ExportFormat
progress: ExportProgress | None = None
file: ExportFile | None = None
warnings: list[str] = Field(default_factory=list)
error: str | None = None
error_code: str | None = None
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
class ExportJobListResponse(Contract):
items: list[ExportJob] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)