razaali10 commited on
Commit
6946637
·
verified ·
1 Parent(s): 46383a4

Upload 20 files

Browse files
Files changed (4) hide show
  1. report_engine.py +65 -1
  2. requirements.txt +1 -1
  3. smoke_test.py +8 -0
  4. tools.py +49 -3
report_engine.py CHANGED
@@ -956,6 +956,56 @@ def _compact_scenario_comparison(df: pd.DataFrame) -> pd.DataFrame:
956
  wanted=["Display Scenario","Source Model","Storm","Storm Status","Simulation Status","Runoff Error (%)","Flow Error (%)","Peak Subcatchment Runoff","Maximum Storage Depth","Maximum Storage Volume","Peak Link Flow","Maximum Node Inflow","Maximum Node Flooding","Hydraulic Difference"]
957
  return out[[c for c in wanted if c in out.columns]].copy()
958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
959
  def generate_report_package(
960
  *,
961
  metadata: ReportMetadata,
@@ -972,6 +1022,7 @@ def generate_report_package(
972
  scenario_records: list[Mapping[str, Any]] | None = None,
973
  scenario_reporting_mode: str = "Base report with scenario comparison",
974
  preliminary_review_artifacts: Mapping[str, Any] | None = None,
 
975
  ) -> dict[str, bytes | str]:
976
  """Generate editable Word report and ZIP package entirely in memory."""
977
  criteria = criteria or ReportCriteria()
@@ -1339,7 +1390,9 @@ def generate_report_package(
1339
  "scenario_comparison": scenario_comparison if scenario_comparison is not None else pd.DataFrame(),
1340
  },
1341
  )
1342
- docx_buffer = io.BytesIO(); doc.save(docx_buffer); docx_bytes = docx_buffer.getvalue()
 
 
1343
  base = _safe_name(metadata.project_name)
1344
 
1345
  zip_buffer = io.BytesIO()
@@ -1377,6 +1430,17 @@ def generate_report_package(
1377
  if frame is not None and not frame.empty:
1378
  zf.writestr(f"tables/scenarios/{sid}_{key}.csv", frame.to_csv(index=False))
1379
  zf.writestr("metadata/llm_report_context.json", json.dumps(llm_context, indent=2, default=str))
 
 
 
 
 
 
 
 
 
 
 
1380
  zf.writestr("metadata/approved_narrative_sections.json", json.dumps(dict(narrative_sections or {}), indent=2, ensure_ascii=False))
1381
  zf.writestr("metadata/project_metadata.json", json.dumps(asdict(metadata), indent=2))
1382
  zf.writestr("metadata/report_criteria.json", json.dumps(asdict(criteria), indent=2, default=str))
 
956
  wanted=["Display Scenario","Source Model","Storm","Storm Status","Simulation Status","Runoff Error (%)","Flow Error (%)","Peak Subcatchment Runoff","Maximum Storage Depth","Maximum Storage Volume","Peak Link Flow","Maximum Node Inflow","Maximum Node Flooding","Hydraulic Difference"]
957
  return out[[c for c in wanted if c in out.columns]].copy()
958
 
959
+ def _embed_attached_figures(doc, figures: list[Mapping[str, Any]] | None) -> None:
960
+ """Insert session-attached figures into the built document.
961
+
962
+ Each figure dict: {"figure_id", "path", "caption", "section", "source"}.
963
+ Figures are inserted at the END of the first level-1 section whose
964
+ heading contains the requested section keyword (case-insensitive), i.e.
965
+ immediately before the next Heading-1 paragraph; unmatched sections fall
966
+ back to the end of the document. Client-supplied figures are labelled as
967
+ such — they are illustrative material attached to the audited report,
968
+ not server-verified outputs.
969
+ """
970
+ if not figures:
971
+ return
972
+ from docx.shared import Inches
973
+
974
+ headings = [(i, p) for i, p in enumerate(doc.paragraphs)
975
+ if p.style.name.startswith("Heading 1")]
976
+
977
+ def anchor_for(section_kw: str):
978
+ kw = (section_kw or "results").strip().lower()
979
+ for pos, (idx, para) in enumerate(headings):
980
+ if kw in para.text.lower():
981
+ if pos + 1 < len(headings):
982
+ return headings[pos + 1][1] # insert before next H1
983
+ return None # matched last section -> append at end
984
+ return None
985
+
986
+ for n, fig in enumerate(figures, start=1):
987
+ path = str(fig.get("path", ""))
988
+ if not path or not Path(path).exists():
989
+ continue
990
+ caption = str(fig.get("caption") or fig.get("figure_id") or f"Attached figure {n}")
991
+ source = str(fig.get("source") or "session-attached")
992
+ label = f"Figure A{n} - {caption} ({source}; illustrative, not a server-verified output)"
993
+ anchor = anchor_for(str(fig.get("section", "results")))
994
+ try:
995
+ if anchor is not None:
996
+ pic_par = anchor.insert_paragraph_before()
997
+ pic_par.add_run().add_picture(path, width=Inches(6.0))
998
+ cap_par = anchor.insert_paragraph_before(label)
999
+ cap_par.style = doc.styles["Caption"] if "Caption" in [s.name for s in doc.styles] else cap_par.style
1000
+ else:
1001
+ doc.add_paragraph().add_run().add_picture(path, width=Inches(6.0))
1002
+ doc.add_paragraph(label)
1003
+ except Exception:
1004
+ # A corrupt image must never abort report generation.
1005
+ (anchor.insert_paragraph_before if anchor is not None else doc.add_paragraph)(
1006
+ f"[Attached figure '{caption}' could not be embedded — file unreadable.]")
1007
+
1008
+
1009
  def generate_report_package(
1010
  *,
1011
  metadata: ReportMetadata,
 
1022
  scenario_records: list[Mapping[str, Any]] | None = None,
1023
  scenario_reporting_mode: str = "Base report with scenario comparison",
1024
  preliminary_review_artifacts: Mapping[str, Any] | None = None,
1025
+ attached_figures: list[Mapping[str, Any]] | None = None,
1026
  ) -> dict[str, bytes | str]:
1027
  """Generate editable Word report and ZIP package entirely in memory."""
1028
  criteria = criteria or ReportCriteria()
 
1390
  "scenario_comparison": scenario_comparison if scenario_comparison is not None else pd.DataFrame(),
1391
  },
1392
  )
1393
+ docx_buffer = io.BytesIO()
1394
+ _embed_attached_figures(doc, attached_figures)
1395
+ doc.save(docx_buffer); docx_bytes = docx_buffer.getvalue()
1396
  base = _safe_name(metadata.project_name)
1397
 
1398
  zip_buffer = io.BytesIO()
 
1430
  if frame is not None and not frame.empty:
1431
  zf.writestr(f"tables/scenarios/{sid}_{key}.csv", frame.to_csv(index=False))
1432
  zf.writestr("metadata/llm_report_context.json", json.dumps(llm_context, indent=2, default=str))
1433
+ if attached_figures:
1434
+ fig_manifest = []
1435
+ for n, fig in enumerate(attached_figures, start=1):
1436
+ fpath = Path(str(fig.get("path", "")))
1437
+ if fpath.exists():
1438
+ zf.writestr(f"figures/{fpath.name}", fpath.read_bytes())
1439
+ fig_manifest.append({"n": n, "figure_id": fig.get("figure_id"),
1440
+ "file": fpath.name, "caption": fig.get("caption"),
1441
+ "section": fig.get("section"), "source": fig.get("source"),
1442
+ "note": "Client-attached illustrative figure; not a server-verified output."})
1443
+ zf.writestr("metadata/attached_figures.json", json.dumps(fig_manifest, indent=2, default=str))
1444
  zf.writestr("metadata/approved_narrative_sections.json", json.dumps(dict(narrative_sections or {}), indent=2, ensure_ascii=False))
1445
  zf.writestr("metadata/project_metadata.json", json.dumps(asdict(metadata), indent=2))
1446
  zf.writestr("metadata/report_criteria.json", json.dumps(asdict(criteria), indent=2, default=str))
requirements.txt CHANGED
@@ -3,8 +3,8 @@ mcp>=1.10,<2
3
  fastapi>=0.110
4
  uvicorn>=0.29
5
  httpx>=0.27
 
6
  pandas>=2.0
7
  numpy>=1.26
8
  python-docx>=1.1
9
  PyYAML>=6.0
10
- requests>=2.31
 
3
  fastapi>=0.110
4
  uvicorn>=0.29
5
  httpx>=0.27
6
+ requests>=2.31
7
  pandas>=2.0
8
  numpy>=1.26
9
  python-docx>=1.1
10
  PyYAML>=6.0
 
smoke_test.py CHANGED
@@ -33,6 +33,14 @@ async def main():
33
  print(f"MCP workflow: session {sid} | recon {recon.get('ok')}/{recon.get('links_checked')}")
34
  if "Kincora" in INP:
35
  assert recon.get("ok", 0) >= 26, "REGRESSION PIN FAILED"
 
 
 
 
 
 
 
 
36
  rep = json.loads((await s.call_tool("generate_report",
37
  {"session_id": sid, "project_name": "Smoke Test"})).content[0].text)
38
  dl = httpx.get(f"{BASE}{rep['files']['docx']}", timeout=60)
 
33
  print(f"MCP workflow: session {sid} | recon {recon.get('ok')}/{recon.get('links_checked')}")
34
  if "Kincora" in INP:
35
  assert recon.get("ok", 0) >= 26, "REGRESSION PIN FAILED"
36
+ # 1x1 red PNG — validates the attach_figure -> embedded-report path
37
+ tiny_png = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
38
+ "2mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
39
+ fig = json.loads((await s.call_tool("attach_figure",
40
+ {"session_id": sid, "image_base64": tiny_png,
41
+ "caption": "Smoke-test figure", "section": "results"})).content[0].text)
42
+ assert fig["figure_id"] == "FIG-01", fig
43
+ print("attach_figure: OK", fig["figure_id"])
44
  rep = json.loads((await s.call_tool("generate_report",
45
  {"session_id": sid, "project_name": "Smoke Test"})).content[0].text)
46
  dl = httpx.get(f"{BASE}{rep['files']['docx']}", timeout=60)
tools.py CHANGED
@@ -220,8 +220,13 @@ def get_timeseries(session_id: str, object_type: str, object_id: str, variable:
220
  else:
221
  points = [{"t": str(times[i]) if i < len(times) else i, "v": round(float(v), 6)}
222
  for i, v in enumerate(series)]
 
 
 
223
  return {"object_id": object_id, "variable": variable, "n_source_points": n,
224
- "decimated": n > MAX_TS_POINTS, "peak": round(float(max(series, key=abs, default=0.0)), 6),
 
 
225
  "points": points}
226
 
227
 
@@ -349,6 +354,46 @@ def run_scenario(session_id: str, scenario_name: str,
349
  "deterministic_analysis": narrative[:6000]}
350
 
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  def generate_report(session_id: str, project_name: str, client: str = "",
353
  consultant: str = "", prepared_by: str = "",
354
  outline_plan_no: str = "") -> dict:
@@ -372,7 +417,8 @@ def generate_report(session_id: str, project_name: str, client: str = "",
372
  preliminary_review_artifacts={
373
  "findings": findings, "status": "Preliminary",
374
  "manifest": {"rpt_reconciliation": session.data.get("recon_summary", {})},
375
- } if findings else None)
 
376
  outputs = session.workdir / "outputs"
377
  outputs.mkdir(exist_ok=True)
378
  files = {}
@@ -399,6 +445,6 @@ TOOL_REGISTRY: dict[str, Callable[..., dict]] = {
399
  get_node_results, get_link_results, get_subcatchment_results,
400
  get_timeseries, query_results, get_table_catalog,
401
  calgary_screening, preliminary_design_review, get_reconciliation,
402
- run_scenario, generate_report,
403
  ]
404
  }
 
220
  else:
221
  points = [{"t": str(times[i]) if i < len(times) else i, "v": round(float(v), 6)}
222
  for i, v in enumerate(series)]
223
+ peak_val = max(series, key=abs, default=0.0)
224
+ peak_idx = series.index(peak_val) if series else 0
225
+ time_of_peak = str(times[peak_idx]) if peak_idx < len(times) else None
226
  return {"object_id": object_id, "variable": variable, "n_source_points": n,
227
+ "decimated": n > MAX_TS_POINTS, "peak": round(float(peak_val), 6),
228
+ "time_of_peak": time_of_peak,
229
+ "note": "time_of_peak is from the full-resolution series; do not infer it from decimated point labels.",
230
  "points": points}
231
 
232
 
 
354
  "deterministic_analysis": narrative[:6000]}
355
 
356
 
357
+ def attach_figure(session_id: str, image_base64: str, caption: str,
358
+ section: str = "results", figure_name: str = "") -> dict:
359
+ """Attach a client-generated figure (PNG/JPEG, base64) to the session so
360
+ generate_report embeds it in the AUDITED report instead of the client
361
+ rebuilding the document itself.
362
+
363
+ section: keyword matched against report Heading-1 titles (e.g. "results",
364
+ "methodology", "site"); the figure is placed at the end of that section.
365
+ Figures are labelled as client-attached illustrative material — they are
366
+ not server-verified outputs. Limits: PNG or JPEG, 5 MB decoded.
367
+ """
368
+ session = STORE.get(session_id)
369
+ try:
370
+ blob = base64.b64decode(image_base64, validate=True)
371
+ except (binascii.Error, ValueError) as exc:
372
+ raise ValueError(f"image_base64 is not valid base64: {exc}")
373
+ if len(blob) > 5 * 1024 * 1024:
374
+ raise ValueError("Figure exceeds the 5 MB limit.")
375
+ if blob[:8] == b"\x89PNG\r\n\x1a\n":
376
+ ext = "png"
377
+ elif blob[:3] == b"\xff\xd8\xff":
378
+ ext = "jpg"
379
+ else:
380
+ raise ValueError("Only PNG or JPEG figures are accepted (magic-byte check failed).")
381
+ figures = session.data.setdefault("figures", [])
382
+ figure_id = f"FIG-{len(figures) + 1:02d}"
383
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in (figure_name or figure_id))
384
+ fig_dir = session.workdir / "figures"
385
+ fig_dir.mkdir(exist_ok=True)
386
+ path = fig_dir / f"{safe}.{ext}"
387
+ path.write_bytes(blob)
388
+ figures.append({"figure_id": figure_id, "path": str(path), "caption": caption.strip(),
389
+ "section": section.strip().lower() or "results", "source": "client-attached"})
390
+ return {"figure_id": figure_id, "stored": path.name, "size_bytes": len(blob),
391
+ "section": section, "attached_figures": [
392
+ {"figure_id": f["figure_id"], "caption": f["caption"], "section": f["section"]}
393
+ for f in figures],
394
+ "next_step": "Call generate_report; the figure will be embedded with a labelled caption."}
395
+
396
+
397
  def generate_report(session_id: str, project_name: str, client: str = "",
398
  consultant: str = "", prepared_by: str = "",
399
  outline_plan_no: str = "") -> dict:
 
417
  preliminary_review_artifacts={
418
  "findings": findings, "status": "Preliminary",
419
  "manifest": {"rpt_reconciliation": session.data.get("recon_summary", {})},
420
+ } if findings else None,
421
+ attached_figures=session.data.get("figures") or None)
422
  outputs = session.workdir / "outputs"
423
  outputs.mkdir(exist_ok=True)
424
  files = {}
 
445
  get_node_results, get_link_results, get_subcatchment_results,
446
  get_timeseries, query_results, get_table_catalog,
447
  calgary_screening, preliminary_design_review, get_reconciliation,
448
+ run_scenario, attach_figure, generate_report,
449
  ]
450
  }