"""kb_rule 的 CRUD、draft→published 状态机与已发布规则缓存。 状态机(同 T6 语义):draft → published → archived;archived 终态;draft 可 直接 archived(撤稿)。只有 draft 可修改内容且每次修改 version+1——直接改 published 规则等于线上建议瞬间变味,必须重新走发布动作。 规则不做物理删除:命中链(rule_hits)是报告与 Agent 可解释性的数据来源, 历史规则要可追溯,下线一律走 archived。 """ from __future__ import annotations from datetime import UTC, datetime from decimal import Decimal from typing import Any from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from packages.domain.ids import next_id from packages.rules import dsl, engine, repo from packages.shared.errors import AppError, ErrorCode from packages.shared.logging import get_logger from packages.shared.models import KbRule logger = get_logger(__name__) STATUS_DRAFT = "draft" STATUS_PUBLISHED = "published" STATUS_ARCHIVED = "archived" #: 合法状态跃迁全集(自跃迁也非法:状态机不接受无操作变更)。 LEGAL_TRANSITIONS: frozenset[tuple[str, str]] = frozenset( { (STATUS_DRAFT, STATUS_PUBLISHED), (STATUS_PUBLISHED, STATUS_ARCHIVED), (STATUS_DRAFT, STATUS_ARCHIVED), } ) def ensure_transition(current: str, target: str) -> None: """非法跃迁抛 STATE_CONFLICT——状态错误必须响亮失败,不能悄悄吞掉。""" if (current, target) not in LEGAL_TRANSITIONS: raise AppError( ErrorCode.STATE_CONFLICT, message=f"规则状态不能从 {current} 变更为 {target}", data={"current": current, "target": target}, ) def validate_rule_payload(payload: dict[str, Any]) -> dsl.Rule: """DSL 写入前校验:非法结构 100% 拒绝(未识别键不静默忽略,见 dsl 模块)。""" try: return dsl.Rule.model_validate(payload) except ValidationError as exc: raise AppError( ErrorCode.VALIDATION_FAILED, message="规则 DSL 不合法", data={ "errors": [ f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in exc.errors() ] }, ) from exc def row_to_rule(row: KbRule) -> dsl.Rule: """ORM 行 → 域规则。 已入库数据不合法属于知识库数据错误(5xxxx),必须立刻暴露——静默跳过 一条 published 规则等于线上建议悄悄缺了一块,没人会发现。 """ try: return dsl.Rule.model_validate( { "rule_key": row.rule_key, "category": row.category, "priority": row.priority, "conditions": row.conditions, "effects": row.effects, "is_hard": row.is_hard, "confidence": float(row.confidence), } ) except ValidationError as exc: logger.error("kb_rule_dsl_invalid", rule_key=row.rule_key, errors=exc.errors()) raise AppError( ErrorCode.CONFIG_ERROR, message="知识库中存在不合法的规则数据", data={"rule_key": row.rule_key}, ) from exc class PublishedRules: """进程内已发布规则缓存。 量级 ~400、读极多写极少:整表载入 + 管理动作后失效即可满足 p95 < 50ms (求值本身是纯内存操作)。跨进程失效广播(Redis,架构 §10)属后续接入 项,先留 ``invalidate()`` 单一入口。并发首载最多重复读一次库,无害。 """ def __init__(self) -> None: self._rules: tuple[dsl.Rule, ...] | None = None async def load(self, session: AsyncSession) -> tuple[dsl.Rule, ...]: if self._rules is None: rows = await repo.list_published(session) self._rules = tuple(row_to_rule(row) for row in rows) logger.info("rules_cache_loaded", count=len(self._rules)) return self._rules def invalidate(self) -> None: self._rules = None _published_rules = PublishedRules() def invalidate_published_cache() -> None: """发布/下线/修改规则后调用,进程内缓存立刻重建。""" _published_rules.invalidate() class RuleService: """kb_rule 的读写门面:CRUD + 状态机 + 缓存求值入口。""" def __init__(self, session: AsyncSession) -> None: self._session = session # -- 读 ------------------------------------------------------------------- async def get(self, rule_key: str) -> KbRule: return await self._require(rule_key) async def list(self, status: str | None = None) -> list[KbRule]: if status is None: return await repo.list_all(self._session) return await repo.list_by_status(self._session, status) async def evaluate(self, ctx: dsl.RuleContext) -> engine.RuleVerdict: """用进程内已发布规则缓存求值(命中链 + 硬拦截 + 冲突仲裁)。""" rules = await _published_rules.load(self._session) return engine.evaluate(rules, ctx) # -- 写 ------------------------------------------------------------------- async def create( self, payload: dict[str, Any], *, author: str | None = None, evidence: str | None = None, ) -> KbRule: """新建 draft 规则。DSL 先校验再落库;rule_key 全局唯一。""" rule = validate_rule_payload(payload) if await repo.get_by_key(self._session, rule.rule_key) is not None: raise AppError( ErrorCode.BAD_REQUEST, message="规则键已存在", data={"rule_key": rule.rule_key}, ) row = repo.add_rule( self._session, KbRule( rule_id=next_id(), rule_key=rule.rule_key, category=rule.category, version=1, status=STATUS_DRAFT, priority=rule.priority, conditions=rule.conditions.model_dump()["clauses"], effects=rule.effects.model_dump(), is_hard=rule.is_hard, confidence=Decimal(str(rule.confidence)), author=author, evidence=evidence, ), ) await self._session.flush() logger.info("rule_created", rule_key=rule.rule_key, author=author) return row async def update(self, rule_key: str, payload: dict[str, Any]) -> KbRule: """修改规则内容(仅 draft):version+1;rule_key 是命中链锚点,不可改。""" rule = validate_rule_payload(payload) if rule.rule_key != rule_key: raise AppError( ErrorCode.BAD_REQUEST, message="rule_key 不可修改", data={"rule_key": rule_key}, ) row = await self._require(rule_key) if row.status != STATUS_DRAFT: raise AppError( ErrorCode.STATE_CONFLICT, message="仅 draft 规则可修改,published 规则请先下线重发", data={"rule_key": rule_key, "status": row.status}, ) row.category = rule.category row.priority = rule.priority row.conditions = rule.conditions.model_dump()["clauses"] row.effects = rule.effects.model_dump() row.is_hard = rule.is_hard row.confidence = Decimal(str(rule.confidence)) row.version += 1 await self._session.flush() logger.info("rule_updated", rule_key=rule_key, version=row.version) return row async def publish(self, rule_key: str) -> KbRule: row = await self._require(rule_key) ensure_transition(row.status, STATUS_PUBLISHED) row.status = STATUS_PUBLISHED row.published_at = datetime.now(UTC) await self._session.flush() invalidate_published_cache() logger.info("rule_published", rule_key=rule_key, version=row.version) return row async def archive(self, rule_key: str) -> KbRule: """下线/撤稿(draft 与 published 均可)。规则不物理删除,保留可追溯性。""" row = await self._require(rule_key) ensure_transition(row.status, STATUS_ARCHIVED) row.status = STATUS_ARCHIVED await self._session.flush() invalidate_published_cache() logger.info("rule_archived", rule_key=rule_key) return row async def _require(self, rule_key: str) -> KbRule: row = await repo.get_by_key(self._session, rule_key) if row is None: raise AppError( ErrorCode.NOT_FOUND, message="规则不存在", data={"rule_key": rule_key}, ) return row