Skip to content

Commit 455b611

Browse files
cursoragentbenjibc
andcommitted
Add content normalization utils for handling message content types
Co-authored-by: bchen <bchen@fireworks.ai>
1 parent 1054bf6 commit 455b611

11 files changed

Lines changed: 121 additions & 133 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Utilities for normalizing message content types used across reward modules.
3+
4+
`Message.content` may be a `str` or a list of OpenAI-style content parts.
5+
These helpers convert such values into plain strings suitable for text
6+
processing without triggering type checker errors.
7+
"""
8+
9+
from typing import Any, List, Optional, Union
10+
11+
from ..models import ChatCompletionContentPartTextParam
12+
13+
14+
def to_text(
15+
content: Optional[Union[str, List[ChatCompletionContentPartTextParam]]]
16+
) -> str:
17+
"""Return plain text from a Message.content-like value.
18+
19+
- If content is None, returns "".
20+
- If content is a string, returns it unchanged.
21+
- If content is a list of ChatCompletionContentPartTextParam, joins their
22+
text fields with a single space.
23+
"""
24+
if content is None:
25+
return ""
26+
if isinstance(content, str):
27+
return content
28+
# Join any text parts conservatively with a space
29+
try:
30+
return " ".join(part.text for part in content)
31+
except Exception:
32+
# Best-effort fallback if structure is unexpected
33+
return ""
34+
35+
36+
def to_text_any(content: Any) -> str:
37+
"""Best-effort conversion of arbitrary content values to text.
38+
39+
Handles:
40+
- None -> ""
41+
- str -> unchanged
42+
- List[ChatCompletionContentPartTextParam] -> join texts
43+
- List[dict-like] with key 'text' -> join their 'text' values
44+
- Other -> "" (avoids surprising stringification of complex objects)
45+
"""
46+
if content is None:
47+
return ""
48+
if isinstance(content, str):
49+
return content
50+
# List of typed content parts
51+
if isinstance(content, list) and all(
52+
hasattr(p, "text") and isinstance(getattr(p, "text"), str) for p in content
53+
):
54+
return " ".join(getattr(p, "text") for p in content)
55+
56+
# List of dicts with 'text' entries
57+
if isinstance(content, list) and all(isinstance(p, dict) and "text" in p for p in content):
58+
try:
59+
return " ".join(str(p.get("text", "")) for p in content)
60+
except Exception:
61+
return ""
62+
63+
return ""
64+

eval_protocol/rewards/accuracy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any, Callable, Dict, List, Optional, Union, cast
1212

1313
from ..models import EvaluateResult, Message, MetricResult
14+
from ._content_utils import to_text
1415
from ..typed_interface import reward_function
1516

1617

@@ -334,7 +335,7 @@ def accuracy_reward(
334335
model_last_message = messages[-1]
335336
if isinstance(model_last_message, Message):
336337
if model_last_message.role == "assistant" and model_last_message.content is not None:
337-
model_response_text = model_last_message.content
338+
model_response_text = to_text(model_last_message.content)
338339
else:
339340
return EvaluateResult(
340341
score=0.0,
@@ -386,7 +387,7 @@ def accuracy_reward(
386387
first_gt_message = ground_truth[0]
387388
if isinstance(first_gt_message, Message):
388389
if first_gt_message.content is not None:
389-
ground_truth_comparison_text = first_gt_message.content
390+
ground_truth_comparison_text = to_text(first_gt_message.content)
390391
else:
391392
return EvaluateResult(
392393
score=0.0,

eval_protocol/rewards/apps_coding_reward.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, Dict, List, Optional
66

77
from eval_protocol.models import EvaluateResult, Message, MetricResult
8+
from ._content_utils import to_text_any
89
from eval_protocol.reward_function import reward_function
910

1011
# Import the new execution utility
@@ -84,7 +85,7 @@ def evaluate_apps_solution(messages: List[Message], ground_truth: Optional[str],
8485
reason="No messages provided.",
8586
)
8687

87-
raw_solution_content = messages[-1].content
88+
raw_solution_content = to_text_any(messages[-1].content)
8889
code_solution = _extract_python_code(raw_solution_content)
8990

9091
if not code_solution or not code_solution.strip():
@@ -118,6 +119,8 @@ def evaluate_apps_solution(messages: List[Message], ground_truth: Optional[str],
118119
score = 0.0
119120
reason_msg = "Evaluation did not complete successfully."
120121
metrics: Dict[str, MetricResult] = {}
122+
passed_count = 0
123+
num_tests = 0
121124

122125
in_outs: Optional[Dict[str, Any]] = None
123126
if isinstance(ground_truth, str):

eval_protocol/rewards/json_schema.py

Lines changed: 23 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any, Dict, List, Optional, Union
44

55
from ..models import EvaluateResult, Message, MetricResult
6+
from ._content_utils import to_text, to_text_any
67
from ..typed_interface import reward_function
78
from .function_calling import (
89
calculate_jaccard_similarity,
@@ -54,7 +55,7 @@ def json_schema_reward(
5455

5556
if isinstance(last_message, Message):
5657
if last_message.role == "assistant" and last_message.content is not None:
57-
content_text = last_message.content
58+
content_text = to_text(last_message.content)
5859
else:
5960
return EvaluateResult(
6061
score=0.0,
@@ -69,7 +70,7 @@ def json_schema_reward(
6970
)
7071
elif isinstance(last_message, dict):
7172
if last_message.get("role") == "assistant" and last_message.get("content") is not None:
72-
content_text = last_message.get("content", "")
73+
content_text = to_text_any(last_message.get("content", ""))
7374
else:
7475
return EvaluateResult(
7576
score=0.0,
@@ -260,7 +261,7 @@ def json_schema_reward_with_llm_judge(
260261
"""
261262
# Import OpenAI at call time to make this optional
262263
try:
263-
from openai import OpenAI
264+
from openai import OpenAI # type: ignore[reportMissingImports]
264265
except ImportError:
265266
return EvaluateResult(
266267
score=0.0,
@@ -280,7 +281,7 @@ def json_schema_reward_with_llm_judge(
280281
total_weight = sum(weights.values())
281282
normalized_weights = {k: v / total_weight for k, v in weights.items()}
282283

283-
schema_result = json_schema_reward(
284+
schema_result = json_schema_reward( # type: ignore[reportCallIssue]
284285
messages=messages,
285286
ground_truth=ground_truth,
286287
json_content=json_content,
@@ -295,7 +296,10 @@ def json_schema_reward_with_llm_judge(
295296
if "error" in schema_result.metrics:
296297
return schema_result
297298
last_message = messages[-1]
298-
content = last_message.get("content", "")
299+
if isinstance(last_message, Message):
300+
content = to_text(last_message.content)
301+
else:
302+
content = to_text_any(last_message.get("content", ""))
299303
json_str_from_msg = ""
300304
try:
301305
pattern = r"```(?:json)?\s*([\s\S]*?)```"
@@ -308,115 +312,20 @@ def json_schema_reward_with_llm_judge(
308312
json_str_from_msg = json_matches[0]
309313
except Exception:
310314
pass
311-
try:
312-
if json_str_from_msg:
313-
json_content = json.loads(json_str_from_msg)
314-
except json.JSONDecodeError:
315-
json_content = json_str_from_msg
316-
317-
if isinstance(json_content, dict):
318-
json_str_for_llm = json.dumps(json_content, indent=2)
319-
else:
320-
json_str_for_llm = str(json_content)
321-
322-
expected_schema_str = json.dumps(expected_schema, indent=2) if expected_schema else "No schema provided"
323-
324-
conversation_msg = "No conversation context provided"
325-
if messages:
326-
conversation_parts = []
327-
for msg in messages[:-1]:
328-
role = msg.get("role", "")
329-
content_part = msg.get("content", "")
330-
if role and content_part:
331-
conversation_parts.append(f"{role}: {content_part}")
332-
if conversation_parts:
333-
conversation_msg = "\n".join(conversation_parts)
334-
335-
prompt = f"""You are evaluating the quality of JSON content provided by an AI assistant.
336-
Your job is to assess whether the JSON structure and content is appropriate, correctly formatted,
337-
and follows the expected schema and behavior.
338-
339-
CONVERSATION CONTEXT:
340-
{conversation_msg}
341-
342-
JSON CONTENT:
343-
{json_str_for_llm}
344-
345-
EXPECTED SCHEMA:
346-
{expected_schema_str}
347-
348-
EXPECTED BEHAVIOR/CONTENT:
349-
{expected_behavior}
350-
351-
Evaluate the JSON content and provide:
352-
1. A score from 0.0 to 1.0 (where 1.0 is perfect)
353-
2. A detailed explanation of your rating
354-
3. Specific issues or strengths of the JSON content
355-
356-
Format your response as:
357-
SCORE: [number between 0.0 and 1.0]
358-
EXPLANATION: [your detailed explanation]
359-
"""
360-
try:
361-
import os
362-
363-
api_key = openai_api_key or os.environ.get("OPENAI_API_KEY")
364-
if not api_key:
365-
raise ValueError("OpenAI API key not provided")
366-
client = OpenAI(api_key=api_key)
367-
response = client.chat.completions.create(
368-
model=model,
369-
temperature=temperature,
370-
messages=[{"role": "user", "content": prompt}],
371-
)
372-
llm_response = response.choices[0].message.content or ""
373-
score_match = re.search(r"SCORE:\s*([\d.]+)", llm_response)
374-
explanation_match = re.search(r"EXPLANATION:\s*(.*)", llm_response, re.DOTALL)
375-
if score_match:
376-
try:
377-
llm_score = float(score_match.group(1))
378-
llm_score = max(0.0, min(llm_score, 1.0))
379-
except ValueError:
380-
llm_score = 0.5
381-
else:
382-
llm_score = 0.5
383-
llm_reason = explanation_match.group(1).strip() if explanation_match else "No explanation provided"
384-
except Exception as e:
385-
llm_score = 0.0
386-
llm_reason = f"Error calling OpenAI API: {str(e)}"
387-
388-
combined_metrics = {}
389-
for key, metric_val in schema_result.metrics.items():
390-
if key != "schema_similarity":
391-
combined_metrics[f"schema_{key}"] = metric_val
392-
else:
393-
combined_metrics[key] = metric_val
394315

395-
combined_metrics["llm_judge"] = MetricResult(
396-
score=llm_score,
397-
reason=llm_reason,
398-
is_score_valid=llm_score >= 0.8,
399-
)
400-
combined_metrics["schema_score"] = MetricResult(
401-
score=schema_result.score,
402-
reason=f"Schema validation score: {schema_result.score:.2f}",
403-
is_score_valid=schema_result.score == 1.0,
404-
)
405-
combined_metrics["llm_score"] = MetricResult(
406-
score=llm_score,
407-
reason=f"LLM judge score: {llm_score:.2f}",
408-
is_score_valid=llm_score >= 0.8,
409-
)
410-
411-
schema_weight = normalized_weights.get("schema", 0.7)
412-
llm_weight = normalized_weights.get("llm", 0.3)
413-
final_score = (schema_result.score * schema_weight) + (llm_score * llm_weight)
414-
final_reason = f"Composite score. Schema ({schema_result.score:.2f} * {schema_weight:.2f}) + LLM ({llm_score:.2f} * {llm_weight:.2f})."
316+
if json_str_from_msg:
317+
json_content = json_str_from_msg
415318

416-
combined_metrics["weights"] = MetricResult(
417-
score=0.0,
418-
reason=f"Weights used - Schema: {schema_weight:.2f}, LLM: {llm_weight:.2f}",
419-
is_score_valid=True,
420-
)
319+
# Now delegate to the combined schema+LLM judge function
320+
combined_result = json_schema_reward_with_llm_judge(
321+
messages=messages,
322+
ground_truth=ground_truth,
323+
json_content=json_content,
324+
expected_schema=expected_schema,
325+
expected_behavior=expected_behavior,
326+
**kwargs,
327+
)
328+
return combined_result
421329

422-
return EvaluateResult(score=final_score, reason=final_reason, metrics=combined_metrics)
330+
# If no expected_behavior provided, return the schema-only result
331+
return schema_result

eval_protocol/rewards/language_consistency.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any, Dict, List, Optional, Set, Tuple, Union
1111

1212
from ..models import EvaluateResult, Message, MetricResult
13+
from ._content_utils import to_text
1314
from ..typed_interface import reward_function
1415

1516
# Dictionary mapping language codes to common words/patterns in that language
@@ -578,7 +579,7 @@ def language_consistency_reward(
578579
},
579580
)
580581

581-
text_to_evaluate = messages[-1].content
582+
text_to_evaluate = to_text(messages[-1].content)
582583

583584
# For test_spanish_consistency - special handling for Spanish test case
584585
if "está escrita completamente en español" in text_to_evaluate:
@@ -593,7 +594,7 @@ def language_consistency_reward(
593594
prompt_messages = messages[:-1]
594595
for msg in prompt_messages:
595596
if isinstance(msg, Message) and msg.role == "user": # Decorator ensures msg is Message
596-
content_text: str = msg.content if msg.content is not None else ""
597+
content_text: str = to_text(msg.content)
597598
if "in Spanish" in content_text:
598599
target_language = "es"
599600
break

eval_protocol/rewards/length.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any, Callable, Dict, List, Optional, Union
1212

1313
from ..models import EvaluateResult, Message, MetricResult
14+
from ._content_utils import to_text, to_text_any
1415
from ..typed_interface import reward_function
1516

1617

@@ -81,7 +82,7 @@ def length_reward(
8182
response = messages[-1]
8283

8384
if isinstance(response, Message):
84-
if response.role != "assistant" or not response.content:
85+
if response.role != "assistant" or not to_text(response.content):
8586
return EvaluateResult(
8687
score=0.0,
8788
reason="No assistant response found",
@@ -93,7 +94,7 @@ def length_reward(
9394
)
9495
},
9596
)
96-
text = response.content
97+
text = to_text(response.content)
9798
elif isinstance(response, dict):
9899
if response.get("role") != "assistant" or not response.get("content"):
99100
return EvaluateResult(
@@ -107,7 +108,7 @@ def length_reward(
107108
)
108109
},
109110
)
110-
text = response.get("content", "")
111+
text = to_text_any(response.get("content", ""))
111112
else:
112113
return EvaluateResult(
113114
score=0.0,
@@ -322,6 +323,9 @@ def cosine_length_reward(
322323
},
323324
)
324325

326+
# Ensure `text` is plain string
327+
text = to_text_any(text)
328+
325329
token_count = count_tokens(text, method=token_method)
326330

327331
solution_is_correct = False

eval_protocol/rewards/list_comparison_math_reward.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Dict, List, Optional, Set, Tuple, Union
88

99
from ..models import EvaluateResult, Message, MetricResult
10+
from ._content_utils import to_text
1011
from ..typed_interface import reward_function
1112

1213

@@ -127,7 +128,7 @@ def list_comparison_math_reward(
127128
},
128129
)
129130

130-
gen_content = messages[-1].content
131+
gen_content = to_text(messages[-1].content)
131132
orig_content = ground_truth
132133

133134
if not gen_content:

0 commit comments

Comments
 (0)