| """ |
| 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, |
| "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 [])] |
|
|
| |
| 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, |
| }) |
|
|
| |
| 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_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) |
|
|