Skip to main content
← Blog

Just send count and language — letting AI fill the Public Library every night

VauDium ·

Fecit's Public Library shouldn't feel empty. We went from an admin batch endpoint that took a topics list, to an auto endpoint that only takes count and language, to a daily job that runs at 03:00 KST. The narrower the interface, the more we could hand over to the AI.

Just send count and language

If the Public Library stops getting new content, the feeling of liveness goes with it.

We already had a batch endpoint (/generate/using/ai/batch) where an admin would post a topics: [...] list from the console. Every run meant thinking up five good prompts. So it didn’t happen.

“Can we make a feature to put Overviews into the Public Library? It’s there in some form already — I want to automate the process.”

Looking at automation, it wasn’t one decision. It was three.

First — how much to hand to the AI

The first shape that came up was this:

“Admin registers a recommended topic pool. The scheduler periodically consumes N from it and generates Overviews.”

Who fills the pool? Eventually, a person.

Next shape:

“Even topic selection is up to the AI.”

That was the real automation. As long as topic stays in the interface, somebody has to fill it. Narrow the interface, and responsibility shifts to the AI.

The new ticket ended up like this:

class GenerateOverviewsAutoTicket(BaseModel):
    count: int
    language: Optional[str] = None

Only count and language. Category, point_color, and the topic itself — all decided by the AI.

Second — who calls it

Now we had the auto endpoint. Who presses it every night?

Three options:

  1. System cron + a script
  2. APScheduler embedded in the API server
  3. Register on the existing fecit/2/job server

We picked 3. There’s already an APScheduler-based job server running. No reason to split it into two places. Adding the job was one line:

job_server.register_cron(
    "auto_generate_public_overviews",
    auto_generate_public_overviews,
    hour=3, minute=0, second=0,
)

03:00 KST, every day. The library fills itself while we sleep.

Third — authentication

The original auto endpoint was guarded by @authorize_achiever() + @authorize_service_admin — meaning it needed an admin’s logged-in bearer token. The job server carrying that around is awkward.

The pattern already used by other jobs was the answer — x-api-key header.

@router.post("/generate/using/ai/auto", status_code=204)
@authorize_api_key(DEFAULT_API_KEY)
async def generate_overviews_auto(...):
    achiever = await load_auto_overview_owner()
    ...

load_auto_overview_owner() hardcodes the achiever id to use as owner — the caller doesn’t need to declare “who they are”. Owner is fixed at the system level.

The prompt that handed over topics was interesting

The core of the topic suggester was this:

excludeTitles: [200 most recent titles in the same language]

“Don’t semantically overlap with what’s already there.” That single rule is enough for the AI to find fresh subjects. Forbidding paraphrases of the same activity adds even more variety.

And one more thing — a pattern I noticed while reviewing generated content:

“Titles all look like ‘X-minute do something’. Can we drop the X-minute?”

There’s a separate duration field. Putting time in the title duplicates it, and the pattern feels stale. One line added to the prompt:

No time durations (“5-minute”, “X-min”) in topic — that’s what duration is for.

The next run produced clean titles like “Email triage and priority-tagging routine”.

Clear semantic chains produce good content

To push quality further we expanded the output schema — target, obstacle, expectation, duration, difficulty, materials, tools. The first version of the prompt got the semantics slightly wrong.

target is the subject of change, isn’t it? It’s being described as the success criterion.

So we tightened the chain:

  • target = the current (unresolved) state before the routine
  • obstacle = the friction encountered going from target to expectation
  • expectation = the state target should reach

Once the three fields had a clear relationship, what the AI wrote naturally aligned. “target = success criterion” is a wrong instruction — and the AI dutifully writes wrong content from it.

The narrower the interface

The full flow ended up like this:

  1. Job server fires auto_generate_public_overviews at 03:00 KST
  2. Job calls /generate/using/ai/auto with {count: 5, language: "en"}, then {count: 5, language: "ko"} (with x-api-key)
  3. API loads the owner achiever → fetches 200 recent same-language titles as exclude list → AI suggester returns [{topic, category, point_color}]
  4. For each item, generate the template + Overview, publish with Visibility.PUBLIC into the Public Library

The caller only needs to know how many, in what language. Everything else lives inside the system.

A narrow interface is the surface of automation. The fewer inputs a human has to provide, the more the system can do for itself while you sleep.