chore: establish learning module baseline
This commit is contained in:
commit
2d04197f3f
2409 files changed
+533050
No files matched your search
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* PBL v2 — Stage 3 (dynamic) signal pipeline.
|
||||
*
|
||||
* Wraps the bare `ProficiencySignal` builders from `proficiency.ts`
|
||||
* into one-line entry points the Instructor calls from inside its
|
||||
* tool executes. Each entry point:
|
||||
*
|
||||
* 1. Builds the appropriate `ProficiencySignal`.
|
||||
* 2. Folds it into the project's assessment via
|
||||
* `updateProjectAssessment` (EWMA + retier-gate logic).
|
||||
* 3. On a tier transition, appends a `proficiency_changed`
|
||||
* engagement event so the silent audit trail captures the
|
||||
* switch.
|
||||
* 4. Returns the SSE patches the caller should yield so the
|
||||
* client's dev badge and engagement ledger stay in sync.
|
||||
*
|
||||
* Why centralise: keeping the conversion + bookkeeping + SSE
|
||||
* emission in one place means the Instructor file only deals with
|
||||
* its existing concerns (LLM streaming, tools, force-advance) and
|
||||
* one new line per tool — `yield* trackXxx(project, ...)`. It also
|
||||
* keeps the algorithm side of things easily unit-testable without
|
||||
* mocking the streaming layer.
|
||||
*
|
||||
* **No UI side-effect by design.** The patches emitted here are only
|
||||
* consumed by the dev badge (env-flag gated) and the silent
|
||||
* engagement ledger — never the chat. See INTEGRATION-PLAN §9 for
|
||||
* the rationale.
|
||||
*/
|
||||
|
||||
import { microtaskEngagement, recordEvent } from '../kernel/engagement';
|
||||
import {
|
||||
ensureAssessment,
|
||||
explicitAssessment,
|
||||
signalFromClosingCheck,
|
||||
signalFromForceAdvance,
|
||||
signalFromObservation,
|
||||
signalFromSubmissionScore,
|
||||
signalFromTaskSpeed,
|
||||
stepProficiency,
|
||||
updateProjectAssessment,
|
||||
type ProficiencyDirective,
|
||||
} from '../kernel/proficiency';
|
||||
import { appendProficiencyUpdatedRuntimeEvent } from '../kernel/runtime-events';
|
||||
import type { PBLProjectV2, ProficiencyTransition } from '../../types';
|
||||
import type { PBLSSEEvent } from '../../api/sse';
|
||||
|
||||
/** Did a transition fire on the last signal? Used by callers that
|
||||
* need to render the transition history outside the SSE channel
|
||||
* (e.g. the future evaluator). */
|
||||
export interface DynamicSignalResult {
|
||||
transition?: ProficiencyTransition;
|
||||
patches: PBLSSEEvent[];
|
||||
}
|
||||
|
||||
/** Append the engagement event and build the SSE patches for a
|
||||
* signal-driven update. Pure-ish: mutates `project` via the
|
||||
* engagement ledger (matches the rest of the operations module). */
|
||||
function emit(project: PBLProjectV2, transition?: ProficiencyTransition): DynamicSignalResult {
|
||||
const patches: PBLSSEEvent[] = [];
|
||||
if (transition) {
|
||||
const evt = recordEvent(project, 'proficiency_changed', {
|
||||
payload: {
|
||||
from: transition.from,
|
||||
to: transition.to,
|
||||
reason: transition.reason,
|
||||
score: project.proficiencyAssessment?.score,
|
||||
confidence: project.proficiencyAssessment?.confidence,
|
||||
},
|
||||
});
|
||||
patches.push({
|
||||
type: 'project_patch',
|
||||
patch: {
|
||||
kind: 'engagement_event',
|
||||
event: evt,
|
||||
eventKind: 'proficiency_changed',
|
||||
microtaskId: evt.microtaskId,
|
||||
milestoneId: evt.milestoneId,
|
||||
ts: evt.ts,
|
||||
payload: evt.payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (project.proficiencyAssessment) {
|
||||
patches.push({
|
||||
type: 'project_patch',
|
||||
patch: {
|
||||
kind: 'proficiency',
|
||||
assessment: project.proficiencyAssessment,
|
||||
tierChanged: !!transition,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { transition, patches };
|
||||
}
|
||||
|
||||
/** Convert an Instructor `record_observation` tool call into a
|
||||
* proficiency signal. Caller should pass `repeat` when the same
|
||||
* error signature has been seen in the same microtask before — the
|
||||
* engagement ledger has `repeatErrorCount` cached on completion. */
|
||||
export function trackObservation(
|
||||
project: PBLProjectV2,
|
||||
kind: 'error' | 'concept_unlocked' | 'struggle' | 'question',
|
||||
opts: { repeat?: boolean; note?: string } = {},
|
||||
): DynamicSignalResult {
|
||||
const signal = signalFromObservation(kind, opts);
|
||||
const { transition } = updateProjectAssessment(project, signal);
|
||||
return emit(project, transition);
|
||||
}
|
||||
|
||||
/** Convert an Instructor `record_closing_check` tool call into a
|
||||
* proficiency signal. */
|
||||
export function trackClosingCheck(
|
||||
project: PBLProjectV2,
|
||||
quality: 'weak' | 'ok' | 'strong',
|
||||
): DynamicSignalResult {
|
||||
const signal = signalFromClosingCheck(quality);
|
||||
const { transition } = updateProjectAssessment(project, signal);
|
||||
return emit(project, transition);
|
||||
}
|
||||
|
||||
/** Convert a force-advance trigger into a proficiency signal. */
|
||||
export function trackForceAdvance(project: PBLProjectV2): DynamicSignalResult {
|
||||
const signal = signalFromForceAdvance();
|
||||
const { transition } = updateProjectAssessment(project, signal);
|
||||
return emit(project, transition);
|
||||
}
|
||||
|
||||
/** Convert a microtask completion into a task-speed signal, derived
|
||||
* from the engagement ledger's `learnerTurnCount` for that
|
||||
* microtask. No-op on extremely short/medium ranges where the
|
||||
* speed has no clear directional reading. */
|
||||
export function trackMicrotaskCompletion(
|
||||
project: PBLProjectV2,
|
||||
microtaskId: string,
|
||||
): DynamicSignalResult {
|
||||
const summary = microtaskEngagement(project, microtaskId);
|
||||
const signal = signalFromTaskSpeed(summary.learnerTurnCount ?? 0);
|
||||
if (!signal) {
|
||||
// Neutral middle band; still emit a snapshot of the unchanged
|
||||
// assessment so the dev badge stays current after every
|
||||
// microtask completion (caller decides whether to forward).
|
||||
if (!project.proficiencyAssessment) return { patches: [] };
|
||||
return {
|
||||
patches: [
|
||||
{
|
||||
type: 'project_patch',
|
||||
patch: {
|
||||
kind: 'proficiency',
|
||||
assessment: project.proficiencyAssessment,
|
||||
tierChanged: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const { transition } = updateProjectAssessment(project, signal);
|
||||
return emit(project, transition);
|
||||
}
|
||||
|
||||
/** Convert a submission score into a proficiency signal. Used by the
|
||||
* evaluator path (PR 6); included here so the wiring is in one
|
||||
* place. */
|
||||
export function trackSubmissionScore(project: PBLProjectV2, score: number): DynamicSignalResult {
|
||||
const signal = signalFromSubmissionScore(score);
|
||||
if (!signal) return { patches: [] };
|
||||
const { transition } = updateProjectAssessment(project, signal);
|
||||
return emit(project, transition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an already-resolved difficulty directive, bypassing the dynamic retier
|
||||
* gates (highest priority). The directive comes from the Instructor's
|
||||
* `adjust_difficulty` tool: the LLM judges — from the learner's message, in any
|
||||
* language — whether they asked to change difficulty / stated their own level,
|
||||
* and a learner telling us what they want is treated as ground truth (set the
|
||||
* tier directly, immediately, bypassing every gate). No-op (no patches) when
|
||||
* the directive resolves to the learner's current tier.
|
||||
*
|
||||
* Performance signals can still adapt the tier later (the override resets the
|
||||
* retier counters, so it also wins over any same-turn dynamic signal during the
|
||||
* cooldown window) — exactly like an initial self-report.
|
||||
*
|
||||
* Same-tier directive (target == current): NOT a no-op. The learner still
|
||||
* explicitly stated their level, so we anchor it — lock confidence to 1, mark
|
||||
* the source as self-report and reset the cooldown counters — WITHOUT
|
||||
* fabricating a "tier changed" transition or engagement event. Skipping this
|
||||
* (the old behaviour) let the explicit declaration be silently overwritten by a
|
||||
* couple of subsequent dynamic signals, which is exactly the drift the learner
|
||||
* was trying to prevent.
|
||||
*/
|
||||
export function applyProficiencyDirective(
|
||||
project: PBLProjectV2,
|
||||
directive: ProficiencyDirective,
|
||||
): DynamicSignalResult {
|
||||
const current = ensureAssessment(project);
|
||||
const target =
|
||||
directive.kind === 'absolute'
|
||||
? directive.tier
|
||||
: stepProficiency(current.tier, directive.direction);
|
||||
|
||||
const next = explicitAssessment(target, 'self-report');
|
||||
if (current.tier === target) {
|
||||
// Anchor in place: keep the existing transition history, emit only the
|
||||
// proficiency patch (tierChanged:false) so the dev badge / state stay in
|
||||
// sync. No transition → no proficiency_changed event.
|
||||
project.proficiencyAssessment = { ...next, transitions: current.transitions };
|
||||
project.proficiency = target;
|
||||
appendProficiencyUpdatedRuntimeEvent(project);
|
||||
project.updatedAt = next.lastUpdatedAt;
|
||||
return emit(project);
|
||||
}
|
||||
|
||||
const transition: ProficiencyTransition = {
|
||||
from: current.tier,
|
||||
to: target,
|
||||
ts: next.lastUpdatedAt,
|
||||
reason: directive.kind === 'absolute' ? 'learner self-report' : 'learner difficulty request',
|
||||
};
|
||||
project.proficiencyAssessment = { ...next, transitions: [...current.transitions, transition] };
|
||||
project.proficiency = target;
|
||||
appendProficiencyUpdatedRuntimeEvent(project);
|
||||
project.updatedAt = next.lastUpdatedAt;
|
||||
return emit(project, transition);
|
||||
}
|
||||
|
||||
/** Increment the assessment's `turnsSinceRetier` counter at the
|
||||
* start of every learner-driven turn so the cooldown gate has
|
||||
* fresh data. Called once per Instructor turn from the route
|
||||
* handler / runInstructorTurn. */
|
||||
export function tickTurnOnProject(project: PBLProjectV2): void {
|
||||
if (!project.proficiencyAssessment) return;
|
||||
project.proficiencyAssessment = {
|
||||
...project.proficiencyAssessment,
|
||||
turnsSinceRetier: project.proficiencyAssessment.turnsSinceRetier + 1,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user