Spaces:
Runtime error
Runtime error
Speed + UX: trim scored schema, cache prompt, lighter ctx, live status
Browse filesApp-only deploy to the Space. Same app changes as origin commit 6408b42 but
without the demo/ video tooling (and its font binary, which HF rejects and the
running app never uses).
- PARSE schema trimmed to ranker-only fields (~20-35% fewer output tokens)
- Why column derived from score.explain (deterministic, zero tokens)
- cache_prompt: reuse system-prompt KV between calls
- llama-server ctx 8192 -> 4096
- prioritize() streams an instant status so CPU inference isn't a frozen wait
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +61 -13
- llm.py +4 -0
- prompts.py +1 -4
- score.py +30 -55
- start.sh +1 -1
- test_score.py +0 -13
app.py
CHANGED
|
@@ -37,6 +37,35 @@ Two scores compete and the higher one shows:
|
|
| 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']}"
|
|
@@ -59,19 +88,30 @@ def render(tasks: list[dict]):
|
|
| 59 |
score.format_score(c["display"]),
|
| 60 |
score.due_label(t.get("due_date"), now) if t.get("due_date") else "β",
|
| 61 |
flag,
|
| 62 |
-
|
| 63 |
round(c["I"]),
|
| 64 |
round(c["R"]),
|
| 65 |
round(t["effort_hours"], 2),
|
| 66 |
])
|
| 67 |
top = ranked[0]
|
| 68 |
-
header =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
collected = []
|
| 76 |
if text and text.strip():
|
| 77 |
collected += llm.parse_braindump(text)
|
|
@@ -80,9 +120,10 @@ def prioritize(text, image, tasks):
|
|
| 80 |
for i, t in enumerate(collected):
|
| 81 |
t["id"] = f"t{i}"
|
| 82 |
if not collected:
|
| 83 |
-
|
|
|
|
| 84 |
header, rows, choices = render(collected)
|
| 85 |
-
|
| 86 |
|
| 87 |
|
| 88 |
def load_task(sel, tasks):
|
|
@@ -98,7 +139,7 @@ def apply_edit(sel, impact, readiness, effort, tasks):
|
|
| 98 |
t["impact"] = int(impact)
|
| 99 |
t["readiness"] = int(readiness)
|
| 100 |
t["effort_hours"] = float(effort)
|
| 101 |
-
t["
|
| 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)
|
|
@@ -121,12 +162,16 @@ def _find(sel, tasks):
|
|
| 121 |
return next((x for x in tasks if x["id"] == tid), None)
|
| 122 |
|
| 123 |
|
| 124 |
-
with gr.Blocks(title="whatfirst-small", theme=
|
| 125 |
-
gr.
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
)
|
| 131 |
tasks_state = gr.State([])
|
| 132 |
|
|
@@ -141,7 +186,10 @@ with gr.Blocks(title="whatfirst-small", theme=gr.themes.Soft()) as demo:
|
|
| 141 |
go = gr.Button("Prioritize", variant="primary")
|
| 142 |
sample = gr.Button("Try a sample")
|
| 143 |
with gr.Column(scale=3):
|
| 144 |
-
header = gr.Markdown(
|
|
|
|
|
|
|
|
|
|
| 145 |
table = gr.Dataframe(
|
| 146 |
headers=DF_HEADERS, datatype=DF_DATATYPES, interactive=False,
|
| 147 |
wrap=True, label="Ranked",
|
|
|
|
| 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 |
+
# ββ Branding, borrowed from the full what-first.com app βββββββββββββββββββββββ
|
| 41 |
+
# Indigo β blue gradient, slate/ink text, Roboto β the palette the marketing
|
| 42 |
+
# site and the demo video (demo/compose.py) share.
|
| 43 |
+
INDIGO, BLUE = "#4F46E5", "#2563EB"
|
| 44 |
+
SLATE, INK, MUTED = "#1E293B", "#111827", "#5A6473"
|
| 45 |
+
|
| 46 |
+
THEME = gr.themes.Soft(
|
| 47 |
+
primary_hue=gr.themes.colors.indigo,
|
| 48 |
+
secondary_hue=gr.themes.colors.blue,
|
| 49 |
+
neutral_hue=gr.themes.colors.slate,
|
| 50 |
+
font=[gr.themes.GoogleFont("Roboto"), "system-ui", "sans-serif"],
|
| 51 |
+
).set(
|
| 52 |
+
button_primary_background_fill=f"linear-gradient(90deg, {INDIGO}, {BLUE})",
|
| 53 |
+
button_primary_background_fill_hover=f"linear-gradient(90deg, {BLUE}, {INDIGO})",
|
| 54 |
+
button_primary_text_color="white",
|
| 55 |
+
block_title_text_weight="600",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
CSS = f"""
|
| 59 |
+
#wf-hero {{
|
| 60 |
+
background: linear-gradient(120deg, {INDIGO}, {BLUE});
|
| 61 |
+
color: #fff; border-radius: 16px; padding: 26px 30px; margin-bottom: 8px;
|
| 62 |
+
}}
|
| 63 |
+
#wf-hero h1 {{ margin: 0 0 6px; font-weight: 700; letter-spacing: -0.5px; }}
|
| 64 |
+
#wf-hero p {{ margin: 0; opacity: 0.92; font-size: 1.02em; line-height: 1.5; }}
|
| 65 |
+
#wf-hero a {{ color: #fff; text-decoration: underline; }}
|
| 66 |
+
#wf-first {{ color: {SLATE}; font-weight: 600; }}
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
|
| 70 |
def _label(t: dict) -> str:
|
| 71 |
return f"{t['id']} Β· {t['title']}"
|
|
|
|
| 88 |
score.format_score(c["display"]),
|
| 89 |
score.due_label(t.get("due_date"), now) if t.get("due_date") else "β",
|
| 90 |
flag,
|
| 91 |
+
score.explain(t, now) + (" Β· edited" if t.get("edited") else ""),
|
| 92 |
round(c["I"]),
|
| 93 |
round(c["R"]),
|
| 94 |
round(t["effort_hours"], 2),
|
| 95 |
])
|
| 96 |
top = ranked[0]
|
| 97 |
+
header = (
|
| 98 |
+
f"### <span id='wf-first'>βΆ Do this first: {top['title']}</span>"
|
| 99 |
+
f" Β· score {score.format_score(score.priority(top))}"
|
| 100 |
+
)
|
| 101 |
return header, rows, [_label(t) for t in ranked]
|
| 102 |
|
| 103 |
|
| 104 |
def prioritize(text, image, tasks):
|
| 105 |
+
# A generator: the first yield lands instantly so the ~60s CPU inference
|
| 106 |
+
# doesn't look like a frozen app. Gradio streams each yield to the UI.
|
| 107 |
if not llm.is_ready():
|
| 108 |
+
yield tasks, "### β³ The local model is still loading β give it a moment and try again.", [], gr.update()
|
| 109 |
+
return
|
| 110 |
+
hint = ""
|
| 111 |
+
if text and text.strip():
|
| 112 |
+
n = len([ln for ln in text.splitlines() if ln.strip()])
|
| 113 |
+
hint = f" ~{n} item{'s' if n != 1 else ''}"
|
| 114 |
+
yield tasks, f"### ⳠReading and scoring{hint} on the local model⦠(~60s on free CPU)", [], gr.update()
|
| 115 |
collected = []
|
| 116 |
if text and text.strip():
|
| 117 |
collected += llm.parse_braindump(text)
|
|
|
|
| 120 |
for i, t in enumerate(collected):
|
| 121 |
t["id"] = f"t{i}"
|
| 122 |
if not collected:
|
| 123 |
+
yield collected, "### Nothing actionable found β try a different dump or photo.", [], gr.update(choices=[], value=None)
|
| 124 |
+
return
|
| 125 |
header, rows, choices = render(collected)
|
| 126 |
+
yield collected, header, rows, gr.update(choices=choices, value=(choices[0] if choices else None))
|
| 127 |
|
| 128 |
|
| 129 |
def load_task(sel, tasks):
|
|
|
|
| 139 |
t["impact"] = int(impact)
|
| 140 |
t["readiness"] = int(readiness)
|
| 141 |
t["effort_hours"] = float(effort)
|
| 142 |
+
t["edited"] = True
|
| 143 |
header, rows, choices = render(tasks)
|
| 144 |
keep = sel if sel in choices else (choices[0] if choices else None)
|
| 145 |
return tasks, header, rows, gr.update(choices=choices, value=keep)
|
|
|
|
| 162 |
return next((x for x in tasks if x["id"] == tid), None)
|
| 163 |
|
| 164 |
|
| 165 |
+
with gr.Blocks(title="whatfirst-small", theme=THEME, css=CSS) as demo:
|
| 166 |
+
gr.HTML(
|
| 167 |
+
"<div id='wf-hero'>"
|
| 168 |
+
"<h1>whatfirst Β· small</h1>"
|
| 169 |
+
"<p>Dump everything on your mind β or snap a photo of your to-do list β and a "
|
| 170 |
+
"<strong>small local model</strong> turns it into structured tasks. A "
|
| 171 |
+
"<strong>transparent scoring engine</strong> then tells you what to do <em>first</em>, "
|
| 172 |
+
"and shows its work. No cloud, no API keys. The full app lives at "
|
| 173 |
+
"<a href='https://what-first.com'>what-first.com</a>.</p>"
|
| 174 |
+
"</div>"
|
| 175 |
)
|
| 176 |
tasks_state = gr.State([])
|
| 177 |
|
|
|
|
| 186 |
go = gr.Button("Prioritize", variant="primary")
|
| 187 |
sample = gr.Button("Try a sample")
|
| 188 |
with gr.Column(scale=3):
|
| 189 |
+
header = gr.Markdown(
|
| 190 |
+
"### Add a brain-dump or a photo, then hit **Prioritize**.",
|
| 191 |
+
sanitize_html=False,
|
| 192 |
+
)
|
| 193 |
table = gr.Dataframe(
|
| 194 |
headers=DF_HEADERS, datatype=DF_DATATYPES, interactive=False,
|
| 195 |
wrap=True, label="Ranked",
|
llm.py
CHANGED
|
@@ -63,6 +63,10 @@ def _chat(system: str, user_content, max_tokens: int) -> str:
|
|
| 63 |
"top_p": 0.9,
|
| 64 |
"stream": False,
|
| 65 |
"response_format": {"type": "json_object"},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
r = requests.post(CHAT_URL, json=payload, timeout=REQUEST_TIMEOUT)
|
| 68 |
r.raise_for_status()
|
|
|
|
| 63 |
"top_p": 0.9,
|
| 64 |
"stream": False,
|
| 65 |
"response_format": {"type": "json_object"},
|
| 66 |
+
# Keep the (identical, ~400-token) system-prompt prefix in the server's
|
| 67 |
+
# KV cache between calls, so repeated requests β the image flow's two
|
| 68 |
+
# passes, per-task re-suggest, back-to-back demos β skip re-processing it.
|
| 69 |
+
"cache_prompt": True,
|
| 70 |
}
|
| 71 |
r = requests.post(CHAT_URL, json=payload, timeout=REQUEST_TIMEOUT)
|
| 72 |
r.raise_for_status()
|
prompts.py
CHANGED
|
@@ -23,18 +23,15 @@ effort_hours: realistic focused time. 0.25 = 15 minutes, 1 = an hour, 8 = a day.
|
|
| 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, "
|
| 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": [] }}.
|
|
|
|
| 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, "due_date": string | null, "due_time": string | null, "impact": integer, "readiness": integer, "effort_hours": number }} ] }}
|
| 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 |
- 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.
|
| 33 |
- 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.
|
| 34 |
- impact, readiness, effort_hours: always score every task using the scale above. If importance is stated ("really important", "no rush"), let it guide impact.
|
|
|
|
| 35 |
- Split distinct tasks into separate items. Skip greetings, filler, and self-talk that isn't a task.
|
| 36 |
- Return at most 25 items. Drop near-duplicates.
|
| 37 |
- If there are no actionable tasks, return {{ "items": [] }}.
|
score.py
CHANGED
|
@@ -22,10 +22,6 @@ PREP_BIAS = 0.7 # action bias on the prep branch (<1)
|
|
| 22 |
SCORE_CAP = 1000 # backstop guard, above the formula's natural max
|
| 23 |
TIE_BAND = 0.10 # value-closeness band (log-bucketed for transitivity)
|
| 24 |
|
| 25 |
-
# Quick-win lens: a short, ready-to-start task for a spare moment.
|
| 26 |
-
QUICK_WIN_MAX_EFFORT_HOURS = 0.25 # 15 minutes
|
| 27 |
-
QUICK_WIN_MIN_READINESS = 7
|
| 28 |
-
|
| 29 |
|
| 30 |
def clamp(x: float, lo: float, hi: float) -> float:
|
| 31 |
return max(lo, min(hi, x))
|
|
@@ -66,13 +62,6 @@ def comp_urgency(d: float, e_hours: float) -> float:
|
|
| 66 |
return min(30, base + accel)
|
| 67 |
|
| 68 |
|
| 69 |
-
def urgency(due_date, effort_hours) -> float:
|
| 70 |
-
if not due_date:
|
| 71 |
-
return 2
|
| 72 |
-
d = days_to_due_raw(due_date)
|
| 73 |
-
return max(cal_urgency(d), comp_urgency(d, effort_hours or 0.05))
|
| 74 |
-
|
| 75 |
-
|
| 76 |
# -- Core score ---------------------------------------------------------------
|
| 77 |
|
| 78 |
def score_components(t: dict) -> dict:
|
|
@@ -115,24 +104,6 @@ def value_bucket(v: float) -> int:
|
|
| 115 |
return math.floor(math.log(max(v, 0.1)) / math.log(1 + TIE_BAND))
|
| 116 |
|
| 117 |
|
| 118 |
-
# -- Assignment (collapses to "always mine" with no teams) --------------------
|
| 119 |
-
|
| 120 |
-
def assignee_ids(task: dict) -> list:
|
| 121 |
-
ids = task.get("assignee_user_ids")
|
| 122 |
-
if isinstance(ids, list):
|
| 123 |
-
return ids
|
| 124 |
-
if task.get("assignee_user_id") is not None:
|
| 125 |
-
return [task["assignee_user_id"]]
|
| 126 |
-
return []
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def mine_to_do(task: dict, current_user_id=None) -> bool:
|
| 130 |
-
if current_user_id is None:
|
| 131 |
-
return True
|
| 132 |
-
ids = assignee_ids(task)
|
| 133 |
-
return len(ids) == 0 or current_user_id in ids
|
| 134 |
-
|
| 135 |
-
|
| 136 |
# -- Dates --------------------------------------------------------------------
|
| 137 |
|
| 138 |
def normalize_iso(due_date):
|
|
@@ -180,7 +151,7 @@ def deadline_status(task: dict, now: datetime | None = None):
|
|
| 180 |
|
| 181 |
# -- Ranking ------------------------------------------------------------------
|
| 182 |
|
| 183 |
-
def deadline_risk_map(sorted_active: list, now: datetime | None = None
|
| 184 |
"""Walk the queue in priority order, accumulate effort, and flag any task
|
| 185 |
whose projected wall-clock finish lands past its deadline. Returns
|
| 186 |
{id: 'at-risk' | 'tight'}."""
|
|
@@ -190,8 +161,6 @@ def deadline_risk_map(sorted_active: list, now: datetime | None = None, current_
|
|
| 190 |
for t in sorted_active:
|
| 191 |
if not t or t.get("completed"):
|
| 192 |
continue
|
| 193 |
-
if not mine_to_do(t, current_user_id):
|
| 194 |
-
continue
|
| 195 |
E = max(t.get("effort_hours") or 0, 0)
|
| 196 |
acc += E
|
| 197 |
if not t.get("due_date"):
|
|
@@ -210,7 +179,7 @@ def deadline_risk_map(sorted_active: list, now: datetime | None = None, current_
|
|
| 210 |
return out
|
| 211 |
|
| 212 |
|
| 213 |
-
def rank_active(tasks: list, now: datetime | None = None
|
| 214 |
"""Rank with deadlines as a constraint, not a blended term. Three tiers:
|
| 215 |
0 overdue (EDF), 1 binding/at-risk (EDF, lifted above the value pack),
|
| 216 |
2 value pack (value bucket, then sooner due breaks near-ties)."""
|
|
@@ -219,11 +188,9 @@ def rank_active(tasks: list, now: datetime | None = None, current_user_id=None)
|
|
| 219 |
V = {t["id"]: priority(t) for t in active}
|
| 220 |
D = {t["id"]: days_to_due_raw(t.get("due_date"), now) for t in active}
|
| 221 |
by_value = sorted(active, key=lambda t: V[t["id"]], reverse=True)
|
| 222 |
-
risk = deadline_risk_map(by_value, now
|
| 223 |
|
| 224 |
def tier_of(t):
|
| 225 |
-
if not mine_to_do(t, current_user_id):
|
| 226 |
-
return 2
|
| 227 |
if t.get("due_date") and D[t["id"]] <= 0:
|
| 228 |
return 0
|
| 229 |
return 1 if risk.get(t["id"]) == "at-risk" else 2
|
|
@@ -239,25 +206,6 @@ def rank_active(tasks: list, now: datetime | None = None, current_user_id=None)
|
|
| 239 |
return sorted(by_value, key=sort_key)
|
| 240 |
|
| 241 |
|
| 242 |
-
# -- Quick-win lens -----------------------------------------------------------
|
| 243 |
-
|
| 244 |
-
def is_quick_win(task: dict) -> bool:
|
| 245 |
-
if not task or task.get("completed"):
|
| 246 |
-
return False
|
| 247 |
-
e = task.get("effort_hours")
|
| 248 |
-
if not isinstance(e, (int, float)) or not math.isfinite(e) or e <= 0 or e > QUICK_WIN_MAX_EFFORT_HOURS:
|
| 249 |
-
return False
|
| 250 |
-
return readiness_of(task) >= QUICK_WIN_MIN_READINESS or bool(task.get("execute_anyway"))
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
def quick_wins(tasks: list) -> list:
|
| 254 |
-
"""Quick wins, shortest first, ties by priority desc."""
|
| 255 |
-
return sorted(
|
| 256 |
-
(t for t in tasks if is_quick_win(t)),
|
| 257 |
-
key=lambda t: (t["effort_hours"], -priority(t)),
|
| 258 |
-
)
|
| 259 |
-
|
| 260 |
-
|
| 261 |
# -- Display helpers ----------------------------------------------------------
|
| 262 |
|
| 263 |
def format_score(n: float) -> str:
|
|
@@ -265,6 +213,33 @@ def format_score(n: float) -> str:
|
|
| 265 |
return str(int(rounded)) if rounded == int(rounded) else f"{rounded:.1f}"
|
| 266 |
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
def due_label(due_date, now: datetime | None = None) -> str:
|
| 269 |
"""Compact relative deadline label ('now', '3h', 'tomorrow', '2d', '5d late')."""
|
| 270 |
if not due_date:
|
|
|
|
| 22 |
SCORE_CAP = 1000 # backstop guard, above the formula's natural max
|
| 23 |
TIE_BAND = 0.10 # value-closeness band (log-bucketed for transitivity)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def clamp(x: float, lo: float, hi: float) -> float:
|
| 27 |
return max(lo, min(hi, x))
|
|
|
|
| 62 |
return min(30, base + accel)
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# -- Core score ---------------------------------------------------------------
|
| 66 |
|
| 67 |
def score_components(t: dict) -> dict:
|
|
|
|
| 104 |
return math.floor(math.log(max(v, 0.1)) / math.log(1 + TIE_BAND))
|
| 105 |
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# -- Dates --------------------------------------------------------------------
|
| 108 |
|
| 109 |
def normalize_iso(due_date):
|
|
|
|
| 151 |
|
| 152 |
# -- Ranking ------------------------------------------------------------------
|
| 153 |
|
| 154 |
+
def deadline_risk_map(sorted_active: list, now: datetime | None = None) -> dict:
|
| 155 |
"""Walk the queue in priority order, accumulate effort, and flag any task
|
| 156 |
whose projected wall-clock finish lands past its deadline. Returns
|
| 157 |
{id: 'at-risk' | 'tight'}."""
|
|
|
|
| 161 |
for t in sorted_active:
|
| 162 |
if not t or t.get("completed"):
|
| 163 |
continue
|
|
|
|
|
|
|
| 164 |
E = max(t.get("effort_hours") or 0, 0)
|
| 165 |
acc += E
|
| 166 |
if not t.get("due_date"):
|
|
|
|
| 179 |
return out
|
| 180 |
|
| 181 |
|
| 182 |
+
def rank_active(tasks: list, now: datetime | None = None) -> list:
|
| 183 |
"""Rank with deadlines as a constraint, not a blended term. Three tiers:
|
| 184 |
0 overdue (EDF), 1 binding/at-risk (EDF, lifted above the value pack),
|
| 185 |
2 value pack (value bucket, then sooner due breaks near-ties)."""
|
|
|
|
| 188 |
V = {t["id"]: priority(t) for t in active}
|
| 189 |
D = {t["id"]: days_to_due_raw(t.get("due_date"), now) for t in active}
|
| 190 |
by_value = sorted(active, key=lambda t: V[t["id"]], reverse=True)
|
| 191 |
+
risk = deadline_risk_map(by_value, now)
|
| 192 |
|
| 193 |
def tier_of(t):
|
|
|
|
|
|
|
| 194 |
if t.get("due_date") and D[t["id"]] <= 0:
|
| 195 |
return 0
|
| 196 |
return 1 if risk.get(t["id"]) == "at-risk" else 2
|
|
|
|
| 206 |
return sorted(by_value, key=sort_key)
|
| 207 |
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
# -- Display helpers ----------------------------------------------------------
|
| 210 |
|
| 211 |
def format_score(n: float) -> str:
|
|
|
|
| 213 |
return str(int(rounded)) if rounded == int(rounded) else f"{rounded:.1f}"
|
| 214 |
|
| 215 |
|
| 216 |
+
def explain(t: dict, now: datetime | None = None) -> str:
|
| 217 |
+
"""A short, deterministic 'why it ranks here' phrase, read off the same
|
| 218 |
+
components that drive the score. This replaces a model-written reason: it
|
| 219 |
+
costs the model no tokens to produce and can never drift from the math the
|
| 220 |
+
way an LLM sentence can."""
|
| 221 |
+
now = now or datetime.now()
|
| 222 |
+
c = score_components(t)
|
| 223 |
+
I, R, E = c["I"], c["R"], c["E"]
|
| 224 |
+
status = deadline_status(t, now)
|
| 225 |
+
if status == "overdue":
|
| 226 |
+
return "overdue β start now"
|
| 227 |
+
if c["prep_wins"]:
|
| 228 |
+
return ("high-value but not ready β de-risk first" if I >= 5
|
| 229 |
+
else "not ready β needs a first step")
|
| 230 |
+
if status in ("today", "tomorrow"):
|
| 231 |
+
return f"due {status} β time-sensitive"
|
| 232 |
+
if E <= 1 and R >= 7:
|
| 233 |
+
return "quick win β short and ready"
|
| 234 |
+
if R <= 3:
|
| 235 |
+
return "blocked β next step unclear"
|
| 236 |
+
if I >= 8:
|
| 237 |
+
return "high-impact work"
|
| 238 |
+
if I <= 3:
|
| 239 |
+
return "minor β low impact"
|
| 240 |
+
return "steady priority"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
def due_label(due_date, now: datetime | None = None) -> str:
|
| 244 |
"""Compact relative deadline label ('now', '3h', 'tomorrow', '2d', '5d late')."""
|
| 245 |
if not due_date:
|
start.sh
CHANGED
|
@@ -14,7 +14,7 @@ echo "[start] launching 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
|
| 18 |
--threads "$(nproc)" &
|
| 19 |
|
| 20 |
# 3. Start the Gradio app (foreground). is_ready() polls the server's /health,
|
|
|
|
| 14 |
--model "${MODEL_DIR}/${MODEL_FILE}" \
|
| 15 |
--mmproj "${MODEL_DIR}/${MMPROJ_FILE}" \
|
| 16 |
--host 127.0.0.1 --port 8080 \
|
| 17 |
+
--ctx-size 4096 \
|
| 18 |
--threads "$(nproc)" &
|
| 19 |
|
| 20 |
# 3. Start the Gradio app (foreground). is_ready() polls the server's /health,
|
test_score.py
CHANGED
|
@@ -123,19 +123,6 @@ def test_completed_tasks_excluded():
|
|
| 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"
|
|
|
|
| 123 |
assert order == ["live"]
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
def test_due_label():
|
| 127 |
assert s.due_label("2026-06-08T12:00:00", NOW) == "tomorrow"
|
| 128 |
assert s.due_label("2026-06-12T12:00:00", NOW) == "5d"
|