Skip to content

Commit a56ff13

Browse files
committed
feat: eval模块judge-agent新增json输出修复
TAPD: --story=134193711
1 parent f83ef6a commit a56ff13

3 files changed

Lines changed: 136 additions & 33 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ dependencies = [
5454
"typer>=0.12.0", # MIT License
5555
"watchdog",
5656
"markdownify>=1.2.0", # MIT License
57+
"json-repair>=0.40.0", # MIT License: lenient JSON parsing for LLM judge outputs
5758
]
5859

5960
[project.optional-dependencies]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ charset-normalizer>=3.0.0
4949

5050
litellm>=1.75.5
5151
mempalace==3.3.4
52+
json-repair>=0.40.0

trpc_agent_sdk/evaluation/_llm_judge.py

Lines changed: 134 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@
1111
import copy
1212
import json
1313
import os
14+
import re
1415
import uuid
1516
from typing import Any
1617
from typing import Optional
1718
from typing import Protocol
1819

20+
import json_repair
1921
from pydantic import BaseModel as PydanticBaseModel
22+
from pydantic import field_validator
2023

2124
from trpc_agent_sdk.agents import LlmAgent
2225
from trpc_agent_sdk.context import InvocationContext
@@ -52,7 +55,20 @@
5255
class 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

5874
class 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

67101
class 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

72117
class 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+
417469
class 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

Comments
 (0)