From ab2c945f0b5b1629113b6a6da372c649a766d722 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 31 Jul 2026 17:57:26 +0800 Subject: [PATCH] feat: make chat model routing configurable --- .../apps/admin-web/src/App.vue | 21 ++- .../admin-web/src/config/systemSettings.ts | 23 ++- .../apps/admin-web/src/styles.css | 7 +- .../apps/admin-web/src/types/api.ts | 1 + .../alembic/versions/0019_periodic_reports.py | 4 +- .../0020_question_insight_persistence.py | 122 +++++++------ .../0021_periodic_report_async_jobs.py | 4 +- .../0023_simple_knowledge_model_routing.py | 71 ++++++++ .../apps/backend/app/api/admin_settings.py | 3 + .../apps/backend/app/models/ai_config.py | 1 + .../apps/backend/app/schemas/admin.py | 1 + .../app/services/agent_debug_service.py | 1 + .../apps/backend/app/services/chat_service.py | 1 + .../app/services/chat_stream_service.py | 1 + .../app/services/model_routing_service.py | 108 ++++++++++- .../backend/app/services/model_service.py | 1 + .../app/services/model_stream_service.py | 1 + .../tests/test_agent_runtime_config.py | 86 +++++++++ .../apps/backend/tests/test_model_routing.py | 167 ++++++++++++++++++ .../tests/test_production_readiness.py | 15 ++ .../deployment/DEPLOYMENT_GUIDE.md | 14 +- .../docs/qianwen_product_todo.md | 2 +- 22 files changed, 567 insertions(+), 88 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0023_simple_knowledge_model_routing.py diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 64b132f..0c5f1f8 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -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)[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") {
固定信息 + 简单知识 深度对话 摘要沉淀 周期报告 @@ -1491,6 +1497,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
深度对话 固定信息 + 简单知识 摘要 报告
@@ -1544,6 +1551,18 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { type="password" show-password /> + + + {{ setting.description }} diff --git a/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts b/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts index f66b6fc..5a92434 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts @@ -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", diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index 4692aa3..8d58ec3 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -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%; } diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index b610960..8e1ccc5 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -213,6 +213,7 @@ export interface ModelItem { allowSummary: number; allowReport: number; allowFixedInfo: number; + allowSimpleKnowledge: number; allowDeepChat: number; } diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0019_periodic_reports.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0019_periodic_reports.py index 5023dd5..e294b57 100644 --- a/ai_knowledge_base_v2/apps/backend/alembic/versions/0019_periodic_reports.py +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0019_periodic_reports.py @@ -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), diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py index 623c420..59f5f57 100644 --- a/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py @@ -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: diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0021_periodic_report_async_jobs.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0021_periodic_report_async_jobs.py index 4e7f781..85454c1 100644 --- a/ai_knowledge_base_v2/apps/backend/alembic/versions/0021_periodic_report_async_jobs.py +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0021_periodic_report_async_jobs.py @@ -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 diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0023_simple_knowledge_model_routing.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0023_simple_knowledge_model_routing.py new file mode 100644 index 0000000..5c1246f --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0023_simple_knowledge_model_routing.py @@ -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") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py index 558fe40..d39bbd9 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py @@ -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, } diff --git a/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py b/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py index 66479e7..bae79d8 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/ai_config.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py index 76b9863..81001bb 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py index c73cd04..10f1ebc 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/agent_debug_service.py @@ -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: diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py index ac0a8ae..e6e5261 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py @@ -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, diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py index e2a912f..73fdea5 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py @@ -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, diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_routing_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_routing_service.py index 7703443..45e4efe 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_routing_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_routing_service.py @@ -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() diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py index b6c9fa5..b1970bd 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py index 30707b7..c1f37e1 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_stream_service.py @@ -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, ) diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py b/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py index ca2f032..f01eaea 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_agent_runtime_config.py @@ -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}) diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_model_routing.py b/ai_knowledge_base_v2/apps/backend/tests/test_model_routing.py index 2d4b3b7..0e4f3c4 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_model_routing.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_model_routing.py @@ -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) diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py index 2def2b3..3b4bbc9 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py @@ -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") diff --git a/ai_knowledge_base_v2/deployment/DEPLOYMENT_GUIDE.md b/ai_knowledge_base_v2/deployment/DEPLOYMENT_GUIDE.md index 7e9e910..9af4263 100644 --- a/ai_knowledge_base_v2/deployment/DEPLOYMENT_GUIDE.md +++ b/ai_knowledge_base_v2/deployment/DEPLOYMENT_GUIDE.md @@ -168,21 +168,27 @@ PERIODIC_REPORT_MAX_ATTEMPTS=3 模型管理现在区分两个概念: - 可用模型:允许后台任务选择,可同时启用多个; -- 默认主模型:只能有一个,正式用户聊天、追问改写和检索重排始终使用它。 +- 默认主模型:只能有一个,承担未分流的正式聊天、追问改写和检索重排。 周期报告会选择“周期报告”能力已开启的可用模型,主题摘要和成长档案会选择“摘要沉淀”能力已开启的可用模型。若找不到匹配模型,会自动回退默认主模型,不会因为分流配置缺失直接中断任务。 -固定信息问答采用保守分流:只有本轮最终召回并采用的知识全部属于固定信息类时,才选择“固定信息”能力模型;混合召回、未命中和其他知识问答仍使用默认主模型。场景模型在尚未输出任何内容前调用失败,会自动重试默认主模型;已经输出部分内容后不会重新生成,避免重复内容。 +正式问答的分流规则在“系统配置 / AI 问答 / 正式问答模型分流规则”中配置: + +- 关闭分流:全部正式问答使用默认主模型; +- 仅固定信息(升级后的默认值):只有本轮最终召回并采用的知识全部属于固定信息类时,才选择“固定信息”能力模型; +- 保守分流:在上一条基础上,把短、明确、只命中课程/问答/通用知识,且不涉及个人感受、关系、建议或判断的问题交给“简单知识”能力模型。 + +混合固定信息、未命中、深度问题和个人化问题始终使用默认主模型。场景模型在尚未输出任何内容前调用失败,会自动重试默认主模型;已经输出部分内容后不会重新生成,避免重复内容。 部署迁移后,旧版本原来启用的模型会自动成为默认主模型。新增其他模型时建议按以下顺序操作: 1. 保存模型并执行“测试”; 2. 加入可用池; 3. 只勾选它实际承担的能力; -4. 如需让低成本模型承担报告,应取消默认主模型的“周期报告”能力,避免默认模型优先命中; +4. 如需让低成本模型承担某一场景,应取消默认主模型的对应能力,并在专用模型上开启该能力,避免默认模型优先命中; 5. 在数据看板“模型使用与成本”中核对实际模型和成本。 -如线上发现固定信息模型质量或稳定性异常,可在“系统配置 / AI 问答”关闭“固定信息模型分流”,下一次提问立即恢复为默认主模型,无需重新部署。后台 Agent 预览选择默认主模型时会复用正式分流规则;显式选择非默认模型时视为人工调试覆盖,不执行自动分流。 +如线上发现分流模型质量或稳定性异常,可把“正式问答模型分流规则”改为“关闭分流”,下一次提问立即恢复为默认主模型,无需重新部署。后台 Agent 预览选择默认主模型时会复用正式分流规则;显式选择非默认模型时视为人工调试覆盖,不执行自动分流。旧版本的 `fixed_info_model_routing_enabled` 开关仍兼容,但保存新规则后以 `chat_model_routing_mode` 为准。 停用或删除唯一默认主模型会被后端拒绝,必须先启用并设置替代主模型。该限制用于避免生产聊天突然变成无模型可用。 diff --git a/ai_knowledge_base_v2/docs/qianwen_product_todo.md b/ai_knowledge_base_v2/docs/qianwen_product_todo.md index 74289d9..9de9add 100644 --- a/ai_knowledge_base_v2/docs/qianwen_product_todo.md +++ b/ai_knowledge_base_v2/docs/qianwen_product_todo.md @@ -651,7 +651,7 @@ AI 日志增加: - 2026-07-31:一期已新增模型输入/输出千 Token 单价、币种、适用场景和可用能力字段;AI 请求日志记录模型 ID、估算成本、币种、问题类型、知识命中和路由原因;数据看板展示筛选范围内估算成本。暂未自动切换模型,避免影响正式回答稳定性,后续再基于这些字段做模型分流。 - 2026-07-31:二期先完成低风险后台任务分流。模型管理支持“多个可用模型 + 一个默认主模型”;周期报告和主题摘要按能力标签选模型,无匹配时回退默认主模型;正式聊天、追问改写和检索重排仍固定使用默认主模型。后台生成会记录实际模型、分流原因、Token、耗时和估算成本,数据看板增加按调用场景、模型和币种拆分的成本明细。固定信息和正式聊天的自动分流暂不启用,待后台任务运行稳定并核对成本后再做。 -- 2026-07-31:固定信息问答已接入保守自动分流。只有本轮最终采用的召回内容全部来自固定信息类知识库时,才选择“固定信息”能力模型;未命中、非固定信息召回、固定信息与其他类型混合召回均继续使用默认主模型。场景模型在输出任何内容前失败时自动回退主模型,已开始输出后不重放,防止用户看到重复回答。用户端 AI 日志和后台 Agent 预览均展示实际模型、问题类型和路由原因;系统设置可通过 `fixed_info_model_routing_enabled` 即时关闭该分流。 +- 2026-07-31:正式问答分流增加可配置规则,系统配置提供“关闭分流 / 仅固定信息 / 保守分流”三档,升级后默认保持“仅固定信息”,不会静默扩大分流范围。“保守分流”只把短、明确、仅命中课程/问答/通用知识,且不涉及个人感受、关系、建议、判断的问题交给“简单知识”能力模型;混合固定信息、未命中、深度或个人化问题仍使用默认主模型。用户端和后台 Agent 预览共用同一规则,日志继续记录实际模型、问题类型和路由原因;旧的 `fixed_info_model_routing_enabled` 配置仍作为兼容回退。 ---