1111import copy
1212import json
1313import os
14+ import re
1415import uuid
1516from typing import Any
1617from typing import Optional
1718from typing import Protocol
1819
20+ import json_repair
1921from pydantic import BaseModel as PydanticBaseModel
22+ from pydantic import field_validator
2023
2124from trpc_agent_sdk .agents import LlmAgent
2225from trpc_agent_sdk .context import InvocationContext
5255class FinalResponseOutput (PydanticBaseModel ):
5356 """Pydantic schema for llm_final_response judge output (reasoning + valid/invalid)."""
5457 reasoning : str
55- is_the_agent_response_valid : str # Must be "valid" or "invalid"
58+ is_the_agent_response_valid : str # "valid" or "invalid"
59+
60+ # Coerce non-string reasoning (e.g. nested object) to JSON repr.
61+ @field_validator ("reasoning" , mode = "before" )
62+ @classmethod
63+ def _stringify_reasoning (cls , v : Any ) -> str :
64+ if v is None :
65+ return ""
66+ if isinstance (v , str ):
67+ return v
68+ try :
69+ return json .dumps (v , ensure_ascii = False )
70+ except (TypeError , ValueError ):
71+ return str (v )
5672
5773
5874class RubricItemOutput (PydanticBaseModel ):
@@ -63,11 +79,40 @@ class RubricItemOutput(PydanticBaseModel):
6379 reason : str
6480 verdict : str # "yes" or "no"
6581
82+ # Coerce scalar types (int/float/bool) into strings before validation.
83+ @field_validator ("id" , "rubric" , "evidence" , "reason" , "verdict" , mode = "before" )
84+ @classmethod
85+ def _stringify (cls , v : Any ) -> str :
86+ if v is None :
87+ return ""
88+ if isinstance (v , str ):
89+ return v
90+ if isinstance (v , bool ):
91+ # bool must precede int (bool is a subclass of int).
92+ return "yes" if v else "no"
93+ if isinstance (v , (int , float )):
94+ return str (v )
95+ try :
96+ return json .dumps (v , ensure_ascii = False )
97+ except (TypeError , ValueError ):
98+ return str (v )
99+
66100
67101class RubricJudgeOutput (PydanticBaseModel ):
68102 """Pydantic schema for llm_rubric_response and llm_rubric_knowledge_recall judge output."""
69103 items : list [RubricItemOutput ]
70104
105+ # Unpack items that were double-serialized as a JSON-encoded string.
106+ @field_validator ("items" , mode = "before" )
107+ @classmethod
108+ def _unpack_items (cls , v : Any ) -> Any :
109+ if isinstance (v , str ):
110+ try :
111+ return json .loads (v )
112+ except (TypeError , ValueError , json .JSONDecodeError ):
113+ return v
114+ return v
115+
71116
72117class MessagesConstructor (Protocol ):
73118 """Builds the user message sent to the judge model from invocations and criterion."""
@@ -414,8 +459,24 @@ def format_user_message(
414459 raise ValueError (f"Unknown metric_name: { metric_name !r} " )
415460
416461
462+ _SALVAGE_MARK = "[salvaged]"
463+
464+ # Verdict tokens recognized by salvage regex; canonicalized to yes/no.
465+ _VERDICT_YES = {"yes" , "true" , "pass" , "passed" , "valid" }
466+ _VERDICT_NO = {"no" , "false" , "fail" , "failed" , "invalid" }
467+
468+
417469class DefaultResponseScorer :
418- """Parses judge JSON into ScoreResult; dispatches by metric (final_response vs rubric)."""
470+ """Parses judge LLM output into a ScoreResult.
471+
472+ Two-layer pipeline:
473+ 1. json_repair.loads + Pydantic.model_validate (handles markdown fences,
474+ single quotes, trailing commas, scalar-type coercion via field
475+ validators on the schema classes).
476+ 2. Regex salvage on the raw text when Pydantic rejects the dict. Extracts
477+ only verdict tokens; does not fabricate rubric/reason/evidence. If no
478+ verdict token is found, the caller raises (no silent zero-score).
479+ """
419480
420481 def parse_response (self , response_text : str , metric_name : str ) -> ScoreResult :
421482 if metric_name == "llm_final_response" :
@@ -425,52 +486,92 @@ def parse_response(self, response_text: str, metric_name: str) -> ScoreResult:
425486 raise ValueError (f"unknown metric_name: { metric_name !r} " )
426487
427488 @staticmethod
428- def _extract_json (text : str ) -> str :
429- """Extract the first JSON object or array from text, stripping any surrounding non-JSON content.
489+ def _load_json (text : str ) -> Any :
490+ """Lenient JSON load via json_repair (falls back to repair parser on failure)."""
491+ return json_repair .loads (text or "" )
430492
431- Example: when the judge model returns Markdown-wrapped JSON, we strip the fence
432- so that model_validate_json receives valid JSON instead of failing on the prefix:
493+ @staticmethod
494+ def _salvage_final_response (text : str ) -> Optional [ScoreResult ]:
495+ """Regex-extract the valid/invalid verdict; return None if not found."""
496+ if not text :
497+ return None
498+ m = re .search (
499+ r'is_the_agent_response_valid["\s:=]+["\']?(valid|invalid)\b' ,
500+ text ,
501+ re .IGNORECASE ,
502+ )
503+ if not m :
504+ return None
505+ label = m .group (1 ).strip ().lower ()
506+ score = 1.0 if label == "valid" else 0.0
507+ return ScoreResult (
508+ score = score ,
509+ reason = f"{ _SALVAGE_MARK } verdict={ label !r} ; reasoning omitted" ,
510+ )
433511
434- input: '```json\\ n{"items": [{"id": "1", "verdict": "yes"}]}\\ n```'
435- output: '{"items": [{"id": "1", "verdict": "yes"}]}'
512+ @staticmethod
513+ def _salvage_rubric_response (text : str ) -> Optional [ScoreResult ]:
514+ """Regex-extract verdict tokens; return None if none found.
436515
437- Without this, Pydantic would raise: Invalid JSON: expected value at line 1 column 1
438- ( because the first character is '`' rather than '{' or '[') .
516+ Only verdict values are scraped — id/rubric/evidence/reason are not,
517+ because positional alignment across free-form fields is unreliable .
439518 """
440- text = (text or "" ).strip ()
441- # Strip markdown code fence (e.g. ```json ... ```) so model output wrapped in code block still parses
442- if text .startswith ("```" ):
443- first_line , _ , rest = text .partition ("\n " )
444- if first_line in ("```json" , "```" ):
445- text = rest
446- text = text .strip ()
447- if text .endswith ("```" ):
448- text = text [:- 3 ].strip ()
449- for start_char , end_char in [("{" , "}" ), ("[" , "]" )]:
450- start = text .find (start_char )
451- if start == - 1 :
452- continue
453- end = text .rfind (end_char )
454- if end > start :
455- return text [start :end + 1 ]
456- return text
519+ if not text :
520+ return None
521+ # `\\?` tolerates escaped quotes from double-serialized JSON strings.
522+ matches = re .findall (
523+ r'\\?["\']verdict\\?["\']\s*:\s*\\?["\']([A-Za-z]+)\\?["\']' ,
524+ text ,
525+ )
526+ scores : list [float ] = []
527+ for raw in matches :
528+ tok = raw .strip ().lower ()
529+ if tok in _VERDICT_YES :
530+ scores .append (1.0 )
531+ elif tok in _VERDICT_NO :
532+ scores .append (0.0 )
533+ # Unknown tokens dropped; never mapped to a guessed score.
534+ if not scores :
535+ return None
536+ rubric_scores = [
537+ RubricScore (
538+ id = f"salvaged_{ i } " ,
539+ reason = f"{ _SALVAGE_MARK } original rubric text omitted" ,
540+ score = s ,
541+ )
542+ for i , s in enumerate (scores )
543+ ]
544+ avg = sum (scores ) / len (scores )
545+ return ScoreResult (
546+ score = avg ,
547+ reason = f"{ _SALVAGE_MARK } extracted { len (scores )} verdict(s); rubric/evidence/reason omitted" ,
548+ rubric_scores = rubric_scores ,
549+ )
457550
458551 def _parse_final_response (self , response_text : str ) -> ScoreResult :
459552 try :
460- obj = FinalResponseOutput .model_validate_json (self ._extract_json (response_text ))
553+ data = self ._load_json (response_text )
554+ obj = FinalResponseOutput .model_validate (data )
461555 except Exception as e :
462- response_preview = f"; got: { (response_text or '' )[:200 ]!r} " if response_text else ""
463- raise ValueError (f"failed to parse final response JSON: { e } { response_preview } " ) from e
556+ salvaged = self ._salvage_final_response (response_text )
557+ if salvaged is not None :
558+ return salvaged
559+ preview = f"; got: { (response_text or '' )[:200 ]!r} " if response_text else ""
560+ raise ValueError (f"failed to parse final response JSON: { e } { preview } " ) from e
464561 label = obj .is_the_agent_response_valid .strip ().lower ()
465562 score = 1.0 if label == "valid" else 0.0
466563 return ScoreResult (score = score , reason = obj .reasoning .strip ())
467564
468565 def _parse_rubric_response (self , response_text : str ) -> ScoreResult :
469566 try :
470- obj = RubricJudgeOutput .model_validate_json (self ._extract_json (response_text ))
567+ data = self ._load_json (response_text )
568+ obj = RubricJudgeOutput .model_validate (data )
471569 except Exception as e :
472- response_preview = f"; got: { (response_text or '' )[:500 ]!r} " if response_text else ""
473- raise ValueError (f"failed to parse rubric response JSON: { e } { response_preview } " ) from e
570+ salvaged = self ._salvage_rubric_response (response_text )
571+ if salvaged is not None :
572+ return salvaged
573+ preview = f"; got: { (response_text or '' )[:500 ]!r} " if response_text else ""
574+ raise ValueError (f"failed to parse rubric response JSON: { e } { preview } " ) from e
474575 if not obj .items :
475576 raise ValueError ("rubric response JSON contains empty items array" )
476577 rubric_scores : list [RubricScore ] = []
0 commit comments