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:
2026-08-28 09:56:07 +08:00
parent 167ae24796
commit 36bc1022f1
26 changed files with 1531 additions and 99 deletions
+63 -39
View File
@@ -10,7 +10,6 @@ from app.contracts import (
AgentRunCreateRequest,
AgentRunListResponse,
ChatRequest,
ErrorResponse,
ExtensionInstallRequest,
IndexJob,
IndexRebuildRequest,
@@ -27,6 +26,7 @@ from app.contracts import (
PermissionDecisionRequest,
Plugin,
PluginListResponse,
PluginPermissionGrantRequest,
ProviderConfig,
ProviderCreateRequest,
ProviderListResponse,
@@ -46,17 +46,16 @@ from app.contracts import (
TranscriptionJob,
TranscriptionRequest,
)
from app.agent import AgentRunNotFoundError
from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.container import container
from app.errors import ApiError, not_implemented
from app.errors import ApiError
from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError
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")
not_implemented_response = {501: {"model": ErrorResponse, "description": "业务服务尚未实现"}}
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")
@router.post(
"/notes/{note_id}/move", response_model=Note, responses=not_implemented_response, tags=["Notes"]
)
async def move_note(note_id: str, _: NoteMoveRequest) -> Note:
not_implemented(f"notes.move:{note_id}")
@router.post("/notes/{note_id}/move", response_model=Note, tags=["Notes"])
async def move_note(note_id: str, request: NoteMoveRequest) -> Note:
return await note_service.move_note(note_id, folder=request.folder)
# Retrieval and chat
@@ -219,6 +216,8 @@ async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun:
return await container.agent.create_run(request)
except ExtensionError as 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(
@@ -386,6 +385,19 @@ async def disable_plugin(plugin_id: str) -> Plugin:
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(
"/plugins/{plugin_id}",
response_model=OperationResponse,
@@ -407,7 +419,6 @@ async def list_providers() -> ProviderListResponse:
@router.get(
"/providers/{provider_id}",
response_model=ProviderConfig,
responses=not_implemented_response,
tags=["Providers"],
)
async def get_provider(provider_id: str) -> ProviderConfig:
@@ -417,7 +428,6 @@ async def get_provider(provider_id: str) -> ProviderConfig:
@router.post(
"/providers",
response_model=ProviderConfig,
responses=not_implemented_response,
tags=["Providers"],
)
async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
@@ -446,7 +456,6 @@ async def create_provider(request: ProviderCreateRequest) -> ProviderConfig:
@router.patch(
"/providers/{provider_id}",
response_model=ProviderConfig,
responses=not_implemented_response,
tags=["Providers"],
)
async def update_provider(
@@ -455,7 +464,19 @@ async def update_provider(
current = configurable_provider_or_404(provider_id).config
if provider_id == "mock":
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)
container.providers.replace(config, adapter)
return config
@@ -464,7 +485,6 @@ async def update_provider(
@router.delete(
"/providers/{provider_id}",
response_model=OperationResponse,
responses=not_implemented_response,
tags=["Providers"],
)
async def delete_provider(provider_id: str) -> OperationResponse:
@@ -478,7 +498,6 @@ async def delete_provider(provider_id: str) -> OperationResponse:
@router.get(
"/providers/{provider_id}/models",
response_model=ProviderModelsResponse,
responses=not_implemented_response,
tags=["Providers"],
)
async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
@@ -492,7 +511,6 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
@router.post(
"/providers/test",
response_model=ProviderTestResponse,
responses=not_implemented_response,
tags=["Providers"],
)
async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
@@ -517,38 +535,39 @@ async def test_provider(request: ProviderTestRequest) -> ProviderTestResponse:
async def list_tasks(
limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0)
) -> 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.post("/tasks", response_model=Task, 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, responses=not_implemented_response, tags=["Tasks"]
)
@router.get("/tasks/{task_id}", response_model=Task, tags=["Tasks"])
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(
"/tasks/{task_id}", response_model=Task, responses=not_implemented_response, tags=["Tasks"]
)
async def update_task(task_id: str, _: TaskUpdateRequest) -> Task:
not_implemented(f"tasks.update:{task_id}")
@router.patch("/tasks/{task_id}", response_model=Task, 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))
@router.delete(
"/tasks/{task_id}",
response_model=OperationResponse,
responses=not_implemented_response,
tags=["Tasks"],
)
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
@@ -556,21 +575,26 @@ async def delete_task(task_id: str) -> OperationResponse:
"/media/transcriptions",
response_model=TranscriptionJob,
status_code=202,
responses=not_implemented_response,
tags=["Media"],
)
async def create_transcription(_: TranscriptionRequest) -> TranscriptionJob:
not_implemented("media.transcriptions.create")
async def create_transcription(request: TranscriptionRequest) -> TranscriptionJob:
return transcription_service.create_transcription(
request.attachment_id, request.language
)
@router.get(
"/media/transcriptions/{job_id}",
response_model=TranscriptionJob,
responses=not_implemented_response,
tags=["Media"],
)
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"])