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 的升级路径)。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""admin 路由包:每域一个 router 文件,全部经 ``admin_auth`` 鉴权。"""
|
||||
@@ -0,0 +1,71 @@
|
||||
"""素材路由(FR-ADM-03):图像资产登记与审核。
|
||||
|
||||
素材由运营流水线高频登记(T-P2-07/15),审核是唯一能把 ``tryon_ready``
|
||||
打开的入口——服务层已强制「tryon_ready 只能随 approved 打开」,这里只做
|
||||
透传,约束在 HTTP 层的表现是 400(BAD_REQUEST)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from apps.admin.deps import KbAssetServiceDep, admin_auth
|
||||
from packages.kb.schemas import AssetCreate, AssetOut, AssetReview
|
||||
from packages.shared.models import KbAsset
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/assets", tags=["admin:assets"], dependencies=[Depends(admin_auth)])
|
||||
|
||||
|
||||
def _serialize(asset: KbAsset) -> dict[str, Any]:
|
||||
return AssetOut(
|
||||
asset_id=str(asset.asset_id),
|
||||
entity_id=str(asset.entity_id),
|
||||
asset_type=asset.asset_type,
|
||||
cos_key=asset.cos_key,
|
||||
spec_check=dict(asset.spec_check),
|
||||
tryon_ready=asset.tryon_ready,
|
||||
review_status=asset.review_status,
|
||||
license=asset.license,
|
||||
created_at=asset.created_at,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.post("", summary="登记素材(登记时 tryon_ready 恒为 False,与请求体取值无关)")
|
||||
async def register_asset(payload: AssetCreate, assets: KbAssetServiceDep) -> dict[str, Any]:
|
||||
asset = await assets.register_asset(payload)
|
||||
return ok(_serialize(asset))
|
||||
|
||||
|
||||
@router.get("", summary="素材列表:entity_id / tryon_ready 过滤 + 分页")
|
||||
async def list_assets(
|
||||
assets: KbAssetServiceDep,
|
||||
entity_id: int | None = Query(default=None, description="按实体过滤(雪花 ID)"),
|
||||
tryon_ready: bool | None = Query(default=None, description="true=仅可换装素材"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
rows, total = await assets.list_assets(
|
||||
entity_id=entity_id, tryon_ready=tryon_ready, limit=limit, offset=offset
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"items": [_serialize(row) for row in rows],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/review",
|
||||
summary="审核素材(tryon_ready=true 仅在 review_status=approved 时被接受)",
|
||||
)
|
||||
async def review_asset(
|
||||
asset_id: int, payload: AssetReview, assets: KbAssetServiceDep
|
||||
) -> dict[str, Any]:
|
||||
asset = await assets.review_asset(asset_id, payload)
|
||||
return ok(_serialize(asset))
|
||||
@@ -0,0 +1,58 @@
|
||||
"""知识缺口看板路由(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})
|
||||
@@ -0,0 +1,132 @@
|
||||
"""知识实体路由(FR-ADM-01):维护 + 状态机流转 + 图谱查看。
|
||||
|
||||
与客户端 ``apps.api`` 的 kb 路由关键差异:
|
||||
|
||||
- 鉴权走 ``X-Admin-Token``(共享令牌),操作者没有用户身份,``updated_by``
|
||||
固定记录为 ``admin-console``——V1 的操作留证靠 request_id 关联访问日志;
|
||||
- 多出 ``POST /items/{id}/transition``:把状态机流转从 PATCH 的「内容+状态
|
||||
可混用」里独立出来,发布/撤稿动作在这里挂上跨域缓存失效编排(T-P2-10
|
||||
的「自然失效」入口)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from apps.admin.deps import KbServiceDep, admin_auth
|
||||
from apps.admin.publishing import CACHE_AFFECTING_TARGETS, invalidate_downstream_caches
|
||||
from apps.admin.schemas import TransitionRequest
|
||||
from packages.kb.schemas import (
|
||||
MAX_GRAPH_HOPS,
|
||||
EntityCreate,
|
||||
EntityOut,
|
||||
EntityStatus,
|
||||
EntityType,
|
||||
EntityUpdate,
|
||||
)
|
||||
from packages.shared.models import KbEntity
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/kb", tags=["admin:kb"], dependencies=[Depends(admin_auth)])
|
||||
|
||||
#: V1 共享令牌鉴权没有操作者身份,字段统一记渠道来源(见模块 docstring)。
|
||||
ADMIN_OPERATOR = "admin-console"
|
||||
|
||||
|
||||
def _serialize(entity: KbEntity) -> dict[str, Any]:
|
||||
return EntityOut(
|
||||
entity_id=str(entity.entity_id),
|
||||
entity_type=entity.entity_type,
|
||||
name=entity.name,
|
||||
attrs=dict(entity.attrs),
|
||||
status=entity.status,
|
||||
version=entity.version,
|
||||
source=entity.source,
|
||||
license=entity.license,
|
||||
updated_by=entity.updated_by,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.get("/items", summary="实体列表:entity_type / status 过滤 + 名称模糊 + 分页")
|
||||
async def list_items(
|
||||
kb: KbServiceDep,
|
||||
entity_type: EntityType | None = Query(default=None, description="12 类本体之一"),
|
||||
status: EntityStatus | None = Query(default=None),
|
||||
name: str | None = Query(default=None, max_length=128, description="名称模糊匹配"),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
rows, total = await kb.search_entities(
|
||||
entity_type=entity_type,
|
||||
status=status,
|
||||
name_ilike=name,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"items": [_serialize(row) for row in rows],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/items", summary="新建实体(draft / version=1 起步)")
|
||||
async def create_item(payload: EntityCreate, kb: KbServiceDep) -> dict[str, Any]:
|
||||
entity = await kb.create_entity(payload=payload, updated_by=ADMIN_OPERATOR)
|
||||
return ok(_serialize(entity))
|
||||
|
||||
|
||||
@router.get("/items/{entity_id}", summary="实体详情")
|
||||
async def get_item(entity_id: int, kb: KbServiceDep) -> dict[str, Any]:
|
||||
entity = await kb.get_entity(entity_id=entity_id)
|
||||
return ok(_serialize(entity))
|
||||
|
||||
|
||||
@router.patch("/items/{entity_id}", summary="修改内容(draft/reviewing;published 只读)")
|
||||
async def update_item(entity_id: int, payload: EntityUpdate, kb: KbServiceDep) -> dict[str, Any]:
|
||||
entity = await kb.update_entity(entity_id=entity_id, payload=payload, updated_by=ADMIN_OPERATOR)
|
||||
return ok(_serialize(entity))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items/{entity_id}/transition",
|
||||
summary="状态机流转:draft→reviewing→published→archived(发布/撤稿触发缓存失效编排)",
|
||||
)
|
||||
async def transition_item(
|
||||
entity_id: int, payload: TransitionRequest, kb: KbServiceDep
|
||||
) -> dict[str, Any]:
|
||||
# 先读当前状态:与目标相同的重放是幂等 no-op(与 service 语义一致),
|
||||
# 不触发失效编排——缓存重建是全量动作,重放不该有副作用。
|
||||
current = await kb.get_entity(entity_id=entity_id)
|
||||
if current.status == payload.target:
|
||||
return ok(_serialize(current))
|
||||
|
||||
entity = await kb.update_entity(
|
||||
entity_id=entity_id,
|
||||
payload=EntityUpdate(status=payload.target),
|
||||
updated_by=ADMIN_OPERATOR,
|
||||
)
|
||||
if payload.target in CACHE_AFFECTING_TARGETS:
|
||||
invalidate_downstream_caches(
|
||||
domain="kb_entity", key=str(entity.entity_id), target=payload.target
|
||||
)
|
||||
return ok(_serialize(entity))
|
||||
|
||||
|
||||
@router.get("/items/{entity_id}/graph", summary="从该实体出发的 1~3 跳可解释路径")
|
||||
async def get_item_graph(
|
||||
entity_id: int,
|
||||
kb: KbServiceDep,
|
||||
max_hops: int = Query(
|
||||
default=MAX_GRAPH_HOPS, ge=1, le=MAX_GRAPH_HOPS, description="遍历深度 1~3,超界 422"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
result = await kb.traverse_graph(entity_id=entity_id, max_hops=max_hops)
|
||||
return ok(result.model_dump())
|
||||
@@ -0,0 +1,82 @@
|
||||
"""规则路由(FR-ADM-02):决策表维护 + 发布/下线状态机。
|
||||
|
||||
规则以 ``rule_key``(命中链锚点)为地址,不是数字主键——路径参数即 rule_key。
|
||||
HTTP 载荷直接复用 ``packages.rules.dsl.Rule``(封闭词汇表在入口就拦住 typo
|
||||
键名),service 层落库前还会再校验一遍。发布与下线都走缓存失效编排:规则
|
||||
既影响建议输出(规则缓存),也影响检索加权(rule_boost)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from apps.admin.deps import RuleServiceDep, admin_auth
|
||||
from apps.admin.publishing import invalidate_downstream_caches
|
||||
from apps.admin.schemas import RuleOut, RuleTransitionRequest, RuleUpsert
|
||||
from packages.shared.models import KbRule
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["admin:rules"], dependencies=[Depends(admin_auth)])
|
||||
|
||||
#: V1 共享令牌鉴权没有操作者身份,author 记渠道来源。
|
||||
ADMIN_OPERATOR = "admin-console"
|
||||
|
||||
|
||||
def _serialize(row: KbRule) -> dict[str, Any]:
|
||||
return RuleOut(
|
||||
rule_id=str(row.rule_id),
|
||||
rule_key=row.rule_key,
|
||||
category=row.category,
|
||||
version=row.version,
|
||||
status=row.status,
|
||||
priority=row.priority,
|
||||
conditions=dict(row.conditions),
|
||||
effects=dict(row.effects),
|
||||
is_hard=row.is_hard,
|
||||
confidence=float(row.confidence),
|
||||
evidence=row.evidence,
|
||||
author=row.author,
|
||||
reviewer=row.reviewer,
|
||||
published_at=row.published_at,
|
||||
created_at=row.created_at,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.get("", summary="规则列表(可选 status 过滤)")
|
||||
async def list_rules(
|
||||
rules: RuleServiceDep,
|
||||
status: str | None = Query(default=None, description="draft / published / archived"),
|
||||
) -> dict[str, Any]:
|
||||
rows = await rules.list(status)
|
||||
return ok({"items": [_serialize(row) for row in rows], "total": len(rows)})
|
||||
|
||||
|
||||
@router.post("", summary="新建规则(draft / version=1 起步,DSL 先校验后落库)")
|
||||
async def create_rule(payload: RuleUpsert, rules: RuleServiceDep) -> dict[str, Any]:
|
||||
row = await rules.create(payload.model_dump(), author=ADMIN_OPERATOR)
|
||||
return ok(_serialize(row))
|
||||
|
||||
|
||||
@router.patch("/{rule_key}", summary="修改规则内容(仅 draft;rule_key 不可改)")
|
||||
async def update_rule(rule_key: str, payload: RuleUpsert, rules: RuleServiceDep) -> dict[str, Any]:
|
||||
row = await rules.update(rule_key, payload.model_dump())
|
||||
return ok(_serialize(row))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{rule_key}/transition",
|
||||
summary="发布 / 下线规则(均触发规则缓存与检索缓存的失效编排)",
|
||||
)
|
||||
async def transition_rule(
|
||||
rule_key: str, payload: RuleTransitionRequest, rules: RuleServiceDep
|
||||
) -> dict[str, Any]:
|
||||
# service 的 publish/archive 自带非法跃迁校验(40900)与规则缓存失效;
|
||||
# 检索缓存的联动在这里补上(跨域编排属应用层职责,见 publishing 模块)。
|
||||
if payload.target == "published":
|
||||
row = await rules.publish(rule_key)
|
||||
else:
|
||||
row = await rules.archive(rule_key)
|
||||
invalidate_downstream_caches(domain="kb_rule", key=rule_key, target=payload.target)
|
||||
return ok(_serialize(row))
|
||||
Reference in New Issue
Block a user