693cd952cd
仓库首次入库(此前仅存在于本地工作区与测试服构建产物中,未上传)。 改动(src/knowledge_adapter/asset_content_processor.py): - 新增 _TRUNCATED_TRAILING_MARKDOWN_RESOURCE_IMAGE 规则,清除 chunk 末尾被截断的  - 回归测试 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 比对,为唯一变更文件
114 lines
3.5 KiB
Python
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)
|