Files
knowledge-adapter-service/src/knowledge_adapter/asset_repository.py
T
姚章浩 693cd952cd feat(asset): 清理被截断的结尾图片引用,修复整单 502
仓库首次入库(此前仅存在于本地工作区与测试服构建产物中,未上传)。

改动(src/knowledge_adapter/asset_content_processor.py):
- 新增 _TRUNCATED_TRAILING_MARKDOWN_RESOURCE_IMAGE 规则,清除 chunk 末尾被截断的
  ![](resource://xxxx 残片(WeKnora 分块在图片引用中间截断时产生,旧逻辑无法匹配完整引用,
  残留 resource:// 触发 ContentProcessingError,导致整单 502)
- 回归测试 tests/test_asset_content_processor.py::
  test_truncated_trailing_markdown_resource_image_is_removed

验证证据:
- 测试服 120.76.217.51,镜像 knowledge-adapter-service:reviewed-test-aeab4b5fca2d
  (release aeab4b5fca2d8661):KB d015d05c-25c8-4d63-b2ab-e8590d3c2dfb 5 个查询全部
  http=200、records=5、残留 resource:// = 0;KB b9dacd62 无退化;容器日志无 error/exception
- 本地:pytest 141 项全通过、ruff 全通过
- 该文件与 258c31e0fe07fd08 源码的差异经逐文件 md5 比对,为唯一变更文件
2026-09-16 10:01:27 +08:00

114 lines
3.5 KiB
Python

from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol, cast
from uuid import UUID, uuid4
_UPSERT_MAPPING = """
INSERT INTO asset_mapping (asset_id, source_ref, object_key, content_type, status)
VALUES ($1, $2, $3, $4, 'active')
ON CONFLICT (source_ref) DO UPDATE
SET source_ref = asset_mapping.source_ref
WHERE asset_mapping.object_key = EXCLUDED.object_key
RETURNING asset_id, source_ref, object_key, content_type, created_at, status
"""
_SELECT_ACTIVE_BY_ASSET_ID = """
SELECT asset_id, source_ref, object_key, content_type, created_at, status
FROM asset_mapping
WHERE asset_id = $1
AND status = 'active'
"""
_SELECT_ACTIVE_BY_SOURCE_REF = """
SELECT asset_id, source_ref, object_key, content_type, created_at, status
FROM asset_mapping
WHERE source_ref = $1
AND status = 'active'
"""
@dataclass(frozen=True, slots=True)
class AssetMapping:
asset_id: UUID
source_ref: str
object_key: str
content_type: str | None
created_at: datetime
status: str
class AssetMappingConflictError(RuntimeError):
pass
class AssetRepositoryError(RuntimeError):
pass
class AssetConnection(Protocol):
async def fetchrow(self, query: str, *args: object) -> Mapping[str, Any] | None: ...
class AssetPool(Protocol):
def acquire(self) -> AbstractAsyncContextManager[AssetConnection]: ...
def _mapping_from_row(row: Mapping[str, Any]) -> AssetMapping:
return AssetMapping(
asset_id=UUID(str(row["asset_id"])),
source_ref=str(row["source_ref"]),
object_key=str(row["object_key"]),
content_type=str(row["content_type"]) if row["content_type"] else None,
created_at=cast(datetime, row["created_at"]),
status=str(row["status"]),
)
class AssetRepository:
def __init__(self, *, pool: AssetPool) -> None:
self._pool = pool
async def get_active_by_asset_id(self, asset_id: UUID) -> AssetMapping | None:
try:
async with self._pool.acquire() as connection:
row = await connection.fetchrow(_SELECT_ACTIVE_BY_ASSET_ID, asset_id)
except Exception as error:
raise AssetRepositoryError("asset repository unavailable") from error
if row is None:
return None
return _mapping_from_row(row)
async def get_active_by_source_ref(self, source_ref: str) -> AssetMapping | None:
try:
async with self._pool.acquire() as connection:
row = await connection.fetchrow(_SELECT_ACTIVE_BY_SOURCE_REF, source_ref)
except Exception as error:
raise AssetRepositoryError("asset repository unavailable") from error
if row is None:
return None
return _mapping_from_row(row)
async def get_or_create(
self,
*,
source_ref: str,
object_key: str,
content_type: str | None,
) -> AssetMapping:
try:
async with self._pool.acquire() as connection:
row = await connection.fetchrow(
_UPSERT_MAPPING,
uuid4(),
source_ref,
object_key,
content_type,
)
except Exception as error:
raise AssetRepositoryError("asset repository unavailable") from error
if row is None:
raise AssetMappingConflictError("asset mapping conflict")
return _mapping_from_row(row)