"""LangSmith evaluators for the harmonic analysis agent.""" from langchain_anthropic import ChatAnthropic from langsmith import Client from langsmith.evaluation import EvaluationResult from langsmith.schemas import Example, Run from pydantic import BaseModel _JUDGE_MODEL = "claude-sonnet-4-6" class _MentionedSongs(BaseModel): titles: list[str] def _extract_mentioned_songs(response: str) -> list[str]: judge = ChatAnthropic(model=_JUDGE_MODEL).with_structured_output(_MentionedSongs) result = judge.invoke( "Extract all song titles explicitly mentioned in the following text. " "Return only the titles as a list — no artist names, no explanation.\n\n" + response ) return result.titles _FORMATS_TOOL = "get_supported_chord_formats" _EXPECTED_NOTABLE = frozenset({ "Let It Be", "Ho Hey", "With or Without You", "Stand By Me", "Someone Like You", "Wonderwall", "Can't Help Falling in Love", "Riptide", "Hallelujah", "Piano Man", "Hey Soul Sister", }) def _count_tool_use_in_llm_runs(llm_runs: list[Run], tool_name: str) -> int: """Count calls to a named tool across LLM run outputs. Handles two serialisation formats: - wrap_anthropic (OpenAI-compatible): outputs["tool_calls"][].function.name - LangChain ChatAnthropic: outputs["generations"][][]["message"]["tool_calls"][].name """ count = 0 for r in llm_runs: outputs = r.outputs or {} # wrap_anthropic / OpenAI-compatible format for tc in outputs.get("tool_calls") or []: if tc.get("function", {}).get("name") == tool_name: count += 1 # LangChain ChatAnthropic generations format for gen_list in outputs.get("generations") or []: for gen in gen_list: msg = gen.get("message") or {} msg_data = msg.get("kwargs", msg) for tc in msg_data.get("tool_calls") or []: if tc.get("name") == tool_name: count += 1 return count def evaluate_formats_tool_called_once(run: Run, example: Example) -> EvaluationResult: """Check that the supported-formats tool was called exactly once. :param run: LangSmith run — LLM child runs are fetched via the client. :param example: Dataset example (unused). """ client = Client() llm_runs = list(client.list_runs(trace_id=run.id, run_type="llm")) count = _count_tool_use_in_llm_runs(llm_runs, _FORMATS_TOOL) return EvaluationResult( key="formats_tool_called_once", score=int(count == 1), comment=f"{_FORMATS_TOOL} called {count} time(s)", ) def _title_in_text(title: str, text: str) -> bool: return title.lower() in text.lower() def evaluate_notable_songs(run: Run, example: Example) -> list[dict]: """Precision, recall, and F1 over the expected notable songs. Uses an LLM judge to extract song titles from the prose response, then matches deterministically against ``_EXPECTED_NOTABLE``. All three metrics are derived from a single extraction call. :param run: LangSmith run — expects ``outputs["response"]`` as a string. :param example: Dataset example (unused). """ response = (run.outputs or {}).get("response", "") if not response: return [ {"key": "notable_songs_precision", "score": None, "comment": "no response in run outputs"}, {"key": "notable_songs_recall", "score": None, "comment": "no response in run outputs"}, {"key": "notable_songs_f1", "score": None, "comment": "no response in run outputs"}, ] mentioned = _extract_mentioned_songs(response) mentioned_lower = {t.lower() for t in mentioned} expected_lower = {t.lower() for t in _EXPECTED_NOTABLE} tp = len(mentioned_lower & expected_lower) precision = tp / len(mentioned_lower) if mentioned_lower else 0.0 recall = tp / len(expected_lower) f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0 return [ {"key": "notable_songs_precision", "score": precision, "comment": f"{tp}/{len(mentioned_lower)}"}, {"key": "notable_songs_recall", "score": recall, "comment": f"{tp}/{len(expected_lower)}"}, {"key": "notable_songs_f1", "score": f1, "comment": f"p={precision:.2f} r={recall:.2f}"}, ] def evaluate_result_count(run: Run, example: Example) -> EvaluationResult: """Check the number of returned songs does not exceed the requested limit. :param run: LangSmith run — expects ``outputs["neighbours"]`` as a list. :param example: Dataset example — expects ``inputs["limit"]`` as an int. """ neighbours = (run.outputs or {}).get("neighbours", []) limit = (example.inputs or {}).get("limit") if limit is None: return EvaluationResult( key="result_count_within_limit", score=None, comment="limit not found in dataset example inputs", ) count = len(neighbours) return EvaluationResult( key="result_count_within_limit", score=int(count <= limit), comment=f"{count} results returned, limit was {limit}", )