evaluation_event_projection.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. from __future__ import annotations
  2. import re
  3. from collections import defaultdict
  4. from typing import Any
  5. from .evaluation_report_parser import EvaluationReportParser
  6. from .runtime_event_index import (
  7. RuntimeEventIndex,
  8. event_input,
  9. event_output,
  10. event_summary,
  11. integer,
  12. )
  13. class EvaluationEventProjector:
  14. """Project evaluator calls using event metadata for identity and one parser.
  15. The report body never decides build/round association. It only supplies
  16. business-facing evaluation content after the event has been placed by its
  17. structured metadata.
  18. """
  19. def __init__(self, parser: EvaluationReportParser | None = None):
  20. self._parser = parser or EvaluationReportParser()
  21. def project(
  22. self,
  23. index: RuntimeEventIndex,
  24. *,
  25. valid_rounds: set[int],
  26. valid_branches: set[int],
  27. ) -> dict[str, Any]:
  28. multipath: dict[int, list[dict[str, Any]]] = defaultdict(list)
  29. overall: dict[int, list[dict[str, Any]]] = defaultdict(list)
  30. unassigned: list[dict[str, Any]] = []
  31. for event in index.ordered:
  32. if str(event.get("event_type") or "") != "agent_invoke":
  33. continue
  34. name = str(event.get("event_name") or "")
  35. if name not in {"script_multipath_evaluator", "script_evaluator"}:
  36. continue
  37. round_index = integer(event.get("round_index"))
  38. if round_index not in valid_rounds:
  39. unassigned.append(event_summary(event, "missing-business-context"))
  40. continue
  41. report = _agent_summary(event) or str(event.get("output_preview") or "")
  42. parsed = self._parser.parse(
  43. report,
  44. kind="multipath" if name == "script_multipath_evaluator" else "overall",
  45. )
  46. if name == "script_evaluator":
  47. overall[round_index].append(
  48. {
  49. **event_summary(event, "structured-round"),
  50. **parsed,
  51. "id": f"overall-evaluation:{integer(event.get('id')) or 0}",
  52. "roundIndex": round_index,
  53. "detailRef": f"event:{integer(event.get('id')) or 0}",
  54. "promptRef": f"prompt:event:{integer(event.get('id')) or 0}",
  55. }
  56. )
  57. continue
  58. task = _agent_task(event) or str(event.get("input_preview") or "")
  59. branch_ids = [value for value in _branch_ids(task) if value in valid_branches]
  60. review = {
  61. **event_summary(event, "explicit-task-branch-ids"),
  62. **parsed,
  63. "id": f"multipath-review:{integer(event.get('id')) or 0}",
  64. "roundIndex": round_index,
  65. "branchIds": branch_ids,
  66. "branchConclusions": _branch_conclusions(parsed, branch_ids),
  67. "comparison": parsed.get("recommendation"),
  68. "detailRef": f"event:{integer(event.get('id')) or 0}",
  69. "promptRef": f"prompt:event:{integer(event.get('id')) or 0}",
  70. }
  71. if branch_ids:
  72. multipath[round_index].append(review)
  73. else:
  74. unassigned.append({**review, "association": "missing-branch-scope"})
  75. return {
  76. "multipathReviewsByRound": dict(multipath),
  77. "overallEvaluationsByRound": dict(overall),
  78. "unassigned": unassigned,
  79. }
  80. def _branch_ids(text: str) -> list[int]:
  81. values: list[int] = []
  82. source = text or ""
  83. # Agent tasks commonly express one batch as ``branch_id = 4, 5, 6, 7``.
  84. # Capture the complete labelled list instead of keeping only its first id.
  85. list_pattern = (
  86. r"\bbranch[_\s-]*ids?\s*[:=]\s*\[?\s*"
  87. r"(\d+(?:\s*[,,、/]\s*\d+)*)\s*\]?"
  88. )
  89. for match in re.finditer(list_pattern, source, flags=re.IGNORECASE):
  90. for raw_value in re.findall(r"\d+", match.group(1)):
  91. value = int(raw_value)
  92. if value not in values:
  93. values.append(value)
  94. # Keep supporting prose that names branches individually.
  95. for match in re.finditer(r"(?:方案|分支)\s*(\d+)", source, flags=re.IGNORECASE):
  96. value = int(match.group(1))
  97. if value not in values:
  98. values.append(value)
  99. return values
  100. def _branch_conclusions(
  101. parsed: dict[str, Any], branch_ids: list[int]
  102. ) -> list[dict[str, Any]]:
  103. by_subject = {
  104. str(item.get("subject") or ""): item
  105. for item in parsed.get("itemConclusions") or []
  106. if isinstance(item, dict)
  107. }
  108. return [
  109. {
  110. "branchId": branch_id,
  111. "conclusion": (by_subject.get(f"方案 {branch_id}") or {}).get("conclusion"),
  112. "summary": _item_summary(by_subject.get(f"方案 {branch_id}")),
  113. }
  114. for branch_id in branch_ids
  115. ]
  116. def _item_summary(item: Any) -> str | None:
  117. if not isinstance(item, dict):
  118. return None
  119. values = [
  120. *(str(value) for value in item.get("achievements") or [] if value),
  121. *(str(value) for value in item.get("problems") or [] if value),
  122. ]
  123. return ";".join(values[:2]) or None
  124. def _agent_task(event: dict[str, Any]) -> str | None:
  125. value = event_input(event)
  126. if isinstance(value, dict) and isinstance(value.get("task"), str):
  127. return value["task"]
  128. return None
  129. def _agent_summary(event: dict[str, Any]) -> str | None:
  130. value = event_output(event)
  131. if isinstance(value, dict) and isinstance(value.get("summary"), str):
  132. return value["summary"]
  133. return None