feat: make chat model routing configurable

This commit is contained in:
2026-07-31 17:57:26 +08:00
parent 85a6da5949
commit ab2c945f0b
22 changed files with 567 additions and 88 deletions

View File

@@ -148,6 +148,7 @@ const modelForm = reactive({
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowSimpleKnowledge: 1,
allowDeepChat: 1,
});
@@ -311,6 +312,7 @@ function modelSceneLabel(scene: string) {
background_report: "周期报告",
background_summary: "摘要沉淀",
fixed_info: "固定信息问答",
simple_knowledge: "简单知识问答",
knowledge_grounded: "知识问答",
general_chat: "通用对话",
} as Record<string, string>)[scene] || scene || "未分类";
@@ -707,6 +709,7 @@ async function quickAddModel() {
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowSimpleKnowledge: 1,
allowDeepChat: 1,
});
ElMessage.success(`${provider.label} 模型已新增`);
@@ -814,6 +817,7 @@ function editModel(row: ModelItem) {
allowSummary: row.allowSummary ?? 1,
allowReport: row.allowReport ?? 1,
allowFixedInfo: row.allowFixedInfo ?? 1,
allowSimpleKnowledge: row.allowSimpleKnowledge ?? 1,
allowDeepChat: row.allowDeepChat ?? 1,
});
}
@@ -849,6 +853,7 @@ function resetModelForm() {
allowSummary: 1,
allowReport: 1,
allowFixedInfo: 1,
allowSimpleKnowledge: 1,
allowDeepChat: 1,
});
}
@@ -905,7 +910,7 @@ function normalizeSystemSettingValue(setting: (typeof systemSettingDefinitions)[
if (setting.type === "switch") {
return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase());
}
if (setting.type === "text" || setting.type === "password") {
if (setting.type === "text" || setting.type === "password" || setting.type === "select") {
return rawValue;
}
const parsed = Number(rawValue);
@@ -1463,6 +1468,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div>
<div class="entitlement-capabilities">
<el-checkbox v-model="modelForm.allowFixedInfo" :true-value="1" :false-value="0">固定信息</el-checkbox>
<el-checkbox v-model="modelForm.allowSimpleKnowledge" :true-value="1" :false-value="0">简单知识</el-checkbox>
<el-checkbox v-model="modelForm.allowDeepChat" :true-value="1" :false-value="0">深度对话</el-checkbox>
<el-checkbox v-model="modelForm.allowSummary" :true-value="1" :false-value="0">摘要沉淀</el-checkbox>
<el-checkbox v-model="modelForm.allowReport" :true-value="1" :false-value="0">周期报告</el-checkbox>
@@ -1491,6 +1497,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<div class="model-capability-tags">
<el-tag v-if="row.allowDeepChat === 1" size="small">深度对话</el-tag>
<el-tag v-if="row.allowFixedInfo === 1" size="small">固定信息</el-tag>
<el-tag v-if="row.allowSimpleKnowledge === 1" size="small">简单知识</el-tag>
<el-tag v-if="row.allowSummary === 1" size="small" type="success">摘要</el-tag>
<el-tag v-if="row.allowReport === 1" size="small" type="warning">报告</el-tag>
</div>
@@ -1544,6 +1551,18 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
type="password"
show-password
/>
<el-select
v-else-if="setting.type === 'select'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
>
<el-option
v-for="option in setting.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-switch v-else v-model="systemSettingValues[setting.key]" />
<small>{{ setting.description }}</small>
</label>

View File

@@ -1,5 +1,10 @@
export type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text" | "password";
type SystemSettingType = "number" | "switch" | "text" | "password" | "select";
interface SystemSettingOption {
label: string;
value: string;
}
export interface SystemSettingDefinition {
key: string;
@@ -9,6 +14,7 @@ export interface SystemSettingDefinition {
min?: number;
max?: number;
placeholder?: string;
options?: SystemSettingOption[];
description: string;
}
@@ -160,11 +166,16 @@ export const systemSettingSections: SystemSettingSection[] = [
description: "后台始终记录引用;此项控制用户端是否展示。",
},
{
key: "fixed_info_model_routing_enabled",
label: "固定信息模型分流",
type: "switch",
defaultValue: true,
description: "仅当本轮实际召回内容全部来自固定信息类知识库时使用对应低成本模型;关闭后统一使用默认主模型。",
key: "chat_model_routing_mode",
label: "正式问答模型分流规则",
type: "select",
defaultValue: "fixed_only",
options: [
{ label: "关闭分流(全部使用主模型)", value: "off" },
{ label: "仅固定信息(推荐)", value: "fixed_only" },
{ label: "保守分流(固定信息 + 简单知识)", value: "conservative" },
],
description: "“简单知识”只覆盖短问题、明确知识问法且仅召回课程/问答/通用知识;涉及个人情况、情绪、关系、建议或混合固定信息时仍使用主模型。",
},
{
key: "chat_max_active_requests",

View File

@@ -1741,18 +1741,19 @@ textarea {
gap: 7px;
}
.setting-field span {
.setting-field > span {
color: #223631;
font-weight: 600;
}
.setting-field small {
.setting-field > small {
color: #667a73;
line-height: 1.5;
}
.setting-field .el-input-number,
.setting-field .el-input {
.setting-field .el-input,
.setting-field .el-select {
width: 100%;
}

View File

@@ -213,6 +213,7 @@ export interface ModelItem {
allowSummary: number;
allowReport: number;
allowFixedInfo: number;
allowSimpleKnowledge: number;
allowDeepChat: number;
}

View File

@@ -25,7 +25,9 @@ def upgrade() -> None:
sa.Column("period_start", sa.DateTime(), nullable=False),
sa.Column("period_end", sa.DateTime(), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
# MySQL 8.4 rejects literal defaults on TEXT columns. The ORM already
# supplies an empty string while a report job is pending.
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source_summary_ids", sa.Text(), nullable=True),
sa.Column("source_topic_ids", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True),

View File

@@ -1,83 +1,79 @@
"""persist cleaned question insights
Revision ID: 0020_question_insight_persistence
Revision ID: 0020_question_insight_store
Revises: 0019_periodic_reports
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
from alembic import context, op
import sqlalchemy as sa
revision = "0020_question_insight_persistence"
revision = "0020_question_insight_store"
down_revision = "0019_periodic_reports"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_question_insight_cleaned_question",
sa.Column(
"id",
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
primary_key=True,
autoincrement=True,
),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("part_index", sa.Integer(), nullable=False),
sa.Column("cleaner_version", sa.String(length=20), nullable=False),
sa.Column("source_hash", sa.String(length=64), nullable=False),
sa.Column("cleaned_text", sa.Text(), nullable=False),
sa.Column("normalized_text", sa.Text(), nullable=False),
sa.Column("category", sa.String(length=50), nullable=False, server_default="other"),
sa.Column("tokens_json", sa.Text(), nullable=False),
sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"),
sa.Column("filtered_reason", sa.String(length=100), nullable=True),
sa.Column("source_created_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
["message_id"],
["sys_chat_message.id"],
ondelete="CASCADE",
),
sa.UniqueConstraint(
"message_id",
"cleaner_version",
"part_index",
name="uq_question_insight_message_version_part",
),
)
op.create_index(
"ix_sys_question_insight_cleaned_question_session_id",
"sys_question_insight_cleaned_question",
["session_id"],
)
op.create_index(
"ix_sys_question_insight_cleaned_question_user_id",
"sys_question_insight_cleaned_question",
["user_id"],
)
op.create_index(
"ix_question_insight_version_accepted_created",
"sys_question_insight_cleaned_question",
["cleaner_version", "accepted", "source_created_at"],
)
op.create_index(
"ix_question_insight_category_created",
"sys_question_insight_cleaned_question",
["category", "source_created_at"],
)
op.create_index(
"ix_question_insight_session_created",
"sys_question_insight_cleaned_question",
["session_id", "source_created_at"],
)
table_name = "sys_question_insight_cleaned_question"
table_exists = False
existing_indexes: set[str] = set()
if not context.is_offline_mode():
inspector = sa.inspect(op.get_bind())
table_exists = inspector.has_table(table_name)
if table_exists:
existing_indexes = {item["name"] for item in inspector.get_indexes(table_name)}
if not table_exists:
op.create_table(
table_name,
sa.Column(
"id",
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
primary_key=True,
autoincrement=True,
),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("part_index", sa.Integer(), nullable=False),
sa.Column("cleaner_version", sa.String(length=20), nullable=False),
sa.Column("source_hash", sa.String(length=64), nullable=False),
sa.Column("cleaned_text", sa.Text(), nullable=False),
sa.Column("normalized_text", sa.Text(), nullable=False),
sa.Column("category", sa.String(length=50), nullable=False, server_default="other"),
sa.Column("tokens_json", sa.Text(), nullable=False),
sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"),
sa.Column("filtered_reason", sa.String(length=100), nullable=True),
sa.Column("source_created_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
["message_id"],
["sys_chat_message.id"],
ondelete="CASCADE",
),
sa.UniqueConstraint(
"message_id",
"cleaner_version",
"part_index",
name="uq_question_insight_message_version_part",
),
)
indexes = {
"ix_sys_question_insight_cleaned_question_session_id": ["session_id"],
"ix_sys_question_insight_cleaned_question_user_id": ["user_id"],
"ix_question_insight_version_accepted_created": ["cleaner_version", "accepted", "source_created_at"],
"ix_question_insight_category_created": ["category", "source_created_at"],
"ix_question_insight_session_created": ["session_id", "source_created_at"],
}
for index_name, columns in indexes.items():
if index_name not in existing_indexes:
op.create_index(index_name, table_name, columns)
def downgrade() -> None:

View File

@@ -1,7 +1,7 @@
"""add durable periodic report job fields
Revision ID: 0021_periodic_report_async_jobs
Revises: 0020_question_insight_persistence
Revises: 0020_question_insight_store
Create Date: 2026-07-31 00:00:00.000000
"""
@@ -12,7 +12,7 @@ import sqlalchemy as sa
revision = "0021_periodic_report_async_jobs"
down_revision = "0020_question_insight_persistence"
down_revision = "0020_question_insight_store"
branch_labels = None
depends_on = None

View File

@@ -0,0 +1,71 @@
"""add simple knowledge model capability
Revision ID: 0023_simple_knowledge_route
Revises: 0022_model_routing
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_simple_knowledge_route"
down_revision = "0022_model_routing"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Existing models retain the capability so deployment behavior stays on the
# current default model until an administrator explicitly reassigns it.
op.add_column(
"sys_model",
sa.Column("allow_simple_knowledge", sa.Integer(), nullable=False, server_default="1"),
)
# Materialize the new three-state setting so the admin UI and runtime show
# the same value even when the legacy emergency switch had been disabled.
op.execute(
"""
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
SELECT
'chat_model_routing_mode',
CASE
WHEN EXISTS (
SELECT 1
FROM sys_system_config
WHERE config_key = 'fixed_info_model_routing_enabled'
AND LOWER(TRIM(config_value)) NOT IN ('1', 'true', 'yes', 'on', '启用')
) THEN 'off'
ELSE 'fixed_only'
END,
'正式问答模型分流规则off / fixed_only / conservative'
"""
)
def downgrade() -> None:
# Preserve the closest legacy behavior before removing the new setting.
op.execute(
"""
UPDATE sys_system_config AS legacy
JOIN sys_system_config AS current_mode
ON current_mode.config_key = 'chat_model_routing_mode'
SET legacy.config_value = CASE
WHEN current_mode.config_value = 'off' THEN 'false'
ELSE 'true'
END
WHERE legacy.config_key = 'fixed_info_model_routing_enabled'
"""
)
op.execute(
"""
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
SELECT
'fixed_info_model_routing_enabled',
CASE WHEN config_value = 'off' THEN 'false' ELSE 'true' END,
'固定信息模型分流'
FROM sys_system_config
WHERE config_key = 'chat_model_routing_mode'
"""
)
op.execute("DELETE FROM sys_system_config WHERE config_key = 'chat_model_routing_mode'")
op.drop_column("sys_model", "allow_simple_knowledge")

View File

@@ -294,6 +294,7 @@ def create_model(
allow_summary=payload.allowSummary,
allow_report=payload.allowReport,
allow_fixed_info=payload.allowFixedInfo,
allow_simple_knowledge=payload.allowSimpleKnowledge,
allow_deep_chat=payload.allowDeepChat,
enabled=0,
is_default=0,
@@ -346,6 +347,7 @@ def update_model(
model.allow_summary = payload.allowSummary
model.allow_report = payload.allowReport
model.allow_fixed_info = payload.allowFixedInfo
model.allow_simple_knowledge = payload.allowSimpleKnowledge
model.allow_deep_chat = payload.allowDeepChat
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
@@ -502,6 +504,7 @@ def _model_dict(model: ModelConfig) -> dict:
"allowSummary": model.allow_summary,
"allowReport": model.allow_report,
"allowFixedInfo": model.allow_fixed_info,
"allowSimpleKnowledge": model.allow_simple_knowledge,
"allowDeepChat": model.allow_deep_chat,
}

View File

@@ -57,6 +57,7 @@ class ModelConfig(Base):
allow_summary: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_report: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_fixed_info: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_simple_knowledge: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
allow_deep_chat: Mapped[int] = mapped_column(Integer, default=1, nullable=False)

View File

@@ -182,6 +182,7 @@ class ModelSaveRequest(BaseModel):
allowSummary: int = Field(default=1, ge=0, le=1)
allowReport: int = Field(default=1, ge=0, le=1)
allowFixedInfo: int = Field(default=1, ge=0, le=1)
allowSimpleKnowledge: int = Field(default=1, ge=0, le=1)
allowDeepChat: int = Field(default=1, ge=0, le=1)

View File

@@ -136,6 +136,7 @@ class AgentDebugService:
chat_route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks],
rag_result.question,
)
automatic_route = default_model is not None and requested_model.id == default_model.id
if automatic_route:

View File

@@ -146,6 +146,7 @@ class ChatService:
failed_route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [],
rag_result.question if rag_result is not None else normalized_question,
)
AiRequestLogService.write_failed(
db,

View File

@@ -441,6 +441,7 @@ def _model_log_context(
route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks] if rag_result is not None else [],
rag_result.question if rag_result is not None else "",
)
return (
route.reason,

View File

@@ -1,19 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from typing import Literal, cast
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.models.ai_config import ModelConfig, SystemConfig
ModelScenario = Literal["report", "summary", "fixed_info", "deep_chat"]
ModelScenario = Literal["report", "summary", "fixed_info", "simple_knowledge", "deep_chat"]
ChatRoutingMode = Literal["off", "fixed_only", "conservative"]
SCENARIO_LABELS: dict[ModelScenario, str] = {
"report": "周期报告",
"summary": "摘要沉淀",
"fixed_info": "固定信息",
"simple_knowledge": "简单知识问答",
"deep_chat": "深度对话",
}
@@ -21,9 +23,59 @@ _SCENARIO_FIELDS = {
"report": ModelConfig.allow_report,
"summary": ModelConfig.allow_summary,
"fixed_info": ModelConfig.allow_fixed_info,
"simple_knowledge": ModelConfig.allow_simple_knowledge,
"deep_chat": ModelConfig.allow_deep_chat,
}
_SIMPLE_KNOWLEDGE_TYPES = {"course", "qa", "general"}
_SIMPLE_QUERY_MARKERS = (
"是什么",
"有哪些",
"有什么",
"包括什么",
"定义",
"含义",
"区别",
"时间",
"几点",
"在哪里",
"多少",
"多久",
"步骤",
"怎么操作",
"怎么练",
"注意事项",
"作业",
"功课",
)
_DEEP_QUERY_MARKERS = (
"",
"自己",
"感觉",
"感受",
"情绪",
"身体",
"害怕",
"担心",
"焦虑",
"难受",
"痛苦",
"卡住",
"怎么办",
"为什么",
"关系",
"孩子",
"父母",
"伴侣",
"创伤",
"建议",
"分析",
"帮我",
"适合我",
"对不对",
"确认一下",
)
@dataclass(frozen=True)
class ModelRoute:
@@ -90,14 +142,20 @@ class ModelRoutingService:
)
@classmethod
def resolve_chat(cls, db: Session, knowledge_types: list[str]) -> ModelRoute:
def resolve_chat(
cls,
db: Session,
knowledge_types: list[str],
question: str = "",
) -> ModelRoute:
normalized_types = {item.strip().lower() for item in knowledge_types if item and item.strip()}
mode = cls.chat_routing_mode(db)
if normalized_types == {"fixed"}:
if not cls._config_bool(db, "fixed_info_model_routing_enabled", True):
if mode == "off":
return ModelRoute(
model=cls.default_model(db),
scenario="deep_chat",
reason="正式聊天:仅召回固定信息类知识库,但固定信息模型分流开关已关闭;使用默认主模型",
reason="正式聊天:仅召回固定信息类知识库,但正式问答模型分流开关已关闭;使用默认主模型",
fallback_used=False,
question_type="fixed_info",
)
@@ -110,6 +168,16 @@ class ModelRoutingService:
question_type="fixed_info",
)
if mode == "conservative" and cls._is_simple_knowledge_query(question, normalized_types):
route = cls.resolve(db, "simple_knowledge")
return ModelRoute(
model=route.model,
scenario=route.scenario,
reason=f"正式聊天:命中知识库且符合保守简单知识规则;{route.reason}",
fallback_used=route.fallback_used,
question_type="simple_knowledge",
)
default = cls.default_model(db)
if not normalized_types:
reason = "正式聊天:未召回知识库;使用默认主模型"
@@ -128,9 +196,35 @@ class ModelRoutingService:
question_type=question_type,
)
@classmethod
def chat_routing_mode(cls, db: Session) -> ChatRoutingMode:
value = cls._config_value(db, "chat_model_routing_mode")
if value in {"off", "fixed_only", "conservative"}:
return cast(ChatRoutingMode, value)
# Compatibility with the previous emergency switch.
return "fixed_only" if cls._config_bool(db, "fixed_info_model_routing_enabled", True) else "off"
@staticmethod
def _is_simple_knowledge_query(question: str, knowledge_types: set[str]) -> bool:
normalized_question = "".join(question.split()).lower()
if not normalized_question or len(normalized_question) > 80:
return False
if not knowledge_types or not knowledge_types.issubset(_SIMPLE_KNOWLEDGE_TYPES):
return False
if any(marker in normalized_question for marker in _DEEP_QUERY_MARKERS):
return False
return any(marker in normalized_question for marker in _SIMPLE_QUERY_MARKERS)
@staticmethod
def _config_bool(db: Session, key: str, default: bool) -> bool:
value = ModelRoutingService._config_value(db, key)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on", "启用"}
@staticmethod
def _config_value(db: Session, key: str) -> str | None:
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
if config is None or not config.config_value.strip():
return default
return config.config_value.strip().lower() in {"1", "true", "yes", "on", "启用"}
return None
return config.config_value.strip()

View File

@@ -35,6 +35,7 @@ class ModelClientService:
route = ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks],
rag_result.question,
)
model = route.model
mock_model_enabled = _system_config_bool(db, "mock_model_enabled", get_settings().mock_model_enabled)

View File

@@ -154,6 +154,7 @@ def _chat_route(db: Session, rag_result: RagResult) -> ModelRoute:
return ModelRoutingService.resolve_chat(
db,
[chunk.knowledge_type for chunk in rag_result.chunks],
rag_result.question,
)

View File

@@ -232,6 +232,92 @@ def test_agent_preview_uses_same_fixed_info_route_when_default_model_is_selected
assert complete["questionType"] == "fixed_info"
assert any(item.get("tool") == "model_route" for item in complete["retrievalTrace"])
def test_agent_preview_uses_conservative_simple_knowledge_route(monkeypatch):
async def answer_chunks():
yield "原生课程包含静水流深等练习。"
async def build_result(_db, _payload):
return RagResult(
question="原生里的作业都有哪些",
knowledge_scopes=[],
chunks=[
RetrievedChunk(
knowledge_id=1,
knowledge_name="原生课程",
title="课程作业",
content="静水流深静心。",
knowledge_type="course",
)
],
prompt="原生里的作业都有哪些",
tool_trace=[],
)
captured = {}
def debug_stream(model, _rag_result, _overrides, **kwargs):
captured["model"] = model.model_name
captured["questionType"] = kwargs["question_type"]
return AsyncStreamingModelResponse(
model_id=model.id,
model_name=model.model_name,
input_token=10,
chunks=answer_chunks(),
route_reason=kwargs["route_reason"],
question_type=kwargs["question_type"],
)
with _database() as db:
admin = _admin()
main = _model()
main.is_default = 1
main.allow_simple_knowledge = 0
simple = ModelConfig(
id=2,
provider="simple",
display_name="简单知识模型",
api_type="openai_compatible",
model_name="simple-model",
base_url="https://example.com/v1",
api_url="",
api_key="encrypted",
auth_type="bearer",
max_token=8192,
stream_enabled=1,
timeout_second=30,
enabled=1,
is_default=0,
allow_simple_knowledge=1,
)
db.add_all(
[
admin,
main,
simple,
SystemConfig(config_key="chat_model_routing_mode", config_value="conservative"),
]
)
db.commit()
monkeypatch.setattr(AgentDebugService, "build_result", build_result)
monkeypatch.setattr(ModelStreamService, "debug_stream_async", debug_stream)
payload = AgentDebugRequest(
promptContent="你是测试助手",
modelId=main.id,
question="原生里的作业都有哪些",
)
async def collect_events():
return [item async for item in AgentDebugService.stream(payload, db, admin)]
events = asyncio.run(collect_events())
complete = next(item for item in events if item["type"] == "complete")
assert captured == {"model": "simple-model", "questionType": "simple_knowledge"}
assert complete["modelName"] == "simple-model"
assert complete["questionType"] == "simple_knowledge"
def test_debug_stream_setting_overrides_model_without_changing_it():
model = _model()
debug_model = _copy_model_with_overrides(model, {"stream_enabled": 0})

View File

@@ -41,6 +41,7 @@ def _model(
allow_report: int,
allow_summary: int,
allow_fixed_info: int = 1,
allow_simple_knowledge: int = 1,
) -> ModelConfig:
return ModelConfig(
id=model_id,
@@ -54,6 +55,7 @@ def _model(
allow_report=allow_report,
allow_summary=allow_summary,
allow_fixed_info=allow_fixed_info,
allow_simple_knowledge=allow_simple_knowledge,
allow_deep_chat=1,
input_price_per_1k=Decimal("0.002"),
output_price_per_1k=Decimal("0.006"),
@@ -132,6 +134,171 @@ def test_fixed_info_chat_routes_only_when_all_recalled_knowledge_is_fixed():
assert "分流开关已关闭" in disabled_route.reason
def test_simple_knowledge_routing_defaults_to_fixed_only_for_safe_rollout():
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_simple_knowledge=0,
)
simple = _model(
2,
"simple-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_simple_knowledge=1,
)
db.add_all([main, simple])
db.commit()
route = ModelRoutingService.resolve_chat(db, ["course"], "原生里的作业都有哪些")
assert ModelRoutingService.chat_routing_mode(db) == "fixed_only"
assert route.model is main
assert route.question_type == "knowledge_grounded"
def test_conservative_mode_routes_only_clear_simple_knowledge_questions():
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_fixed_info=0,
allow_simple_knowledge=0,
)
simple = _model(
2,
"simple-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_fixed_info=0,
allow_simple_knowledge=1,
)
db.add_all(
[
main,
simple,
SystemConfig(config_key="chat_model_routing_mode", config_value="conservative"),
]
)
db.commit()
simple_route = ModelRoutingService.resolve_chat(db, ["course", "qa"], "原生里的作业都有哪些")
personal_route = ModelRoutingService.resolve_chat(
db,
["course"],
"我做阴影人格练习时身体很难受怎么办",
)
mixed_route = ModelRoutingService.resolve_chat(db, ["fixed", "course"], "课程作业有哪些")
assert simple_route.model is simple
assert simple_route.question_type == "simple_knowledge"
assert "保守简单知识规则" in simple_route.reason
assert personal_route.model is main
assert personal_route.question_type == "knowledge_grounded"
assert mixed_route.model is main
assert mixed_route.question_type == "knowledge_grounded"
def test_chat_routing_off_keeps_fixed_information_on_main_model():
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_fixed_info=0,
)
fixed = _model(
2,
"fixed-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_fixed_info=1,
)
db.add_all(
[
main,
fixed,
SystemConfig(config_key="chat_model_routing_mode", config_value="off"),
]
)
db.commit()
route = ModelRoutingService.resolve_chat(db, ["fixed"], "本周上课时间是什么")
assert route.model is main
assert route.question_type == "fixed_info"
assert "分流开关已关闭" in route.reason
def test_user_stream_uses_conservative_simple_knowledge_route(monkeypatch):
async def collect(response):
return [chunk async for chunk in response.chunks]
with _db() as db:
main = _model(
1,
"main-model",
is_default=1,
allow_report=1,
allow_summary=1,
allow_simple_knowledge=0,
)
simple = _model(
2,
"simple-model",
is_default=0,
allow_report=0,
allow_summary=0,
allow_simple_knowledge=1,
)
db.add_all(
[
main,
simple,
SystemConfig(config_key="chat_model_routing_mode", config_value="conservative"),
SystemConfig(config_key="mock_model_enabled", config_value="false"),
]
)
db.commit()
async def stream(model, _rag_result):
yield f"{model.model_name}回答"
rag_result = RagResult(
question="原生里的作业都有哪些",
knowledge_scopes=[],
chunks=[
RetrievedChunk(
knowledge_id=1,
knowledge_name="原生课程",
title="课程作业",
content="静水流深静心。",
knowledge_type="course",
)
],
prompt="原生里的作业都有哪些",
)
monkeypatch.setattr("app.services.model_stream_service._stream_configured_model_async", stream)
response = ModelStreamService.stream_async(db, rag_result)
chunks = asyncio.run(collect(response))
assert chunks == ["由simple-model回答"]
assert response.model_id == simple.id
assert response.question_type == "simple_knowledge"
def test_fixed_info_non_stream_call_falls_back_to_main_model_on_provider_failure(monkeypatch):
with _db() as db:
main = _model(1, "main-model", is_default=1, allow_report=1, allow_summary=1, allow_fixed_info=0)

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from datetime import date
import asyncio
import json
from pathlib import Path
import re
import time
import pytest
@@ -41,6 +43,19 @@ def test_secret_is_encrypted_and_masked():
assert SecretService.masked(encrypted) == MASKED_SECRET
def test_migration_revision_ids_fit_default_alembic_version_column():
versions_dir = Path(__file__).parents[1] / "alembic" / "versions"
revision_pattern = re.compile(r'^revision\s*=\s*["\']([^"\']+)', re.MULTILINE)
revision_ids = []
for migration in versions_dir.glob("*.py"):
match = revision_pattern.search(migration.read_text(encoding="utf-8"))
if match:
revision_ids.append((migration.name, match.group(1)))
too_long = [(filename, revision) for filename, revision in revision_ids if len(revision) > 32]
assert not too_long, f"Alembic revision ID 超过默认 VARCHAR(32): {too_long}"
def test_rate_limit_blocks_after_limit():
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")
SecurityStateService.enforce_limit("test:rate", limit=2, window_seconds=60, message="too many")