report_generator.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """Report generation service (P4-M4).
  2. Generates Word simulation reports from task results.
  3. Includes cover, parameters, results summary, metrics, AI analysis.
  4. """
  5. import json
  6. import os
  7. from datetime import datetime
  8. from typing import Any, Dict, List, Optional, Tuple
  9. try:
  10. from docx import Document
  11. from docx.shared import Inches, Pt, RGBColor
  12. from docx.enum.text import WD_ALIGN_PARAGRAPH
  13. HAS_DOCX = True
  14. except ImportError:
  15. HAS_DOCX = False
  16. # P5-M6: physics-domain grouping (single source of truth = afmcore.metrics)
  17. try:
  18. import sys as _sys
  19. _afm_src = os.path.join(
  20. os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
  21. os.path.dirname(os.path.abspath(__file__)))))),
  22. "src",
  23. )
  24. if _afm_src not in _sys.path:
  25. _sys.path.insert(0, _afm_src)
  26. from afmcore.metrics import METRIC_DEFINITIONS as _MD
  27. _METRIC_DOMAIN: Dict[str, str] = {
  28. m["key"]: m.get("domain", "electromagnetic") for m in _MD
  29. }
  30. _METRIC_LABEL: Dict[str, str] = {m["key"]: m["label"] for m in _MD}
  31. _METRIC_UNIT: Dict[str, str] = {m["key"]: m.get("unit", "") for m in _MD}
  32. except Exception: # noqa: BLE001
  33. _METRIC_DOMAIN = {}
  34. _METRIC_LABEL = {}
  35. _METRIC_UNIT = {}
  36. _DOMAIN_ORDER = ["electromagnetic", "thermal", "structural"]
  37. _DOMAIN_LABELS = {
  38. "electromagnetic": "Electromagnetic Metrics",
  39. "thermal": "Thermal Metrics",
  40. "structural": "Structural / Mechanical Metrics",
  41. }
  42. def _group_metrics_by_domain(metrics: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
  43. """Group a flat metrics dict by physics domain.
  44. Returns {domain: {key: value}}. Unknown keys default to
  45. 'electromagnetic'. Empty domains are omitted.
  46. """
  47. grouped: Dict[str, Dict[str, Any]] = {d: {} for d in _DOMAIN_ORDER}
  48. for key, value in metrics.items():
  49. domain = _METRIC_DOMAIN.get(key, "electromagnetic")
  50. if domain not in grouped:
  51. grouped[domain] = {}
  52. grouped[domain][key] = value
  53. return {d: v for d, v in grouped.items() if v}
  54. def _metric_display(key: str, value: Any) -> Tuple[str, str]:
  55. """Return (display_label, display_value) for a metric key."""
  56. label = _METRIC_LABEL.get(key, key)
  57. unit = _METRIC_UNIT.get(key, "")
  58. if unit:
  59. label = "%s [%s]" % (label, unit) if "[" not in label else label
  60. if isinstance(value, float):
  61. disp = "%.4g" % value
  62. else:
  63. disp = str(value)
  64. return label, disp
  65. class ReportGenerator:
  66. """Generate simulation reports from task results."""
  67. def __init__(self, output_dir: Optional[str] = None):
  68. self.output_dir = output_dir or os.path.join(
  69. os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
  70. "output", "reports"
  71. )
  72. os.makedirs(self.output_dir, exist_ok=True)
  73. def generate_report(self, task_data: Dict[str, Any],
  74. ai_analysis: Optional[Dict[str, Any]] = None,
  75. report_title: Optional[str] = None) -> str:
  76. """Generate a Word report from task data.
  77. Args:
  78. task_data: Task data including parameters, results, metrics
  79. ai_analysis: Optional AI analysis results
  80. report_title: Custom report title
  81. Returns:
  82. Path to generated report file
  83. """
  84. if not HAS_DOCX:
  85. return self._generate_json_report(task_data, ai_analysis, report_title)
  86. doc = Document()
  87. # Title
  88. title = report_title or f"Simulation Report - {task_data.get('task_name', 'Task')}"
  89. heading = doc.add_heading(title, level=0)
  90. heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
  91. # Metadata
  92. doc.add_paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  93. doc.add_paragraph(f"Task ID: {task_data.get('task_id', 'N/A')}")
  94. doc.add_paragraph(f"Status: {task_data.get('status', 'N/A')}")
  95. # Parameters section
  96. doc.add_heading("Simulation Parameters", level=1)
  97. params = task_data.get("plan_data", {})
  98. if params:
  99. table = doc.add_table(rows=1, cols=2)
  100. table.style = "Table Grid"
  101. hdr = table.rows[0].cells
  102. hdr[0].text = "Parameter"
  103. hdr[1].text = "Value"
  104. for key, value in params.items():
  105. row = table.add_row().cells
  106. row[0].text = str(key)
  107. row[1].text = str(value)
  108. # Results summary (P5-M6: grouped by physics domain)
  109. doc.add_heading("Results Summary", level=1)
  110. results = task_data.get("result_metrics", {})
  111. if results:
  112. grouped = _group_metrics_by_domain(results)
  113. for domain in _DOMAIN_ORDER:
  114. if domain not in grouped:
  115. continue
  116. doc.add_heading(_DOMAIN_LABELS.get(domain, domain), level=2)
  117. table = doc.add_table(rows=1, cols=2)
  118. table.style = "Table Grid"
  119. hdr = table.rows[0].cells
  120. hdr[0].text = "Metric"
  121. hdr[1].text = "Value"
  122. for key, value in grouped[domain].items():
  123. label, disp = _metric_display(key, value)
  124. row = table.add_row().cells
  125. row[0].text = label
  126. row[1].text = disp
  127. # Per-point results
  128. doc.add_heading("Per-Point Results", level=1)
  129. points = task_data.get("points", [])
  130. if points:
  131. table = doc.add_table(rows=1, cols=4)
  132. table.style = "Table Grid"
  133. hdr = table.rows[0].cells
  134. hdr[0].text = "Point"
  135. hdr[1].text = "Parameters"
  136. hdr[2].text = "Status"
  137. hdr[3].text = "Duration (s)"
  138. for i, point in enumerate(points):
  139. row = table.add_row().cells
  140. row[0].text = str(i + 1)
  141. row[1].text = json.dumps(point.get("params", {}), ensure_ascii=False)
  142. row[2].text = point.get("status", "N/A")
  143. row[3].text = str(point.get("duration_s", "N/A"))
  144. # AI Analysis section
  145. if ai_analysis:
  146. doc.add_heading("AI Analysis", level=1)
  147. if "summary" in ai_analysis:
  148. doc.add_paragraph(ai_analysis["summary"])
  149. if "convergence" in ai_analysis:
  150. doc.add_heading("Convergence Assessment", level=2)
  151. conv = ai_analysis["convergence"]
  152. doc.add_paragraph(f"Converged: {conv.get('converged', 'N/A')}")
  153. doc.add_paragraph(f"Confidence: {conv.get('confidence', 'N/A')}")
  154. if "recommendations" in ai_analysis:
  155. doc.add_heading("Recommendations", level=2)
  156. for rec in ai_analysis["recommendations"]:
  157. doc.add_paragraph(rec, style="List Bullet")
  158. # Save
  159. filename = f"report_{task_data.get('task_id', 'task')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx"
  160. filepath = os.path.join(self.output_dir, filename)
  161. doc.save(filepath)
  162. return filepath
  163. def _generate_json_report(self, task_data: Dict[str, Any],
  164. ai_analysis: Optional[Dict[str, Any]],
  165. report_title: Optional[str]) -> str:
  166. """Fallback: generate JSON report when python-docx is unavailable."""
  167. _raw_metrics = task_data.get("result_metrics", {})
  168. report = {
  169. "title": report_title or f"Simulation Report - {task_data.get('task_name', 'Task')}",
  170. "generated_at": datetime.now().isoformat(),
  171. "task_data": task_data,
  172. "metrics_by_domain": _group_metrics_by_domain(_raw_metrics),
  173. "ai_analysis": ai_analysis,
  174. }
  175. filename = f"report_{task_data.get('task_id', 'task')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
  176. filepath = os.path.join(self.output_dir, filename)
  177. with open(filepath, "w", encoding="utf-8") as f:
  178. json.dump(report, f, ensure_ascii=False, indent=2)
  179. return filepath
  180. def list_reports(self) -> List[Dict[str, Any]]:
  181. """List all generated reports."""
  182. reports = []
  183. if os.path.exists(self.output_dir):
  184. for f in os.listdir(self.output_dir):
  185. if f.endswith((".docx", ".json")):
  186. filepath = os.path.join(self.output_dir, f)
  187. reports.append({
  188. "filename": f,
  189. "path": filepath,
  190. "size": os.path.getsize(filepath),
  191. "created_at": datetime.fromtimestamp(os.path.getctime(filepath)).isoformat(),
  192. })
  193. return sorted(reports, key=lambda x: x["created_at"], reverse=True)
  194. _generator: Optional[ReportGenerator] = None
  195. def get_report_generator() -> ReportGenerator:
  196. global _generator
  197. if _generator is None:
  198. _generator = ReportGenerator()
  199. return _generator