@router.get("/plan-report/{entry_id}")
def get_plan_report(entry_id: str):
"""Auto-generate a structured report from a plan node's subtree."""
fed = federation.find_tree_for_id(entry_id)
if fed is not None:
# Federated (read-only peer) plan: build the plan and its subtree from
# the peer's namespaced projection rather than the local graph, mirroring
# how the tree/entry routes surface federated nodes.
tree, _local_id = fed
all_entries = federation.project_entries(tree)
plan = next((e for e in all_entries if e["id"] == entry_id), None)
if plan is None:
raise HTTPException(status_code=404, detail=f"Entry not found: {entry_id}")
contains_edges: list[dict] = []
else:
try:
graph = loader.graph
except FileNotFoundError:
raise HTTPException(status_code=503, detail="Memory file not found")
rows = list(graph.cypher(
"MATCH (e:entry) WHERE e.id = $eid "
"RETURN e.id AS id, e.type AS type, e.title AS title, e.body AS body, "
"e.status AS status, e.success AS success, e.created_at AS created_at",
params={"eid": entry_id},
))
if not rows:
raise HTTPException(status_code=404, detail=f"Entry not found: {entry_id}")
plan = rows[0]
project_id = _get_project()["id"]
all_entries = list(graph.cypher(
"MATCH (e:entry) WHERE e.project = $proj AND e.status = $st "
"RETURN e.id AS id, e.type AS type, e.title AS title, e.body AS body, "
"e.parent_id AS parent_id, e.success AS success, e.tags AS tags, "
"e.created_at AS created_at, e.node_label AS node_label",
params={"proj": project_id, "st": "active"},
))
# Also include children connected via secondary `contains` edges
contains_edges = list(graph.cypher(
"MATCH (src:entry)-[:contains]->(tgt:entry) "
"WHERE src.project = $proj AND tgt.status = $st "
"RETURN src.id AS source, tgt.id AS target",
params={"proj": project_id, "st": "active"},
))
children_of: dict[str, list] = {}
entry_map = {e["id"]: e for e in all_entries}
for e in all_entries:
children_of.setdefault(e["parent_id"], []).append(e)
for ce in contains_edges:
if ce["target"] in entry_map:
children_of.setdefault(ce["source"], []).append(entry_map[ce["target"]])
subtree: list[dict] = []
visited: set[str] = set()
stack = list(children_of.get(entry_id, []))
while stack:
e = stack.pop()
if e["id"] in visited:
continue
visited.add(e["id"])
subtree.append(e)
stack.extend(children_of.get(e["id"], []))
decisions = [e for e in subtree if e["type"] == "decision"]
experiments = [e for e in subtree if e["type"] == "experiment"]
checkpoints = [e for e in subtree if e["type"] == "checkpoint"]
todos = [e for e in subtree if e["type"] == "todo"]
notes = [e for e in subtree if e["type"] in ("note", "annotation")]
sub_plans = [e for e in subtree if e["type"] == "plan"]
exp_pass = [e for e in experiments if e.get("success") == "true"]
exp_fail = [e for e in experiments if e.get("success") == "false"]
exp_pending = [e for e in experiments if e.get("success") not in ("true", "false")]
lines = []
lines.append(f"# {plan['title']}")
lines.append("")
if subtree:
sorted_entries = sorted(subtree, key=lambda e: e.get("created_at", ""))
first = sorted_entries[0].get("created_at", "")[:10] if sorted_entries else "?"
last = sorted_entries[-1].get("created_at", "")[:10] if sorted_entries else "?"
lines.append(f"**Period:** {first} — {last} ")
lines.append(f"**Status:** {plan.get('status', 'active')} ")
lines.append(f"**Total entries:** {len(subtree)}")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Summary")
lines.append("")
summary_parts = []
if decisions:
summary_parts.append(f"{len(decisions)} decision{'s' if len(decisions) != 1 else ''}")
if experiments:
exp_summary = f"{len(experiments)} experiment{'s' if len(experiments) != 1 else ''}"
details = []
if exp_pass:
details.append(f"{len(exp_pass)} passed")
if exp_fail:
details.append(f"{len(exp_fail)} failed")
if exp_pending:
details.append(f"{len(exp_pending)} pending")
if details:
exp_summary += f" ({', '.join(details)})"
summary_parts.append(exp_summary)
if checkpoints:
summary_parts.append(f"{len(checkpoints)} checkpoint{'s' if len(checkpoints) != 1 else ''}")
if todos:
summary_parts.append(f"{len(todos)} open todo{'s' if len(todos) != 1 else ''}")
if notes:
summary_parts.append(f"{len(notes)} note{'s' if len(notes) != 1 else ''}")
if sub_plans:
summary_parts.append(f"{len(sub_plans)} sub-plan{'s' if len(sub_plans) != 1 else ''}")
lines.append("This plan contains " + ", ".join(summary_parts) + ".")
lines.append("")
if decisions:
lines.append("---")
lines.append("")
lines.append("## Decisions")
lines.append("")
for d in sorted(decisions, key=lambda e: e.get("created_at", "")):
lines.append(f"### {d['title']}")
if d.get("body"):
lines.append("")
lines.append(d["body"])
lines.append("")
if experiments:
lines.append("---")
lines.append("")
lines.append("## Experiments")
lines.append("")
for e in sorted(experiments, key=lambda e: e.get("created_at", "")):
status_icon = "✓" if e.get("success") == "true" else "✗" if e.get("success") == "false" else "?"
lines.append(f"- **{status_icon} {e['title']}**")
if e.get("body"):
body_preview = e["body"][:200]
if len(e["body"]) > 200:
body_preview += "..."
lines.append(f" {body_preview}")
lines.append("")
if checkpoints:
lines.append("---")
lines.append("")
lines.append("## Checkpoints")
lines.append("")
for c in sorted(checkpoints, key=lambda e: e.get("created_at", "")):
date = c.get("created_at", "")[:10]
lines.append(f"- **{date}** — {c['title']}")
if c.get("body"):
body_preview = c["body"][:300]
if len(c["body"]) > 300:
body_preview += "..."
lines.append(f" {body_preview}")
lines.append("")
if todos:
lines.append("---")
lines.append("")
lines.append("## Open Items")
lines.append("")
for t in todos:
lines.append(f"- [ ] {t['title']}")
lines.append("")
if notes:
lines.append("---")
lines.append("")
lines.append("## Notes & Annotations")
lines.append("")
for n in sorted(notes, key=lambda e: e.get("created_at", "")):
label = "Note" if n["type"] == "note" else "Annotation"
lines.append(f"- **[{label}]** {n['title']}")
if n.get("body"):
body_preview = n["body"][:200]
if len(n["body"]) > 200:
body_preview += "..."
lines.append(f" {body_preview}")
lines.append("")
return {"entry_id": entry_id, "title": plan["title"], "report": "\n".join(lines)}