@contractspec/example.learning-patterns 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$bundle.log +39 -0
- package/.turbo/turbo-build.log +40 -0
- package/CHANGELOG.md +178 -0
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/dist/docs/index.d.ts +1 -0
- package/dist/docs/index.js +1 -0
- package/dist/docs/learning-patterns.docblock.d.ts +1 -0
- package/dist/docs/learning-patterns.docblock.js +31 -0
- package/dist/docs/learning-patterns.docblock.js.map +1 -0
- package/dist/events.d.ts +16 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +16 -0
- package/dist/events.js.map +1 -0
- package/dist/example.d.ts +33 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +35 -0
- package/dist/example.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/tracks/ambient-coach.d.ts +8 -0
- package/dist/tracks/ambient-coach.d.ts.map +1 -0
- package/dist/tracks/ambient-coach.js +48 -0
- package/dist/tracks/ambient-coach.js.map +1 -0
- package/dist/tracks/drills.d.ts +8 -0
- package/dist/tracks/drills.d.ts.map +1 -0
- package/dist/tracks/drills.js +54 -0
- package/dist/tracks/drills.js.map +1 -0
- package/dist/tracks/index.d.ts +4 -0
- package/dist/tracks/index.js +5 -0
- package/dist/tracks/quests.d.ts +8 -0
- package/dist/tracks/quests.d.ts.map +1 -0
- package/dist/tracks/quests.js +56 -0
- package/dist/tracks/quests.js.map +1 -0
- package/example.ts +1 -0
- package/package.json +68 -0
- package/src/docs/index.ts +1 -0
- package/src/docs/learning-patterns.docblock.ts +30 -0
- package/src/events.ts +16 -0
- package/src/example.ts +24 -0
- package/src/index.ts +10 -0
- package/src/learning-patterns.test.ts +256 -0
- package/src/tracks/ambient-coach.ts +44 -0
- package/src/tracks/drills.ts +51 -0
- package/src/tracks/index.ts +3 -0
- package/src/tracks/quests.ts +44 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.js +7 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { LearningJourneyTrackSpec } from "@contractspec/module.learning-journey/track-spec";
|
|
2
|
+
|
|
3
|
+
//#region src/tracks/quests.d.ts
|
|
4
|
+
declare const questTrack: LearningJourneyTrackSpec;
|
|
5
|
+
declare const questTracks: LearningJourneyTrackSpec[];
|
|
6
|
+
//#endregion
|
|
7
|
+
export { questTrack, questTracks };
|
|
8
|
+
//# sourceMappingURL=quests.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quests.d.ts","names":[],"sources":["../../src/tracks/quests.ts"],"sourcesContent":[],"mappings":";;;cAGa,YAAY;cAwCZ,aAAa"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { LEARNING_EVENTS } from "../events.js";
|
|
2
|
+
|
|
3
|
+
//#region src/tracks/quests.ts
|
|
4
|
+
const questTrack = {
|
|
5
|
+
id: "learning_patterns_quest_7day",
|
|
6
|
+
name: "Quest (7-day)",
|
|
7
|
+
description: "Time-bounded quest with day unlocks.",
|
|
8
|
+
targetUserSegment: "learner",
|
|
9
|
+
targetRole: "individual",
|
|
10
|
+
totalXp: 70,
|
|
11
|
+
steps: [
|
|
12
|
+
{
|
|
13
|
+
id: "day1_start",
|
|
14
|
+
title: "Start the quest",
|
|
15
|
+
order: 1,
|
|
16
|
+
completion: {
|
|
17
|
+
kind: "event",
|
|
18
|
+
eventName: LEARNING_EVENTS.QUEST_STARTED
|
|
19
|
+
},
|
|
20
|
+
xpReward: 10
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "day1_complete",
|
|
24
|
+
title: "Complete day 1 step",
|
|
25
|
+
order: 2,
|
|
26
|
+
completion: {
|
|
27
|
+
kind: "event",
|
|
28
|
+
eventName: LEARNING_EVENTS.QUEST_STEP_COMPLETED
|
|
29
|
+
},
|
|
30
|
+
availability: {
|
|
31
|
+
unlockOnDay: 1,
|
|
32
|
+
dueWithinHours: 48
|
|
33
|
+
},
|
|
34
|
+
xpReward: 10
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "day2_complete",
|
|
38
|
+
title: "Complete day 2 step",
|
|
39
|
+
order: 3,
|
|
40
|
+
completion: {
|
|
41
|
+
kind: "event",
|
|
42
|
+
eventName: LEARNING_EVENTS.QUEST_STEP_COMPLETED
|
|
43
|
+
},
|
|
44
|
+
availability: {
|
|
45
|
+
unlockOnDay: 2,
|
|
46
|
+
dueWithinHours: 48
|
|
47
|
+
},
|
|
48
|
+
xpReward: 10
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
};
|
|
52
|
+
const questTracks = [questTrack];
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
export { questTrack, questTracks };
|
|
56
|
+
//# sourceMappingURL=quests.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quests.js","names":["questTrack: LearningJourneyTrackSpec","questTracks: LearningJourneyTrackSpec[]"],"sources":["../../src/tracks/quests.ts"],"sourcesContent":["import type { LearningJourneyTrackSpec } from '@contractspec/module.learning-journey/track-spec';\nimport { LEARNING_EVENTS } from '../events';\n\nexport const questTrack: LearningJourneyTrackSpec = {\n id: 'learning_patterns_quest_7day',\n name: 'Quest (7-day)',\n description: 'Time-bounded quest with day unlocks.',\n targetUserSegment: 'learner',\n targetRole: 'individual',\n totalXp: 70,\n steps: [\n {\n id: 'day1_start',\n title: 'Start the quest',\n order: 1,\n completion: { kind: 'event', eventName: LEARNING_EVENTS.QUEST_STARTED },\n xpReward: 10,\n },\n {\n id: 'day1_complete',\n title: 'Complete day 1 step',\n order: 2,\n completion: {\n kind: 'event',\n eventName: LEARNING_EVENTS.QUEST_STEP_COMPLETED,\n },\n availability: { unlockOnDay: 1, dueWithinHours: 48 },\n xpReward: 10,\n },\n {\n id: 'day2_complete',\n title: 'Complete day 2 step',\n order: 3,\n completion: {\n kind: 'event',\n eventName: LEARNING_EVENTS.QUEST_STEP_COMPLETED,\n },\n availability: { unlockOnDay: 2, dueWithinHours: 48 },\n xpReward: 10,\n },\n ],\n};\n\nexport const questTracks: LearningJourneyTrackSpec[] = [questTrack];\n"],"mappings":";;;AAGA,MAAaA,aAAuC;CAClD,IAAI;CACJ,MAAM;CACN,aAAa;CACb,mBAAmB;CACnB,YAAY;CACZ,SAAS;CACT,OAAO;EACL;GACE,IAAI;GACJ,OAAO;GACP,OAAO;GACP,YAAY;IAAE,MAAM;IAAS,WAAW,gBAAgB;IAAe;GACvE,UAAU;GACX;EACD;GACE,IAAI;GACJ,OAAO;GACP,OAAO;GACP,YAAY;IACV,MAAM;IACN,WAAW,gBAAgB;IAC5B;GACD,cAAc;IAAE,aAAa;IAAG,gBAAgB;IAAI;GACpD,UAAU;GACX;EACD;GACE,IAAI;GACJ,OAAO;GACP,OAAO;GACP,YAAY;IACV,MAAM;IACN,WAAW,gBAAgB;IAC5B;GACD,cAAc;IAAE,aAAa;IAAG,gBAAgB;IAAI;GACpD,UAAU;GACX;EACF;CACF;AAED,MAAaC,cAA0C,CAAC,WAAW"}
|
package/example.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './src/example';
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contractspec/example.learning-patterns",
|
|
3
|
+
"version": "1.44.0",
|
|
4
|
+
"description": "Example: drills + ambient coach + quests learning patterns, powered by Learning Journey (event-driven, deterministic).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js",
|
|
11
|
+
"./docs": "./dist/docs/index.js",
|
|
12
|
+
"./docs/learning-patterns.docblock": "./dist/docs/learning-patterns.docblock.js",
|
|
13
|
+
"./events": "./dist/events.js",
|
|
14
|
+
"./example": "./dist/example.js",
|
|
15
|
+
"./tracks": "./dist/tracks/index.js",
|
|
16
|
+
"./tracks/ambient-coach": "./dist/tracks/ambient-coach.js",
|
|
17
|
+
"./tracks/drills": "./dist/tracks/drills.js",
|
|
18
|
+
"./tracks/quests": "./dist/tracks/quests.js",
|
|
19
|
+
"./*": "./*"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"publish:pkg": "bun publish --tolerate-republish --ignore-scripts --verbose",
|
|
23
|
+
"publish:pkg:canary": "bun publish:pkg --tag canary",
|
|
24
|
+
"build": "bun build:types && bun build:bundle",
|
|
25
|
+
"build:bundle": "tsdown",
|
|
26
|
+
"build:types": "tsc --noEmit",
|
|
27
|
+
"dev": "bun build:bundle --watch",
|
|
28
|
+
"clean": "rimraf dist .turbo",
|
|
29
|
+
"lint": "bun lint:fix",
|
|
30
|
+
"lint:fix": "eslint src --fix",
|
|
31
|
+
"lint:check": "eslint src",
|
|
32
|
+
"test": "bun test"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@contractspec/lib.schema": "1.44.0",
|
|
36
|
+
"@contractspec/lib.contracts": "1.44.0",
|
|
37
|
+
"@contractspec/module.learning-journey": "1.44.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@contractspec/tool.tsdown": "1.44.0",
|
|
41
|
+
"@contractspec/tool.typescript": "1.44.0",
|
|
42
|
+
"tsdown": "^0.18.3",
|
|
43
|
+
"typescript": "^5.9.3"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"exports": {
|
|
48
|
+
".": "./dist/index.js",
|
|
49
|
+
"./docs": "./dist/docs/index.js",
|
|
50
|
+
"./docs/learning-patterns.docblock": "./dist/docs/learning-patterns.docblock.js",
|
|
51
|
+
"./events": "./dist/events.js",
|
|
52
|
+
"./example": "./dist/example.js",
|
|
53
|
+
"./tracks": "./dist/tracks/index.js",
|
|
54
|
+
"./tracks/ambient-coach": "./dist/tracks/ambient-coach.js",
|
|
55
|
+
"./tracks/drills": "./dist/tracks/drills.js",
|
|
56
|
+
"./tracks/quests": "./dist/tracks/quests.js",
|
|
57
|
+
"./*": "./*"
|
|
58
|
+
},
|
|
59
|
+
"registry": "https://registry.npmjs.org/"
|
|
60
|
+
},
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "https://github.com/lssm-tech/contractspec.git",
|
|
65
|
+
"directory": "packages/examples/learning-patterns"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://contractspec.io"
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './learning-patterns.docblock';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DocBlock } from '@contractspec/lib.contracts/docs';
|
|
2
|
+
import { registerDocBlocks } from '@contractspec/lib.contracts/docs';
|
|
3
|
+
|
|
4
|
+
const docBlocks: DocBlock[] = [
|
|
5
|
+
{
|
|
6
|
+
id: 'docs.examples.learning-patterns.goal',
|
|
7
|
+
title: 'Learning Patterns — Goal',
|
|
8
|
+
summary:
|
|
9
|
+
'Domain-agnostic drills, ambient coaching, and quests built on Learning Journey.',
|
|
10
|
+
kind: 'goal',
|
|
11
|
+
visibility: 'public',
|
|
12
|
+
route: '/docs/examples/learning-patterns/goal',
|
|
13
|
+
tags: ['learning', 'drills', 'quests', 'coaching'],
|
|
14
|
+
body: `## Why it matters
|
|
15
|
+
- Demonstrates multiple learning archetypes without vertical coupling.\n- Progress is event-driven (no client-side hacks).\n- SRS logic is deterministic and testable.`,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'docs.examples.learning-patterns.reference',
|
|
19
|
+
title: 'Learning Patterns — Reference',
|
|
20
|
+
summary:
|
|
21
|
+
'Track specs and event names exported by the learning patterns example.',
|
|
22
|
+
kind: 'reference',
|
|
23
|
+
visibility: 'public',
|
|
24
|
+
route: '/docs/examples/learning-patterns',
|
|
25
|
+
tags: ['learning', 'reference'],
|
|
26
|
+
body: `## Tracks\n- Drills + SRS\n- Ambient Coach\n- Quests\n\n## Events\n- drill.*\n- coach.*\n- quest.*`,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
registerDocBlocks(docBlocks);
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const LEARNING_EVENTS = {
|
|
2
|
+
DRILL_CARD_ANSWERED: 'drill.card.answered',
|
|
3
|
+
DRILL_SESSION_COMPLETED: 'drill.session.completed',
|
|
4
|
+
DRILL_CARD_MASTERED: 'drill.card.mastered',
|
|
5
|
+
|
|
6
|
+
COACH_TIP_TRIGGERED: 'coach.tip.triggered',
|
|
7
|
+
COACH_TIP_SHOWN: 'coach.tip.shown',
|
|
8
|
+
COACH_TIP_ACKNOWLEDGED: 'coach.tip.acknowledged',
|
|
9
|
+
COACH_TIP_ACTION_TAKEN: 'coach.tip.actionTaken',
|
|
10
|
+
|
|
11
|
+
QUEST_STARTED: 'quest.started',
|
|
12
|
+
QUEST_STEP_COMPLETED: 'quest.step.completed',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type LearningEventName =
|
|
16
|
+
(typeof LEARNING_EVENTS)[keyof typeof LEARNING_EVENTS];
|
package/src/example.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const example = {
|
|
2
|
+
id: 'learning-patterns',
|
|
3
|
+
title: 'Learning Patterns',
|
|
4
|
+
summary:
|
|
5
|
+
'Domain-agnostic learning archetypes implemented as Learning Journey tracks.',
|
|
6
|
+
tags: ['learning', 'journey', 'patterns'],
|
|
7
|
+
kind: 'library',
|
|
8
|
+
visibility: 'public',
|
|
9
|
+
docs: {
|
|
10
|
+
rootDocId: 'docs.examples.learning-patterns',
|
|
11
|
+
},
|
|
12
|
+
entrypoints: {
|
|
13
|
+
packageName: '@contractspec/example.learning-patterns',
|
|
14
|
+
docs: './docs',
|
|
15
|
+
},
|
|
16
|
+
surfaces: {
|
|
17
|
+
templates: true,
|
|
18
|
+
sandbox: { enabled: true, modes: ['markdown', 'specs'] },
|
|
19
|
+
studio: { enabled: true, installable: true },
|
|
20
|
+
mcp: { enabled: true },
|
|
21
|
+
},
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export default example;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
LearningJourneyTrackSpec,
|
|
5
|
+
StepAvailabilitySpec,
|
|
6
|
+
StepCompletionConditionSpec,
|
|
7
|
+
} from '@contractspec/module.learning-journey/track-spec';
|
|
8
|
+
import { SRSEngine } from '@contractspec/module.learning-journey/engines/srs';
|
|
9
|
+
import { StreakEngine } from '@contractspec/module.learning-journey/engines/streak';
|
|
10
|
+
import { XPEngine } from '@contractspec/module.learning-journey/engines/xp';
|
|
11
|
+
|
|
12
|
+
import { ambientCoachTrack } from './tracks/ambient-coach';
|
|
13
|
+
import { drillsTrack } from './tracks/drills';
|
|
14
|
+
import { questTrack } from './tracks/quests';
|
|
15
|
+
import { LEARNING_EVENTS } from './events';
|
|
16
|
+
|
|
17
|
+
interface LearningEvent {
|
|
18
|
+
name: string;
|
|
19
|
+
payload?: Record<string, unknown>;
|
|
20
|
+
occurredAt?: Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StepState {
|
|
24
|
+
id: string;
|
|
25
|
+
status: 'PENDING' | 'COMPLETED';
|
|
26
|
+
occurrences: number;
|
|
27
|
+
masteryCount: number;
|
|
28
|
+
availableAt?: Date;
|
|
29
|
+
dueAt?: Date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const matchesFilter = (
|
|
33
|
+
filter: Record<string, unknown> | undefined,
|
|
34
|
+
payload: Record<string, unknown> | undefined
|
|
35
|
+
): boolean => {
|
|
36
|
+
if (!filter) return true;
|
|
37
|
+
if (!payload) return false;
|
|
38
|
+
return Object.entries(filter).every(([k, v]) => payload[k] === v);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getAvailability = (
|
|
42
|
+
availability: StepAvailabilitySpec | undefined,
|
|
43
|
+
startedAt: Date | undefined
|
|
44
|
+
): { availableAt?: Date; dueAt?: Date } => {
|
|
45
|
+
if (!availability || !startedAt) return {};
|
|
46
|
+
const baseTime = startedAt.getTime();
|
|
47
|
+
let unlockTime = baseTime;
|
|
48
|
+
if (availability.unlockOnDay !== undefined) {
|
|
49
|
+
unlockTime =
|
|
50
|
+
baseTime + (availability.unlockOnDay - 1) * 24 * 60 * 60 * 1000;
|
|
51
|
+
}
|
|
52
|
+
if (availability.unlockAfterHours !== undefined) {
|
|
53
|
+
unlockTime = baseTime + availability.unlockAfterHours * 60 * 60 * 1000;
|
|
54
|
+
}
|
|
55
|
+
const availableAt = new Date(unlockTime);
|
|
56
|
+
const dueAt =
|
|
57
|
+
availability.dueWithinHours !== undefined
|
|
58
|
+
? new Date(
|
|
59
|
+
availableAt.getTime() + availability.dueWithinHours * 60 * 60 * 1000
|
|
60
|
+
)
|
|
61
|
+
: undefined;
|
|
62
|
+
return { availableAt, dueAt };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const matchesCondition = (
|
|
66
|
+
condition: StepCompletionConditionSpec,
|
|
67
|
+
event: LearningEvent,
|
|
68
|
+
step: StepState
|
|
69
|
+
): { matched: boolean; occurrences?: number; masteryCount?: number } => {
|
|
70
|
+
if ((condition.kind ?? 'event') === 'event') {
|
|
71
|
+
if (condition.eventName !== event.name) return { matched: false };
|
|
72
|
+
if (!matchesFilter(condition.payloadFilter, event.payload))
|
|
73
|
+
return { matched: false };
|
|
74
|
+
return { matched: true };
|
|
75
|
+
}
|
|
76
|
+
if (condition.kind === 'count') {
|
|
77
|
+
if (condition.eventName !== event.name) return { matched: false };
|
|
78
|
+
if (!matchesFilter(condition.payloadFilter, event.payload))
|
|
79
|
+
return { matched: false };
|
|
80
|
+
const occurrences = step.occurrences + 1;
|
|
81
|
+
return { matched: occurrences >= condition.atLeast, occurrences };
|
|
82
|
+
}
|
|
83
|
+
if (condition.kind === 'srs_mastery') {
|
|
84
|
+
if (condition.eventName !== event.name) return { matched: false };
|
|
85
|
+
if (!matchesFilter(condition.payloadFilter, event.payload))
|
|
86
|
+
return { matched: false };
|
|
87
|
+
const masteryKey = condition.masteryField ?? 'mastery';
|
|
88
|
+
const masteryValue = event.payload?.[masteryKey];
|
|
89
|
+
if (typeof masteryValue !== 'number') return { matched: false };
|
|
90
|
+
if (masteryValue < condition.minimumMastery) return { matched: false };
|
|
91
|
+
const masteryCount = step.masteryCount + 1;
|
|
92
|
+
const required = condition.requiredCount ?? 1;
|
|
93
|
+
return { matched: masteryCount >= required, masteryCount };
|
|
94
|
+
}
|
|
95
|
+
if (condition.kind === 'time_window') {
|
|
96
|
+
// For this example suite, we treat time_window as a direct match on eventName
|
|
97
|
+
if (condition.eventName !== event.name) return { matched: false };
|
|
98
|
+
return { matched: true };
|
|
99
|
+
}
|
|
100
|
+
return { matched: false };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function initProgress(track: LearningJourneyTrackSpec): StepState[] {
|
|
104
|
+
return track.steps.map((s) => ({
|
|
105
|
+
id: s.id,
|
|
106
|
+
status: 'PENDING',
|
|
107
|
+
occurrences: 0,
|
|
108
|
+
masteryCount: 0,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function applyEvents(
|
|
113
|
+
track: LearningJourneyTrackSpec,
|
|
114
|
+
events: LearningEvent[]
|
|
115
|
+
): StepState[] {
|
|
116
|
+
const steps = initProgress(track);
|
|
117
|
+
let startedAt: Date | undefined;
|
|
118
|
+
for (const event of events) {
|
|
119
|
+
const eventTime = event.occurredAt ?? new Date();
|
|
120
|
+
if (!startedAt) startedAt = eventTime;
|
|
121
|
+
for (let index = 0; index < track.steps.length; index++) {
|
|
122
|
+
const spec = track.steps[index];
|
|
123
|
+
const state = steps[index];
|
|
124
|
+
if (!spec || !state) continue;
|
|
125
|
+
if (state.status === 'COMPLETED') continue;
|
|
126
|
+
const { availableAt, dueAt } = getAvailability(
|
|
127
|
+
spec.availability,
|
|
128
|
+
startedAt
|
|
129
|
+
);
|
|
130
|
+
state.availableAt = availableAt;
|
|
131
|
+
state.dueAt = dueAt;
|
|
132
|
+
if (availableAt && eventTime < availableAt) continue;
|
|
133
|
+
if (dueAt && eventTime > dueAt) continue;
|
|
134
|
+
const res = matchesCondition(spec.completion, event, state);
|
|
135
|
+
if (res.occurrences !== undefined) state.occurrences = res.occurrences;
|
|
136
|
+
if (res.masteryCount !== undefined) state.masteryCount = res.masteryCount;
|
|
137
|
+
if (res.matched) state.status = 'COMPLETED';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return steps;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe('@contractspec/example.learning-patterns tracks', () => {
|
|
144
|
+
it('drills track progresses via session count + mastery', () => {
|
|
145
|
+
const events: LearningEvent[] = [
|
|
146
|
+
{ name: LEARNING_EVENTS.DRILL_SESSION_COMPLETED },
|
|
147
|
+
{
|
|
148
|
+
name: LEARNING_EVENTS.DRILL_SESSION_COMPLETED,
|
|
149
|
+
payload: { accuracyBucket: 'high' },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: LEARNING_EVENTS.DRILL_SESSION_COMPLETED,
|
|
153
|
+
payload: { accuracyBucket: 'high' },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: LEARNING_EVENTS.DRILL_SESSION_COMPLETED,
|
|
157
|
+
payload: { accuracyBucket: 'high' },
|
|
158
|
+
},
|
|
159
|
+
...Array.from({ length: 5 }).map(() => ({
|
|
160
|
+
name: LEARNING_EVENTS.DRILL_CARD_MASTERED,
|
|
161
|
+
payload: { skillId: 's1', mastery: 0.9 },
|
|
162
|
+
})),
|
|
163
|
+
];
|
|
164
|
+
const progress = applyEvents(drillsTrack, events);
|
|
165
|
+
expect(progress.every((s) => s.status === 'COMPLETED')).toBeTrue();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('ambient coach track progresses via shown -> acknowledged -> actionTaken', () => {
|
|
169
|
+
const progress = applyEvents(ambientCoachTrack, [
|
|
170
|
+
{ name: LEARNING_EVENTS.COACH_TIP_SHOWN },
|
|
171
|
+
{ name: LEARNING_EVENTS.COACH_TIP_ACKNOWLEDGED },
|
|
172
|
+
{ name: LEARNING_EVENTS.COACH_TIP_ACTION_TAKEN },
|
|
173
|
+
]);
|
|
174
|
+
expect(progress.every((s) => s.status === 'COMPLETED')).toBeTrue();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('quest track respects unlockOnDay availability', () => {
|
|
178
|
+
const start = new Date('2026-01-01T10:00:00.000Z');
|
|
179
|
+
const day1 = new Date('2026-01-01T12:00:00.000Z');
|
|
180
|
+
const day2 = new Date('2026-01-02T12:00:00.000Z');
|
|
181
|
+
|
|
182
|
+
// Attempt to complete steps on day1 (only day1 step should unlock)
|
|
183
|
+
const p1 = applyEvents(questTrack, [
|
|
184
|
+
{ name: LEARNING_EVENTS.QUEST_STARTED, occurredAt: start },
|
|
185
|
+
{ name: LEARNING_EVENTS.QUEST_STEP_COMPLETED, occurredAt: day1 },
|
|
186
|
+
]);
|
|
187
|
+
expect(p1[0]?.status).toBe('COMPLETED');
|
|
188
|
+
expect(p1[1]?.status).toBe('COMPLETED');
|
|
189
|
+
expect(p1[2]?.status).toBe('PENDING'); // day2 step not yet available
|
|
190
|
+
|
|
191
|
+
// Now complete on day2
|
|
192
|
+
const p2 = applyEvents(questTrack, [
|
|
193
|
+
{ name: LEARNING_EVENTS.QUEST_STARTED, occurredAt: start },
|
|
194
|
+
{ name: LEARNING_EVENTS.QUEST_STEP_COMPLETED, occurredAt: day2 },
|
|
195
|
+
]);
|
|
196
|
+
expect(p2[2]?.status).toBe('COMPLETED');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('@contractspec/example.learning-patterns XP + streak + SRS determinism', () => {
|
|
201
|
+
it('XP engine produces deterministic results for streak bonus inputs', () => {
|
|
202
|
+
const xp = new XPEngine();
|
|
203
|
+
const r1 = xp.calculate({
|
|
204
|
+
activity: 'lesson_complete',
|
|
205
|
+
score: 90,
|
|
206
|
+
attemptNumber: 1,
|
|
207
|
+
currentStreak: 7,
|
|
208
|
+
});
|
|
209
|
+
const r2 = xp.calculate({
|
|
210
|
+
activity: 'lesson_complete',
|
|
211
|
+
score: 90,
|
|
212
|
+
attemptNumber: 1,
|
|
213
|
+
currentStreak: 7,
|
|
214
|
+
});
|
|
215
|
+
expect(r1.totalXp).toBe(r2.totalXp);
|
|
216
|
+
expect(r1.totalXp).toBeGreaterThan(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('streak engine increments on consecutive days deterministically', () => {
|
|
220
|
+
const streak = new StreakEngine({ timezone: 'UTC' });
|
|
221
|
+
const initial = {
|
|
222
|
+
currentStreak: 0,
|
|
223
|
+
longestStreak: 0,
|
|
224
|
+
lastActivityAt: null,
|
|
225
|
+
lastActivityDate: null,
|
|
226
|
+
freezesRemaining: 0,
|
|
227
|
+
freezeUsedAt: null,
|
|
228
|
+
};
|
|
229
|
+
const day1 = streak.update(
|
|
230
|
+
initial,
|
|
231
|
+
new Date('2026-01-01T10:00:00.000Z')
|
|
232
|
+
).state;
|
|
233
|
+
const day2 = streak.update(
|
|
234
|
+
day1,
|
|
235
|
+
new Date('2026-01-02T10:00:00.000Z')
|
|
236
|
+
).state;
|
|
237
|
+
expect(day2.currentStreak).toBe(2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('SRS engine nextReviewAt is deterministic for a fixed now + rating', () => {
|
|
241
|
+
const srs = new SRSEngine();
|
|
242
|
+
const now = new Date('2026-01-01T00:00:00.000Z');
|
|
243
|
+
const state = {
|
|
244
|
+
interval: 0,
|
|
245
|
+
easeFactor: 2.5,
|
|
246
|
+
repetitions: 0,
|
|
247
|
+
learningStep: 0,
|
|
248
|
+
isGraduated: false,
|
|
249
|
+
isRelearning: false,
|
|
250
|
+
lapses: 0,
|
|
251
|
+
};
|
|
252
|
+
const result = srs.calculateNextReview(state, 'GOOD', now);
|
|
253
|
+
// default learningSteps are minutes; first GOOD advances to next step (10 minutes)
|
|
254
|
+
expect(result.nextReviewAt.toISOString()).toBe('2026-01-01T00:10:00.000Z');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LearningJourneyTrackSpec } from '@contractspec/module.learning-journey/track-spec';
|
|
2
|
+
import { LEARNING_EVENTS } from '../events';
|
|
3
|
+
|
|
4
|
+
export const ambientCoachTrack: LearningJourneyTrackSpec = {
|
|
5
|
+
id: 'learning_patterns_ambient_coach_basics',
|
|
6
|
+
name: 'Ambient Coach Basics',
|
|
7
|
+
description: 'Contextual tips triggered by behavior events.',
|
|
8
|
+
targetUserSegment: 'learner',
|
|
9
|
+
targetRole: 'individual',
|
|
10
|
+
totalXp: 30,
|
|
11
|
+
steps: [
|
|
12
|
+
{
|
|
13
|
+
id: 'tip_shown',
|
|
14
|
+
title: 'See a contextual tip',
|
|
15
|
+
order: 1,
|
|
16
|
+
completion: { kind: 'event', eventName: LEARNING_EVENTS.COACH_TIP_SHOWN },
|
|
17
|
+
xpReward: 10,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'tip_acknowledged',
|
|
21
|
+
title: 'Acknowledge a tip',
|
|
22
|
+
order: 2,
|
|
23
|
+
completion: {
|
|
24
|
+
kind: 'event',
|
|
25
|
+
eventName: LEARNING_EVENTS.COACH_TIP_ACKNOWLEDGED,
|
|
26
|
+
},
|
|
27
|
+
xpReward: 10,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'tip_action_taken',
|
|
31
|
+
title: 'Take an action from a tip',
|
|
32
|
+
order: 3,
|
|
33
|
+
completion: {
|
|
34
|
+
kind: 'event',
|
|
35
|
+
eventName: LEARNING_EVENTS.COACH_TIP_ACTION_TAKEN,
|
|
36
|
+
},
|
|
37
|
+
xpReward: 10,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const ambientCoachTracks: LearningJourneyTrackSpec[] = [
|
|
43
|
+
ambientCoachTrack,
|
|
44
|
+
];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { LearningJourneyTrackSpec } from '@contractspec/module.learning-journey/track-spec';
|
|
2
|
+
import { LEARNING_EVENTS } from '../events';
|
|
3
|
+
|
|
4
|
+
export const drillsTrack: LearningJourneyTrackSpec = {
|
|
5
|
+
id: 'learning_patterns_drills_basics',
|
|
6
|
+
name: 'Drills Basics',
|
|
7
|
+
description: 'Short drill sessions with an SRS-style mastery step.',
|
|
8
|
+
targetUserSegment: 'learner',
|
|
9
|
+
targetRole: 'individual',
|
|
10
|
+
totalXp: 50,
|
|
11
|
+
steps: [
|
|
12
|
+
{
|
|
13
|
+
id: 'complete_first_session',
|
|
14
|
+
title: 'Complete your first session',
|
|
15
|
+
order: 1,
|
|
16
|
+
completion: {
|
|
17
|
+
kind: 'event',
|
|
18
|
+
eventName: LEARNING_EVENTS.DRILL_SESSION_COMPLETED,
|
|
19
|
+
},
|
|
20
|
+
xpReward: 10,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'hit_accuracy_threshold',
|
|
24
|
+
title: 'Hit high accuracy 3 times',
|
|
25
|
+
order: 2,
|
|
26
|
+
completion: {
|
|
27
|
+
kind: 'count',
|
|
28
|
+
eventName: LEARNING_EVENTS.DRILL_SESSION_COMPLETED,
|
|
29
|
+
atLeast: 3,
|
|
30
|
+
payloadFilter: { accuracyBucket: 'high' },
|
|
31
|
+
},
|
|
32
|
+
xpReward: 20,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'master_cards',
|
|
36
|
+
title: 'Master 5 cards',
|
|
37
|
+
order: 3,
|
|
38
|
+
completion: {
|
|
39
|
+
kind: 'srs_mastery',
|
|
40
|
+
eventName: LEARNING_EVENTS.DRILL_CARD_MASTERED,
|
|
41
|
+
minimumMastery: 0.8,
|
|
42
|
+
requiredCount: 5,
|
|
43
|
+
skillIdField: 'skillId',
|
|
44
|
+
masteryField: 'mastery',
|
|
45
|
+
},
|
|
46
|
+
xpReward: 20,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const drillTracks: LearningJourneyTrackSpec[] = [drillsTrack];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LearningJourneyTrackSpec } from '@contractspec/module.learning-journey/track-spec';
|
|
2
|
+
import { LEARNING_EVENTS } from '../events';
|
|
3
|
+
|
|
4
|
+
export const questTrack: LearningJourneyTrackSpec = {
|
|
5
|
+
id: 'learning_patterns_quest_7day',
|
|
6
|
+
name: 'Quest (7-day)',
|
|
7
|
+
description: 'Time-bounded quest with day unlocks.',
|
|
8
|
+
targetUserSegment: 'learner',
|
|
9
|
+
targetRole: 'individual',
|
|
10
|
+
totalXp: 70,
|
|
11
|
+
steps: [
|
|
12
|
+
{
|
|
13
|
+
id: 'day1_start',
|
|
14
|
+
title: 'Start the quest',
|
|
15
|
+
order: 1,
|
|
16
|
+
completion: { kind: 'event', eventName: LEARNING_EVENTS.QUEST_STARTED },
|
|
17
|
+
xpReward: 10,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'day1_complete',
|
|
21
|
+
title: 'Complete day 1 step',
|
|
22
|
+
order: 2,
|
|
23
|
+
completion: {
|
|
24
|
+
kind: 'event',
|
|
25
|
+
eventName: LEARNING_EVENTS.QUEST_STEP_COMPLETED,
|
|
26
|
+
},
|
|
27
|
+
availability: { unlockOnDay: 1, dueWithinHours: 48 },
|
|
28
|
+
xpReward: 10,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'day2_complete',
|
|
32
|
+
title: 'Complete day 2 step',
|
|
33
|
+
order: 3,
|
|
34
|
+
completion: {
|
|
35
|
+
kind: 'event',
|
|
36
|
+
eventName: LEARNING_EVENTS.QUEST_STEP_COMPLETED,
|
|
37
|
+
},
|
|
38
|
+
availability: { unlockOnDay: 2, dueWithinHours: 48 },
|
|
39
|
+
xpReward: 10,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const questTracks: LearningJourneyTrackSpec[] = [questTrack];
|
package/tsconfig.json
ADDED