feat: 添加可配置场景注册表与规则引擎
- 新增场景基类、配置加载、注册表与原语模块 - 添加 r10_refund_split 规则及场景 JSON Schema - 扩展 scan 引擎与 scenarios API - 新增场景注册表/配置/集成测试 - 更新前端 App、api、labels 支持新场景
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"""场景元数据 API:暴露已注册的审计场景,供前端动态渲染。
|
||||
|
||||
新增场景(实现 BaseScenario 并经 @register_scenario 注册)后,本端点自动反映,
|
||||
无需改动前端代码。导入 registry 时会触发 app.scenarios 包的自动发现与注册。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.schemas import ScenarioOut
|
||||
from app.scenarios.registry import scenario_meta
|
||||
|
||||
router = APIRouter(prefix="/scenarios", tags=["scenarios"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScenarioOut])
|
||||
def list_scenarios() -> list[dict]:
|
||||
"""返回全部已注册场景的元数据(场景码、标题、展示名、风险域)。"""
|
||||
return scenario_meta()
|
||||
@@ -83,3 +83,12 @@ class DashboardSummary(BaseModel):
|
||||
by_confidence: dict[str, int]
|
||||
by_scenario: dict[str, int]
|
||||
total_amount_involved: float
|
||||
|
||||
|
||||
class ScenarioOut(BaseModel):
|
||||
"""已注册审计场景的元数据,供前端动态渲染场景名(替代写死的 scenarioLabel)。"""
|
||||
|
||||
code: str
|
||||
title: str
|
||||
label: str
|
||||
risk_domain: str
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.clues import service as clue_svc
|
||||
from app.clues.models import Clue
|
||||
from app.scenarios import churn_fraud as cf
|
||||
from app.scenarios import split_contract as sc
|
||||
from app.scenarios.base import BaseScenario
|
||||
|
||||
MODEL_VERSION = "mock-llm@0.1"
|
||||
|
||||
@@ -98,3 +99,53 @@ def run_churn_scan(
|
||||
data_version_id=data_version_id,
|
||||
)
|
||||
return ScanResult("R9", len(retention_curve), clue)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScenarioRunResult:
|
||||
"""通用调度下单个场景的落库结果:扫描覆盖数 + 生成的线索列表。"""
|
||||
|
||||
scenario_code: str
|
||||
scanned_count: int
|
||||
clues: list[Clue]
|
||||
|
||||
|
||||
def run_scenarios(
|
||||
session: Session,
|
||||
scenarios: list[BaseScenario],
|
||||
*,
|
||||
data_version_id: uuid.UUID | None = None,
|
||||
actor: str = "system",
|
||||
) -> list[ScenarioRunResult]:
|
||||
"""通用场景调度器:遍历已实例化的场景,检测→按阈值过滤→统一落库。
|
||||
|
||||
新增场景无需改动本函数:实现 BaseScenario 子类并经 @register_scenario 注册即可。
|
||||
每个场景自报扫描覆盖数(证明全量性),超过阈值的草稿由本函数统一调 create_clue 落库。
|
||||
"""
|
||||
results: list[ScenarioRunResult] = []
|
||||
for scenario in scenarios:
|
||||
outcome = scenario.scan(session, data_version_id=data_version_id)
|
||||
created: list[Clue] = []
|
||||
for draft in outcome.drafts:
|
||||
if draft.score >= scenario.score_threshold:
|
||||
created.append(
|
||||
clue_svc.create_clue(
|
||||
session,
|
||||
title=draft.title or scenario.title,
|
||||
risk_domain=scenario.risk_domain,
|
||||
scenario_code=scenario.code,
|
||||
score=draft.score,
|
||||
rationale=draft.rationale,
|
||||
evidence=draft.evidence,
|
||||
subjects=draft.subjects,
|
||||
amount_involved=draft.amount_involved,
|
||||
model_version=MODEL_VERSION,
|
||||
rule_version=scenario.rule_version,
|
||||
data_version_id=data_version_id,
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
ScenarioRunResult(scenario.code, outcome.scanned_count, created)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -10,6 +10,7 @@ from app import __version__
|
||||
from app.api.clues import router as clues_router
|
||||
from app.api.datahub import router as datahub_router
|
||||
from app.api.nlq import router as nlq_router
|
||||
from app.api.scenarios import router as scenarios_router
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
@@ -30,6 +31,7 @@ app = FastAPI(
|
||||
app.include_router(datahub_router)
|
||||
app.include_router(clues_router)
|
||||
app.include_router(nlq_router)
|
||||
app.include_router(scenarios_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
"""审计场景检测器:将业务数据中的异常模式转化为线索。"""
|
||||
"""审计场景检测器:将业务数据中的异常模式转化为线索。
|
||||
|
||||
自动发现:导入本包下的全部场景模块以触发 @register_scenario 注册。
|
||||
新增场景时只需在 scenarios/ 下新增一个模块文件,无需改动任何现有代码即可被
|
||||
注册表(registry)与调度器(engines.scan.run_scenarios)发现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
# 框架自身模块(非具体场景),不参与"代码场景"的自动导入
|
||||
_FRAMEWORK_MODULES = {"base", "registry", "primitives", "config"}
|
||||
|
||||
# 1) 自动发现并导入"代码场景"模块(A 打底),触发 @register_scenario 注册
|
||||
for _module in pkgutil.iter_modules(__path__):
|
||||
if _module.name not in _FRAMEWORK_MODULES:
|
||||
importlib.import_module(f"{__name__}.{_module.name}")
|
||||
|
||||
# 2) 加载"配置驱动场景"(B 快捷通道):自动发现 rules/*.yaml 并注册,无需改代码
|
||||
from app.scenarios.config import load_yaml_scenarios # noqa: E402
|
||||
|
||||
load_yaml_scenarios()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""场景插件框架:统一的场景基类与产出物(A 打底)。
|
||||
|
||||
每个审计场景实现一个 BaseScenario 子类,自包含"检测→评分→产出线索草稿",
|
||||
并通过 @register_scenario 注册。扫描调度器(engines/scan.run_scenarios)统一遍历
|
||||
执行并落库,新增场景无需改动调度器、API 与前端。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClueDraft:
|
||||
"""场景产出的线索草稿:仅承载"内容",落库由调度器统一完成。
|
||||
|
||||
风险域、场景码等元数据来自场景类属性;模型/数据版本由调度器注入。
|
||||
"""
|
||||
|
||||
score: float
|
||||
rationale: str
|
||||
evidence: dict
|
||||
subjects: dict = field(default_factory=dict)
|
||||
amount_involved: float | None = None
|
||||
title: str | None = None # 覆盖场景默认标题;为 None 时用 BaseScenario.title
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanOutcome:
|
||||
"""单个场景一次扫描的结果:扫描覆盖数(证明全量性)+ 线索草稿列表。"""
|
||||
|
||||
scanned_count: int
|
||||
drafts: list[ClueDraft] = field(default_factory=list)
|
||||
|
||||
|
||||
class BaseScenario(ABC):
|
||||
"""审计场景抽象基类。
|
||||
|
||||
子类通过类属性声明元数据(场景码、标题、风险域、前端展示名、阈值),
|
||||
并实现 scan() 完成检测与评分。命中阈值的草稿由调度器统一生成线索。
|
||||
"""
|
||||
|
||||
# 场景码(如 "R8"),全局唯一,注册表按此索引
|
||||
code: str = ""
|
||||
# 默认线索标题(草稿可覆盖)
|
||||
title: str = ""
|
||||
# 风险域(如 "收入"/"成本")
|
||||
risk_domain: str = ""
|
||||
# 前端展示名(替代 labels.ts 中写死的 scenarioLabel)
|
||||
label: str = ""
|
||||
# 评分阈值:草稿 score >= 阈值才生成线索
|
||||
score_threshold: float = 0.5
|
||||
# 规则版本(配置驱动场景可用于追溯;代码场景一般为 None)
|
||||
rule_version: str | None = None
|
||||
|
||||
@abstractmethod
|
||||
def scan(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
data_version_id: uuid.UUID | None = None,
|
||||
) -> ScanOutcome:
|
||||
"""执行检测与评分,返回扫描覆盖数与命中草稿。
|
||||
|
||||
Args:
|
||||
session: 数据库会话(如需自取数)
|
||||
data_version_id: 当前数据版本 ID(用于线索可追溯)
|
||||
|
||||
Returns:
|
||||
ScanOutcome:scanned_count 体现全量覆盖,drafts 为候选线索
|
||||
"""
|
||||
...
|
||||
@@ -8,6 +8,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.scenarios.base import BaseScenario, ClueDraft, ScanOutcome
|
||||
from app.scenarios.registry import register_scenario
|
||||
|
||||
|
||||
@dataclass
|
||||
class CohortPoint:
|
||||
@@ -83,3 +86,50 @@ def build_rationale(finding: ChurnFinding, mismatch: float) -> str:
|
||||
return (
|
||||
f"未见明显断崖退订,但佣金与业务质量不匹配度为 {mismatch:.0%},建议关注。"
|
||||
)
|
||||
|
||||
|
||||
@register_scenario
|
||||
class ChurnFraudScenario(BaseScenario):
|
||||
"""场景二养卡骗补检测的插件封装:时序断崖 + 佣金质量不匹配 → 线索草稿。"""
|
||||
|
||||
code = "R9"
|
||||
title = "疑似养卡骗补(脉冲增长+规律退订)"
|
||||
risk_domain = "成本"
|
||||
label = "养卡骗补"
|
||||
score_threshold = 0.5 # 与原 run_churn_scan 的 score>=0.5 一致
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
retention_curve: list[CohortPoint],
|
||||
commission_paid: float,
|
||||
active_ratio: float,
|
||||
zero_usage_ratio: float,
|
||||
channel_key: str,
|
||||
) -> None:
|
||||
self.retention_curve = retention_curve
|
||||
self.commission_paid = commission_paid
|
||||
self.active_ratio = active_ratio
|
||||
self.zero_usage_ratio = zero_usage_ratio
|
||||
self.channel_key = channel_key
|
||||
|
||||
def scan(self, session, *, data_version_id=None) -> ScanOutcome:
|
||||
finding = detect_pulse_decay(self.retention_curve)
|
||||
mismatch = commission_quality_mismatch(
|
||||
self.commission_paid, self.active_ratio, self.zero_usage_ratio
|
||||
)
|
||||
score = churn_risk_score(finding, mismatch)
|
||||
draft = ClueDraft(
|
||||
score=score,
|
||||
rationale=build_rationale(finding, mismatch),
|
||||
evidence={
|
||||
"cliff_month": finding.cliff_month,
|
||||
"max_drop": finding.max_drop,
|
||||
"commission_paid": self.commission_paid,
|
||||
"active_ratio": self.active_ratio,
|
||||
"zero_usage_ratio": self.zero_usage_ratio,
|
||||
"mismatch": mismatch,
|
||||
},
|
||||
subjects={"channel": self.channel_key},
|
||||
amount_involved=self.commission_paid,
|
||||
)
|
||||
return ScanOutcome(scanned_count=len(self.retention_curve), drafts=[draft])
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""YAML 配置驱动场景(B 快捷通道):用「配置 + 原语库」声明同构场景,免写 Python。
|
||||
|
||||
加载 scenarios/rules/*.yaml,经 pydantic 校验后为每条配置动态生成一个 BaseScenario
|
||||
子类并注册。新增此类场景 = 新增一个 YAML 文件,无需改动任何 Python 代码。
|
||||
运行时与代码场景一致:实例化后注入数据行 rows,由 run_scenarios 统一调度落库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.scenarios.base import BaseScenario, ClueDraft, ScanOutcome
|
||||
from app.scenarios.primitives import get_primitive
|
||||
from app.scenarios.registry import register_scenario
|
||||
|
||||
_DEFAULT_RULES_DIR = Path(__file__).parent / "rules"
|
||||
|
||||
|
||||
class ScenarioConfig(BaseModel):
|
||||
"""YAML 场景配置的 schema(pydantic 校验,字段缺失/类型错误即报错)。"""
|
||||
|
||||
code: str = Field(min_length=1)
|
||||
title: str = Field(min_length=1)
|
||||
risk_domain: str = Field(min_length=1)
|
||||
label: str = Field(min_length=1)
|
||||
detector: str = Field(min_length=1)
|
||||
params: dict = Field(default_factory=dict)
|
||||
threshold: float = 0.5
|
||||
rationale_template: str = "{summary}"
|
||||
rule_version: str | None = None
|
||||
|
||||
|
||||
class ConfigScenario(BaseScenario):
|
||||
"""由 YAML 配置驱动的场景:运行时按 detector 调用原语,模板生成人话理由。
|
||||
|
||||
config 由动态子类(build_scenario_class)设置;数据行 rows 在实例化时注入。
|
||||
"""
|
||||
|
||||
config: ScenarioConfig
|
||||
|
||||
def __init__(self, rows: list[dict] | None = None) -> None:
|
||||
self._rows = rows or []
|
||||
|
||||
def with_rows(self, rows: list[dict]) -> ConfigScenario:
|
||||
"""注入待扫描的数据行(链式返回自身)。"""
|
||||
self._rows = rows
|
||||
return self
|
||||
|
||||
def scan(self, session, *, data_version_id: uuid.UUID | None = None) -> ScanOutcome:
|
||||
fn = get_primitive(self.config.detector)
|
||||
if fn is None:
|
||||
raise ValueError(f"未知检测原语: {self.config.detector}")
|
||||
result = fn(self._rows, self.config.params)
|
||||
drafts: list[ClueDraft] = []
|
||||
if result.hit and result.score > 0:
|
||||
try:
|
||||
rationale = self.config.rationale_template.format(
|
||||
summary=result.summary, **result.metrics
|
||||
)
|
||||
except (KeyError, IndexError, ValueError):
|
||||
rationale = result.summary
|
||||
drafts.append(
|
||||
ClueDraft(
|
||||
score=result.score,
|
||||
rationale=rationale,
|
||||
evidence=result.evidence,
|
||||
subjects=result.subjects,
|
||||
)
|
||||
)
|
||||
return ScanOutcome(scanned_count=len(self._rows), drafts=drafts)
|
||||
|
||||
|
||||
def build_scenario_class(config: ScenarioConfig) -> type[ConfigScenario]:
|
||||
"""按配置动态生成并注册一个 ConfigScenario 子类(类属性承载元数据)。"""
|
||||
attrs = {
|
||||
"code": config.code,
|
||||
"title": config.title,
|
||||
"risk_domain": config.risk_domain,
|
||||
"label": config.label,
|
||||
"score_threshold": config.threshold,
|
||||
"rule_version": config.rule_version,
|
||||
"config": config,
|
||||
}
|
||||
cls = type(f"ConfigScenario_{config.code}", (ConfigScenario,), attrs)
|
||||
return register_scenario(cls)
|
||||
|
||||
|
||||
def load_scenario_config(path: Path) -> ScenarioConfig:
|
||||
"""读取并校验单个 YAML 场景配置文件。"""
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
try:
|
||||
config = ScenarioConfig(**raw)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"场景配置 {path.name} 校验失败: {exc}") from exc
|
||||
if get_primitive(config.detector) is None:
|
||||
raise ValueError(
|
||||
f"场景配置 {path.name} 引用了未注册的检测原语: {config.detector}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def load_yaml_scenarios(rules_dir: Path | str | None = None) -> list[type[ConfigScenario]]:
|
||||
"""加载 rules 目录下全部 *.yaml 场景配置,动态生成并注册场景类。
|
||||
|
||||
新增 YAML 文件即被发现,无需改动代码。返回新注册的场景类列表(便于测试)。
|
||||
"""
|
||||
rules_dir = Path(rules_dir) if rules_dir else _DEFAULT_RULES_DIR
|
||||
classes: list[type[ConfigScenario]] = []
|
||||
if not rules_dir.exists():
|
||||
return classes
|
||||
for path in sorted(rules_dir.glob("*.yaml")):
|
||||
config = load_scenario_config(path)
|
||||
classes.append(build_scenario_class(config))
|
||||
return classes
|
||||
@@ -0,0 +1,210 @@
|
||||
"""检测原语库(B 快捷通道):可被 YAML 配置场景复用的通用检测/评分模式。
|
||||
|
||||
每个原语接收数据行(list[dict])与参数(dict),返回统一的 PrimitiveResult。
|
||||
新增"同构"场景时,可在 YAML 中通过 detector 引用原语、params 传参,无需编写 Python。
|
||||
若遇到原语无法表达的全新算法,则回到 A 方案实现一个 BaseScenario 子类(或新增原语)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# 原语统一签名:(rows, params) -> PrimitiveResult
|
||||
PrimitiveFn = Callable[[list[dict], dict], "PrimitiveResult"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrimitiveResult:
|
||||
"""原语输出:是否命中、评分(0-1)、证据、主体、可读简述与可插值指标。"""
|
||||
|
||||
hit: bool
|
||||
score: float
|
||||
evidence: dict = field(default_factory=dict)
|
||||
subjects: dict = field(default_factory=dict)
|
||||
summary: str = "" # 供 rationale_template 的 {summary} 占位
|
||||
metrics: dict = field(default_factory=dict) # 供 rationale_template 的其它占位
|
||||
|
||||
|
||||
# 全局原语注册表:name -> 原语函数
|
||||
PRIMITIVE_REGISTRY: dict[str, PrimitiveFn] = {}
|
||||
|
||||
|
||||
def register_primitive(name: str) -> Callable[[PrimitiveFn], PrimitiveFn]:
|
||||
"""函数装饰器:把检测原语注册到全局表。"""
|
||||
|
||||
def deco(fn: PrimitiveFn) -> PrimitiveFn:
|
||||
PRIMITIVE_REGISTRY[name] = fn
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def get_primitive(name: str) -> PrimitiveFn | None:
|
||||
"""按名称查找已注册的检测原语。"""
|
||||
return PRIMITIVE_REGISTRY.get(name)
|
||||
|
||||
|
||||
def _to_float(value: object, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@register_primitive("threshold_edge")
|
||||
def threshold_edge(rows: list[dict], params: dict) -> PrimitiveResult:
|
||||
"""金额集中在审批阈值边缘 [edge_ratio*threshold, threshold) 的拆单型模式。
|
||||
|
||||
params:
|
||||
amount_field (str): 金额字段名
|
||||
threshold (float): 审批阈值(>0)
|
||||
edge_ratio (float): 边缘下界比例,默认 0.8
|
||||
min_count (int): 触发命中的最小记录数,默认 3
|
||||
key_field (str, 可选): 主体字段名(用于汇总涉及主体)
|
||||
"""
|
||||
amount_field = params["amount_field"]
|
||||
threshold = _to_float(params.get("threshold"))
|
||||
edge_ratio = _to_float(params.get("edge_ratio", 0.8), 0.8)
|
||||
min_count = int(params.get("min_count", 3))
|
||||
if threshold <= 0:
|
||||
raise ValueError("threshold 必须为正数")
|
||||
|
||||
lower = edge_ratio * threshold
|
||||
near = [r for r in rows if lower <= _to_float(r.get(amount_field)) < threshold]
|
||||
ratio = (len(near) / len(rows)) if rows else 0.0
|
||||
total = sum(_to_float(r.get(amount_field)) for r in near)
|
||||
hit = len(near) >= min_count
|
||||
|
||||
score = 0.0
|
||||
if hit:
|
||||
score = min(0.6, 0.1 * len(near)) + 0.2 * ratio
|
||||
score = round(min(score, 1.0), 3)
|
||||
|
||||
subjects: dict = {}
|
||||
key_field = params.get("key_field")
|
||||
if key_field:
|
||||
subjects = {
|
||||
"keys": sorted(
|
||||
{str(r.get(key_field)) for r in near if r.get(key_field) is not None}
|
||||
)
|
||||
}
|
||||
|
||||
return PrimitiveResult(
|
||||
hit=hit,
|
||||
score=score,
|
||||
evidence={
|
||||
"near_count": len(near),
|
||||
"edge_ratio": round(ratio, 3),
|
||||
"near_amount": total,
|
||||
"threshold": threshold,
|
||||
},
|
||||
subjects=subjects,
|
||||
summary=(
|
||||
f"检测到 {len(near)} 条记录金额集中在审批阈值 {threshold:.0f} 的边缘区间"
|
||||
f"(占比 {ratio:.0%}),合计约 {total:.0f}"
|
||||
),
|
||||
metrics={
|
||||
"near_count": len(near),
|
||||
"ratio": round(ratio, 3),
|
||||
"near_amount": total,
|
||||
"threshold": threshold,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@register_primitive("cliff_drop")
|
||||
def cliff_drop(rows: list[dict], params: dict) -> PrimitiveResult:
|
||||
"""时序断崖:按 index 排序后,相邻点 value 骤降超过 cliff_drop 即命中。
|
||||
|
||||
params:
|
||||
index_field (str): 序号/时间字段名
|
||||
value_field (str): 取值字段名(如留存率)
|
||||
cliff_drop (float): 触发断崖的最小跌幅,默认 0.5
|
||||
"""
|
||||
index_field = params["index_field"]
|
||||
value_field = params["value_field"]
|
||||
cliff = _to_float(params.get("cliff_drop", 0.5), 0.5)
|
||||
|
||||
ordered = sorted(rows, key=lambda r: _to_float(r.get(index_field)))
|
||||
max_drop = 0.0
|
||||
cliff_at: object = None
|
||||
for prev, cur in zip(ordered, ordered[1:], strict=False):
|
||||
drop = _to_float(prev.get(value_field)) - _to_float(cur.get(value_field))
|
||||
if drop > max_drop:
|
||||
max_drop = drop
|
||||
if drop >= cliff:
|
||||
cliff_at = cur.get(index_field)
|
||||
|
||||
hit = cliff_at is not None
|
||||
max_drop = round(max_drop, 3)
|
||||
score = round(min(0.4 + 0.5 * max_drop, 1.0), 3) if hit else round(0.3 * max_drop, 3)
|
||||
|
||||
return PrimitiveResult(
|
||||
hit=hit,
|
||||
score=score,
|
||||
evidence={"cliff_at": cliff_at, "max_drop": max_drop},
|
||||
summary=(
|
||||
f"在 {cliff_at} 处出现断崖式骤降(最大跌幅 {max_drop:.0%})"
|
||||
if hit
|
||||
else f"未见明显断崖(最大跌幅 {max_drop:.0%})"
|
||||
),
|
||||
metrics={"cliff_at": cliff_at, "max_drop": max_drop},
|
||||
)
|
||||
|
||||
|
||||
@register_primitive("ratio_threshold")
|
||||
def ratio_threshold(rows: list[dict], params: dict) -> PrimitiveResult:
|
||||
"""聚合比率/阈值判定:对某字段做聚合并与阈值比较,命中则给定分。
|
||||
|
||||
params:
|
||||
value_field (str): 取值字段名
|
||||
agg (str): 聚合方式 mean/sum/max/min/count,默认 mean
|
||||
op (str): 比较运算 >=,>,<=,<,默认 >=
|
||||
threshold (float): 比较阈值
|
||||
score (float): 命中评分,默认 0.8
|
||||
"""
|
||||
value_field = params["value_field"]
|
||||
agg = str(params.get("agg", "mean"))
|
||||
op = str(params.get("op", ">="))
|
||||
threshold = _to_float(params.get("threshold"))
|
||||
hit_score = _to_float(params.get("score", 0.8), 0.8)
|
||||
|
||||
values = [_to_float(r.get(value_field)) for r in rows]
|
||||
if not values and agg != "count":
|
||||
return PrimitiveResult(hit=False, score=0.0, summary="无数据,未触发")
|
||||
|
||||
if agg == "sum":
|
||||
actual = sum(values)
|
||||
elif agg == "max":
|
||||
actual = max(values)
|
||||
elif agg == "min":
|
||||
actual = min(values)
|
||||
elif agg == "count":
|
||||
actual = float(len(values))
|
||||
else: # mean
|
||||
actual = sum(values) / len(values)
|
||||
|
||||
comparators = {
|
||||
">=": lambda a, b: a >= b,
|
||||
">": lambda a, b: a > b,
|
||||
"<=": lambda a, b: a <= b,
|
||||
"<": lambda a, b: a < b,
|
||||
}
|
||||
cmp = comparators.get(op)
|
||||
if cmp is None:
|
||||
raise ValueError(f"不支持的比较运算: {op}")
|
||||
hit = cmp(actual, threshold)
|
||||
score = round(hit_score, 3) if hit else 0.0
|
||||
|
||||
return PrimitiveResult(
|
||||
hit=hit,
|
||||
score=score,
|
||||
evidence={"agg": agg, "actual": round(actual, 3), "op": op, "threshold": threshold},
|
||||
summary=(
|
||||
f"{value_field} 的{agg}为 {actual:.3g},{op} 阈值 {threshold:.3g}"
|
||||
if hit
|
||||
else f"{value_field} 的{agg}为 {actual:.3g},未达阈值 {threshold:.3g}"
|
||||
),
|
||||
metrics={"actual": round(actual, 3), "threshold": threshold},
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""场景注册表:按场景码索引,供调度器与 API 统一发现已注册场景。
|
||||
|
||||
仿 app.ingest.registry 的设计:类装饰器自动注册,新增场景零改动核心代码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.scenarios.base import BaseScenario
|
||||
|
||||
# 全局注册表:scenario_code -> Scenario 类
|
||||
SCENARIO_REGISTRY: dict[str, type[BaseScenario]] = {}
|
||||
|
||||
|
||||
def register_scenario(cls: type[BaseScenario]) -> type[BaseScenario]:
|
||||
"""类装饰器:将场景注册到全局表(按 code 索引)。"""
|
||||
if cls.code:
|
||||
SCENARIO_REGISTRY[cls.code] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def get_scenario(code: str) -> type[BaseScenario] | None:
|
||||
"""按场景码查找已注册的场景类。"""
|
||||
return SCENARIO_REGISTRY.get(code)
|
||||
|
||||
|
||||
def scenario_meta() -> list[dict]:
|
||||
"""导出全部已注册场景的元数据,供 /scenarios API 与前端动态渲染。"""
|
||||
return [
|
||||
{
|
||||
"code": cls.code,
|
||||
"title": cls.title,
|
||||
"label": cls.label,
|
||||
"risk_domain": cls.risk_domain,
|
||||
}
|
||||
for cls in SCENARIO_REGISTRY.values()
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# yaml-language-server: $schema=./scenario.schema.json
|
||||
# 场景 R10(纯配置示例)· 大额退款拆分规避审批
|
||||
# 演示「B 快捷通道」:新增本场景仅需此 YAML 文件,无需改动任何 Python 代码。
|
||||
# 系统启动时由 app.scenarios.config.load_yaml_scenarios 自动发现、校验并注册。
|
||||
|
||||
code: R10
|
||||
title: 疑似大额退款拆分规避审批
|
||||
risk_domain: 收入
|
||||
label: 退款拆分
|
||||
|
||||
# 复用通用检测原语:金额集中在审批阈值边缘
|
||||
detector: threshold_edge
|
||||
threshold: 0.0 # threshold_edge 命中(评分>0)即生成线索
|
||||
rule_version: rules@r10.v1
|
||||
|
||||
params:
|
||||
amount_field: refund_amount # 数据行中的金额字段
|
||||
threshold: 500000 # 退款审批阈值(50 万)
|
||||
edge_ratio: 0.8 # 边缘下界 = 0.8 * 阈值
|
||||
min_count: 3 # 至少 3 笔集中才判定命中
|
||||
key_field: customer # 主体字段,用于汇总涉及客户
|
||||
|
||||
rationale_template: "{summary};疑似将整笔大额退款拆分至审批阈值以下以规避审批,建议穿透核查关联客户。"
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "AIAudit 配置驱动场景",
|
||||
"description": "配置驱动审计场景(B 快捷通道)的 schema,对应 app.scenarios.config.ScenarioConfig;权威校验以后端 pydantic 为准。",
|
||||
"type": "object",
|
||||
"required": ["code", "title", "risk_domain", "label", "detector"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "场景码,全局唯一,如 R10"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "默认线索标题"
|
||||
},
|
||||
"risk_domain": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "风险域,如 收入/成本"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "前端展示名"
|
||||
},
|
||||
"detector": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "检测原语名(见 app/scenarios/primitives.py)",
|
||||
"examples": ["threshold_edge", "cliff_drop", "ratio_threshold"]
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": "传给检测原语的参数(键随原语而定)"
|
||||
},
|
||||
"threshold": {
|
||||
"type": "number",
|
||||
"description": "评分阈值:草稿 score >= 阈值才生成线索",
|
||||
"default": 0.5
|
||||
},
|
||||
"rationale_template": {
|
||||
"type": "string",
|
||||
"description": "线索理由模板,支持 {summary} 及原语 metrics 中的占位符"
|
||||
},
|
||||
"rule_version": {
|
||||
"type": ["string", "null"],
|
||||
"description": "规则版本,用于线索可追溯"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.scenarios.base import BaseScenario, ClueDraft, ScanOutcome
|
||||
from app.scenarios.registry import register_scenario
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContractRecord:
|
||||
@@ -76,3 +79,58 @@ def build_rationale(finding: SplitFinding, threshold: float, shared_controller:
|
||||
else:
|
||||
parts.append("建议进一步穿透客户关联关系以确认是否同一实控人。")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@register_scenario
|
||||
class SplitContractScenario(BaseScenario):
|
||||
"""场景一拆单检测的插件封装:检测→评分→产出线索草稿。
|
||||
|
||||
数据通过构造注入(兼容现有 seed/测试的数据流);后续可改为从 session 自取数,
|
||||
接口保持不变。
|
||||
"""
|
||||
|
||||
code = "R8"
|
||||
title = "疑似政企拆单规避审批"
|
||||
risk_domain = "收入"
|
||||
label = "政企拆单"
|
||||
score_threshold = 0.0 # 命中即出线索(与原 run_split_contract_scan 的 score>0 一致)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
contracts: list[ContractRecord],
|
||||
approval_threshold: float,
|
||||
shared_controller: bool = False,
|
||||
) -> None:
|
||||
self.contracts = contracts
|
||||
self.approval_threshold = approval_threshold
|
||||
self.shared_controller = shared_controller
|
||||
|
||||
def scan(self, session, *, data_version_id=None) -> ScanOutcome:
|
||||
finding = detect_threshold_edge(self.contracts, self.approval_threshold)
|
||||
score = split_risk_score(finding, self.shared_controller)
|
||||
drafts: list[ClueDraft] = []
|
||||
if score > 0:
|
||||
drafts.append(
|
||||
ClueDraft(
|
||||
score=score,
|
||||
rationale=build_rationale(
|
||||
finding, self.approval_threshold, self.shared_controller
|
||||
),
|
||||
evidence={
|
||||
"near_threshold_contracts": [
|
||||
c.contract_id for c in finding.near_threshold
|
||||
],
|
||||
"edge_ratio": finding.ratio,
|
||||
"near_threshold_amount": finding.total_amount,
|
||||
"approval_threshold": self.approval_threshold,
|
||||
"shared_controller": self.shared_controller,
|
||||
},
|
||||
subjects={
|
||||
"customers": sorted(
|
||||
{c.customer_key for c in finding.near_threshold}
|
||||
)
|
||||
},
|
||||
amount_involved=finding.total_amount,
|
||||
)
|
||||
)
|
||||
return ScanOutcome(scanned_count=len(self.contracts), drafts=drafts)
|
||||
|
||||
@@ -9,3 +9,4 @@ celery==5.4.0
|
||||
redis==5.2.1
|
||||
httpx==0.28.1
|
||||
python-dotenv==1.0.1
|
||||
pyyaml==6.0.3
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""通用场景调度器 run_scenarios 的落库集成测试(需 PostgreSQL)。
|
||||
|
||||
验证「场景插件 → 统一调度 → 阈值过滤 → 落库」链路,且与旧 run_xxx_scan 行为一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.clues.models import ClueStatus, ConfidenceTier
|
||||
from app.engines import scan
|
||||
from app.scenarios.churn_fraud import ChurnFraudScenario, CohortPoint
|
||||
from app.scenarios.split_contract import ContractRecord, SplitContractScenario
|
||||
|
||||
|
||||
def test_run_scenarios_creates_clues(session):
|
||||
contracts = [ContractRecord(f"C{i}", f"CUST{i}", 850000) for i in range(8)]
|
||||
curve = [CohortPoint(0, 1.0), CohortPoint(1, 0.95), CohortPoint(2, 0.1)]
|
||||
scenarios = [
|
||||
SplitContractScenario(
|
||||
contracts, approval_threshold=1_000_000, shared_controller=True
|
||||
),
|
||||
ChurnFraudScenario(
|
||||
retention_curve=curve,
|
||||
commission_paid=300000,
|
||||
active_ratio=0.05,
|
||||
zero_usage_ratio=0.9,
|
||||
channel_key="CH-001",
|
||||
),
|
||||
]
|
||||
results = scan.run_scenarios(session, scenarios)
|
||||
by_code = {r.scenario_code: r for r in results}
|
||||
|
||||
assert by_code["R8"].scanned_count == 8
|
||||
assert len(by_code["R8"].clues) == 1
|
||||
r8_clue = by_code["R8"].clues[0]
|
||||
assert r8_clue.confidence == ConfidenceTier.HIGH
|
||||
assert r8_clue.status == ClueStatus.NEW
|
||||
assert r8_clue.amount_involved > 0
|
||||
assert r8_clue.model_version == scan.MODEL_VERSION
|
||||
|
||||
assert by_code["R9"].scanned_count == 3
|
||||
assert len(by_code["R9"].clues) == 1
|
||||
assert by_code["R9"].clues[0].scenario_code == "R9"
|
||||
assert by_code["R9"].clues[0].subjects["channel"] == "CH-001"
|
||||
|
||||
|
||||
def test_run_scenarios_filters_below_threshold(session):
|
||||
# 干净数据:拆单不命中、养卡曲线平滑且佣金匹配 → 均不应生成线索
|
||||
contracts = [ContractRecord("C1", "A", 100000), ContractRecord("C2", "B", 3_000_000)]
|
||||
curve = [CohortPoint(0, 1.0), CohortPoint(1, 0.98), CohortPoint(2, 0.97)]
|
||||
scenarios = [
|
||||
SplitContractScenario(contracts, approval_threshold=1_000_000),
|
||||
ChurnFraudScenario(
|
||||
retention_curve=curve,
|
||||
commission_paid=10000,
|
||||
active_ratio=0.9,
|
||||
zero_usage_ratio=0.05,
|
||||
channel_key="CH-X",
|
||||
),
|
||||
]
|
||||
results = scan.run_scenarios(session, scenarios)
|
||||
for r in results:
|
||||
assert r.clues == []
|
||||
@@ -0,0 +1,100 @@
|
||||
"""配置驱动场景(B 快捷通道)测试:原语库、YAML 加载与校验、ConfigScenario 扫描。
|
||||
|
||||
纯逻辑,无需数据库。验证「新增 YAML → 自动注册 → 可扫描产出线索草稿」链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.scenarios.config import (
|
||||
ScenarioConfig,
|
||||
load_yaml_scenarios,
|
||||
)
|
||||
from app.scenarios.primitives import cliff_drop, ratio_threshold, threshold_edge
|
||||
from app.scenarios.registry import SCENARIO_REGISTRY, get_scenario
|
||||
|
||||
|
||||
# ---------- 原语库 ----------
|
||||
|
||||
def test_threshold_edge_primitive_hits():
|
||||
rows = [{"refund_amount": 450000, "customer": f"C{i}"} for i in range(5)]
|
||||
res = threshold_edge(
|
||||
rows,
|
||||
{"amount_field": "refund_amount", "threshold": 500000, "key_field": "customer"},
|
||||
)
|
||||
assert res.hit
|
||||
assert res.score > 0
|
||||
assert res.evidence["near_count"] == 5
|
||||
assert len(res.subjects["keys"]) == 5
|
||||
|
||||
|
||||
def test_threshold_edge_primitive_no_hit_when_spread():
|
||||
rows = [{"refund_amount": 100000}, {"refund_amount": 900000}]
|
||||
res = threshold_edge(rows, {"amount_field": "refund_amount", "threshold": 500000})
|
||||
assert not res.hit
|
||||
assert res.score == 0.0
|
||||
|
||||
|
||||
def test_threshold_edge_requires_positive_threshold():
|
||||
with pytest.raises(ValueError):
|
||||
threshold_edge([], {"amount_field": "x", "threshold": 0})
|
||||
|
||||
|
||||
def test_cliff_drop_primitive():
|
||||
rows = [{"m": 0, "v": 1.0}, {"m": 1, "v": 0.95}, {"m": 2, "v": 0.1}]
|
||||
res = cliff_drop(rows, {"index_field": "m", "value_field": "v"})
|
||||
assert res.hit
|
||||
assert res.evidence["cliff_at"] == 2
|
||||
|
||||
|
||||
def test_ratio_threshold_primitive():
|
||||
rows = [{"z": 0.9}, {"z": 0.95}]
|
||||
res = ratio_threshold(
|
||||
rows, {"value_field": "z", "agg": "mean", "op": ">=", "threshold": 0.8}
|
||||
)
|
||||
assert res.hit
|
||||
assert res.score == 0.8
|
||||
|
||||
|
||||
# ---------- YAML 配置校验 ----------
|
||||
|
||||
def test_scenario_config_validation_error():
|
||||
with pytest.raises(ValidationError):
|
||||
ScenarioConfig(code="R99") # 缺 title/risk_domain/label/detector
|
||||
|
||||
|
||||
def test_load_yaml_scenarios_registers_r10():
|
||||
load_yaml_scenarios() # 加载内置 rules/*.yaml(含 R10 示例)
|
||||
assert "R10" in SCENARIO_REGISTRY
|
||||
cls = get_scenario("R10")
|
||||
assert cls is not None
|
||||
assert cls.label == "退款拆分"
|
||||
assert cls.risk_domain == "收入"
|
||||
|
||||
|
||||
# ---------- ConfigScenario 扫描 ----------
|
||||
|
||||
def test_config_scenario_scan_produces_clue_draft():
|
||||
load_yaml_scenarios()
|
||||
cls = get_scenario("R10")
|
||||
rows = [{"refund_amount": 460000 + i * 5000, "customer": f"政企{i}"} for i in range(6)]
|
||||
outcome = cls(rows=rows).scan(None)
|
||||
assert outcome.scanned_count == 6
|
||||
assert len(outcome.drafts) == 1
|
||||
draft = outcome.drafts[0]
|
||||
assert draft.score > 0
|
||||
assert "退款" in draft.rationale # rationale_template 生效
|
||||
assert "keys" in draft.subjects
|
||||
|
||||
|
||||
def test_config_scenario_no_draft_when_clean():
|
||||
load_yaml_scenarios()
|
||||
cls = get_scenario("R10")
|
||||
rows = [
|
||||
{"refund_amount": 100000, "customer": "A"},
|
||||
{"refund_amount": 2_000_000, "customer": "B"},
|
||||
]
|
||||
outcome = cls(rows=rows).scan(None)
|
||||
assert outcome.drafts == []
|
||||
@@ -0,0 +1,74 @@
|
||||
"""场景插件框架测试:注册表、场景 scan() 产出、/scenarios API(纯逻辑,无需数据库)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.scenarios.base import ScanOutcome
|
||||
from app.scenarios.churn_fraud import ChurnFraudScenario, CohortPoint
|
||||
from app.scenarios.registry import SCENARIO_REGISTRY, get_scenario, scenario_meta
|
||||
from app.scenarios.split_contract import ContractRecord, SplitContractScenario
|
||||
|
||||
|
||||
def test_registry_contains_builtin_scenarios():
|
||||
# 导入 app.scenarios.* 已通过自动发现触发 @register_scenario 注册 R8/R9
|
||||
assert "R8" in SCENARIO_REGISTRY
|
||||
assert "R9" in SCENARIO_REGISTRY
|
||||
assert get_scenario("R8") is SplitContractScenario
|
||||
assert get_scenario("R9") is ChurnFraudScenario
|
||||
|
||||
|
||||
def test_scenario_meta_fields():
|
||||
meta = {m["code"]: m for m in scenario_meta()}
|
||||
assert meta["R8"]["label"] == "政企拆单"
|
||||
assert meta["R8"]["risk_domain"] == "收入"
|
||||
assert meta["R9"]["label"] == "养卡骗补"
|
||||
assert set(meta["R8"]) == {"code", "title", "label", "risk_domain"}
|
||||
|
||||
|
||||
def test_split_scenario_scan_hits():
|
||||
contracts = [ContractRecord(f"C{i}", f"CUST{i}", 850000) for i in range(8)]
|
||||
scenario = SplitContractScenario(
|
||||
contracts, approval_threshold=1_000_000, shared_controller=True
|
||||
)
|
||||
outcome = scenario.scan(None)
|
||||
assert isinstance(outcome, ScanOutcome)
|
||||
assert outcome.scanned_count == 8
|
||||
assert len(outcome.drafts) == 1
|
||||
draft = outcome.drafts[0]
|
||||
assert draft.score >= scenario.score_threshold
|
||||
assert draft.amount_involved > 0
|
||||
assert draft.evidence["shared_controller"] is True
|
||||
|
||||
|
||||
def test_split_scenario_no_draft_when_clean():
|
||||
contracts = [ContractRecord("C1", "A", 100000), ContractRecord("C2", "B", 3_000_000)]
|
||||
outcome = SplitContractScenario(contracts, approval_threshold=1_000_000).scan(None)
|
||||
assert outcome.drafts == []
|
||||
assert outcome.scanned_count == 2
|
||||
|
||||
|
||||
def test_churn_scenario_scan_produces_draft():
|
||||
curve = [CohortPoint(0, 1.0), CohortPoint(1, 0.95), CohortPoint(2, 0.1)]
|
||||
scenario = ChurnFraudScenario(
|
||||
retention_curve=curve,
|
||||
commission_paid=300000,
|
||||
active_ratio=0.05,
|
||||
zero_usage_ratio=0.9,
|
||||
channel_key="CH-001",
|
||||
)
|
||||
outcome = scenario.scan(None)
|
||||
assert outcome.scanned_count == 3
|
||||
assert len(outcome.drafts) == 1
|
||||
draft = outcome.drafts[0]
|
||||
assert draft.subjects["channel"] == "CH-001"
|
||||
assert draft.score >= scenario.score_threshold
|
||||
|
||||
|
||||
def test_scenarios_api_endpoint():
|
||||
client = TestClient(app)
|
||||
resp = client.get("/scenarios")
|
||||
assert resp.status_code == 200
|
||||
codes = {item["code"] for item in resp.json()}
|
||||
assert {"R8", "R9"} <= codes
|
||||
+15
-1
@@ -1,12 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Dashboard from "./Dashboard";
|
||||
import Clues from "./Clues";
|
||||
import NLQ from "./NLQ";
|
||||
import { api } from "./api";
|
||||
import { setScenarioLabels } from "./labels";
|
||||
|
||||
type Tab = "dashboard" | "clues" | "nlq";
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
const [, setLabelsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 启动时从后端拉取场景元数据,动态填充场景展示名(失败则回退到内置兜底)
|
||||
api
|
||||
.listScenarios()
|
||||
.then((items) => {
|
||||
setScenarioLabels(items);
|
||||
setLabelsReady(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface NLQResponse {
|
||||
egress: boolean;
|
||||
}
|
||||
|
||||
export interface ScenarioMeta {
|
||||
code: string;
|
||||
title: string;
|
||||
label: string;
|
||||
risk_domain: string;
|
||||
}
|
||||
|
||||
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const resp = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -65,4 +72,5 @@ export const api = {
|
||||
}),
|
||||
nlq: (question: string) =>
|
||||
http<NLQResponse>("/nlq", { method: "POST", body: JSON.stringify({ question }) }),
|
||||
listScenarios: () => http<ScenarioMeta[]>("/scenarios"),
|
||||
};
|
||||
|
||||
@@ -22,11 +22,19 @@ export const feedbackLabel: Record<string, string> = {
|
||||
false_positive: "误报",
|
||||
};
|
||||
|
||||
// 场景展示名:内置少量兜底,运行时由后端 /scenarios 填充(新增场景无需改前端)
|
||||
export const scenarioLabel: Record<string, string> = {
|
||||
R8: "政企拆单",
|
||||
R9: "养卡骗补",
|
||||
};
|
||||
|
||||
// 用后端返回的场景元数据就地填充展示名(组件仍读同一对象引用,无需改动)
|
||||
export function setScenarioLabels(items: { code: string; label: string }[]): void {
|
||||
for (const it of items) {
|
||||
if (it.code && it.label) scenarioLabel[it.code] = it.label;
|
||||
}
|
||||
}
|
||||
|
||||
export const providerLabel: Record<string, string> = {
|
||||
mock: "本地Mock",
|
||||
vllm: "本地vLLM",
|
||||
|
||||
Reference in New Issue
Block a user