863 lines
30 KiB
Python
863 lines
30 KiB
Python
"""
|
|
Context Compression Evaluation
|
|
|
|
Public API for evaluating context compression quality using probe-based
|
|
assessment. This module provides three composable components:
|
|
|
|
- **ProbeGenerator**: Extracts factual claims, file operations, and decisions
|
|
from conversation history, then generates typed probes for evaluation.
|
|
Use when: building a compression evaluation pipeline and needing to
|
|
automatically derive test questions from raw conversation history.
|
|
|
|
- **CompressionEvaluator**: Scores probe responses against a multi-dimensional
|
|
rubric (accuracy, context awareness, artifact trail, completeness,
|
|
continuity, instruction following). Use when: comparing compression methods
|
|
or validating that a compression strategy preserves critical information.
|
|
|
|
- **StructuredSummarizer**: Implements anchored iterative summarization with
|
|
explicit sections for session intent, file tracking, decisions, and next
|
|
steps. Use when: compressing long-running coding sessions where file
|
|
tracking and decision rationale must survive compression.
|
|
|
|
Top-level convenience function:
|
|
- **evaluate_compression_quality**: End-to-end pipeline that generates probes,
|
|
collects model responses, evaluates them, and returns a scored summary with
|
|
recommendations. Use when: running a one-shot compression quality check
|
|
without wiring up individual components.
|
|
|
|
PRODUCTION NOTES:
|
|
- The LLM judge calls are stubbed for demonstration. Production systems
|
|
should implement actual API calls to a frontier model.
|
|
- Token estimation uses simplified heuristics. Production systems should
|
|
use model-specific tokenizers.
|
|
- Ground truth extraction uses pattern matching. Production systems may
|
|
benefit from more sophisticated fact extraction.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Dict, Optional, Callable
|
|
from enum import Enum
|
|
import json
|
|
import re
|
|
|
|
__all__ = [
|
|
"ProbeType",
|
|
"Probe",
|
|
"CriterionResult",
|
|
"EvaluationResult",
|
|
"RUBRIC_CRITERIA",
|
|
"ProbeGenerator",
|
|
"CompressionEvaluator",
|
|
"StructuredSummarizer",
|
|
"evaluate_compression_quality",
|
|
]
|
|
|
|
|
|
class ProbeType(Enum):
|
|
"""Types of evaluation probes for compression quality assessment."""
|
|
RECALL = "recall"
|
|
ARTIFACT = "artifact"
|
|
CONTINUATION = "continuation"
|
|
DECISION = "decision"
|
|
|
|
|
|
@dataclass
|
|
class Probe:
|
|
"""A probe question for evaluating compression quality.
|
|
|
|
Use when: constructing evaluation inputs for CompressionEvaluator.
|
|
Each probe targets a specific information category that compression
|
|
may have lost.
|
|
"""
|
|
probe_type: ProbeType
|
|
question: str
|
|
ground_truth: Optional[str] = None
|
|
context_reference: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class CriterionResult:
|
|
"""Result for a single evaluation criterion."""
|
|
criterion_id: str
|
|
score: float
|
|
reasoning: str
|
|
|
|
|
|
@dataclass
|
|
class EvaluationResult:
|
|
"""Complete evaluation result for a probe response.
|
|
|
|
Contains per-criterion scores, per-dimension aggregates, and an
|
|
overall aggregate score.
|
|
"""
|
|
probe: Probe
|
|
response: str
|
|
criterion_results: List[CriterionResult]
|
|
aggregate_score: float
|
|
dimension_scores: Dict[str, float] = field(default_factory=dict)
|
|
|
|
|
|
# Evaluation Rubrics
|
|
|
|
RUBRIC_CRITERIA: Dict[str, List[Dict]] = {
|
|
"accuracy": [
|
|
{
|
|
"id": "accuracy_factual",
|
|
"question": "Are facts, file paths, and technical details correct?",
|
|
"weight": 0.6
|
|
},
|
|
{
|
|
"id": "accuracy_technical",
|
|
"question": "Are code references and technical concepts correct?",
|
|
"weight": 0.4
|
|
}
|
|
],
|
|
"context_awareness": [
|
|
{
|
|
"id": "context_conversation_state",
|
|
"question": "Does the response reflect current conversation state?",
|
|
"weight": 0.5
|
|
},
|
|
{
|
|
"id": "context_artifact_state",
|
|
"question": "Does the response reflect which files/artifacts were accessed?",
|
|
"weight": 0.5
|
|
}
|
|
],
|
|
"artifact_trail": [
|
|
{
|
|
"id": "artifact_files_created",
|
|
"question": "Does the agent know which files were created?",
|
|
"weight": 0.3
|
|
},
|
|
{
|
|
"id": "artifact_files_modified",
|
|
"question": "Does the agent know which files were modified?",
|
|
"weight": 0.4
|
|
},
|
|
{
|
|
"id": "artifact_key_details",
|
|
"question": "Does the agent remember function names, variable names, error messages?",
|
|
"weight": 0.3
|
|
}
|
|
],
|
|
"completeness": [
|
|
{
|
|
"id": "completeness_coverage",
|
|
"question": "Does the response address all parts of the question?",
|
|
"weight": 0.6
|
|
},
|
|
{
|
|
"id": "completeness_depth",
|
|
"question": "Is sufficient detail provided?",
|
|
"weight": 0.4
|
|
}
|
|
],
|
|
"continuity": [
|
|
{
|
|
"id": "continuity_work_state",
|
|
"question": "Can the agent continue without re-fetching information?",
|
|
"weight": 0.4
|
|
},
|
|
{
|
|
"id": "continuity_todo_state",
|
|
"question": "Does the agent maintain awareness of pending tasks?",
|
|
"weight": 0.3
|
|
},
|
|
{
|
|
"id": "continuity_reasoning",
|
|
"question": "Does the agent retain rationale behind previous decisions?",
|
|
"weight": 0.3
|
|
}
|
|
],
|
|
"instruction_following": [
|
|
{
|
|
"id": "instruction_format",
|
|
"question": "Does the response follow the requested format?",
|
|
"weight": 0.5
|
|
},
|
|
{
|
|
"id": "instruction_constraints",
|
|
"question": "Does the response respect stated constraints?",
|
|
"weight": 0.5
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
class ProbeGenerator:
|
|
"""Generate typed probes from conversation history.
|
|
|
|
Use when: automatically deriving evaluation questions from raw
|
|
conversation history at compression points. Extracts facts, file
|
|
operations, and decisions via pattern matching, then produces
|
|
one probe per category.
|
|
|
|
For production systems, replace the regex-based extraction with
|
|
an LLM-based extractor for higher recall.
|
|
"""
|
|
|
|
def __init__(self, conversation_history: str) -> None:
|
|
self.history = conversation_history
|
|
self.extracted_facts = self._extract_facts()
|
|
self.extracted_files = self._extract_files()
|
|
self.extracted_decisions = self._extract_decisions()
|
|
|
|
def generate_probes(self) -> List[Probe]:
|
|
"""Generate all probe types for evaluation.
|
|
|
|
Use when: preparing evaluation inputs at a compression point.
|
|
Returns one probe per category (recall, artifact, continuation,
|
|
decision) based on extractable content from the history.
|
|
"""
|
|
probes: List[Probe] = []
|
|
|
|
# Recall probes
|
|
if self.extracted_facts:
|
|
probes.append(Probe(
|
|
probe_type=ProbeType.RECALL,
|
|
question="What was the original error or issue that started this session?",
|
|
ground_truth=self.extracted_facts.get("original_error"),
|
|
context_reference="session_start"
|
|
))
|
|
|
|
# Artifact probes
|
|
if self.extracted_files:
|
|
probes.append(Probe(
|
|
probe_type=ProbeType.ARTIFACT,
|
|
question="Which files have we modified? Describe what changed in each.",
|
|
ground_truth=json.dumps(self.extracted_files),
|
|
context_reference="file_operations"
|
|
))
|
|
|
|
# Continuation probes
|
|
probes.append(Probe(
|
|
probe_type=ProbeType.CONTINUATION,
|
|
question="What should we do next?",
|
|
ground_truth=self.extracted_facts.get("next_steps"),
|
|
context_reference="task_state"
|
|
))
|
|
|
|
# Decision probes
|
|
if self.extracted_decisions:
|
|
probes.append(Probe(
|
|
probe_type=ProbeType.DECISION,
|
|
question="What key decisions did we make and why?",
|
|
ground_truth=json.dumps(self.extracted_decisions),
|
|
context_reference="decision_points"
|
|
))
|
|
|
|
return probes
|
|
|
|
def _extract_facts(self) -> Dict[str, str]:
|
|
"""Extract factual claims from history."""
|
|
facts: Dict[str, str] = {}
|
|
|
|
# Extract error patterns
|
|
error_patterns = [
|
|
r"error[:\s]+(.+?)(?:\n|$)",
|
|
r"(\d{3})\s+(Unauthorized|Not Found|Internal Server Error)",
|
|
r"exception[:\s]+(.+?)(?:\n|$)"
|
|
]
|
|
|
|
for pattern in error_patterns:
|
|
match = re.search(pattern, self.history, re.IGNORECASE)
|
|
if match:
|
|
facts["original_error"] = match.group(0).strip()
|
|
break
|
|
|
|
# Extract next steps
|
|
next_step_patterns = [
|
|
r"next[:\s]+(.+?)(?:\n|$)",
|
|
r"TODO[:\s]+(.+?)(?:\n|$)",
|
|
r"remaining[:\s]+(.+?)(?:\n|$)"
|
|
]
|
|
|
|
for pattern in next_step_patterns:
|
|
match = re.search(pattern, self.history, re.IGNORECASE)
|
|
if match:
|
|
facts["next_steps"] = match.group(0).strip()
|
|
break
|
|
|
|
return facts
|
|
|
|
def _extract_files(self) -> List[Dict[str, str]]:
|
|
"""Extract file operations from history."""
|
|
files: List[Dict[str, str]] = []
|
|
|
|
# Common file patterns
|
|
file_patterns = [
|
|
r"(?:modified|changed|updated|edited)\s+([^\s]+\.[a-z]+)",
|
|
r"(?:created|added)\s+([^\s]+\.[a-z]+)",
|
|
r"(?:read|examined|opened)\s+([^\s]+\.[a-z]+)"
|
|
]
|
|
|
|
for pattern in file_patterns:
|
|
matches = re.findall(pattern, self.history, re.IGNORECASE)
|
|
for match in matches:
|
|
if match not in [f["path"] for f in files]:
|
|
files.append({
|
|
"path": match,
|
|
"operation": "modified" if "modif" in pattern else "created" if "creat" in pattern else "read"
|
|
})
|
|
|
|
return files
|
|
|
|
def _extract_decisions(self) -> List[Dict[str, str]]:
|
|
"""Extract decision points from history."""
|
|
decisions: List[Dict[str, str]] = []
|
|
|
|
decision_patterns = [
|
|
r"decided to\s+(.+?)(?:\n|$)",
|
|
r"chose\s+(.+?)(?:\n|$)",
|
|
r"going with\s+(.+?)(?:\n|$)",
|
|
r"will use\s+(.+?)(?:\n|$)"
|
|
]
|
|
|
|
for pattern in decision_patterns:
|
|
matches = re.findall(pattern, self.history, re.IGNORECASE)
|
|
for match in matches:
|
|
decisions.append({
|
|
"decision": match.strip(),
|
|
"context": pattern.split("\\s+")[0]
|
|
})
|
|
|
|
return decisions[:5] # Limit to 5 decisions
|
|
|
|
|
|
class CompressionEvaluator:
|
|
"""Evaluate compression quality using probes and LLM judge.
|
|
|
|
Use when: comparing compression methods or validating that a specific
|
|
compression pass preserved critical information. Scores responses
|
|
across six dimensions (accuracy, context awareness, artifact trail,
|
|
completeness, continuity, instruction following) and produces an
|
|
aggregate quality score.
|
|
|
|
The evaluate() method is the primary entry point. Call it once per
|
|
probe, then call get_summary() to retrieve aggregated results.
|
|
"""
|
|
|
|
def __init__(self, model: str = "gpt-5.2") -> None:
|
|
self.model = model
|
|
self.results: List[EvaluationResult] = []
|
|
|
|
def evaluate(self,
|
|
probe: Probe,
|
|
response: str,
|
|
compressed_context: str) -> EvaluationResult:
|
|
"""Evaluate a single probe response against the rubric.
|
|
|
|
Use when: scoring how well a model's response (given compressed
|
|
context) answers a probe question. Returns per-criterion scores,
|
|
per-dimension aggregates, and an overall score.
|
|
|
|
Args:
|
|
probe: The probe question with expected ground truth.
|
|
response: The model's response to evaluate.
|
|
compressed_context: The compressed context that was provided
|
|
to the model when generating the response.
|
|
|
|
Returns:
|
|
EvaluationResult with scores and reasoning across all
|
|
applicable dimensions.
|
|
"""
|
|
# Get relevant criteria based on probe type
|
|
criteria = self._get_criteria_for_probe(probe.probe_type)
|
|
|
|
# Evaluate each criterion
|
|
criterion_results: List[CriterionResult] = []
|
|
for criterion in criteria:
|
|
result = self._evaluate_criterion(
|
|
criterion,
|
|
probe,
|
|
response,
|
|
compressed_context
|
|
)
|
|
criterion_results.append(result)
|
|
|
|
# Calculate dimension scores
|
|
dimension_scores = self._calculate_dimension_scores(criterion_results)
|
|
|
|
# Calculate aggregate score
|
|
aggregate_score = sum(dimension_scores.values()) / len(dimension_scores) if dimension_scores else 0.0
|
|
|
|
result = EvaluationResult(
|
|
probe=probe,
|
|
response=response,
|
|
criterion_results=criterion_results,
|
|
aggregate_score=aggregate_score,
|
|
dimension_scores=dimension_scores
|
|
)
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
def get_summary(self) -> Dict:
|
|
"""Get summary of all evaluation results.
|
|
|
|
Use when: all probes have been evaluated and an aggregate
|
|
report is needed to compare methods or make a go/no-go
|
|
decision on a compression strategy.
|
|
|
|
Returns:
|
|
Dictionary with total evaluations, average score,
|
|
per-dimension averages, and weakest/strongest dimensions.
|
|
"""
|
|
if not self.results:
|
|
return {"error": "No evaluations performed"}
|
|
|
|
avg_score = sum(r.aggregate_score for r in self.results) / len(self.results)
|
|
|
|
# Average dimension scores
|
|
dimension_totals: Dict[str, float] = {}
|
|
dimension_counts: Dict[str, int] = {}
|
|
|
|
for result in self.results:
|
|
for dim, score in result.dimension_scores.items():
|
|
dimension_totals[dim] = dimension_totals.get(dim, 0) + score
|
|
dimension_counts[dim] = dimension_counts.get(dim, 0) + 1
|
|
|
|
avg_dimensions = {
|
|
dim: dimension_totals[dim] / dimension_counts[dim]
|
|
for dim in dimension_totals
|
|
}
|
|
|
|
return {
|
|
"total_evaluations": len(self.results),
|
|
"average_score": avg_score,
|
|
"dimension_averages": avg_dimensions,
|
|
"weakest_dimension": min(avg_dimensions, key=avg_dimensions.get) if avg_dimensions else None,
|
|
"strongest_dimension": max(avg_dimensions, key=avg_dimensions.get) if avg_dimensions else None,
|
|
}
|
|
|
|
def _get_criteria_for_probe(self, probe_type: ProbeType) -> List[Dict]:
|
|
"""Get relevant criteria for probe type."""
|
|
criteria: List[Dict] = []
|
|
|
|
# All probes get accuracy and completeness
|
|
criteria.extend(RUBRIC_CRITERIA["accuracy"])
|
|
criteria.extend(RUBRIC_CRITERIA["completeness"])
|
|
|
|
# Add type-specific criteria
|
|
if probe_type == ProbeType.ARTIFACT:
|
|
criteria.extend(RUBRIC_CRITERIA["artifact_trail"])
|
|
elif probe_type == ProbeType.CONTINUATION:
|
|
criteria.extend(RUBRIC_CRITERIA["continuity"])
|
|
elif probe_type == ProbeType.RECALL:
|
|
criteria.extend(RUBRIC_CRITERIA["context_awareness"])
|
|
elif probe_type == ProbeType.DECISION:
|
|
criteria.extend(RUBRIC_CRITERIA["context_awareness"])
|
|
criteria.extend(RUBRIC_CRITERIA["continuity"])
|
|
|
|
criteria.extend(RUBRIC_CRITERIA["instruction_following"])
|
|
|
|
return criteria
|
|
|
|
def _evaluate_criterion(self,
|
|
criterion: Dict,
|
|
probe: Probe,
|
|
response: str,
|
|
context: str) -> CriterionResult:
|
|
"""
|
|
Evaluate a single criterion using LLM judge.
|
|
|
|
PRODUCTION NOTE: This is a stub implementation.
|
|
Production systems should call the actual LLM API:
|
|
|
|
```python
|
|
result = openai.chat.completions.create(
|
|
model="gpt-5.2",
|
|
messages=[
|
|
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
|
|
{"role": "user", "content": self._format_judge_input(criterion, probe, response, context)}
|
|
]
|
|
)
|
|
return self._parse_judge_output(result)
|
|
```
|
|
"""
|
|
# Stub implementation - in production, call LLM judge
|
|
score = self._heuristic_score(criterion, response, probe.ground_truth)
|
|
reasoning = f"Evaluated {criterion['id']} based on response content."
|
|
|
|
return CriterionResult(
|
|
criterion_id=criterion["id"],
|
|
score=score,
|
|
reasoning=reasoning
|
|
)
|
|
|
|
def _heuristic_score(self,
|
|
criterion: Dict,
|
|
response: str,
|
|
ground_truth: Optional[str]) -> float:
|
|
"""
|
|
Heuristic scoring for demonstration.
|
|
|
|
Production systems should use LLM judge instead.
|
|
"""
|
|
score = 3.0 # Base score
|
|
|
|
# Adjust based on response length and content
|
|
if len(response) < 50:
|
|
score -= 1.0 # Too short
|
|
elif len(response) > 500:
|
|
score += 0.5 # Detailed
|
|
|
|
# Check for technical content
|
|
if any(ext in response for ext in [".ts", ".py", ".js", ".md"]):
|
|
score += 0.5 # Contains file references
|
|
|
|
overlap_ratio = self._ground_truth_overlap_ratio(response, ground_truth)
|
|
if overlap_ratio >= 0.75:
|
|
score += 1.0
|
|
elif overlap_ratio >= 0.4:
|
|
score += 0.5
|
|
elif ground_truth:
|
|
score -= 0.5
|
|
|
|
return min(5.0, max(0.0, score))
|
|
|
|
def _ground_truth_overlap_ratio(self,
|
|
response: str,
|
|
ground_truth: Optional[str]) -> float:
|
|
if not ground_truth:
|
|
return 0.0
|
|
|
|
terms = self._extract_ground_truth_terms(ground_truth)
|
|
if not terms:
|
|
return 1.0 if ground_truth.lower() in response.lower() else 0.0
|
|
|
|
response_lower = response.lower()
|
|
matches = sum(1 for term in terms if term in response_lower)
|
|
return matches / len(terms)
|
|
|
|
def _extract_ground_truth_terms(self, ground_truth: str) -> List[str]:
|
|
try:
|
|
parsed = json.loads(ground_truth)
|
|
except json.JSONDecodeError:
|
|
return [ground_truth.lower()] if ground_truth.strip() else []
|
|
|
|
terms: List[str] = []
|
|
|
|
def collect(value) -> None:
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized:
|
|
terms.append(normalized)
|
|
elif isinstance(value, dict):
|
|
for nested in value.values():
|
|
collect(nested)
|
|
elif isinstance(value, list):
|
|
for nested in value:
|
|
collect(nested)
|
|
|
|
collect(parsed)
|
|
return list(dict.fromkeys(terms))
|
|
|
|
def _calculate_dimension_scores(self,
|
|
criterion_results: List[CriterionResult]) -> Dict[str, float]:
|
|
"""Calculate dimension scores from criterion results."""
|
|
dimension_scores: Dict[str, float] = {}
|
|
|
|
for dimension, criteria in RUBRIC_CRITERIA.items():
|
|
criterion_ids = [c["id"] for c in criteria]
|
|
relevant_results = [
|
|
r for r in criterion_results
|
|
if r.criterion_id in criterion_ids
|
|
]
|
|
|
|
if relevant_results:
|
|
# Weighted average
|
|
total_weight = sum(
|
|
c["weight"] for c in criteria
|
|
if c["id"] in [r.criterion_id for r in relevant_results]
|
|
)
|
|
weighted_sum = sum(
|
|
r.score * next(c["weight"] for c in criteria if c["id"] == r.criterion_id)
|
|
for r in relevant_results
|
|
)
|
|
dimension_scores[dimension] = weighted_sum / total_weight if total_weight > 0 else 0.0
|
|
|
|
return dimension_scores
|
|
|
|
|
|
class StructuredSummarizer:
|
|
"""Generate structured summaries with explicit sections.
|
|
|
|
Use when: implementing anchored iterative summarization for
|
|
long-running coding sessions. Maintains a persistent summary
|
|
with dedicated sections for session intent, file modifications,
|
|
decisions, current state, and next steps.
|
|
|
|
Call update_from_span() each time a new content span is truncated.
|
|
The summarizer merges new information into existing sections rather
|
|
than regenerating, preventing cumulative detail loss.
|
|
"""
|
|
|
|
TEMPLATE = """## Session Intent
|
|
{intent}
|
|
|
|
## Files Modified
|
|
{files_modified}
|
|
|
|
## Files Read (Not Modified)
|
|
{files_read}
|
|
|
|
## Decisions Made
|
|
{decisions}
|
|
|
|
## Current State
|
|
{current_state}
|
|
|
|
## Next Steps
|
|
{next_steps}
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.sections: Dict = {
|
|
"intent": "",
|
|
"files_modified": [],
|
|
"files_read": [],
|
|
"decisions": [],
|
|
"current_state": "",
|
|
"next_steps": []
|
|
}
|
|
|
|
def update_from_span(self, new_content: str) -> str:
|
|
"""Update summary from newly truncated content span.
|
|
|
|
Use when: a compression trigger fires and a portion of
|
|
conversation history is about to be discarded. Pass the
|
|
content that will be truncated; the summarizer extracts
|
|
structured information and merges it with prior state.
|
|
|
|
Args:
|
|
new_content: The conversation span being truncated.
|
|
|
|
Returns:
|
|
Formatted summary string with all sections populated.
|
|
"""
|
|
# Extract information from new content
|
|
new_info = self._extract_from_content(new_content)
|
|
|
|
# Merge with existing sections
|
|
self._merge_sections(new_info)
|
|
|
|
# Generate formatted summary
|
|
return self._format_summary()
|
|
|
|
def _extract_from_content(self, content: str) -> Dict:
|
|
"""Extract structured information from content."""
|
|
extracted: Dict = {
|
|
"intent": "",
|
|
"files_modified": [],
|
|
"files_read": [],
|
|
"decisions": [],
|
|
"current_state": "",
|
|
"next_steps": []
|
|
}
|
|
|
|
# Extract file modifications
|
|
mod_pattern = r"(?:modified|changed|updated|fixed)\s+([^\s]+\.[a-z]+)[:\s]*(.+?)(?:\n|$)"
|
|
for match in re.finditer(mod_pattern, content, re.IGNORECASE):
|
|
extracted["files_modified"].append({
|
|
"path": match.group(1),
|
|
"change": match.group(2).strip()[:100]
|
|
})
|
|
|
|
# Extract file reads
|
|
read_pattern = r"(?:read|examined|opened|checked)\s+([^\s]+\.[a-z]+)"
|
|
for match in re.finditer(read_pattern, content, re.IGNORECASE):
|
|
file_path = match.group(1)
|
|
if file_path not in [f["path"] for f in extracted["files_modified"]]:
|
|
extracted["files_read"].append(file_path)
|
|
|
|
# Extract decisions
|
|
decision_pattern = r"(?:decided|chose|going with|will use)\s+(.+?)(?:\n|$)"
|
|
for match in re.finditer(decision_pattern, content, re.IGNORECASE):
|
|
extracted["decisions"].append(match.group(1).strip()[:150])
|
|
|
|
return extracted
|
|
|
|
def _merge_sections(self, new_info: Dict) -> None:
|
|
"""Merge new information with existing sections."""
|
|
# Update intent if empty
|
|
if new_info["intent"] and not self.sections["intent"]:
|
|
self.sections["intent"] = new_info["intent"]
|
|
|
|
# Merge file lists (deduplicate by path)
|
|
existing_mod_paths = [f["path"] for f in self.sections["files_modified"]]
|
|
for file_info in new_info["files_modified"]:
|
|
if file_info["path"] not in existing_mod_paths:
|
|
self.sections["files_modified"].append(file_info)
|
|
|
|
# Merge read files
|
|
for file_path in new_info["files_read"]:
|
|
if file_path not in self.sections["files_read"]:
|
|
self.sections["files_read"].append(file_path)
|
|
|
|
# Append decisions
|
|
self.sections["decisions"].extend(new_info["decisions"])
|
|
|
|
# Update current state (latest wins)
|
|
if new_info["current_state"]:
|
|
self.sections["current_state"] = new_info["current_state"]
|
|
|
|
# Merge next steps
|
|
self.sections["next_steps"].extend(new_info["next_steps"])
|
|
|
|
def _format_summary(self) -> str:
|
|
"""Format sections into summary string."""
|
|
files_modified_str = "\n".join(
|
|
f"- {f['path']}: {f['change']}"
|
|
for f in self.sections["files_modified"]
|
|
) or "None"
|
|
|
|
files_read_str = "\n".join(
|
|
f"- {f}" for f in self.sections["files_read"]
|
|
) or "None"
|
|
|
|
decisions_str = "\n".join(
|
|
f"- {d}" for d in self.sections["decisions"][-5:] # Keep last 5
|
|
) or "None"
|
|
|
|
next_steps_str = "\n".join(
|
|
f"{i+1}. {s}" for i, s in enumerate(self.sections["next_steps"][-5:])
|
|
) or "None"
|
|
|
|
return self.TEMPLATE.format(
|
|
intent=self.sections["intent"] or "Not specified",
|
|
files_modified=files_modified_str,
|
|
files_read=files_read_str,
|
|
decisions=decisions_str,
|
|
current_state=self.sections["current_state"] or "In progress",
|
|
next_steps=next_steps_str
|
|
)
|
|
|
|
|
|
def evaluate_compression_quality(
|
|
original_history: str,
|
|
compressed_context: str,
|
|
model_response_fn: Callable[[str, str], str],
|
|
) -> Dict:
|
|
"""Evaluate compression quality for a conversation end-to-end.
|
|
|
|
Use when: running a one-shot quality check on a compression pass.
|
|
Generates probes from original history, collects model responses
|
|
using the compressed context, evaluates each response, and returns
|
|
a scored summary with actionable recommendations.
|
|
|
|
Args:
|
|
original_history: The full conversation before compression.
|
|
compressed_context: The compressed version to evaluate.
|
|
model_response_fn: Callable that takes (compressed_context, question)
|
|
and returns the model's response string.
|
|
|
|
Returns:
|
|
Dictionary with total evaluations, average score, per-dimension
|
|
averages, weakest/strongest dimensions, and recommendations list.
|
|
"""
|
|
# Generate probes
|
|
generator = ProbeGenerator(original_history)
|
|
probes = generator.generate_probes()
|
|
|
|
# Evaluate each probe
|
|
evaluator = CompressionEvaluator()
|
|
|
|
for probe in probes:
|
|
# Get model response using compressed context
|
|
response = model_response_fn(compressed_context, probe.question)
|
|
|
|
# Evaluate response
|
|
evaluator.evaluate(probe, response, compressed_context)
|
|
|
|
# Get summary
|
|
summary = evaluator.get_summary()
|
|
|
|
# Add recommendations
|
|
summary["recommendations"] = []
|
|
|
|
if summary.get("weakest_dimension") == "artifact_trail":
|
|
summary["recommendations"].append(
|
|
"Consider implementing separate artifact tracking outside compression"
|
|
)
|
|
|
|
if summary.get("average_score", 0) < 3.5:
|
|
summary["recommendations"].append(
|
|
"Compression quality is below threshold - consider less aggressive compression"
|
|
)
|
|
|
|
return summary
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Demo: generate probes and evaluate a sample compression
|
|
|
|
sample_history = """
|
|
User reported error: 401 Unauthorized on /api/auth/login endpoint.
|
|
Examined auth.controller.ts - JWT generation looks correct.
|
|
Examined middleware/cors.ts - no issues found.
|
|
Modified config/redis.ts: Fixed connection pooling configuration.
|
|
Modified services/session.service.ts: Added retry logic for transient failures.
|
|
Decided to use Redis connection pool instead of per-request connections.
|
|
Modified tests/auth.test.ts: Updated mock setup for new config.
|
|
14 tests passing, 2 failing (mock setup issues).
|
|
Next: Fix remaining test failures in session service mocks.
|
|
"""
|
|
|
|
sample_compressed = """
|
|
## Session Intent
|
|
Debug 401 Unauthorized on /api/auth/login.
|
|
|
|
## Root Cause
|
|
Stale Redis connection in session store.
|
|
|
|
## Files Modified
|
|
- config/redis.ts: Fixed connection pooling
|
|
- services/session.service.ts: Added retry logic
|
|
- tests/auth.test.ts: Updated mock setup
|
|
|
|
## Test Status
|
|
14 passing, 2 failing
|
|
|
|
## Next Steps
|
|
1. Fix remaining test failures
|
|
"""
|
|
|
|
# Stub model response function
|
|
def mock_model_response(context: str, question: str) -> str:
|
|
if "error" in question.lower():
|
|
return "The original error was a 401 Unauthorized on /api/auth/login."
|
|
if "files" in question.lower():
|
|
return "Modified config/redis.ts, services/session.service.ts, tests/auth.test.ts."
|
|
if "next" in question.lower():
|
|
return "Fix remaining test failures in session service mocks."
|
|
if "decision" in question.lower():
|
|
return "Decided to use Redis connection pool instead of per-request connections."
|
|
return "No specific information available."
|
|
|
|
# Run evaluation
|
|
result = evaluate_compression_quality(
|
|
original_history=sample_history,
|
|
compressed_context=sample_compressed,
|
|
model_response_fn=mock_model_response,
|
|
)
|
|
|
|
print("=== Compression Quality Evaluation ===")
|
|
print(f"Total evaluations: {result['total_evaluations']}")
|
|
print(f"Average score: {result['average_score']:.2f}")
|
|
print()
|
|
print("Dimension averages:")
|
|
for dim, score in result.get("dimension_averages", {}).items():
|
|
print(f" {dim}: {score:.2f}")
|
|
print()
|
|
print(f"Weakest dimension: {result.get('weakest_dimension')}")
|
|
print(f"Strongest dimension: {result.get('strongest_dimension')}")
|
|
print()
|
|
if result.get("recommendations"):
|
|
print("Recommendations:")
|
|
for rec in result["recommendations"]:
|
|
print(f" - {rec}")
|
|
else:
|
|
print("No recommendations - compression quality looks acceptable.")
|