feat: route fixed information chats by model
This commit is contained in:
@@ -21,7 +21,7 @@ from app.services.model_stream_service import (
|
||||
_stream_configured_model_async,
|
||||
)
|
||||
from app.services.model_service import ModelClientService, _copy_model_with_overrides, _max_output_tokens
|
||||
from app.services.rag_service import PromptService, RagResult
|
||||
from app.services.rag_service import PromptService, RagResult, RetrievedChunk
|
||||
|
||||
|
||||
def _database() -> Session:
|
||||
@@ -148,10 +148,90 @@ def test_disabled_stream_returns_one_complete_chunk():
|
||||
|
||||
with patch("app.services.model_stream_service._call_configured_model", return_value=answer):
|
||||
chunks = asyncio.run(collect())
|
||||
|
||||
assert chunks == [answer]
|
||||
|
||||
|
||||
def test_agent_preview_uses_same_fixed_info_route_when_default_model_is_selected(monkeypatch):
|
||||
async def fixed_chunks():
|
||||
yield "本周三晚八点上课。"
|
||||
|
||||
async def build_result(_db, _payload):
|
||||
return RagResult(
|
||||
question="本周什么时候上课?",
|
||||
knowledge_scopes=[],
|
||||
chunks=[
|
||||
RetrievedChunk(
|
||||
knowledge_id=1,
|
||||
knowledge_name="当前安排",
|
||||
title="本周安排",
|
||||
content="本周三晚八点上课。",
|
||||
knowledge_type="fixed",
|
||||
)
|
||||
],
|
||||
prompt="本周什么时候上课?",
|
||||
tool_trace=[],
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def debug_stream(model, _rag_result, _overrides, **kwargs):
|
||||
captured["model"] = model.model_name
|
||||
captured["fallback"] = kwargs["fallback_model"].model_name
|
||||
captured["routeReason"] = kwargs["route_reason"]
|
||||
return AsyncStreamingModelResponse(
|
||||
model_id=model.id,
|
||||
model_name=model.model_name,
|
||||
input_token=10,
|
||||
chunks=fixed_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_fixed_info = 0
|
||||
fixed = ModelConfig(
|
||||
id=2,
|
||||
provider="fixed",
|
||||
display_name="固定信息模型",
|
||||
api_type="openai_compatible",
|
||||
model_name="fixed-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_fixed_info=1,
|
||||
)
|
||||
db.add_all([admin, main, fixed])
|
||||
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"] == "fixed-model"
|
||||
assert captured["fallback"] == "production-model"
|
||||
assert "仅召回固定信息类" in captured["routeReason"]
|
||||
assert complete["modelName"] == "fixed-model"
|
||||
assert complete["questionType"] == "fixed_info"
|
||||
assert any(item.get("tool") == "model_route" for item in complete["retrievalTrace"])
|
||||
|
||||
def test_debug_stream_setting_overrides_model_without_changing_it():
|
||||
model = _model()
|
||||
debug_model = _copy_model_with_overrides(model, {"stream_enabled": 0})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +17,9 @@ from app.models.logs import AiRequestLog
|
||||
from app.schemas.admin import DefaultModelRequest, EnableModelRequest
|
||||
from app.services.model_routing_service import ModelRoutingService
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.model_stream_service import ModelStreamService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.rag_service import RagResult, RetrievedChunk
|
||||
from app.services.tracked_generation_service import TrackedGenerationService
|
||||
|
||||
|
||||
@@ -36,6 +40,7 @@ def _model(
|
||||
is_default: int,
|
||||
allow_report: int,
|
||||
allow_summary: int,
|
||||
allow_fixed_info: int = 1,
|
||||
) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
id=model_id,
|
||||
@@ -48,7 +53,7 @@ def _model(
|
||||
is_default=is_default,
|
||||
allow_report=allow_report,
|
||||
allow_summary=allow_summary,
|
||||
allow_fixed_info=1,
|
||||
allow_fixed_info=allow_fixed_info,
|
||||
allow_deep_chat=1,
|
||||
input_price_per_1k=Decimal("0.002"),
|
||||
output_price_per_1k=Decimal("0.006"),
|
||||
@@ -86,6 +91,127 @@ def test_background_scenario_falls_back_to_default_model():
|
||||
assert "回退默认主模型" in route.reason
|
||||
|
||||
|
||||
def test_fixed_info_chat_routes_only_when_all_recalled_knowledge_is_fixed():
|
||||
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])
|
||||
db.commit()
|
||||
|
||||
fixed_route = ModelRoutingService.resolve_chat(db, ["fixed", "fixed"])
|
||||
mixed_route = ModelRoutingService.resolve_chat(db, ["fixed", "course"])
|
||||
no_hit_route = ModelRoutingService.resolve_chat(db, [])
|
||||
|
||||
assert fixed_route.model is fixed
|
||||
assert fixed_route.question_type == "fixed_info"
|
||||
assert mixed_route.model is main
|
||||
assert mixed_route.question_type == "knowledge_grounded"
|
||||
assert "混合类型" in mixed_route.reason
|
||||
assert no_hit_route.model is main
|
||||
assert no_hit_route.question_type == "general_chat"
|
||||
|
||||
db.add(SystemConfig(config_key="fixed_info_model_routing_enabled", config_value="false"))
|
||||
db.commit()
|
||||
disabled_route = ModelRoutingService.resolve_chat(db, ["fixed"])
|
||||
assert disabled_route.model is main
|
||||
assert disabled_route.question_type == "fixed_info"
|
||||
assert "分流开关已关闭" in disabled_route.reason
|
||||
|
||||
|
||||
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)
|
||||
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="mock_model_enabled", config_value="false")])
|
||||
db.commit()
|
||||
calls: list[str] = []
|
||||
|
||||
def call(model, _rag_result, *, allow_no_hit=False):
|
||||
calls.append(model.model_name)
|
||||
if model.id == fixed.id:
|
||||
raise ExternalServiceError("固定信息模型暂时不可用", provider="model")
|
||||
return "主模型回退回答"
|
||||
|
||||
monkeypatch.setattr("app.services.model_service._call_configured_model", call)
|
||||
completion = ModelClientService.complete(db, _rag_result("fixed"))
|
||||
|
||||
assert calls == ["fixed-model", "main-model"]
|
||||
assert completion.model_id == main.id
|
||||
assert completion.answer == "主模型回退回答"
|
||||
assert completion.question_type == "fixed_info"
|
||||
assert "运行时回退默认主模型" in (completion.route_reason or "")
|
||||
|
||||
|
||||
def test_fixed_info_async_stream_falls_back_before_first_output(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_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="mock_model_enabled", config_value="false")])
|
||||
db.commit()
|
||||
|
||||
async def stream(model, _rag_result):
|
||||
if model.id == fixed.id:
|
||||
raise ExternalServiceError("固定信息模型暂时不可用", provider="model")
|
||||
yield "主模型流式回退回答"
|
||||
|
||||
monkeypatch.setattr("app.services.model_stream_service._stream_configured_model_async", stream)
|
||||
response = ModelStreamService.stream_async(db, _rag_result("fixed"))
|
||||
chunks = asyncio.run(collect(response))
|
||||
|
||||
assert chunks == ["主模型流式回退回答"]
|
||||
assert response.model_id == main.id
|
||||
assert response.question_type == "fixed_info"
|
||||
assert "运行时回退默认主模型" in (response.route_reason or "")
|
||||
|
||||
|
||||
def test_fixed_info_stream_does_not_restart_after_partial_output(monkeypatch):
|
||||
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="mock_model_enabled", config_value="false")])
|
||||
db.commit()
|
||||
calls: list[str] = []
|
||||
|
||||
async def stream(model, _rag_result):
|
||||
calls.append(model.model_name)
|
||||
if model.id == fixed.id:
|
||||
yield "已经输出的部分"
|
||||
raise ExternalServiceError("输出中断", provider="model")
|
||||
yield "不应重新生成"
|
||||
|
||||
async def collect(response):
|
||||
chunks = []
|
||||
with pytest.raises(ExternalServiceError):
|
||||
async for chunk in response.chunks:
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
|
||||
monkeypatch.setattr("app.services.model_stream_service._stream_configured_model_async", stream)
|
||||
response = ModelStreamService.stream_async(db, _rag_result("fixed"))
|
||||
chunks = asyncio.run(collect(response))
|
||||
|
||||
assert chunks == ["已经输出的部分"]
|
||||
assert calls == ["fixed-model"]
|
||||
assert response.model_id == fixed.id
|
||||
|
||||
|
||||
def test_tracked_background_generation_records_actual_route_tokens_and_cost():
|
||||
with _db() as db:
|
||||
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
|
||||
@@ -152,3 +278,20 @@ def test_model_pool_keeps_one_default_and_rejects_disabling_last_default():
|
||||
current_admin=admin,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def _rag_result(knowledge_type: str) -> RagResult:
|
||||
return RagResult(
|
||||
question="本周上课时间是什么?",
|
||||
knowledge_scopes=[],
|
||||
chunks=[
|
||||
RetrievedChunk(
|
||||
knowledge_id=1,
|
||||
knowledge_name="当前安排",
|
||||
title="本周安排",
|
||||
content="本周三晚八点上课。",
|
||||
knowledge_type=knowledge_type,
|
||||
)
|
||||
],
|
||||
prompt="本周上课时间是什么?",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user