$ cat workspace-template.yaml

L

Learner Lifecycle Console

// An operations console for a course business, built on the organization ontology. Reads learners, cohorts, enrollments and courses from the graph, joins them to live Odoo invoices, and sorts every learner into one bucket: overdue, paid-but-inactive, at risk, ready to upsell, or healthy. Drafts re-engagement outreach, and on request writes the follow-up back to Odoo and stamps it on the ontology.

Automation
#ontology#console#odoo#customer-success#erp#dashboard

// Canvas Preview

canvas.flow

// Instruction

instruction.md

Learner Lifecycle Console

Overview

The daily operating console for a course business. It reads the organization ontology — learners, cohorts, enrollments, courses — joins it to live Odoo invoices, and sorts every learner into exactly one bucket: overdue_unpaid, paid_inactive, at_risk, completed_upsell, healthy. It then drafts re-engagement outreach, and on request writes the follow-up back to Odoo and stamps it onto the ontology.

This is an application, not a pipeline. The ontology is the system of record it reads; Odoo is the system of engagement it reads money from and writes follow-ups to. The graph produces a decision and an action, not a dataset.

Division of authority, and it matters: the ontology answers who, what course, how far along. Odoo answers how much is still owed. AR is re-read from Odoo on every run rather than trusted from the ontology copy, so the console can never quote a stale balance.

Requires: the ontology already loaded (run Odoo to Ontology Feeder first) and an Odoo connection on the workspace-token owner's account.

Workflow Chain

"Read Ontology"[enrollments] ────┐"Odoo Invoices (live)"[data] ────┼──► "Segment Learners""Run Date"[value] ───────────────┘        │                                          ├─[summary]──────► "Console" (canvas preview)                                          ├─[atRiskText]───► "Outreach Draft" ──► "Save Snapshot" (GATED)                                          └─[atRiskRows]──┬► "Mark Outreach Sent" (GATED — mutates the ontology)                                                          └► "Log Odoo Follow-up" (GATED — writes to Odoo)"Run Date"[value] ───────────────────────────────────────► both gated action nodes

Node Reference

  • "Run Date" (String) — the ISO date every ageing rule reads. The executor has no reliable clock; nothing infers today. Configurable: value.
  • "Read Ontology" (Custom Code) — lists Enrollment, Learner, Cohort and Course and joins them into flat rows, each carrying its enrollmentId so the action nodes need no second lookup. Configurable: maxObjects.
  • "Odoo Invoices (live)" (Odoo RPC) — posted customer invoices with residual and due date. Configurable: params.domain, params.limit.
  • "Segment Learners" (Custom Code) — the business logic. Buckets are evaluated in priority order, first match wins, so counts sum to the population and nobody is chased twice. Outputs kpis, segmentCounts, atRiskRows, overdueRows, upsellRows, revenueByCohort, atRiskText, summary. Configurable: staleDays (default 21).
  • "Console" (Preview) — shows the segmentation summary on the canvas.
  • "Outreach Draft" (Text Transform) — one short re-engagement message per at-risk learner. Read-only: it drafts, it never sends. Configurable: prompt, llmModelId (leave EMPTY for the workspace default).
  • "Mark Outreach Sent" (Custom Code) — GATED. Patches each enrollment with status, outreachSentAt, outreachSegment. This is the workspace mutating the ontology. Configurable: maxRows, newStatus.
  • "Log Odoo Follow-up" (Custom Code) — GATED. One scheduled activity per learner on their Odoo contact. Configurable: maxRows, note.
  • "Save Snapshot" (save-file) — GATED. Writes the drafted outreach to Drive. destinationFolder ships EMPTY on purpose; a baked folder id 422s on any other instance.

Execution Strategy

You MUST complete all 5 phases in order: Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5. Do NOT skip any phase.

There is exactly ONE requestUserDecision in this instruction, in Phase 4. It is deliberately NOT in Phase 1: every choice it offers depends on the segment counts, and asking which learners to contact before the numbers exist forces the user to answer blind. Phases 1–3 are entirely read-only, so nothing is committed before that gate.

Phase 1: Refresh

  1. applyCanvasData to commit the template, then use nodeFinder for every id — preview ids are stale.
  2. If the user named a date, nodeFinder "Run Date" → upsertNode { value: <that date> }, and set the same value on "Segment Learners" runDate. Otherwise keep the baked value; do NOT ask.
  3. nodeExecutor "Read Ontology" and "Odoo Invoices (live)" together — they are independent.
  4. getNodeOutput on both. If "Read Ontology" fails with "no Enrollment objects are visible", STOP: the ontology was never loaded. Report that the Odoo to Ontology Feeder must run first, and do not continue. If "Odoo Invoices (live)" returns record_count: 0, continue but record it — every learner's AR state will read as unknown, and the console must say so rather than imply everyone has paid.

Phase 1 complete. Now proceed to Phase 2.

Phase 2: Segment

  1. nodeExecutor "Segment Learners", then "Console".
  2. getNodeOutput "Segment Learners". Read kpis, segmentCounts and summary.
  3. Report the console to the user in plain prose, in this order: active learners and average progress; euros overdue and across how many invoices; the at-risk count split into paid_inactive vs at_risk; the upsell count. Name the top three at-risk learners with their course, progress and days idle — the list is already sorted highest-value first, so the top of it is the work.
  4. If summary reports enrollments with no matching Odoo invoice, say so explicitly and say what it means: their AR state is unknown, not zero.
  5. If any row's engagementSource is synthetic, say that progress figures are derived rather than measured, because no Moodle completion feed is wired. Never present synthetic engagement as observed behaviour.

Phase 2 complete. Now proceed to Phase 3.

Phase 3: Draft outreach

  1. nodeExecutor "Outreach Draft". getNodeOutput and show the user the first two drafted messages in full so they can judge the tone before anything is sent anywhere.
  2. If atRiskRows was empty, "Outreach Draft" will report that nobody is at risk. Skip to Phase 4 and offer only the snapshot and the schedule.

Phase 3 complete. Now proceed to Phase 4.

Phase 4: The action gate

  1. Call requestUserDecision ONCE, with key: "actions", allowMultiple: true, allowFreeText: false, and these options. State the counts inside the question text so the user is choosing against real numbers:
    • mark_ontology — "Stamp the outreach on N enrollments in the ontology (status → outreach_sent)"
    • odoo_activity — "Create N follow-up activities on the learners' Odoo contacts"
    • save_snapshot — "Save the drafted outreach to Drive"
    • nothing — "Do nothing for now"
  2. If the result is cancelled: true, or nothing is among the selections, take NO action and go to Phase 5. Never treat a cancelled gate as consent.
  3. For each selected action, and ONLY those:
    • mark_ontologynodeExecutor "Mark Outreach Sent".
    • odoo_activitynodeExecutor "Log Odoo Follow-up".
    • save_snapshot → call selectDriveItem (type=folder) so the user picks the destination, then nodeFinder "Save Snapshot" → upsertNode { destinationFolder: <picked id>, name: "learner-outreach-<runDate>.md" }, then nodeExecutor it. Never invent a folder id and never reuse the empty default.
  4. getNodeOutput each action node that ran and read its summary. Each one reports a maxRows cap when it truncated — repeat that number to the user verbatim. A capped run that reads as complete is the failure this guards against.

Phase 4 complete. Now proceed to Phase 5.

Phase 5: Verify and report

  1. Call verification covering: what was read, how the population split, and exactly which side effects executed with their counts. If no action was selected, say plainly that nothing was written.
  2. Call followUp offering: (a) attach a daily schedule so the console refreshes each morning, (b) open the Vista dashboard, (c) re-run for a different date, (d) drill into one learner's 360 in the ontology module. Do NOT create the schedule yourself.

Configuration Tips

User saysConfigure
"we're stricter about idle learners""Segment Learners" staleDays → lower (e.g. 14)
"only chase people who owe money""Odoo Invoices (live)" params.domain → add ["payment_state","!=","paid"]
"the outreach sounds too generic""Outreach Draft" prompt — the rules live in the prompt, not the code
"write in Italian""Outreach Draft" languageItalian
"contact more than 25 people"maxRows on "Mark Outreach Sent" AND "Log Odoo Follow-up" — both cap independently
"use a specific model""Outreach Draft" llmModelId. Leave EMPTY unless the user names one — an invented model id routes badly
"show me last month instead""Run Date" value AND "Segment Learners" runDate — set both

Error Handling

ErrorCauseFix
no Enrollment objects are visibleOntology never loaded, or no read accessRun the Odoo to Ontology Feeder first
no ontology object types are visibleTypes not seeded, or the token's user is not in ontology-review / emblema-adminRun seed_ontology_types.py. The listing returns an EMPTY set rather than 403 for a non-reviewer, so both causes look identical
runDate ... is not an ISO date"Run Date" holds a labelSet value to YYYY-MM-DD
Odoo is not connectedNo Odoo connection on the workspace-token OWNER (not the viewer)Add it in account settings → Connections
Every learner lands in at_risklastActivityAt is empty across the board — no engagement data at allExpected when syntheticEngagement was false in the feeder and no Moodle feed exists. Say so rather than reporting a crisis
Odoo has no res.partner ir.model row or no mail.activity.typeActivities/Discuss not installed on that OdooSkip odoo_activity; the other actions are unaffected
"Save Snapshot" 422sdestinationFolder still emptyAlways pick a folder with selectDriveItem; never bake one
ModuleNotFoundError: gaisExecutor image predates the gais SDKRebuild emblema/workspace-executor-python

// Dependencies

requirements.py
1from input import String2from process import CustomCode3from process import OdooRPC4from control import Preview5from process import TextTransform6from output import SaveFile

// Variables

variables.yaml
1runDate:2  type: undefined3  label: "Run date"4  description: "ISO date treated as today by every ageing rule."5  required: undefined67staleDays:8  type: undefined9  label: "Stale after (days)"10  description: "Idle days before a running learner counts as at risk."11  required: undefined

$ git log --oneline

v1.0.6
HEAD
2026-08-28
v1.0.32026-08-28
v1.0.22026-08-28
v1.0.12026-08-28
v1.0.02026-08-28