Spaces:
Runtime error
Runtime error
| """whatfirst-small — a Gradio app that turns a messy brain-dump (or a photo of a | |
| to-do list) into a transparently-ranked "do this first" list, using a small | |
| local model for the messy-language part and a deterministic, legible engine for | |
| the ranking. | |
| Capture -> AI structures each task (impact / readiness / effort / due) -> the | |
| scoring engine ranks -> you can correct any score and it re-ranks live. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from datetime import datetime | |
| import gradio as gr | |
| import llm | |
| import score | |
| SAMPLE_DUMP = """email the landlord about the lease renewal, kind of important and due Friday | |
| finish the Q3 board deck — big deal, maybe 4 hours of work, needs to be done by next Wednesday | |
| 5 min: cancel the unused gym membership | |
| look into switching the team to the new CI, no rush, not sure where to start | |
| book the dentist, due tomorrow | |
| reply to Sam's thread, quick one | |
| draft the hiring plan — important but I'm blocked until we agree on budget""" | |
| FORMULA_BLURB = """ | |
| Two scores compete and the higher one shows: | |
| - **do = (Impact · Urgency · Readiness_eff · QuickWin) / 10** — the case for doing it now. | |
| - **prep = (Impact · Urgency · (10 − Readiness)) / 10 · 0.7 · QuickWin** — the case for de-risking it first (wins only when a task is valuable but not ready). | |
| **Urgency** climbs as a deadline nears and *explodes* once it's within a day, so a looming deadline can't be buried by a shiny far-off task. **QuickWin** rewards short, ready tasks. Deadlines act as a *constraint*, not just a term: anything overdue, or that genuinely won't finish in time given everything ahead of it, is lifted above the value pack. Every number is shown — nothing is a black box. | |
| """ | |
| DF_HEADERS = ["#", "Task", "Score", "Due", "Flag", "Why", "I", "R", "Eff·h"] | |
| DF_DATATYPES = ["number", "str", "str", "str", "str", "str", "number", "number", "number"] | |
| # ── Branding, borrowed from the full what-first.com app ─────────────────────── | |
| # Indigo → blue gradient, slate/ink text, Roboto — the palette the marketing | |
| # site and the demo video (demo/compose.py) share. | |
| INDIGO, BLUE = "#4F46E5", "#2563EB" | |
| SLATE, INK, MUTED = "#1E293B", "#111827", "#5A6473" | |
| # Gold is the site's accent (demo/compose.py); GOLD_LT reads brighter on the | |
| # dark hero gradient, where the darker brand gold would muddy. | |
| GOLD, GOLD_LT = "#D97706", "#FBBF24" | |
| THEME = gr.themes.Soft( | |
| primary_hue=gr.themes.colors.indigo, | |
| secondary_hue=gr.themes.colors.blue, | |
| neutral_hue=gr.themes.colors.slate, | |
| font=[gr.themes.GoogleFont("Roboto"), "system-ui", "sans-serif"], | |
| ).set( | |
| button_primary_background_fill=f"linear-gradient(90deg, {INDIGO}, {BLUE})", | |
| button_primary_background_fill_hover=f"linear-gradient(90deg, {BLUE}, {INDIGO})", | |
| button_primary_text_color="white", | |
| block_title_text_weight="600", | |
| ) | |
| CSS = f""" | |
| #wf-hero {{ | |
| background: linear-gradient(120deg, {INK}, {INDIGO} 55%, {BLUE}); | |
| border-radius: 16px; padding: 26px 30px; margin-bottom: 8px; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| }} | |
| /* Gradio's prose styles set explicit heading/text colors, so force white here. */ | |
| #wf-hero, #wf-hero h1, #wf-hero p, #wf-hero em {{ color: #fff !important; }} | |
| #wf-hero h1 {{ margin: 0 0 6px; font-weight: 700; letter-spacing: -0.5px; }} | |
| #wf-hero h1 .wf-accent {{ color: {GOLD_LT} !important; }} | |
| #wf-hero p {{ margin: 0; opacity: 0.95; font-size: 1.02em; line-height: 1.5; }} | |
| #wf-hero strong {{ color: {GOLD_LT} !important; font-weight: 700; }} | |
| #wf-hero a {{ color: {GOLD_LT} !important; text-decoration: underline; text-underline-offset: 2px; }} | |
| #wf-first {{ color: {SLATE}; font-weight: 600; }} | |
| """ | |
| def _label(t: dict) -> str: | |
| return f"{t['id']} · {t['title']}" | |
| def render(tasks: list[dict]): | |
| """tasks -> (header_md, dataframe_rows, dropdown_choices).""" | |
| if not tasks: | |
| return "### Add a brain-dump or a photo, then hit **Prioritize**.", [], [] | |
| now = datetime.now() | |
| ranked = score.rank_active(tasks, now) | |
| risk = score.deadline_risk_map(ranked, now) | |
| rows = [] | |
| for i, t in enumerate(ranked): | |
| c = score.score_components(t) | |
| flag = score.deadline_status(t, now) or risk.get(t["id"], "") | |
| rows.append([ | |
| i + 1, | |
| t["title"], | |
| score.format_score(c["display"]), | |
| score.due_label(t.get("due_date"), now) if t.get("due_date") else "—", | |
| flag, | |
| score.explain(t, now) + (" · edited" if t.get("edited") else ""), | |
| round(c["I"]), | |
| round(c["R"]), | |
| round(t["effort_hours"], 2), | |
| ]) | |
| top = ranked[0] | |
| header = ( | |
| f"### <span id='wf-first'>▶ Do this first: {top['title']}</span>" | |
| f" · score {score.format_score(score.priority(top))}" | |
| ) | |
| return header, rows, [_label(t) for t in ranked] | |
| def prioritize(text, image, tasks): | |
| # A generator: the first yield lands instantly so the ~60s CPU inference | |
| # doesn't look like a frozen app. Gradio streams each yield to the UI. | |
| if not llm.is_ready(): | |
| yield tasks, "### ⏳ The local model is still loading — give it a moment and try again.", [], gr.update() | |
| return | |
| hint = "" | |
| if text and text.strip(): | |
| n = len([ln for ln in text.splitlines() if ln.strip()]) | |
| hint = f" ≈{n} item{'s' if n != 1 else ''}" | |
| yield tasks, f"### ⏳ Reading and scoring{hint} on the local model… (≈60s on free CPU)", [], gr.update() | |
| collected = [] | |
| if text and text.strip(): | |
| collected += llm.parse_braindump(text) | |
| if image is not None: | |
| collected += llm.extract_from_image(image) | |
| for i, t in enumerate(collected): | |
| t["id"] = f"t{i}" | |
| if not collected: | |
| yield collected, "### Nothing actionable found — try a different dump or photo.", [], gr.update(choices=[], value=None) | |
| return | |
| header, rows, choices = render(collected) | |
| yield collected, header, rows, gr.update(choices=choices, value=(choices[0] if choices else None)) | |
| def load_task(sel, tasks): | |
| t = _find(sel, tasks) | |
| if not t: | |
| return 5, 8, 1.0 | |
| return t["impact"], t["readiness"], t["effort_hours"] | |
| def apply_edit(sel, impact, readiness, effort, tasks): | |
| t = _find(sel, tasks) | |
| if t: | |
| t["impact"] = int(impact) | |
| t["readiness"] = int(readiness) | |
| t["effort_hours"] = float(effort) | |
| t["edited"] = True | |
| header, rows, choices = render(tasks) | |
| keep = sel if sel in choices else (choices[0] if choices else None) | |
| return tasks, header, rows, gr.update(choices=choices, value=keep) | |
| def resuggest(sel, tasks): | |
| t = _find(sel, tasks) | |
| if not t or not llm.is_ready(): | |
| return gr.update(), gr.update(), gr.update() | |
| s = llm.score_task(t["title"], t.get("notes") or "", t.get("category") or "") | |
| if not s: | |
| return t["impact"], t["readiness"], t["effort_hours"] | |
| return s["impact"], s["readiness"], s["effort_hours"] | |
| def _find(sel, tasks): | |
| if not sel: | |
| return None | |
| tid = sel.split(" · ", 1)[0] | |
| return next((x for x in tasks if x["id"] == tid), None) | |
| with gr.Blocks(title="whatfirst-small", theme=THEME, css=CSS) as demo: | |
| gr.HTML( | |
| "<div id='wf-hero'>" | |
| "<h1>whatfirst · <span class='wf-accent'>small</span></h1>" | |
| "<p>Dump everything on your mind — or snap a photo of your to-do list — and a " | |
| "<strong>small local model</strong> turns it into structured tasks. A " | |
| "<strong>transparent scoring engine</strong> then tells you what to do <em>first</em>, " | |
| "and shows its work. No cloud, no API keys. The full app lives at " | |
| "<a href='https://what-first.com'>what-first.com</a>.</p>" | |
| "</div>" | |
| ) | |
| tasks_state = gr.State([]) | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| dump = gr.Textbox( | |
| label="Brain-dump", lines=8, | |
| placeholder="email the landlord by Friday, finish the deck (4h, big deal), 5-min: cancel the gym…", | |
| ) | |
| image = gr.Image(label="…or a photo of a list", type="pil", height=180) | |
| with gr.Row(): | |
| go = gr.Button("Prioritize", variant="primary") | |
| sample = gr.Button("Try a sample") | |
| with gr.Column(scale=3): | |
| header = gr.Markdown( | |
| "### Add a brain-dump or a photo, then hit **Prioritize**.", | |
| sanitize_html=False, | |
| ) | |
| table = gr.Dataframe( | |
| headers=DF_HEADERS, datatype=DF_DATATYPES, interactive=False, | |
| wrap=True, label="Ranked", | |
| ) | |
| with gr.Accordion("Correct a score (the model proposes — you decide)", open=False): | |
| picker = gr.Dropdown(label="Task", choices=[], interactive=True) | |
| with gr.Row(): | |
| impact = gr.Slider(1, 10, value=5, step=1, label="Impact") | |
| readiness = gr.Slider(1, 10, value=8, step=1, label="Readiness") | |
| effort = gr.Slider(0.05, 8, value=1.0, step=0.05, label="Effort (hours)") | |
| with gr.Row(): | |
| apply_btn = gr.Button("Apply & re-rank", variant="primary") | |
| resuggest_btn = gr.Button("Re-suggest with AI") | |
| with gr.Accordion("How the score works", open=False): | |
| gr.Markdown(FORMULA_BLURB) | |
| go.click(prioritize, [dump, image, tasks_state], [tasks_state, header, table, picker]) | |
| sample.click(lambda: SAMPLE_DUMP, None, dump) | |
| picker.change(load_task, [picker, tasks_state], [impact, readiness, effort]) | |
| apply_btn.click(apply_edit, [picker, impact, readiness, effort, tasks_state], | |
| [tasks_state, header, table, picker]) | |
| resuggest_btn.click(resuggest, [picker, tasks_state], [impact, readiness, effort]) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860"))) | |