fix(backend): 修复全面审阅发现的核心问题
修复索引首次失败回滚、Vault 扫描边界、Markdown 代码围栏和 UTF-16 Citation 偏移。 收紧 Plugin 权限与 JSON Schema 校验,补齐 Note Move、Task、Attachment 和 Transcript Tool。 接入 OpenAI SSE 与 Ollama JSONL 真流式输出,修正 Provider PATCH 语义并限制运行时内存保留。 新增对应回归测试,后端测试增至 62 项。
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
from app.agent.permissions import PermissionManager, PermissionMode, PermissionPolicy
|
from app.agent.permissions import PermissionManager, PermissionMode, PermissionPolicy
|
||||||
from app.agent.runtime import AgentRuntime, AgentRunNotFoundError
|
from app.agent.runtime import AgentCapacityError, AgentRuntime, AgentRunNotFoundError
|
||||||
from app.agent.tools import ToolRegistry
|
from app.agent.tools import ToolRegistry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentRunNotFoundError",
|
"AgentRunNotFoundError",
|
||||||
|
"AgentCapacityError",
|
||||||
"AgentRuntime",
|
"AgentRuntime",
|
||||||
"PermissionManager",
|
"PermissionManager",
|
||||||
"PermissionMode",
|
"PermissionMode",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||||
from app.contracts import SearchMode, SearchRequest, ToolDefinition
|
from app.contracts import SearchMode, SearchRequest, TaskStatus, ToolDefinition
|
||||||
from app.retrieval.engine import engine
|
from app.retrieval.engine import engine
|
||||||
from app.services import note_service
|
from app.services import note_service
|
||||||
|
from app.services import attachment_service, task_service, transcription_service
|
||||||
|
|
||||||
|
|
||||||
class ToolArguments(BaseModel):
|
class ToolArguments(BaseModel):
|
||||||
@@ -54,6 +57,42 @@ class NoteListArguments(ToolArguments):
|
|||||||
tag: str | None = None
|
tag: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NoteMoveArguments(ToolArguments):
|
||||||
|
note_id: str = Field(min_length=1)
|
||||||
|
folder: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreateArguments(ToolArguments):
|
||||||
|
title: str = Field(min_length=1)
|
||||||
|
description: str = ""
|
||||||
|
note_id: str | None = None
|
||||||
|
due_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskUpdateArguments(ToolArguments):
|
||||||
|
task_id: str = Field(min_length=1)
|
||||||
|
title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
status: TaskStatus | None = None
|
||||||
|
note_id: str | None = None
|
||||||
|
due_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskListArguments(ToolArguments):
|
||||||
|
limit: int = Field(default=50, ge=1, le=100)
|
||||||
|
offset: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentReadArguments(ToolArguments):
|
||||||
|
attachment_id: str = Field(min_length=1)
|
||||||
|
max_chars: int = Field(default=100_000, ge=1, le=1_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioTranscribeArguments(ToolArguments):
|
||||||
|
attachment_id: str = Field(min_length=1)
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def echo(arguments: EchoArguments, _: ToolExecutionContext) -> dict[str, str]:
|
async def echo(arguments: EchoArguments, _: ToolExecutionContext) -> dict[str, str]:
|
||||||
return {"text": arguments.text}
|
return {"text": arguments.text}
|
||||||
|
|
||||||
@@ -94,6 +133,39 @@ def list_notes(arguments: NoteListArguments, _: ToolExecutionContext) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def move_note(arguments: NoteMoveArguments, _: ToolExecutionContext) -> dict:
|
||||||
|
note = await note_service.move_note(arguments.note_id, folder=arguments.folder)
|
||||||
|
return note.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(arguments: TaskCreateArguments, _: ToolExecutionContext) -> dict:
|
||||||
|
return task_service.create_task(**arguments.model_dump()).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(arguments: TaskUpdateArguments, _: ToolExecutionContext) -> dict:
|
||||||
|
values = arguments.model_dump(exclude_unset=True)
|
||||||
|
task_id = values.pop("task_id")
|
||||||
|
return task_service.update_task(task_id, values).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks(arguments: TaskListArguments, _: ToolExecutionContext) -> dict:
|
||||||
|
items, total = task_service.list_tasks(**arguments.model_dump())
|
||||||
|
return {
|
||||||
|
"items": [item.model_dump(mode="json") for item in items],
|
||||||
|
"page": {"total": total, "limit": arguments.limit, "offset": arguments.offset},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_attachment(arguments: AttachmentReadArguments, _: ToolExecutionContext) -> dict:
|
||||||
|
return attachment_service.read_attachment(**arguments.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_audio(arguments: AudioTranscribeArguments, _: ToolExecutionContext) -> dict:
|
||||||
|
return transcription_service.create_transcription(
|
||||||
|
arguments.attachment_id, arguments.language
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
def _register(
|
def _register(
|
||||||
registry: ToolRegistry,
|
registry: ToolRegistry,
|
||||||
*,
|
*,
|
||||||
@@ -178,3 +250,51 @@ def register_builtin_tools(registry: ToolRegistry) -> None:
|
|||||||
executor=list_notes,
|
executor=list_notes,
|
||||||
permission="notes.read",
|
permission="notes.read",
|
||||||
)
|
)
|
||||||
|
_register(
|
||||||
|
registry,
|
||||||
|
name="notes.move",
|
||||||
|
description="Move a note to another folder while preserving note_id.",
|
||||||
|
arguments_model=NoteMoveArguments,
|
||||||
|
executor=move_note,
|
||||||
|
permission="notes.write",
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
registry,
|
||||||
|
name="tasks.create",
|
||||||
|
description="Create a persistent task.",
|
||||||
|
arguments_model=TaskCreateArguments,
|
||||||
|
executor=create_task,
|
||||||
|
permission="tasks.write",
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
registry,
|
||||||
|
name="tasks.update",
|
||||||
|
description="Update a persistent task.",
|
||||||
|
arguments_model=TaskUpdateArguments,
|
||||||
|
executor=update_task,
|
||||||
|
permission="tasks.write",
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
registry,
|
||||||
|
name="tasks.list",
|
||||||
|
description="List persistent tasks.",
|
||||||
|
arguments_model=TaskListArguments,
|
||||||
|
executor=list_tasks,
|
||||||
|
permission="tasks.read",
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
registry,
|
||||||
|
name="attachments.read",
|
||||||
|
description="Read a UTF-8 attachment from host-managed attachment storage.",
|
||||||
|
arguments_model=AttachmentReadArguments,
|
||||||
|
executor=read_attachment,
|
||||||
|
permission="attachments.read",
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
registry,
|
||||||
|
name="audio.transcribe",
|
||||||
|
description="Read a host-generated transcript for an audio attachment.",
|
||||||
|
arguments_model=AudioTranscribeArguments,
|
||||||
|
executor=transcribe_audio,
|
||||||
|
permission="attachments.read",
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,13 +10,39 @@ class PermissionMode(str, Enum):
|
|||||||
deny = "deny"
|
deny = "deny"
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
"notes.read",
|
||||||
|
"notes.search",
|
||||||
|
"notes.write",
|
||||||
|
"notes.delete",
|
||||||
|
"tasks.read",
|
||||||
|
"tasks.write",
|
||||||
|
"attachments.read",
|
||||||
|
"network.request",
|
||||||
|
"secrets.use",
|
||||||
|
"ui.command",
|
||||||
|
"ui.settings",
|
||||||
|
"ui.sidebar",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PermissionPolicy:
|
class PermissionPolicy:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._rules: dict[str, PermissionMode] = {
|
self._rules: dict[str, PermissionMode] = {
|
||||||
|
"notes.read": PermissionMode.allow,
|
||||||
|
"notes.search": PermissionMode.allow,
|
||||||
"notes.delete": PermissionMode.confirm,
|
"notes.delete": PermissionMode.confirm,
|
||||||
"notes.write": PermissionMode.confirm,
|
"notes.write": PermissionMode.confirm,
|
||||||
|
"tasks.read": PermissionMode.allow,
|
||||||
|
"tasks.write": PermissionMode.confirm,
|
||||||
|
"attachments.read": PermissionMode.allow,
|
||||||
"network.request": PermissionMode.confirm,
|
"network.request": PermissionMode.confirm,
|
||||||
"secrets.use": PermissionMode.confirm,
|
"secrets.use": PermissionMode.confirm,
|
||||||
|
"ui.command": PermissionMode.allow,
|
||||||
|
"ui.settings": PermissionMode.allow,
|
||||||
|
"ui.sidebar": PermissionMode.allow,
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_rule(self, permission: str, mode: PermissionMode) -> None:
|
def set_rule(self, permission: str, mode: PermissionMode) -> None:
|
||||||
@@ -25,7 +51,7 @@ class PermissionPolicy:
|
|||||||
def mode_for(self, permission: str | None) -> PermissionMode:
|
def mode_for(self, permission: str | None) -> PermissionMode:
|
||||||
if permission is None:
|
if permission is None:
|
||||||
return PermissionMode.allow
|
return PermissionMode.allow
|
||||||
return self._rules.get(permission, PermissionMode.allow)
|
return self._rules.get(permission, PermissionMode.deny)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -34,11 +34,18 @@ class AgentRunNotFoundError(LookupError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCapacityError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
TERMINAL_STATUSES = {
|
TERMINAL_STATUSES = {
|
||||||
AgentRunStatus.completed,
|
AgentRunStatus.completed,
|
||||||
AgentRunStatus.failed,
|
AgentRunStatus.failed,
|
||||||
AgentRunStatus.cancelled,
|
AgentRunStatus.cancelled,
|
||||||
}
|
}
|
||||||
|
MAX_RUN_RECORDS = 200
|
||||||
|
MAX_EVENTS_PER_RUN = 2_000
|
||||||
|
MAX_TOOL_CALLS_PER_TURN = 50
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -67,6 +74,7 @@ class AgentRuntime:
|
|||||||
self._records: dict[str, RunRecord] = {}
|
self._records: dict[str, RunRecord] = {}
|
||||||
|
|
||||||
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
|
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun:
|
||||||
|
self._prune_records()
|
||||||
provider = self.providers.get(request.provider_id)
|
provider = self.providers.get(request.provider_id)
|
||||||
skill_config = None
|
skill_config = None
|
||||||
if request.skill_id:
|
if request.skill_id:
|
||||||
@@ -217,6 +225,13 @@ class AgentRuntime:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if turn.tool_calls:
|
if turn.tool_calls:
|
||||||
|
if len(turn.tool_calls) > MAX_TOOL_CALLS_PER_TURN:
|
||||||
|
self._fail(
|
||||||
|
record,
|
||||||
|
"TOO_MANY_TOOL_CALLS",
|
||||||
|
f"Provider requested more than {MAX_TOOL_CALLS_PER_TURN} tools in one turn.",
|
||||||
|
)
|
||||||
|
return
|
||||||
calls = [
|
calls = [
|
||||||
ToolCall(
|
ToolCall(
|
||||||
tool_call_id=item.tool_call_id,
|
tool_call_id=item.tool_call_id,
|
||||||
@@ -393,6 +408,8 @@ class AgentRuntime:
|
|||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
record.events.append(event)
|
record.events.append(event)
|
||||||
|
if len(record.events) > MAX_EVENTS_PER_RUN:
|
||||||
|
del record.events[: len(record.events) - MAX_EVENTS_PER_RUN]
|
||||||
for queue in record.subscribers:
|
for queue in record.subscribers:
|
||||||
queue.put_nowait(event)
|
queue.put_nowait(event)
|
||||||
|
|
||||||
@@ -429,3 +446,20 @@ class AgentRuntime:
|
|||||||
return self._records[run_id]
|
return self._records[run_id]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise AgentRunNotFoundError(run_id) from exc
|
raise AgentRunNotFoundError(run_id) from exc
|
||||||
|
|
||||||
|
def _prune_records(self) -> None:
|
||||||
|
overflow = len(self._records) - MAX_RUN_RECORDS + 1
|
||||||
|
if overflow <= 0:
|
||||||
|
return
|
||||||
|
terminal = sorted(
|
||||||
|
(
|
||||||
|
record
|
||||||
|
for record in self._records.values()
|
||||||
|
if record.run.status in TERMINAL_STATUSES
|
||||||
|
),
|
||||||
|
key=lambda record: record.run.updated_at,
|
||||||
|
)
|
||||||
|
for record in terminal[:overflow]:
|
||||||
|
self._records.pop(record.run.run_id, None)
|
||||||
|
if len(self._records) >= MAX_RUN_RECORDS:
|
||||||
|
raise AgentCapacityError("Too many active Agent runs.")
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from time import perf_counter
|
|||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
||||||
|
|
||||||
from app.contracts import ToolCall, ToolDefinition, ToolResult
|
from app.contracts import ToolCall, ToolDefinition, ToolResult
|
||||||
|
|
||||||
@@ -78,8 +80,9 @@ class ToolRegistry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
Draft202012Validator(registered.definition.parameters).validate(call.arguments)
|
||||||
arguments = registered.arguments_model.model_validate(call.arguments)
|
arguments = registered.arguments_model.model_validate(call.arguments)
|
||||||
except ValidationError as exc:
|
except (ValidationError, JsonSchemaValidationError) as exc:
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
tool_call_id=call.tool_call_id,
|
tool_call_id=call.tool_call_id,
|
||||||
name=call.name,
|
name=call.name,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Settings:
|
|||||||
data_dir: Path
|
data_dir: Path
|
||||||
db_path: Path
|
db_path: Path
|
||||||
vault_path: Path
|
vault_path: Path
|
||||||
|
attachments_path: Path
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -37,4 +38,7 @@ def get_settings() -> Settings:
|
|||||||
data_dir=data_dir,
|
data_dir=data_dir,
|
||||||
db_path=Path(os.getenv("APP_DB_PATH", str(data_dir / "app.db"))),
|
db_path=Path(os.getenv("APP_DB_PATH", str(data_dir / "app.db"))),
|
||||||
vault_path=Path(os.getenv("APP_VAULT_PATH", str(data_dir / "vault"))),
|
vault_path=Path(os.getenv("APP_VAULT_PATH", str(data_dir / "vault"))),
|
||||||
|
attachments_path=Path(
|
||||||
|
os.getenv("APP_ATTACHMENTS_PATH", str(data_dir / "attachments"))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ class Plugin(Contract):
|
|||||||
manifest: PluginManifest
|
manifest: PluginManifest
|
||||||
status: PluginStatus
|
status: PluginStatus
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
granted_permissions: list[str] = Field(default_factory=list)
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -391,6 +392,10 @@ class PluginListResponse(Contract):
|
|||||||
items: list[Plugin] = Field(default_factory=list)
|
items: list[Plugin] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginPermissionGrantRequest(Contract):
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# Providers
|
# Providers
|
||||||
class ProviderType(str, Enum):
|
class ProviderType(str, Enum):
|
||||||
mock = "mock"
|
mock = "mock"
|
||||||
@@ -506,6 +511,9 @@ class TranscriptionJob(Contract):
|
|||||||
job_id: str
|
job_id: str
|
||||||
attachment_id: str
|
attachment_id: str
|
||||||
status: Literal["queued", "processing", "completed", "failed"]
|
status: Literal["queued", "processing", "completed", "failed"]
|
||||||
|
text: str | None = None
|
||||||
|
error_code: str | None = None
|
||||||
|
error_message: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ MIGRATIONS: list[str] = [
|
|||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
# v2: 第一阶段 Task Core;正文仍归 Note/Vault,任务状态持久化到 SQLite。
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
task_id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'todo',
|
||||||
|
note_id TEXT REFERENCES notes(note_id) ON DELETE SET NULL,
|
||||||
|
due_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_status_due ON tasks(status, due_at);
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class ApiError(Exception):
|
|||||||
message: str,
|
message: str,
|
||||||
details: dict[str, Any] | None = None,
|
details: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.code = code
|
self.code = code
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ from pathlib import Path
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
from jsonschema.exceptions import SchemaError
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
|
||||||
|
|
||||||
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||||
|
from app.agent.permissions import KNOWN_PERMISSIONS
|
||||||
from app.contracts import (
|
from app.contracts import (
|
||||||
ModelCapability,
|
ModelCapability,
|
||||||
Plugin,
|
Plugin,
|
||||||
@@ -73,6 +76,7 @@ class SkillRuntime:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _manifest_error("skill", exc) from exc
|
raise _manifest_error("skill", exc) from exc
|
||||||
_validate_id("skill", manifest.skill_id)
|
_validate_id("skill", manifest.skill_id)
|
||||||
|
_validate_permissions("skill", manifest.permissions)
|
||||||
if manifest.skill_id in self._records:
|
if manifest.skill_id in self._records:
|
||||||
raise ExtensionError(
|
raise ExtensionError(
|
||||||
"SKILL_ALREADY_INSTALLED",
|
"SKILL_ALREADY_INSTALLED",
|
||||||
@@ -257,6 +261,7 @@ class PluginRuntime:
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _manifest_error("plugin", exc) from exc
|
raise _manifest_error("plugin", exc) from exc
|
||||||
_validate_id("plugin", manifest.plugin_id)
|
_validate_id("plugin", manifest.plugin_id)
|
||||||
|
_validate_permissions("plugin", manifest.permissions)
|
||||||
if manifest.plugin_id in self._records:
|
if manifest.plugin_id in self._records:
|
||||||
raise ExtensionError(
|
raise ExtensionError(
|
||||||
"PLUGIN_ALREADY_INSTALLED",
|
"PLUGIN_ALREADY_INSTALLED",
|
||||||
@@ -275,6 +280,7 @@ class PluginRuntime:
|
|||||||
)
|
)
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
_validate_id("tool", spec.name)
|
_validate_id("tool", spec.name)
|
||||||
|
_validate_tool_schema(spec)
|
||||||
if spec.permission and spec.permission not in manifest.permissions:
|
if spec.permission and spec.permission not in manifest.permissions:
|
||||||
raise ExtensionError(
|
raise ExtensionError(
|
||||||
"PLUGIN_PERMISSION_UNDECLARED",
|
"PLUGIN_PERMISSION_UNDECLARED",
|
||||||
@@ -283,7 +289,14 @@ class PluginRuntime:
|
|||||||
)
|
)
|
||||||
|
|
||||||
record = _PluginRecord(
|
record = _PluginRecord(
|
||||||
plugin=Plugin(manifest=manifest, status=PluginStatus.installed),
|
plugin=Plugin(
|
||||||
|
manifest=manifest,
|
||||||
|
status=(
|
||||||
|
PluginStatus.permission_required
|
||||||
|
if manifest.permissions
|
||||||
|
else PluginStatus.installed
|
||||||
|
),
|
||||||
|
),
|
||||||
tools=specs,
|
tools=specs,
|
||||||
package_path=root,
|
package_path=root,
|
||||||
registered_tools=[],
|
registered_tools=[],
|
||||||
@@ -309,6 +322,17 @@ class PluginRuntime:
|
|||||||
status_code=501,
|
status_code=501,
|
||||||
details={"plugin_id": plugin_id, "backend": "mcp"},
|
details={"plugin_id": plugin_id, "backend": "mcp"},
|
||||||
)
|
)
|
||||||
|
missing_grants = sorted(
|
||||||
|
set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions)
|
||||||
|
)
|
||||||
|
if missing_grants:
|
||||||
|
record.plugin.status = PluginStatus.permission_required
|
||||||
|
raise ExtensionError(
|
||||||
|
"PLUGIN_PERMISSION_REQUIRED",
|
||||||
|
"Plugin permissions must be granted before it can be enabled.",
|
||||||
|
status_code=409,
|
||||||
|
details={"plugin_id": plugin_id, "permissions": missing_grants},
|
||||||
|
)
|
||||||
conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)]
|
conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)]
|
||||||
if conflicts:
|
if conflicts:
|
||||||
raise ExtensionError(
|
raise ExtensionError(
|
||||||
@@ -353,6 +377,27 @@ class PluginRuntime:
|
|||||||
record.plugin.error_message = None
|
record.plugin.error_message = None
|
||||||
return record.plugin.model_copy(deep=True)
|
return record.plugin.model_copy(deep=True)
|
||||||
|
|
||||||
|
def set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
|
||||||
|
record = self._record(plugin_id)
|
||||||
|
requested = set(permissions)
|
||||||
|
declared = set(record.plugin.manifest.permissions)
|
||||||
|
undeclared = sorted(requested - declared)
|
||||||
|
if undeclared:
|
||||||
|
raise ExtensionError(
|
||||||
|
"PLUGIN_PERMISSION_UNDECLARED",
|
||||||
|
"Cannot grant permissions that are not declared by the Plugin.",
|
||||||
|
details={"plugin_id": plugin_id, "permissions": undeclared},
|
||||||
|
)
|
||||||
|
record.plugin.granted_permissions = sorted(requested)
|
||||||
|
missing = declared - requested
|
||||||
|
if missing and record.plugin.enabled:
|
||||||
|
self.disable(plugin_id)
|
||||||
|
if missing:
|
||||||
|
record.plugin.status = PluginStatus.permission_required
|
||||||
|
elif not record.plugin.enabled:
|
||||||
|
record.plugin.status = PluginStatus.installed
|
||||||
|
return record.plugin.model_copy(deep=True)
|
||||||
|
|
||||||
def disable(self, plugin_id: str) -> Plugin:
|
def disable(self, plugin_id: str) -> Plugin:
|
||||||
record = self._record(plugin_id)
|
record = self._record(plugin_id)
|
||||||
for name in record.registered_tools:
|
for name in record.registered_tools:
|
||||||
@@ -429,6 +474,16 @@ def _validate_id(kind: str, value: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_permissions(kind: str, permissions: list[str]) -> None:
|
||||||
|
unknown = sorted(set(permissions) - KNOWN_PERMISSIONS)
|
||||||
|
if unknown:
|
||||||
|
raise ExtensionError(
|
||||||
|
"EXTENSION_PERMISSION_INVALID",
|
||||||
|
f"Invalid {kind} permissions: {', '.join(unknown)}",
|
||||||
|
details={"kind": kind, "permissions": unknown},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError:
|
def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError:
|
||||||
return ExtensionError(
|
return ExtensionError(
|
||||||
"EXTENSION_MANIFEST_INVALID",
|
"EXTENSION_MANIFEST_INVALID",
|
||||||
@@ -457,3 +512,23 @@ def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
|
|||||||
fields[name] = (annotation, ... if name in required else None)
|
fields[name] = (annotation, ... if name in required else None)
|
||||||
model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name)
|
model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name)
|
||||||
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
|
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
|
||||||
|
schema = spec.parameters or {"type": "object", "properties": {}}
|
||||||
|
try:
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
except SchemaError as exc:
|
||||||
|
raise ExtensionError(
|
||||||
|
"PLUGIN_TOOL_SCHEMA_INVALID",
|
||||||
|
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(
|
||||||
|
schema.get("properties", {}), dict
|
||||||
|
):
|
||||||
|
raise ExtensionError(
|
||||||
|
"PLUGIN_TOOL_SCHEMA_INVALID",
|
||||||
|
"Tool parameters must be an object schema with object properties.",
|
||||||
|
details={"tool": spec.name},
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.textutils import count_tokens
|
|||||||
|
|
||||||
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$")
|
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$")
|
||||||
_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$")
|
_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$")
|
||||||
|
_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(?:[^`]*)$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -48,9 +49,10 @@ def parse_note(
|
|||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
created_at: datetime,
|
created_at: datetime,
|
||||||
updated_at: datetime,
|
updated_at: datetime,
|
||||||
|
note_id: str | None = None,
|
||||||
) -> ParsedNote:
|
) -> ParsedNote:
|
||||||
"""解析一篇 Markdown,生成 ParsedNote(元数据 + Block 列表)。"""
|
"""解析一篇 Markdown,生成 ParsedNote(元数据 + Block 列表)。"""
|
||||||
note_id = note_id_for_path(file_path)
|
note_id = note_id or note_id_for_path(file_path)
|
||||||
frontmatter = _extract_frontmatter(markdown)
|
frontmatter = _extract_frontmatter(markdown)
|
||||||
|
|
||||||
fallback_title = Path(file_path).stem
|
fallback_title = Path(file_path).stem
|
||||||
@@ -79,13 +81,14 @@ def parse_blocks(markdown: str, note_id: str) -> list[NoteBlock]:
|
|||||||
heading_stack: list[str] = []
|
heading_stack: list[str] = []
|
||||||
body: list[tuple[str, int]] = []
|
body: list[tuple[str, int]] = []
|
||||||
id_counters: dict[str, int] = {}
|
id_counters: dict[str, int] = {}
|
||||||
|
fence_marker: str | None = None
|
||||||
|
|
||||||
def make_block(path: list[str], chunk: list[tuple[str, int]]) -> None:
|
def make_block(path: list[str], chunk: list[tuple[str, int]]) -> None:
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return
|
return
|
||||||
content = "\n".join(line for line, _ in chunk)
|
content = "\n".join(line for line, _ in chunk)
|
||||||
start = chunk[0][1]
|
start = chunk[0][1]
|
||||||
end = chunk[-1][1] + len(chunk[-1][0])
|
end = chunk[-1][1] + _utf16_len(chunk[-1][0])
|
||||||
block_id = _stable_block_id(note_id, path, content, id_counters)
|
block_id = _stable_block_id(note_id, path, content, id_counters)
|
||||||
blocks.append(
|
blocks.append(
|
||||||
NoteBlock(
|
NoteBlock(
|
||||||
@@ -109,6 +112,20 @@ def parse_blocks(markdown: str, note_id: str) -> list[NoteBlock]:
|
|||||||
if offset < content_start:
|
if offset < content_start:
|
||||||
continue # 跳过 frontmatter 区域,但保留 offset 准确性
|
continue # 跳过 frontmatter 区域,但保留 offset 准确性
|
||||||
|
|
||||||
|
fence = _FENCE_RE.match(line)
|
||||||
|
if fence_marker is not None:
|
||||||
|
body.append((line, offset))
|
||||||
|
marker = fence.group(1) if fence else ""
|
||||||
|
if marker.startswith(fence_marker[0]) and len(marker) >= len(fence_marker):
|
||||||
|
fence_marker = None
|
||||||
|
flush_body()
|
||||||
|
continue
|
||||||
|
if fence:
|
||||||
|
flush_body()
|
||||||
|
fence_marker = fence.group(1)
|
||||||
|
body.append((line, offset))
|
||||||
|
continue
|
||||||
|
|
||||||
heading = _HEADING_RE.match(line)
|
heading = _HEADING_RE.match(line)
|
||||||
if heading:
|
if heading:
|
||||||
flush_body()
|
flush_body()
|
||||||
@@ -138,7 +155,7 @@ def _stable_block_id(note_id: str, path: list[str], content: str, counters: dict
|
|||||||
|
|
||||||
|
|
||||||
def _split_lines(text: str) -> list[tuple[str, int]]:
|
def _split_lines(text: str) -> list[tuple[str, int]]:
|
||||||
"""按行拆分并记录每行在原文中的起始字符偏移。"""
|
"""按行拆分并记录 UTF-16 code unit 偏移,直接兼容浏览器编辑器。"""
|
||||||
result: list[tuple[str, int]] = []
|
result: list[tuple[str, int]] = []
|
||||||
start = 0
|
start = 0
|
||||||
for raw in text.splitlines(keepends=True):
|
for raw in text.splitlines(keepends=True):
|
||||||
@@ -148,19 +165,23 @@ def _split_lines(text: str) -> list[tuple[str, int]]:
|
|||||||
elif line.endswith("\n") or line.endswith("\r"):
|
elif line.endswith("\n") or line.endswith("\r"):
|
||||||
line = line[:-1]
|
line = line[:-1]
|
||||||
result.append((line, start))
|
result.append((line, start))
|
||||||
start += len(raw)
|
start += _utf16_len(raw)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _content_start(markdown: str) -> int:
|
def _content_start(markdown: str) -> int:
|
||||||
"""返回正文起始偏移:有 frontmatter 时跳过 --- 分隔块,否则为 0。"""
|
"""返回正文起始 UTF-16 偏移:有 frontmatter 时跳过 --- 分隔块。"""
|
||||||
if markdown.startswith("---"):
|
if markdown.startswith("---"):
|
||||||
end = markdown.find("\n---", 3)
|
end = markdown.find("\n---", 3)
|
||||||
if end != -1:
|
if end != -1:
|
||||||
return end + 4
|
return _utf16_len(markdown[: end + 4])
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _utf16_len(text: str) -> int:
|
||||||
|
return len(text.encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
def _extract_frontmatter(markdown: str) -> dict[str, str]:
|
def _extract_frontmatter(markdown: str) -> dict[str, str]:
|
||||||
"""极简 frontmatter 解析,只提取 key: value 行。"""
|
"""极简 frontmatter 解析,只提取 key: value 行。"""
|
||||||
if not markdown.startswith("---"):
|
if not markdown.startswith("---"):
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.contracts import ModelCapability, ModelInfo, ModelRequest
|
from app.contracts import ModelCapability, ModelEvent, ModelEventType, ModelInfo, ModelRequest
|
||||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||||
|
|
||||||
@@ -86,6 +89,115 @@ class OllamaProvider(TurnStreamingMixin):
|
|||||||
if isinstance(item, dict) and item.get("name")
|
if isinstance(item, dict) and item.get("name")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
||||||
|
payload = self._chat_payload(request, stream=True)
|
||||||
|
sequence = 0
|
||||||
|
|
||||||
|
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
|
||||||
|
nonlocal sequence
|
||||||
|
item = ModelEvent(
|
||||||
|
event=kind, sequence=sequence, data=data or {},
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
sequence += 1
|
||||||
|
return item
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for data in self._stream_json(payload):
|
||||||
|
message = data.get("message") or {}
|
||||||
|
if message.get("thinking"):
|
||||||
|
yield event(ModelEventType.thinking_delta, {"text": message["thinking"]})
|
||||||
|
if message.get("content"):
|
||||||
|
yield event(ModelEventType.text_delta, {"text": message["content"]})
|
||||||
|
for raw_call in message.get("tool_calls") or []:
|
||||||
|
function = raw_call.get("function") or {}
|
||||||
|
call_id = raw_call.get("id") or f"call_{uuid4().hex}"
|
||||||
|
yield event(
|
||||||
|
ModelEventType.tool_call_start,
|
||||||
|
{"tool_call_id": call_id, "name": function.get("name") or ""},
|
||||||
|
)
|
||||||
|
yield event(
|
||||||
|
ModelEventType.tool_call_delta,
|
||||||
|
{
|
||||||
|
"tool_call_id": call_id,
|
||||||
|
"arguments_delta": json.dumps(
|
||||||
|
function.get("arguments") or {}, ensure_ascii=False
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
|
||||||
|
if data.get("done"):
|
||||||
|
yield event(
|
||||||
|
ModelEventType.usage,
|
||||||
|
{
|
||||||
|
"input_tokens": int(data.get("prompt_eval_count") or 0),
|
||||||
|
"output_tokens": int(data.get("eval_count") or 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield event(ModelEventType.done)
|
||||||
|
except ProviderError as exc:
|
||||||
|
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
|
||||||
|
yield event(ModelEventType.done)
|
||||||
|
|
||||||
|
def _chat_payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||||
|
messages = []
|
||||||
|
if request.system:
|
||||||
|
messages.append({"role": "system", "content": request.system})
|
||||||
|
for message in request.messages:
|
||||||
|
item: dict[str, object] = {"role": message.role.value, "content": message.content}
|
||||||
|
if message.tool_calls:
|
||||||
|
item["tool_calls"] = [
|
||||||
|
{"function": {"name": call.name, "arguments": call.arguments}}
|
||||||
|
for call in message.tool_calls
|
||||||
|
]
|
||||||
|
messages.append(item)
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"model": request.model, "messages": messages, "stream": stream
|
||||||
|
}
|
||||||
|
if request.tools:
|
||||||
|
payload["tools"] = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description,
|
||||||
|
"parameters": tool.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for tool in request.tools
|
||||||
|
]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=self.timeout_seconds, transport=self.transport
|
||||||
|
) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST", f"{self.base_url}/api/chat", json=payload
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_INVALID_RESPONSE", "Ollama returned invalid JSONL."
|
||||||
|
) from exc
|
||||||
|
if isinstance(data, dict):
|
||||||
|
yield data
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderError("PROVIDER_TIMEOUT", "Ollama request timed out.") from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise ProviderError(
|
||||||
|
"MODEL_NOT_FOUND" if exc.response.status_code == 404 else "PROVIDER_UNAVAILABLE",
|
||||||
|
f"Ollama returned HTTP {exc.response.status_code}.",
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Ollama is unavailable.") from exc
|
||||||
|
|
||||||
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
async def test_connection(self, model: str | None = None) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
models = await self.list_models()
|
models = await self.list_models()
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import json
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import datetime, timezone
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.contracts import MessageRole, ModelCapability, ModelInfo, ModelRequest
|
from app.contracts import (
|
||||||
|
MessageRole,
|
||||||
|
ModelCapability,
|
||||||
|
ModelEvent,
|
||||||
|
ModelEventType,
|
||||||
|
ModelInfo,
|
||||||
|
ModelRequest,
|
||||||
|
)
|
||||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||||
from app.providers.credentials import CredentialResolver
|
from app.providers.credentials import CredentialResolver
|
||||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||||
@@ -25,29 +34,7 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
self.transport = transport
|
self.transport = transport
|
||||||
|
|
||||||
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
async def complete(self, request: ModelRequest) -> ProviderTurn:
|
||||||
payload: dict[str, object] = {
|
payload = self._payload(request, stream=False)
|
||||||
"model": request.model,
|
|
||||||
"messages": self._messages(request),
|
|
||||||
"stream": False,
|
|
||||||
}
|
|
||||||
if request.tools:
|
|
||||||
payload["tools"] = [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tool.name,
|
|
||||||
"description": tool.description,
|
|
||||||
"parameters": tool.parameters,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for tool in request.tools
|
|
||||||
]
|
|
||||||
if request.temperature is not None:
|
|
||||||
payload["temperature"] = request.temperature
|
|
||||||
if request.max_tokens is not None:
|
|
||||||
payload["max_tokens"] = request.max_tokens
|
|
||||||
if request.response_format is not None:
|
|
||||||
payload["response_format"] = request.response_format
|
|
||||||
|
|
||||||
data = await self._request("POST", "/chat/completions", json=payload)
|
data = await self._request("POST", "/chat/completions", json=payload)
|
||||||
try:
|
try:
|
||||||
@@ -73,6 +60,133 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
output_tokens=int(usage.get("completion_tokens") or 0),
|
output_tokens=int(usage.get("completion_tokens") or 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _payload(self, request: ModelRequest, *, stream: bool) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"model": request.model,
|
||||||
|
"messages": self._messages(request),
|
||||||
|
"stream": stream,
|
||||||
|
}
|
||||||
|
if request.tools:
|
||||||
|
payload["tools"] = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description,
|
||||||
|
"parameters": tool.parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for tool in request.tools
|
||||||
|
]
|
||||||
|
if request.temperature is not None:
|
||||||
|
payload["temperature"] = request.temperature
|
||||||
|
if request.max_tokens is not None:
|
||||||
|
payload["max_tokens"] = request.max_tokens
|
||||||
|
if request.response_format is not None:
|
||||||
|
payload["response_format"] = request.response_format
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
|
||||||
|
sequence = 0
|
||||||
|
open_calls: dict[int, str] = {}
|
||||||
|
|
||||||
|
def event(kind: ModelEventType, data: dict | None = None) -> ModelEvent:
|
||||||
|
nonlocal sequence
|
||||||
|
item = ModelEvent(
|
||||||
|
event=kind,
|
||||||
|
sequence=sequence,
|
||||||
|
data=data or {},
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
sequence += 1
|
||||||
|
return item
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for data in self._stream_json(self._payload(request, stream=True)):
|
||||||
|
usage = data.get("usage") or {}
|
||||||
|
if usage:
|
||||||
|
yield event(
|
||||||
|
ModelEventType.usage,
|
||||||
|
{
|
||||||
|
"input_tokens": int(usage.get("prompt_tokens") or 0),
|
||||||
|
"output_tokens": int(usage.get("completion_tokens") or 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
choices = data.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
continue
|
||||||
|
choice = choices[0]
|
||||||
|
delta = choice.get("delta") or {}
|
||||||
|
if delta.get("reasoning_content"):
|
||||||
|
yield event(
|
||||||
|
ModelEventType.thinking_delta,
|
||||||
|
{"text": delta["reasoning_content"]},
|
||||||
|
)
|
||||||
|
if delta.get("content"):
|
||||||
|
yield event(ModelEventType.text_delta, {"text": delta["content"]})
|
||||||
|
for raw_call in delta.get("tool_calls") or []:
|
||||||
|
index = int(raw_call.get("index") or 0)
|
||||||
|
function = raw_call.get("function") or {}
|
||||||
|
call_id = raw_call.get("id") or open_calls.get(index) or f"call_{uuid4().hex}"
|
||||||
|
if index not in open_calls:
|
||||||
|
open_calls[index] = call_id
|
||||||
|
yield event(
|
||||||
|
ModelEventType.tool_call_start,
|
||||||
|
{"tool_call_id": call_id, "name": function.get("name") or ""},
|
||||||
|
)
|
||||||
|
if function.get("arguments"):
|
||||||
|
yield event(
|
||||||
|
ModelEventType.tool_call_delta,
|
||||||
|
{
|
||||||
|
"tool_call_id": open_calls[index],
|
||||||
|
"arguments_delta": function["arguments"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if choice.get("finish_reason") == "tool_calls":
|
||||||
|
for call_id in open_calls.values():
|
||||||
|
yield event(
|
||||||
|
ModelEventType.tool_call_end, {"tool_call_id": call_id}
|
||||||
|
)
|
||||||
|
open_calls.clear()
|
||||||
|
for call_id in open_calls.values():
|
||||||
|
yield event(ModelEventType.tool_call_end, {"tool_call_id": call_id})
|
||||||
|
yield event(ModelEventType.done)
|
||||||
|
except ProviderError as exc:
|
||||||
|
yield event(ModelEventType.error, {"code": exc.code, "message": exc.message})
|
||||||
|
yield event(ModelEventType.done)
|
||||||
|
|
||||||
|
async def _stream_json(self, payload: dict[str, object]) -> AsyncIterator[dict]:
|
||||||
|
headers = self._headers()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=self.timeout_seconds, transport=self.transport
|
||||||
|
) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
value = line[5:].strip()
|
||||||
|
if not value or value == "[DONE]":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ProviderError(
|
||||||
|
"PROVIDER_INVALID_RESPONSE", "Provider returned invalid SSE JSON."
|
||||||
|
) from exc
|
||||||
|
if isinstance(data, dict):
|
||||||
|
yield data
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise self._status_error(exc) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
||||||
|
|
||||||
async def list_models(self) -> list[ModelInfo]:
|
async def list_models(self) -> list[ModelInfo]:
|
||||||
data = await self._request("GET", "/models")
|
data = await self._request("GET", "/models")
|
||||||
return [
|
return [
|
||||||
@@ -127,10 +241,7 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
async def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = self._headers()
|
||||||
api_key = self.credentials.resolve(self.credential_id)
|
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=self.timeout_seconds, transport=self.transport
|
timeout=self.timeout_seconds, transport=self.transport
|
||||||
@@ -143,14 +254,25 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
|||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
raise ProviderError("PROVIDER_TIMEOUT", "Provider request timed out.") from exc
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
code = {
|
raise self._status_error(exc) from exc
|
||||||
401: "PROVIDER_AUTH_FAILED",
|
|
||||||
404: "MODEL_NOT_FOUND",
|
|
||||||
429: "PROVIDER_RATE_LIMITED",
|
|
||||||
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
|
||||||
raise ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.") from exc
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
raise ProviderError("PROVIDER_UNAVAILABLE", "Provider is unavailable.") from exc
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Provider returned non-object JSON.")
|
raise ProviderError("PROVIDER_INVALID_RESPONSE", "Provider returned non-object JSON.")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
api_key = self.credentials.resolve(self.credential_id)
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _status_error(exc: httpx.HTTPStatusError) -> ProviderError:
|
||||||
|
code = {
|
||||||
|
401: "PROVIDER_AUTH_FAILED",
|
||||||
|
404: "MODEL_NOT_FOUND",
|
||||||
|
429: "PROVIDER_RATE_LIMITED",
|
||||||
|
}.get(exc.response.status_code, "PROVIDER_UNAVAILABLE")
|
||||||
|
return ProviderError(code, f"Provider returned HTTP {exc.response.status_code}.")
|
||||||
|
|||||||
+64
-40
@@ -10,7 +10,6 @@ from app.contracts import (
|
|||||||
AgentRunCreateRequest,
|
AgentRunCreateRequest,
|
||||||
AgentRunListResponse,
|
AgentRunListResponse,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
ErrorResponse,
|
|
||||||
ExtensionInstallRequest,
|
ExtensionInstallRequest,
|
||||||
IndexJob,
|
IndexJob,
|
||||||
IndexRebuildRequest,
|
IndexRebuildRequest,
|
||||||
@@ -27,6 +26,7 @@ from app.contracts import (
|
|||||||
PermissionDecisionRequest,
|
PermissionDecisionRequest,
|
||||||
Plugin,
|
Plugin,
|
||||||
PluginListResponse,
|
PluginListResponse,
|
||||||
|
PluginPermissionGrantRequest,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ProviderCreateRequest,
|
ProviderCreateRequest,
|
||||||
ProviderListResponse,
|
ProviderListResponse,
|
||||||
@@ -46,17 +46,16 @@ from app.contracts import (
|
|||||||
TranscriptionJob,
|
TranscriptionJob,
|
||||||
TranscriptionRequest,
|
TranscriptionRequest,
|
||||||
)
|
)
|
||||||
from app.agent import AgentRunNotFoundError
|
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||||
from app.container import container
|
from app.container import container
|
||||||
from app.errors import ApiError, not_implemented
|
from app.errors import ApiError
|
||||||
from app.extensions import ExtensionError
|
from app.extensions import ExtensionError
|
||||||
from app.providers.registry import ProviderNotFoundError
|
from app.providers.registry import ProviderNotFoundError
|
||||||
from app.providers.factory import UnsupportedProviderError
|
from app.providers.factory import UnsupportedProviderError
|
||||||
from app.retrieval.engine import engine
|
from app.retrieval.engine import engine
|
||||||
from app.services import index_service, note_service
|
from app.services import index_service, note_service, task_service, transcription_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
not_implemented_response = {501: {"model": ErrorResponse, "description": "业务服务尚未实现"}}
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
def utc_now() -> datetime:
|
||||||
@@ -151,11 +150,9 @@ async def delete_note(note_id: str) -> OperationResponse:
|
|||||||
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
|
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post("/notes/{note_id}/move", response_model=Note, tags=["Notes"])
|
||||||
"/notes/{note_id}/move", response_model=Note, responses=not_implemented_response, tags=["Notes"]
|
async def move_note(note_id: str, request: NoteMoveRequest) -> Note:
|
||||||
)
|
return await note_service.move_note(note_id, folder=request.folder)
|
||||||
async def move_note(note_id: str, _: NoteMoveRequest) -> Note:
|
|
||||||
not_implemented(f"notes.move:{note_id}")
|
|
||||||
|
|
||||||
|
|
||||||
# Retrieval and chat
|
# Retrieval and chat
|
||||||
@@ -219,6 +216,8 @@ async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun:
|
|||||||
return await container.agent.create_run(request)
|
return await container.agent.create_run(request)
|
||||||
except ExtensionError as exc:
|
except ExtensionError as exc:
|
||||||
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
|
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
|
||||||
|
except AgentCapacityError as exc:
|
||||||
|
raise ApiError(429, "AGENT_CAPACITY_EXCEEDED", str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -386,6 +385,19 @@ async def disable_plugin(plugin_id: str) -> Plugin:
|
|||||||
return extension_call(lambda: container.plugins.disable(plugin_id))
|
return extension_call(lambda: container.plugins.disable(plugin_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/plugins/{plugin_id}/permissions",
|
||||||
|
response_model=Plugin,
|
||||||
|
tags=["Plugins"],
|
||||||
|
)
|
||||||
|
async def set_plugin_permissions(
|
||||||
|
plugin_id: str, request: PluginPermissionGrantRequest
|
||||||
|
) -> Plugin:
|
||||||
|
return extension_call(
|
||||||
|
lambda: container.plugins.set_permissions(plugin_id, request.permissions)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/plugins/{plugin_id}",
|
"/plugins/{plugin_id}",
|
||||||
response_model=OperationResponse,
|
response_model=OperationResponse,
|
||||||
@@ -407,7 +419,6 @@ async def list_providers() -> ProviderListResponse:
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/providers/{provider_id}",
|
"/providers/{provider_id}",
|
||||||
response_model=ProviderConfig,
|
response_model=ProviderConfig,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Providers"],
|
tags=["Providers"],
|
||||||
)
|
)
|
||||||
async def get_provider(provider_id: str) -> ProviderConfig:
|
async def get_provider(provider_id: str) -> ProviderConfig:
|
||||||
@@ -417,7 +428,6 @@ async def get_provider(provider_id: str) -> ProviderConfig:
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/providers",
|
"/providers",
|
||||||
response_model=ProviderConfig,
|
response_model=ProviderConfig,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Providers"],
|
tags=["Providers"],
|
||||||
)
|
)
|
||||||
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
|
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
|
||||||
@@ -446,7 +456,6 @@ async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
|
|||||||
@router.patch(
|
@router.patch(
|
||||||
"/providers/{provider_id}",
|
"/providers/{provider_id}",
|
||||||
response_model=ProviderConfig,
|
response_model=ProviderConfig,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Providers"],
|
tags=["Providers"],
|
||||||
)
|
)
|
||||||
async def update_provider(
|
async def update_provider(
|
||||||
@@ -455,7 +464,19 @@ async def update_provider(
|
|||||||
current = configurable_provider_or_404(provider_id).config
|
current = configurable_provider_or_404(provider_id).config
|
||||||
if provider_id == "mock":
|
if provider_id == "mock":
|
||||||
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified.")
|
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified.")
|
||||||
config = current.model_copy(update=request.model_dump(exclude_none=True))
|
fields = request.model_fields_set
|
||||||
|
if ("name" in fields and request.name is None) or (
|
||||||
|
"enabled" in fields and request.enabled is None
|
||||||
|
):
|
||||||
|
raise ApiError(
|
||||||
|
422,
|
||||||
|
"VALIDATION_ERROR",
|
||||||
|
"name and enabled cannot be null when explicitly provided.",
|
||||||
|
)
|
||||||
|
updates = {name: getattr(request, name) for name in fields}
|
||||||
|
config = ProviderConfig.model_validate(
|
||||||
|
{**current.model_dump(mode="python"), **updates}
|
||||||
|
)
|
||||||
adapter = container.provider_factory.build(config)
|
adapter = container.provider_factory.build(config)
|
||||||
container.providers.replace(config, adapter)
|
container.providers.replace(config, adapter)
|
||||||
return config
|
return config
|
||||||
@@ -464,7 +485,6 @@ async def update_provider(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/providers/{provider_id}",
|
"/providers/{provider_id}",
|
||||||
response_model=OperationResponse,
|
response_model=OperationResponse,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Providers"],
|
tags=["Providers"],
|
||||||
)
|
)
|
||||||
async def delete_provider(provider_id: str) -> OperationResponse:
|
async def delete_provider(provider_id: str) -> OperationResponse:
|
||||||
@@ -478,7 +498,6 @@ async def delete_provider(provider_id: str) -> OperationResponse:
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/providers/{provider_id}/models",
|
"/providers/{provider_id}/models",
|
||||||
response_model=ProviderModelsResponse,
|
response_model=ProviderModelsResponse,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Providers"],
|
tags=["Providers"],
|
||||||
)
|
)
|
||||||
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
||||||
@@ -492,7 +511,6 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/providers/test",
|
"/providers/test",
|
||||||
response_model=ProviderTestResponse,
|
response_model=ProviderTestResponse,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Providers"],
|
tags=["Providers"],
|
||||||
)
|
)
|
||||||
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
|
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
|
||||||
@@ -517,38 +535,39 @@ async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
|
|||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
|
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
|
||||||
) -> TaskListResponse:
|
) -> TaskListResponse:
|
||||||
return TaskListResponse(page=PageMeta(limit=limit, offset=offset))
|
items, total = task_service.list_tasks(limit=limit, offset=offset)
|
||||||
|
return TaskListResponse(
|
||||||
|
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||||
@router.post(
|
|
||||||
"/tasks", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
|
|
||||||
)
|
)
|
||||||
async def create_task(_: TaskCreateRequest) -> Task:
|
|
||||||
not_implemented("tasks.create")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.post("/tasks", response_model=Task, tags=["Tasks"])
|
||||||
"/tasks/{task_id}", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
|
async def create_task(request: TaskCreateRequest) -> Task:
|
||||||
)
|
return task_service.create_task(**request.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
|
||||||
async def get_task(task_id: str) -> Task:
|
async def get_task(task_id: str) -> Task:
|
||||||
not_implemented(f"tasks.read:{task_id}")
|
task = task_service.get_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
|
||||||
"/tasks/{task_id}", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
|
async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
|
||||||
)
|
return task_service.update_task(task_id, request.model_dump(exclude_unset=True))
|
||||||
async def update_task(task_id: str, _: TaskUpdateRequest) -> Task:
|
|
||||||
not_implemented(f"tasks.update:{task_id}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/tasks/{task_id}",
|
"/tasks/{task_id}",
|
||||||
response_model=OperationResponse,
|
response_model=OperationResponse,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Tasks"],
|
tags=["Tasks"],
|
||||||
)
|
)
|
||||||
async def delete_task(task_id: str) -> OperationResponse:
|
async def delete_task(task_id: str) -> OperationResponse:
|
||||||
not_implemented(f"tasks.delete:{task_id}")
|
if not task_service.delete_task(task_id):
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
||||||
|
return OperationResponse(status="completed", resource_id=task_id, message="deleted")
|
||||||
|
|
||||||
|
|
||||||
# Media and index
|
# Media and index
|
||||||
@@ -556,21 +575,26 @@ async def delete_task(task_id: str) -> OperationResponse:
|
|||||||
"/media/transcriptions",
|
"/media/transcriptions",
|
||||||
response_model=TranscriptionJob,
|
response_model=TranscriptionJob,
|
||||||
status_code=202,
|
status_code=202,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Media"],
|
tags=["Media"],
|
||||||
)
|
)
|
||||||
async def create_transcription(_: TranscriptionRequest) -> TranscriptionJob:
|
async def create_transcription(request: TranscriptionRequest) -> TranscriptionJob:
|
||||||
not_implemented("media.transcriptions.create")
|
return transcription_service.create_transcription(
|
||||||
|
request.attachment_id, request.language
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/media/transcriptions/{job_id}",
|
"/media/transcriptions/{job_id}",
|
||||||
response_model=TranscriptionJob,
|
response_model=TranscriptionJob,
|
||||||
responses=not_implemented_response,
|
|
||||||
tags=["Media"],
|
tags=["Media"],
|
||||||
)
|
)
|
||||||
async def get_transcription(job_id: str) -> TranscriptionJob:
|
async def get_transcription(job_id: str) -> TranscriptionJob:
|
||||||
not_implemented(f"media.transcriptions.read:{job_id}")
|
job = transcription_service.get_transcription(job_id)
|
||||||
|
if job is None:
|
||||||
|
raise ApiError(
|
||||||
|
404, "RESOURCE_NOT_FOUND", "transcription job not found", {"job_id": job_id}
|
||||||
|
)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
@router.get("/index/status", response_model=IndexStatus, tags=["Index"])
|
@router.get("/index/status", response_model=IndexStatus, tags=["Index"])
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.errors import ApiError
|
||||||
|
|
||||||
|
_ATTACHMENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$")
|
||||||
|
MAX_ATTACHMENT_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_path(attachment_id: str) -> Path:
|
||||||
|
if not _ATTACHMENT_ID.fullmatch(attachment_id):
|
||||||
|
raise ApiError(
|
||||||
|
400, "INVALID_ATTACHMENT_ID", "attachment_id is invalid",
|
||||||
|
{"attachment_id": attachment_id},
|
||||||
|
)
|
||||||
|
root = get_settings().attachments_path.resolve()
|
||||||
|
candidate = (root / attachment_id).resolve()
|
||||||
|
if not candidate.is_relative_to(root):
|
||||||
|
raise ApiError(400, "INVALID_ATTACHMENT_ID", "attachment path escapes storage")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def read_attachment(attachment_id: str, *, max_chars: int = 100_000) -> dict[str, object]:
|
||||||
|
path = attachment_path(attachment_id)
|
||||||
|
if not path.is_file():
|
||||||
|
raise ApiError(
|
||||||
|
404, "ATTACHMENT_NOT_FOUND", "attachment not found",
|
||||||
|
{"attachment_id": attachment_id},
|
||||||
|
)
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size > MAX_ATTACHMENT_BYTES:
|
||||||
|
raise ApiError(
|
||||||
|
413, "ATTACHMENT_TOO_LARGE", "attachment exceeds the Tool read limit",
|
||||||
|
{"attachment_id": attachment_id, "size": size},
|
||||||
|
)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
truncated = len(text) > max_chars
|
||||||
|
return {
|
||||||
|
"attachment_id": attachment_id,
|
||||||
|
"content": text[:max_chars],
|
||||||
|
"size": size,
|
||||||
|
"truncated": truncated,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import asyncio
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
_vault_mutation_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def serialized_vault_mutation(operation):
|
||||||
|
"""串行化 Vault 文件与可重建索引的写入,避免 rebuild 与 Note 写操作交错。"""
|
||||||
|
|
||||||
|
@wraps(operation)
|
||||||
|
async def wrapped(*args, **kwargs):
|
||||||
|
async with _vault_mutation_lock:
|
||||||
|
return await operation(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
@@ -17,11 +17,24 @@ from app.contracts import IndexJob, IndexRebuildRequest, IndexStatus
|
|||||||
from app.errors import ApiError
|
from app.errors import ApiError
|
||||||
from app.knowledge.parser import parse_note
|
from app.knowledge.parser import parse_note
|
||||||
from app.services.note_service import index_note
|
from app.services.note_service import index_note
|
||||||
|
from app.services import task_service
|
||||||
|
from app.services.coordination import serialized_vault_mutation
|
||||||
from app.retrieval.vectorstore import SqliteVecStore
|
from app.retrieval.vectorstore import SqliteVecStore
|
||||||
|
|
||||||
vector_store = SqliteVecStore()
|
vector_store = SqliteVecStore()
|
||||||
|
|
||||||
_jobs: dict[str, IndexJob] = {}
|
_jobs: dict[str, IndexJob] = {}
|
||||||
|
_active_job_id: str | None = None
|
||||||
|
_last_completed_at: datetime | None = None
|
||||||
|
_last_error: str | None = None
|
||||||
|
MAX_JOBS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_job(job: IndexJob) -> None:
|
||||||
|
_jobs[job.job_id] = job
|
||||||
|
while len(_jobs) > MAX_JOBS:
|
||||||
|
oldest = next(iter(_jobs))
|
||||||
|
_jobs.pop(oldest, None)
|
||||||
|
|
||||||
|
|
||||||
def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
|
def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
|
||||||
@@ -29,23 +42,28 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
|
|||||||
|
|
||||||
先读入内存:若文件读取失败,rebuild 尚未清空旧索引,不会造成数据损失。
|
先读入内存:若文件读取失败,rebuild 尚未清空旧索引,不会造成数据损失。
|
||||||
"""
|
"""
|
||||||
vault = get_settings().vault_path
|
vault = get_settings().vault_path.resolve()
|
||||||
result: list[tuple[str, str, str, datetime, datetime]] = []
|
result: list[tuple[str, str, str, datetime, datetime]] = []
|
||||||
if not vault.exists():
|
if not vault.exists():
|
||||||
return result
|
return result
|
||||||
for path in sorted(vault.rglob("*.md")):
|
for path in sorted(vault.rglob("*.md")):
|
||||||
|
resolved = path.resolve()
|
||||||
|
if not resolved.is_relative_to(vault):
|
||||||
|
continue
|
||||||
rel = path.relative_to(vault).as_posix()
|
rel = path.relative_to(vault).as_posix()
|
||||||
folder = path.relative_to(vault).parent.as_posix()
|
folder = path.relative_to(vault).parent.as_posix()
|
||||||
if folder == ".":
|
if folder == ".":
|
||||||
folder = ""
|
folder = ""
|
||||||
stat = path.stat()
|
stat = resolved.stat()
|
||||||
created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
|
created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
|
||||||
updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
||||||
result.append((rel, folder, path.read_text(encoding="utf-8"), created, updated))
|
result.append((rel, folder, resolved.read_text(encoding="utf-8"), created, updated))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||||
|
global _active_job_id, _last_completed_at, _last_error
|
||||||
job_id = "job_" + uuid4().hex[:12]
|
job_id = "job_" + uuid4().hex[:12]
|
||||||
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
|
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
|
||||||
if request.scope != "all" or request.note_ids:
|
if request.scope != "all" or request.note_ids:
|
||||||
@@ -59,10 +77,22 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
# 先扫描到内存(失败不会清旧索引),再快照旧库用于失败回滚
|
# 先扫描到内存(失败不会清旧索引),再快照旧库用于失败回滚
|
||||||
docs = _scan_vault()
|
docs = _scan_vault()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
backup_path = settings.db_path.with_suffix(".db.bak") if settings.db_path.exists() else None
|
database_existed = settings.db_path.exists()
|
||||||
|
task_note_links = task_service.note_links() if database_existed else {}
|
||||||
|
backup_path = (
|
||||||
|
settings.db_path.with_name(f"{settings.db_path.name}.{job_id}.bak")
|
||||||
|
if database_existed
|
||||||
|
else None
|
||||||
|
)
|
||||||
if backup_path is not None:
|
if backup_path is not None:
|
||||||
shutil.copy2(settings.db_path, backup_path)
|
shutil.copy2(settings.db_path, backup_path)
|
||||||
|
|
||||||
|
_active_job_id = job_id
|
||||||
|
_last_error = None
|
||||||
|
_remember_job(IndexJob(
|
||||||
|
job_id=job_id, status="running", scope=request.scope,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
))
|
||||||
try:
|
try:
|
||||||
repository.clear_all()
|
repository.clear_all()
|
||||||
await vector_store.clear()
|
await vector_store.clear()
|
||||||
@@ -72,27 +102,39 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
created_at=created, updated_at=updated,
|
created_at=created, updated_at=updated,
|
||||||
)
|
)
|
||||||
await index_note(parsed)
|
await index_note(parsed)
|
||||||
except BaseException:
|
task_service.restore_note_links(task_note_links)
|
||||||
|
except BaseException as exc:
|
||||||
# 重建失败:恢复旧索引,避免留下半成品;记录 failed 任务后向上抛
|
# 重建失败:恢复旧索引,避免留下半成品;记录 failed 任务后向上抛
|
||||||
if backup_path is not None and backup_path.exists():
|
if backup_path is not None and backup_path.exists():
|
||||||
shutil.copy2(backup_path, settings.db_path)
|
shutil.copy2(backup_path, settings.db_path)
|
||||||
_jobs[job_id] = IndexJob(
|
elif not database_existed:
|
||||||
|
settings.db_path.unlink(missing_ok=True)
|
||||||
|
_remember_job(IndexJob(
|
||||||
job_id=job_id, status="failed", scope=request.scope,
|
job_id=job_id, status="failed", scope=request.scope,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
)
|
))
|
||||||
|
_last_error = str(exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
_active_job_id = None
|
||||||
if backup_path is not None:
|
if backup_path is not None:
|
||||||
backup_path.unlink(missing_ok=True)
|
backup_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
|
job = IndexJob(job_id=job_id, status="completed", scope=request.scope, created_at=datetime.now(timezone.utc))
|
||||||
_jobs[job_id] = job
|
_remember_job(job)
|
||||||
|
_last_completed_at = job.created_at
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def get_status() -> IndexStatus:
|
def get_status() -> IndexStatus:
|
||||||
# 同步重建、无排队任务,因此状态恒为 idle;实际索引规模可由 GET /api/notes 与搜索反映
|
if _active_job_id is not None:
|
||||||
return IndexStatus(status="idle", pending_jobs=0)
|
return IndexStatus(status="running", pending_jobs=0, active_job_id=_active_job_id)
|
||||||
|
return IndexStatus(
|
||||||
|
status="failed" if _last_error else "idle",
|
||||||
|
pending_jobs=0,
|
||||||
|
last_completed_at=_last_completed_at,
|
||||||
|
error_message=_last_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_job(job_id: str) -> IndexJob | None:
|
def get_job(job_id: str) -> IndexJob | None:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.errors import ApiError
|
|||||||
from app.knowledge.parser import ParsedNote, parse_note
|
from app.knowledge.parser import ParsedNote, parse_note
|
||||||
from app.retrieval.embedding import HashEmbeddingProvider
|
from app.retrieval.embedding import HashEmbeddingProvider
|
||||||
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
|
from app.retrieval.vectorstore import SqliteVecStore, VectorRecord
|
||||||
|
from app.services.coordination import serialized_vault_mutation
|
||||||
|
|
||||||
# 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider
|
# 轻量实现实例(无状态,可直接复用);接入真实模型后替换为对应 Provider
|
||||||
embedding = HashEmbeddingProvider()
|
embedding = HashEmbeddingProvider()
|
||||||
@@ -148,6 +149,7 @@ async def index_note(parsed: ParsedNote) -> None:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
|
async def create_note(*, title: str, markdown: str, folder: str | None, tags: list[str]) -> Note:
|
||||||
rel_path, clean_folder = _rel_path(folder, title)
|
rel_path, clean_folder = _rel_path(folder, title)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -183,6 +185,7 @@ async def get_note(note_id: str) -> Note | None:
|
|||||||
record.created_at, record.updated_at, record.blocks, markdown)
|
record.created_at, record.updated_at, record.blocks, markdown)
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
async def update_note(
|
async def update_note(
|
||||||
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
|
note_id: str, *, title: str | None = None, markdown: str | None = None, tags: list[str] | None = None
|
||||||
) -> Note:
|
) -> Note:
|
||||||
@@ -213,6 +216,58 @@ async def update_note(
|
|||||||
parsed.created_at, parsed.updated_at, parsed.blocks, new_md)
|
parsed.created_at, parsed.updated_at, parsed.blocks, new_md)
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
|
async def move_note(note_id: str, *, folder: str) -> Note:
|
||||||
|
record = repository.get_note_record(note_id)
|
||||||
|
if record is None:
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||||
|
|
||||||
|
clean_folder = _normalize_folder(folder)
|
||||||
|
filename = Path(record.file_path).name
|
||||||
|
new_rel_path = f"{clean_folder}/{filename}" if clean_folder else filename
|
||||||
|
if new_rel_path == record.file_path:
|
||||||
|
note = await get_note(note_id)
|
||||||
|
assert note is not None
|
||||||
|
return note
|
||||||
|
|
||||||
|
source = _abs_path(record.file_path)
|
||||||
|
target = _abs_path(new_rel_path)
|
||||||
|
if not source.is_file():
|
||||||
|
raise ApiError(
|
||||||
|
409, "NOTE_FILE_MISSING", "note file is missing from the Vault",
|
||||||
|
{"note_id": note_id, "file_path": record.file_path},
|
||||||
|
)
|
||||||
|
if target.exists():
|
||||||
|
raise ApiError(
|
||||||
|
409, "RESOURCE_CONFLICT", "a note already exists at the target path",
|
||||||
|
{"note_id": note_id, "file_path": new_rel_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
markdown = source.read_text(encoding="utf-8")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source.replace(target)
|
||||||
|
try:
|
||||||
|
parsed = parse_note(
|
||||||
|
markdown=markdown,
|
||||||
|
file_path=new_rel_path,
|
||||||
|
folder=clean_folder,
|
||||||
|
tags=record.tags,
|
||||||
|
created_at=record.created_at,
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
note_id=record.note_id,
|
||||||
|
)
|
||||||
|
parsed.title = record.title
|
||||||
|
await index_note(parsed)
|
||||||
|
except BaseException:
|
||||||
|
target.replace(source)
|
||||||
|
raise
|
||||||
|
return _build_note(
|
||||||
|
parsed.note_id, parsed.title, parsed.file_path, parsed.tags,
|
||||||
|
parsed.created_at, parsed.updated_at, parsed.blocks, markdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@serialized_vault_mutation
|
||||||
async def delete_note(note_id: str) -> bool:
|
async def delete_note(note_id: str) -> bool:
|
||||||
record = repository.get_note_record(note_id)
|
record = repository.get_note_record(note_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app import repository
|
||||||
|
from app.contracts import Task, TaskStatus
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
from app.errors import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_from_row(row) -> Task:
|
||||||
|
return Task(
|
||||||
|
task_id=row["task_id"],
|
||||||
|
title=row["title"],
|
||||||
|
description=row["description"],
|
||||||
|
status=TaskStatus(row["status"]),
|
||||||
|
note_id=row["note_id"],
|
||||||
|
due_at=datetime.fromisoformat(row["due_at"]) if row["due_at"] else None,
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(
|
||||||
|
*, title: str, description: str = "", note_id: str | None = None,
|
||||||
|
due_at: datetime | None = None,
|
||||||
|
) -> Task:
|
||||||
|
if note_id and repository.get_note_record(note_id) is None:
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||||
|
task_id = f"task_{uuid4().hex}"
|
||||||
|
now = _now()
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tasks
|
||||||
|
(task_id, title, description, status, note_id, due_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
task_id, title, description, TaskStatus.todo.value, note_id,
|
||||||
|
due_at.isoformat() if due_at else None, now.isoformat(), now.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||||
|
return _task_from_row(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: str) -> Task | None:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||||
|
return _task_from_row(row) if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks(*, limit: int, offset: int) -> tuple[list[Task], int]:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM tasks ORDER BY updated_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
return [_task_from_row(row) for row in rows], total
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(task_id: str, values: dict[str, object]) -> Task:
|
||||||
|
current = get_task(task_id)
|
||||||
|
if current is None:
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
||||||
|
if "note_id" in values and values["note_id"]:
|
||||||
|
note_id = str(values["note_id"])
|
||||||
|
if repository.get_note_record(note_id) is None:
|
||||||
|
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
||||||
|
if values.get("title") is None:
|
||||||
|
values.pop("title", None)
|
||||||
|
if values.get("description") is None:
|
||||||
|
values.pop("description", None)
|
||||||
|
if values.get("status") is None:
|
||||||
|
values.pop("status", None)
|
||||||
|
|
||||||
|
columns: list[str] = []
|
||||||
|
params: list[object] = []
|
||||||
|
for name, value in values.items():
|
||||||
|
columns.append(f"{name} = ?")
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
value = value.isoformat()
|
||||||
|
elif isinstance(value, TaskStatus):
|
||||||
|
value = value.value
|
||||||
|
params.append(value)
|
||||||
|
columns.append("updated_at = ?")
|
||||||
|
params.append(_now().isoformat())
|
||||||
|
params.append(task_id)
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE tasks SET {', '.join(columns)} WHERE task_id = ?",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||||
|
return _task_from_row(row)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task(task_id: str) -> bool:
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
cursor = conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def note_links() -> dict[str, str]:
|
||||||
|
"""重建可再生 Note 索引前,暂存不可再生 Task 到 Note 的业务关联。"""
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
row["task_id"]: row["note_id"]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT task_id, note_id FROM tasks WHERE note_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def restore_note_links(links: dict[str, str]) -> None:
|
||||||
|
if not links:
|
||||||
|
return
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
with transaction(conn):
|
||||||
|
for task_id, note_id in links.items():
|
||||||
|
exists = conn.execute(
|
||||||
|
"SELECT 1 FROM notes WHERE note_id = ?", (note_id,)
|
||||||
|
).fetchone()
|
||||||
|
if exists:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET note_id = ? WHERE task_id = ?",
|
||||||
|
(note_id, task_id),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.contracts import TranscriptionJob
|
||||||
|
from app.services.attachment_service import attachment_path
|
||||||
|
|
||||||
|
_jobs: OrderedDict[str, TranscriptionJob] = OrderedDict()
|
||||||
|
MAX_JOBS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def create_transcription(attachment_id: str, language: str | None = None) -> TranscriptionJob:
|
||||||
|
del language # 预生成 transcript 暂不需要语言识别。
|
||||||
|
source = attachment_path(attachment_id)
|
||||||
|
transcript = source if source.suffix.lower() in {".txt", ".md"} else Path(f"{source}.txt")
|
||||||
|
job = TranscriptionJob(
|
||||||
|
job_id=f"transcription_{uuid4().hex}",
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
status="completed" if transcript.is_file() else "failed",
|
||||||
|
text=transcript.read_text(encoding="utf-8") if transcript.is_file() else None,
|
||||||
|
error_code=None if transcript.is_file() else "TRANSCRIPTION_BACKEND_UNAVAILABLE",
|
||||||
|
error_message=(
|
||||||
|
None
|
||||||
|
if transcript.is_file()
|
||||||
|
else "No host-generated transcript is available; local speech models are phase two."
|
||||||
|
),
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
_jobs[job.job_id] = job
|
||||||
|
while len(_jobs) > MAX_JOBS:
|
||||||
|
_jobs.popitem(last=False)
|
||||||
|
return job.model_copy(deep=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_transcription(job_id: str) -> TranscriptionJob | None:
|
||||||
|
job = _jobs.get(job_id)
|
||||||
|
return job.model_copy(deep=True) if job else None
|
||||||
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.116,<1.0",
|
"fastapi>=0.116,<1.0",
|
||||||
"httpx>=0.28,<1.0",
|
"httpx>=0.28,<1.0",
|
||||||
|
"jsonschema>=4.25,<5.0",
|
||||||
"pyyaml>=6.0,<7.0",
|
"pyyaml>=6.0,<7.0",
|
||||||
"sqlite-vec>=0.1.9",
|
"sqlite-vec>=0.1.9",
|
||||||
"uvicorn[standard]>=0.35,<1.0",
|
"uvicorn[standard]>=0.35,<1.0",
|
||||||
|
|||||||
@@ -2,8 +2,25 @@ import asyncio
|
|||||||
|
|
||||||
from app.main import health, service_status
|
from app.main import health, service_status
|
||||||
from app.routes import get_index_status, list_notes, list_plugins, list_providers, list_skills
|
from app.routes import get_index_status, list_notes, list_plugins, list_providers, list_skills
|
||||||
from app.routes import create_provider, delete_provider, get_provider, update_provider
|
from app.routes import (
|
||||||
from app.contracts import ProviderCreateRequest, ProviderType, ProviderUpdateRequest
|
create_provider,
|
||||||
|
create_task,
|
||||||
|
delete_provider,
|
||||||
|
delete_task,
|
||||||
|
get_provider,
|
||||||
|
get_task,
|
||||||
|
list_tasks,
|
||||||
|
update_provider,
|
||||||
|
update_task,
|
||||||
|
)
|
||||||
|
from app.contracts import (
|
||||||
|
ProviderCreateRequest,
|
||||||
|
ProviderType,
|
||||||
|
ProviderUpdateRequest,
|
||||||
|
TaskCreateRequest,
|
||||||
|
TaskStatus,
|
||||||
|
TaskUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_health() -> None:
|
def test_health() -> None:
|
||||||
@@ -79,3 +96,46 @@ def test_provider_configuration_lifecycle() -> None:
|
|||||||
assert fetched.provider_type == ProviderType.ollama
|
assert fetched.provider_type == ProviderType.ollama
|
||||||
assert disabled.enabled is False
|
assert disabled.enabled is False
|
||||||
assert deleted.resource_id == created.provider_id
|
assert deleted.resource_id == created.provider_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_patch_can_clear_nullable_fields() -> None:
|
||||||
|
created = asyncio.run(
|
||||||
|
create_provider(
|
||||||
|
ProviderCreateRequest(
|
||||||
|
provider_type=ProviderType.ollama,
|
||||||
|
name="Clearable",
|
||||||
|
base_url="http://127.0.0.1:11434",
|
||||||
|
default_model="qwen",
|
||||||
|
credential_id="unused",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cleared = asyncio.run(
|
||||||
|
update_provider(
|
||||||
|
created.provider_id,
|
||||||
|
ProviderUpdateRequest(
|
||||||
|
base_url=None, default_model=None, credential_id=None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert cleared.base_url is None
|
||||||
|
assert cleared.default_model is None
|
||||||
|
assert cleared.credential_id is None
|
||||||
|
finally:
|
||||||
|
asyncio.run(delete_provider(created.provider_id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_lifecycle_is_persistent() -> None:
|
||||||
|
created = asyncio.run(create_task(TaskCreateRequest(title="审阅修复")))
|
||||||
|
fetched = asyncio.run(get_task(created.task_id))
|
||||||
|
updated = asyncio.run(
|
||||||
|
update_task(created.task_id, TaskUpdateRequest(status=TaskStatus.done))
|
||||||
|
)
|
||||||
|
listed = asyncio.run(list_tasks(limit=20, offset=0))
|
||||||
|
deleted = asyncio.run(delete_task(created.task_id))
|
||||||
|
|
||||||
|
assert fetched.title == "审阅修复"
|
||||||
|
assert updated.status == TaskStatus.done
|
||||||
|
assert any(item.task_id == created.task_id for item in listed.items)
|
||||||
|
assert deleted.resource_id == created.task_id
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.contracts import (
|
|||||||
)
|
)
|
||||||
from app.extensions import ExtensionError
|
from app.extensions import ExtensionError
|
||||||
from app.services import note_service
|
from app.services import note_service
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
def run(coroutine):
|
def run(coroutine):
|
||||||
@@ -172,3 +173,127 @@ tools: [plugin.not-installed]
|
|||||||
with pytest.raises(ExtensionError) as exc:
|
with pytest.raises(ExtensionError) as exc:
|
||||||
container.skills.enable("missing-tool")
|
container.skills.enable("missing-tool")
|
||||||
assert exc.value.code == "SKILL_DEPENDENCY_MISSING"
|
assert exc.value.code == "SKILL_DEPENDENCY_MISSING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_permissions_must_be_known_and_granted(tmp_path) -> None:
|
||||||
|
package = tmp_path / "write-plugin"
|
||||||
|
package.mkdir()
|
||||||
|
(package / "plugin.yaml").write_text(
|
||||||
|
"""
|
||||||
|
id: write-plugin
|
||||||
|
name: Write Plugin
|
||||||
|
version: 1.0.0
|
||||||
|
permissions: [notes.write]
|
||||||
|
contributes:
|
||||||
|
tools: [plugin.write]
|
||||||
|
backend:
|
||||||
|
type: internal_rpc
|
||||||
|
transport: none
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(package / "tools.yaml").write_text(
|
||||||
|
"""
|
||||||
|
tools:
|
||||||
|
- name: plugin.write
|
||||||
|
description: permission test
|
||||||
|
permission: notes.write
|
||||||
|
handler: echo
|
||||||
|
parameters:
|
||||||
|
type: object
|
||||||
|
properties: {text: {type: string}}
|
||||||
|
required: [text]
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
container = build_container()
|
||||||
|
|
||||||
|
installed = container.plugins.install(package)
|
||||||
|
assert installed.status == "permission_required"
|
||||||
|
with pytest.raises(ExtensionError) as exc:
|
||||||
|
container.plugins.enable("write-plugin")
|
||||||
|
assert exc.value.code == "PLUGIN_PERMISSION_REQUIRED"
|
||||||
|
|
||||||
|
granted = container.plugins.set_permissions("write-plugin", ["notes.write"])
|
||||||
|
enabled = container.plugins.enable("write-plugin")
|
||||||
|
assert granted.granted_permissions == ["notes.write"]
|
||||||
|
assert enabled.status == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_rejects_unknown_permissions_and_invalid_schema(tmp_path) -> None:
|
||||||
|
unknown = tmp_path / "unknown-permission"
|
||||||
|
unknown.mkdir()
|
||||||
|
(unknown / "plugin.yaml").write_text(
|
||||||
|
"""
|
||||||
|
id: unknown-permission
|
||||||
|
name: Unknown
|
||||||
|
version: 1.0.0
|
||||||
|
permissions: [notes.wirte]
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
container = build_container()
|
||||||
|
with pytest.raises(ExtensionError) as exc:
|
||||||
|
container.plugins.install(unknown)
|
||||||
|
assert exc.value.code == "EXTENSION_PERMISSION_INVALID"
|
||||||
|
|
||||||
|
malformed = tmp_path / "malformed-schema"
|
||||||
|
malformed.mkdir()
|
||||||
|
(malformed / "plugin.yaml").write_text(
|
||||||
|
"""
|
||||||
|
id: malformed-schema
|
||||||
|
name: Malformed
|
||||||
|
version: 1.0.0
|
||||||
|
contributes:
|
||||||
|
tools: [bad.schema]
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(malformed / "tools.yaml").write_text(
|
||||||
|
"""
|
||||||
|
tools:
|
||||||
|
- name: bad.schema
|
||||||
|
description: invalid schema
|
||||||
|
handler: echo
|
||||||
|
parameters:
|
||||||
|
type: object
|
||||||
|
properties: []
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ExtensionError) as exc:
|
||||||
|
container.plugins.install(malformed)
|
||||||
|
assert exc.value.code == "PLUGIN_TOOL_SCHEMA_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_and_transcription_tools_use_host_storage() -> None:
|
||||||
|
async def scenario() -> None:
|
||||||
|
root = get_settings().attachments_path
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(root / "meeting.txt").write_text("会议转写内容", encoding="utf-8")
|
||||||
|
container = build_container()
|
||||||
|
|
||||||
|
attachment = await container.tools.execute(
|
||||||
|
ToolCall(
|
||||||
|
tool_call_id="call_attachment",
|
||||||
|
name="attachments.read",
|
||||||
|
arguments={"attachment_id": "meeting.txt"},
|
||||||
|
),
|
||||||
|
ToolExecutionContext(run_id="run_attachment"),
|
||||||
|
)
|
||||||
|
transcription = await container.tools.execute(
|
||||||
|
ToolCall(
|
||||||
|
tool_call_id="call_transcription",
|
||||||
|
name="audio.transcribe",
|
||||||
|
arguments={"attachment_id": "meeting.txt"},
|
||||||
|
),
|
||||||
|
ToolExecutionContext(run_id="run_transcription"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert attachment.success is True
|
||||||
|
assert attachment.output["content"] == "会议转写内容"
|
||||||
|
assert transcription.success is True
|
||||||
|
assert transcription.output["status"] == "completed"
|
||||||
|
assert transcription.output["text"] == "会议转写内容"
|
||||||
|
|
||||||
|
run(scenario())
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import json
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.contracts import Message, MessageRole, ModelRequest, ToolCall, ToolDefinition
|
from app.contracts import (
|
||||||
|
Message,
|
||||||
|
MessageRole,
|
||||||
|
ModelEventType,
|
||||||
|
ModelRequest,
|
||||||
|
ToolCall,
|
||||||
|
ToolDefinition,
|
||||||
|
)
|
||||||
from app.providers.ollama import OllamaProvider
|
from app.providers.ollama import OllamaProvider
|
||||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||||
|
|
||||||
@@ -17,6 +24,10 @@ def run(coroutine):
|
|||||||
return asyncio.run(coroutine)
|
return asyncio.run(coroutine)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect(stream):
|
||||||
|
return [event async for event in stream]
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compatible_maps_tool_call_and_credentials() -> None:
|
def test_openai_compatible_maps_tool_call_and_credentials() -> None:
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
|
|
||||||
@@ -152,3 +163,65 @@ def test_ollama_maps_models_and_completion() -> None:
|
|||||||
assert turn.text == "local answer"
|
assert turn.text == "local answer"
|
||||||
assert turn.input_tokens == 5
|
assert turn.input_tokens == 5
|
||||||
assert turn.output_tokens == 2
|
assert turn.output_tokens == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_compatible_streams_incremental_sse() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["stream"] is True
|
||||||
|
body = "\n".join(
|
||||||
|
[
|
||||||
|
'data: {"choices":[{"delta":{"content":"hel"}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"content":"lo"},"finish_reason":"stop"}]}',
|
||||||
|
'data: {"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1}}',
|
||||||
|
"data: [DONE]",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return httpx.Response(200, text=body)
|
||||||
|
|
||||||
|
provider = OpenAICompatibleProvider(
|
||||||
|
base_url="https://provider.test/v1",
|
||||||
|
credential_id=None,
|
||||||
|
credentials=StaticCredentials(),
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
request = ModelRequest(
|
||||||
|
provider_id="test", model="model",
|
||||||
|
messages=[Message(role=MessageRole.user, content="hello")],
|
||||||
|
)
|
||||||
|
events = run(collect(provider.stream(request)))
|
||||||
|
|
||||||
|
assert [item.data["text"] for item in events if item.event == ModelEventType.text_delta] == [
|
||||||
|
"hel", "lo"
|
||||||
|
]
|
||||||
|
assert events[-1].event == ModelEventType.done
|
||||||
|
assert [item.sequence for item in events] == list(range(len(events)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_streams_incremental_jsonl() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content)
|
||||||
|
assert payload["stream"] is True
|
||||||
|
body = "\n".join(
|
||||||
|
[
|
||||||
|
'{"message":{"content":"本"},"done":false}',
|
||||||
|
'{"message":{"content":"地"},"done":false}',
|
||||||
|
'{"message":{"content":""},"done":true,"prompt_eval_count":3,"eval_count":2}',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return httpx.Response(200, text=body)
|
||||||
|
|
||||||
|
provider = OllamaProvider(transport=httpx.MockTransport(handler))
|
||||||
|
request = ModelRequest(
|
||||||
|
provider_id="ollama", model="qwen",
|
||||||
|
messages=[Message(role=MessageRole.user, content="hello")],
|
||||||
|
)
|
||||||
|
events = run(collect(provider.stream(request)))
|
||||||
|
|
||||||
|
assert [item.data["text"] for item in events if item.event == ModelEventType.text_delta] == [
|
||||||
|
"本", "地"
|
||||||
|
]
|
||||||
|
assert events[-2].event == ModelEventType.usage
|
||||||
|
assert events[-1].event == ModelEventType.done
|
||||||
|
|||||||
@@ -77,6 +77,28 @@ def test_block_ids_are_stable() -> None:
|
|||||||
assert all(b.block_id.startswith("blk_") for b in p1.blocks)
|
assert all(b.block_id.startswith("blk_") for b in p1.blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_fence_does_not_create_fake_headings() -> None:
|
||||||
|
markdown = '# Real Heading\n\n```python\n# code comment\nprint("x")\n```\n'
|
||||||
|
parsed = parse_note(
|
||||||
|
markdown=markdown, file_path="code.md", folder="",
|
||||||
|
tags=None, created_at=_dt(), updated_at=_dt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert all(block.heading_path == ["Real Heading"] for block in parsed.blocks)
|
||||||
|
assert any("# code comment" in block.content for block in parsed.blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offsets_use_browser_compatible_utf16_units() -> None:
|
||||||
|
markdown = "# \U0001f600\n\nbody"
|
||||||
|
parsed = parse_note(
|
||||||
|
markdown=markdown, file_path="emoji.md", folder="",
|
||||||
|
tags=None, created_at=_dt(), updated_at=_dt(),
|
||||||
|
)
|
||||||
|
body = next(block for block in parsed.blocks if block.content == "body")
|
||||||
|
|
||||||
|
assert body.start_offset == len(markdown[: markdown.index("body")].encode("utf-16-le")) // 2
|
||||||
|
|
||||||
|
|
||||||
def test_tokens_split_cjk_bigrams_and_match_query() -> None:
|
def test_tokens_split_cjk_bigrams_and_match_query() -> None:
|
||||||
toks = tokens("向量检索")
|
toks = tokens("向量检索")
|
||||||
assert "向" in toks and "量" in toks
|
assert "向" in toks and "量" in toks
|
||||||
@@ -454,7 +476,6 @@ def test_patch_partial_content_no_orphan_vectors(vault) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert ids("vec_blocks") == ids("blocks")
|
assert ids("vec_blocks") == ids("blocks")
|
||||||
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
note_service.update_note(note.note_id, markdown="# 标题\n\n段落一改了。\n\n新增段落。")
|
note_service.update_note(note.note_id, markdown="# 标题\n\n段落一改了。\n\n新增段落。")
|
||||||
)
|
)
|
||||||
@@ -462,6 +483,22 @@ def test_patch_partial_content_no_orphan_vectors(vault) -> None:
|
|||||||
assert ids("vec_blocks") == ids("blocks")
|
assert ids("vec_blocks") == ids("blocks")
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_note_preserves_id_and_updates_indexed_path(vault) -> None:
|
||||||
|
from app.services import note_service
|
||||||
|
|
||||||
|
created = asyncio.run(
|
||||||
|
note_service.create_note(
|
||||||
|
title="可移动", markdown="# 标题\n\n正文", folder="原目录", tags=[]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
moved = asyncio.run(note_service.move_note(created.note_id, folder="新目录"))
|
||||||
|
|
||||||
|
assert moved.note_id == created.note_id
|
||||||
|
assert moved.file_path == "新目录/可移动.md"
|
||||||
|
assert not (vault / "原目录" / "可移动.md").exists()
|
||||||
|
assert (vault / "新目录" / "可移动.md").exists()
|
||||||
|
assert asyncio.run(note_service.get_note(created.note_id)).file_path == moved.file_path
|
||||||
|
|
||||||
def test_fts_metadata_filter_recalls_beyond_candidate_pool(vault) -> None:
|
def test_fts_metadata_filter_recalls_beyond_candidate_pool(vault) -> None:
|
||||||
"""metadata 过滤不能受候选池截断影响:目标块排在 50 名之外也应被召回(审阅 #4)。"""
|
"""metadata 过滤不能受候选池截断影响:目标块排在 50 名之外也应被召回(审阅 #4)。"""
|
||||||
from app.retrieval.engine import engine
|
from app.retrieval.engine import engine
|
||||||
@@ -524,3 +561,42 @@ def test_rebuild_failure_restores_old_index(vault, monkeypatch) -> None:
|
|||||||
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
|
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
|
||||||
|
|
||||||
assert repository.stats() == before # 旧索引已恢复,无半成品
|
assert repository.stats() == before # 旧索引已恢复,无半成品
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_rebuild_failure_removes_partial_database(vault, monkeypatch) -> None:
|
||||||
|
"""首次启动没有旧库时,失败也不能留下已经写入的部分索引。"""
|
||||||
|
from app.services import index_service
|
||||||
|
|
||||||
|
_write_vault(
|
||||||
|
vault,
|
||||||
|
{"a.md": "# A\n\nfirst", "b.md": "# B\n\nsecond"},
|
||||||
|
)
|
||||||
|
real_index = index_service.index_note
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
async def fail_on_second(parsed):
|
||||||
|
calls["count"] += 1
|
||||||
|
if calls["count"] == 2:
|
||||||
|
raise RuntimeError("injected first-rebuild failure")
|
||||||
|
await real_index(parsed)
|
||||||
|
|
||||||
|
monkeypatch.setattr(index_service, "index_note", fail_on_second)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
|
||||||
|
|
||||||
|
assert not get_settings().db_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_preserves_task_note_links(vault) -> None:
|
||||||
|
from app.services import index_service, note_service, task_service
|
||||||
|
|
||||||
|
note = asyncio.run(
|
||||||
|
note_service.create_note(
|
||||||
|
title="任务关联", markdown="# 任务关联", folder="", tags=[]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = task_service.create_task(title="跟进", note_id=note.note_id)
|
||||||
|
|
||||||
|
asyncio.run(index_service.rebuild(IndexRebuildRequest(scope="all")))
|
||||||
|
|
||||||
|
assert task_service.get_task(task.task_id).note_id == note.note_id
|
||||||
|
|||||||
Generated
+175
@@ -33,6 +33,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attrs"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.7.22"
|
version = "2026.7.22"
|
||||||
@@ -174,6 +183,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema"
|
||||||
|
version = "4.26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "jsonschema-specifications" },
|
||||||
|
{ name = "referencing" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema-specifications"
|
||||||
|
version = "2025.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "referencing" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notes-agent-backend"
|
name = "notes-agent-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -181,6 +217,7 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "jsonschema" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "sqlite-vec" },
|
{ name = "sqlite-vec" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
@@ -195,6 +232,7 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.116,<1.0" },
|
{ name = "fastapi", specifier = ">=0.116,<1.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||||
|
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||||
{ name = "sqlite-vec", specifier = ">=0.1.9" },
|
{ name = "sqlite-vec", specifier = ">=0.1.9" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
|
||||||
@@ -427,6 +465,143 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "referencing"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpds-py"
|
||||||
|
version = "2026.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlite-vec"
|
name = "sqlite-vec"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
|||||||
Reference in New Issue
Block a user