Files
meida_backend/apps/admin/routers/gaps.py
T
xsl 3961b6fbfa feat(admin): minimal console for KB content operations
apps/admin 独立应用:X-Admin-Token 鉴权(空=全部路由不注册=404)、实体/规则
维护与状态机流转、发布编排(publish/archive → 规则缓存 invalidate +
retrieval invalidate_cache 双失效)、素材登记与审核(tryon_ready 门禁透传)、
缺口看板(T-P2-09,FR-ADM-01~05)。V1 共享令牌鉴权,无角色无审计字段,
操作留证靠 structlog request_id。

测试:单测 tests/unit/test_admin_app.py(10 例:空令牌 404、错/缺/非 ASCII
令牌 40100、正确令牌 200 假服务、发布编排接线、幂等重放无副作用);
集成 tests/integration/test_admin_console.py(5 例:顾问全流程不碰客户端
API、双失效副作用可观察、非法跃迁 40900、素材审核门禁、缺口看板排序、
令牌 401)。483 单测 + 43 集成全绿;ruff/mypy/lint-imports 全绿。

偏差说明:
1. packages/retrieval.repo.list_gaps 有缺陷:对 select(KbGap) 的结果调
   .mappings(),行键是实体名而非列名,GapSummary(row) 取 row.gap_id 必然
   AttributeError(该路径无既有测试覆盖,接真库即暴露)。packages/** 本
   任务禁改,故缺口查询在 apps/admin/routers/gaps.py 以等价 SQL 自实现
   (hit_count 降序、同频次按 last_seen_at 降序、limit/offset 语义一致);
   建议合并阶段修复上游后切回 retrieval.list_gaps。
2. 实体 transition 的 target 收全量四态(任务文档示例仅列 reviewing/
   published/archived):按任务测试要求 published→draft 必须返回 40900
   (状态机语义冲突)而非 schema 422;draft 作为目标由服务层跃迁矩阵恒拒。
3. 规则路由以 rule_key 为路径参数(任务文档写 /rules/{id},但 RuleService
   全程以 rule_key 寻址,无按数字 id 的接口,以代码为准)。
4. 发布编排触发面含 archived(撤稿):依据 retrieval.invalidate_cache 的
   自身契约「发布/归档知识后调用」,比文档字面的「发布成功后」多了撤稿
   方向;reviewing 等其余状态不触发。
5. updated_by/author 记 "admin-console":V1 共享令牌无操作者身份,字段
   留渠道来源;运营账号体系属 V2(见包 docstring 的升级路径)。
2026-08-14 21:19:31 +08:00

59 lines
2.3 KiB
Python

"""知识缺口看板路由(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})