"""知识缺口看板路由(FR-ADM-05):被问到却没有答案的查询,按热度排序。 数据来自检索无结果时的自动埋点(T-P2-06),看板只读:它回答「下一批内容 该生产什么」,是内容排期的输入,不是待办队列。 偏差说明(详见提交信息):协调提交提供的 ``packages.retrieval.list_gaps`` 对 ``select(KbGap)`` 的结果调 ``.mappings()``,行键是实体名而非列名, ``GapSummary(row)`` 取 ``row.gap_id`` 必然 ``AttributeError``(该路径无既有 测试覆盖,真库一跑即暴露)。``packages/**`` 本任务禁改,故此处以等价查询 在 admin 包内自实现;上游修复后应切回 retrieval 的 ``list_gaps``。 """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from apps.admin.deps import SessionDep, admin_auth from apps.admin.schemas import GapOut from packages.shared.models import KbGap from packages.shared.response import ok router = APIRouter(prefix="/kb", tags=["admin:gaps"], dependencies=[Depends(admin_auth)]) async def list_gap_rows(session: AsyncSession, *, limit: int, offset: int) -> list[KbGap]: """kb_gap 看板查询:hit_count 降序,同频次按最近出现降序(与 retrieval 语义一致)。""" result = await session.execute( select(KbGap) .order_by(KbGap.hit_count.desc(), KbGap.last_seen_at.desc()) .limit(limit) .offset(offset) ) return list(result.scalars().all()) @router.get("/gaps", summary="知识缺口列表(hit_count 降序:最常被问到的排最前)") async def list_gaps( session: SessionDep, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), ) -> dict[str, Any]: rows = await list_gap_rows(session, limit=limit, offset=offset) items = [ GapOut( gap_id=str(row.gap_id), query_text=row.query_text, filter_ctx=dict(row.filter_ctx), hit_count=row.hit_count, first_seen_at=row.first_seen_at, last_seen_at=row.last_seen_at, ).model_dump() for row in rows ] return ok({"items": items, "total": len(items), "limit": limit, "offset": offset})