The Yung Foundation’s admin is bilingual: every title, summary and caption is written twice, Marathi first. In September 2026 it gained AI help — translation both ways, writing help, photo descriptions, gallery and news suggestions from the photo library — plus a real body editor, WhatsApp sharing, map links and a live preview. One rule sits under all of it: the AI drafts, an editor decides.
The site speaks Marathi first, to readers in the Satpuda hills around Dhadgaon and Shirpur, and English second. Almost every text field in the admin is a मराठी / English pair, and a small team fills them in after field work, often from a phone.
What piled up: English halves left empty, photos with no alt text (a screen reader gets nothing), newspaper cuttings on someone’s phone waiting to become news items, and a body editor that could not put a photo between two paragraphs.
A foundation’s site makes claims about real people and real numbers, and donors rely on them. So no AI result reaches the public site on its own. Five rules, each enforced in code rather than left to good behaviour:
Every result goes into the form, not the database, with one level of Undo. A reload throws it away.
Fifty photo descriptions or twelve clippings come back as a list with ticks. Nothing is written until it is ticked and saved.
Suggested categories, albums, gallery photos and news items are created unpublished.
The prompt forbids new facts, figures, names, dates, places and quotes. Code checks the ones a model writes anyway.
Quotes and figures are translate-only. Public inbox messages never reach a provider.
Ten use a model, four don’t. They all shipped together because each one leans on another: the gallery suggestions read the photo descriptions, the editor’s photos need the preview, and translation has to keep the editor’s photos in place.
Under every bilingual field: Draft English once the Marathi half has text, Draft Marathi once the English half does. Rich bodies translate as HTML and keep their photos where they were.
One Undo puts back the value from before the last AI change. Nothing is saved until the editor presses Save.
“AI writing help” opens under each field the AI may write: Write, Expand, Shorten, and an optional one-line note (“mention the Dhadgaon school”). The rest of the row is the context — only the tab’s own fields, read from the registry, so nothing else in the request can reach a provider.
The first real run: asked about a programme running “since 2019”, a Flash-Lite model wrote २०१५ in the Marathi and 2019 in the English. Now every draft is compared with its source, and a number that isn’t in the source is listed under the field: “check them before saving: 2015”. Devanagari digits become Latin digits, in code.
A new programme with only a title: Fill empty fields with AI drafts the summary, body and region in both languages from what the row already says. That is the “generate programme text” feature, and it works the same on blog posts, events and values.
Some words have to be the foundation’s own, or somebody else’s exactly. Five fields carry aiWrite: false: they show the translate buttons and nothing else, and the endpoint re-checks the registry rather than trusting the browser.
| Field | Why |
|---|---|
testimonials.quote | A beneficiary’s own words. An invented quote is a consent problem. |
key_facts.valueimpact_stats.label | Figures donors rely on. |
news_items.titlenews_items.outlet | Somebody else’s headline and masthead. |
The Page text tab is translate-only too. The Volunteer and Contact inboxes, the photo tab and the AI tab are refused outright, so a message from the public never reaches a provider.
Describe with AI in the photo library’s edit dialog drafts alt text (125 characters at most) and a caption in Marathi and English, and suggests up to three sections — Gallery, Program, Event and so on — added to whatever is already ticked.
Describe photos missing alt text takes up to fifty images that have none, describes them seven seconds apart — the free tier’s per-minute limit — and shows a review list with a counter and Stop.
A health-camp photo can be Gallery, Program and Event at once. The library’s single “Context” dropdown became a row of checkboxes over media.contexts text[], GIN-indexed for the filter’s @>. The old context column stays until the deployed code stops writing it; a trigger bridges the gap. no AI
Suggest from library with AI on the three gallery tabs proposes a category and album for each photo not yet in the gallery, and new categories and albums where the photos need them.
(album_id, category) key); slugs must match the CHECK.In News & media, Read a news clipping with AI reads the photo chosen as the item’s image — a cutting, or a screenshot of an article — and fills the fields that are still empty.
Real run, 29 September: two Marathi cuttings came back with headline, outlet (Divya Marathi, Sakal), a printed date and summaries in both languages; the third had no printed date and came back without one. One summary named “Prabodhan Foundation”. Misread or not, catching that is what the review is for.
Pull news from the library with AI reads photos one at a time — the News-section photos by default, or the whole library at one call a photo — and proposes a draft item for each real clipping.
Programme and blog bodies moved from a three-button contenteditable to Tiptap (ProseMirror): headings, lists, quotes, dividers, links, and library photos placed between paragraphs with a caption. Not an AI feature — but AI translation has to keep its photos where they are.
<figure data-media-id="143"> <figcaption>…</figcaption> </figure>
javascript: links are dropped, the text kept.In the Satpuda, a post travels by forward, not by feed. Every post now has a Share on WhatsApp button: a plain wa.me link, no script.
blog_posts.share_text) — bilingual, and draftable by the AI like any other field — or else the title and excerpt. The post’s link is appended, in the reader’s language.og:image comes from a post’s or programme’s cover, with its size and alt text, plus summary_large_image. That is what puts a picture in the chat preview.The Events tab takes the address Google Maps’ Share button gives. The events page shows Open in Google Maps ↗ under the location: a 44 px link that opens a new tab.
maps.app.goo.gl, goo.gl/maps, google.com and google.co.in /maps, maps.google.*. Plain http, a look-alike host, a search URL or javascript: comes back as events_map_url_check.Every content tab now says where its rows appear: “Appears on: /programs/youth-dialogue-camp — its own page · /programs — the list · / — home page cards”. See it on the site opens that real page in a frame, with unsaved text pushed in as you type and the edited row outlined, in Marathi or English.
?preview=1 works for signed-in writers only — the middleware asks is_writer(). A visitor who adds it gets the ordinary page: no markers, no script.private, no-store and noindex, and skip the analytics beacon.?lang= renders without writing the cookie.The AI tab picks the provider and model for every feature, plus an optional fallback tried once on 402, 429, 5xx, no answer or an empty answer. A refused key or a misspelt model is not retried: a second provider answering would hide the misconfiguration. Test calls what is typed, with no fallback.
One ai_usage row per attempt: who, task, tab, provider, model, tokens, a short failure code, the fallback flag. Cost is snapshotted at call time from the prices typed in settings, so a price edit never reprices history; the key runs on Gemini’s free tier. The monthly view is security_invoker, and only admins can read it.
The active model failed 59 of its 60 calls, nearly all rate limits (429). The fallback answered 58 times and carried the month.
| Model | Calls | Answered | Failed |
|---|---|---|---|
| gemini-3.8-flash (active) | 60 | 1 | 59 |
| gemini-3.5-flash-lite (fallback) | 59 | 58 | 1 |
| gemini-3.5-flash | 10 | 2 | 8 |
| AgentRouter · gemini-3.5-flash | 1 | 0 | 1 |
Totals: 130 calls, 69 failed attempts, 58 answered by the fallback; 67,330 tokens in, 7,805 out. By task: describe 76, translate 38, writing help 6, clipping 5, gallery 3, test 2.
Every AI feature posts to /api/admin/ai. It checks in a fixed order, runs the task on the active model, falls back once if it may, and logs every attempt. The provider layer is one plain switch over three fetch branches, with no SDK.
| Gemini | Anthropic | AgentRouter | |
|---|---|---|---|
| Auth | x-goog-api-key | x-api-key | Bearer |
| Call | :generateContent | /v1/messages | /chat/completions |
| JSON | response MIME type | prompt + loose parse | prompt + loose parse |
| Image | inlineData | base64 block | data URI |
Upstream error bodies never reach the browser: an editor sees a short code and the provider’s name. Gemini’s thinking tokens count as output, because they are billed as output.
Every rule below is written in the prompt, and a real run broke every one of them before code enforced it. /api/health runs a fixture through each.
Devanagari digits become Latin in every task.
२०१५ → 2015A number not in the source is listed under the field.
check before saving: 2015Marathi with another Indic script in it is dropped, so the field reads “needs translation”.
शिलीमুক্তA caption that repeats the alt text is dropped.
An album needs 2 photos and a category 3; a photo keeps its album’s category.
A clipping’s date only if printed, and never in the future.
AI output ends up in set:html on public pages, so sanitizeRich keeps only p h2 h3 ul ol li blockquote strong em, a with https or mailto, and the editor’s figure.
GEMINI_API_KEY, ANTHROPIC_API_KEY and AGENTROUTER_API_KEY are runtime secrets read through astro:env/server. The AgentRouter base URL is an environment variable too, not a setting: an admin-editable URL would let any admin send the key somewhere else. CI greps dist/ for AIza…, sk-… and AQ.… key shapes.
The old translate endpoint checked sign-in but not role, so a viewer could spend the key. The new one asks is_writer(), and an error there is a refusal.
On Gemini’s free tier, Google may use the text and photos sent to improve its products, and people may review them. The photos show beneficiaries, children among them. The owner accepted this on 29 September 2026; consent wording is flagged to the foundation, and the AI tab says it in plain words.
It serves only the coding tools it recognises and answered 401 unauthorized client. The site calls it with an honest user-agent: YungFoundationCMS/1.0 and won’t pretend to be anything else; the tab says so, and the fallback covers it.
gemini-2.5-flash answered 404 for a new key. The newer Flash models were overloaded (503), then rate-limited: 59 of 60 calls failed with 429.npm ci on npm 10 rejected it. Lockfiles are now written with npm 10.9.2, the build’s own npm, and dry-run before pushing.media.context, its index and the bridge trigger once the new code is deployed.