Spaces:
Running on Zero
Running on Zero
File size: 22,773 Bytes
d925e38 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 | """The analyzer's brain. Pure functions over plain data (no database, no Telegram), so every number
is unit-tested. The loading of the data lives in app/db/repos/analytics.py.
Honesty rules built in: small samples are labelled, a correlation is never called a cause, and every
insight shows the numbers it is based on."""
import html
import re
from collections import Counter
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from statistics import median
from typing import Optional
ET = timezone(timedelta(hours=3))
SPARSE = 3 # a course with fewer files than this is "short of material"
# ------------------------------------------------------------------ plain data
@dataclass(frozen=True)
class UserRow:
id: int
created: datetime # UTC
joined: date # Ethiopian date of joining
source: str
verified: bool
onboarded: bool
active_days: int
stream: Optional[str] = None
missing: Optional[str] = None # join-gate places last missing, e.g. "dbucentralforum"
@dataclass(frozen=True)
class EventRow:
kind: str # download | search | inline | find
ref: str
n: int
day: date
user_id: int
@dataclass(frozen=True)
class MaterialRow:
id: int
course_id: Optional[int]
title: str
downloads: int
status: str
kind: str
@dataclass(frozen=True)
class SubRow:
status: str
user_id: int
created: datetime
reviewed: Optional[datetime]
@dataclass(frozen=True)
class PostRow:
message_id: int
posted: datetime # UTC
preview: str
tags: list[str]
reactions: Optional[int]
@dataclass
class Snapshot:
now: datetime
users: list[UserRow]
checkins: dict[int, set[date]]
events: list[EventRow]
materials: list[MaterialRow]
courses: dict[int, str]
subs: list[SubRow]
posts: list[PostRow] = field(default_factory=list)
@property
def today(self) -> date:
return self.now.astimezone(ET).date()
# ------------------------------------------------------------------ small helpers
def pct(part: int, whole: int) -> int:
return round(100 * part / whole) if whole else 0
def bar(percent: float, width: int = 10) -> str:
filled = max(0, min(width, round(percent / 100 * width)))
return "▓" * filled + "░" * (width - filled)
def esc(text: str) -> str:
return html.escape(text or "", quote=False)
def extract_tags(text: str) -> list[str]:
"""?start= tags inside a post: 'ad1', 'dorm5' (numbers are personal invite links, ignored)."""
tags = re.findall(r"[?&]start=([A-Za-z0-9_]{1,20})", text or "")
return sorted({t.lower() for t in tags if not t.isdigit()})
# ------------------------------------------------------------------ growth + funnel
def per_day(users: list[UserRow], today: date, days: int = 14) -> list[tuple[date, int]]:
counts = Counter(u.joined for u in users)
return [(today - timedelta(days=i), counts.get(today - timedelta(days=i), 0)) for i in range(days - 1, -1, -1)]
def funnel(users: list[UserRow]) -> dict:
n = len(users)
verified = sum(u.verified for u in users)
onboarded = sum(u.onboarded for u in users)
active3 = sum(u.active_days >= 3 for u in users)
return dict(joined=n, verified=verified, onboarded=onboarded, active3=active3)
def biggest_leak(f: dict) -> Optional[tuple[str, str, int, int]]:
"""The stage with the largest loss between consecutive steps: (from, to, people lost, % lost)."""
steps = [("joined", "in channel + forum", f["joined"], f["verified"]),
("in channel + forum", "finished setup", f["verified"], f["onboarded"])]
steps = [(a, b, x - y, pct(x - y, x)) for a, b, x, y in steps if x > 0]
return max(steps, key=lambda s: s[2]) if steps and max(s[2] for s in steps) > 0 else None
def source_table(users: list[UserRow]) -> list[dict]:
groups: dict[str, list[UserRow]] = {}
for u in users:
groups.setdefault(u.source, []).append(u)
rows = []
for source, members in groups.items():
f = funnel(members)
rows.append(dict(source=source, **f, setup_pct=pct(f["onboarded"], f["joined"])))
return sorted(rows, key=lambda r: -r["joined"])
def stuck_by_missing(users: list[UserRow]) -> list[tuple[str, int]]:
c = Counter((u.missing or "not recorded yet") for u in users if not u.verified)
return c.most_common()
# ------------------------------------------------------------------ activity + retention
def dau_wau(checkins: dict[int, set[date]], today: date) -> dict:
week = [today - timedelta(days=i) for i in range(6, -1, -1)]
dau = [(d, sum(1 for days in checkins.values() if d in days)) for d in week]
wau = sum(1 for days in checkins.values() if any(d in days for d in week))
last_full = dau[-2][1] if len(dau) >= 2 else 0 # yesterday is complete, today is not
return dict(dau=dau, wau=wau, stickiness=pct(last_full, wau))
def retention(users: list[UserRow], checkins: dict[int, set[date]], today: date, cohorts: int = 7) -> dict:
"""Share of each day's new students who were active exactly N days later. Check-ins only exist from the day
activity tracking started, so earlier cohorts are 'n/a' instead of a misleading 0. A day that is not over
yet (today) is never counted."""
all_days = [d for days in checkins.values() for d in days]
start = min(all_days) if all_days else None
rows = []
for offset in range(cohorts, 0, -1):
d = today - timedelta(days=offset)
members = [u for u in users if u.joined == d]
if not members:
continue
cells = {}
for n in (1, 3, 7):
target = d + timedelta(days=n)
if target >= today or start is None or target < start:
cells[n] = None # not reached yet, or before we tracked activity
else:
cells[n] = pct(sum(1 for u in members if target in checkins.get(u.id, set())), len(members))
rows.append(dict(day=d, size=len(members), **{f"d{n}": v for n, v in cells.items()}))
eligible = [u for u in users if u.joined < today]
came_back = sum(1 for u in eligible if any(day > u.joined for day in checkins.get(u.id, set())))
return dict(rows=rows, tracking_start=start, came_back=came_back, eligible=len(eligible))
# ------------------------------------------------------------------ library + searches
def library(snap: Snapshot) -> dict:
ok = [m for m in snap.materials if m.status == "ok"]
per_course = Counter(m.course_id for m in ok if m.course_id)
empty = [name for cid, name in snap.courses.items() if per_course.get(cid, 0) == 0]
sparse = [name for cid, name in snap.courses.items() if 0 < per_course.get(cid, 0) < SPARSE]
week_start = snap.today - timedelta(days=6)
downloads_week = [e for e in snap.events if e.kind == "download" and e.day >= week_start]
since = snap.today - timedelta(days=13)
searches = [e for e in snap.events if e.kind in ("search", "inline", "find") and e.day >= since and len(e.ref) >= 2]
missed = Counter(e.ref for e in searches if e.n == 0)
found = Counter(e.ref for e in searches if e.n > 0)
top_files = sorted(ok, key=lambda m: -m.downloads)[:5]
return dict(
files=len(ok), kinds=Counter(m.kind for m in ok), courses=len(snap.courses), empty=empty, sparse=sparse,
downloads_total=sum(m.downloads for m in ok), downloads_week=len(downloads_week),
downloaders_week=len({e.user_id for e in downloads_week}), top_files=top_files,
by_course=[(snap.courses.get(cid, "?"), n) for cid, n in per_course.most_common(5)],
searches=len(searches), missed_total=sum(missed.values()), missed=missed.most_common(8), found=found.most_common(5),
)
# ------------------------------------------------------------------ contributions + channel posts
def contributions(snap: Snapshot) -> dict:
by = Counter(s.status for s in snap.subs)
done = [s for s in snap.subs if s.reviewed]
hours = [(s.reviewed - s.created).total_seconds() / 3600 for s in done]
pending = [s for s in snap.subs if s.status == "pending"]
oldest = max(((snap.now - s.created).total_seconds() / 3600 for s in pending), default=0.0)
top = Counter(s.user_id for s in snap.subs if s.status == "approved").most_common(3)
return dict(
pending=by["pending"], approved=by["approved"], rejected=by["rejected"],
approval=pct(by["approved"], by["approved"] + by["rejected"]),
avg_hours=round(sum(hours) / len(hours), 1) if hours else None, oldest_pending_hours=round(oldest, 1), top=top,
)
def channel_posts(snap: Snapshot, window_hours: int = 6) -> list[dict]:
"""For each post: students who joined the bot in the hours after it, and students who came through a tag
written in the post. NOTE: a correlation, not proof; close posts share the same joiners."""
rows = []
for p in snap.posts:
end = p.posted + timedelta(hours=window_hours)
soon = sum(1 for u in snap.users if p.posted <= u.created < end)
tagged = sum(1 for u in snap.users if u.source in p.tags)
rows.append(dict(message_id=p.message_id, posted=p.posted, preview=p.preview, tags=p.tags,
reactions=p.reactions, soon=soon, tagged=tagged))
return sorted(rows, key=lambda r: (-r["tagged"], -r["soon"]))
# ------------------------------------------------------------------ insights (rule based, numbers shown)
@dataclass
class Insight:
priority: int # 1 = most important
title: str
evidence: str
action: str
def insights(snap: Snapshot) -> list[Insight]:
out: list[Insight] = []
users, today = snap.users, snap.today
if not users:
return out
f = funnel(users)
leak = biggest_leak(f)
stuck = [u for u in users if not u.verified]
if f["joined"] and pct(len(stuck), f["joined"]) >= 15:
top = [(m, n) for m, n in stuck_by_missing(users)][:3]
detail = ", ".join(f"{n} missing {m}" if m != "not recorded yet" else f"{n} not recorded yet" for m, n in top)
out.append(Insight(1, "Students stop at the join step",
f"{len(stuck)} of {f['joined']} ({pct(len(stuck), f['joined'])}%) never got through: {detail}.",
"Check /gatecheck. If most miss the forum, consider making it optional or send one reminder."))
elif leak and leak[3] >= 10:
out.append(Insight(2, f"Biggest drop: {leak[0]} → {leak[1]}", f"{leak[2]} people ({leak[3]}%) are lost here.",
"Make this step shorter or explain why it matters."))
verified_not_setup = f["verified"] - f["onboarded"]
if f["verified"] and pct(verified_not_setup, f["verified"]) >= 10:
out.append(Insight(2, "Some verified students did not finish setup",
f"{verified_not_setup} of {f['verified']} ({pct(verified_not_setup, f['verified'])}%) stopped at stream/year.",
"Check that the two setup questions are clear."))
ret = retention(users, snap.checkins, today)
d1 = [(r["d1"], r["size"]) for r in ret["rows"] if r["d1"] is not None]
if d1 and sum(s for _, s in d1) >= 20:
total = sum(s for _, s in d1)
avg = round(sum(v * s for v, s in d1) / total)
if avg < 20:
out.append(Insight(1, "Few students come back the next day", f"Day-1 return is {avg}% (over {total} students).",
"Give a reason to return: new files weekly, a daily XP nudge, exam-week reminders."))
elif ret["tracking_start"] is None or snap.checkins and not d1:
since = ret["tracking_start"].isoformat() if ret["tracking_start"] else "now"
out.append(Insight(4, "Not enough days of data for retention yet",
f"Activity has been tracked since {since}.", "Check again in a few days."))
lib = library(snap)
if lib["missed"] and lib["missed"][0][1] >= 2:
q, n = lib["missed"][0]
out.append(Insight(1, "Students search for things you do not have",
f"{lib['missed_total']} searches in 14 days found nothing; top: “{q}” ({n} times).",
"Add material for these words (see Library & searches), then they find it next time."))
if snap.courses and (len(lib["empty"]) / len(snap.courses)) >= 0.4:
out.append(Insight(2, "Many courses have no file", f"{len(lib['empty'])} of {len(snap.courses)} courses are empty.",
"Use the 🎯 wanted-courses prompt in announcements; ask seniors for past exams."))
con = contributions(snap)
if con["pending"] and con["oldest_pending_hours"] >= 24:
out.append(Insight(2, "Student files are waiting", f"{con['pending']} waiting; the oldest {con['oldest_pending_hours']:.0f} h.",
"Run /queue. Slow reviews discourage contributors."))
days = [n for _, n in per_day(users, today, 14)]
nonzero = [n for n in days if n]
if len(nonzero) >= 3 and max(days) >= 3 * max(1, median(days)):
out.append(Insight(3, "Growth comes from single pushes",
f"Best day {max(days)} new students vs a typical day of {median(days):g}.",
"Keep a steady posting rhythm; one-off pushes fade within 2-3 days."))
sources = [r for r in source_table(users) if r["joined"] >= 10 and r["source"] != "before tracking"]
if len(sources) >= 2:
best, worst = max(sources, key=lambda r: r["setup_pct"]), min(sources, key=lambda r: r["setup_pct"])
if best["setup_pct"] - worst["setup_pct"] >= 15:
out.append(Insight(3, "Sources differ in quality",
f"“{best['source']}”: {best['setup_pct']}% finish setup vs “{worst['source']}”: {worst['setup_pct']}% "
f"({best['joined']} and {worst['joined']} students, still small samples).",
"Repeat what works in the better source."))
referral = sum(1 for u in users if u.source == "referral")
if len(users) >= 50 and pct(referral, len(users)) < 5:
out.append(Insight(3, "Few students join by invite", f"{referral} of {len(users)} ({pct(referral, len(users))}%) came from an invite.",
"Mention “Invite friends” in channel posts; the share button is one tap."))
posts = channel_posts(snap)
if posts and (posts[0]["tagged"] or posts[0]["soon"]):
p = posts[0]
out.append(Insight(4, "Your best channel post so far",
f"“{esc(p['preview'][:50])}” → {p['tagged']} via its tag, {p['soon']} new students within 6 h "
"(a correlation, not proof).", "Write more posts like this one."))
return sorted(out, key=lambda i: i.priority)
# ------------------------------------------------------------------ text for each screen
def _fmt_day(d: date) -> str:
return d.strftime("%m-%d")
def overview(snap: Snapshot) -> str:
users, today = snap.users, snap.today
f = funnel(users)
new_today = sum(1 for u in users if u.joined == today)
wau = dau_wau(snap.checkins, today)
ret = retention(users, snap.checkins, today)
lib = library(snap)
con = contributions(snap)
lines = [
f"📊 <b>Analytics</b> · {snap.now.astimezone(ET).strftime('%d %b %H:%M')} (Ethiopian time)",
f"👥 <b>{f['joined']}</b> students (+{new_today} today) · in channel+forum <b>{f['verified']}</b> ({pct(f['verified'], f['joined'])}%) "
f"· finished setup <b>{f['onboarded']}</b> ({pct(f['onboarded'], f['joined'])}%)",
f"🔁 Active this week <b>{wau['wau']}</b> · came back at least once <b>{ret['came_back']}</b> of {ret['eligible']} "
f"({pct(ret['came_back'], ret['eligible'])}%)",
f"📚 <b>{lib['files']}</b> files · {lib['downloads_total']} downloads ({lib['downloads_week']} this week) · "
f"{len(lib['empty'])} courses empty",
f"🔍 {lib['searches']} searches in 14 days · found nothing: {lib['missed_total']} ({pct(lib['missed_total'], lib['searches'])}%)",
f"📤 Student files: {con['pending']} waiting · {con['approved']} approved · {con['rejected']} rejected",
]
ins = insights(snap)[:3]
if ins:
lines.append("\n💡 <b>What matters most now</b>")
lines += [f"{i}. <b>{esc(x.title)}</b>: {x.evidence}" for i, x in enumerate(ins, 1)]
lines.append("\nTap a button for details. Small samples (under ~30 people) are only hints.")
return "\n".join(lines)
def section_growth(snap: Snapshot) -> str:
days = per_day(snap.users, snap.today, 14)
top = max((n for _, n in days), default=0) or 1
lines = ["👥 <b>Growth and sources</b>", "<b>New students per day</b>"]
lines += [f"{_fmt_day(d)} {bar(100 * n / top, 12)} {n}" for d, n in days]
f = funnel(snap.users)
lines.append(f"\n<b>Funnel</b> (everyone ever)\njoined <b>{f['joined']}</b> → in channel + forum <b>{f['verified']}</b> → "
f"finished setup <b>{f['onboarded']}</b> → used the bot on 3+ days <b>{f['active3']}</b>")
lines.append("\n<b>By source</b> (joined → in channel+forum → finished setup → 3+ days)")
for r in source_table(snap.users)[:10]:
small = " ·small sample" if r["joined"] < 10 else ""
lines.append(f"• <b>{esc(r['source'])}</b>: {r['joined']} → {r['verified']} → {r['onboarded']} → {r['active3']} "
f"({r['setup_pct']}% setup){small}")
stuck = stuck_by_missing(snap.users)
if stuck:
lines.append("\n<b>Stuck at the join step</b>")
lines += [f"• {n}: {('missing ' + m) if m != 'not recorded yet' else m}" for m, n in stuck[:5]]
return "\n".join(lines)
def section_retention(snap: Snapshot) -> str:
ret = retention(snap.users, snap.checkins, snap.today)
w = dau_wau(snap.checkins, snap.today)
lines = ["🔁 <b>Activity and retention</b>", "<b>Active students per day</b> (opened the bot)"]
peak = max((n for _, n in w["dau"]), default=0) or 1
lines += [f"{_fmt_day(d)} {bar(100 * n / peak, 12)} {n}" for d, n in w["dau"]]
lines.append(f"\nWeekly active: <b>{w['wau']}</b> · yesterday's active ÷ weekly active: <b>{w['stickiness']}%</b>")
lines.append(f"Came back at least once: <b>{ret['came_back']}</b> of {ret['eligible']} ({pct(ret['came_back'], ret['eligible'])}%)")
lines.append("\n<b>Return rate by signup day</b> (share active again on day 1 / 3 / 7)")
cell = lambda v: "n/a" if v is None else f"{v}%" # noqa: E731
if ret["rows"]:
lines += [f"{_fmt_day(r['day'])} (n={r['size']}): {cell(r['d1'])} / {cell(r['d3'])} / {cell(r['d7'])}" for r in ret["rows"]]
else:
lines.append("no signups in the last 7 days")
since = ret["tracking_start"].isoformat() if ret["tracking_start"] else "not yet"
lines.append(f"\n“n/a” = that day has not come yet, or it is before activity tracking began ({since}).")
return "\n".join(lines)
def section_library(snap: Snapshot) -> str:
lib = library(snap)
lines = ["📚 <b>Library and searches</b>",
f"{lib['files']} files in {lib['courses']} courses · kinds: " + ", ".join(f"{k} {n}" for k, n in lib["kinds"].most_common()),
f"Downloads: {lib['downloads_total']} all time · {lib['downloads_week']} this week by {lib['downloaders_week']} students"]
if lib["top_files"]:
lines.append("\n<b>Most downloaded</b>")
lines += [f"• {esc(m.title[:45])} ({m.downloads})" for m in lib["top_files"] if m.downloads]
if lib["by_course"]:
lines.append("\n<b>Courses with the most files</b>: " + ", ".join(f"{esc(n[:25])} {c}" for n, c in lib["by_course"]))
lines.append(f"\n<b>No file at all</b> ({len(lib['empty'])}): " + (", ".join(esc(n[:28]) for n in lib["empty"][:8]) or "none")
+ ("…" if len(lib["empty"]) > 8 else ""))
if lib["sparse"]:
lines.append(f"<b>Short of files</b> (under {SPARSE}): " + ", ".join(esc(n[:28]) for n in lib["sparse"][:8]))
lines.append(f"\n🔍 <b>Searches (14 days)</b>: {lib['searches']} · found nothing {lib['missed_total']} ({pct(lib['missed_total'], lib['searches'])}%)")
if lib["missed"]:
lines.append("<b>Searched but NOT found</b> (add these!)")
lines += [f"• “{esc(q)}” × {n}" for q, n in lib["missed"]]
if lib["found"]:
lines.append("<b>Most searched and found</b>: " + ", ".join(f"“{esc(q)}” {n}" for q, n in lib["found"]))
return "\n".join(lines)
def section_contrib(snap: Snapshot) -> str:
c = contributions(snap)
lines = ["📤 <b>Student contributions</b>",
f"Waiting <b>{c['pending']}</b> · approved <b>{c['approved']}</b> · rejected <b>{c['rejected']}</b> · approval rate <b>{c['approval']}%</b>"]
if c["avg_hours"] is not None:
lines.append(f"Average time to review: {c['avg_hours']} h")
if c["pending"]:
lines.append(f"Oldest waiting: {c['oldest_pending_hours']:.0f} h")
if c["top"]:
lines.append("Top contributors (user id · approved): " + ", ".join(f"{u} · {n}" for u, n in c["top"]))
if not snap.subs:
lines.append("No student files yet.")
return "\n".join(lines)
def section_channel(snap: Snapshot) -> str:
rows = channel_posts(snap)
lines = ["📢 <b>Channel posts</b>"]
if not rows:
return "\n".join(lines + [
"No posts recorded yet. The bot records new posts of the channel by itself. To add an older post, "
"FORWARD it (text posts) to this bot as admin."])
lines.append("Sorted by students who came through the post's own link tag, then by new students within 6 hours.")
for r in rows[:8]:
react = "?" if r["reactions"] is None else r["reactions"]
tag = f" · tag {', '.join(r['tags'])}: <b>{r['tagged']}</b>" if r["tags"] else ""
lines.append(f"• {r['posted'].astimezone(ET).strftime('%m-%d %H:%M')} “{esc(r['preview'][:40])}”\n"
f" 6h: <b>{r['soon']}</b> new{tag} · reactions: {react}")
lines.append("\nA post and a sign-up close in time is a hint, not proof. Reactions are only known for posts seen live.")
return "\n".join(lines)
def section_insights(snap: Snapshot) -> str:
ins = insights(snap)
if not ins:
return "💡 <b>Insights</b>\nNothing stands out yet; there is not enough data."
lines = ["💡 <b>Insights and what to do</b> (from your numbers, most important first)"]
for i, x in enumerate(ins, 1):
lines.append(f"\n<b>{i}. {esc(x.title)}</b>\n{x.evidence}\n➡️ {esc(x.action)}")
return "\n".join(lines)
|