Spaces:
Sleeping
Sleeping
tomas.helmfridsson commited on
Commit ·
d68e71f
1
Parent(s): 15c0f31
Fix HF output limits and requirements feedback routing
Browse files- src/qa_platform/llm_runtime.py +90 -117
- src/qa_platform/orchestrator.py +28 -3
- tests/test_feedback_routing.py +50 -0
- tests/test_hf_runtime.py +60 -0
- tests/test_service_feedback.py +54 -0
src/qa_platform/llm_runtime.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
import socket
|
|
|
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from importlib import import_module
|
| 8 |
from pathlib import Path
|
|
@@ -18,7 +19,7 @@ class LLMRuntimeError(RuntimeError):
|
|
| 18 |
|
| 19 |
HTTP_TIMEOUT_SECONDS = 55
|
| 20 |
OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 2
|
| 21 |
-
|
| 22 |
HF_MODEL_FAMILY_MAP = {
|
| 23 |
"Qwen 3 32B": "Qwen/Qwen3-32B",
|
| 24 |
"Qwen3-30B-A3B": "Qwen/Qwen3-30B-A3B",
|
|
@@ -189,124 +190,89 @@ def _call_hf_structured_llm(
|
|
| 189 |
schema: dict[str, Any],
|
| 190 |
temperature: float,
|
| 191 |
) -> tuple[dict[str, Any], LLMCallMetadata]:
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
errors: list[str] = []
|
| 197 |
-
for model_id in _candidate_model_ids(runtime_config):
|
| 198 |
-
for attempt in range(1, HF_PROVIDER_MAX_ATTEMPTS + 1):
|
| 199 |
-
try:
|
| 200 |
-
content, usage = _post_hf_inference_chat_completion(
|
| 201 |
-
api_key=token,
|
| 202 |
-
model_id=model_id,
|
| 203 |
-
messages=[
|
| 204 |
-
{
|
| 205 |
-
"role": "system",
|
| 206 |
-
"content": (
|
| 207 |
-
system_prompt.strip()
|
| 208 |
-
+ "\nReturn only valid JSON matching the requested schema. "
|
| 209 |
-
+ "Do not add markdown fences or commentary."
|
| 210 |
-
),
|
| 211 |
-
},
|
| 212 |
-
{
|
| 213 |
-
"role": "user",
|
| 214 |
-
"content": (
|
| 215 |
-
user_prompt.strip()
|
| 216 |
-
+ "\nJSON schema to satisfy exactly:\n"
|
| 217 |
-
+ json.dumps(
|
| 218 |
-
{
|
| 219 |
-
"name": schema_name,
|
| 220 |
-
"schema": schema,
|
| 221 |
-
},
|
| 222 |
-
ensure_ascii=False,
|
| 223 |
-
)
|
| 224 |
-
),
|
| 225 |
-
},
|
| 226 |
-
],
|
| 227 |
-
temperature=temperature,
|
| 228 |
-
)
|
| 229 |
-
return _extract_json_object(content), LLMCallMetadata(
|
| 230 |
-
endpoint="huggingface_hub.InferenceClient.chat_completion",
|
| 231 |
-
model_id=model_id,
|
| 232 |
-
provider_strategy=runtime_config.provider_strategy,
|
| 233 |
-
response_format_used=False,
|
| 234 |
-
usage=usage,
|
| 235 |
-
)
|
| 236 |
-
except Exception as exc: # noqa: BLE001
|
| 237 |
-
errors.append(
|
| 238 |
-
f"{model_id} inference-client attempt {attempt}/{HF_PROVIDER_MAX_ATTEMPTS}: {exc}"
|
| 239 |
-
)
|
| 240 |
-
break
|
| 241 |
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
response_format = {
|
| 244 |
"type": "json_schema",
|
| 245 |
-
"json_schema": {
|
| 246 |
-
"name": schema_name,
|
| 247 |
-
"schema": schema,
|
| 248 |
-
"strict": True,
|
| 249 |
-
},
|
| 250 |
}
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
)
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
)
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
endpoint=endpoint,
|
| 269 |
-
api_key=api_key,
|
| 270 |
-
model_id=model_id,
|
| 271 |
-
messages=messages,
|
| 272 |
-
temperature=temperature,
|
| 273 |
-
response_format=response_format,
|
| 274 |
-
)
|
| 275 |
-
content = _extract_message_content(payload)
|
| 276 |
-
return json.loads(content), LLMCallMetadata(
|
| 277 |
-
endpoint=endpoint,
|
| 278 |
-
model_id=model_id,
|
| 279 |
-
provider_strategy=runtime_config.provider_strategy,
|
| 280 |
-
response_format_used=True,
|
| 281 |
-
usage=payload.get("usage") or {},
|
| 282 |
-
)
|
| 283 |
-
except (LLMRuntimeError, json.JSONDecodeError) as first_error:
|
| 284 |
-
errors.append(
|
| 285 |
-
f"{model_id} router structured-output attempt {attempt}/{HF_PROVIDER_MAX_ATTEMPTS}: {first_error}"
|
| 286 |
-
)
|
| 287 |
-
try:
|
| 288 |
-
fallback_payload = _post_chat_completion(
|
| 289 |
-
endpoint=endpoint,
|
| 290 |
-
api_key=api_key,
|
| 291 |
-
model_id=model_id,
|
| 292 |
-
messages=[
|
| 293 |
-
{"role": "system", "content": fallback_system_prompt},
|
| 294 |
-
{"role": "user", "content": fallback_user_prompt},
|
| 295 |
-
],
|
| 296 |
-
temperature=temperature,
|
| 297 |
-
response_format=None,
|
| 298 |
-
)
|
| 299 |
-
content = _extract_message_content(fallback_payload)
|
| 300 |
-
return _extract_json_object(content), LLMCallMetadata(
|
| 301 |
-
endpoint=endpoint,
|
| 302 |
-
model_id=model_id,
|
| 303 |
-
provider_strategy=runtime_config.provider_strategy,
|
| 304 |
-
response_format_used=False,
|
| 305 |
-
usage=fallback_payload.get("usage") or {},
|
| 306 |
-
)
|
| 307 |
-
except Exception as second_error: # noqa: BLE001
|
| 308 |
-
errors.append(f"{model_id} router fallback attempt: {second_error}")
|
| 309 |
-
break
|
| 310 |
|
| 311 |
raise LLMRuntimeError(
|
| 312 |
f"Live LLM call failed for {runtime_config.agent_name}. Attempts: {' | '.join(errors)}"
|
|
@@ -407,6 +373,7 @@ def _post_hf_inference_chat_completion(
|
|
| 407 |
model_id: str,
|
| 408 |
messages: list[dict[str, str]],
|
| 409 |
temperature: float,
|
|
|
|
| 410 |
) -> tuple[str, dict[str, Any]]:
|
| 411 |
try:
|
| 412 |
huggingface_hub = import_module("huggingface_hub")
|
|
@@ -415,21 +382,23 @@ def _post_hf_inference_chat_completion(
|
|
| 415 |
"huggingface_hub is not installed. Install it to use Hugging Face live execution."
|
| 416 |
) from exc
|
| 417 |
|
| 418 |
-
client = huggingface_hub.InferenceClient(model=model_id, token=api_key)
|
| 419 |
try:
|
| 420 |
response = client.chat_completion(
|
| 421 |
messages=messages,
|
| 422 |
-
max_tokens=
|
| 423 |
temperature=temperature,
|
| 424 |
)
|
| 425 |
except (TimeoutError, socket.timeout) as exc:
|
| 426 |
raise LLMRuntimeError(
|
| 427 |
-
f"Hugging Face InferenceClient timed out after {
|
| 428 |
) from exc
|
| 429 |
except Exception as exc: # noqa: BLE001
|
| 430 |
raise LLMRuntimeError(str(exc)) from exc
|
| 431 |
|
| 432 |
choice = response.choices[0]
|
|
|
|
|
|
|
| 433 |
message = getattr(choice, "message", None)
|
| 434 |
content = None
|
| 435 |
if message is not None:
|
|
@@ -455,6 +424,8 @@ def _post_chat_completion(
|
|
| 455 |
messages: list[dict[str, str]],
|
| 456 |
temperature: float,
|
| 457 |
response_format: dict[str, Any] | None,
|
|
|
|
|
|
|
| 458 |
) -> dict[str, Any]:
|
| 459 |
body: dict[str, Any] = {
|
| 460 |
"model": model_id,
|
|
@@ -462,6 +433,8 @@ def _post_chat_completion(
|
|
| 462 |
"stream": False,
|
| 463 |
"temperature": temperature,
|
| 464 |
}
|
|
|
|
|
|
|
| 465 |
if response_format:
|
| 466 |
body["response_format"] = response_format
|
| 467 |
|
|
@@ -474,7 +447,7 @@ def _post_chat_completion(
|
|
| 474 |
encoded = json.dumps(body).encode("utf-8")
|
| 475 |
req = request.Request(endpoint, data=encoded, headers=headers, method="POST")
|
| 476 |
try:
|
| 477 |
-
with request.urlopen(req, timeout=
|
| 478 |
return json.loads(response.read().decode("utf-8"))
|
| 479 |
except error.HTTPError as exc:
|
| 480 |
detail = exc.read().decode("utf-8", errors="replace")
|
|
@@ -485,7 +458,7 @@ def _post_chat_completion(
|
|
| 485 |
raise LLMRuntimeError(f"Could not reach model endpoint {endpoint}: {exc.reason}") from exc
|
| 486 |
except (TimeoutError, socket.timeout) as exc:
|
| 487 |
raise LLMRuntimeError(
|
| 488 |
-
f"Model endpoint {endpoint} timed out after {
|
| 489 |
) from exc
|
| 490 |
|
| 491 |
|
|
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
import socket
|
| 6 |
+
import time
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from importlib import import_module
|
| 9 |
from pathlib import Path
|
|
|
|
| 19 |
|
| 20 |
HTTP_TIMEOUT_SECONDS = 55
|
| 21 |
OLLAMA_DISCOVERY_TIMEOUT_SECONDS = 2
|
| 22 |
+
HF_OUTPUT_TOKEN_BUDGET = 8192
|
| 23 |
HF_MODEL_FAMILY_MAP = {
|
| 24 |
"Qwen 3 32B": "Qwen/Qwen3-32B",
|
| 25 |
"Qwen3-30B-A3B": "Qwen/Qwen3-30B-A3B",
|
|
|
|
| 190 |
schema: dict[str, Any],
|
| 191 |
temperature: float,
|
| 192 |
) -> tuple[dict[str, Any], LLMCallMetadata]:
|
| 193 |
+
endpoint, api_key = _resolve_endpoint_and_key(runtime_config)
|
| 194 |
+
model_id = resolve_live_model_id(runtime_config)
|
| 195 |
+
# Keep the selected provider strategy throughout retries, and share one budget.
|
| 196 |
+
deadline = time.monotonic() + max(0.1, runtime_config.timeout_seconds - 1)
|
| 197 |
errors: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
def remaining_timeout() -> float:
|
| 200 |
+
remaining = deadline - time.monotonic()
|
| 201 |
+
if remaining <= 0:
|
| 202 |
+
raise LLMRuntimeError(
|
| 203 |
+
f"HF call budget exhausted for {runtime_config.agent_name}. "
|
| 204 |
+
f"Attempts: {' | '.join(errors)}"
|
| 205 |
+
)
|
| 206 |
+
return remaining
|
| 207 |
+
|
| 208 |
+
json_messages = [
|
| 209 |
+
{
|
| 210 |
+
"role": "system",
|
| 211 |
+
"content": (
|
| 212 |
+
system_prompt.strip()
|
| 213 |
+
+ "\nReturn only valid JSON matching the requested schema. "
|
| 214 |
+
+ "Do not add markdown fences or commentary."
|
| 215 |
+
),
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"role": "user",
|
| 219 |
+
"content": (
|
| 220 |
+
user_prompt.strip()
|
| 221 |
+
+ "\nJSON schema to satisfy exactly:\n"
|
| 222 |
+
+ json.dumps({"name": schema_name, "schema": schema}, ensure_ascii=False)
|
| 223 |
+
),
|
| 224 |
+
},
|
| 225 |
+
]
|
| 226 |
+
try:
|
| 227 |
+
content, usage = _post_hf_inference_chat_completion(
|
| 228 |
+
api_key=api_key,
|
| 229 |
+
model_id=model_id,
|
| 230 |
+
messages=json_messages,
|
| 231 |
+
temperature=temperature,
|
| 232 |
+
timeout_seconds=remaining_timeout(),
|
| 233 |
+
)
|
| 234 |
+
return _extract_json_object(content), LLMCallMetadata(
|
| 235 |
+
endpoint="huggingface_hub.InferenceClient.chat_completion",
|
| 236 |
+
model_id=model_id,
|
| 237 |
+
provider_strategy=runtime_config.provider_strategy,
|
| 238 |
+
response_format_used=False,
|
| 239 |
+
usage=usage,
|
| 240 |
+
)
|
| 241 |
+
except Exception as exc: # noqa: BLE001
|
| 242 |
+
errors.append(f"{model_id} inference-client attempt: {exc}")
|
| 243 |
+
|
| 244 |
response_format = {
|
| 245 |
"type": "json_schema",
|
| 246 |
+
"json_schema": {"name": schema_name, "schema": schema, "strict": True},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
}
|
| 248 |
+
for format_value in (response_format, None):
|
| 249 |
+
timeout_seconds = remaining_timeout()
|
| 250 |
+
try:
|
| 251 |
+
payload = _post_chat_completion(
|
| 252 |
+
endpoint=endpoint,
|
| 253 |
+
api_key=api_key,
|
| 254 |
+
model_id=model_id,
|
| 255 |
+
messages=(
|
| 256 |
+
[{"role": "system", "content": system_prompt.strip()},
|
| 257 |
+
{"role": "user", "content": user_prompt.strip()}]
|
| 258 |
+
if format_value else json_messages
|
| 259 |
+
),
|
| 260 |
+
temperature=temperature,
|
| 261 |
+
response_format=format_value,
|
| 262 |
+
timeout_seconds=timeout_seconds,
|
| 263 |
+
max_tokens=HF_OUTPUT_TOKEN_BUDGET,
|
| 264 |
)
|
| 265 |
+
if (payload.get("choices") or [{}])[0].get("finish_reason") == "length":
|
| 266 |
+
raise LLMRuntimeError("HF response exhausted its 8192-token output budget.")
|
| 267 |
+
return _extract_json_object(_extract_message_content(payload)), LLMCallMetadata(
|
| 268 |
+
endpoint=endpoint,
|
| 269 |
+
model_id=model_id,
|
| 270 |
+
provider_strategy=runtime_config.provider_strategy,
|
| 271 |
+
response_format_used=bool(format_value),
|
| 272 |
+
usage=payload.get("usage") or {},
|
| 273 |
)
|
| 274 |
+
except Exception as exc: # noqa: BLE001
|
| 275 |
+
errors.append(f"{model_id} router {'structured' if format_value else 'JSON'} attempt: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
raise LLMRuntimeError(
|
| 278 |
f"Live LLM call failed for {runtime_config.agent_name}. Attempts: {' | '.join(errors)}"
|
|
|
|
| 373 |
model_id: str,
|
| 374 |
messages: list[dict[str, str]],
|
| 375 |
temperature: float,
|
| 376 |
+
timeout_seconds: float = HTTP_TIMEOUT_SECONDS,
|
| 377 |
) -> tuple[str, dict[str, Any]]:
|
| 378 |
try:
|
| 379 |
huggingface_hub = import_module("huggingface_hub")
|
|
|
|
| 382 |
"huggingface_hub is not installed. Install it to use Hugging Face live execution."
|
| 383 |
) from exc
|
| 384 |
|
| 385 |
+
client = huggingface_hub.InferenceClient(model=model_id, token=api_key, timeout=timeout_seconds)
|
| 386 |
try:
|
| 387 |
response = client.chat_completion(
|
| 388 |
messages=messages,
|
| 389 |
+
max_tokens=HF_OUTPUT_TOKEN_BUDGET,
|
| 390 |
temperature=temperature,
|
| 391 |
)
|
| 392 |
except (TimeoutError, socket.timeout) as exc:
|
| 393 |
raise LLMRuntimeError(
|
| 394 |
+
f"Hugging Face InferenceClient timed out after {timeout_seconds:g} seconds."
|
| 395 |
) from exc
|
| 396 |
except Exception as exc: # noqa: BLE001
|
| 397 |
raise LLMRuntimeError(str(exc)) from exc
|
| 398 |
|
| 399 |
choice = response.choices[0]
|
| 400 |
+
if getattr(choice, "finish_reason", None) == "length":
|
| 401 |
+
raise LLMRuntimeError("HF response exhausted its 8192-token output budget.")
|
| 402 |
message = getattr(choice, "message", None)
|
| 403 |
content = None
|
| 404 |
if message is not None:
|
|
|
|
| 424 |
messages: list[dict[str, str]],
|
| 425 |
temperature: float,
|
| 426 |
response_format: dict[str, Any] | None,
|
| 427 |
+
timeout_seconds: float = HTTP_TIMEOUT_SECONDS,
|
| 428 |
+
max_tokens: int | None = None,
|
| 429 |
) -> dict[str, Any]:
|
| 430 |
body: dict[str, Any] = {
|
| 431 |
"model": model_id,
|
|
|
|
| 433 |
"stream": False,
|
| 434 |
"temperature": temperature,
|
| 435 |
}
|
| 436 |
+
if max_tokens is not None:
|
| 437 |
+
body["max_tokens"] = max_tokens
|
| 438 |
if response_format:
|
| 439 |
body["response_format"] = response_format
|
| 440 |
|
|
|
|
| 447 |
encoded = json.dumps(body).encode("utf-8")
|
| 448 |
req = request.Request(endpoint, data=encoded, headers=headers, method="POST")
|
| 449 |
try:
|
| 450 |
+
with request.urlopen(req, timeout=timeout_seconds) as response:
|
| 451 |
return json.loads(response.read().decode("utf-8"))
|
| 452 |
except error.HTTPError as exc:
|
| 453 |
detail = exc.read().decode("utf-8", errors="replace")
|
|
|
|
| 458 |
raise LLMRuntimeError(f"Could not reach model endpoint {endpoint}: {exc.reason}") from exc
|
| 459 |
except (TimeoutError, socket.timeout) as exc:
|
| 460 |
raise LLMRuntimeError(
|
| 461 |
+
f"Model endpoint {endpoint} timed out after {timeout_seconds:g} seconds."
|
| 462 |
) from exc
|
| 463 |
|
| 464 |
|
src/qa_platform/orchestrator.py
CHANGED
|
@@ -914,12 +914,11 @@ class OrchestratorAgent:
|
|
| 914 |
"feedback_messages": review.improvement_actions[:2]
|
| 915 |
or ["Strengthen oracle quality and expected results in the current test designs."],
|
| 916 |
}
|
| 917 |
-
if "requirements" in routing_focus
|
| 918 |
return {
|
| 919 |
"from_agent": "Review Agent",
|
| 920 |
"to_agent": "Requirements Analyst",
|
| 921 |
-
"feedback_messages":
|
| 922 |
-
or ["Clarify ambiguous requirements and reduce assumptions before redesign."],
|
| 923 |
}
|
| 924 |
|
| 925 |
weak_oracle_findings = [finding for finding in review.findings if "weak oracle definition" in finding]
|
|
@@ -1007,6 +1006,25 @@ class OrchestratorAgent:
|
|
| 1007 |
|
| 1008 |
return None
|
| 1009 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
def _normalize_routing_focus(
|
| 1011 |
self,
|
| 1012 |
raw_focus: list[str],
|
|
@@ -1021,10 +1039,17 @@ class OrchestratorAgent:
|
|
| 1021 |
focus.add("requirements")
|
| 1022 |
elif normalized in {"test_design", "test design", "designer", "test design agent"}:
|
| 1023 |
focus.add("test_design")
|
|
|
|
|
|
|
|
|
|
| 1024 |
if any(
|
| 1025 |
keyword in combined
|
| 1026 |
for keyword in (
|
| 1027 |
"assumption",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
"ambiguous requirement",
|
| 1029 |
"unclear requirement",
|
| 1030 |
"acceptance criteria",
|
|
|
|
| 914 |
"feedback_messages": review.improvement_actions[:2]
|
| 915 |
or ["Strengthen oracle quality and expected results in the current test designs."],
|
| 916 |
}
|
| 917 |
+
if "requirements" in routing_focus:
|
| 918 |
return {
|
| 919 |
"from_agent": "Review Agent",
|
| 920 |
"to_agent": "Requirements Analyst",
|
| 921 |
+
"feedback_messages": self._requirements_review_feedback(review),
|
|
|
|
| 922 |
}
|
| 923 |
|
| 924 |
weak_oracle_findings = [finding for finding in review.findings if "weak oracle definition" in finding]
|
|
|
|
| 1006 |
|
| 1007 |
return None
|
| 1008 |
|
| 1009 |
+
def _requirements_review_feedback(self, review) -> list[str]:
|
| 1010 |
+
clarification_words = (
|
| 1011 |
+
"clarify", "define", "specify", "document", "resolve",
|
| 1012 |
+
"reduce assumptions", "remove assumptions",
|
| 1013 |
+
)
|
| 1014 |
+
# Prefer clarification requests over test actions that merely mention assumptions.
|
| 1015 |
+
targeted = [
|
| 1016 |
+
action for action in review.improvement_actions
|
| 1017 |
+
if any(word in action.lower() for word in clarification_words)
|
| 1018 |
+
]
|
| 1019 |
+
if targeted:
|
| 1020 |
+
return targeted[:2]
|
| 1021 |
+
requirement_words = ("requirement", "acceptance criteria", "assumption", "ambigu", "policy", "rules")
|
| 1022 |
+
findings = [
|
| 1023 |
+
finding for finding in review.findings
|
| 1024 |
+
if any(word in finding.lower() for word in requirement_words)
|
| 1025 |
+
]
|
| 1026 |
+
return findings[:2] or ["Clarify ambiguous requirements and reduce assumptions before redesign."]
|
| 1027 |
+
|
| 1028 |
def _normalize_routing_focus(
|
| 1029 |
self,
|
| 1030 |
raw_focus: list[str],
|
|
|
|
| 1039 |
focus.add("requirements")
|
| 1040 |
elif normalized in {"test_design", "test design", "designer", "test design agent"}:
|
| 1041 |
focus.add("test_design")
|
| 1042 |
+
# Explicit agent routing takes precedence over words in the explanation.
|
| 1043 |
+
if focus:
|
| 1044 |
+
return focus
|
| 1045 |
if any(
|
| 1046 |
keyword in combined
|
| 1047 |
for keyword in (
|
| 1048 |
"assumption",
|
| 1049 |
+
"ambiguity",
|
| 1050 |
+
"ambiguities",
|
| 1051 |
+
"undefined",
|
| 1052 |
+
"unspecified",
|
| 1053 |
"ambiguous requirement",
|
| 1054 |
"unclear requirement",
|
| 1055 |
"acceptance criteria",
|
tests/test_feedback_routing.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import unittest
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
|
| 5 |
+
from qa_platform.models import ReviewReport
|
| 6 |
+
from qa_platform.orchestrator import OrchestratorAgent
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FeedbackRoutingTests(unittest.TestCase):
|
| 10 |
+
def route(self, focus, finding, action):
|
| 11 |
+
orchestrator = OrchestratorAgent()
|
| 12 |
+
orchestrator.reviewer.last_execution = {'routing_focus': focus}
|
| 13 |
+
return orchestrator._select_review_backtrack_route([], ReviewReport(
|
| 14 |
+
approved=False, coverage_ratio=1.0, findings=[finding], improvement_actions=[action]))
|
| 15 |
+
|
| 16 |
+
def test_explicit_requirements_route_survives_oracle_wording(self):
|
| 17 |
+
route = self.route(['requirements'], 'Unclear requirement causes weak oracle.', 'Clarify requirement before revising test case oracle.')
|
| 18 |
+
self.assertEqual(route['to_agent'], 'Requirements Analyst')
|
| 19 |
+
|
| 20 |
+
def test_inferred_mixed_problems_fix_requirements_first(self):
|
| 21 |
+
route = self.route([], 'Ambiguous requirement causes weak oracle.', 'Clarify requirement before revising test case oracle.')
|
| 22 |
+
self.assertEqual(route['to_agent'], 'Requirements Analyst')
|
| 23 |
+
|
| 24 |
+
def test_explicit_design_route_is_not_overridden_by_explanatory_keywords(self):
|
| 25 |
+
route = self.route(['test_design'], 'Oracle fails to check acceptance criteria.', 'Revise the test case.')
|
| 26 |
+
self.assertEqual(route['to_agent'], 'Test Design Agent')
|
| 27 |
+
|
| 28 |
+
def test_explicit_mixed_route_fixes_requirements_first(self):
|
| 29 |
+
route = self.route(['test_design', 'requirements'], 'Unclear requirement and weak oracle.', 'Clarify requirement.')
|
| 30 |
+
self.assertEqual(route['to_agent'], 'Requirements Analyst')
|
| 31 |
+
|
| 32 |
+
def test_requirements_feedback_filters_out_earlier_test_actions(self):
|
| 33 |
+
orchestrator = OrchestratorAgent()
|
| 34 |
+
orchestrator.reviewer.last_execution = {'routing_focus': ['requirements', 'test_design']}
|
| 35 |
+
route = orchestrator._select_review_backtrack_route([], ReviewReport(
|
| 36 |
+
approved=False, coverage_ratio=1.0, findings=['Unclear requirement and weak oracle.'],
|
| 37 |
+
improvement_actions=['Enhance test oracle with server checks.', 'Clarify acceptance criteria for registration.']))
|
| 38 |
+
self.assertEqual(route['feedback_messages'], ['Clarify acceptance criteria for registration.'])
|
| 39 |
+
|
| 40 |
+
def test_undefined_policy_returns_to_requirements(self):
|
| 41 |
+
route = self.route([], 'Password policy is undefined and the oracle is weak.', 'Document the password rules.')
|
| 42 |
+
self.assertEqual(route['to_agent'], 'Requirements Analyst')
|
| 43 |
+
|
| 44 |
+
def test_test_action_mentioning_assumptions_does_not_take_clarification_slot(self):
|
| 45 |
+
orchestrator = OrchestratorAgent()
|
| 46 |
+
orchestrator.reviewer.last_execution = {'routing_focus': ['requirements', 'test_design']}
|
| 47 |
+
route = orchestrator._select_review_backtrack_route([], ReviewReport(
|
| 48 |
+
approved=False, coverage_ratio=1.0, findings=['Password policy is unclear.'],
|
| 49 |
+
improvement_actions=['Create a test covering security assumptions.', 'Clarify password policy in the requirements.']))
|
| 50 |
+
self.assertEqual(route['feedback_messages'], ['Clarify password policy in the requirements.'])
|
tests/test_hf_runtime.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import unittest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
from unittest.mock import Mock, patch
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
|
| 10 |
+
from qa_platform import llm_runtime as runtime
|
| 11 |
+
from qa_platform.agent_runtime import build_agent_runtime_configs
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class HFRuntimeTests(unittest.TestCase):
|
| 15 |
+
def test_sdk_has_output_space_and_network_timeout(self):
|
| 16 |
+
client = Mock()
|
| 17 |
+
client.chat_completion.return_value = SimpleNamespace(
|
| 18 |
+
choices=[SimpleNamespace(finish_reason='stop', message=SimpleNamespace(content='{"ok":true}'))],
|
| 19 |
+
usage=SimpleNamespace(prompt_tokens=10, completion_tokens=2400, total_tokens=2410),
|
| 20 |
+
)
|
| 21 |
+
hub = SimpleNamespace(InferenceClient=Mock(return_value=client))
|
| 22 |
+
with patch.object(runtime, 'import_module', return_value=hub):
|
| 23 |
+
content, usage = runtime._post_hf_inference_chat_completion('secret', 'model:cheapest', [], .2, timeout_seconds=90)
|
| 24 |
+
self.assertTrue(json.loads(content)['ok'])
|
| 25 |
+
self.assertEqual(usage['completion_tokens'], 2400)
|
| 26 |
+
self.assertEqual(hub.InferenceClient.call_args.kwargs['timeout'], 90)
|
| 27 |
+
self.assertEqual(client.chat_completion.call_args.kwargs['max_tokens'], 8192)
|
| 28 |
+
|
| 29 |
+
def test_truncated_response_is_reported_even_if_json_looks_valid(self):
|
| 30 |
+
client = Mock()
|
| 31 |
+
client.chat_completion.return_value = SimpleNamespace(
|
| 32 |
+
choices=[SimpleNamespace(finish_reason='length', message=SimpleNamespace(content='{"ok":true}'))],
|
| 33 |
+
)
|
| 34 |
+
with patch.object(runtime, 'import_module', return_value=SimpleNamespace(InferenceClient=Mock(return_value=client))):
|
| 35 |
+
with self.assertRaisesRegex(runtime.LLMRuntimeError, 'output budget'):
|
| 36 |
+
runtime._post_hf_inference_chat_completion('secret', 'model', [], .2)
|
| 37 |
+
|
| 38 |
+
def test_fallback_keeps_provider_and_shares_remaining_budget(self):
|
| 39 |
+
config = build_agent_runtime_configs()[1]
|
| 40 |
+
with patch.dict(os.environ, {'HF_TOKEN': 'test-token'}), \
|
| 41 |
+
patch.object(runtime, 'load_local_env'), \
|
| 42 |
+
patch.object(runtime.time, 'monotonic', side_effect=[0, 0, 100]), \
|
| 43 |
+
patch.object(runtime, '_post_hf_inference_chat_completion', side_effect=runtime.LLMRuntimeError('provider unavailable')) as sdk, \
|
| 44 |
+
patch.object(runtime, '_post_chat_completion', return_value={'choices': [{'finish_reason': 'stop', 'message': {'content': '{"ok":true}'}}]}) as router:
|
| 45 |
+
payload, _ = runtime.call_structured_llm(config, 'Return JSON', 'Test', 'schema', {'type': 'object'})
|
| 46 |
+
self.assertTrue(payload['ok'])
|
| 47 |
+
self.assertEqual(sdk.call_args.kwargs['timeout_seconds'], 149)
|
| 48 |
+
self.assertEqual(router.call_args.kwargs['timeout_seconds'], 49)
|
| 49 |
+
self.assertEqual(router.call_args.kwargs['model_id'], config.model_id)
|
| 50 |
+
|
| 51 |
+
def test_no_fallback_starts_after_budget_exhaustion(self):
|
| 52 |
+
config = build_agent_runtime_configs()[1]
|
| 53 |
+
with patch.dict(os.environ, {'HF_TOKEN': 'test-token'}), \
|
| 54 |
+
patch.object(runtime, 'load_local_env'), \
|
| 55 |
+
patch.object(runtime.time, 'monotonic', side_effect=[0, 0, 150]), \
|
| 56 |
+
patch.object(runtime, '_post_hf_inference_chat_completion', side_effect=runtime.LLMRuntimeError('timeout')), \
|
| 57 |
+
patch.object(runtime, '_post_chat_completion') as router:
|
| 58 |
+
with self.assertRaisesRegex(runtime.LLMRuntimeError, 'budget exhausted'):
|
| 59 |
+
runtime.call_structured_llm(config, 'Return JSON', 'Test', 'schema', {'type': 'object'})
|
| 60 |
+
router.assert_not_called()
|
tests/test_service_feedback.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import unittest
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 6 |
+
import app
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ServiceFeedbackTests(unittest.TestCase):
|
| 10 |
+
def test_service_review_returns_to_requirements_before_redesign(self):
|
| 11 |
+
calls = []
|
| 12 |
+
requirement_calls = []
|
| 13 |
+
reviews = 0
|
| 14 |
+
|
| 15 |
+
def invoke(**kwargs):
|
| 16 |
+
nonlocal reviews
|
| 17 |
+
key = kwargs['service_agent_key']
|
| 18 |
+
calls.append(key)
|
| 19 |
+
if key == 'requirements_analyst':
|
| 20 |
+
requirement_calls.append(kwargs['agent_input']['feedback_messages'])
|
| 21 |
+
output = {'requirements': [{
|
| 22 |
+
'requirement_id': 'REQ-001', 'original_text': 'The user can log in.',
|
| 23 |
+
'normalized_text': 'The user can log in.', 'priority': 'medium',
|
| 24 |
+
'acceptance_criteria': ['Valid credentials authenticate the user.'], 'assumptions': [],
|
| 25 |
+
}]}
|
| 26 |
+
elif key == 'test_designer':
|
| 27 |
+
output = {'test_designs': [{
|
| 28 |
+
'test_case_id': 'TC-001', 'requirement_ids': ['REQ-001'],
|
| 29 |
+
'title': 'Valid login', 'test_type': 'functional',
|
| 30 |
+
'steps': ['Submit valid credentials.'], 'expected_results': ['Authenticated.'],
|
| 31 |
+
'oracle': {'description': 'Authenticated user session is established.'},
|
| 32 |
+
}]}
|
| 33 |
+
else:
|
| 34 |
+
reviews += 1
|
| 35 |
+
output = {'review': {
|
| 36 |
+
'approved': reviews > 1, 'coverage_ratio': 1.0,
|
| 37 |
+
'findings': [] if reviews > 1 else [{
|
| 38 |
+
'severity': 'major', 'target_type': 'test_case', 'target_id': 'TC-001',
|
| 39 |
+
'issue': 'Ambiguous requirement produces a weak oracle.',
|
| 40 |
+
'recommendation': 'Clarify requirement before revising the test case.',
|
| 41 |
+
}],
|
| 42 |
+
'improvement_actions': [] if reviews > 1 else ['Clarify requirement before revising the test case oracle.'],
|
| 43 |
+
}}
|
| 44 |
+
return {'status': 'completed', 'output': output, 'memory_updates': {}, 'errors': []}
|
| 45 |
+
|
| 46 |
+
configs = app.build_backend_agent_runtime_configs('QA agent service', app.DEFAULT_QA_AGENT_SERVICE_BASE_URL)
|
| 47 |
+
with patch.object(app, '_invoke_qa_agent_service', side_effect=invoke):
|
| 48 |
+
result = app.build_orchestrator_for_backend('QA agent service', app.DEFAULT_QA_AGENT_SERVICE_BASE_URL).process(
|
| 49 |
+
'Login', 'The user can log in.', agent_configs=configs,
|
| 50 |
+
run_controls=app.RunControlConfig(max_rounds=10, max_feedback_messages=12, max_feedback_per_agent_pair=4))
|
| 51 |
+
self.assertTrue(result.review.approved)
|
| 52 |
+
self.assertEqual(calls, ['requirements_analyst', 'test_designer', 'reviewer'] * 2)
|
| 53 |
+
self.assertEqual(result.pair_feedback_counts, {'Review Agent -> Requirements Analyst': 1})
|
| 54 |
+
self.assertTrue(requirement_calls[1])
|