Skip to content

Infographic Recipes (FEAT-324)

Recipe-driven, replayable A2UI infographics: precise construction instructions — datasets, registered transforms, a catalog layout, a render profile — persisted once and replayed deterministically with fresh data, with no LLM in the replay loop.

Design record: sdd/specs/infographic-builder.spec.md (spec) and sdd/proposals/infographic-builder.brainstorm.md (brainstorm, all open questions resolved there).


1. Concepts

A recipe is pure data

An InfographicRecipe (parrot.outputs.a2ui.recipes.InfographicRecipe) is a Pydantic model, never stored/executed code (spec G1):

  • params — declared parameters ({param} substitution + five built-in relative-date resolvers: current_month, previous_month, today, yesterday, first_of_month).
  • data_sources — DatasetManager dataset names, each bound to an alias transforms reference.
  • transforms — an ordered chain of registered transformer calls (name + params + output_key); every value in the layout traces back to one of these.
  • layout — a catalog component tree (Infographic, Chart, KPICard, DataTable, Report, ...) whose data-carrying properties are {"$bind": "/pointer"} bindings into the assembled dataModel.
  • render — the renderer profile (interactive-html, ssr_html, pdf, ...) plus optional delivery config. Known pre-existing defect (unrelated to FEAT-528, discovered while writing that feature's tests): get_a2ui_renderer resolves a profile's satellite module via f"parrot.outputs.a2ui_renderers.{name}", and every renderer's registered name matches its own filename exactly EXCEPT "interactive-html" (hyphen) — the file is interactive_html.py (underscore), and a hyphen cannot appear in a Python module path segment, so resolving THIS profile — the model's own default — raises ModuleNotFoundError unconditionally, in every environment, for every recipe that does not explicitly override render.profile. Not fixed here; pick ssr_html, echarts, pdf, adaptive_cards, or folium_map explicitly until it is.
  • schedule — optional; principal is REQUIRED before a recipe can be scheduled (spec G8 — see §4).
  • narrative (FEAT-420, optional, additive — see §6) — a reference to a skill name that renders deterministic facts as prose. Never code; the recipe's schema_version stays 1.

Transformers

Registered via @infographic_transformer (parrot.outputs.a2ui.recipes.transformers): pure (inputs: dict, params: dict) -> dict functions. Eight ship built-in (parrot.outputs.a2ui.recipes.library):

Transformer Purpose
day_totals Per-snapshot revenue/EBITDA totals + variance (dynamic per-snapshot-date keys)
division_breakdown Per-division rollup + per-project variances (latest snapshot)
variance_analysis First-vs-latest comparison with STABLE keys (first_totals/last_totals) — the layout-friendly equivalent of day_totals
top_movers Worst/best N projects by EBITDA variance, with a first-vs-latest trend
groupby_aggregate Generic group-by + named aggregation (reshapes multi-day data into flat, chartable rows)
pivot Generic pivot table
latest_vs_baseline Generic baseline-vs-latest delta join
narrative_facts (FEAT-420) Structured narrative judgements (direction flags, top driver + urgency, per-division read kind) derived from variance_analysis/top_movers/division_breakdown's outputs — see §6. Takes prior-step output_keys as input, not raw dataset columns (requires_columns={})

Every transformer declares requires_columns (checked by the fail-fast gate, §3) and a params_schema (discoverable via infographic_get_recipe_contract). An input alias referencing a PRIOR step's output_key (rather than a data_sources alias) is exempt from the columns gate — it has no columns to check, and is validated instead at transform-execution time. This is what lets narrative_facts take other transformers' OUTPUTS as its inputs.

Stores

AbstractRecipeStore (parrot.outputs.a2ui.recipes.store) — three backends:

  • FileRecipeStore(directory) (core) — one YAML file per recipe (<directory>/<name>.yaml, or <directory>/<owner>/<name>.yaml when owner-scoped). Atomic writes (write-to-temp + os.replace). Recipes travel baked into a deployment artifact — changing a dashboard means a deploy. Best for hand-authored recipes checked into a repo.
  • DBRecipeStore(redis_url=..., namespace=...) (core) — Redis, despite the name (mirrors parrot.skills.store.SkillRegistry's actual persistence mechanism — Redis + in-memory fallback, NOT a SQL table). Degrades to in-memory when Redis is unset/unreachable; a flush loses every recipe. Invisible to SQL.
  • PgRecipeStore(dsn=None, *, schema="navigator") (FEAT-528, parrot.handlers.models.recipes, ai-parrot-server) — relational, one row per recipe in <schema>.infographic_recipes ((name, owner) primary key), created by a lazy ensure_schema() safe to call on every boot. Mirrors PgUISurfaceStore's connection/DDL idiom (same navigator schema a recipe's produced surfaces already live in — see parrot.handlers.models.ui_surfaces), so a recipe and the ui_surfaces row it produces can live side by side, backed up and queried the same way. title/description are denormalised columns so list() never deserialises the full recipe JSONB. Takes an explicit schema= keyword (unlike PgUISurfaceStore, which hardcodes navigator) so a host is not forced onto the same schema assumption.
from parrot.handlers.models.recipes import PgRecipeStore

store = PgRecipeStore()          # parrot.conf.default_dsn, schema="navigator"
await store.ensure_schema()      # idempotent; first store use also triggers it lazily
await store.save(recipe)         # upsert on (name, owner), bumps updated_at

Choosing one: FileRecipeStore for recipes that ARE deploy artifacts (hand-authored, reviewed in a PR); PgRecipeStore when a recipe should be editable, queryable, and backed-up DATA — the default choice once a host already runs Postgres for ui_surfaces; DBRecipeStore only when Redis is the durability story you already have and SQL visibility does not matter.

All three share one contract: save (overwrite + updated_at bump), get, list (lightweight summaries only), delete. RecipeNotFoundError lists available names; RecipeSchemaVersionError guards schema_version drift.

Triggers — one runner, three doors

RecipeRunner (parrot.tools.infographic_recipes.runner) executes the seven-step replay pipeline (params → data → gate → transforms → layout bind-check → render → best-effort delivery). All three triggers call the SAME RecipeRunner.run():

  1. Chat tool — InfographicToolkit.infographic_run_recipe(name, params) (§2).
  2. REST — POST /api/v1/infographic_recipes/{name}/run (§3).
  3. Scheduler — run_infographic_recipe callback on the existing AgentSchedulerManager (§4).

2. The example recipe, annotated

examples/infographic_recipes/budget-variance-daily.yaml reproduces the reference sdd/artifacts/budget_variance_dashboard_Template.html dashboard end-to-end. Load it with:

from parrot.outputs.a2ui.recipes import InfographicRecipe

recipe = InfographicRecipe.from_yaml(open("budget-variance-daily.yaml").read())

Key sections, walked through:

params:
  - name: month
    default: current_month   # resolves to "YYYY-MM" at replay time
Declares one param; resolve_params() (parrot.outputs.a2ui.recipes.params) resolves current_month to the literal month string unless a caller supplies an override (e.g. {"month": "2026-06"}) — undeclared overrides are rejected (typo protection).

data_sources:
  - dataset: in_month_projections
    alias: snapshots
  - dataset: in_month_projections
    alias: df
TWO aliases for the SAME dataset: snapshots feeds the finance-domain transformers (which all expect an input keyed "snapshots"), df feeds groupby_aggregate (which expects "df") — each built-in transformer hard-codes its own expected input-alias name, so a recipe re-aliases the same dataset per transformer that needs it.

transforms:
  - transformer: variance_analysis
    inputs: [snapshots]
    params: {snapshot_col: snapshot}
    output_key: variance_analysis
The layout's KPICards bind to variance_analysis's STABLE first_totals/last_totals keys — NOT day_totals's per-snapshot-date keys, which change every day and would break a fixed $bind pointer.

  - transformer: narrative_facts
    inputs: [variance_analysis, top_movers, division_breakdown]
    output_key: narrative_facts
FEAT-420: narrative_facts derives structured judgements (direction flags, top driver + urgency, per-division read kind) from the THREE steps above — its inputs are their output_keys, not raw dataset columns, so it MUST come after all three (transforms order is execution order). See §6.

layout:
  component: Infographic
  properties:
    sections:
      - heading: Snapshot
        text: {$bind: "/narrative", optional: true}
        components:
          - component: KPICard
            properties:
              value: {$bind: "/variance_analysis/last_totals/rev_actual"}
Every data-carrying property is a $bind pointer into the assembled dataModel — never a literal value. The runner cross-checks every pointer's top-level key against the transform chain's output_keys BEFORE rendering (spec §7's documented "$bind drift" risk). The section's text carries a sibling optional: true (FEAT-420, §6) — with no narrator injected, /narrative is never populated and this property is simply OMITTED from the baked output rather than aborting the run.

narrative:
  skill: budget-narrative
  facts_key: narrative_facts
  output_key: narrative
A top-level, recipe-level field (a sibling of transforms, NOT a transform step): skill names a skill registered under .agent/skills/; facts_key is the data_model key holding the facts to render (narrative_facts's output_key above); output_key (default "narrative") is where the generated prose lands. See §6 for the full narrative contract.

render:
  profile: interactive-html
Self-contained interactive HTML (vendored Chart.js v4 + vanilla-JS day tabs/metric toggle/column sort — see Module 7). Swap to ssr_html or pdf for static delivery channels (email attachments, print).


3. Replay

Chat tool

toolkit = InfographicToolkit(
    artifact_store=artifact_store,
    recipe_store=recipe_store,       # FileRecipeStore | DBRecipeStore
    dataset_manager=dataset_manager,  # builds a RecipeRunner internally
)
# infographic_save_recipe / infographic_list_recipes / infographic_run_recipe /
# infographic_get_recipe_contract are now exposed (absent otherwise).

infographic_save_recipe freezes the CURRENT session's envelope + explicit dataset/transform provenance into a recipe (dry-run validated before persisting) — the LLM half of dual authorship (spec G2). Ad-hoc REPL-computed data (not a registered transformer call) cannot be frozen; this is a documented boundary, not a bug.

REST

GET    /api/v1/infographic_recipes              # list (owner-scoped)
GET    /api/v1/infographic_recipes/{name}        # full recipe
PUT    /api/v1/infographic_recipes/{name}        # create/overwrite
DELETE /api/v1/infographic_recipes/{name}
POST   /api/v1/infographic_recipes/{name}/run    # {"params": {...}} body

Configure the store/runner once at server startup:

from parrot.handlers.infographic_recipes import register_recipe_routes

register_recipe_routes(
    app, recipe_store=recipe_store, dataset_manager=dataset_manager,
)

POST .../run returns 422 with the structured RecipeRunError body on a schema-drift/gate failure (§5), 404 listing available recipe names when the target doesn't exist, 200 with {"artifact_id", "filename", "mime_type", "size", "storage_ref"} on success.

Scheduling

The existing APScheduler-based AgentSchedulerManager already ships jobs + post-run callbacks — FEAT-324 registers run_infographic_recipe as a callback (CALLBACK_REGISTRY), NOT a new scheduler (spec Non-Goal). Create any lightweight scheduled job via the existing SchedulerJobsHandler REST CRUD, attaching the recipe callback:

POST /api/v1/parrot/scheduler/schedules
{
  "agent_name": "ops-bot",
  "schedule_type": "daily",
  "schedule_config": {"hour": 6, "minute": 0},
  "prompt": "noop",
  "callbacks": [
    {"type": "run_infographic_recipe", "config": {"recipe_name": "budget-variance-daily"}}
  ]
}

The recipe MUST have schedule.principal set (uncomment the schedule: block in the YAML and set a real principal) — scheduled replays run under THAT principal, resolved into a minimal PermissionContext, and never fall back to a server identity (spec G8). A missing principal fails the job outright.

Replaying in another service (FEAT-528)

A host that wants to replay a recipe needs its registered @infographic_transformer functions in-process — and nothing else from the agent that ships them: no agent class, no LLM, no ai-parrot-visualizations. Registration is purely an import side effect, and an agent's transformer module (e.g. agents/flex_dashboard/ transformers.py) is normally only reachable through its author's own package layout — which a host with its own top-level package of the same name cannot import (this is exactly the failure Module 2 of FEAT-528 fixed: a plain import agents.flex_dashboard.transformers only resolves when the process's OWN agents/ directory happens to be on sys.path as the agents package).

load_transformer_module (parrot.tools.infographic_recipes) is the supported, documented answer:

from parrot.tools.infographic_recipes import load_transformer_module, RecipeRunner
from parrot.handlers.models.recipes import PgRecipeStore
from parrot.tools.dataset_manager.tool import DatasetManager

load_transformer_module("/path/to/agents/flex_dashboard/transformers.py")

dataset_manager = DatasetManager(generate_guide=False)
dataset_manager.add_dataframe("hours", hours_df)   # or add_query(...) over a real source
# ... register every dataset alias the recipe's data_sources reference ...

runner = RecipeRunner(PgRecipeStore(dsn), dataset_manager)
artifact = await runner.run("flex-program-dashboard", pctx=pctx)

It imports a transformer module by FILE PATH, under a deterministic synthetic module name derived from the resolved path — a second call for the same path is a no-op (returns the already-imported module; never re-registers). If the module's directory has a sibling __init__.py (true for agents/flex_dashboard/), the module is loaded AS A PACKAGE SUBMODULE so its own relative imports (e.g. transformers.py's from .normalize import ...) resolve correctly — no sys.path mutation, no dependency on any particular top-level package name being free.

The params-vs-filtering rule (do not assume more than the recipe declares): a recipe's params reach the underlying data fetch ONLY through DataSourceSpec.conditions/sql (RecipeRunner._fetch_frames, runner.py) — a data source with neither declares no conditions, and its fetch is therefore UNFILTERED regardless of what params a caller passes. Flex's own published recipe declares no conditions/sql on any data source (its params only feed _apply_filters INSIDE each transformer, post-fetch, in pandas) — replaying it in another service fetches the full frame every time; filtering is a property of a specific recipe's data sources, not a guarantee this replay path adds on your behalf.

Still open: agents/flex_dashboard* and agents/finance_reporter.py are company agents living in the framework's repo, which the standing rule forbids ("ai-parrot es un framework de IA, no de empresa") — moving both to navigator-plugins is tracked as a separate work item (spec §8), owner Jesús, not performed by FEAT-528. Module 2's parent-agnostic import fix and load_transformer_module make that move SAFE (the sibling package no longer cares what its parent is named); they do not perform it. finance_reporter.py:73's SKILLS_DIR anchoring (parents[1]/ .agent/skills, resolved relative to the file's OWN location) will silently resolve to nothing once that file relocates to a different repo — repairing that anchor is part of the move, not of this feature.


4. Permissions (spec G8)

Trigger pctx source
Chat tool Real PermissionContext captured by InfographicToolkit._pre_execute from the toolkit-dispatch-injected _permission_context (falls back to a principal-only context built from the resolved user id when invoked outside the dispatch path — e.g. direct method calls)
REST Built from the authenticated session's user id via build_principal_context (parrot.auth.permission)
Scheduler Resolved from the recipe's schedule.principal (+ optional schedule.tenant_id/schedule.roles) — REQUIRED, no fallback

Every RecipeRunner.run() call site (chat tool, REST, scheduler) ALWAYS passes a real pctx — a falsy pctx makes DatasetManager's PBAC/data-plane guards fail OPEN (no filtering applied) rather than closed, so this is a hard requirement, not a nicety. PermissionContexts built from a bare principal (REST, chat-tool fallback) default tenant_id to the principal itself and grant no roles — set schedule.tenant_id/schedule.roles explicitly on a recipe's schedule block for role-gated PBAC policies to apply to its scheduled replays.

RecipeRunner.run() also takes recipe_owner — it MUST match the owner a recipe was saved under (stores key by (name, owner)); all three triggers resolve and pass it automatically (the invoker's user id for chat/REST, None/unscoped for scheduled recipes unless you scope those separately).

DatasetManager's PBAC/data-plane guards apply unchanged in all three paths — a permission-denied dataset fails the run, it never silently narrows or widens access.

Publishing a recipe over a TableSource

DatasetManager's TableSource REJECTS any fetch that arrives without an explicit sql statement (and any bare SELECT *) — a deliberate data-plane guardrail, not a bug. A recipe whose DataSourceSpec.sql is empty therefore publishes fine and then aborts every replay with a stage="data" error.

Declare the statement on the descriptor and publish_recipe threads it into the matching data source:

SectionDescriptor(
    template="budget_variance_dashboard_Template.html",
    mode="data-splice",
    sections=[...],                      # datasets=["snapshots"]
    dataset_sql={
        "snapshots": (
            "SELECT snapshot_date, division, project, "
            "rev_actual, rev_budget, ebitda_actual, ebitda_budget "
            "FROM troc.finance_projection ORDER BY snapshot_date"
        ),
    },
)

dataset_sql is keyed by DATASET ALIAS and lives at descriptor level (like params) because several sections routinely read the same dataset — one statement, fetched once per replay. Select exactly the columns the target transformers declare in requires_columns (plus whatever snapshot_col they compare across snapshots); aliases that turn out to be a prior step's output_key are not data sources at all, so an entry for one is inert. See agents/finance_reporter.py for a worked example against a live Postgres table.

{param} substitution into sql templates

DataSourceSpec.sql's {param} substitution is guarded against SQL injection: a resolved param value containing quotes, semicolons, or comment markers (--, /*, */) is rejected with a stage="data" error BEFORE any query executes. DatasetManager's TableSource executes sql close to verbatim and documents itself as NOT a security boundary — recipe params overrides are a new, less-trusted input to that path compared to TableSource's existing (LLM/agent-authored) callers. Prefer DataSourceSpec.conditions (parameterized, escaped at fetch time) over embedding {param} directly inside sql wherever possible.


5. Reading a RecipeRunError

Every abort constructs a structured diagnostic — never a raw traceback:

class RecipeRunError(BaseModel):
    recipe: str                # recipe name
    stage: Literal["params", "data", "gate", "transform", "layout", "render"]
    transformer: Optional[str] # offending transformer, if applicable
    dataset: Optional[str]     # offending dataset/alias, if applicable
    missing_columns: list[str] # required columns absent from the input
    detail: str                # human-readable message
  • stage="params" — an override references an undeclared param, or a declared param has neither a default nor an override.
  • stage="data" — a dataset isn't registered (lists available names via DatasetManager.list_datasets()), or fetch_dataset() itself errored.
  • stage="gate" — an unknown transformer name (lists registered names), or a required column is missing from a data-source-backed input — BEFORE any transform executes.
  • stage="transform" — a transform raised, or a step references an alias that is neither a data-source alias nor a prior step's output_key.
  • stage="layout" — a $bind pointer's top-level key is absent from the assembled dataModel (an output_key was renamed without updating the layout), or the assembled envelope fails catalog validation. A pointer marked optional: true (FEAT-420, §6) never raises here even when absent — see the Determinism Boundary section. dry_run's equivalent static check additionally treats a recipe's narrative.output_key as a valid bindable key (it is not a TransformStep, but it is a legitimate data_model key once a narrator populates it).
  • stage="render" — the resolved renderer's render() call itself raised. (An UNKNOWN/uninstalled renderer profile instead raises the existing actionable ImportError naming the pip extra — it never reaches RecipeRunError.)

infographic_get_recipe_contract(name) (chat tool) exposes exactly which datasets/columns/params a recipe needs, so an operator can verify replayability before scheduling it.


6. Narrative (FEAT-420)

A recipe may declare an optional, fenced narrative step that renders deterministic facts as prose — the only non-deterministic part of an otherwise fully deterministic replay.

Determinism boundary

Every number in a rendered artifact traces to a registered transformer. Prose is best-effort. No narrator configured, a missing skill, an LLM error, or a figure-guard rejection all degrade the SAME way: the narrative section is simply absent, and the run still succeeds. A pure replay never fails for lack of an LLM (spec criterion G-E). Narrative generation is never retried, never blocks, and is never guaranteed byte-identical across replays — only the DATA is deterministic; the WORDING is not.

This is the one thing to internalize before reading further: nothing below changes what is guaranteed. It only adds a best-effort layer on top.

The narrative block

narrative:
  skill: budget-narrative
  facts_key: narrative_facts
  output_key: narrative     # default

NarrativeSpec (parrot.outputs.a2ui.recipes.models) — a reference to a skill name, never code (spec G1, same principle as transforms referencing registered transformer names). skill must resolve in the skill registry at narration time (dry_run flags an unresolvable facts_key statically; the skill name itself is only checked live, since the runner has no registry handle). InfographicRecipe.schema_version stays 1 — this is an additive field, exactly like section_descriptor before it.

Injecting a narrator

from parrot.tools.infographic_recipes.runner import RecipeRunner

# No narrator -> the step is skipped entirely; recipes with a `narrative`
# block still replay deterministically.
runner = RecipeRunner(recipe_store, dataset_manager)

# A narrator -> prose lands at data_model[narrative.output_key].
runner = RecipeRunner(recipe_store, dataset_manager, narrator=my_narrator)

narrator implements the Narrator protocol (parrot.tools.infographic_recipes.narrator):

@runtime_checkable
class Narrator(Protocol):
    async def narrate(self, facts: dict[str, Any], skill: str) -> Optional[str]: ...

parrot.bots.mixins.NarrativeMixin is the reusable implementation — mix it onto any SkillRegistryMixin-composing agent and pass narrator=<that agent> (it satisfies the protocol). It carries no domain vocabulary — any facts dict and any skill name work (spec criterion G-I). run_scheduled_refresh needs no change to support narration: it takes a RecipeRunner instance, so injection happens once, at construction — whoever builds the runner decides whether it can narrate.

Optional bindings

text: {$bind: "/narrative", optional: true}

A $bind expression carrying a sibling optional: true degrades instead of aborting when its pointer does not resolve:

  • Baking (parrot.outputs.a2ui.baking._resolve_value) omits the property entirely rather than raising BakeError.
  • The runner's drift check (_check_bind_drift_or_raise) logs the absent pointer at INFO instead of aborting the run.
  • A binding without optional is unaffected — still raises/aborts on an unresolved pointer, exactly as before this feature. Optional bindings are opt-in per pointer, not a global relaxation.

Mark every narrative-bound property optional: true — a recipe that binds into /narrative without it will abort on any no-narrator replay, defeating the determinism guarantee above.

The figure guard — and its limitation

Before a narrator's prose reaches data_model, every numeric literal in it is checked for derivability from the facts (parrot.tools.infographic_recipes.figure_guard.figures_are_derivable). All-or-nothing: a single non-derivable figure discards the WHOLE narrative, never just the offending sentence — a partially-scrubbed paragraph is a new artifact nobody reviewed. This degrades to the exact same "no narrative" state as a missing narrator (spec criterion G-H); there is one fallback path, not two. Guard enforcement is the narrator implementation's responsibility (NarrativeMixin applies it internally) — RecipeRunner itself trusts whatever a Narrator returns.

Stated limitation (spec §7 Known Risks, not silently glossed over): the guard catches invented figures, not a fluent mis-characterisation of a correct one — e.g. it cannot tell "EBITDA improved by $42.0K" from "EBITDA worsened by $42.0K" when both figures are individually derivable. This is the residual risk the fence does not close; review of the narrating skill's phrasing rules is what keeps it in check, not the guard.

Skill discovery and the 1000-token cap

Narrative skills live as data in .agent/skills/<name>/ (composite: SKILL.md + asset files), discovered by parrot.skills.loader.SkillsDirectoryLoader. SkillDefinition.MAX_TOKENS caps the SKILL.md body at 1000 cl100k_base tokens (frontmatter excluded from the count) — a composite skill moves the facts contract and reference phrasing into assets to stay under it.

Gotcha: the cap is enforced at discovery, not at authoring time. An over-cap or otherwise unparseable SKILL.md is logged as a warning and silently skipped — the skill registry simply does not contain it, which looks exactly like "the narrative stopped working" with no error anywhere obvious. Keep the SKILL.md body lean and measure its token count before committing a change to it.

The snapshot_col gotcha

The finance transformers default to snapshot_col="snapshot" (library.py); a real table may expose a differently-named column (e.g. snapshot_date). Three of the four finance transformers degrade silently on a wrong/missing snapshot_col — day_totals and top_movers fall back to treating the whole frame as one snapshot, division_breakdown just uses whatever rows are present — while variance_analysis is the ONE that raises outright without the column. A silent single-snapshot read is easy to miss in review (the numbers still look plausible), so treat the params entry as load-bearing: pass snapshot_col explicitly and consistently on every step that needs it, never rely on the default when the real column name differs.

ai-parrot-visualizations — verified unaffected

No satellite renderer change was needed for narrative (verified during FEAT-420 research): Report-rooted envelopes and per-section text were already rendered by interactive-html (ReportComponent.lower already omits an absent text/summary; interactive_html._render_infographic already omits an absent section text) — the optional-binding mechanism above builds entirely on that pre-existing graceful-omission behaviour.


7. Migration from daily_report.py

The reference artifacts (sdd/artifacts/daily_report.py, executive_summary.py, budget_variance_dashboard_Template.html — gitignored, non-importable; referenced here for the migration story only) implement this exact pattern as a standalone Windows script. FEAT-324 replaces each piece with a core AI-Parrot mechanism:

daily_report.py concern FEAT-324 replacement
Windows Task Scheduler AgentSchedulerManager (existing) + run_infographic_recipe callback
Outlook COM delivery deliver_artifact() (existing NotificationMixin.send_notification bridge)
parse_csv + hardcoded row indices DatasetManager dataset registration (any source: table, query, in-memory)
day_totals/division_breakdown/analyze (inline functions) Registered @infographic_transformers (day_totals, division_breakdown, variance_analysis, top_movers) — same math, ported verbatim where the semantics carry over
String-splicing into an HTML template (splice_into_template) interactive-html renderer — Chart.js + vanilla-JS day-tabs/metric-toggle/sort, driven by the embedded dataModel JSON, zero string templating
Hardcoded file paths / one-off script A persisted, versioned InfographicRecipe — replayable by name, from chat, REST, or a schedule
No structured failure mode (silent wrong numbers) Fail-fast RecipeRunError — the gate aborts BEFORE any transform runs on drifted data

The budget-variance-daily.yaml example (§2) is the concrete migration target: same construction instructions the legacy script encoded in Python, now declarative, replayable, and schema-drift-safe.


8. Testing

packages/ai-parrot/tests/integration/infographic_recipes/test_e2e.py exercises the full pipeline against synthetic fixture CSVs (derived from daily_report.py's compact-row format, never the real reference data):

  • test_e2e_budget_variance_recipe — fixtures → DatasetManager → recipe → interactive-HTML RenderedArtifact.
  • test_rerun_updates_values_keeps_structure — re-running with changed fixture data yields updated numbers, identical dataModel structure.
  • test_e2e_freeze_then_replay — a simulated session envelope → freeze → replay produces an equivalent envelope, deterministically, with no LLM.
  • test_e2e_static_profile_delivery — the same recipe rendered via ssr_html → deliver_artifact (mocked notification provider).

No pixel/screenshot assertions (no browser in CI) — structure is asserted via the embedded dataModel JSON's key set and static HTML markers; value changes are asserted on the actual numbers.

PgRecipeStore and the host loader (FEAT-528)

Three additional suites cover the third store and the host-side loader, all gated on a live scratch Postgres — set NAVIGATOR_PG_DSN (skipped automatically when it is unset):

  • tests/unit/handlers/test_pg_recipe_store.py — round-trip, upsert (updated_at bump, no second row), owner scoping, RecipeNotFoundError/ RecipeSchemaVersionError parity with FileRecipeStore/DBRecipeStore, list() summaries, and idempotent ensure_schema().
  • tests/integration/test_pg_recipe_store_replay.py — publish through store A, replay through a SEPARATE store B instance (proving the row is the only shared state); register_recipe_routes wired onto a real aiohttp.web.Application with the same three views manager.py registers; the REST GET/POST .../run lane against a real PgRecipeStore (not a mock).
  • tests/integration/test_foreign_host_flex_replay.py — the FieldSync shape end-to-end: load_transformer_module registers flex's transformers in a subprocess with NO agents package on sys.path, six DatasetManager frames stand in for querysource, the recipe is read back from Postgres, and the assembled envelope carries all five dashboard sections — with no FlexDashboard instance anywhere in the process.

load_transformer_module itself (tests/unit/tools/ test_load_transformer_module.py) and the parent-agnostic import fix (tests/unit/agents/test_flex_dashboard_importability.py) are unit-tested without any database — see those files' own docstrings for the mutation-checked regression story.