Files

576 lines
20 KiB
Python
Raw Permalink Normal View History

2026-08-26 12:18:18 +08:00
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import httpx
from fastapi import HTTPException
from db.models import ModelResource, TestRun
from models import (
AssistantConfig,
RuntimeModelResource,
RuntimeTool,
RuntimeToolBinding,
)
from routes.test_runs import create_prompt_improvement
from services.test_runs.prompt_improvement import (
PROMPT_IMPROVEMENT_SNAPSHOT_KEY,
PromptImprovementAgent,
PromptImprovementError,
_GeneratedImprovement,
build_prompt_improvement_context,
build_prompt_improvement_snapshot,
public_test_run_config,
)
from test_schemas import PromptImprovementOut
def evaluator_resource() -> RuntimeModelResource:
return RuntimeModelResource(
id="model_eval",
name="测试评估模型",
capability="LLM",
interface_type="openai-llm",
values={
"apiUrl": "https://model.example/v1",
"modelId": "judge-model",
},
secrets={"apiKey": "model-secret"},
)
def failed_result() -> dict:
return {
"startup": {
"completed": True,
"greeting": "您好",
"assistantOutputs": ["请问需要什么帮助?"],
"runtimeRecords": [{"privateNoise": "must-not-be-forwarded"}],
},
"turns": [
{
"id": "turn_1",
"index": 0,
"userInput": "查询订单",
"assistantReply": "请提供订单号",
"toolCalls": [
{
"functionName": "query_order",
"argumentsJson": '{"orderId":"A1"}',
"resultJson": '{"status":"ok"}',
"outcome": "success",
}
],
"runtimeRecords": [{"privateNoise": "must-not-be-forwarded"}],
"evaluations": [
{
"label": "回复 · 关键词",
"status": "fail",
"expected": "应包含:查询成功",
"actual": "请提供订单号",
"reason": "没有告知查询结果",
}
],
}
],
"overallCriteria": [],
}
def missing_tool_result() -> dict:
return {
"startup": {"completed": True},
"turns": [
{
"id": "turn_photo",
"index": 0,
"userInput": "1",
"assistantReply": "现在开始拍照,请拍车辆前方。",
"toolCalls": [],
"evaluations": [
{
"id": "behavior_photo",
"kind": "tool_call",
"label": "工具 · set_photo_button_visible",
"status": "fail",
"expected": "应调用 set_photo_button_visible 1 次以上",
"actual": "实际调用 0 次 set_photo_button_visible",
"reason": "工具调用次数不符合要求。",
}
],
}
],
"overallCriteria": [],
}
def missing_tool_case() -> dict:
return {
"name": "进入拍照阶段",
"turns": [
{
"id": "turn_photo",
"userInput": "1",
"behaviors": [
{
"id": "behavior_photo",
"type": "tool_call",
"toolId": "tool_photo",
"functionName": "set_photo_button_visible",
"expectation": "called",
"minCalls": 1,
"maxCalls": None,
"paramAssertions": [
{
"name": "visible",
"matchMode": "exact",
"value": "true",
}
],
}
],
}
],
}
def missing_tool_snapshot() -> dict:
return {
"assistantType": "prompt",
"runtimeMode": "pipeline",
"prompt": "信息确认后立即调用 `set_photo_button_visible(true)`。",
"greeting": "您好",
"dynamicVariableNames": [],
"tools": [
{
"toolId": "tool_photo",
"functionName": "set_photo_button_visible",
"type": "client",
"parameters": [{"name": "visible", "type": "boolean"}],
}
],
}
class PromptImprovementSnapshotTests(unittest.TestCase):
def test_snapshot_keeps_model_visible_tool_settings_without_secrets(self):
config = AssistantConfig(
type="prompt",
runtimeMode="pipeline",
prompt="服务 {{customer_name}}",
greeting="您好",
dynamic_variable_definitions={"customer_name": {"type": "string"}},
startup={
"openingMode": "confirmation",
"entryMode": "wait_user",
"openingMessage": {
"title": "提示",
"message": "请确认",
"confirmLabel": "确认",
},
"actions": [
{
"phase": "opening",
"required": True,
"toolBinding": {
"toolId": "tool_order",
"argumentBindings": {
"token": {
"source": "literal",
"value": "literal-secret",
}
},
},
}
],
},
tools=[
RuntimeTool(
id="tool_order",
name="查询订单",
function_name="query_order",
type="http",
description="按订单号查询",
definition={
"type": "http",
"config": {
"url": "https://private.example/orders",
"headers": {"Authorization": "header-secret"},
"parameters": [
{
"name": "order_id",
"type": "string",
"location": "body",
"description": "订单号",
"required": True,
}
],
},
},
secrets={"token": "tool-secret"},
)
],
tool_bindings=[
RuntimeToolBinding(
tool_id="tool_order",
argument_bindings={
"order_id": {"source": "model"},
"token": {"source": "literal", "value": "binding-secret"},
},
result_assignments={"status": "order_status"},
)
],
)
snapshot = build_prompt_improvement_snapshot(config)
serialized = json.dumps(snapshot, ensure_ascii=False)
self.assertEqual(snapshot["prompt"], "服务 {{customer_name}}")
self.assertEqual(snapshot["tools"][0]["functionName"], "query_order")
self.assertEqual(snapshot["tools"][0]["parameters"][0]["name"], "order_id")
self.assertEqual(
snapshot["tools"][0]["binding"]["argumentSources"]["token"],
{"source": "literal"},
)
for secret in (
"private.example",
"header-secret",
"tool-secret",
"literal-secret",
"binding-secret",
):
self.assertNotIn(secret, serialized)
def test_internal_snapshot_is_hidden_from_public_run_config(self):
public = public_test_run_config(
{
"timeoutSecs": 60,
PROMPT_IMPROVEMENT_SNAPSHOT_KEY: {"prompt": "secret context"},
}
)
self.assertEqual(public, {"timeoutSecs": 60})
def test_context_contains_failure_trajectory_but_not_runtime_records(self):
context = build_prompt_improvement_context(
{
"assistantType": "prompt",
"runtimeMode": "pipeline",
"prompt": "你是订单助手",
"greeting": "您好",
"startup": {},
"tools": [],
"dynamicVariableNames": [],
},
{
"name": "订单查询",
"description": "查询已存在订单",
"contextTurns": [{"role": "user", "content": "之前的消息"}],
"turns": [{"id": "turn_1", "userInput": "查询订单"}],
},
failed_result(),
)
self.assertIn("你是订单助手", context)
self.assertIn("没有告知查询结果", context)
self.assertIn("query_order", context)
self.assertNotIn("must-not-be-forwarded", context)
def test_context_keeps_structured_missing_tool_assertion(self):
context = json.loads(
build_prompt_improvement_context(
missing_tool_snapshot(),
missing_tool_case(),
missing_tool_result(),
)
)
failure = context["failedEvaluations"][0]
self.assertEqual(failure["kind"], "tool_call")
self.assertEqual(failure["turnIndex"], 1)
self.assertEqual(failure["actualCallCount"], 0)
self.assertEqual(
failure["expectedBehavior"]["functionName"],
"set_photo_button_visible",
)
self.assertEqual(
failure["expectedBehavior"]["paramAssertions"][0],
{"name": "visible", "matchMode": "exact", "value": "true"},
)
class PromptImprovementAgentTests(unittest.IsolatedAsyncioTestCase):
async def test_valid_change_preserves_existing_variable(self):
agent = PromptImprovementAgent(evaluator_resource())
generated = _GeneratedImprovement.model_validate(
{
"diagnosis": "缺少查询后的回复要求",
"promptChange": {
"after": "服务 {{customer_name}}。查询完成后说明结果。",
"reason": "补充回复规则",
"evidence": ["实际回复没有查询结果"],
},
"manualRecommendations": [],
}
)
with patch.object(agent, "_request", AsyncMock(return_value=generated)):
result = await agent.improve(
snapshot={
"prompt": "服务 {{customer_name}}。",
"greeting": "您好",
"dynamicVariableNames": ["customer_name"],
"tools": [],
},
case_snapshot={"name": "订单", "turns": []},
result=failed_result(),
)
self.assertEqual(result.prompt_change.before, "服务 {{customer_name}}。")
self.assertIn("说明结果", result.prompt_change.after)
async def test_missing_tool_noop_is_retried_with_focused_instruction(self):
agent = PromptImprovementAgent(evaluator_resource())
before = missing_tool_snapshot()["prompt"]
noop = _GeneratedImprovement.model_validate(
{
"diagnosis": "Prompt 已经要求调用工具",
"promptChange": {
"after": before,
"reason": "无需修改",
"evidence": ["已有调用要求"],
},
}
)
repaired = _GeneratedImprovement.model_validate(
{
"diagnosis": "助手用文字进入下一阶段,漏掉实际工具调用",
"promptChange": {
"after": (
f"{before}\n调用成功前不得输出拍照提示,"
"不得用自然语言代替工具调用。"
),
"reason": "将工具调用设为进入下一阶段的前置门槛",
"evidence": ["实际调用 0 次 set_photo_button_visible"],
},
}
)
request = AsyncMock(side_effect=[noop, repaired])
with patch.object(agent, "_request", request):
result = await agent.improve(
snapshot=missing_tool_snapshot(),
case_snapshot=missing_tool_case(),
result=missing_tool_result(),
)
self.assertEqual(request.await_count, 2)
retry_instruction = request.await_args_list[1].kwargs["retry_instruction"]
self.assertIn("实际调用 0 次", retry_instruction)
self.assertIn("调用成功前", result.prompt_change.after)
async def test_missing_tool_uses_safe_fallback_after_two_noop_results(self):
agent = PromptImprovementAgent(evaluator_resource())
before = missing_tool_snapshot()["prompt"]
noop = _GeneratedImprovement.model_validate(
{
"diagnosis": "Prompt 已包含工具调用要求",
"promptChange": {
"after": before,
"reason": "保持原文",
"evidence": ["已有调用要求"],
},
}
)
with patch.object(
agent,
"_request",
AsyncMock(side_effect=[noop, noop]),
):
result = await agent.improve(
snapshot=missing_tool_snapshot(),
case_snapshot=missing_tool_case(),
result=missing_tool_result(),
)
self.assertIn("# 工具调用执行约束", result.prompt_change.after)
self.assertIn("不得用自然语言", result.prompt_change.after)
self.assertIn("没有实际调用", result.diagnosis)
self.assertEqual(
result.prompt_change.evidence,
[
"应调用 set_photo_button_visible 1 次以上;"
"实际调用 0 次 set_photo_button_visible;"
"工具调用次数不符合要求。"
],
)
async def test_unknown_variable_is_rejected(self):
agent = PromptImprovementAgent(evaluator_resource())
generated = _GeneratedImprovement.model_validate(
{
"diagnosis": "需要修改",
"promptChange": {
"after": "服务 {{unknown_name}}。",
"reason": "错误建议",
"evidence": [],
},
}
)
with patch.object(agent, "_request", AsyncMock(return_value=generated)):
with self.assertRaisesRegex(PromptImprovementError, "未知动态变量"):
await agent.improve(
snapshot={
"prompt": "服务用户。",
"greeting": "您好",
"dynamicVariableNames": [],
"tools": [],
},
case_snapshot={"name": "订单", "turns": []},
result=failed_result(),
)
async def test_non_prompt_root_cause_can_return_manual_recommendation(self):
agent = PromptImprovementAgent(evaluator_resource())
generated = _GeneratedImprovement.model_validate(
{
"diagnosis": "工具描述与测试预期不一致",
"promptChange": None,
"manualRecommendations": [
{
"category": "tool",
"title": "检查工具描述",
"detail": "明确工具的适用条件。",
}
],
}
)
with patch.object(agent, "_request", AsyncMock(return_value=generated)):
result = await agent.improve(
snapshot={
"prompt": "服务用户。",
"greeting": "您好",
"dynamicVariableNames": [],
"tools": [],
},
case_snapshot={"name": "订单", "turns": []},
result=failed_result(),
)
self.assertIsNone(result.prompt_change)
self.assertEqual(result.manual_recommendations[0].category, "tool")
async def test_model_timeout_is_reported_as_gateway_timeout(self):
agent = PromptImprovementAgent(evaluator_resource())
fake_client = AsyncMock()
fake_client.__aenter__.return_value = fake_client
fake_client.post.side_effect = httpx.ReadTimeout("timeout")
with patch(
"services.test_runs.prompt_improvement.httpx.AsyncClient",
return_value=fake_client,
):
with self.assertRaises(PromptImprovementError) as raised:
await agent._request("{}")
self.assertEqual(raised.exception.status_code, 504)
class FakeScalarResult:
def __init__(self, values):
self._values = values
def scalars(self):
return self
def all(self):
return self._values
class FakePromptImprovementSession:
def __init__(self, run, run_case, evaluator):
self.run = run
self.run_case = run_case
self.evaluator = evaluator
async def get(self, model, identity):
if model is TestRun and identity == self.run.id:
return self.run
if model is ModelResource and identity == self.evaluator.id:
return self.evaluator
return None
async def execute(self, _query):
return FakeScalarResult([self.run_case])
class PromptImprovementRouteTests(unittest.IsolatedAsyncioTestCase):
def make_session(self, *, status="fail", run_status="completed"):
snapshot = {
"assistantType": "prompt",
"runtimeMode": "pipeline",
"prompt": "你是订单助手",
"dynamicVariableNames": [],
}
run = SimpleNamespace(
id="run_1",
status=run_status,
config={
"evaluatorModelResourceId": "model_eval",
PROMPT_IMPROVEMENT_SNAPSHOT_KEY: snapshot,
},
)
run_case = SimpleNamespace(
status=status,
position=0,
case_snapshot={"name": "订单查询", "turns": []},
result=failed_result(),
)
evaluator = SimpleNamespace(
id="model_eval",
name="测试评估模型",
capability="LLM",
interface_type="openai-llm",
values={"apiUrl": "https://model.example/v1", "modelId": "judge"},
secrets={"apiKey": "secret"},
support_image_input=False,
enabled=True,
)
return FakePromptImprovementSession(run, run_case, evaluator)
async def test_route_returns_generated_result_without_writing_assistant(self):
expected = PromptImprovementOut.model_validate(
{
"diagnosis": "缺少结果说明",
"promptChange": {
"before": "你是订单助手",
"after": "你是订单助手。查询后说明结果。",
"reason": "补充规则",
"evidence": [],
},
"evaluatorName": "测试评估模型",
}
)
session = self.make_session()
with patch.object(
PromptImprovementAgent,
"improve",
AsyncMock(return_value=expected),
):
result = await create_prompt_improvement("run_1", session=session)
self.assertEqual(result, expected)
async def test_route_rejects_execution_error(self):
session = self.make_session(status="error")
with self.assertRaises(HTTPException) as raised:
await create_prompt_improvement("run_1", session=session)
self.assertEqual(raised.exception.status_code, 409)
if __name__ == "__main__":
unittest.main()