How k4i.com Publishes Posts Unattended With Small AI Models, Groq and GitHub Actions
k4i.com now publishes three kinds of post on its own: risk factor changes from 10-K filings, earnings guidance checked against results, and a chip and AI infrastructure press release digest. No server, no paid inference, no one at the keyboard. Each one is a scheduled GitHub Actions workflow in the site’s repo that reads a public source, runs it through a model, checks the output with plain code and commits a Markdown file. Cloudflare Pages builds the site from there.
This is the setup as it stands after one long day of getting it to work, including the parts that broke. It follows the idea in /zero-budget-ai-apps-work-best-as-self-feeding-sites-not-open-tools/: the site feeds itself, nobody outside can trigger a model call, so the bill can’t run away.
Two kinds of model, two kinds of job
The split that held up was simple. Small local models do the comparing and sorting. Groq does the reading of long, messy text where a small model would miss things.
Risk factor diffs run on small models only. The pipeline pulls a company’s latest annual filing and the one before it from SEC EDGAR, cuts out Item 1A, splits it into paragraphs and matches old against new. Exact matches drop out first. What’s left goes to bge-small-en-v1.5 (33 million parameters, ONNX through fastembed) to pair reworded paragraphs, and difflib decides how much the wording moved. Qwen2.5-1.5B-Instruct on the runner’s CPU writes one opening sentence. Everything else in the post, the quotes, the counts, the links, is code. One filing takes about four minutes of work on a two-core runner.
Earnings guidance and the digest run on Groq. An earnings release is 3,000 words of tables and boilerplate, and the outlook can be written five different ways. gpt-oss-120b on Groq’s free tier pulls out the reported quarter and the outlook as JSON, each figure with the exact sentence it came from. The digest sends a batch of headlines and gets back which ones are on topic and how they group.
The model never gets the last word. Every quote has to appear in the source text, every number in a sentence has to appear in the facts it was given, and a banned-phrase list catches the usual filler. The small model’s sentence goes through the same kind of gate:
def check_lead(sentence: str, facts: str, company: str) -> str | None:
"""Return a reason the sentence is rejected, or None if it passes (trimmed)."""
words = sentence.split()
if not 8 <= len(words) <= 38:
return f"length {len(words)} words"
if company.lower() not in sentence.lower():
return "company name missing"
for num in re.findall(r"\d[\d,.]*", sentence):
num = num.rstrip(".,")
if num and num not in facts:
return f"number {num} not in facts"
return None
When a check fails the post falls back to a sentence built from a template. A dull line beats a wrong one.
The workflow shell
Each pipeline is one workflow file with the same shape: a cheap check step that exits in seconds when there’s nothing new, the expensive steps only when there is, and a commit step that always runs.
on:
schedule:
- cron: "41 1,3,6,8,11,13,15,18,20,23 * * *" # 10 runs a day
workflow_dispatch:
permissions:
contents: write
concurrency:
group: riskdiff
cancel-in-progress: false
jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
# ... checkout, setup-python, check step sets outputs.pending
- name: Compare filings and write posts
if: steps.check.outputs.pending != '0'
timeout-minutes: 12
run: python riskdiff/riskdiff.py run
- name: Commit results and status
if: always() && steps.check.outputs.pending != '0'
run: bash ops/commit.sh riskdiff
The step timeout sits below the job timeout on purpose. If the work step hangs, it gets killed and the commit step still runs, so whatever finished before the hang gets saved. The script also writes its state file after every item, and it stops starting new items after four minutes, leaving the rest for the next run.
The commit script retries the push when another pipeline got there first. It adds [CF-Pages-Skip] to commits that only touch status files, so a quiet run doesn’t use one of Cloudflare’s 500 monthly builds. Each pipeline also writes a small status page into the repo: last run, run time, posts, what was skipped and why. GitHub’s Actions logs aren’t always easy to reach, and a Markdown file in the repo is.
Groq’s free tier has three limits, and they all bit
Per day. gpt-oss-120b on my account allows 200,000 tokens a day. Reruns and a backlog of 20 earnings releases used it up before lunch. The fix was to fall through to the next model on a preference list when that particular error comes back:
if exc.code == 429 and re.search(r"per day|\(TPD\)|\(RPD\)", detail) and fallback:
nxt = fallback(body["model"]) # next model that hasn't hit its cap
if nxt:
body["model"] = nxt
continue # retry at once with the new model
last = f"Groq daily cap reached on every model: {detail}"
break
The fallback has a cost. The first time it kicked in, gpt-oss-20b missed AMD’s outlook and the pipeline published “AMD Reports … With No Revenue Outlook”. AMD had guided about $13 billion. The verification caught invented numbers, not missing ones. Now a post without a verified outlook doesn’t publish at all, since a missing outlook is far more often a missed extraction than a company that gave none.
Per minute. The same model allows 8,000 tokens per minute, and Micron’s release plus the one before it was bigger than that in a single request. Groq answers with HTTP 413. That case now gets one retry with half the text.
Per request rate. A plain 429 waits what Groq asks, 60 seconds at most, and tries once more. If that fails the item is put off to the next run and doesn’t count as a failure. The first version retried six times with waits up to 90 seconds, which is how one run sat for half an hour doing nothing.
The bugs that weren’t the model’s fault
Most of the day went to ordinary code. Groq got every NVIDIA figure right on the first run. My parser turned “$108.0 billion, plus or minus 2%” into a range of $91 billion to $91 billion, and “74.0%, plus or minus 50 basis points” into 25% to 125%. Seven posts went out with numbers like that and came down within the hour. The fix was to split on the plus-or-minus first and read the second number relative to the first:
parts = PLUS_MINUS.split(text, maxsplit=1) # "+/-", "±" or "plus or minus"
if len(parts) == 2:
left, right = amounts(parts[0]), amounts(parts[1])
mid = _conv(kind, left[0][0], left[0][1]) # revenue in millions
hv, hu = right[0]
if hu == "%" and kind != "gross_margin":
half = mid * hv / 100 # 2% of the midpoint
elif hu == "bp":
half = hv / 100 # 50 basis points = 0.5 points
else:
half = _conv(kind, hv, hu, left[0][1]) # "$100M", or same unit as the midpoint
return mid - half, mid + half, mid
On top of that, a plausibility check: a revenue range wider than 30%, or a gross margin range wider than eight points, gets dropped instead of published.
The other one was quieter. Each pipeline was meant to handle one or two items per run, read from an environment variable with a config default:
limit = int(os.environ.get("RISKDIFF_LIMIT", "0") or CONFIG.get("max_per_run", 0))
The string "0" is truthy in Python, so the config value never applied and every run tried the whole watchlist. That’s why the first runs took 20 to 40 minutes. Dropping the "0" default fixed it.
Two install problems are worth knowing too. llama-cpp-python compiled from source on the runner and burned 25 minutes. torch from the CPU wheel index plus transformers installs in under a minute, so that’s what runs Qwen now. GlobeNewswire answers GitHub’s runners with a 403, so the digest reads PR Newswire’s RSS feeds only.
What it costs to run
Groq costs nothing on the free tier, with the limits above. GitHub Actions is free for public repos, but k4i.com’s repo is private, and a private repo on the Free plan gets 2,000 minutes a month. Every job is billed rounded up to the minute, even a run that finds nothing new. At 10 runs a day per pipeline that comes to about 30 minutes a day once the backlog clears, roughly 900 a month.
One small-model experiment didn’t make the cut. A tech press release brief, with bge-small dropping duplicates and Qwen2.5-0.5B writing one line per release, ran in about two minutes and the 0.5B model wrote most of its lines cleanly. It was fast and it held up, and it was also the most expensive pipeline in minutes for the least useful post. It’s gone.
What’s left is a small model doing the comparison work it’s good at, a hosted model doing the reading it’s good at, and plain code checking both before anything goes live.