Skip to content

Bbeggs/server side learner pathways - #281

Open
macdiesel wants to merge 5 commits into
mainfrom
bbeggs/server-side-learner-pathways
Open

Bbeggs/server side learner pathways#281
macdiesel wants to merge 5 commits into
mainfrom
bbeggs/server-side-learner-pathways

Conversation

@macdiesel

Copy link
Copy Markdown
Member

Description:
Add a description of your changes here.

Jira:
ENT-XXXX

Merge checklist:

  • ./manage.py makemigrations has been run
    • Note: This must be run if you modified any models.
      • It may or may not make a migration depending on exactly what you modified, but it should still be run.

Post merge:

  • Ensure that your changes went out to the stage instance
  • Deploy to prod instance

macdiesel and others added 2 commits September 10, 2026 12:17
Moves learner pathway generation out of the learner-portal MFE into
enterprise-access, built on the existing apps/workflow pattern (ADR 0025)
as two workflows behind two endpoints.

  POST /api/v1/learner-pathways/careers/   intake -> career candidates
  POST /api/v1/learner-pathways/pathway/   selected career -> 5 courses

Both are behind LEARNER_PATHWAYS_SERVER_PIPELINE_ENABLED (default False,
so they 404) and an RBAC role. No MFE calls them.

Why this shape, and what the measurements changed:

- Step records already persist per-execution input, output, timing and
  failure, so the evaluation harness reads them instead of running a
  parallel implementation. That means we measure the real pipeline.

- Both Algolia indexes AND every query word with no fallback configured,
  so an 8-word query returns zero hits rather than poor ones. Retrieval
  sends removeWordsIfNoResults=allOptional and persists the query and hit
  count, because relaxation buys volume rather than relevance.

- The catalog's skill vocabulary is Lightcast-canonical: "Python" is not
  a facet value, "Python (Programming Language)" is. Skill terms are
  resolved against the catalog's own vocabulary rather than matched
  exactly. The facet snapshot is truncated at Algolia's 1,000-value cap,
  so a conditional facet-search pass recovers the long tail.

- Pathway structure is deterministic, not delegated to a model: five
  courses under a level quota and a per-provider cap. This addresses the
  reported "two 101 courses but zero 102 courses" defect, which measured
  as a selection problem rather than a content gap.

- Only topical relevance goes to a model, through an adapter selected by
  configuration so model comparison is a query over step records.

- Quality has a written bar in three tiers: correctness gates that are
  bugs rather than judgements, a ship bar as per-persona pass/fail, and
  tracked-not-gating metrics.

Adds ADR 0037 with a mermaid diagram of the composition, and
docs/references/algolia_search.md recording the measured index behaviour
the decisions rest on.

The measured bar is NOT met: 23% recall@20 and 0% on technology. This
lands the shape and the instrumentation, not a solved quality problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@macdiesel
macdiesel requested review from a team as code owners September 10, 2026 14:13
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.95986% with 113 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (86085cc) to head (52814d0).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...al/management/commands/run_retrieval_diagnostic.py 81.03% 12 Missing and 10 partials ⚠️
...eval/management/commands/report_pathway_harness.py 88.72% 11 Missing and 4 partials ⚠️
enterprise_access/apps/pathways/api.py 85.14% 14 Missing and 1 partial ⚠️
...ay_eval/management/commands/run_pathway_harness.py 84.33% 8 Missing and 5 partials ⚠️
...nterprise_access/apps/api_client/algolia_client.py 88.00% 12 Missing ⚠️
enterprise_access/apps/pathway_eval/personas.py 94.15% 5 Missing and 5 partials ⚠️
...rise_access/apps/pathways/model_backends/claude.py 82.60% 7 Missing and 1 partial ⚠️
...rise_access/apps/pathways/model_backends/openai.py 83.67% 7 Missing and 1 partial ⚠️
enterprise_access/apps/pathway_eval/harness.py 97.95% 2 Missing and 1 partial ⚠️
enterprise_access/apps/pathway_eval/scoring.py 97.22% 1 Missing and 2 partials ⚠️
... and 3 more

❌ Your patch check has failed because the patch coverage (94.95%) is below the target coverage (95.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #281      +/-   ##
==========================================
+ Coverage   87.72%   88.75%   +1.02%     
==========================================
  Files         157      182      +25     
  Lines       13489    15725    +2236     
  Branches     1312     1574     +262     
==========================================
+ Hits        11833    13956    +2123     
- Misses       1351     1432      +81     
- Partials      305      337      +32     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

macdiesel and others added 3 commits September 10, 2026 18:41
Seed migration
--------------
Without a candidate_rerank prompt row, XpertBackend raises, re-ranking
degrades to retrieval order, and the pipeline runs *without* its model
step while still returning a valid pathway. That failure is silent by
design -- losing the ordering beats losing the recommendation -- so a run
in that state reads as "the model does not help" when it was never
called. prompts/migrations/0003 seeds it so a fresh environment works.

The migration is idempotent (get_or_create on prompt_type), never
clobbers an admin edit, and reverses only if the row is still the seeded
text. Its text is inlined rather than imported: migrations are frozen
history, so importing a constant that will legitimately change would
make the migration mean something different later.

The prompt asks for topical relevance ONLY, and says so explicitly.
Level spread, provider spread and de-duplication are guaranteed
deterministically by pathway_assembly; asking a model to reproduce
arithmetic invites a disagreement someone then has to adjudicate. It
also asks for every key to be ranked rather than dropping poor fits,
because assembly fills each rung from the front of the window -- so last
place is how the model says "poor fit", and dropping loses that signal.

OpenAI backend
--------------
A third backend alongside xpert and claude, so the harness has two
metered backends to compare and "is there a model-class difference?"
becomes a query over persisted traces rather than an assertion. Lazily
imported like anthropic, so a Xpert-only deployment never loads it.

It requests response_format=json_object: every prompt here requires
JSON and OpenAI can enforce that server-side, removing a failure mode
rather than handling it.

Verified live against gpt-4o with a candidate set containing two
deliberately off-topic courses. Both ranked last, all six keys ranked
exactly once, no fabricated keys, and the rationales named the mismatch
plainly. 780/231 tokens, 5.3s -- the first real latency data point for
the budget ADR 0037 flags as outstanding.

Two pre-existing prompt tests asserted a whole-table row count and now
see the seeded row; they assert per prompt_type instead, which is what
the constraint under test actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by the first live end-to-end run of PathwayAssemblyWorkflow, executed on the
host against the pinned 2U catalog and gpt-4o.

The re-rank prompt asks for JSON but never names ``ordered_keys`` -- that field name
exists only in the output schema, which ``build_system_prompt`` appends from the
prompt's database row. The direct backends (claude, openai) have no row, so they were
sending the prompt text alone. gpt-4o duly returned a valid, well-reasoned ranking
under field names of its own choosing, ``parse_rerank_response`` recognised none of
them, and the run degraded to retrieval order.

That degradation is silent by design -- losing the ordering beats losing the pathway --
so the only evidence was one log line reading "no ordered_keys list", next to a
complete, apparently healthy 2/2/1 pathway. Nothing raised and no test failed: every
existing test supplies the response shape it expects, so none of them could observe a
prompt that failed to ask for that shape.

Extracts the composition rule as ``compose_system_prompt`` and uses it on both paths,
so a prompt validated on one backend is the prompt the other sends.

Verified live after the fix: 19 of 19 candidate keys ranked, zero fabricated, and the
ordering now changes which courses are selected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the LEARNER_PATHWAYS_SERVER_PIPELINE_ENABLED Django setting with a waffle
switch, so the pipeline can be turned on and off in Django admin without a deploy.

Switches rather than flags, deliberately. A waffle flag is request-scoped: enableable
per-user, by percentage, and -- with WAFFLE_OVERRIDE -- by a ?flag_name=1 query string.
A switch is one global boolean an administrator sets, and waffle never honours a
query-string override for one. So nobody can enable an unreleased pipeline that spends
money per call by crafting a URL. It also reads with no request at all, which a flag
cannot express off-request, so the harness and management commands honour the same
toggle as the endpoints. test_toggles.py asserts the type to stop a silent downgrade.

Adds a second switch as a re-rank kill switch, with inverted polarity: default off
means re-ranking runs. An enable-style switch defaulting to off would mean that turning
the pipeline on produced pathways with no model input at all -- retrieval order, no
error, a well-formed five-course response. That is the silent degradation this pipeline
was bitten by in b266cc5, and it must not be reachable by forgetting a second switch.
Turning it on is a supported degradation for cost, latency or a failing provider, and it
overrides the workflow's own per-run input so it stops harness spend too.

A switch that has never been created reads as off, so the previous safe default holds in
every environment with nothing configured.

Verified: both switches toggled via ./manage.py waffle_switch against a real database and
read back correctly through the helpers; a live end-to-end assembly run still re-ranks
19 of 19 candidates with the kill switch at its default. 2731 tests pass; isort,
pycodestyle, pylint and pii_check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant