vct / app /analytics.py
vg15o2's picture
Deploy Vconnect API
c91457f
Raw
History Blame Contribute Delete
10.8 kB
"""
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)