""" analytics.py — deterministic, grounded computation over a project's milestones. Everything the model must NOT guess is computed here in Python: today's date, days-to-due, what's overdue, what's blocked, the project's final deadline, and a plain-text status digest. The chat layer injects these facts so answers about deadlines / blockers / status are read off the sheet, not invented. """ import datetime import os import re RISK_HINTS = ("may extend", "may vary", "depending on", "lead time", "to be ordered", "issues", "delay", "risk") DONE = {"completed", "done", "closed"} BLOCKED = {"delayed"} def today() -> datetime.date: """Reference 'now'. Override with PORTAL_TODAY=YYYY-MM-DD for testing.""" override = os.environ.get("PORTAL_TODAY", "").strip() if override: try: return datetime.date.fromisoformat(override) except ValueError: pass return datetime.date.today() def _date(s): if not s: return None try: return datetime.date.fromisoformat(str(s)[:10]) except ValueError: return None def enrich(milestone: dict, ref: datetime.date) -> dict: """Return the milestone plus derived fields (non-destructive copy).""" m = dict(milestone) status = (m.get("status") or "").strip().lower() target = _date(m.get("target_end")) m["_done"] = status in DONE m["_blocked"] = status in BLOCKED remarks = (m.get("remarks") or "").lower() m["_at_risk"] = any(h in remarks for h in RISK_HINTS) if target and not m["_done"]: m["_days_to_due"] = (target - ref).days m["_overdue"] = m["_days_to_due"] < 0 else: m["_days_to_due"] = None m["_overdue"] = False return m def snapshot(project: dict, ref: datetime.date) -> dict: rows = [enrich(m, ref) for m in (project.get("milestones") or [])] by_status = {} for m in rows: key = (m.get("status") or "Unknown").strip() or "Unknown" by_status[key] = by_status.get(key, 0) + 1 overdue = sorted( [m for m in rows if m["_overdue"]], key=lambda m: m["_days_to_due"], ) blocked = [m for m in rows if m["_blocked"]] at_risk = [m for m in rows if m["_at_risk"] and not m["_done"]] due_soon = sorted( [m for m in rows if m["_days_to_due"] is not None and 0 <= m["_days_to_due"] <= 14], key=lambda m: m["_days_to_due"], ) open_targets = [(_date(m.get("target_end")), m) for m in rows if not m["_done"]] open_targets = [(d, m) for d, m in open_targets if d] next_deadline = min(open_targets, key=lambda x: x[0]) if open_targets else None all_targets = [(_date(m.get("target_end")), m) for m in rows] all_targets = [(d, m) for d, m in all_targets if d] final_deadline = max(all_targets, key=lambda x: x[0]) if all_targets else None total = len(rows) done = sum(1 for m in rows if m["_done"]) return { "today": ref.isoformat(), "total": total, "done": done, "percent_complete": round(100 * done / total) if total else 0, "by_status": by_status, "overdue": overdue, "blocked": blocked, "at_risk": at_risk, "due_soon": due_soon, "next_deadline": next_deadline, # (date, milestone) or None "final_deadline": final_deadline, } def _label(m): sno = (m.get("sno") or "").strip() return (sno + " " if sno else "") + (m.get("milestone") or "").strip() def _section_key(sno: str) -> str: m = re.match(r"(\d+)", (sno or "").strip()) return m.group(1) if m else "?" def _row_view(m: dict, section_name: str) -> dict: """Serializable milestone row with derived flags for the dashboard/table.""" return { "sno": m.get("sno", ""), "milestone": m.get("milestone", ""), "owner": m.get("owner", ""), "status": m.get("status", ""), "start": m.get("start", ""), "target_end": m.get("target_end", ""), "actual_end": m.get("actual_end", ""), "duration": m.get("duration", ""), "depends": m.get("depends", ""), "remarks": m.get("remarks", ""), "section": section_name, "done": m["_done"], "overdue": m["_overdue"], "blocked": m["_blocked"], "at_risk": m["_at_risk"], "days_to_due": m["_days_to_due"], } def dashboard(project: dict, ref: datetime.date) -> dict: """Everything the executive dashboard needs in one call (no LLM, grounded).""" rows = [enrich(m, ref) for m in (project.get("milestones") or [])] # section (in-project "domain") names from the x.0 rows topname = {} for m in rows: sno = (m.get("sno") or "").strip() if re.match(r"^\d+\.0$", sno): topname[_section_key(sno)] = (m.get("milestone") or "").strip() sec_rows = {} for m in rows: k = _section_key(m.get("sno")) sec_rows.setdefault(k, []).append(m) def sec_name(k): return topname.get(k, f"Section {k}") sections = [] for k in sorted(sec_rows, key=lambda x: (int(x) if x.isdigit() else 999)): items = sec_rows[k] total = len(items) done = sum(1 for m in items if m["_done"]) starts = [_date(m.get("start")) for m in items if _date(m.get("start"))] ends = [_date(m.get("target_end")) for m in items if _date(m.get("target_end"))] sections.append({ "key": k, "name": sec_name(k), "total": total, "done": done, "in_progress": sum(1 for m in items if (m.get("status") or "").lower() == "in progress"), "overdue": sum(1 for m in items if m["_overdue"]), "blocked": sum(1 for m in items if m["_blocked"]), "percent": round(100 * done / total) if total else 0, "start": min(starts).isoformat() if starts else None, "end": max(ends).isoformat() if ends else None, }) # owner workload owners = {} for m in rows: o = (m.get("owner") or "—").strip() or "—" owners.setdefault(o, {"owner": o, "total": 0, "overdue": 0, "blocked": 0}) owners[o]["total"] += 1 if m["_overdue"]: owners[o]["overdue"] += 1 if m["_blocked"]: owners[o]["blocked"] += 1 owners_list = sorted(owners.values(), key=lambda x: x["total"], reverse=True) # status breakdown for the ring status_counts = {} for m in rows: s = (m.get("status") or "Unknown").strip() or "Unknown" status_counts[s] = status_counts.get(s, 0) + 1 status_breakdown = [{"status": s, "count": c} for s, c in sorted(status_counts.items(), key=lambda x: -x[1])] milestones = [_row_view(m, sec_name(_section_key(m.get("sno")))) for m in rows] blockers = [r for r in milestones if r["blocked"]] at_risk = [r for r in milestones if r["at_risk"] and not r["done"]] all_starts = [_date(m.get("start")) for m in rows if _date(m.get("start"))] all_ends = [_date(m.get("target_end")) for m in rows if _date(m.get("target_end"))] return { "summary": snapshot_summary(project, ref), "status_breakdown": status_breakdown, "sections": sections, "owners": owners_list[:8], "milestones": milestones, "blockers": blockers, "at_risk": at_risk, "timeline": { "start": min(all_starts).isoformat() if all_starts else None, "end": max(all_ends).isoformat() if all_ends else None, "today": ref.isoformat(), }, "facets": { "statuses": sorted({(m.get("status") or "").strip() for m in rows if (m.get("status") or "").strip()}), "owners": sorted({(m.get("owner") or "").strip() for m in rows if (m.get("owner") or "").strip()}), "sections": [s["name"] for s in sections], }, } def snapshot_summary(project: dict, ref: datetime.date) -> dict: """JSON-safe headline numbers for the UI stat cards (no LLM, fully grounded).""" s = snapshot(project, ref) def deadline(t): if not t: return None d, m = t return {"date": d.isoformat(), "label": _label(m), "days": (d - ref).days} bs = s["by_status"] return { "today": s["today"], "total": s["total"], "done": s["done"], "in_progress": bs.get("In Progress", 0), "not_started": bs.get("Not Started", 0), "delayed": bs.get("Delayed", 0), "overdue": len(s["overdue"]), "blocked": len(s["blocked"]), "at_risk": len(s["at_risk"]), "due_soon": len(s["due_soon"]), "percent_complete": s["percent_complete"], "next_deadline": deadline(s["next_deadline"]), "final_deadline": deadline(s["final_deadline"]), } def status_digest_text(project: dict, ref: datetime.date) -> str: """A fully grounded, no-LLM status summary — the backbone of alerting.""" s = snapshot(project, ref) out = [f"PROJECT STATUS — {project.get('name','(unnamed)')} (as of {s['today']})", f"Progress: {s['done']}/{s['total']} milestones completed ({s['percent_complete']}%).", "By status: " + ", ".join(f"{k}: {v}" for k, v in sorted(s['by_status'].items())) or "n/a"] if s["final_deadline"]: d, m = s["final_deadline"] out.append(f"Final milestone deadline: {d.isoformat()} — {_label(m)}.") if s["next_deadline"]: d, m = s["next_deadline"] days = (d - ref).days when = f"in {days} day(s)" if days >= 0 else f"{abs(days)} day(s) ago" out.append(f"Next open deadline: {d.isoformat()} ({when}) — {_label(m)}.") def block(title, items, render): if not items: return f"\n{title}: none." return f"\n{title} ({len(items)}):\n" + "\n".join(" - " + render(m) for m in items) out.append(block("OVERDUE", s["overdue"], lambda m: f"{_label(m)} — owner {m.get('owner') or '?'}, due {m.get('target_end') or '?'}, " f"{abs(m['_days_to_due'])} day(s) late, status {m.get('status') or '?'}")) out.append(block("BLOCKED (status = Delayed)", s["blocked"], lambda m: f"{_label(m)} — owner {m.get('owner') or '?'}" + (f"; remark: {m.get('remarks')}" if m.get('remarks') else "") + (f"; dependency: {m.get('depends')}" if m.get('depends') else ""))) out.append(block("AT-RISK (remarks flag schedule risk)", s["at_risk"], lambda m: f"{_label(m)} — {m.get('remarks')}")) out.append(block("DUE WITHIN 14 DAYS", s["due_soon"], lambda m: f"{_label(m)} — due {m.get('target_end')}, in {m['_days_to_due']} day(s), " f"owner {m.get('owner') or '?'}, status {m.get('status') or '?'}")) return "\n".join(out)