Content Router Service
File:src/cofounder_agent/services/content_router_service.py
Tested by: src/cofounder_agent/tests/unit/services/test_content_router_service.py (and any integration test that exercises a full pipeline run)
Last reviewed: 2026-05-23
What it does
The content router is the single entry point for “given a topic, run the whole content pipeline.” As of 2026-05-16 (Lane C Stage 4) it is a thin TemplateRunner dispatcher: it builds the shared pipeline context (image_service, settings, style_tracker, site_config,
models_by_phase, experiment assignment) and hands it to
TemplateRunner.run(template_slug, context) keyed on
pipeline_tasks.template_slug. The LangGraph template (registered in
services/pipeline_templates/__init__.py) owns the node ordering —
the router no longer threads 12 stages directly. A NULL template_slug
fails loud per feedback_no_silent_defaults. The legacy chunked
StageRunner path was deleted in the same Stage 4 cut along with
plugins/stage_runner.py itself.
The router also owns two cross-cutting concerns the stages can’t see:
the GPU mode switch (Ollama → image-gen → Ollama) around the featured image
stage, and the writer-fallback canary that compares
pipeline_writer_model against the model the draft actually came back
with so a silent 72B → 27B downgrade is logged loudly instead of going
unnoticed.
Public API
process_content_generation_task(topic, style, tone, target_length, ...) -> dict[str, Any]— runs the full pipeline. Required:database_service(forpipeline_taskspersistence). Optional:task_id,generate_featured_image,models_by_phase,quality_preference,category,target_audience,tags. Returns the sharedresultdict — see theresult["status"]field forpending,published,awaiting_approval,rejected, orfailed.
services/flows/content_generation.py (the Prefect-orchestrated
content pipeline that owns dispatch as of 2026-05-16 Stage 4) or
/api/tasks/generate (HTTP entry).
Configuration
The router itself reads only one DB-configured setting directly:pipeline_writer_model(default: writer chain decides) — compared against the model that produced the draft to detect silent fallback.pipeline_dry_run_mode(defaultfalse) — whentrue, the writer chain short-circuits withAllModelsFailedError. The router demotes the resulting halt to adry_run_haltaudit event atseverity=infoinstead of the usualerrorso it doesn’t drown real failures.
docs/architecture/multi-agent-pipeline.md and
docs/architecture/anti-hallucination.md for the per-stage settings.
Dependencies
- Reads from:
services.container.get_service("settings")(DI seam, falls back toNoneoutside lifespan)services.image_style_rotation.ImageStyleTrackerservices.image_service.get_image_serviceservices.gpu_scheduler.gpufor the image-gen/Ollama mode switchsite_config(fromAppContaineror DI) for the writer-fallback + dry-run checksplugins.registry.get_core_samples()for the stage list
- Writes to:
pipeline_tasks(status, error_message, task_metadata) viadatabase_service.update_taskaudit_log(multiple event_types:task_started,generation_complete,qa_passed/qa_failed,pipeline_complete,dry_run_halt,error,writer_fallback)webhook_eventsindirectly via webhook delivery (emit_webhook_event("task.failed", ...)on failure). Earlier docs called thispipeline_events; the actual writer was alwayswebhook_events. The unrelatedpipeline_eventstable was dropped 2026-05-04 (poindexter#366).
- External APIs: none directly — stages own the LLM/HTTP calls.
Failure modes
- Stage halt before content exists —
generate_contentreturnscontinue_workflow=False. Router raisesRuntimeErrorwith the stage’sdetail; nothing further runs and the task is markedfailed. Diagnose via theerroraudit event payload (full traceback in logs). - Stage halt after content exists —
quality_evaluationreturns halt. Same pattern. The partially-generated content, image, and SEO metadata are preserved intask_metadataso an operator can still review what was produced. - Cross-model QA rejection — on the graph_def path
qa.aggregate(the qa.* rail block that replaced the deletedcross_model_qastage, #355) setsstatus="rejected", persists the reject viamodules/content/atoms/_qa_persist.py, and halts the graph; not raised as an error. - Silent writer fallback — primary model timed out or 500’d, writer
chain succeeded with the next model. Detected by comparing
pipeline_writer_modeltoresult["model_used"]aftergenerate_content. Fireswriter_fallbackaudit event atseverity=warning. Visible on the /pipeline Grafana dashboard. - Dry-run halt — when
pipeline_dry_run_mode=true, the writer chain raisesAllModelsFailedError. The router demotes the audit severity fromerrortoinfoso the 24h error count isn’t poisoned by intentional dry-run noise.
Common ops
- Re-run a failed task: call
process_content_generation_task(...)with the sametask_id. The function tolerates re-entry; downstream stages handle idempotency (e.g. publish_service has a slug-suffix guard). - Toggle dry-run:
poindexter settings set pipeline_dry_run_mode trueto short-circuit the writer chain without consuming GPU/cloud time. - Check writer-fallback events:
SELECT * FROM audit_log WHERE event_type = 'writer_fallback' ORDER BY created_at DESC LIMIT 20; - Disable featured-image stage for one task: pass
generate_featured_image=False. The stage runs but short-circuits. - Inspect what halted: the failure path stores
error_stageanderror_messageinpipeline_tasks.task_metadata.
See also
docs/architecture/multi-agent-pipeline.md— stage-by-stage breakdown with the prompts and gates each stage owns.docs/architecture/anti-hallucination.md— how the validator + QA stages slot into the run order.docs/architecture/services/multi_model_qa.md— theMultiModelQArail library that theqa.*atoms delegate to.feedback_writer_model_canary(operator design note) — operator playbook for diagnosing pipeline-wide approval-rate drops via the writer-fallback event.