286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""Inspect a stuck execution and optionally probe target/judge calls without DB writes."""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.core.config import get_settings
|
|
from app.db.models import TestResult, TestRun
|
|
from app.db.session import SessionLocal
|
|
from app.providers.model_provider import ProviderFactory
|
|
from app.services.dataset_service import DatasetGateway
|
|
from app.services.test_execution_service import TestExecutionService
|
|
|
|
|
|
def arguments():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--execution-id", default="R0169")
|
|
parser.add_argument("--run-id", type=int)
|
|
parser.add_argument(
|
|
"--probe",
|
|
action="store_true",
|
|
help="Actually call target and judge providers; never writes results",
|
|
)
|
|
parser.add_argument(
|
|
"--compare",
|
|
action="store_true",
|
|
help="Probe a cumulative schema ladder and stop at the first failure",
|
|
)
|
|
parser.add_argument("--timeout", type=float, default=60, help="Outer timeout per probe")
|
|
return parser.parse_args()
|
|
|
|
|
|
def retry_budget(timeout: float, retries: int, delay: float, max_delay: float) -> float:
|
|
return timeout * (retries + 1) + sum(
|
|
min(delay * 2**attempt, max_delay) for attempt in range(retries)
|
|
)
|
|
|
|
|
|
def elapsed_seconds(value: datetime) -> int:
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return max(0, int((datetime.now(timezone.utc) - value).total_seconds()))
|
|
|
|
|
|
def find_run(db, run_id: int | None, execution_id: str):
|
|
if run_id:
|
|
return db.get(TestRun, run_id)
|
|
runs = db.scalars(select(TestRun).order_by(TestRun.id.desc())).all()
|
|
for run in runs:
|
|
summary = json.loads(run.summary_json or "{}")
|
|
if execution_id in {
|
|
summary.get("current_execution_id"),
|
|
summary.get("retry_execution_id"),
|
|
}:
|
|
return run
|
|
result = db.scalar(
|
|
select(TestResult)
|
|
.where(TestResult.execution_id == execution_id)
|
|
.order_by(TestResult.id.desc())
|
|
)
|
|
return db.get(TestRun, result.run_id) if result else None
|
|
|
|
|
|
def build_request(case):
|
|
model_input = case["model_input"]
|
|
messages = []
|
|
system_parts = [model_input["system"]] if model_input.get("system") else []
|
|
for attachment in model_input.get("attachments", []):
|
|
if attachment["type"] == "image":
|
|
messages.append(
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": attachment["file_content"]},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
else:
|
|
system_parts.append("测试资料:" + json.dumps(attachment, ensure_ascii=False))
|
|
if system_parts:
|
|
messages.insert(0, {"role": "system", "content": "\n\n".join(system_parts)})
|
|
messages.extend(model_input.get("messages", []))
|
|
tools = [model_input["tool_schema"]] if model_input.get("tool_schema") else None
|
|
return messages, tools
|
|
|
|
|
|
def simple_tools(tools):
|
|
if not tools:
|
|
return None
|
|
function = tools[0]["function"]
|
|
return [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": function["name"],
|
|
"description": function.get("description", ""),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def schema_ladder(tools):
|
|
simple = simple_tools(tools)
|
|
if not simple:
|
|
return []
|
|
original = tools[0]["function"]["parameters"]
|
|
variants = [("SIMPLE TOOL", simple)]
|
|
|
|
query_bounds = copy.deepcopy(simple)
|
|
query_bounds[0]["function"]["parameters"]["properties"]["query"] = copy.deepcopy(
|
|
original["properties"]["query"]
|
|
)
|
|
variants.append(("+ QUERY BOUNDS", query_bounds))
|
|
|
|
closed = copy.deepcopy(query_bounds)
|
|
closed[0]["function"]["parameters"]["additionalProperties"] = False
|
|
variants.append(("+ ADDITIONAL PROPERTIES FALSE", closed))
|
|
|
|
time_range = copy.deepcopy(closed)
|
|
parameters = time_range[0]["function"]["parameters"]
|
|
parameters["properties"]["time_range"] = copy.deepcopy(
|
|
original["properties"]["time_range"]
|
|
)
|
|
parameters["required"].append("time_range")
|
|
variants.append(("+ TIME RANGE ENUM", time_range))
|
|
|
|
source_enum = copy.deepcopy(time_range)
|
|
source_schema = copy.deepcopy(original["properties"]["source_types"])
|
|
source_schema.pop("uniqueItems", None)
|
|
source_schema.pop("maxItems", None)
|
|
source_enum[0]["function"]["parameters"]["properties"]["source_types"] = source_schema
|
|
variants.append(("+ SOURCE TYPES ENUM", source_enum))
|
|
|
|
variants.append(("+ ARRAY LIMITS (ORIGINAL)", tools))
|
|
return variants
|
|
|
|
|
|
async def timed(label, awaitable, timeout):
|
|
started = time.monotonic()
|
|
try:
|
|
value = await asyncio.wait_for(awaitable, timeout)
|
|
except Exception as exc:
|
|
print(f"{label}: FAIL {time.monotonic() - started:.2f}s {type(exc).__name__}: {exc}")
|
|
return None
|
|
print(f"{label}: OK {time.monotonic() - started:.2f}s")
|
|
return value
|
|
|
|
|
|
async def main():
|
|
args = arguments()
|
|
settings = get_settings()
|
|
case = next(
|
|
(
|
|
item
|
|
for item in DatasetGateway(settings).load()
|
|
if item["execution_id"] == args.execution_id
|
|
),
|
|
None,
|
|
)
|
|
if not case:
|
|
raise SystemExit(f"Unknown execution_id: {args.execution_id}")
|
|
|
|
with SessionLocal() as db:
|
|
run = find_run(db, args.run_id, args.execution_id)
|
|
if not run:
|
|
raise SystemExit("Run not found; pass --run-id explicitly")
|
|
summary = json.loads(run.summary_json or "{}")
|
|
result = db.scalar(
|
|
select(TestResult).where(
|
|
TestResult.run_id == run.id,
|
|
TestResult.execution_id == args.execution_id,
|
|
)
|
|
)
|
|
budget = retry_budget(
|
|
settings.request_timeout_seconds,
|
|
settings.request_retry_count,
|
|
settings.request_retry_delay_seconds,
|
|
settings.request_retry_max_delay_seconds,
|
|
)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"run_id": run.id,
|
|
"run_status": run.status,
|
|
"run_updated_seconds_ago": elapsed_seconds(run.updated_at),
|
|
"phase": summary.get("phase"),
|
|
"current_execution_id": summary.get("current_execution_id"),
|
|
"retry_execution_id": summary.get("retry_execution_id"),
|
|
"auto_judge": summary.get("auto_judge"),
|
|
"result": (
|
|
{
|
|
"execution_status": result.execution_status,
|
|
"verdict": result.verdict,
|
|
"error": result.error_message,
|
|
}
|
|
if result
|
|
else None
|
|
),
|
|
"case_mode": case.get("interaction_mode"),
|
|
"has_tool_schema": bool(case["model_input"].get("tool_schema")),
|
|
"provider_timeout_seconds": settings.request_timeout_seconds,
|
|
"provider_retry_count": settings.request_retry_count,
|
|
"worst_case_seconds_per_provider_call": budget,
|
|
},
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
)
|
|
if result:
|
|
print("DIAGNOSIS: execution already has a stored result; refresh the run detail/UI.")
|
|
elif summary.get("current_execution_id") == args.execution_id:
|
|
print("DIAGNOSIS: backend is waiting in target or judge call, or its worker was interrupted.")
|
|
else:
|
|
print("DIAGNOSIS: execution is queued or the in-process background task was lost.")
|
|
if not args.probe and not args.compare:
|
|
print("NEXT: add --compare for target A/B tests or --probe for target and judge.")
|
|
return
|
|
|
|
factory = ProviderFactory(settings, db)
|
|
target = factory.create("target")
|
|
messages, tools = build_request(case)
|
|
print(f"TARGET: endpoint={target.endpoint} model={target.model}")
|
|
if args.compare:
|
|
outcomes = {}
|
|
for label, variant_tools in [("NO TOOLS", None), *schema_ladder(tools)]:
|
|
reply = await timed(
|
|
label, target.chat(messages, tools=variant_tools), args.timeout
|
|
)
|
|
outcomes[label] = "ok" if reply is not None else "timeout_or_error"
|
|
if reply is None:
|
|
break
|
|
print("A/B RESULT:", json.dumps(outcomes, ensure_ascii=False))
|
|
failed = next((label for label, result in outcomes.items() if result != "ok"), None)
|
|
if failed == "NO TOOLS":
|
|
print("DIAGNOSIS: prompt/model/server path is slow; tool schema is not the cause.")
|
|
elif failed:
|
|
print(f"DIAGNOSIS: first failing schema stage is {failed}.")
|
|
else:
|
|
print("DIAGNOSIS: the full original schema works now; suspect intermittent load.")
|
|
return
|
|
reply = await timed("TARGET", target.chat(messages, tools=tools), args.timeout)
|
|
if reply is None:
|
|
return
|
|
response = reply.content or json.dumps(
|
|
{"tool_calls": reply.tool_calls}, ensure_ascii=False
|
|
)
|
|
print(
|
|
"TARGET RESULT:",
|
|
json.dumps(
|
|
{
|
|
"content_length": len(reply.content),
|
|
"tool_call_count": len(reply.tool_calls),
|
|
"preview": response[:500],
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
if summary.get("auto_judge"):
|
|
judge = factory.create("judge")
|
|
print(f"JUDGE: endpoint={judge.endpoint} model={judge.model}")
|
|
service = TestExecutionService(db=db, target_provider=target, judge_provider=judge)
|
|
judged = await timed("JUDGE", service._judge(case, response), args.timeout)
|
|
if judged is not None:
|
|
print("JUDGE RESULT:", json.dumps(judged, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|