Spaces:
Runtime error
Runtime error
Commit ·
3a8d5b6
0
Parent(s):
whatfirst-small: offline Gradio + llama.cpp task prioritizer
Browse filesHF 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>
- .dockerignore +8 -0
- .gitattributes +1 -0
- .gitignore +7 -0
- Dockerfile +36 -0
- README.md +79 -0
- app.py +172 -0
- download_model.py +27 -0
- llm.py +238 -0
- prompts.py +87 -0
- requirements.txt +4 -0
- sample_data/sample_braindump.txt +8 -0
- score.py +333 -0
- start.sh +23 -0
- 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)
|