reporting.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """Report extraction, telemetry parsing, and persistence helpers."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from dataclasses import dataclass
  6. from pathlib import Path
  7. from typing import Any
  8. from urllib.parse import quote
  9. _TELEMETRY_PATTERN = re.compile(r"\s*<telemetry>\s*(\{[\s\S]*?\})\s*</telemetry>\s*$", re.IGNORECASE)
  10. _MARKDOWN_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
  11. _URL_SCHEMES = ("http://", "https://", "data:", "file://")
  12. PROJECT_ROOT = Path(__file__).resolve().parents[2]
  13. @dataclass(frozen=True)
  14. class ReportTelemetry:
  15. methods: tuple[str, ...] = ()
  16. domain: str = "unknown"
  17. tools_used: tuple[str, ...] = ()
  18. search_used: bool = False
  19. search_notes: str = "unknown"
  20. cleaned_data_saved: bool = False
  21. cleaned_data_path: str = ""
  22. figures_generated: tuple[str, ...] = ()
  23. valid: bool = False
  24. warning: str | None = None
  25. raw_payload: dict[str, Any] | None = None
  26. @dataclass(frozen=True)
  27. class ReportExtractionResult:
  28. report_markdown: str
  29. telemetry: ReportTelemetry
  30. def _normalize_string_list(value: Any) -> tuple[str, ...]:
  31. if not isinstance(value, list):
  32. return ()
  33. normalized = [str(item).strip() for item in value if str(item).strip()]
  34. return tuple(normalized)
  35. def extract_report_and_telemetry(result_text: str) -> ReportExtractionResult:
  36. """Extract the report body and structured telemetry from the model output."""
  37. if not result_text.strip():
  38. return ReportExtractionResult(
  39. report_markdown="# Data Analysis Report\n\nNo valid output was produced.",
  40. telemetry=ReportTelemetry(warning="missing_output"),
  41. )
  42. raw_text = result_text.strip()
  43. telemetry = ReportTelemetry(warning="missing")
  44. telemetry_match = _TELEMETRY_PATTERN.search(raw_text)
  45. report_body = raw_text
  46. if telemetry_match:
  47. report_body = raw_text[: telemetry_match.start()].strip()
  48. telemetry_json = telemetry_match.group(1).strip()
  49. try:
  50. payload = json.loads(telemetry_json)
  51. if not isinstance(payload, dict):
  52. raise ValueError("Telemetry JSON must decode to an object.")
  53. telemetry = ReportTelemetry(
  54. methods=_normalize_string_list(payload.get("methods")),
  55. domain=str(payload.get("domain", "unknown")).strip() or "unknown",
  56. tools_used=_normalize_string_list(payload.get("tools_used")),
  57. search_used=bool(payload.get("search_used", False)),
  58. search_notes=str(payload.get("search_notes", "unknown")).strip() or "unknown",
  59. cleaned_data_saved=bool(payload.get("cleaned_data_saved", False)),
  60. cleaned_data_path=str(payload.get("cleaned_data_path", "")).strip(),
  61. figures_generated=_normalize_string_list(payload.get("figures_generated")),
  62. valid=True,
  63. warning=None,
  64. raw_payload=payload,
  65. )
  66. except Exception as exc:
  67. telemetry = ReportTelemetry(warning=f"malformed:{exc}")
  68. report_match = re.search(r"(# .+[\s\S]*)", report_body)
  69. if report_match:
  70. cleaned_report = report_match.group(1).strip()
  71. else:
  72. cleaned_report = report_body.strip()
  73. if not cleaned_report:
  74. cleaned_report = "# Data Analysis Report\n\nNo valid Markdown report body was produced."
  75. return ReportExtractionResult(report_markdown=cleaned_report, telemetry=telemetry)
  76. def extract_markdown_report(result_text: str) -> str:
  77. """Extract only the human-facing Markdown report from the agent output."""
  78. return extract_report_and_telemetry(result_text).report_markdown
  79. def _resolve_markdown_asset_path(
  80. raw_target: str,
  81. *,
  82. project_root: str | Path | None = None,
  83. base_dir: str | Path | None = None,
  84. ) -> str:
  85. target = raw_target.strip()
  86. if not target:
  87. return raw_target
  88. if target.startswith(_URL_SCHEMES) or target.startswith("/"):
  89. return target
  90. if target.startswith("<") and target.endswith(">"):
  91. target = target[1:-1].strip()
  92. candidate_path = Path(target)
  93. if candidate_path.is_absolute():
  94. return candidate_path.resolve().as_posix()
  95. roots: list[Path] = []
  96. if base_dir is not None:
  97. roots.append(Path(base_dir))
  98. if project_root is not None:
  99. roots.append(Path(project_root))
  100. else:
  101. roots.append(PROJECT_ROOT)
  102. roots.append(Path.cwd())
  103. for root in roots:
  104. try:
  105. resolved = (root / candidate_path).resolve()
  106. except OSError:
  107. continue
  108. if resolved.exists():
  109. return resolved.as_posix()
  110. fallback_root = Path(project_root) if project_root is not None else PROJECT_ROOT
  111. return (fallback_root / candidate_path).resolve().as_posix()
  112. def normalize_markdown_image_paths(
  113. report_markdown: str,
  114. *,
  115. project_root: str | Path | None = None,
  116. base_dir: str | Path | None = None,
  117. ) -> str:
  118. """Convert Markdown image references to absolute filesystem paths."""
  119. def replace(match: re.Match[str]) -> str:
  120. alt_text = match.group(1)
  121. raw_target = match.group(2).strip()
  122. normalized_target = _resolve_markdown_asset_path(
  123. raw_target,
  124. project_root=project_root,
  125. base_dir=base_dir,
  126. )
  127. return f"![{alt_text}]({normalized_target})"
  128. return _MARKDOWN_IMAGE_PATTERN.sub(replace, report_markdown)
  129. def convert_markdown_images_to_gradio_urls(
  130. report_markdown: str,
  131. *,
  132. project_root: str | Path | None = None,
  133. base_dir: str | Path | None = None,
  134. ) -> str:
  135. """Convert Markdown image references to Gradio-served file URLs."""
  136. def replace(match: re.Match[str]) -> str:
  137. alt_text = match.group(1)
  138. raw_target = match.group(2).strip()
  139. absolute_target = _resolve_markdown_asset_path(
  140. raw_target,
  141. project_root=project_root,
  142. base_dir=base_dir,
  143. )
  144. # Gradio 4.x serves local files through the /file=... route.
  145. gradio_target = f"/file={quote(absolute_target, safe='/:')}"
  146. return f"![{alt_text}]({gradio_target})"
  147. return _MARKDOWN_IMAGE_PATTERN.sub(replace, report_markdown)
  148. def save_markdown_report(report_markdown: str, report_path: str | Path) -> Path:
  149. """Persist a Markdown report to disk."""
  150. path = Path(report_path)
  151. path.parent.mkdir(parents=True, exist_ok=True)
  152. path.write_text(report_markdown, encoding="utf-8")
  153. return path