evalrun 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +6 -0
- agents/auditor/__init__.py +13 -0
- agents/auditor/budget_auditor.py +91 -0
- agents/auditor/parser.py +139 -0
- agents/auditor/prompts.py +64 -0
- agents/auditor/schema.py +67 -0
- agents/base.py +20 -0
- agents/reflection/__init__.py +3 -0
- agents/reflection/agent.py +77 -0
- agents/reflection/prompts.py +18 -0
- agents/research/__init__.py +4 -0
- agents/research/agent.py +45 -0
- agents/research/planner.py +52 -0
- agents/research/prompts.py +14 -0
- agents/support/__init__.py +5 -0
- agents/support/triage_agent.py +45 -0
- agents/travel/__init__.py +11 -0
- agents/travel/agent.py +377 -0
- agents/travel/prompts.py +30 -0
- agents/travel/session.py +110 -0
- cli/__init__.py +6 -0
- cli/demo.py +47 -0
- cli/formatter.py +93 -0
- cli/html_reporter.py +647 -0
- cli/main.py +423 -0
- cli/progress.py +38 -0
- cli/resolver.py +99 -0
- evalrun-0.4.0.dist-info/METADATA +268 -0
- evalrun-0.4.0.dist-info/RECORD +100 -0
- evalrun-0.4.0.dist-info/WHEEL +5 -0
- evalrun-0.4.0.dist-info/entry_points.txt +2 -0
- evalrun-0.4.0.dist-info/licenses/LICENSE +0 -0
- evalrun-0.4.0.dist-info/top_level.txt +4 -0
- framework/__init__.py +70 -0
- framework/core/__init__.py +17 -0
- framework/core/adapters.py +118 -0
- framework/core/contracts.py +88 -0
- framework/core/suite.py +44 -0
- framework/evaluation/__init__.py +22 -0
- framework/evaluation/base.py +29 -0
- framework/evaluation/dimensions.py +7 -0
- framework/evaluation/engine.py +110 -0
- framework/evaluation/evaluators/__init__.py +7 -0
- framework/evaluation/evaluators/adaptability.py +27 -0
- framework/evaluation/evaluators/base_llm.py +104 -0
- framework/evaluation/evaluators/constraint.py +27 -0
- framework/evaluation/evaluators/information_accuracy.py +41 -0
- framework/evaluation/evaluators/personalization.py +27 -0
- framework/evaluation/evaluators/planning.py +27 -0
- framework/evaluation/evaluators/support.py +81 -0
- framework/evaluation/prompts/__init__.py +11 -0
- framework/evaluation/prompts/adaptability.py +57 -0
- framework/evaluation/prompts/base.py +52 -0
- framework/evaluation/prompts/constraint.py +41 -0
- framework/evaluation/prompts/information_accuracy.py +79 -0
- framework/evaluation/prompts/personalization.py +57 -0
- framework/evaluation/prompts/planning.py +61 -0
- framework/evaluation/runner.py +363 -0
- framework/evaluation/testing.py +25 -0
- framework/exceptions.py +49 -0
- framework/llms/__init__.py +8 -0
- framework/llms/base.py +37 -0
- framework/llms/factory.py +38 -0
- framework/llms/gemini.py +85 -0
- framework/llms/mock.py +25 -0
- framework/llms/openai.py +94 -0
- framework/llms/openai_compatible.py +139 -0
- framework/mcp/__init__.py +20 -0
- framework/mcp/client.py +62 -0
- framework/mcp/constraints.py +125 -0
- framework/mcp/revision_summary.py +122 -0
- framework/mcp/server.py +49 -0
- framework/memory/__init__.py +3 -0
- framework/memory/base.py +17 -0
- framework/models.py +83 -0
- framework/parser.py +45 -0
- framework/parsers/__init__.py +12 -0
- framework/parsers/frontmatter.py +28 -0
- framework/parsers/mapper.py +66 -0
- framework/parsers/markdown.py +122 -0
- framework/parsers/transformers.py +112 -0
- framework/profiles/__init__.py +28 -0
- framework/profiles/registry.py +104 -0
- framework/profiles/support.py +27 -0
- framework/profiles/travel.py +78 -0
- framework/regression/__init__.py +17 -0
- framework/regression/comparator.py +273 -0
- framework/regression/loader.py +145 -0
- framework/sdk.py +151 -0
- framework/utils.py +41 -0
- framework/verification/__init__.py +13 -0
- framework/verification/base.py +32 -0
- framework/verification/extractor.py +105 -0
- framework/verification/local.py +122 -0
- framework/verification/models.py +112 -0
- framework/verification/pipeline.py +36 -0
- framework/verification/prompts.py +24 -0
- framework/verification/utils.py +54 -0
- ui/__init__.py +1 -0
- ui/server.py +255 -0
framework/llms/openai.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""OpenAI client wrapper implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from framework.llms.base import BaseLLM, LLMResponse, Message
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OpenAILLM(BaseLLM):
|
|
8
|
+
"""OpenAI API client wrapper."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
model_name: str = "gpt-5.6-terra",
|
|
13
|
+
api_key: Optional[str] = None,
|
|
14
|
+
base_url: str = "https://api.openai.com/v1",
|
|
15
|
+
timeout: float = 60.0,
|
|
16
|
+
max_retries: int = 2,
|
|
17
|
+
):
|
|
18
|
+
"""Initializes the OpenAILLM.
|
|
19
|
+
|
|
20
|
+
Validates that the 'openai' library is installed immediately.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
model_name: The target model ID.
|
|
24
|
+
api_key: The OpenAI API key. Reads from environment if None.
|
|
25
|
+
base_url: The API base URL to connect to. Defaults to OpenAI API.
|
|
26
|
+
timeout: The request timeout in seconds. Defaults to 60.0.
|
|
27
|
+
max_retries: The maximum number of retries for request failures. Defaults to 2.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ImportError: If the 'openai' library is not installed.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
# pyrefly: ignore [missing-import]
|
|
34
|
+
import openai
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"The 'openai' library is required to use OpenAILLM. "
|
|
38
|
+
"Please install it using 'pip install openai'."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self._client = openai.OpenAI(
|
|
42
|
+
api_key=api_key,
|
|
43
|
+
base_url=base_url,
|
|
44
|
+
timeout=timeout,
|
|
45
|
+
max_retries=max_retries,
|
|
46
|
+
)
|
|
47
|
+
self.model_name = model_name
|
|
48
|
+
self.max_retries = max_retries
|
|
49
|
+
# GPT-5 family Chat Completions models use the newer
|
|
50
|
+
# `max_completion_tokens` parameter. NVIDIA's OpenAI-compatible NIM
|
|
51
|
+
# endpoint continues to expect `max_tokens`.
|
|
52
|
+
self._token_parameter = (
|
|
53
|
+
"max_completion_tokens"
|
|
54
|
+
if base_url.rstrip("/") == "https://api.openai.com/v1" and model_name.startswith("gpt-5")
|
|
55
|
+
else "max_tokens"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def generate(self, messages: List[Message]) -> LLMResponse:
|
|
59
|
+
# Compatibility layer: prepend system content to user content
|
|
60
|
+
# since some NIM endpoints (e.g. MiniMax) do not support the system role.
|
|
61
|
+
formatted_messages = []
|
|
62
|
+
system_content = ""
|
|
63
|
+
for msg in messages:
|
|
64
|
+
if msg.role == "system":
|
|
65
|
+
system_content += msg.content + "\n"
|
|
66
|
+
else:
|
|
67
|
+
role = msg.role
|
|
68
|
+
content = msg.content
|
|
69
|
+
if system_content:
|
|
70
|
+
content = system_content + "\n" + content
|
|
71
|
+
system_content = ""
|
|
72
|
+
formatted_messages.append({"role": role, "content": content})
|
|
73
|
+
|
|
74
|
+
if system_content and not formatted_messages:
|
|
75
|
+
formatted_messages.append({"role": "user", "content": system_content})
|
|
76
|
+
|
|
77
|
+
request_kwargs = {
|
|
78
|
+
"model": self.model_name,
|
|
79
|
+
"messages": formatted_messages,
|
|
80
|
+
self._token_parameter: 4096,
|
|
81
|
+
}
|
|
82
|
+
for attempt in range(self.max_retries + 1):
|
|
83
|
+
response = self._client.chat.completions.create(
|
|
84
|
+
**request_kwargs,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if not response.choices:
|
|
88
|
+
raise ValueError(f"API returned empty choices. Full response: {response}")
|
|
89
|
+
|
|
90
|
+
response_text = response.choices[0].message.content or ""
|
|
91
|
+
if response_text.strip():
|
|
92
|
+
return LLMResponse(text=response_text)
|
|
93
|
+
|
|
94
|
+
return LLMResponse(text=response_text)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
|
|
6
|
+
from framework.llms.base import BaseLLM, LLMResponse, Message
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenAICompatibleLLM(BaseLLM):
|
|
10
|
+
"""Universal OpenAI-compatible completion client adapter.
|
|
11
|
+
|
|
12
|
+
Supports cloud hosted APIs (OpenAI, NVIDIA NIM, Gemini OpenAI-compat) and
|
|
13
|
+
local user-hosted servers (vLLM, LM Studio, Ollama) via a single base_url.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
model_name: str,
|
|
19
|
+
api_key: Optional[str] = None,
|
|
20
|
+
base_url: Optional[str] = None,
|
|
21
|
+
timeout: float = 180.0,
|
|
22
|
+
max_retries: int = 3,
|
|
23
|
+
retry_delay: float = 1.0,
|
|
24
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
25
|
+
provider: str = "openai_compatible",
|
|
26
|
+
):
|
|
27
|
+
"""Initializes the OpenAICompatibleLLM client.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model_name: Model identifier (e.g. 'gpt-5.6-terra', 'qwen2.5-72b-instruct', 'llama-3.3-70b').
|
|
31
|
+
api_key: API key. Defaults to OPENAI_API_KEY environment variable or 'EMPTY' for local endpoints.
|
|
32
|
+
base_url: Base endpoint URL (e.g. 'http://localhost:8000/v1', 'https://integrate.api.nvidia.com/v1').
|
|
33
|
+
timeout: Request timeout in seconds.
|
|
34
|
+
max_retries: Maximum retry attempts on transient errors.
|
|
35
|
+
retry_delay: Base delay between retries in seconds.
|
|
36
|
+
extra_headers: Optional custom HTTP headers.
|
|
37
|
+
provider: Human-readable provider label.
|
|
38
|
+
"""
|
|
39
|
+
self.model_name = model_name
|
|
40
|
+
self.provider = provider
|
|
41
|
+
self.base_url = base_url or "https://api.openai.com/v1"
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.max_retries = max_retries
|
|
44
|
+
self.retry_delay = retry_delay
|
|
45
|
+
self.extra_headers = extra_headers
|
|
46
|
+
|
|
47
|
+
resolved_api_key = api_key or os.environ.get("OPENAI_API_KEY") or "EMPTY"
|
|
48
|
+
self.client = OpenAI(
|
|
49
|
+
api_key=resolved_api_key,
|
|
50
|
+
base_url=self.base_url,
|
|
51
|
+
timeout=self.timeout,
|
|
52
|
+
default_headers=self.extra_headers,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self.request_count: int = 0
|
|
56
|
+
self.retries_attempted: int = 0
|
|
57
|
+
self.last_error: Optional[str] = None
|
|
58
|
+
self.last_token_usage: Optional[Dict[str, int]] = None
|
|
59
|
+
|
|
60
|
+
def _is_transient_error(self, e: Exception) -> bool:
|
|
61
|
+
"""Determines whether an exception is a transient network/server error suitable for retries."""
|
|
62
|
+
if isinstance(e, (TimeoutError, ConnectionError)):
|
|
63
|
+
return True
|
|
64
|
+
status_code = getattr(e, "status_code", None)
|
|
65
|
+
if status_code is not None:
|
|
66
|
+
if status_code in (429, 500, 502, 503, 504):
|
|
67
|
+
return True
|
|
68
|
+
if 400 <= status_code < 500 and status_code != 429:
|
|
69
|
+
return False
|
|
70
|
+
err_str = str(e).lower()
|
|
71
|
+
if any(k in err_str for k in ("timeout", "connection", "rate limit", "500", "502", "503", "504", "deadline exceeded")):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def generate(self, messages: List[Message]) -> LLMResponse:
|
|
76
|
+
"""Generates a text response from chat messages with retry logic.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
messages: List of Message instances.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
An LLMResponse containing the text output and execution metadata.
|
|
83
|
+
"""
|
|
84
|
+
formatted_messages = [{"role": msg.role, "content": msg.content} for msg in messages]
|
|
85
|
+
|
|
86
|
+
response = None
|
|
87
|
+
last_exception = None
|
|
88
|
+
retries_this_call = 0
|
|
89
|
+
|
|
90
|
+
for attempt in range(1 + self.max_retries):
|
|
91
|
+
try:
|
|
92
|
+
response = self.client.chat.completions.create(
|
|
93
|
+
model=self.model_name,
|
|
94
|
+
messages=formatted_messages,
|
|
95
|
+
)
|
|
96
|
+
break
|
|
97
|
+
except Exception as e:
|
|
98
|
+
last_exception = e
|
|
99
|
+
self.last_error = str(e)
|
|
100
|
+
if attempt < self.max_retries and self._is_transient_error(e):
|
|
101
|
+
retries_this_call += 1
|
|
102
|
+
self.retries_attempted += 1
|
|
103
|
+
time.sleep(self.retry_delay * (2 ** attempt))
|
|
104
|
+
else:
|
|
105
|
+
raise last_exception
|
|
106
|
+
|
|
107
|
+
self.request_count += 1
|
|
108
|
+
self.last_error = None
|
|
109
|
+
|
|
110
|
+
if hasattr(response, "usage") and response.usage:
|
|
111
|
+
self.last_token_usage = {
|
|
112
|
+
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
|
113
|
+
"completion_tokens": getattr(response.usage, "completion_tokens", 0),
|
|
114
|
+
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
|
115
|
+
}
|
|
116
|
+
else:
|
|
117
|
+
self.last_token_usage = None
|
|
118
|
+
|
|
119
|
+
if not getattr(response, "choices", None):
|
|
120
|
+
return LLMResponse(
|
|
121
|
+
text="",
|
|
122
|
+
metadata={
|
|
123
|
+
"error": "Empty choices array in model response",
|
|
124
|
+
"provider": self.provider,
|
|
125
|
+
"model_name": self.model_name,
|
|
126
|
+
"retries_attempted": retries_this_call,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
content = response.choices[0].message.content or ""
|
|
131
|
+
return LLMResponse(
|
|
132
|
+
text=content,
|
|
133
|
+
metadata={
|
|
134
|
+
"provider": self.provider,
|
|
135
|
+
"model_name": self.model_name,
|
|
136
|
+
"token_usage": self.last_token_usage,
|
|
137
|
+
"retries_attempted": retries_this_call,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""MCP integrations for deterministic agent validation tools."""
|
|
2
|
+
|
|
3
|
+
from framework.mcp.constraints import (
|
|
4
|
+
calculate_savings,
|
|
5
|
+
get_locked_constraints,
|
|
6
|
+
validate_revision,
|
|
7
|
+
)
|
|
8
|
+
from framework.mcp.client import TravelValidationMCPClient
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"calculate_savings",
|
|
12
|
+
"get_locked_constraints",
|
|
13
|
+
"validate_revision",
|
|
14
|
+
"TravelValidationMCPClient",
|
|
15
|
+
]
|
|
16
|
+
"""MCP-backed deterministic validation components."""
|
|
17
|
+
|
|
18
|
+
from .revision_summary import RevisionSummary, parse_revision_summary
|
|
19
|
+
|
|
20
|
+
__all__ = ["RevisionSummary", "parse_revision_summary"]
|
framework/mcp/client.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Async client for the local travel-validation MCP server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
from mcp.client.stdio import stdio_client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TravelValidationMCPClient:
|
|
12
|
+
"""Connects to the local travel constraint server over stdio.
|
|
13
|
+
|
|
14
|
+
The client creates a short-lived session per request. This is appropriate for
|
|
15
|
+
the current local validation workflow and keeps the agent integration
|
|
16
|
+
explicit; long-lived connection management is unnecessary until deployment.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, python_executable: Optional[str] = None):
|
|
20
|
+
self.python_executable = python_executable or sys.executable
|
|
21
|
+
|
|
22
|
+
def _server_parameters(self) -> StdioServerParameters:
|
|
23
|
+
return StdioServerParameters(
|
|
24
|
+
command=self.python_executable,
|
|
25
|
+
args=["-m", "framework.mcp.server"],
|
|
26
|
+
env=dict(os.environ),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def list_tools(self) -> List[str]:
|
|
30
|
+
"""Returns the names of tools exposed by the validation server."""
|
|
31
|
+
async with stdio_client(self._server_parameters()) as (read, write):
|
|
32
|
+
async with ClientSession(read, write) as session:
|
|
33
|
+
await session.initialize()
|
|
34
|
+
result = await session.list_tools()
|
|
35
|
+
return [tool.name for tool in result.tools]
|
|
36
|
+
|
|
37
|
+
async def call_tool(
|
|
38
|
+
self, name: str, arguments: Dict[str, Any]
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
"""Calls a tool and returns its structured JSON response.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
RuntimeError: If the MCP server reports an error or returns an
|
|
44
|
+
unexpected non-dictionary payload.
|
|
45
|
+
"""
|
|
46
|
+
async with stdio_client(self._server_parameters()) as (read, write):
|
|
47
|
+
async with ClientSession(read, write) as session:
|
|
48
|
+
await session.initialize()
|
|
49
|
+
result = await session.call_tool(name, arguments)
|
|
50
|
+
|
|
51
|
+
if result.isError:
|
|
52
|
+
raise RuntimeError(f"MCP tool '{name}' failed: {result.content}")
|
|
53
|
+
if not isinstance(result.structuredContent, dict):
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"MCP tool '{name}' returned an unexpected payload: {result.content}"
|
|
56
|
+
)
|
|
57
|
+
payload = dict(result.structuredContent)
|
|
58
|
+
# FastMCP wraps plain dictionary tool returns under ``result``. Keep the
|
|
59
|
+
# application-facing client independent from that transport detail.
|
|
60
|
+
if set(payload) == {"result"} and isinstance(payload["result"], dict):
|
|
61
|
+
return dict(payload["result"])
|
|
62
|
+
return payload
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Deterministic constraint checks used by the travel validation MCP server."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
MID_TRIP_REPLANNING_SCENARIO = "travel-mid-trip-replanning"
|
|
8
|
+
|
|
9
|
+
_SCENARIO_CONSTRAINTS: Dict[str, Dict[str, Any]] = {
|
|
10
|
+
MID_TRIP_REPLANNING_SCENARIO: {
|
|
11
|
+
"scenario_id": MID_TRIP_REPLANNING_SCENARIO,
|
|
12
|
+
"immutable_booking_ids": ["kyoto-hostel", "narita-return-flight"],
|
|
13
|
+
"locked_bookings": [
|
|
14
|
+
{
|
|
15
|
+
"booking_id": "kyoto-hostel",
|
|
16
|
+
"description": "Kyoto hostel stay on Days 15–18",
|
|
17
|
+
"start_day": 15,
|
|
18
|
+
"end_day": 18,
|
|
19
|
+
"reason": "Pre-paid and non-refundable",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"booking_id": "narita-return-flight",
|
|
23
|
+
"description": "Return flight from Narita on Day 28",
|
|
24
|
+
"start_day": 28,
|
|
25
|
+
"end_day": 28,
|
|
26
|
+
"reason": "Non-changeable",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
"required_savings_inr": 20000.0,
|
|
30
|
+
"revision_start_day": 13,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_locked_constraints(scenario_id: str) -> Dict[str, Any]:
|
|
36
|
+
"""Returns immutable bookings and numeric targets for a supported scenario."""
|
|
37
|
+
try:
|
|
38
|
+
return deepcopy(_SCENARIO_CONSTRAINTS[scenario_id])
|
|
39
|
+
except KeyError as error:
|
|
40
|
+
raise ValueError(f"Unsupported scenario: '{scenario_id}'") from error
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_revision(
|
|
44
|
+
scenario_id: str,
|
|
45
|
+
booking_actions: List[Dict[str, str]],
|
|
46
|
+
changed_days: List[int],
|
|
47
|
+
) -> Dict[str, Any]:
|
|
48
|
+
"""Checks whether a proposed revision changes an immutable booking.
|
|
49
|
+
|
|
50
|
+
``booking_actions`` must include one entry for each locked booking with an
|
|
51
|
+
action of ``preserve``, ``move``, ``cancel``, or ``modify``. The function
|
|
52
|
+
intentionally validates only explicit, deterministic assertions; the LLM
|
|
53
|
+
remains responsible for itinerary quality and travel judgment.
|
|
54
|
+
"""
|
|
55
|
+
constraints = get_locked_constraints(scenario_id)
|
|
56
|
+
immutable_ids = constraints["immutable_booking_ids"]
|
|
57
|
+
actions_by_booking = {
|
|
58
|
+
action.get("booking_id"): action.get("action")
|
|
59
|
+
for action in booking_actions
|
|
60
|
+
if action.get("booking_id")
|
|
61
|
+
}
|
|
62
|
+
violations = []
|
|
63
|
+
|
|
64
|
+
for booking_id in immutable_ids:
|
|
65
|
+
action = actions_by_booking.get(booking_id)
|
|
66
|
+
if action is None:
|
|
67
|
+
violations.append(
|
|
68
|
+
f"No preservation assertion was supplied for locked booking '{booking_id}'."
|
|
69
|
+
)
|
|
70
|
+
elif action != "preserve":
|
|
71
|
+
violations.append(
|
|
72
|
+
f"Locked booking '{booking_id}' must be preserved, not '{action}'."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
invalid_days = [
|
|
76
|
+
day
|
|
77
|
+
for day in changed_days
|
|
78
|
+
if not isinstance(day, int) or day < constraints["revision_start_day"]
|
|
79
|
+
]
|
|
80
|
+
if invalid_days:
|
|
81
|
+
violations.append(
|
|
82
|
+
"A replanning revision cannot modify days before Day "
|
|
83
|
+
f"{constraints['revision_start_day']}: {invalid_days}."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"valid": not violations,
|
|
88
|
+
"violations": violations,
|
|
89
|
+
"immutable_booking_ids": immutable_ids,
|
|
90
|
+
"changed_days": changed_days,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def calculate_savings(
|
|
95
|
+
scenario_id: str, savings_items: List[Dict[str, Any]]
|
|
96
|
+
) -> Dict[str, Any]:
|
|
97
|
+
"""Totals explicit savings items against the scenario's required reduction."""
|
|
98
|
+
constraints = get_locked_constraints(scenario_id)
|
|
99
|
+
normalized_items = []
|
|
100
|
+
invalid_items = []
|
|
101
|
+
|
|
102
|
+
for item in savings_items:
|
|
103
|
+
label = str(item.get("label", "")).strip()
|
|
104
|
+
try:
|
|
105
|
+
amount_inr = float(item["amount_inr"])
|
|
106
|
+
except (KeyError, TypeError, ValueError):
|
|
107
|
+
invalid_items.append(item)
|
|
108
|
+
continue
|
|
109
|
+
if not label or amount_inr < 0:
|
|
110
|
+
invalid_items.append(item)
|
|
111
|
+
continue
|
|
112
|
+
normalized_items.append({"label": label, "amount_inr": amount_inr})
|
|
113
|
+
|
|
114
|
+
total_savings_inr = sum(item["amount_inr"] for item in normalized_items)
|
|
115
|
+
target_savings_inr = constraints["required_savings_inr"]
|
|
116
|
+
remaining_gap_inr = max(target_savings_inr - total_savings_inr, 0.0)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"target_savings_inr": target_savings_inr,
|
|
120
|
+
"total_savings_inr": total_savings_inr,
|
|
121
|
+
"remaining_gap_inr": remaining_gap_inr,
|
|
122
|
+
"target_met": not invalid_items and remaining_gap_inr == 0.0,
|
|
123
|
+
"accepted_items": normalized_items,
|
|
124
|
+
"invalid_items": invalid_items,
|
|
125
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Structured, LLM-produced claims for deterministic revision validation.
|
|
2
|
+
|
|
3
|
+
The travel itinerary remains free-form text for the traveler. Before a
|
|
4
|
+
replanning revision is accepted, the model must also provide this small JSON
|
|
5
|
+
summary of the claims that a deterministic tool can verify.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from typing import Any, Dict, List
|
|
10
|
+
|
|
11
|
+
from framework.utils import parse_json_markdown
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class BookingAction:
|
|
16
|
+
"""A declared treatment of one booking in a proposed itinerary revision."""
|
|
17
|
+
|
|
18
|
+
booking_id: str
|
|
19
|
+
action: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SavingsItem:
|
|
24
|
+
"""One concrete cost-saving substitution claimed by the proposed revision."""
|
|
25
|
+
|
|
26
|
+
label: str
|
|
27
|
+
amount_inr: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RevisionSummary:
|
|
32
|
+
"""The complete deterministic-validation contract for a replanning draft."""
|
|
33
|
+
|
|
34
|
+
scenario_id: str
|
|
35
|
+
changed_days: List[int]
|
|
36
|
+
booking_actions: List[BookingAction]
|
|
37
|
+
savings_items: List[SavingsItem]
|
|
38
|
+
|
|
39
|
+
def to_tool_arguments(self) -> Dict[str, Any]:
|
|
40
|
+
"""Returns JSON-compatible arguments for the validation MCP tools."""
|
|
41
|
+
return {
|
|
42
|
+
"scenario_id": self.scenario_id,
|
|
43
|
+
"booking_actions": [asdict(action) for action in self.booking_actions],
|
|
44
|
+
"changed_days": self.changed_days,
|
|
45
|
+
"savings_items": [asdict(item) for item in self.savings_items],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
REVISION_SUMMARY_JSON_TEMPLATE = """{
|
|
50
|
+
"scenario_id": "travel-mid-trip-replanning",
|
|
51
|
+
"changed_days": [13, 19, 22],
|
|
52
|
+
"booking_actions": [
|
|
53
|
+
{"booking_id": "kyoto-hostel", "action": "preserve"},
|
|
54
|
+
{"booking_id": "narita-return-flight", "action": "preserve"}
|
|
55
|
+
],
|
|
56
|
+
"savings_items": [
|
|
57
|
+
{"label": "Kyoto-to-Tokyo night bus instead of Shinkansen", "amount_inr": 12000}
|
|
58
|
+
]
|
|
59
|
+
}"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_revision_summary(text: str) -> RevisionSummary:
|
|
63
|
+
"""Parses and validates the JSON contract emitted by an LLM.
|
|
64
|
+
|
|
65
|
+
This parser intentionally rejects incomplete or ambiguous declarations. A
|
|
66
|
+
model must explicitly state every field before its proposal can be passed
|
|
67
|
+
to the deterministic MCP tools.
|
|
68
|
+
"""
|
|
69
|
+
payload = parse_json_markdown(text)
|
|
70
|
+
if not isinstance(payload, dict):
|
|
71
|
+
raise ValueError("Revision summary must be a JSON object.")
|
|
72
|
+
|
|
73
|
+
scenario_id = payload.get("scenario_id")
|
|
74
|
+
if not isinstance(scenario_id, str) or not scenario_id.strip():
|
|
75
|
+
raise ValueError("Revision summary requires a non-empty scenario_id.")
|
|
76
|
+
|
|
77
|
+
changed_days = payload.get("changed_days")
|
|
78
|
+
if not isinstance(changed_days, list) or not all(
|
|
79
|
+
isinstance(day, int) and not isinstance(day, bool) for day in changed_days
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("changed_days must be a list of integer day numbers.")
|
|
82
|
+
|
|
83
|
+
raw_booking_actions = payload.get("booking_actions")
|
|
84
|
+
if not isinstance(raw_booking_actions, list):
|
|
85
|
+
raise ValueError("booking_actions must be a list.")
|
|
86
|
+
booking_actions = []
|
|
87
|
+
for item in raw_booking_actions:
|
|
88
|
+
if not isinstance(item, dict):
|
|
89
|
+
raise ValueError("Each booking action must be an object.")
|
|
90
|
+
booking_id = item.get("booking_id")
|
|
91
|
+
action = item.get("action")
|
|
92
|
+
if not isinstance(booking_id, str) or not booking_id.strip():
|
|
93
|
+
raise ValueError("Each booking action requires a non-empty booking_id.")
|
|
94
|
+
if action not in {"preserve", "move", "cancel", "modify"}:
|
|
95
|
+
raise ValueError("Each booking action must be preserve, move, cancel, or modify.")
|
|
96
|
+
booking_actions.append(BookingAction(booking_id=booking_id, action=action))
|
|
97
|
+
|
|
98
|
+
raw_savings_items = payload.get("savings_items")
|
|
99
|
+
if not isinstance(raw_savings_items, list):
|
|
100
|
+
raise ValueError("savings_items must be a list.")
|
|
101
|
+
savings_items = []
|
|
102
|
+
for item in raw_savings_items:
|
|
103
|
+
if not isinstance(item, dict):
|
|
104
|
+
raise ValueError("Each savings item must be an object.")
|
|
105
|
+
label = item.get("label")
|
|
106
|
+
amount_inr = item.get("amount_inr")
|
|
107
|
+
if not isinstance(label, str) or not label.strip():
|
|
108
|
+
raise ValueError("Each savings item requires a non-empty label.")
|
|
109
|
+
if (
|
|
110
|
+
not isinstance(amount_inr, (int, float))
|
|
111
|
+
or isinstance(amount_inr, bool)
|
|
112
|
+
or amount_inr < 0
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("Each savings amount_inr must be a non-negative number.")
|
|
115
|
+
savings_items.append(SavingsItem(label=label, amount_inr=float(amount_inr)))
|
|
116
|
+
|
|
117
|
+
return RevisionSummary(
|
|
118
|
+
scenario_id=scenario_id,
|
|
119
|
+
changed_days=changed_days,
|
|
120
|
+
booking_actions=booking_actions,
|
|
121
|
+
savings_items=savings_items,
|
|
122
|
+
)
|
framework/mcp/server.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Local stdio MCP server for travel-planning constraint validation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from framework.mcp.constraints import (
|
|
8
|
+
calculate_savings as calculate_savings_check,
|
|
9
|
+
get_locked_constraints as get_locked_constraints_check,
|
|
10
|
+
validate_revision as validate_revision_check,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
mcp = FastMCP(
|
|
15
|
+
"Travel Constraint Validation",
|
|
16
|
+
instructions=(
|
|
17
|
+
"Use these tools to verify explicit locked-booking constraints and "
|
|
18
|
+
"cost-saving arithmetic for travel itinerary revisions."
|
|
19
|
+
),
|
|
20
|
+
json_response=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def get_locked_constraints(scenario_id: str) -> Dict[str, Any]:
|
|
26
|
+
"""Get immutable bookings and savings targets for a benchmark scenario."""
|
|
27
|
+
return get_locked_constraints_check(scenario_id)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
def validate_revision(
|
|
32
|
+
scenario_id: str,
|
|
33
|
+
booking_actions: List[Dict[str, str]],
|
|
34
|
+
changed_days: List[int],
|
|
35
|
+
) -> Dict[str, Any]:
|
|
36
|
+
"""Check whether a revision preserves every locked booking."""
|
|
37
|
+
return validate_revision_check(scenario_id, booking_actions, changed_days)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@mcp.tool()
|
|
41
|
+
def calculate_savings(
|
|
42
|
+
scenario_id: str, savings_items: List[Dict[str, Any]]
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""Calculate explicit INR savings against a scenario's required reduction."""
|
|
45
|
+
return calculate_savings_check(scenario_id, savings_items)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
mcp.run()
|
framework/memory/base.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Abstract base class for session memory."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseSessionMemory(ABC):
|
|
7
|
+
"""Abstract base class defining the interface for agent session memory."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def to_yaml(self) -> str:
|
|
11
|
+
"""Serializes memory state to structured YAML string for LLM injection."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def to_json(self) -> str:
|
|
16
|
+
"""Serializes memory state to JSON string representation."""
|
|
17
|
+
pass
|