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:
+19
-1
@@ -1 +1,19 @@
|
||||
"""极简运营后台(占位,FR-ADM-01~05)。"""
|
||||
"""极简运营后台(T-P2-09,FR-ADM-01~05):知识内容的维护 / 审核 / 发布 / 回滚入口。
|
||||
|
||||
与 ``apps/api`` 平级的独立 FastAPI 应用(部署上分离):顾问从这里完成实体、
|
||||
规则、素材与缺口看板的全部操作,不再需要工程介入——这是 M2 出口的成败标志。
|
||||
|
||||
V1 极简鉴权的边界与升级路径:
|
||||
|
||||
- **共享令牌,无角色**:全后台只有一把 ``X-Admin-Token``(``ADMIN_TOKEN``),
|
||||
建库与审核是同一个人。没有「编辑 / 审核分离」的角色体系。
|
||||
- **无审计日志字段**:谁在什么时间改了什么,V1 只能靠 structlog 的
|
||||
request_id 关联访问日志(service 层的每个写操作都有结构化事件兜底),
|
||||
没有独立的审计表。令牌泄露 = 全权限泄露,轮换令牌是唯一的处置手段。
|
||||
- **升级路径**:运营账号体系(登录、角色、操作审计)属 V2;届时把
|
||||
``apps.admin.deps.admin_auth`` 换成账号会话依赖即可,路由与服务层不动。
|
||||
- **空令牌 = 未部署**:``ADMIN_TOKEN`` 为空时应用装配期不注册任何业务路由
|
||||
(docs/openapi 一并关闭),所有 admin 端点 404——避免带病上线。
|
||||
|
||||
应用工厂见 :func:`apps.admin.app.create_admin_app`。
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""admin 应用工厂(T-P2-09):极简运营后台的装配层。
|
||||
|
||||
与客户端 API 的装配差异只有三点:路由前缀 ``/admin/api/v1``(区分流量)、
|
||||
鉴权换成 ``X-Admin-Token``、**空令牌时一个业务路由都不注册**——「未部署」
|
||||
的语义用装配期决定,而不是每个请求里判空(404 更干净,扫描器也探不到面)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from apps.admin.routers import assets, gaps, kb, rules
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.db import dispose_engine, get_session_factory
|
||||
from packages.shared.handlers import install_exception_handlers
|
||||
from packages.shared.logging import configure_logging, get_logger
|
||||
from packages.shared.middleware import RequestContextMiddleware
|
||||
from packages.shared.observability import setup_tracing
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 与客户端 API(/api/v1)区分的固定前缀;不提供 /v1 别名(后台没有历史包袱)。
|
||||
ADMIN_API_PREFIX = "/admin/api/v1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings: Settings = app.state.admin_settings
|
||||
# 启动即触碰共享 engine(懒加载单例,复用 packages.shared.db):
|
||||
# 连接配置有问题应该在部署的那一刻暴露,而不是等顾问点第一个按钮。
|
||||
get_session_factory()
|
||||
logger.info(
|
||||
"admin_started",
|
||||
env=settings.app_env,
|
||||
routes_registered=bool(settings.admin_token),
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await dispose_engine()
|
||||
logger.info("admin_stopped")
|
||||
|
||||
|
||||
def create_admin_app(settings: Settings | None = None) -> FastAPI:
|
||||
cfg = settings or get_settings()
|
||||
configure_logging(
|
||||
level=cfg.log_level, json_output=cfg.log_json, extra_redact_keys=cfg.extra_redact_keys
|
||||
)
|
||||
|
||||
# 空令牌 = 等价未部署:业务路由、openapi、docs 全部不装配。
|
||||
deployed = bool(cfg.admin_token)
|
||||
app = FastAPI(
|
||||
title="美搭运营后台",
|
||||
version="0.1.0",
|
||||
description=(
|
||||
"极简运营后台:知识实体/规则维护、素材登记审核、发布编排与缺口看板"
|
||||
"(T-P2-09)。鉴权:请求头 X-Admin-Token。"
|
||||
),
|
||||
lifespan=lifespan,
|
||||
openapi_url=f"{ADMIN_API_PREFIX}/openapi.json" if deployed else None,
|
||||
docs_url=f"{ADMIN_API_PREFIX}/docs" if deployed and cfg.is_local else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
app.state.admin_settings = cfg
|
||||
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
install_exception_handlers(app)
|
||||
setup_tracing(app, cfg)
|
||||
|
||||
if deployed:
|
||||
for router in (kb.router, rules.router, assets.router, gaps.router):
|
||||
app.include_router(router, prefix=ADMIN_API_PREFIX)
|
||||
else:
|
||||
logger.warning(
|
||||
"admin_routes_disabled",
|
||||
reason="ADMIN_TOKEN 为空:全部 admin 端点不注册(等价未部署)",
|
||||
)
|
||||
return app
|
||||
@@ -0,0 +1,90 @@
|
||||
"""admin 应用的依赖装配:鉴权 + session + 各域 service。
|
||||
|
||||
**不 import ``apps.api`` 的任何东西**(import-linter:apps 之间互不依赖)——
|
||||
session 依赖在这里按同样语义自建一份;``ok``/``AppError`` 等统一信封件来自
|
||||
``packages.shared``,是允许的共享层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.kb.service import KbAssetService, KbService
|
||||
from packages.rules.service import RuleService
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
#: 鉴权请求头。共享令牌方案(见包 docstring 的 V1 边界说明)。
|
||||
ADMIN_TOKEN_HEADER = "X-Admin-Token"
|
||||
|
||||
|
||||
def admin_settings(request: Request) -> Settings:
|
||||
"""取应用工厂装配时挂上的 settings。
|
||||
|
||||
鉴权口径必须与应用装配口径同源:工厂收到的 settings 决定路由是否注册,
|
||||
也必须决定令牌是否匹配——两处各读一遍 ``get_settings()`` 会给「换令牌
|
||||
重启了一半」的窗口留缝。
|
||||
"""
|
||||
settings: Settings = request.app.state.admin_settings
|
||||
return settings
|
||||
|
||||
|
||||
async def admin_auth(request: Request) -> None:
|
||||
"""``X-Admin-Token`` 精确匹配校验,失败 → 40100(UNAUTHENTICATED)。
|
||||
|
||||
用 ``hmac.compare_digest`` 而非 ``==``:令牌比较不该泄露「对到了第几位」。
|
||||
先 encode 成 bytes 再比较——str 版本的 compare_digest 遇到非 ASCII 头会
|
||||
直接 TypeError 变 500,那等于给攻击者一个免费的服务端异常。
|
||||
"""
|
||||
settings = admin_settings(request)
|
||||
expected = settings.admin_token.encode()
|
||||
if not expected:
|
||||
# 正常到不了这里(空令牌时路由根本没注册);留作纵深防御:
|
||||
# 万一未来有人在别的 app 里复用这个依赖,也不许空配置放行。
|
||||
raise AppError(ErrorCode.UNAUTHENTICATED, message="后台未配置访问令牌")
|
||||
provided = request.headers.get(ADMIN_TOKEN_HEADER, "").encode()
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise AppError(
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
message="管理令牌缺失或不正确",
|
||||
hint=f"请在请求头携带 {ADMIN_TOKEN_HEADER}",
|
||||
)
|
||||
|
||||
|
||||
async def session_dep() -> AsyncIterator[AsyncSession]:
|
||||
"""请求级事务作用域:成功统一 commit、异常统一 rollback。
|
||||
|
||||
与 ``apps.api`` 的同名依赖语义一致(包内自建,不跨 app import)。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def kb_service_dep(session: SessionDep) -> KbService:
|
||||
return KbService(session)
|
||||
|
||||
|
||||
def kb_asset_service_dep(session: SessionDep) -> KbAssetService:
|
||||
return KbAssetService(session)
|
||||
|
||||
|
||||
def rule_service_dep(session: SessionDep) -> RuleService:
|
||||
return RuleService(session)
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(session_dep)]
|
||||
KbServiceDep = Annotated[KbService, Depends(kb_service_dep)]
|
||||
KbAssetServiceDep = Annotated[KbAssetService, Depends(kb_asset_service_dep)]
|
||||
RuleServiceDep = Annotated[RuleService, Depends(rule_service_dep)]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""发布编排:知识变更后的下游缓存「自然失效」(T-P2-10 的入口)。
|
||||
|
||||
运营后台是唯一有「发布 / 撤稿」动作的地方,因此失效编排长在这里而不是
|
||||
service 层:``KbService``/``RuleService`` 管状态机,跨域的缓存联动(规则缓存
|
||||
+ 检索缓存)是应用层的编排职责。T-P2-10 要求的「发布后自然失效」——顾问点
|
||||
发布,检索立刻能看见新知识,不需要任何人记得去刷缓存。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.retrieval import service as retrieval_service
|
||||
from packages.rules import service as rules_service
|
||||
from packages.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 落地后必须让下游缓存失效的目标状态:``published`` 让新知识对检索可见,
|
||||
#: ``archived`` 让旧知识从检索消失——两个方向都是线上知识集合的真实变更。
|
||||
#: (依据 ``packages.retrieval.service.invalidate_cache`` 的契约:「发布/归档
|
||||
#: 知识后调用」。)
|
||||
CACHE_AFFECTING_TARGETS: frozenset[str] = frozenset({"published", "archived"})
|
||||
|
||||
|
||||
def invalidate_downstream_caches(*, domain: str, key: str, target: str) -> None:
|
||||
"""规则进程内缓存 + 检索结果缓存整体失效。
|
||||
|
||||
- 规则缓存:``RuleService.publish/archive`` 内部已会失效一次,这里显式
|
||||
再调是编排契约的一部分(实体发布同样要失效规则缓存——实体变更可能
|
||||
改变规则命中的知识基础),幂等无副作用;
|
||||
- 检索缓存:当前是失效版本号桩,T14 会在背后接真正的进程内缓存实现,
|
||||
调用点无需再改。
|
||||
"""
|
||||
rules_service.invalidate_published_cache()
|
||||
retrieval_service.invalidate_cache()
|
||||
logger.info("admin_knowledge_invalidated", domain=domain, key=key, target=target)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,78 @@
|
||||
"""admin 域请求/响应模型(FR-ADM-01~05)。
|
||||
|
||||
实体与素材的载荷复用 ``packages/kb/schemas.py``(服务层契约同源);本模块只
|
||||
放后台特有的形状:状态机流转请求、规则对外视图、缺口看板行。雪花 ID 一律
|
||||
以字符串出现在载荷里(与 kb 契约一致:JS Number 只有 2^53 精度)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.kb.schemas import EntityStatus
|
||||
from packages.rules.dsl import Rule as RuleUpsert
|
||||
|
||||
__all__ = [
|
||||
"GapOut",
|
||||
"RuleOut",
|
||||
"RuleTransitionRequest",
|
||||
"RuleUpsert",
|
||||
"TransitionRequest",
|
||||
]
|
||||
|
||||
|
||||
class TransitionRequest(BaseModel):
|
||||
"""``POST /kb/items/{id}/transition`` 载荷。
|
||||
|
||||
``target`` 收全量四态而不是只收三个「前进方向」:published→draft 这类非法
|
||||
跃迁应该是**状态机语义冲突(40900)**,而不是 schema 层的 422——顾问在
|
||||
界面上看到的错误要能告诉他「为什么不行」(矩阵见 ``packages.kb.service``),
|
||||
而不是「这个值不合法」。draft 作为目标在矩阵里永远非法,自然会被拦下。
|
||||
"""
|
||||
|
||||
target: EntityStatus
|
||||
|
||||
|
||||
class RuleTransitionRequest(BaseModel):
|
||||
"""``POST /rules/{rule_key}/transition`` 载荷。
|
||||
|
||||
规则状态机里 draft 永远不是合法目标(发布与下线是仅有的两个前进动作,
|
||||
与实体不同,规则没有 reviewing 中间态),直接在 schema 层收窄。
|
||||
"""
|
||||
|
||||
target: Literal["published", "archived"]
|
||||
|
||||
|
||||
class RuleOut(BaseModel):
|
||||
"""规则对外视图。conditions/effects 保持入库的 DSL 原样(决策表是数据,
|
||||
后台表单按 DSL 渲染,不做二次加工)。"""
|
||||
|
||||
rule_id: str
|
||||
rule_key: str
|
||||
category: str
|
||||
version: int = Field(description="内容版本号,每次修改 +1")
|
||||
status: str
|
||||
priority: int
|
||||
conditions: dict[str, Any]
|
||||
effects: dict[str, Any]
|
||||
is_hard: bool
|
||||
confidence: float
|
||||
evidence: str | None
|
||||
author: str | None
|
||||
reviewer: str | None
|
||||
published_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GapOut(BaseModel):
|
||||
"""缺口看板一行(kb_gap,T-P2-06)。hit_count 是排序依据也是优先级信号。"""
|
||||
|
||||
gap_id: str
|
||||
query_text: str = Field(description="用户真实查询原文——内容生产的起点")
|
||||
filter_ctx: dict[str, Any] = Field(description="缺口出现时的过滤条件快照")
|
||||
hit_count: int = Field(description="同一缺口累计出现次数")
|
||||
first_seen_at: datetime
|
||||
last_seen_at: datetime
|
||||
Reference in New Issue
Block a user