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:
@@ -17,11 +17,24 @@ from app.contracts import IndexJob, IndexRebuildRequest, IndexStatus
|
||||
from app.errors import ApiError
|
||||
from app.knowledge.parser import parse_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
|
||||
|
||||
vector_store = SqliteVecStore()
|
||||
|
||||
_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]]:
|
||||
@@ -29,23 +42,28 @@ def _scan_vault() -> list[tuple[str, str, str, datetime, datetime]]:
|
||||
|
||||
先读入内存:若文件读取失败,rebuild 尚未清空旧索引,不会造成数据损失。
|
||||
"""
|
||||
vault = get_settings().vault_path
|
||||
vault = get_settings().vault_path.resolve()
|
||||
result: list[tuple[str, str, str, datetime, datetime]] = []
|
||||
if not vault.exists():
|
||||
return result
|
||||
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()
|
||||
folder = path.relative_to(vault).parent.as_posix()
|
||||
if folder == ".":
|
||||
folder = ""
|
||||
stat = path.stat()
|
||||
stat = resolved.stat()
|
||||
created = datetime.fromtimestamp(stat.st_ctime, 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
|
||||
|
||||
|
||||
@serialized_vault_mutation
|
||||
async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
global _active_job_id, _last_completed_at, _last_error
|
||||
job_id = "job_" + uuid4().hex[:12]
|
||||
# 增量重建(scope != all 或指定 note_ids)尚未实现,明确拒绝而非静默全量重建
|
||||
if request.scope != "all" or request.note_ids:
|
||||
@@ -59,10 +77,22 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
# 先扫描到内存(失败不会清旧索引),再快照旧库用于失败回滚
|
||||
docs = _scan_vault()
|
||||
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:
|
||||
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:
|
||||
repository.clear_all()
|
||||
await vector_store.clear()
|
||||
@@ -72,27 +102,39 @@ async def rebuild(request: IndexRebuildRequest) -> IndexJob:
|
||||
created_at=created, updated_at=updated,
|
||||
)
|
||||
await index_note(parsed)
|
||||
except BaseException:
|
||||
task_service.restore_note_links(task_note_links)
|
||||
except BaseException as exc:
|
||||
# 重建失败:恢复旧索引,避免留下半成品;记录 failed 任务后向上抛
|
||||
if backup_path is not None and backup_path.exists():
|
||||
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,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
))
|
||||
_last_error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
_active_job_id = None
|
||||
if backup_path is not None:
|
||||
backup_path.unlink(missing_ok=True)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_status() -> IndexStatus:
|
||||
# 同步重建、无排队任务,因此状态恒为 idle;实际索引规模可由 GET /api/notes 与搜索反映
|
||||
return IndexStatus(status="idle", pending_jobs=0)
|
||||
if _active_job_id is not None:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user