Equinox Haber is an editorial-first, AI-assisted Turkish news publishing system for haber.sametbasbug.dev.
The project explores a practical question: can a small public news surface stay fast, readable, transparent, and auditable when software handles ingestion, deduplication, queueing, audits, image generation, static builds, and deployment discipline — while an editorial agent/human-in-the-loop keeps the final news judgment?
The reusable part is the workflow. The live output is a global-focused Turkish news stream.
If you want to inspect the reusable workflow layer without touching providers or production data:
python3 -m venv newsroom/.venv
newsroom/.venv/bin/pip install -e "newsroom[test]"
npm install
npm run qualitynpm run quality runs the newsroom test suite and an Astro build. The tests
never reach the network and never call a provider.
This repository is intentionally split into two layers:
- Source code and workflow tooling are licensed under AGPL-3.0. This
includes the Astro site, the
newsroompackage, its CLI, gates, tests, and the build/deploy workflow. - Editorial content, generated/published images, brand identity, and
protected media are not part of that grant. See
CONTENT_LICENSE.md.
In short: the system can be studied, reused, forked, and adapted — provided changes stay open, including when run as a network service; the published news archive and brand layer are not open content.
Most small publishing projects eventually hit the same maintenance wall:
- collecting sources is easy, but selecting responsibly is hard;
- automation is fast, but blind autopublish is risky;
- duplicate stories and repeated angles quietly lower quality;
- AI-generated drafts can help, but they must not become the editor;
- image generation needs strict size/licensing/visual guardrails;
- publishing needs boring safeguards: audits, build checks, narrow commits, CI, and rollback-friendly history.
Equinox Haber is a working experiment around those constraints.
Many maintainers now have to review AI-assisted changes, generated summaries, bot-authored PRs, or automated release notes. The hard part is not calling a model; it is keeping the workflow auditable when automation touches public output.
This repo is useful as a small, concrete reference for:
- separating ingestion/automation from final editorial authority;
- keeping provider-dependent steps out of CI;
- writing tests for stale-source, duplicate, manual-review, image, and leak-prevention gates;
- documenting where automation must stop and a human/maintainer must decide;
- preserving a clear license boundary between reusable tooling and protected published content.
The current live site is a standalone GitHub Pages news surface at haber.sametbasbug.dev.
Key routes:
/— live news homepage with category tabs and automatic featured signals/?kategori=siyaset,/?kategori=ekonomi,/?kategori=teknoloji,/?kategori=bilim— category-filtered homepage views/<slug>/— article pages with canonical news metadata/sayfa/<n>/— paginated public archive/icerik-paneli/— expanded content-panel stream/rss.xml— RSS feed for the latest public items/news-sitemap.xml— Google News sitemap for the recent publication window/sitemap-index.xml— sitemap index generated by Astro
Transparency and policy pages:
/hakkimizda//yazarlar//iletisim//editorial-ilkeler//duzeltme-politikasi//yapay-zeka-ve-yayin-sureci//gizlilik-politikasi/
The header is intentionally category-focused. Trust, policy, RSS, and ecosystem links live in the footer so the news reading surface stays clean.
The homepage has two editorial layers:
- Canlı akış — the main chronological stream.
- Öne çıkanlar / Akıştan seçilenler — an automatic signal block, not a fake manual editor pick.
The automatic featured model scores recent items using recency, source count, breaking/global signal terms, and optional editorPick as a boost. Old manual flags are penalized so stale stories do not stay pinned forever.
When a category tab is active, the side block becomes category-specific, for example Teknoloji akışından, and keeps its items inside that category. In the default Tümü view, it prefers category diversity.
The repository has two main parts:
- Astro publishing surface — static news pages, category UI, article templates, RSS, Google News sitemap, sitemap index, transparency pages, author/site shell, and GitHub Pages deployment.
newsroom, the Python rail — RSS collection, mechanical screening, candidate board, response contract, Turkish language measurement, markdown generation, hero normalization with a stock fallback, audits, and a publish step that rolls back rather than leave a half-finished story.
haber-project/
src/
components/news/ # News UI components
content/equinoxHaber/ # Published markdown news items
data/ # Site/category helpers
layouts/ # News/article/info page shells
pages/ # Astro routes, RSS, news sitemap
public/images/generated/ # Generated hero images
newsroom/
POLICY.md # The single editorial source of truth
newsroom/
ingest.py # RSS collection
screen.py # Mechanical eligibility gates
brief.py # Candidate board for the editorial agent
accept.py # Response contract validation
lang.py # Turkish/untranslated measurement
publish.py # Markdown rendering and slugs
hero.py # Hero normalization and stock fallback
cycle.py # prepare / publish orchestration
docs/ # Runbook and decision log
tests/ # Suite plus the corpus it measures against
data/ # Runtime state, git-ignored
A human's agent can publish on that human's behalf, and no key ever reaches the agent. Orbit holds the permission, signs a 60-second document per call, and delivers it to Haber itself. Revocation is one switch in the Orbit dashboard: turn agent access off and the next call is refused.
- Haber declares what an agent can do in
public/orbit-actions.json. Orbit reads that file and caches it for ten minutes; adding an operation needs no change on the Orbit side. - Orbit posts to
/api/orbit-eylemwith an ES256 document that carries the human's pairwise subject, the acting agent (act, RFC 8693) and the operation. Haber verifies it against Orbit's JWKS. - Permission to publish is still Haber's decision, not Orbit's. The
publishersrow must name the agent and the human it acts for; the byline comes from that row, never from the request body.
Six operations exist today, in two permission tiers.
Writing and internal state need a publishers row: haber.panoYaz pins the
candidate board, haber.panoOku reads it back (an agent that loses the
briefId between turns would otherwise strand a board for six hours),
haber.yayinla publishes one story from it, and
haber.yayindanKaldir / haber.yayinaAlGeri withdraw and restore one.
Reading the archive does not: haber.yayinlariOku is open to the agent of
anyone who has connected Haber in Orbit and opened agent access. It is not the
open web — the request still carries an Orbit-signed document — but it needs no
publisher row.
That operation is deliberately not an RSS clone. The feed stays exactly as
it is, public and unchanged; the Orbit path simply reaches further. RSS gives
the latest stories. haber.yayinlariOku gives the whole archive with search,
tag, category and date filters, pagination, and the article body on request.
The advantage of holding an Orbit account is built by adding, not by
withholding.
Withdrawal is deliberately narrower than publishing. It hides rather than deletes, requires a written reason, and only applies to stories published in the last 24 hours: the case it exists for is "we just published something wrong". Anything older is a human decision. A withdrawn address returns 410 Gone with a short page rather than a silent 404 — it was live, it may have been shared, and pretending it never existed would be the wrong answer to both readers and crawlers.
Publishers can see the trail at /denetim: recent actions, refusals,
withdrawals. Refused attempts reach the calling agent as an opaque error —
Orbit does not carry a site's error body — so this page is the only place the
refusal is legible.
The full contract lives in orbit-project/docs/baglisite-ajan-eylemleri.md.
Granting an agent is one row. subject is the Orbit agent id prefixed with
agent:, acts_for is the human's pairwise subject as Haber knows it (the
readers.orbit_subject written at sign-in), and author must be one of the
supported bylines:
INSERT INTO publishers (subject, acts_for, author, may_write_brief, may_publish, created_at)
VALUES ('agent:<orbit agent id>', '<human pairwise subject>', 'Selene AI', 1, 1, datetime('now'));The older publisher-key path (hbr_pub_v1_…) still works and is intentionally
left open during the transition. Closing it means clearing
publishers.key_digest — one visible, reversible step.
The project is editorial-first.
Python is not the editor. The split is deliberate and it is the one rule the whole system is built from:
Python does only what can be verified. Judgement lives in
POLICY.md.
If a rule is decidable — is this text Turkish, is the source older than 24
hours, does this slug already exist, does the hero file actually exist — it
lives in code and is not negotiable. If a rule needs judgement — is this story
worth publishing, which source is more trustworthy, is this paragraph carrying
its weight — it lives in newsroom/POLICY.md and the editorial agent applies
it. No rule is written in both places.
Asteria AI is the narrow editorial agent. Each cycle it receives one brief — candidates with their extracted source text, plus the distribution of recent publications — reads the policy from disk, and returns a JSON response. The rail validates that response against a contract and carries it into the Astro site, or rejects it and publishes nothing.
Current category set:
- Siyaset
- Ekonomi
- Teknoloji
- Bilim
Turkey-related stories are included only when the global context is strong enough for one of those categories.
One cycle is three steps.
# 1. Collect, screen, and build the candidate board
newsroom/.venv/bin/newsroom prepare
# 2. The editorial agent reads POLICY.md and the brief, then writes a response
# (no command here — this step is the editorial work)
# 3. Validate the response and carry it into the site
newsroom/.venv/bin/newsroom publish --response response.jsonprepare writes the full brief to newsroom/data/current-brief.json. Extracted
source text travels inside the brief, so the editorial step does not need to
fetch article pages itself.
publish validates against the acceptance contract, renders the markdown,
resolves the hero, runs the audits and the Astro build, and commits a narrow
path set. It never pushes. If any gate fails, the written file and any
generated image are removed and nothing is committed — a cycle either lands
completely or leaves no trace.
Selecting nothing is a valid outcome, not a failure (POLICY.md §7).
Every gate here is mechanical. None of them encodes an editorial preference — that separation is the point.
Before a candidate reaches the board:
- source age, with future-dating and undated feeds both rejected;
- liveblog and sponsored-path formats;
- duplicate URL or near-duplicate headline against everything published;
- paywalled pages, detected by how little text they yield rather than by a host list.
Before a response is published:
- schema and contract completeness;
- Turkish body measurement by density, not word count, so length does not bias the result; proper-noun spans are excluded from the English signal;
- untranslated-headline detection against the source title;
- body-depth floor as a truncation guard, and a paragraph-count range;
- leaked internal notes and audit markers;
- frontmatter validity, sources section, category;
- hero file existence and
1200×675WebP under 400 KB; - working-tree scope: only that story's files may have changed;
- Astro build.
Thresholds are not guesses. They were chosen against a corpus of 585 published
articles and 976 source texts kept in newsroom/tests/corpus, and the suite
replays them on every run.
CI is provider-free: newsroom tests and an Astro build. It performs no collection, calls no AI provider, and runs no publish command.
The public release includes the surfaces expected by news/feed consumers:
rss.xmlis limited to the latest 50 items and uses absolute image enclosure URLs.news-sitemap.xmllists recently published items with Google News metadata.sitemap-index.xmlis generated for the static site.- Article pages include canonical URLs and structured news metadata.
- Public transparency pages explain ownership, authorship, corrections, privacy, editorial principles, and AI use.
Generated hero images are intentionally local and normalized:
- final path:
public/images/generated/equinox-haber/<slug>.webp - dimensions:
1200×675 - format:
WebP - default quality target:
82 - audit maximum:
400 KB
The generator writes a raw file wherever it likes; the rail then resizes, crops to centre, strips metadata, and writes the final WebP. Provider output dimensions never reach the site directly.
Resolution order, in full:
- an image already exists for this slug — never regenerated;
- a generated file was supplied — normalized;
- neither — a stock photo is fetched as a fallback;
- none of the above — the story publishes without a hero, on purpose.
Step four is deliberate. A missing image does not stop a story.
Two constraints worth stating plainly:
Publisher images are not reused. Source, RSS, and OG images are blocked outright. A photograph's licence cannot be determined by looking at it, and a gate that treats an unverifiable thing as verified is not a gate.
Stock searches look for the subject, not the event. A stock archive has a photograph of a fire in it, but it is not the fire in the story. A frame that reads as a news scene shows the reader something that did not happen — the same objection that rules out reusing the publisher's image. The search term asks for the fact, the setting, the object, the geography: the field, not the moment. When a story runs on stock, its alt text is omitted rather than reused, because the text was written for an image that was never made.
Requirements:
- Python 3.12+
- Node.js 24+
- npm
python3 -m venv newsroom/.venv
newsroom/.venv/bin/pip install -e "newsroom[test]"
npm installImageMagick (magick) is needed for hero normalization. Tests that require it
skip themselves when it is absent.
# Run the Astro dev server (static preview, no Worker bindings)
npm run dev
# Run the deployed Worker locally, with D1/R2 bindings and server routes.
# This also applies pending migrations to the LOCAL database — skipping that
# step leaves the local D1 empty and every page fails with
# "no such table: site_state".
npm run dev:ssr
# Build the static site
npm run build
# Run provider-free local quality checks
npm run quality
# Run the newsroom test suite
newsroom/.venv/bin/python -m pytest newsroom/tests
# Show cycle state between runs
newsroom/.venv/bin/newsroom statusUseful npm wrappers:
npm run news:prepare
npm run news:status
npm run test:newsroomnewsroom/POLICY.md— the editorial policy itself, in fullnewsroom/docs/RUNBOOK.md— operating the cyclenewsroom/docs/DECISIONS.md— decisions the code cannot explain, with their reasoningnewsroom/docs/GATE_INVENTORY.md— how each gate of the previous system was classified: mechanical, judgement, or droppeddocs/environment.md— documented public/optional environment variablesCONTRIBUTING.md— contribution guidelinesROADMAP.md— maintainability roadmapSECURITY.md— security reporting and scope
This is a working public project, not a polished framework package. It powers the live Equinox Haber site and contains real operational history.
The current maintenance priority is to keep the production surface boringly reliable:
- keep the category tabs, feeds, sitemap, and transparency pages current;
- keep judgement in
POLICY.mdand verification in Python, and never write the same rule in both; - keep generated images small and locally auditable;
- maintain provider-free CI checks;
- record decisions the code cannot explain in
newsroom/docs/DECISIONS.md; - keep this README as the top-level map whenever routes, feeds, gates, or release policy change.
- Code and workflow tooling:
GNU AGPL-3.0 - Content, images, media, and brand layer:
CONTENT_LICENSE.md - Third-party materials remain subject to their original owners and terms.