news-digest: fixes from first deploy on ana-docker

Three iterations to get end-to-end:

1. Dockerfile missed COPY run-digest.sh — cron's exec target wasn't
   in the image, every fire failed. Added COPY + chmod.

2. Jinja template used {{ list|sum(attribute='items') }} which
   sum()s lists with start=0 → TypeError int+list. Switched to
   computing reddit_total / tech_total in Python and passing as
   template args.

3. LLM defaulted to qwen3.5-35-a3b which (a) is broken in
   llama-swap (model process exits on launch), (b) when working,
   defaults to extended-thinking mode that eats the entire token
   budget without producing any visible content. Same pattern with
   qwen3.6-35-a3b. Switched default to granite-4-small — small (4B),
   fast (~1s/call), no thinking-mode pathology, returns clean JSON.
   Whole pipeline now runs in ~35s total across 8 sources.

Also hardened the LLM response parser to fall back to
reasoning_content when content is empty — catches the thinking-mode
case if anyone ever points the digest at one of those models. Plus
the deploy playbook gained DOCKER_BUILDKIT=0 because ana-docker is
on docker 20.10 which doesn't carry the buildx driver versions our
newer client expects ("client version 1.52 is too new"). Real fix is
upgrading docker on the fleet — separate workstream.
This commit is contained in:
2026-04-26 13:35:37 -07:00
parent 2e80e69ef5
commit 3b2c964c2d
5 changed files with 29 additions and 9 deletions
+7 -2
View File
@@ -94,10 +94,15 @@ steps:
# /output, so the worker writes a copy of style.css into /output too.
- name: docker compose build (~2-3 min first time)
# DOCKER_BUILDKIT=0 forces the legacy build path. ana-docker is on
# docker 20.10 (Debian package) which doesn't carry the buildx
# driver versions our newer client expects → "client version 1.52
# is too new" without this fallback. Strip the noisy per-step
# download lines for log readability.
shell: |
set -o pipefail
cd {{ compose_dir }} && docker compose build --progress=plain 2>&1 \
| grep -vE '^#[0-9]+ [0-9.]+ (Downloading|Collecting|Requirement|Using cached|Installing collected|Successfully (installed|built)|Saved /|━|Resolved|Prepared|Built)'
cd {{ compose_dir }} && DOCKER_BUILDKIT=0 docker compose build 2>&1 \
| grep -vE '^Step [0-9]+/[0-9]+ : (RUN|COPY)|^Removing intermediate|^ ---> |^ ---> Running|Collecting|Downloading|Requirement|Using cached|Installing collected|Successfully (installed|built)|'
- name: Pre-stage style.css into /output so the first page render works
# Worker writes index.html which references "style.css" (relative).
+7 -1
View File
@@ -21,7 +21,13 @@ NEWS_DIGEST_TZ=America/Los_Angeles
# Model picked for one-shot summarization quality + low VRAM impact.
# qwen3.5-35-a3b is loaded in the persistent group on ana-ml2.
LLAMA_SWAP_URL=http://10.250.50.54:9292
LLAMA_SWAP_MODEL=qwen3.5-35-a3b
# granite-4-small — small (~4B), fast (~1s/call), no extended-thinking
# phase that eats the token budget like qwen3.x do. Plenty of capability
# for the one-sentence-tldr + one-word-tag task. To swap to a larger
# model later, ones currently working: gemma4-26b-a4b, granite-4-small.
# Avoid: qwen3.5-35-a3b (model file broken — process exits on launch),
# qwen3.6-35-a3b (defaults to thinking mode, eats budget without output).
LLAMA_SWAP_MODEL=granite-4-small
LLAMA_SWAP_TIMEOUT=180
# ── miniflux (feed source for Tech aggregators + subreddit list) ─────
+2 -1
View File
@@ -19,8 +19,9 @@ WORKDIR /app
COPY digest.py /app/digest.py
COPY templates /app/templates
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY run-digest.sh /usr/local/bin/run-digest.sh
COPY crontab /etc/crontabs/root
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/run-digest.sh
# Sentinel + first-run output dir
VOLUME /output
+11 -3
View File
@@ -275,7 +275,11 @@ def summarize_source(src: Source) -> None:
timeout=LLAMA_SWAP_TIMEOUT,
)
r.raise_for_status()
content = r.json()["choices"][0]["message"]["content"].strip()
msg = r.json()["choices"][0]["message"]
# Models in extended-thinking mode (e.g. Qwen3.x defaults) put
# output in reasoning_content and leave content empty until they
# exit thinking — fall back so we get *something* to parse.
content = (msg.get("content") or msg.get("reasoning_content") or "").strip()
# Some models wrap JSON in ```...``` even when told not to.
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.M).strip()
mapped = {x.get("id"): x for x in json.loads(content)}
@@ -306,9 +310,13 @@ def render(reddit_sources: list[Source], tech_sources: list[Source],
env.filters["domain"] = _domain
template = env.get_template("digest.html.j2")
edition = "morning" if generated_at.hour < 14 else "evening"
reddit_kept = [s for s in reddit_sources if s.items]
tech_kept = [s for s in tech_sources if s.items]
return template.render(
reddit_sources=[s for s in reddit_sources if s.items],
tech_sources=[s for s in tech_sources if s.items],
reddit_sources=reddit_kept,
tech_sources=tech_kept,
reddit_total=sum(len(s.items) for s in reddit_kept),
tech_total=sum(len(s.items) for s in tech_kept),
generated_at=generated_at,
edition=edition,
edition_short="AM" if edition == "morning" else "PM",
+2 -2
View File
@@ -59,7 +59,7 @@
<span class="desk-num">01</span>
<h2 class="desk-title">Reddit</h2>
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered</span>
<span class="desk-count">{{ reddit_sources|sum(attribute='items')|length }} items</span>
<span class="desk-count">{{ reddit_total }} items</span>
</header>
{% for src in reddit_sources %}
@@ -122,7 +122,7 @@
<span class="desk-num">02</span>
<h2 class="desk-title">Tech Feeds</h2>
<span class="desk-sub">non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours</span>
<span class="desk-count">{{ tech_sources|sum(attribute='items')|length }} items</span>
<span class="desk-count">{{ tech_total }} items</span>
</header>
{% for src in tech_sources %}