1+ import json
2+ import os
13import re
24from pathlib import Path
35from typing import Any
46
7+ import litellm
8+
59from eval_protocol .models import (
610 EvaluateResult ,
711 EvaluationRow ,
812 Message ,
913 MetricResult ,
1014 ChatCompletionContentPartTextParam ,
1115)
12- from eval_protocol .pytest .default_single_turn_rollout_process import (
13- SingleTurnRolloutProcessor ,
14- )
16+ from eval_protocol .pytest .default_single_turn_rollout_process import SingleTurnRolloutProcessor
1517from eval_protocol .pytest .evaluation_test import evaluation_test
1618
1719DATASET_PATH = Path (__file__ ).resolve ().parents [1 ] / "data" / "vision_food_reasoning_sample.jsonl"
18- DATASET_SOURCE_ID = "fireworks-ai/vision-food-reasoning-dataset"
1920
2021_BOLD_LABEL_PATTERN = re .compile (r"\*\*(?P<label>[^*]+)\*\*" )
2122_APPEARS_PATTERN = re .compile (r"appears to be\s+(?P<label>[A-Za-z0-9_\- ]+)" , re .IGNORECASE )
@@ -84,35 +85,6 @@ def _extract_label_from_text(text: str) -> str | None:
8485 return None
8586
8687
87- def vision_food_reasoning_dataset_adapter (rows : list [dict [str , Any ]]) -> list [EvaluationRow ]:
88- adapted : list [EvaluationRow ] = []
89- for idx , raw in enumerate (rows ):
90- messages_payload = raw .get ("messages" )
91- if not isinstance (messages_payload , list ) or len (messages_payload ) < 2 :
92- continue
93- try :
94- user_message = Message .model_validate (messages_payload [0 ])
95- assistant_reference = Message .model_validate (messages_payload [- 1 ])
96- except Exception :
97- continue
98- reference_text = _content_to_text (assistant_reference .content )
99- raw_label = _extract_label_from_text (reference_text )
100- normalized_label = _normalize_label (raw_label )
101- if not normalized_label :
102- continue
103- row = EvaluationRow (messages = [user_message ])
104- row .input_metadata .row_id = str (raw .get ("id" ) or f"vision_food_reasoning_{ idx } " )
105- row .input_metadata .dataset_info = {
106- "source" : DATASET_SOURCE_ID ,
107- "normalized_label" : normalized_label ,
108- }
109- row .ground_truth = {"label" : normalized_label , "raw_label" : raw_label or "" }
110- adapted .append (row )
111- if not adapted :
112- raise RuntimeError ("Vision food reasoning adapter returned no usable rows." )
113- return adapted
114-
115-
11688def _extract_prediction (row : EvaluationRow ) -> tuple [str , str ]:
11789 assistant_messages = [m for m in row .messages if m .role == "assistant" ]
11890 if not assistant_messages :
@@ -122,6 +94,80 @@ def _extract_prediction(row: EvaluationRow) -> tuple[str, str]:
12294 return label , text
12395
12496
97+ def _llm_equivalence_check (
98+ ground_truth_label : str ,
99+ prediction_text : str ,
100+ * ,
101+ judge_model : str | None = None ,
102+ ) -> tuple [bool , str ]:
103+ model_name = (
104+ judge_model
105+ or os .getenv ("VISION_FOOD_REASONING_JUDGE_MODEL" )
106+ or "fireworks_ai/accounts/fireworks/models/gpt-oss-120b"
107+ )
108+ prediction_text = prediction_text .strip ()
109+ if not prediction_text :
110+ return False , "LLM judge skipped: prediction text is empty."
111+
112+ system_prompt = (
113+ "You are a strict food classification judge. "
114+ "Given the ground-truth dish label and a model response, decide whether the response "
115+ "unambiguously identifies the same dish. "
116+ "Only consider the final answer portion; ignore speculation or unrelated commentary. "
117+ 'Respond with compact JSON like {"equivalent": true, "reason": "..."}.'
118+ )
119+ user_prompt = (
120+ "GROUND TRUTH LABEL: {label}\n "
121+ 'MODEL RESPONSE:\n """\n {response}\n """\n \n '
122+ "If the model clearly identifies the same dish, set equivalent=true, otherwise false. "
123+ "Explain the decision in the reason."
124+ ).format (label = ground_truth_label , response = prediction_text )
125+
126+ try :
127+ completion = litellm .completion (
128+ model = model_name ,
129+ temperature = 0 ,
130+ messages = [
131+ {"role" : "system" , "content" : system_prompt },
132+ {"role" : "user" , "content" : user_prompt },
133+ ],
134+ )
135+ raw_content = completion ["choices" ][0 ]["message" ]["content" ]
136+ except Exception as exc : # pragma: no cover - depends on external service
137+ return False , f"LLM judge failed: { exc } "
138+
139+ if not raw_content :
140+ return False , "LLM judge returned empty response."
141+
142+ parsed = _parse_json_blob (raw_content )
143+ if not isinstance (parsed , dict ):
144+ return False , f"LLM judge did not return JSON: { raw_content } "
145+
146+ decision = parsed .get ("equivalent" )
147+ reason = parsed .get ("reason" ) or "LLM judge provided no reason."
148+ if isinstance (decision , str ):
149+ decision = decision .strip ().lower () in {"true" , "yes" , "1" }
150+ elif not isinstance (decision , bool ):
151+ decision = False
152+ reason = f"LLM judge missing boolean decision. Raw: { raw_content } "
153+ return bool (decision ), reason
154+
155+
156+ def _parse_json_blob (blob : str ) -> Any :
157+ try :
158+ return json .loads (blob )
159+ except json .JSONDecodeError :
160+ start = blob .find ("{" )
161+ end = blob .rfind ("}" )
162+ if start != - 1 and end != - 1 and start < end :
163+ snippet = blob [start : end + 1 ]
164+ try :
165+ return json .loads (snippet )
166+ except json .JSONDecodeError :
167+ return None
168+ return None
169+
170+
125171def _ground_truth_label (row : EvaluationRow ) -> str :
126172 if isinstance (row .ground_truth , dict ):
127173 return _normalize_label (row .ground_truth .get ("label" ))
@@ -132,10 +178,9 @@ def _ground_truth_label(row: EvaluationRow) -> str:
132178
133179@evaluation_test (
134180 input_dataset = [str (DATASET_PATH )],
135- dataset_adapter = vision_food_reasoning_dataset_adapter ,
136181 completion_params = [
137182 {
138- "model" : "fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b -instruct" ,
183+ "model" : "fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b -instruct" ,
139184 # "max_tokens": 512,
140185 # "model": "openrouter/qwen/qwen3-vl-30b-a3b-instruct",
141186 # "model": "gpt-4.1-mini",
@@ -151,8 +196,8 @@ def _ground_truth_label(row: EvaluationRow) -> str:
151196 rollout_processor = SingleTurnRolloutProcessor (),
152197 aggregation_method = "mean" ,
153198 passed_threshold = None ,
154- num_runs = 1 ,
155199 max_dataset_rows = 10 ,
200+ num_runs = 1 ,
156201 mode = "pointwise" ,
157202)
158203def test_vision_food_reasoning (row : EvaluationRow ) -> EvaluationRow :
@@ -161,11 +206,23 @@ def test_vision_food_reasoning(row: EvaluationRow) -> EvaluationRow:
161206
162207 is_valid = bool (predicted_label )
163208 is_correct = is_valid and predicted_label == ground_truth_label and bool (ground_truth_label )
209+
210+ llm_equivalent = False
211+ llm_reason = "LLM judge not triggered."
212+ if not is_correct and ground_truth_label :
213+ llm_equivalent , llm_reason = _llm_equivalence_check (ground_truth_label , raw_prediction )
214+ if llm_equivalent :
215+ is_correct = True
216+ is_valid = True
217+
164218 score = 1.0 if is_correct else 0.0
219+ reason = "Prediction matches ground truth" if is_correct else "Prediction did not match"
220+ if llm_equivalent :
221+ reason = "LLM judge considered the prediction equivalent to the ground truth."
165222
166223 row .evaluation_result = EvaluateResult (
167224 score = score ,
168- reason = "Prediction matches ground truth" if is_correct else "Prediction did not match" ,
225+ reason = reason ,
169226 is_score_valid = is_valid ,
170227 metrics = {
171228 "exact_match" : MetricResult (
@@ -177,7 +234,12 @@ def test_vision_food_reasoning(row: EvaluationRow) -> EvaluationRow:
177234 "ground_truth_label" : ground_truth_label ,
178235 "raw_prediction" : raw_prediction ,
179236 },
180- )
237+ ),
238+ "llm_equivalence" : MetricResult (
239+ score = 1.0 if llm_equivalent else 0.0 ,
240+ is_score_valid = llm_equivalent ,
241+ reason = llm_reason ,
242+ ),
181243 },
182244 )
183245 return row
0 commit comments