optiquant Claude Opus 4.8 (1M context) commited on
Commit
3a8d5b6
·
0 Parent(s):

whatfirst-small: offline Gradio + llama.cpp task prioritizer

Browse files

HF Build Small hackathon entry (Backyard AI). A small local VLM
(Qwen2.5-VL-3B via llama.cpp) turns a brain-dump or a photo of a to-do
list into structured tasks; a clean-room Python port of the what-first.com
scoring engine ranks them transparently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (14) hide show
  1. .dockerignore +8 -0
  2. .gitattributes +1 -0
  3. .gitignore +7 -0
  4. Dockerfile +36 -0
  5. README.md +79 -0
  6. app.py +172 -0
  7. download_model.py +27 -0
  8. llm.py +238 -0
  9. prompts.py +87 -0
  10. requirements.txt +4 -0
  11. sample_data/sample_braindump.txt +8 -0
  12. score.py +333 -0
  13. start.sh +23 -0
  14. test_score.py +158 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__/
3
+ *.pyc
4
+ *.gguf
5
+ models/
6
+ .gradio/
7
+ .venv/
8
+ plan/
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.ttf filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.gguf
4
+ models/
5
+ .gradio/
6
+ .venv/
7
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # whatfirst-small — Gradio app + a local llama.cpp server, one container.
2
+ # Builds llama-server (multimodal) from source, then runs the Gradio UI in front
3
+ # of it. No cloud APIs at runtime.
4
+
5
+ FROM python:3.11-slim
6
+
7
+ # --- build llama.cpp (llama-server includes multimodal / mtmd support) -------
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ build-essential cmake git libgomp1 ca-certificates \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp /opt/llama.cpp \
13
+ && cmake -S /opt/llama.cpp -B /opt/llama.cpp/build \
14
+ -DGGML_NATIVE=OFF -DLLAMA_CURL=OFF -DLLAMA_BUILD_TESTS=OFF \
15
+ && cmake --build /opt/llama.cpp/build --config Release -j --target llama-server \
16
+ && rm -rf /opt/llama.cpp/.git
17
+
18
+ # --- non-root user (Hugging Face Spaces convention) --------------------------
19
+ RUN useradd -m -u 1000 user
20
+ USER user
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH \
23
+ HF_HOME=/home/user/.cache/huggingface \
24
+ MODEL_DIR=/home/user/models \
25
+ MODEL_REPO=ggml-org/Qwen2.5-VL-3B-Instruct-GGUF \
26
+ MODEL_FILE=Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf \
27
+ MMPROJ_FILE=mmproj-Qwen2.5-VL-3B-Instruct-f16.gguf \
28
+ PORT=7860
29
+
30
+ WORKDIR /home/user/app
31
+ COPY --chown=user requirements.txt .
32
+ RUN pip install --no-cache-dir --user -r requirements.txt
33
+ COPY --chown=user . .
34
+
35
+ EXPOSE 7860
36
+ CMD ["bash", "start.sh"]
README.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: whatfirst small
3
+ emoji: 🗂️
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ ---
11
+
12
+ # whatfirst · small
13
+
14
+ **Dump everything on your mind — get back what to do *first*, with the math shown.**
15
+
16
+ A small **local** vision-language model (Qwen2.5-VL-3B, ~2 GB, running on
17
+ llama.cpp) reads a messy brain-dump or a photo of a to-do list and turns each
18
+ line into a structured task — impact, readiness, effort, deadline. A
19
+ **deterministic, transparent scoring engine** then ranks them and tells you the
20
+ one thing to start now, showing every number behind the call. No cloud, no API
21
+ keys, runs on a laptop.
22
+
23
+ Built for the [Hugging Face Build Small hackathon](https://huggingface.co/build-small-hackathon)
24
+ (Backyard AI track).
25
+
26
+ ## Why this exists
27
+
28
+ Deciding *what to do first* is a real, daily problem — and most "AI to-do" apps
29
+ answer it with a black box. This one keeps the AI where it earns its keep (turning
30
+ vague human language into structured fields) and makes the prioritization itself
31
+ **legible**: two competing scores (do-it-now vs. de-risk-first), an urgency curve
32
+ that explodes as a deadline nears, a quick-win boost for short ready tasks, and
33
+ deadlines treated as a hard constraint rather than a number folded into a blob.
34
+
35
+ The problem, and the scoring model, come from
36
+ **[what-first.com](https://what-first.com)** — a full web app the same team built
37
+ in June 2026, where the scoring runs server-side against Claude. This entry is a
38
+ fresh, **offline, small-model** take built for the hackathon: can a 3B model on a
39
+ laptop do the load-bearing language work that a frontier cloud model does in the
40
+ product? The ranking engine here is a clean-room reimplementation in Python with
41
+ its own tests — no dependency on the original.
42
+
43
+ ## How it works
44
+
45
+ ```
46
+ brain-dump / photo ──▶ Qwen2.5-VL-3B (llama.cpp, localhost) ──▶ structured tasks
47
+
48
+ score.py (deterministic)
49
+
50
+ ranked list + "do this first"
51
+ ```
52
+
53
+ - `score.py` — the scoring + deadline-ranking engine (pure standard-library math).
54
+ - `llm.py` — client for the local llama.cpp server (brain-dump parse, image
55
+ extract, single-task re-score). Every model output is re-clamped before scoring.
56
+ - `prompts.py` — the system prompts that pin the model to strict JSON.
57
+ - `app.py` — the Gradio UI: capture, ranked table, and sliders to correct any
58
+ score and re-rank live.
59
+
60
+ ## Run it locally
61
+
62
+ ```bash
63
+ docker build -t whatfirst-small .
64
+ docker run -p 7860:7860 whatfirst-small # first boot downloads ~2.3 GB of weights
65
+ ```
66
+
67
+ Then open http://localhost:7860. On a CPU-only box, expect a few seconds per task
68
+ — that's the cost of staying fully on the grid-less side. Tests:
69
+
70
+ ```bash
71
+ python -m pytest test_score.py # or: python test_score.py
72
+ ```
73
+
74
+ ## Notes
75
+
76
+ - **Model:** [`ggml-org/Qwen2.5-VL-3B-Instruct-GGUF`](https://huggingface.co/ggml-org/Qwen2.5-VL-3B-Instruct-GGUF)
77
+ (Q4_K_M + f16 mmproj), ≤ 32B and laptop-runnable.
78
+ - **Off the grid:** all inference is local llama.cpp over localhost; nothing
79
+ leaves the box at runtime.
app.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """whatfirst-small — a Gradio app that turns a messy brain-dump (or a photo of a
2
+ to-do list) into a transparently-ranked "do this first" list, using a small
3
+ local model for the messy-language part and a deterministic, legible engine for
4
+ the ranking.
5
+
6
+ Capture -> AI structures each task (impact / readiness / effort / due) -> the
7
+ scoring engine ranks -> you can correct any score and it re-ranks live.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from datetime import datetime
14
+
15
+ import gradio as gr
16
+
17
+ import llm
18
+ import score
19
+
20
+ SAMPLE_DUMP = """email the landlord about the lease renewal, kind of important and due Friday
21
+ finish the Q3 board deck — big deal, maybe 4 hours of work, needs to be done by next Wednesday
22
+ 5 min: cancel the unused gym membership
23
+ look into switching the team to the new CI, no rush, not sure where to start
24
+ book the dentist, due tomorrow
25
+ reply to Sam's thread, quick one
26
+ draft the hiring plan — important but I'm blocked until we agree on budget"""
27
+
28
+ FORMULA_BLURB = """
29
+ Two scores compete and the higher one shows:
30
+
31
+ - **do = (Impact · Urgency · Readiness_eff · QuickWin) / 10** — the case for doing it now.
32
+ - **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).
33
+
34
+ **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.
35
+ """
36
+
37
+ DF_HEADERS = ["#", "Task", "Score", "Due", "Flag", "Why", "I", "R", "Eff·h"]
38
+ DF_DATATYPES = ["number", "str", "str", "str", "str", "str", "number", "number", "number"]
39
+
40
+
41
+ def _label(t: dict) -> str:
42
+ return f"{t['id']} · {t['title']}"
43
+
44
+
45
+ def render(tasks: list[dict]):
46
+ """tasks -> (header_md, dataframe_rows, dropdown_choices)."""
47
+ if not tasks:
48
+ return "### Add a brain-dump or a photo, then hit **Prioritize**.", [], []
49
+ now = datetime.now()
50
+ ranked = score.rank_active(tasks, now)
51
+ risk = score.deadline_risk_map(ranked, now)
52
+ rows = []
53
+ for i, t in enumerate(ranked):
54
+ c = score.score_components(t)
55
+ flag = score.deadline_status(t, now) or risk.get(t["id"], "")
56
+ rows.append([
57
+ i + 1,
58
+ t["title"],
59
+ score.format_score(c["display"]),
60
+ score.due_label(t.get("due_date"), now) if t.get("due_date") else "—",
61
+ flag,
62
+ t.get("reason", "") or "",
63
+ round(c["I"]),
64
+ round(c["R"]),
65
+ round(t["effort_hours"], 2),
66
+ ])
67
+ top = ranked[0]
68
+ header = f"### ▶ Do this first: **{top['title']}** · score {score.format_score(score.priority(top))}"
69
+ return header, rows, [_label(t) for t in ranked]
70
+
71
+
72
+ def prioritize(text, image, tasks):
73
+ if not llm.is_ready():
74
+ return tasks, "### ⏳ The local model is still loading — give it a moment and try again.", [], gr.update()
75
+ collected = []
76
+ if text and text.strip():
77
+ collected += llm.parse_braindump(text)
78
+ if image is not None:
79
+ collected += llm.extract_from_image(image)
80
+ for i, t in enumerate(collected):
81
+ t["id"] = f"t{i}"
82
+ if not collected:
83
+ return collected, "### Nothing actionable found — try a different dump or photo.", [], gr.update(choices=[], value=None)
84
+ header, rows, choices = render(collected)
85
+ return collected, header, rows, gr.update(choices=choices, value=(choices[0] if choices else None))
86
+
87
+
88
+ def load_task(sel, tasks):
89
+ t = _find(sel, tasks)
90
+ if not t:
91
+ return 5, 8, 1.0
92
+ return t["impact"], t["readiness"], t["effort_hours"]
93
+
94
+
95
+ def apply_edit(sel, impact, readiness, effort, tasks):
96
+ t = _find(sel, tasks)
97
+ if t:
98
+ t["impact"] = int(impact)
99
+ t["readiness"] = int(readiness)
100
+ t["effort_hours"] = float(effort)
101
+ t["reason"] = (t.get("reason") or "").split(" · edited")[0] + " · edited"
102
+ header, rows, choices = render(tasks)
103
+ keep = sel if sel in choices else (choices[0] if choices else None)
104
+ return tasks, header, rows, gr.update(choices=choices, value=keep)
105
+
106
+
107
+ def resuggest(sel, tasks):
108
+ t = _find(sel, tasks)
109
+ if not t or not llm.is_ready():
110
+ return gr.update(), gr.update(), gr.update()
111
+ s = llm.score_task(t["title"], t.get("notes") or "", t.get("category") or "")
112
+ if not s:
113
+ return t["impact"], t["readiness"], t["effort_hours"]
114
+ return s["impact"], s["readiness"], s["effort_hours"]
115
+
116
+
117
+ def _find(sel, tasks):
118
+ if not sel:
119
+ return None
120
+ tid = sel.split(" · ", 1)[0]
121
+ return next((x for x in tasks if x["id"] == tid), None)
122
+
123
+
124
+ with gr.Blocks(title="whatfirst-small", theme=gr.themes.Soft()) as demo:
125
+ gr.Markdown(
126
+ "# whatfirst · small\n"
127
+ "Dump everything on your mind — or snap a photo of your to-do list — and a "
128
+ "**small local model** turns it into structured tasks. A **transparent scoring "
129
+ "engine** then tells you what to do *first*, and shows its work. No cloud, no API keys."
130
+ )
131
+ tasks_state = gr.State([])
132
+
133
+ with gr.Row():
134
+ with gr.Column(scale=2):
135
+ dump = gr.Textbox(
136
+ label="Brain-dump", lines=8,
137
+ placeholder="email the landlord by Friday, finish the deck (4h, big deal), 5-min: cancel the gym…",
138
+ )
139
+ image = gr.Image(label="…or a photo of a list", type="pil", height=180)
140
+ with gr.Row():
141
+ go = gr.Button("Prioritize", variant="primary")
142
+ sample = gr.Button("Try a sample")
143
+ with gr.Column(scale=3):
144
+ header = gr.Markdown("### Add a brain-dump or a photo, then hit **Prioritize**.")
145
+ table = gr.Dataframe(
146
+ headers=DF_HEADERS, datatype=DF_DATATYPES, interactive=False,
147
+ wrap=True, label="Ranked",
148
+ )
149
+
150
+ with gr.Accordion("Correct a score (the model proposes — you decide)", open=False):
151
+ picker = gr.Dropdown(label="Task", choices=[], interactive=True)
152
+ with gr.Row():
153
+ impact = gr.Slider(1, 10, value=5, step=1, label="Impact")
154
+ readiness = gr.Slider(1, 10, value=8, step=1, label="Readiness")
155
+ effort = gr.Slider(0.05, 8, value=1.0, step=0.05, label="Effort (hours)")
156
+ with gr.Row():
157
+ apply_btn = gr.Button("Apply & re-rank", variant="primary")
158
+ resuggest_btn = gr.Button("Re-suggest with AI")
159
+
160
+ with gr.Accordion("How the score works", open=False):
161
+ gr.Markdown(FORMULA_BLURB)
162
+
163
+ go.click(prioritize, [dump, image, tasks_state], [tasks_state, header, table, picker])
164
+ sample.click(lambda: SAMPLE_DUMP, None, dump)
165
+ picker.change(load_task, [picker, tasks_state], [impact, readiness, effort])
166
+ apply_btn.click(apply_edit, [picker, impact, readiness, effort, tasks_state],
167
+ [tasks_state, header, table, picker])
168
+ resuggest_btn.click(resuggest, [picker, tasks_state], [impact, readiness, effort])
169
+
170
+
171
+ if __name__ == "__main__":
172
+ demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))
download_model.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch the GGUF weights + vision projector from the Hub into MODEL_DIR.
2
+
3
+ Run once at container start (idempotent — hf_hub_download skips files already
4
+ present). Kept tiny and dependency-light so the container can pull the model
5
+ before the server boots.
6
+ """
7
+
8
+ import os
9
+
10
+ from huggingface_hub import hf_hub_download
11
+
12
+ REPO = os.environ.get("MODEL_REPO", "ggml-org/Qwen2.5-VL-3B-Instruct-GGUF")
13
+ MODEL_FILE = os.environ.get("MODEL_FILE", "Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf")
14
+ MMPROJ_FILE = os.environ.get("MMPROJ_FILE", "mmproj-Qwen2.5-VL-3B-Instruct-f16.gguf")
15
+ MODEL_DIR = os.environ.get("MODEL_DIR", "/models")
16
+
17
+
18
+ def main():
19
+ os.makedirs(MODEL_DIR, exist_ok=True)
20
+ for fname in (MODEL_FILE, MMPROJ_FILE):
21
+ print(f"[download] {REPO}/{fname} -> {MODEL_DIR}", flush=True)
22
+ hf_hub_download(repo_id=REPO, filename=fname, local_dir=MODEL_DIR)
23
+ print("[download] done", flush=True)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
llm.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local model client.
2
+
3
+ Talks to a llama.cpp `llama-server` running on localhost (OpenAI-compatible
4
+ `/v1/chat/completions`, with multimodal support for the Qwen2.5-VL GGUF). One
5
+ model serves all three flows. No network leaves the box — this is the
6
+ "off the grid" path.
7
+
8
+ Every model response is treated as untrusted: parsed tolerantly, then every
9
+ field is re-clamped to its domain before it reaches score.py.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import io
16
+ import json
17
+ import os
18
+ from datetime import datetime
19
+
20
+ import requests
21
+
22
+ LLAMA_BASE_URL = os.environ.get("LLAMA_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
23
+ CHAT_URL = f"{LLAMA_BASE_URL}/v1/chat/completions"
24
+ REQUEST_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "240")) # CPU inference is slow
25
+
26
+ from prompts import (
27
+ PARSE_SYSTEM_PROMPT, build_parse_user,
28
+ EXTRACT_SYSTEM_PROMPT, EXTRACT_USER_PROMPT,
29
+ SCORE_SYSTEM_PROMPT, build_score_user,
30
+ )
31
+
32
+
33
+ # -- server plumbing ----------------------------------------------------------
34
+
35
+ def is_ready() -> bool:
36
+ """Has the model server come up and loaded weights?"""
37
+ try:
38
+ r = requests.get(f"{LLAMA_BASE_URL}/health", timeout=5)
39
+ return r.status_code == 200
40
+ except requests.RequestException:
41
+ return False
42
+
43
+
44
+ def _chat(system: str, user_content, max_tokens: int) -> str:
45
+ """One chat completion with a JSON prefill. `user_content` is a string or an
46
+ OpenAI content-parts list (for images). Returns the raw assistant text with
47
+ the prefilled '{' restored."""
48
+ messages = [
49
+ {"role": "system", "content": system},
50
+ {"role": "user", "content": user_content},
51
+ {"role": "assistant", "content": "{"}, # prefill: coerce a JSON object
52
+ ]
53
+ payload = {
54
+ "messages": messages,
55
+ "max_tokens": max_tokens,
56
+ "temperature": 0.2,
57
+ "top_p": 0.9,
58
+ "stream": False,
59
+ }
60
+ r = requests.post(CHAT_URL, json=payload, timeout=REQUEST_TIMEOUT)
61
+ r.raise_for_status()
62
+ text = r.json()["choices"][0]["message"]["content"]
63
+ return "{" + text
64
+
65
+
66
+ def _extract_json(text: str) -> dict:
67
+ """Tolerantly pull the first balanced JSON object out of a model response,
68
+ ignoring markdown fences and any trailing prose."""
69
+ text = text.strip()
70
+ if text.startswith("```"):
71
+ text = text.split("```", 2)[1] if "```" in text[3:] else text
72
+ text = text.lstrip("json").lstrip()
73
+ start = text.find("{")
74
+ if start == -1:
75
+ return {}
76
+ depth, in_str, esc = 0, False, False
77
+ for i in range(start, len(text)):
78
+ ch = text[i]
79
+ if in_str:
80
+ if esc:
81
+ esc = False
82
+ elif ch == "\\":
83
+ esc = True
84
+ elif ch == '"':
85
+ in_str = False
86
+ continue
87
+ if ch == '"':
88
+ in_str = True
89
+ elif ch == "{":
90
+ depth += 1
91
+ elif ch == "}":
92
+ depth -= 1
93
+ if depth == 0:
94
+ try:
95
+ return json.loads(text[start:i + 1])
96
+ except json.JSONDecodeError:
97
+ return {}
98
+ return {}
99
+
100
+
101
+ # -- validation ---------------------------------------------------------------
102
+
103
+ def _clamp_int(v, lo, hi):
104
+ try:
105
+ n = round(float(v))
106
+ except (TypeError, ValueError):
107
+ return None
108
+ return max(lo, min(hi, n))
109
+
110
+
111
+ def _clamp_num(v, lo, hi):
112
+ try:
113
+ n = float(v)
114
+ except (TypeError, ValueError):
115
+ return None
116
+ if n <= 0:
117
+ return None
118
+ return max(lo, min(hi, n))
119
+
120
+
121
+ def _str_or_none(v, n):
122
+ if not isinstance(v, str):
123
+ return None
124
+ s = v.strip()[:n]
125
+ return s or None
126
+
127
+
128
+ def _due_iso(date_v, time_v):
129
+ """Combine a model-proposed YYYY-MM-DD (+ optional HH:MM) into the ISO form
130
+ score.py expects, or None. Garbage shapes are dropped, not guessed."""
131
+ import re
132
+ if not isinstance(date_v, str) or not re.match(r"^\d{4}-\d{2}-\d{2}$", date_v.strip()):
133
+ return None
134
+ date = date_v.strip()
135
+ if isinstance(time_v, str) and re.match(r"^([01]\d|2[0-3]):[0-5]\d$", time_v.strip()):
136
+ return f"{date}T{time_v.strip()}:00"
137
+ return date # score.py normalizes a bare date to 17:00
138
+
139
+
140
+ def _sanitize_scored(raw: dict, idx: int) -> dict | None:
141
+ """A fully-scored item from PARSE: title + scores + optional due."""
142
+ title = _str_or_none(raw.get("title"), 90)
143
+ if not title:
144
+ return None
145
+ impact = _clamp_int(raw.get("impact"), 1, 10)
146
+ readiness = _clamp_int(raw.get("readiness"), 1, 10)
147
+ effort = _clamp_num(raw.get("effort_hours"), 0.05, 200)
148
+ return {
149
+ "id": f"t{idx}",
150
+ "title": title,
151
+ "category": _str_or_none(raw.get("category"), 80),
152
+ "notes": _str_or_none(raw.get("notes"), 400),
153
+ "due_date": _due_iso(raw.get("due_date"), raw.get("due_time")),
154
+ "impact": impact if impact is not None else 5,
155
+ "readiness": readiness if readiness is not None else 8,
156
+ "effort_hours": effort if effort is not None else 1.0,
157
+ "reason": _str_or_none(raw.get("reason"), 200) or "",
158
+ "completed": False,
159
+ }
160
+
161
+
162
+ def _sanitize_titleonly(raw: dict) -> dict | None:
163
+ """An unscored item from EXTRACT: title + context only."""
164
+ title = _str_or_none(raw.get("title"), 90)
165
+ if not title:
166
+ return None
167
+ return {
168
+ "title": title,
169
+ "category": _str_or_none(raw.get("category"), 80),
170
+ "notes": _str_or_none(raw.get("notes"), 400),
171
+ }
172
+
173
+
174
+ # -- flows --------------------------------------------------------------------
175
+
176
+ def parse_braindump(text: str) -> list[dict]:
177
+ """Free-text brain-dump -> fully scored, ready-to-rank task dicts."""
178
+ if not text or not text.strip():
179
+ return []
180
+ now = datetime.now()
181
+ today = now.strftime("%Y-%m-%d")
182
+ weekday = now.strftime("%A")
183
+ out = _chat(PARSE_SYSTEM_PROMPT, build_parse_user(text, today, weekday), max_tokens=1800)
184
+ items = _extract_json(out).get("items", [])
185
+ if not isinstance(items, list):
186
+ return []
187
+ scored = [_sanitize_scored(it, i) for i, it in enumerate(items[:25])]
188
+ return [s for s in scored if s]
189
+
190
+
191
+ def score_task(title: str, notes: str = "", category: str = "") -> dict | None:
192
+ """Re-score a single task (used by the slider 'suggest' button)."""
193
+ out = _chat(SCORE_SYSTEM_PROMPT, build_score_user(title, notes, category), max_tokens=200)
194
+ raw = _extract_json(out)
195
+ impact = _clamp_int(raw.get("impact"), 1, 10)
196
+ readiness = _clamp_int(raw.get("readiness"), 1, 10)
197
+ effort = _clamp_num(raw.get("effort_hours"), 0.05, 200)
198
+ if impact is None or readiness is None or effort is None:
199
+ return None
200
+ return {
201
+ "impact": impact,
202
+ "readiness": readiness,
203
+ "effort_hours": effort,
204
+ "reason": _str_or_none(raw.get("reason"), 200) or "",
205
+ }
206
+
207
+
208
+ def extract_from_image(image) -> list[dict]:
209
+ """Photo of a list -> scored task dicts. Two calls: a vision pass pulls the
210
+ titles, then the text scorer scores them in one batch (keeps it to one
211
+ model, and the scorer is more reliable on text than the VLM head is)."""
212
+ if image is None:
213
+ return []
214
+ b64 = _png_b64(image)
215
+ content = [
216
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
217
+ {"type": "text", "text": EXTRACT_USER_PROMPT},
218
+ ]
219
+ out = _chat(EXTRACT_SYSTEM_PROMPT, content, max_tokens=1024)
220
+ raw_items = _extract_json(out).get("items", [])
221
+ if not isinstance(raw_items, list):
222
+ return []
223
+ titles = [_sanitize_titleonly(it) for it in raw_items[:20]]
224
+ titles = [t for t in titles if t]
225
+ if not titles:
226
+ return []
227
+ # Score the extracted titles in one pass by feeding them as a brain-dump.
228
+ dump = "\n".join(
229
+ f"{t['title']}" + (f" — {t['notes']}" if t.get("notes") else "")
230
+ for t in titles
231
+ )
232
+ return parse_braindump(dump)
233
+
234
+
235
+ def _png_b64(image) -> str:
236
+ buf = io.BytesIO()
237
+ image.convert("RGB").save(buf, format="PNG")
238
+ return base64.b64encode(buf.getvalue()).decode("ascii")
prompts.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System prompts + user-message builders for the local model.
2
+
3
+ Adapted from whatfirst's production prompts (the score / extract / voice system
4
+ prompts), reshaped for a single small model running locally:
5
+
6
+ - PARSE : free-text brain-dump -> fully scored task items (one call)
7
+ - EXTRACT : a photo of a list -> task items (titles only; scored after)
8
+ - SCORE : one task -> impact / readiness / effort (slider re-suggest)
9
+
10
+ Every call uses the JSON-prefill trick (prefill the assistant turn with "{") so
11
+ a small model continues the object instead of wrapping it in prose. All model
12
+ output is untrusted and re-clamped in llm.py before it reaches the scorer.
13
+ """
14
+
15
+ # Shared definitions of the three scoring axes, kept identical across prompts so
16
+ # the model scores consistently whether it sees text or an image.
17
+ SCALE_GUIDE = """impact: how much the task moves this person's life or work forward. 1 = trivial, 5 = meaningful, 10 = transformative.
18
+ readiness: how clear the immediate next physical step is. 1 = blocked or ambiguous, 5 = partial, 10 = obvious next action.
19
+ effort_hours: realistic focused time. 0.25 = 15 minutes, 1 = an hour, 8 = a day."""
20
+
21
+
22
+ # -- PARSE: brain-dump text -> scored items -----------------------------------
23
+
24
+ PARSE_SYSTEM_PROMPT = f"""You turn a messy brain-dump into a prioritized task list for a personal productivity app. The text is one person listing things they need to do; it may ramble, run several tasks together, or include filler.
25
+
26
+ Output a JSON object: {{ "items": [ {{ "title": string, "category": string | null, "notes": string | null, "due_date": string | null, "due_time": string | null, "impact": integer, "readiness": integer, "effort_hours": number, "reason": string }} ] }}
27
+
28
+ {SCALE_GUIDE}
29
+
30
+ Rules:
31
+ - title is a short imperative phrase, sentence case, no trailing period, under 90 characters. Prefer the person's own words.
32
+ - category is a 1-2 word project name if obvious (e.g. "Work", "Home", "Errands"); otherwise null.
33
+ - notes is a clarifying detail that won't fit in the title; otherwise null.
34
+ - due_date: if a day, date, or deadline is named ("Friday", "tomorrow", "by the 5th", "end of week"), resolve it to YYYY-MM-DD relative to the current date given below. Otherwise null.
35
+ - due_time: if a clock time is named ("at 11pm", "by 9am", "noon"), resolve it to 24-hour HH:MM. If a day but no clock time, null. Never invent a time.
36
+ - impact, readiness, effort_hours: always score every task using the scale above. If importance is stated ("really important", "no rush"), let it guide impact.
37
+ - reason: one short lowercase sentence, no exclamations, under 14 words, explaining the scores.
38
+ - Split distinct tasks into separate items. Skip greetings, filler, and self-talk that isn't a task.
39
+ - Return at most 25 items. Drop near-duplicates.
40
+ - If there are no actionable tasks, return {{ "items": [] }}.
41
+
42
+ Output only the JSON object. No prose, no markdown fences."""
43
+
44
+
45
+ def build_parse_user(text: str, today: str, weekday: str) -> str:
46
+ return f"Current date: {today} ({weekday}).\n\nBrain-dump:\n{text.strip()}"
47
+
48
+
49
+ # -- EXTRACT: image -> items (titles only) ------------------------------------
50
+
51
+ EXTRACT_SYSTEM_PROMPT = """You read a screenshot or photo and extract tasks for a personal productivity app. The image may show a handwritten list, a typed list, an email, a chat, a whiteboard, or meeting notes. Identify every discrete actionable task visible.
52
+
53
+ Output a JSON object: { "items": [ { "title": string, "category": string | null, "notes": string | null } ] }
54
+
55
+ Rules:
56
+ - title is a short imperative phrase, sentence case, no trailing period, under 90 characters. Prefer the writer's own words when legible.
57
+ - category is a 1-2 word project name if the image makes one obvious; otherwise null.
58
+ - notes is null unless the image contains a clarifying detail that won't fit in the title.
59
+ - Skip headers, dates, names, decorative text, and items already crossed out or checked off.
60
+ - If the image contains no actionable tasks, return { "items": [] }.
61
+ - Return at most 20 items. Drop near-duplicates.
62
+
63
+ Output only the JSON object. No prose, no markdown fences."""
64
+
65
+ EXTRACT_USER_PROMPT = "Extract every actionable task from this image."
66
+
67
+
68
+ # -- SCORE: one task -> impact / readiness / effort ---------------------------
69
+
70
+ SCORE_SYSTEM_PROMPT = f"""You score one task for a productivity app. Given a title and optional context, output JSON with impact, readiness, effort_hours, and a short reason.
71
+
72
+ {SCALE_GUIDE}
73
+
74
+ A short title can hide real work. If notes describe several steps, treat them as the scope: more open work usually means more effort_hours and lower readiness.
75
+
76
+ reason is one short lowercase sentence, no exclamations, under 14 words.
77
+
78
+ Output only the JSON object. No prose, no markdown fences."""
79
+
80
+
81
+ def build_score_user(title: str, notes: str = "", category: str = "") -> str:
82
+ lines = [f"Title: {title}"]
83
+ if category:
84
+ lines.append(f"Project: {category}")
85
+ if notes:
86
+ lines.append(f"Notes: {notes}")
87
+ return "\n".join(lines)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==5.9.1
2
+ huggingface_hub>=0.25
3
+ pillow>=10.0
4
+ requests>=2.31
sample_data/sample_braindump.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ email the landlord about the lease renewal, kind of important and due Friday
2
+ finish the Q3 board deck — big deal, maybe 4 hours of work, needs to be done by next Wednesday
3
+ 5 min: cancel the unused gym membership
4
+ look into switching the team to the new CI, no rush, not sure where to start
5
+ book the dentist, due tomorrow
6
+ reply to Sam's thread, quick one
7
+ draft the hiring plan — important but I'm blocked until we agree on budget
8
+ pick up dry cleaning sometime this week
score.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Priority scoring + deadline ranking — the deterministic core.
2
+
3
+ Clean-room Python port of whatfirst's scoring engine, re-implemented from the
4
+ formula's documented behaviour. It depends on nothing but the standard library
5
+ (math, datetime) and imports nothing from the original app. Two parallel scores
6
+ compete; the higher one is what the user sees.
7
+
8
+ do_score = (I * U * R_eff * QW) / 10
9
+ prep_score = (I * U * (10 - R)) / 10 * 0.7 * QW
10
+
11
+ where:
12
+ U = max(calendar_urgency, completion_urgency)
13
+ QW = 1 + 0.6 * (I/10) * exp(-E/1.5) quick-win boost
14
+ R_floor = clamp((U - 6) / 4 * 10, 0, 10) urgency's target readiness
15
+ R_eff = R + max(0, R_floor - R) * READY_LIFT urgency lifts an unready task
16
+ defer = U_comp >= 9 and slack < 0.5 and I < 7 flag, not a number
17
+
18
+ READY_LIFT (< 1) keeps the readiness control live: R_eff stays strictly
19
+ increasing in R, so readiness always moves the do-score while urgency can still
20
+ drag an unready-but-urgent task up to where it competes. All inputs are coerced
21
+ to finite numbers and clamped to their domains (I,R in [1,10], E >= 0.05) so the
22
+ displayed score can never be NaN/Infinity.
23
+
24
+ Urgency is unbounded near zero: a rational decay holds for d > 2 days, then an
25
+ inverse-distance term takes over and grows past 10 as the deadline approaches
26
+ (saturating at 30 the instant before due). Crossing the deadline hands off
27
+ continuously: the overdue branch picks up at 30 and ramps to 40 over five days,
28
+ so a task never loses urgency merely by going late.
29
+
30
+ If task["execute_anyway"] is true, R_eff is forced to max(R, 9), prep_score is
31
+ zeroed, and the defer flag is suppressed (the user opted in to risk-on mode).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ from datetime import datetime
38
+
39
+ # -- Tunable scoring knobs ----------------------------------------------------
40
+ # READY_LIFT and PREP_BIAS are both 0.7 by coincidence; they are unrelated and
41
+ # may drift apart, so they are named separately rather than sharing a literal.
42
+
43
+ # How far urgency may close the gap between a task's readiness and the
44
+ # urgency-driven target R_floor. Strictly < 1 so a fully unready task is never
45
+ # lifted all the way to "ready".
46
+ READY_LIFT = 0.7
47
+
48
+ # Action bias on the prep branch: < 1 so a tie in raw value tilts to "do".
49
+ PREP_BIAS = 0.7
50
+
51
+ # Upper guard on the two score branches. Sits above the formula's natural
52
+ # maximum on purpose, so it never clips a real task — only a backstop.
53
+ SCORE_CAP = 1000
54
+
55
+ # Closeness band for the value tier, realized as a transitive log-bucket.
56
+ TIE_BAND = 0.10
57
+
58
+ # Quick-win lens thresholds (a short, ready-to-start task for a spare moment).
59
+ QUICK_WIN_MAX_EFFORT_HOURS = 0.25 # 15 minutes
60
+ QUICK_WIN_MIN_READINESS = 7
61
+
62
+
63
+ def clamp(x: float, lo: float, hi: float) -> float:
64
+ return max(lo, min(hi, x))
65
+
66
+
67
+ def finite(x, fallback: float) -> float:
68
+ """Coerce anything non-numeric or non-finite to a known default before it
69
+ enters the formula — one bad field must yield a sane number, never NaN."""
70
+ try:
71
+ xf = float(x)
72
+ except (TypeError, ValueError):
73
+ return fallback
74
+ return xf if math.isfinite(xf) else fallback
75
+
76
+
77
+ def readiness_of(t: dict) -> float:
78
+ """Default readiness is 8 (a task is presumed mostly-ready)."""
79
+ return finite(t.get("readiness"), 8)
80
+
81
+
82
+ # -- Urgency curves -----------------------------------------------------------
83
+
84
+ def cal_urgency(d: float) -> float:
85
+ if d <= 0:
86
+ return min(40, 30 + (-d) * 2)
87
+ base = 1 + 9 / (1 + (d / 7) ** 1.5)
88
+ T, k, eps = 2, 8, 0.25
89
+ accel = max(0, k / (d + eps) - k / (T + eps)) if d < T else 0
90
+ return min(30, base + accel)
91
+
92
+
93
+ def comp_urgency(d: float, e_hours: float) -> float:
94
+ if d <= 0:
95
+ return min(40, 30 + (-d) * 2)
96
+ e_days = max(e_hours, 0.05) / 4
97
+ slack = d / e_days
98
+ base = 1 + 9 / (1 + slack / 2)
99
+ T, k, eps = 1, 4, 0.1
100
+ accel = max(0, k / (slack + eps) - k / (T + eps)) if slack < T else 0
101
+ return min(30, base + accel)
102
+
103
+
104
+ def urgency(due_date, effort_hours) -> float:
105
+ if not due_date:
106
+ return 2
107
+ d = days_to_due_raw(due_date)
108
+ return max(cal_urgency(d), comp_urgency(d, effort_hours or 0.05))
109
+
110
+
111
+ # -- Core score ---------------------------------------------------------------
112
+
113
+ def score_components(t: dict) -> dict:
114
+ I = clamp(finite(t.get("impact"), 5), 1, 10)
115
+ R = clamp(readiness_of(t), 1, 10)
116
+ E = max(finite(t.get("effort_hours"), 0.05), 0.05)
117
+ d = days_to_due_raw(t.get("due_date"))
118
+ has_due = bool(t.get("due_date")) and math.isfinite(d)
119
+ U_cal = cal_urgency(d) if has_due else 1.5
120
+ U_comp = comp_urgency(d, E) if has_due else 1.5
121
+ U = max(U_cal, U_comp)
122
+ if has_due and d > 0:
123
+ slack = d / (E / 4)
124
+ elif d <= 0:
125
+ slack = 0.0
126
+ else:
127
+ slack = math.inf
128
+ QW = 1 + 0.6 * (I / 10) * math.exp(-E / 1.5)
129
+ risk_on = bool(t.get("execute_anyway"))
130
+ R_floor = clamp((U - 6) / 4 * 10, 0, 10)
131
+ # Partial lift toward R_floor (not max(R, R_floor)): urgency pulls an unready
132
+ # task up so it still competes, but readiness keeps moving the score.
133
+ R_eff = max(R, 9) if risk_on else R + max(0, R_floor - R) * READY_LIFT
134
+ do_score = clamp((I * U * R_eff * QW) / 10, 0.1, SCORE_CAP)
135
+ prep_score = 0.0 if risk_on else clamp((I * U * (10 - R)) / 10 * PREP_BIAS * QW, 0, SCORE_CAP)
136
+ defer = (not risk_on) and has_due and U_comp >= 9 and slack < 0.5 and I < 7
137
+ display = max(do_score, prep_score)
138
+ prep_wins = prep_score > do_score and R <= 5 and not risk_on
139
+ return {
140
+ "I": I, "R": R, "E": E, "d": d, "U": U, "U_cal": U_cal, "U_comp": U_comp,
141
+ "slack": slack, "qw": QW, "R_floor": R_floor, "R_eff": R_eff,
142
+ "do_score": do_score, "prep_score": prep_score, "defer": defer,
143
+ "display": display, "prep_wins": prep_wins,
144
+ }
145
+
146
+
147
+ def priority(t: dict) -> float:
148
+ return score_components(t)["display"]
149
+
150
+
151
+ def value_bucket(v: float) -> int:
152
+ return math.floor(math.log(max(v, 0.1)) / math.log(1 + TIE_BAND))
153
+
154
+
155
+ # -- Assignment (collapses to "always mine" with no teams) --------------------
156
+
157
+ def assignee_ids(task: dict) -> list:
158
+ ids = task.get("assignee_user_ids")
159
+ if isinstance(ids, list):
160
+ return ids
161
+ if task.get("assignee_user_id") is not None:
162
+ return [task["assignee_user_id"]]
163
+ return []
164
+
165
+
166
+ def mine_to_do(task: dict, current_user_id=None) -> bool:
167
+ if current_user_id is None:
168
+ return True
169
+ ids = assignee_ids(task)
170
+ return len(ids) == 0 or current_user_id in ids
171
+
172
+
173
+ # -- Dates --------------------------------------------------------------------
174
+
175
+ def normalize_iso(due_date):
176
+ """A bare date ('YYYY-MM-DD') names a day; treat it as 5pm local so a
177
+ date-only deadline isn't read as midnight. A full datetime passes through."""
178
+ if not isinstance(due_date, str):
179
+ return due_date
180
+ return f"{due_date}T17:00:00" if len(due_date) == 10 else due_date
181
+
182
+
183
+ def _parse(due_date):
184
+ try:
185
+ return datetime.fromisoformat(normalize_iso(due_date))
186
+ except (TypeError, ValueError):
187
+ return None
188
+
189
+
190
+ def days_to_due_raw(due_date, now: datetime | None = None) -> float:
191
+ if not due_date:
192
+ return math.inf
193
+ due = _parse(due_date)
194
+ if due is None:
195
+ return math.inf
196
+ now = now or datetime.now()
197
+ return (due - now).total_seconds() / 86400
198
+
199
+
200
+ def deadline_status(task: dict, now: datetime | None = None):
201
+ """Deadline pressure as a discrete tier, orthogonal to the priority score.
202
+ Returns 'overdue' | 'today' | 'tomorrow' | None."""
203
+ if not task or task.get("completed") or not task.get("due_date"):
204
+ return None
205
+ due = _parse(task["due_date"])
206
+ if due is None:
207
+ return None
208
+ now = now or datetime.now()
209
+ if due < now:
210
+ return "overdue"
211
+ start = lambda d: datetime(d.year, d.month, d.day)
212
+ day_diff = round((start(due) - start(now)).total_seconds() / 86400)
213
+ if day_diff == 0:
214
+ return "today"
215
+ if day_diff == 1:
216
+ return "tomorrow"
217
+ return None
218
+
219
+
220
+ # -- Ranking ------------------------------------------------------------------
221
+
222
+ def deadline_risk_map(sorted_active: list, now: datetime | None = None, current_user_id=None) -> dict:
223
+ """Cumulative deadline-risk tiers across the ranked active queue.
224
+
225
+ Walk the queue in priority order, accumulate effort, and project a
226
+ continuous wall-clock finish for each task. A task whose projected finish
227
+ lands past its own deadline is at risk even if it would have fit alone — the
228
+ work ahead of it ate the runway. Returns {id: 'at-risk' | 'tight'}.
229
+ """
230
+ out = {}
231
+ now = now or datetime.now()
232
+ acc = 0.0 # cumulative effort-hours of the queue so far, inclusive
233
+ for t in sorted_active:
234
+ if not t or t.get("completed"):
235
+ continue
236
+ if not mine_to_do(t, current_user_id):
237
+ continue
238
+ E = max(t.get("effort_hours") or 0, 0)
239
+ acc += E
240
+ if not t.get("due_date"):
241
+ continue
242
+ due = _parse(t["due_date"])
243
+ if due is None:
244
+ continue
245
+ hours_to_due = (due - now).total_seconds() / 3600
246
+ if not (hours_to_due > 0):
247
+ continue # overdue — skip; deadline_status owns that signal
248
+ slack = hours_to_due - acc
249
+ if slack <= 0:
250
+ out[t["id"]] = "at-risk"
251
+ elif slack < 0.5 * E:
252
+ out[t["id"]] = "tight"
253
+ return out
254
+
255
+
256
+ def rank_active(tasks: list, now: datetime | None = None, current_user_id=None) -> list:
257
+ """Rank the active queue with deadlines as a constraint, not a term folded
258
+ into the value score. Three tiers: 0 overdue (EDF), 1 binding/at-risk (EDF,
259
+ lifted above the value pack), 2 value pack (value bucket, sooner due breaks
260
+ near-ties)."""
261
+ now = now or datetime.now()
262
+ active = [t for t in tasks if not t.get("completed")]
263
+ V = {t["id"]: priority(t) for t in active}
264
+ D = {t["id"]: days_to_due_raw(t.get("due_date"), now) for t in active}
265
+ by_value = sorted(active, key=lambda t: V[t["id"]], reverse=True)
266
+ risk = deadline_risk_map(by_value, now, current_user_id)
267
+
268
+ def tier_of(t):
269
+ if not mine_to_do(t, current_user_id):
270
+ return 2
271
+ if t.get("due_date") and D[t["id"]] <= 0:
272
+ return 0
273
+ return 1 if risk.get(t["id"]) == "at-risk" else 2
274
+
275
+ def sort_key(t):
276
+ tid = t["id"]
277
+ tr = tier_of(t)
278
+ if tr < 2:
279
+ return (tr, D[tid], 0.0) # overdue/binding: earliest-deadline-first
280
+ # value pack: higher bucket first, then sooner due breaks near-ties
281
+ return (tr, -value_bucket(V[tid]), D[tid])
282
+
283
+ # Python's sort is stable, so equal keys keep by_value (value-desc) order.
284
+ return sorted(by_value, key=sort_key)
285
+
286
+
287
+ # -- Quick-win lens -----------------------------------------------------------
288
+
289
+ def is_quick_win(task: dict) -> bool:
290
+ """Short enough and ready enough to knock out in a spare moment?"""
291
+ if not task or task.get("completed"):
292
+ return False
293
+ e = task.get("effort_hours")
294
+ if not isinstance(e, (int, float)) or not math.isfinite(e) or e <= 0 or e > QUICK_WIN_MAX_EFFORT_HOURS:
295
+ return False
296
+ return readiness_of(task) >= QUICK_WIN_MIN_READINESS or bool(task.get("execute_anyway"))
297
+
298
+
299
+ def quick_wins(tasks: list) -> list:
300
+ """Quick wins ordered for the spare-moment view: shortest first, ties by
301
+ priority desc."""
302
+ return sorted(
303
+ (t for t in tasks if is_quick_win(t)),
304
+ key=lambda t: (t["effort_hours"], -priority(t)),
305
+ )
306
+
307
+
308
+ # -- Display helpers ----------------------------------------------------------
309
+
310
+ def format_score(n: float) -> str:
311
+ rounded = round(n * 10) / 10
312
+ return str(int(rounded)) if rounded == int(rounded) else f"{rounded:.1f}"
313
+
314
+
315
+ def due_label(due_date, now: datetime | None = None) -> str:
316
+ """Compact relative deadline label ('now', '3h', 'tomorrow', '2d', '5d late')."""
317
+ if not due_date:
318
+ return "-"
319
+ due = _parse(due_date)
320
+ if due is None:
321
+ return "-"
322
+ now = now or datetime.now()
323
+ raw = (due - now).total_seconds() / 86400
324
+ if raw < 0:
325
+ ab = -raw
326
+ return f"{max(1, round(ab * 24))}h late" if ab < 1 else f"{round(ab)}d late"
327
+ start = lambda d: datetime(d.year, d.month, d.day)
328
+ day_diff = round((start(due) - start(now)).total_seconds() / 86400)
329
+ if day_diff == 0:
330
+ return "now" if raw < 1 / 24 else f"{round(raw * 24)}h"
331
+ if day_diff == 1:
332
+ return "tomorrow"
333
+ return f"{day_diff}d"
start.sh ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ MODEL_DIR="${MODEL_DIR:-/models}"
5
+ MODEL_FILE="${MODEL_FILE:-Qwen2.5-VL-3B-Instruct-Q4_K_M.gguf}"
6
+ MMPROJ_FILE="${MMPROJ_FILE:-mmproj-Qwen2.5-VL-3B-Instruct-f16.gguf}"
7
+
8
+ # 1. Pull weights (idempotent).
9
+ python download_model.py
10
+
11
+ # 2. Launch the llama.cpp server (multimodal) on localhost in the background.
12
+ echo "[start] launching llama-server"
13
+ /opt/llama.cpp/build/bin/llama-server \
14
+ --model "${MODEL_DIR}/${MODEL_FILE}" \
15
+ --mmproj "${MODEL_DIR}/${MMPROJ_FILE}" \
16
+ --host 127.0.0.1 --port 8080 \
17
+ --ctx-size 8192 \
18
+ --threads "$(nproc)" &
19
+
20
+ # 3. Start the Gradio app (foreground). is_ready() polls the server's /health,
21
+ # so the UI comes up immediately and the button waits for the model.
22
+ echo "[start] launching gradio"
23
+ exec python app.py
test_score.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained tests for the scoring engine.
2
+
3
+ Numeric anchors are derived by hand from the formula (worked through on paper),
4
+ not copied from score.py's own output, so they actually constrain the port. The
5
+ rest assert structural invariants (monotonicity, tier order, quick-win
6
+ direction) that catch regressions a single magic number would miss.
7
+ """
8
+
9
+ from datetime import datetime
10
+
11
+ import score as s
12
+
13
+
14
+ def approx(a, b, tol=1e-3):
15
+ assert abs(a - b) <= tol, f"expected {b}, got {a}"
16
+
17
+
18
+ # -- Hand-computed urgency anchors --------------------------------------------
19
+
20
+ def test_cal_urgency_week_out():
21
+ # d=7: base = 1 + 9/(1+(7/7)**1.5) = 1 + 9/2 = 5.5; d>=T so no accel.
22
+ approx(s.cal_urgency(7), 5.5)
23
+
24
+
25
+ def test_cal_urgency_overdue_saturates():
26
+ # d=0 -> 30; d=-5 -> 30 + 5*2 = 40 (capped at 40).
27
+ approx(s.cal_urgency(0), 30.0)
28
+ approx(s.cal_urgency(-5), 40.0)
29
+ approx(s.cal_urgency(-100), 40.0) # hard cap
30
+
31
+
32
+ def test_comp_urgency_comfortable_slack():
33
+ # d=7, e=4h -> e_days=1, slack=7, base = 1 + 9/(1+3.5) = 3; no accel.
34
+ approx(s.comp_urgency(7, 4), 3.0)
35
+
36
+
37
+ def test_comp_urgency_explodes_near_zero_slack():
38
+ # Tiny slack drives the inverse-distance term up hard.
39
+ assert s.comp_urgency(0.1, 4) > 20
40
+
41
+
42
+ # -- Hand-computed full-score anchor ------------------------------------------
43
+
44
+ def test_priority_default_no_due():
45
+ # I=5, R=8, E=0.05, no due -> U=1.5, R_eff=8,
46
+ # QW = 1 + 0.3*exp(-1/30) = 1.2901648,
47
+ # do = (5*1.5*8*QW)/10 = 7.740989; prep loses.
48
+ t = {"id": "x", "impact": 5, "readiness": 8, "effort_hours": 0.05}
49
+ approx(s.priority(t), 7.7410)
50
+
51
+
52
+ # -- Structural invariants ----------------------------------------------------
53
+
54
+ def test_readiness_moves_the_score():
55
+ # The whole point of READY_LIFT: readiness stays live. Higher R -> higher do.
56
+ base = {"id": "x", "impact": 6, "effort_hours": 1}
57
+ assert s.priority({**base, "readiness": 10}) > s.priority({**base, "readiness": 8})
58
+
59
+
60
+ def test_quick_win_boost_rewards_short_effort():
61
+ # Lower effort -> larger QW -> higher displayed score, all else equal.
62
+ base = {"id": "x", "impact": 6, "readiness": 8}
63
+ assert s.priority({**base, "effort_hours": 0.05}) > s.priority({**base, "effort_hours": 4})
64
+
65
+
66
+ def test_prep_wins_for_unready_high_impact():
67
+ # R=2, I=8, no due: prep (6.72*QW) beats do (2.4*QW) and R<=5.
68
+ c = s.score_components({"id": "x", "impact": 8, "readiness": 2})
69
+ assert c["prep_wins"] is True
70
+ assert c["prep_score"] > c["do_score"]
71
+
72
+
73
+ def test_risk_on_zeroes_prep_and_lifts_readiness():
74
+ c = s.score_components({"id": "x", "impact": 8, "readiness": 2, "execute_anyway": True})
75
+ assert c["prep_score"] == 0.0
76
+ assert c["R_eff"] >= 9
77
+ assert c["prep_wins"] is False
78
+
79
+
80
+ def test_value_bucket_monotone():
81
+ assert s.value_bucket(100) > s.value_bucket(10) > s.value_bucket(1)
82
+
83
+
84
+ def test_bad_fields_never_nan():
85
+ # A garbage field must fall back, not poison the score.
86
+ c = s.score_components({"id": "x", "impact": None, "readiness": float("nan"), "effort_hours": "oops"})
87
+ assert c["display"] == c["display"] # not NaN
88
+ assert 0.1 <= c["display"] <= s.SCORE_CAP
89
+
90
+
91
+ # -- Ranking ------------------------------------------------------------------
92
+
93
+ NOW = datetime(2026, 6, 7, 12, 0, 0)
94
+
95
+
96
+ def test_overdue_ranks_first():
97
+ tasks = [
98
+ {"id": "B", "impact": 10, "readiness": 10, "effort_hours": 0.1}, # high value, no due
99
+ {"id": "A", "impact": 2, "readiness": 8, "effort_hours": 1, "due_date": "2026-06-01"}, # overdue
100
+ {"id": "C", "impact": 7, "readiness": 8, "effort_hours": 1, "due_date": "2026-06-20"}, # future
101
+ ]
102
+ order = [t["id"] for t in s.rank_active(tasks, NOW)]
103
+ assert order[0] == "A", order # overdue lifted above the value pack
104
+
105
+
106
+ def test_binding_deadline_lifts_above_value():
107
+ # A low-value task that genuinely won't finish in time must beat a juicy
108
+ # future task it would otherwise sit below.
109
+ tasks = [
110
+ {"id": "big", "impact": 10, "readiness": 9, "effort_hours": 2, "due_date": "2026-06-30"},
111
+ {"id": "tight", "impact": 3, "readiness": 9, "effort_hours": 10, "due_date": "2026-06-07T18:00:00"},
112
+ ]
113
+ order = [t["id"] for t in s.rank_active(tasks, NOW)]
114
+ assert order[0] == "tight", order
115
+
116
+
117
+ def test_completed_tasks_excluded():
118
+ tasks = [
119
+ {"id": "done", "impact": 10, "completed": True},
120
+ {"id": "live", "impact": 4},
121
+ ]
122
+ order = [t["id"] for t in s.rank_active(tasks, NOW)]
123
+ assert order == ["live"]
124
+
125
+
126
+ # -- Quick wins ---------------------------------------------------------------
127
+
128
+ def test_quick_win_filter_and_order():
129
+ tasks = [
130
+ {"id": "a", "impact": 5, "readiness": 9, "effort_hours": 0.1}, # qualifies
131
+ {"id": "b", "impact": 9, "readiness": 9, "effort_hours": 0.05}, # qualifies, shorter
132
+ {"id": "c", "impact": 5, "readiness": 3, "effort_hours": 0.1}, # too unready
133
+ {"id": "d", "impact": 5, "readiness": 9, "effort_hours": 2}, # too long
134
+ ]
135
+ order = [t["id"] for t in s.quick_wins(tasks)]
136
+ assert order == ["b", "a"], order # shortest first
137
+
138
+
139
+ def test_due_label():
140
+ assert s.due_label("2026-06-08T12:00:00", NOW) == "tomorrow"
141
+ assert s.due_label("2026-06-12T12:00:00", NOW) == "5d"
142
+ assert s.due_label("2026-06-05T12:00:00", NOW) == "2d late"
143
+
144
+
145
+ if __name__ == "__main__":
146
+ import sys
147
+
148
+ fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
149
+ failed = 0
150
+ for fn in fns:
151
+ try:
152
+ fn()
153
+ print(f" ok {fn.__name__}")
154
+ except AssertionError as e:
155
+ failed += 1
156
+ print(f" FAIL {fn.__name__}: {e}")
157
+ print(f"\n{len(fns) - failed}/{len(fns)} passed")
158
+ sys.exit(1 if failed else 0)