Files
ai-video-fullstack/backend/tests/test_prompt_improvement.py
T

576 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()