Repository navigation
fix(evaluations): Compact traces for prompt #1274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2d367dc
a296477
023e05a
ba0bcb5
7892156
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import copy | ||
| import json | ||
| import logging | ||
| from typing import NotRequired, TypedDict, cast | ||
| from uuid import UUID | ||
|
|
||
| import anthropic | ||
|
|
@@ -28,6 +29,8 @@ | |
| GROUND_TRUTH_SCORE_NAME, | ||
| KNOWLEDGE_BASE_SCORE_NAME, | ||
| PROMPT_SCORE_NAME, | ||
| TraceData, | ||
| TraceScore, | ||
| ) | ||
| from app.crud.jobs import JobCrud | ||
| from app.models.config.config import ConfigTag | ||
|
|
@@ -54,14 +57,12 @@ | |
| # Headroom for a full prompt rewrite + JSON wrapper; too low truncates into invalid JSON. | ||
| _LLM_MAX_TOKENS = 16384 | ||
|
|
||
| # JSON keys expected in the LLM's structured response. | ||
| _LLM_KEY_INSTRUCTIONS = "improved_instructions" | ||
| _LLM_KEY_RATIONALE = "rationale" | ||
|
|
||
| # Cap the rationale so it stays a one-line summary in the commit_message. | ||
| _RATIONALE_MAX_LENGTH = 200 | ||
|
|
||
| # Structured-output schema: guarantees the response is valid JSON with these fields. | ||
| _OUTPUT_SCHEMA = { | ||
| "type": "object", | ||
| "properties": { | ||
|
|
@@ -134,7 +135,6 @@ def validate_improve_prompt( | |
| detail=f"evaluation_not_completed: run status is '{run.status}', must be '{_COMPLETED_STATUS}'", | ||
| ) | ||
| if not run.score_trace_url: | ||
| # Run exists but a precondition is unmet, so 422 rather than 404. | ||
| raise HTTPException( | ||
| status_code=422, | ||
| detail=( | ||
|
|
@@ -143,7 +143,6 @@ def validate_improve_prompt( | |
| ), | ||
| ) | ||
| if run.config_id is None or run.config_version is None: | ||
| # Both FKs are nullable; without them there's no version to improve. | ||
| raise HTTPException( | ||
| status_code=422, | ||
| detail="source_config_unavailable: run has no config_id/config_version reference", | ||
|
|
@@ -406,9 +405,9 @@ def execute_prompt_improvement( | |
| traces = load_json_from_object_store( | ||
| storage=storage, url=run.score_trace_url | ||
| ) | ||
| if traces is None: | ||
| if not isinstance(traces, list): | ||
| raise RuntimeError( | ||
| "trace_download_failed: could not retrieve trace file from storage" | ||
| "trace_download_failed: could not retrieve trace list from storage" | ||
| ) | ||
|
|
||
| improved_instructions, rationale = _draft_improved_prompt( | ||
|
|
@@ -484,8 +483,6 @@ def execute_prompt_improvement( | |
| ) | ||
| raise | ||
| except Exception as exc: | ||
| # HTTPException (from re-validation) carries the clean detail; everything | ||
| # else falls back to str(exc). | ||
| error_message = getattr(exc, "detail", None) or str(exc) | ||
| logger.error( | ||
| f"[execute_prompt_improvement] Failed | job_id={job_id} " | ||
|
|
@@ -565,7 +562,6 @@ def _call_prompt_drafting_llm(*, user_message_text: str) -> tuple[str, str]: | |
| per-cause log lines are kept so operators can still triage the fault. | ||
| """ | ||
| if not settings.ANTHROPIC_API_KEY: | ||
| # Missing platform key is a server-side misconfiguration, not an upstream fault. | ||
| raise RuntimeError( | ||
| "prompt_generation_failed: the platform Anthropic key " | ||
| "(ANTHROPIC_API_KEY) is not configured" | ||
|
|
@@ -646,11 +642,83 @@ def _call_prompt_drafting_llm(*, user_message_text: str) -> tuple[str, str]: | |
| ) from exc | ||
|
|
||
|
|
||
| class TraceScoreSummary(TypedDict): | ||
| """A TraceScore without the UI-only fields (data_type, verdict).""" | ||
|
|
||
| name: str | ||
| value: float | str | ||
| comment: NotRequired[str] | ||
| unscoreable: NotRequired[bool] | ||
|
|
||
|
|
||
| class TraceAttempt(TypedDict): | ||
| """One run of a question: its answer and scores.""" | ||
|
|
||
| llm_answer: str | ||
| scores: list[TraceScoreSummary] | ||
|
|
||
|
|
||
| class TraceQuestion(TypedDict): | ||
| """A dataset question with one attempt per duplication-factor repeat.""" | ||
|
|
||
| question_id: int | str | None | ||
| question: str | ||
| ground_truth_answer: str | ||
| attempts: list[TraceAttempt] | ||
|
|
||
|
|
||
| def _trace_score_summary(score: TraceScore) -> TraceScoreSummary: | ||
| return cast( | ||
| TraceScoreSummary, | ||
| {k: v for k, v in score.items() if k not in ("data_type", "verdict")}, | ||
| ) | ||
|
|
||
|
|
||
| def _format_traces_for_brief(traces: list[TraceData]) -> list[TraceQuestion]: | ||
| """Group repeated attempts per question and keep only what the brief reads. | ||
|
|
||
| A 500-row Hindi judge run blew past Claude's 1M-token context: every repeat | ||
| (duplication factor) re-shipped question + ground truth, and trace_id / | ||
| data_type / verdict / guardrail bookkeeping rode along for nothing. Rows the | ||
| guardrails rewrote substitute `input_to_llm` / `output_from_llm`, since the | ||
| prompt under repair acts on what the LLM actually saw and produced, not on | ||
| the user-facing text. Not `crud.evaluations.core.group_traces_by_question_id`: | ||
| that is the export shape (keeps trace_ids/category, scores as parallel lists) | ||
| and rejects v1 traces that have no question_id. | ||
| """ | ||
| grouped: dict[int | str | tuple[str, str], TraceQuestion] = {} | ||
| for trace in traces: | ||
| question = trace.get("input_to_llm") or trace.get("question", "") | ||
| ground_truth = trace.get("ground_truth_answer", "") | ||
| question_id = trace.get("question_id") | ||
| # v1 traces may lack question_id; the text pair is the next-best identity. | ||
| key = question_id if question_id is not None else (question, ground_truth) | ||
| entry = grouped.setdefault( | ||
| key, | ||
| { | ||
| "question_id": question_id, | ||
| "question": question, | ||
| "ground_truth_answer": ground_truth, | ||
| "attempts": [], | ||
| }, | ||
| ) | ||
| entry["attempts"].append( | ||
| { | ||
| "llm_answer": trace.get("output_from_llm") | ||
| or trace.get("llm_answer", ""), | ||
|
Comment on lines
+707
to
+708
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Do not send pre-redaction answers to the drafting LLM. When an output guardrail removes sensitive text, 🤖 Prompt for AI Agents |
||
| "scores": [ | ||
| _trace_score_summary(score) for score in trace.get("scores", []) | ||
| ], | ||
| } | ||
| ) | ||
| return list(grouped.values()) | ||
|
|
||
|
|
||
| def _draft_improved_prompt( | ||
| *, | ||
| current_instructions: str, | ||
| config_params: dict, | ||
| traces: list | dict, | ||
| traces: list[TraceData], | ||
| is_judge_run: bool = False, | ||
| ) -> tuple[str, str]: | ||
| """Rewrite the prompt from a run's score traces. | ||
|
|
@@ -662,9 +730,13 @@ def _draft_improved_prompt( | |
| """ | ||
| if is_judge_run: | ||
| trace_description = ( | ||
| " from an LLM-as-judge run. Each trace has the fields: `question`, " | ||
| "`ground_truth_answer`, `llm_answer`, and `scores`. `scores` is a list of " | ||
| "metric objects; each has `name`, `value`, and `comment`, where:\n" | ||
| " from an LLM-as-judge run. Each entry is one dataset question with the " | ||
| "fields: `question_id`, `question`, `ground_truth_answer`, and `attempts`. " | ||
| "`attempts` is a list, one per time the question was run, each with " | ||
| "`llm_answer` and `scores`; a wide spread of scores across a question's " | ||
| "attempts means the prompt yields inconsistent behaviour on it. `scores` " | ||
| "is a list of metric objects; each has `name`, `value`, and `comment`, " | ||
| "where:\n" | ||
| f"- `name` is one of `{GROUND_TRUTH_SCORE_NAME}`, `{PROMPT_SCORE_NAME}`, " | ||
| f"or `{KNOWLEDGE_BASE_SCORE_NAME}`.\n" | ||
| "- `value` is the metric's score, an integer from 0 (worst) to 5 (best). Metrics that " | ||
|
|
@@ -706,9 +778,10 @@ def _draft_improved_prompt( | |
| ] | ||
| else: | ||
| trace_description = ( | ||
| ". Each trace has the fields: `question`, `ground_truth_answer`, " | ||
| "`llm_answer`, `category`, and `scores` (a list of scoring objects with " | ||
| "`name`, `value`, and `unscoreable`)." | ||
| ". Each entry is one dataset question with the fields: `question`, " | ||
| "`ground_truth_answer`, and `attempts`, a list, one per time the " | ||
| "question was run, each with `llm_answer` and `scores` (a list of " | ||
| "scoring objects with `name`, `value`, and `unscoreable`)." | ||
| ) | ||
| task_steps = [ | ||
| "1. Identify the answers that performed poorly — those with low scores or " | ||
|
|
@@ -730,11 +803,14 @@ def _draft_improved_prompt( | |
| f"sentence (≤ {_RATIONALE_MAX_LENGTH} characters): what you changed and why." | ||
| ) | ||
| target_config = _target_config_from_params(config_params) | ||
| traces_json = json.dumps( | ||
| _format_traces_for_brief(traces), ensure_ascii=False, separators=(",", ":") | ||
| ) | ||
|
|
||
| user_message_text = ( | ||
| "You are a prompt engineer. Below is a JSON array of evaluation traces" | ||
| f"{trace_description}\n\n" | ||
| f"## Evaluation traces\n```\n{json.dumps(traces)}\n```\n\n" | ||
| f"## Evaluation traces\n```\n{traces_json}\n```\n\n" | ||
| f"## Current system prompt\n```\n{current_instructions}\n```\n\n" | ||
| "## Target configuration (read-only — do NOT change any of these)\n" | ||
| f"```\n{json.dumps(target_config)}\n```\n\n" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Keep each attempt with its effective question.
When repeated traces share a
question_idbut have differentinput_to_llmvalues,setdefaultkeeps only the first rewritten question. The brief then presents later answers and scores as attempts at a question the model did not receive. Split these groups by effective question, or include the effective question in each attempt.🤖 Prompt for AI Agents