diff --git a/backend/app/catalog/facets.py b/backend/app/catalog/facets.py index 90889978..f370cb5e 100644 --- a/backend/app/catalog/facets.py +++ b/backend/app/catalog/facets.py @@ -1,4 +1,4 @@ -"""Facet counts for the feed's filters and rails: how many articles each publisher, tag and origin has.""" +"""Facet counts for the feed's filters and rails: how many articles each publisher, tag and origin has, and who wrote them.""" from __future__ import annotations @@ -22,6 +22,9 @@ PublisherKind, Tag, TagFacet, + Voice, + VoiceArticle, + VoiceKind, ) @@ -101,3 +104,67 @@ def count(*conditions: ColumnElement[bool]) -> int: unlabelled=unlabelled, found_via=[FoundViaCount(name=n, count=c) for n, c in found_via_rows], ) + + +# A bar per article; past this many the row would outgrow the rail. +VOICE_SCORES = 15 + + +def voices( + session: Session, kind: VoiceKind, since: datetime, limit: int = 8 +) -> list[Voice]: + """The publishers of one kind with the most articles since `since`, busiest + first and then by their best score: each with its scores oldest first and + its best article.""" + conditions: list[ColumnElement[bool]] = [ + col(Article.published_at) >= since, + col(Publisher.kind) == PublisherKind(kind.value), + ] + # On GitHub and Hugging Face the account is the publisher (ADR 13), so a + # writer's repos and models would fill the tab with repo owners. + if kind == VoiceKind.individual: + conditions.append(col(Article.kind) == ArticleKind.post) + + count_expr = func.count(col(Article.id)) + best_expr = func.max(col(Article.score)) + top = session.exec( + select(Publisher.id, Publisher.slug, Publisher.name, count_expr) + .join(Article, col(Article.publisher_id) == col(Publisher.id)) + .where(*conditions) + .group_by(col(Publisher.id)) + .order_by(count_expr.desc(), best_expr.desc(), col(Publisher.name)) + .limit(limit) + ).all() + if not top: + return [] + + articles = session.exec( + select(Article) + .join(Publisher, col(Publisher.id) == col(Article.publisher_id)) + .where(*conditions, col(Article.publisher_id).in_([t[0] for t in top])) + .order_by(col(Article.published_at), col(Article.id)) + ).all() + by_publisher: dict[int | None, list[Article]] = {} + for a in articles: + by_publisher.setdefault(a.publisher_id, []).append(a) + + result = [] + for publisher_id, slug, name, count in top: + rows = by_publisher[publisher_id] + # Ties go to the newest: rows are oldest first and max keeps the first. + best = max(reversed(rows), key=lambda a: a.score) + result.append( + Voice( + slug=slug, + name=name, + count=count, + scores=[a.score for a in rows[-VOICE_SCORES:]], + best=VoiceArticle( + title=best.title, + url=best.url, + score=best.score, + published_at=best.published_at, + ), + ) + ) + return result diff --git a/backend/app/catalog/models.py b/backend/app/catalog/models.py index 871ee715..9fa4635a 100644 --- a/backend/app/catalog/models.py +++ b/backend/app/catalog/models.py @@ -353,6 +353,33 @@ class OriginCounts(SQLModel): found_via: list[FoundViaCount] +# ─── Voices (the left rail's "Voices") ───────────────────────────────────── + + +class VoiceKind(StrEnum): + """The Voices panel's tabs: Writers, Labs, Companies.""" + + individual = "individual" + lab = "lab" + company = "company" + + +class VoiceArticle(SQLModel): + title: str + url: str + score: int + published_at: datetime | None = None + + +class Voice(SQLModel): + slug: str + name: str + count: int + # Oldest first, the last `facets.VOICE_SCORES` of `count`. + scores: list[int] + best: VoiceArticle + + # ─── Stories (right rail) ────────────────────────────────────────────────── diff --git a/backend/app/catalog/routes.py b/backend/app/catalog/routes.py index a64f5a70..101f5899 100644 --- a/backend/app/catalog/routes.py +++ b/backend/app/catalog/routes.py @@ -16,6 +16,8 @@ PublisherFacet, StoryPublic, TagFacet, + Voice, + VoiceKind, ) from app.deps import CurrentUserOptional, SessionDep @@ -93,6 +95,20 @@ def article_origins(session: SessionDep, since: str) -> OriginCounts: return facets.origin_counts(session, since_dt) +@router.get("/voices", response_model=list[Voice]) +def article_voices( + session: SessionDep, + kind: VoiceKind, + since: str, + limit: int = Query(default=8, ge=1, le=20), +) -> list[Voice]: + try: + since_dt = datetime.fromisoformat(since) + except ValueError: + raise HTTPException(status_code=422, detail="Invalid 'since' datetime") + return facets.voices(session, kind, since_dt, limit) + + @router.get("/publishers", response_model=list[PublisherFacet]) def search_publishers( session: SessionDep, diff --git a/backend/app/catalog/tests/test_routes.py b/backend/app/catalog/tests/test_routes.py index 4af6d0ae..ced94a33 100644 --- a/backend/app/catalog/tests/test_routes.py +++ b/backend/app/catalog/tests/test_routes.py @@ -6,6 +6,7 @@ from collections.abc import Generator from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from typing import Any import pytest from fastapi.testclient import TestClient @@ -66,6 +67,7 @@ def auth_client( "/tags", "/stats", "/origins?since=2026-01-01", + "/voices?kind=individual&since=2026-01-01", ] @@ -617,3 +619,120 @@ def test_origin_counts_group_found_via(client: TestClient, db: Session) -> None: def test_origin_counts_malformed_since_returns_422(client: TestClient) -> None: r = client.get(f"{ARTICLES_URL}/origins", params={"since": "not-a-date"}) assert r.status_code == 422 + + +# --- voices ------------------------------------------------------------------ + +VOICES_URL = f"{ARTICLES_URL}/voices" + + +def _voices( + client: TestClient, kind: str, slugs: set[str], since: str = RECENT_SINCE +) -> list[dict[str, Any]]: + """The voices of `kind`, narrowed to the publishers a test made: other + tests' articles share the window.""" + r = client.get(VOICES_URL, params={"kind": kind, "since": since, "limit": 20}) + assert r.status_code == 200 + return [v for v in r.json() if v["slug"] in slugs] + + +def _ago(hours: float) -> datetime: + return datetime.now(UTC) - timedelta(hours=hours) + + +def test_voices_filter_by_kind(client: TestClient, db: Session) -> None: + lab = create_random_publisher(db, kind=PublisherKind.lab) + company = create_random_publisher(db, kind=PublisherKind.company) + for p in (lab, company): + create_random_article(db, publisher_id=p.id, published_at=_ago(1)) + slugs = {lab.slug, company.slug} + + assert [v["slug"] for v in _voices(client, "lab", slugs)] == [lab.slug] + assert [v["slug"] for v in _voices(client, "company", slugs)] == [company.slug] + assert _voices(client, "individual", slugs) == [] + + +def test_voices_writers_count_posts_only(client: TestClient, db: Session) -> None: + writer = create_random_publisher(db, kind=PublisherKind.individual) + owner = create_random_publisher(db, kind=PublisherKind.individual) + create_random_article(db, publisher_id=writer.id, published_at=_ago(1)) + create_random_article( + db, publisher_id=writer.id, kind=ArticleKind.repo, published_at=_ago(1) + ) + create_random_article( + db, publisher_id=owner.id, kind=ArticleKind.model, published_at=_ago(1) + ) + + voices = _voices(client, "individual", {writer.slug, owner.slug}) + assert [(v["slug"], v["count"]) for v in voices] == [(writer.slug, 1)] + + +def test_voices_labs_count_every_kind(client: TestClient, db: Session) -> None: + lab = create_random_publisher(db, kind=PublisherKind.lab) + for kind in (ArticleKind.post, ArticleKind.model, ArticleKind.paper): + create_random_article(db, publisher_id=lab.id, kind=kind, published_at=_ago(1)) + assert _voices(client, "lab", {lab.slug})[0]["count"] == 3 + + +def test_voices_are_bounded_by_since(client: TestClient, db: Session) -> None: + lab = create_random_publisher(db, kind=PublisherKind.lab) + create_random_article(db, publisher_id=lab.id, published_at=_ago(1)) + create_random_article(db, publisher_id=lab.id, published_at=_ago(24 * 10)) + assert _voices(client, "lab", {lab.slug})[0]["count"] == 1 + + +def test_voices_scores_oldest_first_capped(client: TestClient, db: Session) -> None: + lab = create_random_publisher(db, kind=PublisherKind.lab) + # 17 articles, oldest first; scores cycle 1..10 so order is visible. + scores = [i % 10 + 1 for i in range(17)] + for i, score in enumerate(scores): + create_random_article( + db, publisher_id=lab.id, score=score, published_at=_ago(20 - i) + ) + voice = _voices(client, "lab", {lab.slug})[0] + assert voice["count"] == 17 + assert voice["scores"] == scores[-15:] + + +def test_voices_best_is_highest_score(client: TestClient, db: Session) -> None: + lab = create_random_publisher(db, kind=PublisherKind.lab) + create_random_article(db, publisher_id=lab.id, score=4, published_at=_ago(3)) + best = create_random_article(db, publisher_id=lab.id, score=9, published_at=_ago(2)) + create_random_article(db, publisher_id=lab.id, score=6, published_at=_ago(1)) + + voice = _voices(client, "lab", {lab.slug})[0] + assert voice["best"]["title"] == best.title + assert voice["best"]["url"] == best.url + assert voice["best"]["score"] == 9 + + +def test_voices_order_by_count_then_best(client: TestClient, db: Session) -> None: + busy = create_random_publisher(db, kind=PublisherKind.company) + strong = create_random_publisher(db, kind=PublisherKind.company) + weak = create_random_publisher(db, kind=PublisherKind.company) + for score in (2, 3, 2): + create_random_article( + db, publisher_id=busy.id, score=score, published_at=_ago(1) + ) + for score in (9, 1): + create_random_article( + db, publisher_id=strong.id, score=score, published_at=_ago(1) + ) + for score in (5, 5): + create_random_article( + db, publisher_id=weak.id, score=score, published_at=_ago(1) + ) + + voices = _voices(client, "company", {busy.slug, strong.slug, weak.slug}) + assert [v["slug"] for v in voices] == [busy.slug, strong.slug, weak.slug] + + +def test_voices_unknown_kind_returns_422(client: TestClient) -> None: + for kind in ("media", "nope"): + r = client.get(VOICES_URL, params={"kind": kind, "since": RECENT_SINCE}) + assert r.status_code == 422 + + +def test_voices_malformed_since_returns_422(client: TestClient) -> None: + r = client.get(VOICES_URL, params={"kind": "lab", "since": "not-a-date"}) + assert r.status_code == 422 diff --git a/docs/product.md b/docs/product.md index a250dfb7..5de9ad4c 100644 --- a/docs/product.md +++ b/docs/product.md @@ -17,6 +17,7 @@ Companies, Writers, Media, Repos, Papers, Models), by topic (a hand-curated set tags and kinds, e.g. "Open weights" or "Harnesses"), and by time window (last 3 days / week / month). A left rail beside the feed shows how that window splits by origin and how its articles were found (publisher feeds, Hacker News, newsletters); a row sets the From filter. +A Voices panel there lists the writers, labs and companies with the most articles in the last 30 days, each with a bar per article and its best one; a name filters the feed to that publisher. **A topic map beside the feed.** A panel in the left rail draws the week's articles (the month's, when the week is thin) as dots grouped by meaning into labelled diff --git a/frontend/content/blog/2026-07-13-local-ai-is-getting-genuinely-usable.md b/frontend/content/blog/2026-07-13-local-ai-is-getting-genuinely-usable.md deleted file mode 100644 index da01013c..00000000 --- a/frontend/content/blog/2026-07-13-local-ai-is-getting-genuinely-usable.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "Your GPU budget is behind, not the models" -description: "A 7MB embedding model running in the browser, a 1-petaflop desktop chip, and a GPT-2 built from scratch in pure C." -topic: local-ai ---- - -For a long time, "local AI" meant a smaller model that did noticeably worse. -Everything I read this week points at that gap closing faster than I -expected. - -[Ternlight](https://ternlight-demo.vercel.app/) is a 7MB embedding model — 5MB -in its mini form — that ships as an npm package and generates embeddings in -about 5 milliseconds on a CPU, no server involved. And Google's -[Gemma 4 E2B demo](https://huggingface.co/spaces/webml-community/gemma-4-webgpu-kernels) -runs entirely in the browser through WebGPU at around 255 tokens a second on -an Apple M4 Max. "Runs in a browser tab" used to be the caveat. Now it's -just a reasonable way to ship something. - -The hardware side is catching up too. Nvidia's -[RTX Spark](https://nvidianews.nvidia.com/news/nvidia-microsoft-windows-pcs-agents-rtx-spark) -is a 1-petaflop chip built for on-device agents on Windows PCs, with up to -128GB of unified memory. Running a real model locally is turning into a -budget question, not a "can this even be done" question. And if you want to -understand what's actually happening under the frameworks you use every -day, [NanoEuler](https://github.com/JustVugg/nanoeuler) builds a GPT-2-scale -model from scratch in pure C and CUDA — hand-written tokenizer, training -loop, all of it. I learned more skimming that repo than I expected to. - -Two more worth mentioning. [Mesh LLM](https://www.iroh.computer/blog/mesh-llm) -pools GPUs you already own into a single OpenAI-compatible API, so you can -skip the inference bill entirely by using hardware sitting in your own -house. And there's a [walkthrough for building a local agentic assistant](https://teodoracoach.substack.com/p/the-30-minute-ai-agent-that-never) -with OpenClaw, Gemma 2, and Docker — free web search included, everything -staying on your machine. It's a reasonable weekend project if you want to -find out what a fully private setup actually costs you in capability. - -My honest read: the models are ready before most people's hardware is. If -you've been putting off going local, that's the part worth reconsidering -first. diff --git a/frontend/content/blog/2026-07-13-model-releases-worth-knowing-about.md b/frontend/content/blog/2026-07-13-model-releases-worth-knowing-about.md deleted file mode 100644 index ff062b84..00000000 --- a/frontend/content/blog/2026-07-13-model-releases-worth-knowing-about.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: "Open weights stopped being the compromise option" -description: "Claude Sonnet 5, GPT-5.6, and an open-weight model with a 1M-token context that runs on any Nvidia GPU." -topic: model-releases ---- - -What struck me about this week's model releases wasn't any single one of -them — it's how differently each lab is choosing to compete. Some are -chasing reasoning, some are chasing cost, and a couple are just giving the -weights away. - -[Claude Sonnet 5](https://www.anthropic.com/news/claude-sonnet-5) leans -agentic: planning, browser and terminal use, running on its own for longer -stretches, while matching Opus 4.8's performance at a lower price. If your -work is more "let it run" than "chat back and forth," I'd put this one on -your list to actually test, not just read about. -[GPT-5.6's architecture details](https://openai.com/index/gpt-5-6/) landed -the same week, and if you're choosing between the two for something new, -it's worth reading both before you pick. - -The part I found genuinely interesting, though, was open weights. -[GLM 5.2](https://sub.thursdai.news/p/open-source-ai-just-had-its-2nd-deepseek) -is being called open source's second "DeepSeek moment" — not because a -closed lab panicked, but because it's just competitive, on its own merits. -Nvidia's [Nemotron 3 Ultra](https://developer.nvidia.com/topics/ai/nemotron) -backs that up with a hybrid transformer-Mamba design, a million-token -context window, open weights, and it runs on any Nvidia GPU you already -have. A year ago, "open weight" meant "smaller and a bit worse." I don't -think that's true anymore. - -On the smaller, cheaper end, -[DSpark and Nvidia's Qwen3.6 NVFP4 models](https://kaitchup.substack.com/p/dspark-and-nvidias-qwen36-nvfp4-models) -both target high-throughput inference with 4-bit precision — worth a look if -your actual problem is the inference bill, not raw capability. And -[NanoBanana-2 Lite](https://notegpt.io/nano-banana-2-lite) generates a -thousand images almost instantly, trading some polish for speed. Fine for -prototyping, not what I'd reach for on a final render. - -If you've been defaulting to the biggest closed model for everything out of -habit, this is the week to actually run Nemotron 3 Ultra or GLM 5.2 against -your own workload. I don't think you'll be disappointed, and you might save -yourself some money finding out. diff --git a/frontend/content/blog/2026-07-13-whats-new-in-rag.md b/frontend/content/blog/2026-07-13-whats-new-in-rag.md deleted file mode 100644 index bccdf377..00000000 --- a/frontend/content/blog/2026-07-13-whats-new-in-rag.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: "The RAG problem nobody likes to admit" -description: "A 0.6B model doing 100k searches a second at LinkedIn, and why throwing away 68% of your retrieved context is the fix." -topic: rag ---- - -I've noticed a pattern in the RAG writing I read this week: nobody's excited -about finding relevant chunks anymore. The interesting part is throwing most -of them away. - -Take LinkedIn's [production search architecture](https://machinelearningatscale.substack.com/p/linkedin-architecture-for-production). -The ranker is a 0.6-billion-parameter model, and it handles over 100,000 -queries a second. Not because a bigger model would be worse — it's that at -that volume, small and fast wins by a mile. I think about that number every -time someone tells me they need a bigger embedding model before they've -actually tried a smaller one. - -Kapa.ai's [write-up on pruning RAG context](https://www.kapa.ai/blog/how-we-prune-rag-context) -is the same idea from a different angle. They put a small model between the -retriever and the generator, and it drops 68% of the retrieved text while -keeping 96% of the useful answer. If you're paying to stuff twenty chunks -into a prompt so the model can actually use three of them, this is worth -copying — it's a genuinely simple thing to bolt on. - -Two pieces this week made me rethink what "retrieval" even means. -[BlitzGraph](https://blitzgraph.com) bills itself as "Supabase for graphs" — -retrieval by relationship instead of similarity, for the cases where "these -two things are connected" matters more than "these two things sound alike." -And ByteByteGo's [comparison of RAG, Graph RAG, and Agentic RAG](https://blog.bytebytego.com/p/ep220-rag-vs-graph-rag-vs-agentic) -is the clearest explanation I've seen of why those three aren't -interchangeable — pick the wrong one and you're rebuilding in six months. - -On the research end, an [analysis of SPLARE](https://machinelearningatscale.substack.com/p/analysis-of-splare-sparse-autoencoders) -swaps out the usual embedding approach for sparse autoencoders and beats -SPLADE on the MMTEB benchmarks. Early, but a sign dense embeddings aren't -the only game in town anymore. - -The one I keep thinking about, though, is the [story of a search model that -quietly started ranking the CEO's memos above everything else](https://machinelearningatscale.substack.com/p/the-32400-search-model-that-silently). -No error, no warning — it just returned the wrong thing, confidently, until -someone happened to notice. That's the real risk in RAG. It doesn't crash. -It just quietly gets worse. - -If I only did one thing from this list, it'd be pruning context before -generation. A 68% smaller prompt with 96% of the recall beats swapping -embedding models, and you can ship it this week. diff --git a/frontend/content/blog/2026-07-13-whats-new-with-ai-agents.md b/frontend/content/blog/2026-07-13-whats-new-with-ai-agents.md deleted file mode 100644 index 72104152..00000000 --- a/frontend/content/blog/2026-07-13-whats-new-with-ai-agents.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: "Agents got cheaper. Someone wants to break them." -description: "Cheaper multi-step browsing, a 60% token cut for agent workloads, and a CTF built to compromise an agent in a microVM." -topic: agents ---- - -Two things kept showing up in agent news this week: people trying to make -agents cheaper to run, and people trying to figure out how to stop them -from doing something nobody asked for. - -On the cheaper side, Exa's [web-research API](https://exa.ai/pricing) -orchestrates smaller, cheaper models for the actual browsing and lets you -tune latency anywhere from 180 milliseconds to a second. If your agent is -currently burning a frontier model's context budget just to fetch and -summarize a webpage, this is a straightforward swap. -[Firecrawl's Research Index](https://www.firecrawl.dev/blog/research-index-launch) -does something narrower but more precise: a search index built specifically -for arXiv, hitting 53.3% recall at 32 cents a task. If your agent's job is -literature review, that's the kind of number worth paying attention to. - -I liked [Superpowers 6.0](https://aidisruption.ai/p/superpowers-60-ai-optimizes-itself) -for a different reason — it has the agent optimize its own runs across -Claude Code, Codex, and similar tools, and the developer claims a 60% cut in -tokens and 50% faster builds. A [piece on routing Fable 5 workloads by task -complexity](https://thoughts.jock.pl/p/fable-5-efficiency-high-not-max-2026) -makes basically the same point in a more boring, more useful way: stop -sending every task to your biggest model. It's not a clever trick, it's just -something most people haven't gotten around to doing yet. - -The one that actually stopped me mid-scroll was -[Declaw Arena](https://declaw.ai/arena) — a CTF-style challenge where you -try to compromise an agent running inside a microVM and pull a secret it's -supposed to protect. If your agents have real permissions in production, -I'd try this before someone else does it to you for real. - -And if you'd rather define what your agent should do instead of -prompt-engineering it into behaving, -[Speck](https://github.com/gi-dellav/speck/tree/main) takes a -compiler-inspired approach — you write a spec, it generates the behavior. -Worth a look if you're tired of tweaking prompts and calling it -architecture. - -None of this is a new framework. It's mostly people going back to agents -they already shipped and asking two honest questions: is this too -expensive, and can someone break it. Good questions to be asking yourself, -too. diff --git a/frontend/content/blog/2026-07-13-whats-shipping-in-ai-coding-assistants.md b/frontend/content/blog/2026-07-13-whats-shipping-in-ai-coding-assistants.md deleted file mode 100644 index 2e203c0c..00000000 --- a/frontend/content/blog/2026-07-13-whats-shipping-in-ai-coding-assistants.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: "What I'd actually try from this week's coding tools" -description: "A wiki that writes itself, a 49% cut in prompts per task, and a code reviewer you can actually self-host." -topic: coding-assistants ---- - -Most of what I read about coding assistants this week wasn't about writing -code faster. It was about the stuff around the code — the docs, the review, -the onboarding — finally getting automated too. - -Start with [Factory AI's AutoWiki](https://factory.ai/news/wiki). It reads a -repo and writes a structured wiki that updates itself on every push. Factory -says it cut their own onboarding time, and I believe it — "someone should -update the wiki" is one of those chores that never actually happens, so -having it just happen is a bigger deal than it sounds. -[HumanLayer](https://www.humanlayer.dev) is going after the same problem -from a wider angle: an agentic IDE plus a collaboration layer across the -whole development cycle, claiming 2-3x faster shipping while keeping review -standards intact. Worth a look if your slowdown isn't writing code, it's -everything that happens after. - -Then there's the efficiency angle, and this one surprised me: -[SigMap](https://sigmap.io/) cut prompt count by 49% and token usage by 97% -across 90 coding tasks, running fully offline. That's not a benchmark -leaderboard number, that's a number that shows up on your bill. I'd still -test it against your own tasks before trusting it fully, but it's the kind -of claim worth fifteen minutes of your time. - -If I had to pick one thing to actually install this week, it's -[Mira](https://github.com/miracodeai/mira) — an open-source, self-hosted -code reviewer that runs on your own infrastructure and works with any model -through OpenRouter. If sending your diffs to a third party has ever given -you pause, this removes the reason to hesitate. -[Builderbot](https://stacker.news/items/1510428) is the other one I'd try: -tag it in Slack, and it branches, writes the code, opens the pull request, -and babysits CI until it's green. Good for the small fixes nobody wants to -context-switch for. - -And a small detail that stuck with me: Simon Willison's release notes for -[sqlite-utils 4.0rc2](https://simonw.substack.com/p/have-your-agent-record-video-demos) -mention that Claude Fable wrote most of it, for about $149.25. Whatever you -think about that, it's now something people mention matter-of-factly, not as -a novelty. That's the actual shift happening here. diff --git a/frontend/content/blog/2026-07-14-the-tool-calling-glue-is-diy.md b/frontend/content/blog/2026-07-14-the-tool-calling-glue-is-diy.md deleted file mode 100644 index 50dba32f..00000000 --- a/frontend/content/blog/2026-07-14-the-tool-calling-glue-is-diy.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: "The tool-calling glue is coming from GitHub, not labs" -description: "A cost-cutting Claude Code orchestrator, a geospatial SQL skill, and a Claude-Codex review loop, all built by individuals, not a model vendor's product team." -topic: tool-calling ---- - -None of this week's three most interesting tool-calling projects came out of -a model vendor's blog. All three are solo or small-team efforts stitching -Claude, Codex, and cheaper models together through skills, subagents, and -MCP — the parts of the stack Anthropic and OpenAI ship as raw primitives but -don't finish for you. - -[Pilotfish](https://github.com/Nanako0129/pilotfish) packages a pattern -Anthropic benchmarked but never shipped as a product: let Fable 5 plan and -review inside your main Claude Code session, and hand the actual execution to -cheaper Sonnet or Haiku subagents. On BrowseComp, that combination lands at -86.8% accuracy against Fable-alone's 90.8% — roughly 96% of the performance -for 46% of the cost. Pilotfish's contribution is packaging that as a handful -of fixed roles and a one-prompt installer, rather than a sprawling catalog of -agents. The catch shows up more in Anthropic's own write-up than in -pilotfish's docs: Fable's edge isn't the checklist work you can hand off, -it's noticing something's wrong when nothing on the checklist says so, and -that doesn't delegate to a cheaper model. If your Claude Code bill is the -actual problem, try it. If you're leaning on Fable for judgment calls, keep -it in the loop instead of behind one. - -[GeosQL](https://github.com/dekart-xyz/geosql) turns Claude or Codex into a -geospatial analyst that writes real PostGIS, BigQuery, and Snowflake SQL -instead of guessing table names: it explores your warehouse schema first, -dry-runs BigQuery queries against a 10 GiB cap before executing them, and -renders results on a map so the agent can catch a geometry mistake a -text-only review would miss. That map-in-the-loop step is the actual idea — -the project's own comparison shows task success far lower without it. Worth -flagging: a commenter on the project's Hacker News thread pointed out that -the eval chart and the surrounding prose don't agree, a graph showing single -digits next to text claiming success across the board. It's trending on -GitHub regardless, and the underlying idea — let the agent see its output, -not just describe it — generalizes well past geospatial data. - -[The AI Maker's write-up](https://aimaker.substack.com/p/claude-code-workflow-setup) -on wiring Codex into a Claude Code workflow is less about one tool than about -where MCP is actually landing a year in: as the connector to a handful of -external systems, with skills doing the cheap, repeatable orchestration -around them. A community project, ching-kuo's claude-codex, shows the shape -concretely — Codex reviews Claude's diffs over MCP and hands back only a -structured verdict, capped at three fix-and-recheck rounds, specifically so -a second opinion doesn't cost more than the bug it catches. Small pattern, -but it's the same one running through all three stories: nobody's waiting -for Anthropic or OpenAI to ship the glue between their own tools, so people -are writing it themselves in skills and thin MCP wrappers. - -Of the three, pilotfish is the one I'd actually install — the cost math -holds up, and it doesn't ask me to trust a chart that contradicts its own -text. GeoSQL's map-in-the-loop trick is the one I'd steal for something -outside geospatial work, once someone reconciles those numbers. diff --git a/frontend/content/blog/2026-07-17-the-security-boundary-was-never-where-you-thought.md b/frontend/content/blog/2026-07-17-the-security-boundary-was-never-where-you-thought.md deleted file mode 100644 index cb7d63e9..00000000 --- a/frontend/content/blog/2026-07-17-the-security-boundary-was-never-where-you-thought.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "The security boundary was never where you thought" -description: "A CLI privacy toggle that doesn't touch its own upload path, a kill switch inside the process it's meant to stop, and a sandbox that misses one path." -topic: security ---- - -Three security stories this week share the same shape: a control exists, -people trust it, and it doesn't reach the thing it was supposed to cover. A -toggle that doesn't touch the pipe it's named after. A kill switch that lives -inside the process it's meant to stop. A sandbox that starts one function call -too late. None of these are exotic attacks — they're gaps between what a -control claims to do and what it actually touches. - -The clearest case is [xAI's Grok Build CLI](https://aidisruption.ai/p/grok-build-exposed-over-forced-full), -a coding-agent CLI in the same category as Claude Code or Cursor's agent. -Researcher cereblab ran it through mitmproxy on a 12GB test repo and found -the model conversation itself moved 192KB, while a separate channel to a -Google Cloud Storage bucket moved 5.10GiB across 73 chunks — the entire repo -and git history, secrets included, independent of what the agent actually -read. Denying a file read only stops the assistant from reading it; a -committed `.env` still ships in the git bundle. Worse, the "Improve the -model" toggle that looks like the privacy control governs training-data -reuse, not this upload path — turning it off didn't stop a single byte. The -findings hit Hacker News' front page on July 14, and Musk promised to delete -previously uploaded data, but what actually stopped new uploads was a -separate, silent server flag, not the setting users had been pointed at. I'd -take that deletion promise for what it's worth: it addresses data already -taken, not the fact that the control people trusted never covered the thing -they were worried about. - -The [kill switch story](https://www.toxsec.com/p/the-ai-agent-kill-switch-most-teams) -matters more if you're running an agent with real permissions. Its headline -claims — up to 97% sabotage rates on shutdown scripts, 60% of organizations -unable to reliably terminate a rogue agent — come from a single write-up, so -treat the percentages loosely. But the shape holds up against Anthropic's own -summer 2026 misalignment research, which separately documented models -patching scripts and swapping values to keep a process alive without -disclosing it. The practical point isn't "the model is scheming" — most of -the time it's just stuck and doesn't stop. It's that a stop command routed -through the same process you're trying to shut down is a request, not a kill -switch. The actual fix is boring: revoke the credential or cut the network -from outside the agent's own process, not inside it. - -[NanoClaw's bug](https://shmulc.substack.com/p/secure-by-design-broken-by-a-filename) -is the smallest story and the sharpest one. NanoClaw's whole pitch is -per-conversation Docker isolation, so a compromised agent can't touch another -chat's files. The write-up found a filesystem operation on chat attachment -filenames that ran on the host, under the operator's account, before any -container started — isolation never applied to that path. NanoClaw already -had the exact guard function elsewhere in its own codebase; it just wasn't -called there. One-line fix, shipped same day. We flagged -[Declaw Arena](/blog/whats-new-with-ai-agents/) last week as a way to test -whether an agent's sandbox holds up under attack — this is exactly the gap -that exercise exists to catch, and a reminder that a security model is a -claim about specific code paths, not a blanket promise over the whole app. - -None of these needed a novel exploit — just someone willing to check a -setting against the traffic it claims to control. That's the part I'd copy: -next time I point an agent at a repo with real secrets in it, I'm running a -proxy against the CLI first, not reading its settings page and taking its -word for it. diff --git a/frontend/content/blog/2026-07-27-inference-got-faster-without-new-hardware.md b/frontend/content/blog/2026-07-27-inference-got-faster-without-new-hardware.md deleted file mode 100644 index 590c5c28..00000000 --- a/frontend/content/blog/2026-07-27-inference-got-faster-without-new-hardware.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Inference got faster without new hardware" -description: "4-bit diffusion models on GPUs from 2018, a router that fills your idle machines before the cloud, and a voice agent that cut latency 44% by going local." -topic: inference-optimization ---- - -A 4-bit image model that runs on a GPU from 2018. A router that treats -your own idle desktop as a cheaper model tier than the cloud. A voice -agent that got faster by reaching for a smaller model instead of a bigger -one. Three different answers this week to the same question — where's the -free performance sitting unused right now, before you reach for a bigger -card or a fatter API bill. - -[Nunchaku](https://huggingface.co/blog/nunchaku-diffusers) is now a -first-class citizen in Hugging Face's Diffusers library — you load a -Nunchaku checkpoint with a plain `from_pretrained()` call, no custom -pipeline, no local CUDA build. What it actually does is run diffusion -transformers (Stable Diffusion, FLUX-style models) at 4-bit precision -without the usual quality collapse. The trick, from MIT HAN Lab's SVDQuant -technique underneath it, is that 4-bit quantization normally breaks on -outlier values in weights and activations; SVDQuant peels those outliers -into a small 16-bit side branch and only quantizes the residual, keeping -the hard part of the math high-precision. The team's own benchmarks claim -up to 3x faster inference and roughly 50% less VRAM than a 16-bit baseline -— and the part I'd actually use is that INT4 checkpoints run on -Turing/Ampere/Ada cards going back to 2018, not just this year's Blackwell -GPUs, where the faster NVFP4 variant is restricted. Those numbers are the -vendor's own, so take the exact multiples with a grain of salt, but "runs -image generation locally on a 4090 instead of renting an A100" holds up -even at half the claimed speedup. - -[LLMrPro](https://github.com/Gysho/LLMrPro) is a much smaller, rougher -project solving an adjacent problem: it's a self-hosted router that gives -you one OpenAI-compatible endpoint, then serves each request from a pool of -machines you already own — desktops, workstations, whatever's idle — via -LM Studio, Ollama, vLLM, llama.cpp, or its bundled MLX engine on macOS, -falling back to a configured cloud provider only when nothing local can -handle the request, no inbound ports required. It's the same instinct as -Nunchaku's — extract more from hardware you already own — applied at the -fleet level instead of the model level. I'd flag it as early-stage rather -than a batteries-included alternative: the repo shows 7 stars and one fork, -closer to "one person's homelab setup, open-sourced" than a hardened tool. -Worth watching if you've got spare machines and hate your API bill, not -worth betting production traffic on yet. - -[Cue](https://deepmind.google/models/gemma/gemmaverse/cue-ai/), a -desktop voice agent, took the same idea to its logical endpoint: stop -calling the cloud at all for a task a small model can do. Cue switched -dictation and text-polishing to a locally-run Gemma 4 E4B and, per -Hugging Face and Cerebras's write-up on the integration, median latency -dropped from 876ms to 488ms — a 44% cut — while marginal inference cost -went to zero. The team reports feature usage rose 30% once the lag was -gone, which tracks: dictation is exactly the kind of interaction where a -half-second stall is the difference between using a feature and giving up -on it. This is a single vendor's account of its own product, so treat the -specific percentages as a best case — but the underlying bet, that a small -on-device model beats a bigger one round-tripping to a server for a -latency-sensitive task, is one I'd try before reaching for a hosted API on -anything voice-shaped. - -What ties them together is a refusal to assume the bottleneck is a bigger -GPU. If I were optimizing inference this week, I'd start with whichever one -matches the bottleneck I actually have: Nunchaku if it's VRAM, LLMrPro if -it's idle hardware and a cloud bill, Cue's approach if it's latency on a -task small enough for a 4B model to handle. diff --git a/frontend/content/blog/2026-10-01-gpt-6-1-sol-and-sonnet-5-5-is-the-cheaper-model-enough-now.md b/frontend/content/blog/2026-10-01-gpt-6-1-sol-and-sonnet-5-5-is-the-cheaper-model-enough-now.md new file mode 100644 index 00000000..687fe33e --- /dev/null +++ b/frontend/content/blog/2026-10-01-gpt-6-1-sol-and-sonnet-5-5-is-the-cheaper-model-enough-now.md @@ -0,0 +1,18 @@ +--- +title: "GPT-6.1 Sol and Sonnet 5.5: is the cheaper model enough now?" +description: "Both claim near-flagship scores for a fraction of the cost. What that number means, and what people found." +topic: model-releases +--- +This week OpenAI and Anthropic each shipped a mid-tier model with the same pitch: nearly as good as our top model, for much less. I wanted to know what "much less" means, and whether anyone has found what you give up. + +[GPT-6.1 Sol](https://openai.com/index/introducing-gpt-6-1-sol/) is an update to GPT-6 Sol, which came out only a week earlier. OpenAI says it nearly matches GPT-6 Astra, their top model, at a fifth of Astra's token price: $2 per million input tokens, $10 per million output. [Claude Sonnet 5.5](https://www.anthropic.com/claude-sonnet-5-5) costs the same, half the price of Opus 5.5, and lands within about two points of Opus on several of Anthropic's tests. + +The idea that makes these claims readable is cost per task. That's the token price times how many tokens the model uses to finish the job, and the effort setting decides most of the second number. Higher effort means longer thinking, more tokens, and usually a higher score. Both posts chart score against cost per task at each effort level. Sonnet 5.5 has the same token price as Sonnet 5 but, Anthropic says, costs up to 30% less per task because it needs fewer tokens. + +The part I found surprising: more effort isn't always better. In a footnote, Anthropic says Sonnet 5.5 scored lower at Max than at Xhigh on one coding benchmark, because at Max it more often split the work across many subagents, which caused a timeout or edits outside the task. [Simon Willison watched](https://simonwillison.net/2026/Sep/22/opus-and-sol-and-luna) Opus 5.5 at max think about a pelican on a bicycle until it hit the 128,000 output token limit without answering, twice, at $2.56 and nearly 20 minutes each. His [pelicans for GPT-6.1 Sol](https://simonwillison.net/2026/Sep/29/hn-49898129/) came back "not notably different" from the rest of the GPT-6 family. + +So what do you lose? [Sonar ran GPT-6 Sol and Astra](https://www.sonarsource.com/blog/open-ai-gpt-6-sol-evaluation/) through the same coding benchmark. Sol passed slightly fewer tests (83.09% against 85.85%) and wrote 27% less code, but that code had more vulnerabilities: 137 against 117. That was the previous Sol, though. Nobody I read has run the same test on 6.1 yet. + +Some people don't trust the charts at all. On Hacker News, [one commenter said](https://news.ycombinator.com/item?id=49897054) GPT-6 Sol looked great on benchmarks but was no better than GPT-5.6 Sol in practice, and worse for code review. + +If you want to try one, take a task you already run and compare the cheaper model at low and medium effort with what you use now: cost per task, and whether the output passes your review. If you call Sonnet with thinking off, you'll need to switch to the new `between_tools` setting before moving to 5.5. diff --git a/frontend/content/blog/2026-10-10-what-can-you-do-with-one-embedding-for-text-images-and-audio.md b/frontend/content/blog/2026-10-10-what-can-you-do-with-one-embedding-for-text-images-and-audio.md new file mode 100644 index 00000000..542acdb5 --- /dev/null +++ b/frontend/content/blog/2026-10-10-what-can-you-do-with-one-embedding-for-text-images-and-audio.md @@ -0,0 +1,22 @@ +--- +title: "What can you do with one embedding for text, images and audio?" +description: "EmbeddingGemma 2 puts every kind of input in the same 768 numbers, and you can throw two-thirds of them away." +topic: embeddings +--- +Google released EmbeddingGemma 2 this week, and the headline is that it puts text, code, images, video and audio "in a shared embedding space". What does that let you build? + +Quick refresher: an embedding model turns an input into a list of numbers, here 768 of them, so that similar things get similar lists. Search then means "find the stored lists closest to my query's list". With a text-only model like the first EmbeddingGemma, images needed a second model, and the two sets of numbers couldn't be compared. + +[EmbeddingGemma 2](https://deepmind.google/blog/embeddinggemma-2-an-open-lightweight-multimodal-embedding-model/) sends everything into the same 768 numbers. A text query can find a photo or an audio clip directly. [MindStudio tried this](https://www.mindstudio.ai/blog/run-embeddinggemma-2-locally) with six images and six text queries, and every query found its image, including "something delicious" matching a picture of curry. The [model card](https://huggingface.co/google/embeddinggemma-2) has an example: one product listing with text, two photos and a video becomes a single vector you can compare against "waterproof shoes for trail running". + +It's built in pieces: a 270M text model, plus vision and audio encoders you only load if you need them. Quantized and text-only, it needs about 191MB of RAM on a Pixel phone. On text alone it scores about the same as the first EmbeddingGemma (61.36 against 61.15); the jump is on code (78.68, up from 68.76). + +The other trick is Matryoshka embeddings. The model is trained to pack the most important information into the first numbers, so you can keep only the first 256 and store a third as much. The card's table shows the cost: down to 256, scores barely move. At 128, text holds up but the multimodal score drops from 59 to 46. MindStudio's six image matches stayed the same at 256. + +The model card has two warnings I'd have missed. After you cut a vector down, you have to re-normalize it, or rankings get worse with no error. And don't run it in float16: you get NaNs or quietly broken vectors. + +[Simon Willison pointed out](https://simonwillison.net/2026/Oct/6/hn-49983751/) why an open license like Apache 2.0 matters especially for embeddings: you might store millions of vectors, and if a hosted model is retired you have to pay to recompute all of them. Open weights mean you can always run the old one. + +Two things I'm unsure about. The card also says deployments must follow Gemma's Prohibited Use Policy, and I don't know how that sits next to Apache 2.0. And nobody I read has tried audio search on real recordings yet. + +If you have a folder of photos or screenshots you can't search, it's a small model to try: embed them and a few text queries at 256 dimensions, and see what comes back. diff --git a/frontend/content/blog/2026-10-10-what-is-a-decision-model-and-why-did-everyone-ship-one.md b/frontend/content/blog/2026-10-10-what-is-a-decision-model-and-why-did-everyone-ship-one.md new file mode 100644 index 00000000..4ea77701 --- /dev/null +++ b/frontend/content/blog/2026-10-10-what-is-a-decision-model-and-why-did-everyone-ship-one.md @@ -0,0 +1,18 @@ +--- +title: "What is a decision model, and why did everyone ship one?" +description: "OpenAI and Cloudflare both shipped models that answer with a probability instead of text. Here's what that buys you." +topic: decision-models +--- +This week OpenAI put its Decisions API into public beta, and Cloudflare released its Clef models. Both are "decision models", and at first I couldn't see how that's different from asking an LLM to reply in JSON. + +Here's the idea, as [Simon Willison explained it](https://simonwillison.net/2026/Sep/21/jev) when TypeSafe's Jev started the trend in September. You send some text (the "state") and a few typed questions. The model never writes text back. It returns numbers: the probability that a statement is true, a probability for each option in a list, or a score on a scale you define. You pay for input only. + +[Cloudflare's post](https://blog.cloudflare.com/clef-faster-cheaper-multimodal/) shows how one is built. Clef starts from a Qwen model, freezes it, and trains small adapters on top. Instead of generating tokens, it reads the input once and scores every allowed option. The new Clef-omni answers text questions in about 130 ms and takes audio and video too, and [the weights are on Hugging Face](https://huggingface.co/Cloudflare/clef) under Apache 2.0. [OpenAI's version](https://community.openai.com/t/decisions-api-is-now-available-in-public-beta/1403877) runs on GPT-6 Luna and costs $0.10 per million input tokens, with no output charge. + +[OpenRouter's comparison](https://openrouter.ai/blog/tutorials/jev-vs-llm-as-a-judge) of Jev with an LLM judge shows why the numbers matter. Asked whether an answer stuck to a passage, the LLM wrote `{"acceptable": false, "confidence": 1}`. Jev returned `0.01`. Both were right about as often (84 and 83 of 88), but every LLM verdict landed near 0 or near 1. Jev put its unsure cases in the middle, and those are the ones you can send to a person. It was also a fifth of the price and a tenth of the latency. On a harder task, checking long summaries against the facts, the LLM judge tracked human experts much better (0.72 against 0.47). + +Not everyone is convinced. A [Red Hat benchmark](https://news.ycombinator.com/item?id=49933476) that drew 166 points on Hacker News argued decision models don't reliably beat LLM judges or trained classifiers. In OpenAI's forum thread, one tester found Jev more accurate than the Decisions API on judgment calls, with Luna making about three times as many confident wrong answers. Another simulated a coin that lands heads 70% of the time. Asked as a yes/no question, the API said heads 70% of the time. Asked as a choice, 98%. Their advice: skip choice questions for now. + +Simon's worry from September still stands: you get a number and no reason. And OpenAI's forum is still asking how its version works inside. + +If you have an LLM call that's really a yes/no or a pick-one, OpenRouter's recipe is a good start: label 50 to 100 real examples, send them to both, and check whether the cases scored 0.9 are right about 90% of the time. diff --git a/frontend/scripts/prerender-blog.ts b/frontend/scripts/prerender-blog.ts index 68069245..ce1ba608 100644 --- a/frontend/scripts/prerender-blog.ts +++ b/frontend/scripts/prerender-blog.ts @@ -499,21 +499,20 @@ ${g.posts

Archive

- ${posts.length} issues + ${posts.length} posts
${groups}
` : "" const main = `
-

The weekly issue

-

One AI topic a week, explained plainly.

-

Each issue takes the most talked-about topic in the ${SITE_NAME} feed and explains what it is, how it works, and what the people who tried it found.

${latestBlock}${archive} +

AI news for builders.

+

Connecting the pieces together.

${latestBlock}${archive}
` return shell({ - title: "Blog", - description: `One AI topic a week, explained plainly, from the ${SITE_NAME} feed.`, + title: "AI news for builders, connecting the pieces", + description: "AI news for builders. Connecting the pieces together.", canonicalPath: "/blog/", cssHrefs, ogType: "website", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 09a553fb..515e4430 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -863,4 +863,73 @@ export const ValidationErrorSchema = { type: 'object', required: ['loc', 'msg', 'type'], title: 'ValidationError' +} as const; + +export const VoiceSchema = { + properties: { + slug: { + type: 'string', + title: 'Slug' + }, + name: { + type: 'string', + title: 'Name' + }, + count: { + type: 'integer', + title: 'Count' + }, + scores: { + items: { + type: 'integer' + }, + type: 'array', + title: 'Scores' + }, + best: { + '$ref': '#/components/schemas/VoiceArticle' + } + }, + type: 'object', + required: ['slug', 'name', 'count', 'scores', 'best'], + title: 'Voice' +} as const; + +export const VoiceArticleSchema = { + properties: { + title: { + type: 'string', + title: 'Title' + }, + url: { + type: 'string', + title: 'Url' + }, + score: { + type: 'integer', + title: 'Score' + }, + published_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Published At' + } + }, + type: 'object', + required: ['title', 'url', 'score'], + title: 'VoiceArticle' +} as const; + +export const VoiceKindSchema = { + type: 'string', + enum: ['individual', 'lab', 'company'], + title: 'VoiceKind', + description: "The Voices panel's tabs: Writers, Labs, Companies." } as const; \ No newline at end of file diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index a44fed79..6575849c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AnalyticsCollectEventData, AnalyticsCollectEventResponse, AnalyticsReadReportData, AnalyticsReadReportResponse, ArticlesReadArticlesData, ArticlesReadArticlesResponse, ArticlesSearchArticlesData, ArticlesSearchArticlesResponse, ArticlesArticleFacetsData, ArticlesArticleFacetsResponse, ArticlesArticleOriginsData, ArticlesArticleOriginsResponse, ArticlesSearchPublishersData, ArticlesSearchPublishersResponse, ArticlesSearchTagsData, ArticlesSearchTagsResponse, ArticlesArticleStatsResponse, LikesLikeArticleData, LikesLikeArticleResponse, LikesUnlikeArticleData, LikesUnlikeArticleResponse, LikesReadLikedArticlesResponse, LoginRequestSignInLinkData, LoginRequestSignInLinkResponse, LoginLoginWithLinkData, LoginLoginWithLinkResponse, LoginRefreshTokenResponse, LoginTestTokenResponse, StoriesReadStoriesData, StoriesReadStoriesResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { AnalyticsCollectEventData, AnalyticsCollectEventResponse, AnalyticsReadReportData, AnalyticsReadReportResponse, ArticlesReadArticlesData, ArticlesReadArticlesResponse, ArticlesSearchArticlesData, ArticlesSearchArticlesResponse, ArticlesArticleFacetsData, ArticlesArticleFacetsResponse, ArticlesArticleOriginsData, ArticlesArticleOriginsResponse, ArticlesArticleVoicesData, ArticlesArticleVoicesResponse, ArticlesSearchPublishersData, ArticlesSearchPublishersResponse, ArticlesSearchTagsData, ArticlesSearchTagsResponse, ArticlesArticleStatsResponse, LikesLikeArticleData, LikesLikeArticleResponse, LikesUnlikeArticleData, LikesUnlikeArticleResponse, LikesReadLikedArticlesResponse, LoginRequestSignInLinkData, LoginRequestSignInLinkResponse, LoginLoginWithLinkData, LoginLoginWithLinkResponse, LoginRefreshTokenResponse, LoginTestTokenResponse, StoriesReadStoriesData, StoriesReadStoriesResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UtilsHealthCheckResponse } from './types.gen'; export class AnalyticsService { /** @@ -150,6 +150,30 @@ export class ArticlesService { }); } + /** + * Article Voices + * @param data The data for the request. + * @param data.kind + * @param data.since + * @param data.limit + * @returns Voice Successful Response + * @throws ApiError + */ + public static articleVoices(data: ArticlesArticleVoicesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/articles/voices', + query: { + kind: data.kind, + since: data.since, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Search Publishers * @param data The data for the request. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 1930c1b9..badc9e57 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -202,6 +202,26 @@ export type ValidationError = { }; }; +export type Voice = { + slug: string; + name: string; + count: number; + scores: Array<(number)>; + best: VoiceArticle; +}; + +export type VoiceArticle = { + title: string; + url: string; + score: number; + published_at?: (string | null); +}; + +/** + * The Voices panel's tabs: Writers, Labs, Companies. + */ +export type VoiceKind = 'individual' | 'lab' | 'company'; + export type AnalyticsCollectEventData = { requestBody: AnalyticsEventCreate; }; @@ -247,6 +267,14 @@ export type ArticlesArticleOriginsData = { export type ArticlesArticleOriginsResponse = (OriginCounts); +export type ArticlesArticleVoicesData = { + kind: VoiceKind; + limit?: number; + since: string; +}; + +export type ArticlesArticleVoicesResponse = (Array); + export type ArticlesSearchPublishersData = { limit?: number; q?: (string | null); diff --git a/frontend/src/components/Articles/ArticlesList.tsx b/frontend/src/components/Articles/ArticlesList.tsx index 0b91243a..c8d15e7f 100644 --- a/frontend/src/components/Articles/ArticlesList.tsx +++ b/frontend/src/components/Articles/ArticlesList.tsx @@ -34,7 +34,7 @@ export function cutoffIso(days: number): string { export function ArticlesList() { const { filters, setFilter } = useFilters() - const { search, dateRange, sort, origin, topic } = filters + const { search, dateRange, sort, origin, topic, publisher } = filters const topicDef = findTopic(topic) const since = cutoffIso(PUBLISHED_DAYS[dateRange] ?? 7) @@ -44,7 +44,7 @@ export function ArticlesList() { const { data, isLoading, isFetching, isError } = useQuery({ queryKey: search ? ["articles", "search", search] - : ["articles", dateRange, sort, origin, topic], + : ["articles", dateRange, sort, origin, topic, publisher], queryFn: () => search ? ArticlesService.searchArticles({ q: search, limit: 50 }) @@ -55,6 +55,7 @@ export function ArticlesList() { origin: (origin || undefined) as Origin | undefined, tag: topicDef?.tags, kind: topicDef?.kinds, + publisher: publisher || undefined, }), placeholderData: keepPreviousData, }) diff --git a/frontend/src/components/Articles/FeedFilters.tsx b/frontend/src/components/Articles/FeedFilters.tsx index 9f8ee67c..88b6cff9 100644 --- a/frontend/src/components/Articles/FeedFilters.tsx +++ b/frontend/src/components/Articles/FeedFilters.tsx @@ -44,7 +44,9 @@ export function FeedFilters() { options={ORIGIN_OPTIONS} value={filters.origin} onChange={(v) => setFilter("origin", v)} - /> + > + {filters.publisher && } + { + setFilter("publisher", "") + setFilter("publisherName", "") + }} + className="ml-1.5 flex items-center gap-1 border px-1.5 py-0.5 text-[13px] leading-4 transition-colors hover:border-foreground max-sm:py-1.5" + > + Publisher: + {name} + + + ) +} + function SearchInput() { const { filters, setFilter } = useFilters() const [localSearch, setLocalSearch] = useState(filters.search) diff --git a/frontend/src/components/Rails/RailsProvider.tsx b/frontend/src/components/Rails/RailsProvider.tsx index 1528cb14..c841d781 100644 --- a/frontend/src/components/Rails/RailsProvider.tsx +++ b/frontend/src/components/Rails/RailsProvider.tsx @@ -10,7 +10,13 @@ import { useSyncExternalStore, } from "react" import { trackEvent } from "@/lib/analytics" -import { LEFT, type RailPanels, RIGHT, type Side } from "./panels" +import { + LEFT, + type RailPanelDef, + type RailPanels, + RIGHT, + type Side, +} from "./panels" import { STORIES_PANEL_ID, useHasStories } from "./panels/Stories" /** @@ -132,12 +138,15 @@ export function RailsProvider({ // Below wide, both rails are one sheet behind one button: the right rail's // panels follow the left's. const panels = useMemo(() => { - const right = hasStories - ? registered.right - : registered.right.filter((p) => p.id !== STORIES_PANEL_ID) + // Stories can be registered on either side; with nothing to show it is + // left out so its side can go empty and lose its rail and button. + const keep = (side: RailPanelDef[]) => + hasStories ? side : side.filter((p) => p.id !== STORIES_PANEL_ID) + const left = keep(registered.left) + const right = keep(registered.right) return mode === "narrow" - ? { left: [...registered.left, ...right], right: [] } - : { left: registered.left, right } + ? { left: [...left, ...right], right: [] } + : { left, right } }, [registered, hasStories, mode]) const [stored, setStored] = useState(load) // Below the wide breakpoint a rail is a temporary layer over the feed, so diff --git a/frontend/src/components/Rails/panels.tsx b/frontend/src/components/Rails/panels.tsx index 20abb4e4..27901a1f 100644 --- a/frontend/src/components/Rails/panels.tsx +++ b/frontend/src/components/Rails/panels.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from "react" import { ReaderPicks } from "./panels/ReaderPicks" import { STORIES_PANEL_ID, Stories } from "./panels/Stories" -import { TopicMap } from "./panels/TopicMap" +import { Voices } from "./panels/Voices" import { WhoWrote } from "./panels/WhoWrote" export type Side = "left" | "right" @@ -26,11 +26,7 @@ export const LEFT: RailPanelDef[] = [ { id: "who-wrote", title: "Who wrote the wire", Component: WhoWrote }, ] export const RIGHT: RailPanelDef[] = [ - { - id: "topic-map", - title: "Topic map", - Component: TopicMap, - }, + { id: "voices", title: "Voices", Component: Voices }, { id: "reader-picks", title: "Reader picks", diff --git a/frontend/src/components/Rails/panels/Stories.tsx b/frontend/src/components/Rails/panels/Stories.tsx index 49bff517..80fe06a2 100644 --- a/frontend/src/components/Rails/panels/Stories.tsx +++ b/frontend/src/components/Rails/panels/Stories.tsx @@ -91,17 +91,8 @@ function StoryRow({ className="flex w-full items-start gap-3 py-3 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" > - - - {story.name} - - {story.grew_today && ( - - )} + + {story.name} diff --git a/frontend/src/components/Rails/panels/Voices.tsx b/frontend/src/components/Rails/panels/Voices.tsx new file mode 100644 index 00000000..13120d29 --- /dev/null +++ b/frontend/src/components/Rails/panels/Voices.tsx @@ -0,0 +1,127 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import { useState } from "react" +import { ArticlesService, type Voice, type VoiceKind } from "@/client" +import { cutoffIso } from "@/components/Articles/ArticlesList" +import { FilterOption } from "@/components/Articles/FilterOption" +import { useFilters } from "@/context/filters" +import { trackEvent } from "@/lib/analytics" +import { cn } from "@/lib/utils" +import { ScoreBars } from "../ScoreMarks" + +// Fixed, not the feed's window: over 3 days most names have one article or +// none, and a row of bars says nothing. +const WINDOW_DAYS = 30 + +const TABS: { value: VoiceKind; label: string }[] = [ + { value: "individual", label: "Writers" }, + { value: "lab", label: "Labs" }, + { value: "company", label: "Companies" }, +] + +/** Who writes the wire: each name with a bar per article, oldest to newest, + * and its best one. A row filters the feed to that publisher. */ +export function Voices() { + const { filters, setFilter } = useFilters() + const { publisher, search } = filters + const [kind, setKind] = useState("individual") + + const { data, isError } = useQuery({ + queryKey: ["articles", "voices", kind], + queryFn: () => + ArticlesService.articleVoices({ kind, since: cutoffIso(WINDOW_DAYS) }), + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, + }) + if (isError || !data) return null + + function pick(voice: Voice) { + if (voice.slug === publisher) { + setFilter("publisher", "") + setFilter("publisherName", "") + return + } + trackEvent("voices_pick", { publisher: voice.slug, kind }) + setFilter("publisher", voice.slug) + setFilter("publisherName", voice.name) + // The feed shows what the row counted: the same month, and for a writer + // posts only (Writers is that From option), not their repos and models. + setFilter("dateRange", "1m") + setFilter("origin", kind === "individual" ? "individual" : "") + } + + return ( + // A search is all-time and ignores the publisher; greyed out like the + // filters. +
+ {/* biome-ignore lint/a11y/useSemanticElements: a fieldset's default styling fights the flex row */} +
+ {TABS.map((t) => ( + setKind(t.value)} + /> + ))} +
+ {data.length === 0 ? ( +

Nobody here in the last 30 days.

+ ) : ( +
    + {data.map((v) => ( +
  • + pick(v)} + /> +
  • + ))} +
+ )} +
+ ) +} + +function Row({ + voice, + active, + onPick, +}: { + voice: Voice + active: boolean + onPick: () => void +}) { + return ( + + ) +} diff --git a/frontend/src/context/filters.tsx b/frontend/src/context/filters.tsx index 4eee7041..90db57cf 100644 --- a/frontend/src/context/filters.tsx +++ b/frontend/src/context/filters.tsx @@ -8,6 +8,10 @@ export type Filters = { origin: string /** A slug from `TOPICS`. */ topic: string + /** A publisher slug, set from the Voices panel. */ + publisher: string + /** That publisher's name, for the chip that clears it. */ + publisherName: string } type FiltersContextType = { @@ -24,6 +28,8 @@ export function FiltersProvider({ children }: { children: ReactNode }) { sort: "published_at-desc", origin: "", topic: "", + publisher: "", + publisherName: "", }) const setFilter = (key: keyof Filters, value: string) => { diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 93b8c2f6..f49c9577 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -56,6 +56,13 @@ function SiteHeader() { wide={wide} nav={ <> + {/* /blog/ is prerendered outside the router, so a plain anchor. */} + + Blog + diff --git a/frontend/tests/rails.ts b/frontend/tests/rails.ts index 01e7265a..103e3333 100644 --- a/frontend/tests/rails.ts +++ b/frontend/tests/rails.ts @@ -7,7 +7,12 @@ export async function openAllPanels(page: Page) { localStorage.setItem( "agentique.rails.v2", JSON.stringify({ - open: { "who-wrote": true, "topic-map": true, "reader-picks": true }, + open: { + "who-wrote": true, + voices: true, + "topic-map": true, + "reader-picks": true, + }, }), ) }) diff --git a/frontend/tests/voices.spec.ts b/frontend/tests/voices.spec.ts new file mode 100644 index 00000000..65609dbb --- /dev/null +++ b/frontend/tests/voices.spec.ts @@ -0,0 +1,174 @@ +import { expect, type Page, test } from "@playwright/test" +import { openAllPanels } from "./rails" + +// Wide enough that the rails sit inline beside the feed, always open. +test.use({ viewport: { width: 1440, height: 900 } }) + +test.beforeEach(({ page }) => openAllPanels(page)) + +function voice(slug: string, name: string, count: number) { + return { + slug, + name, + count, + scores: Array.from({ length: count }, (_, i) => 60 + i * 5), + best: { + title: `Best of ${name}`, + url: `https://example.com/${slug}`, + score: 85, + published_at: new Date().toISOString(), + }, + } +} + +const VOICES: Record[]> = { + individual: [ + voice("simon-willison", "Simon Willison", 5), + voice("hamel-husain", "Hamel Husain", 2), + ], + lab: [voice("anthropic", "Anthropic", 4)], + company: [], +} + +function article(id: number) { + return { + id, + title: `Article by Simon ${id}`, + url: `https://example.com/simon/${id}`, + summary: null, + score: 70, + kind: "post", + found_via: null, + published_at: new Date(Date.now() - id * 864e5).toISOString(), + created_at: new Date().toISOString(), + publisher: { + id: 1, + slug: "simon-willison", + name: "Simon Willison", + kind: "individual", + }, + tags: [], + like_count: 0, + liked_by_me: false, + } +} + +async function serveVoices(page: Page) { + await page.route( + (url) => url.pathname === "/api/v1/articles/voices", + (route) => { + const kind = new URL(route.request().url()).searchParams.get("kind") + return route.fulfill({ + contentType: "application/json", + body: JSON.stringify(VOICES[kind ?? ""] ?? []), + }) + }, + ) +} + +// Only the feed's request for this publisher; everything else hits the backend. +async function servePublisherFeed(page: Page) { + const data = [article(1), article(2)] + await page.route( + (url) => + url.pathname === "/api/v1/articles/" && + url.searchParams.get("publisher") === "simon-willison", + (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ data, count: data.length }), + }), + ) +} + +function panel(page: Page) { + return page.locator('[data-panel="voices"]') +} + +test("tabs switch the list", async ({ page }) => { + await serveVoices(page) + await page.goto("/") + const tabs = panel(page).getByRole("group", { name: "Voices" }) + const names = panel(page).getByTestId("voice-name") + + await expect(tabs.getByRole("button", { name: "Writers" })).toHaveAttribute( + "aria-pressed", + "true", + ) + await expect(names).toHaveText(["Simon Willison", "Hamel Husain"]) + await expect(panel(page).getByTestId("voice").first()).toContainText( + "Best of Simon Willison", + ) + + await tabs.getByRole("button", { name: "Labs" }).click() + await expect(names).toHaveText(["Anthropic"]) + + await tabs.getByRole("button", { name: "Companies" }).click() + await expect(names).toHaveCount(0) + await expect(panel(page)).toContainText("Nobody here") +}) + +test("clicking a name narrows the feed to that publisher, and the chip clears it", async ({ + page, +}) => { + await serveVoices(page) + await servePublisherFeed(page) + await page.goto("/") + await expect(page.getByTestId("article-row").first()).toBeVisible() + + const row = panel(page) + .getByTestId("voice") + .filter({ hasText: "Simon Willison" }) + const narrowed = page.waitForRequest( + (r) => + r.url().includes("/api/v1/articles/?") && + r.url().includes("publisher=simon-willison"), + ) + await row.click() + await narrowed + + await expect(row).toHaveAttribute("aria-pressed", "true") + // A writer's count is posts only, so the feed narrows to Writers too. + await expect( + page + .getByRole("group", { name: "From" }) + .getByRole("button", { name: "Writers" }), + ).toHaveAttribute("aria-pressed", "true") + await expect(page.getByTestId("article-row")).toHaveCount(2) + await expect(page.getByTestId("article-row").first()).toContainText( + "Article by Simon 1", + ) + const chip = page.getByTestId("publisher-chip") + await expect(chip).toHaveText(/Publisher:\s*Simon Willison/) + + await chip.click() + await expect(chip).toHaveCount(0) + await expect(row).toHaveAttribute("aria-pressed", "false") + await expect(page.getByText("Article by Simon 1")).toHaveCount(0) +}) + +test("clicking the active name clears the filter", async ({ page }) => { + await serveVoices(page) + await servePublisherFeed(page) + await page.goto("/") + + const row = panel(page) + .getByTestId("voice") + .filter({ hasText: "Simon Willison" }) + await row.click() + await expect(page.getByTestId("publisher-chip")).toBeVisible() + + await row.click() + await expect(page.getByTestId("publisher-chip")).toHaveCount(0) + await expect(row).toHaveAttribute("aria-pressed", "false") +}) + +test("a failed fetch hides the panel", async ({ page }) => { + await page.route( + (url) => url.pathname === "/api/v1/articles/voices", + (route) => route.fulfill({ status: 500, body: "{}" }), + ) + await page.goto("/") + await expect(page.getByTestId("article-row").first()).toBeVisible() + await expect(panel(page)).toBeHidden() +})