Add specifications for gamification, generation, and R42 chat services

- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data.
- Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling.
- Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
This commit is contained in:
RaymondVerhoef
2026-05-23 18:13:08 +02:00
parent dda20612e9
commit 472685f0d7
62 changed files with 11552 additions and 21 deletions

View File

@@ -0,0 +1,139 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "0lr0nf3rzh5tdq3",
"created": "2026-05-23 13:57:33.625Z",
"updated": "2026-05-23 13:57:33.625Z",
"name": "source_documents",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "ju0ymfsy",
"name": "filename",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "7q9zkqtk",
"name": "file",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"application/pdf",
"text/markdown",
"text/x-markdown",
"text/plain"
],
"thumbs": [],
"maxSelect": 1,
"maxSize": 52428800,
"protected": false
}
},
{
"system": false,
"id": "ny7sztwr",
"name": "format",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"pdf",
"md",
"txt"
]
}
},
{
"system": false,
"id": "sitx0eng",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"processing",
"processed",
"failed"
]
}
},
{
"system": false,
"id": "p7qduuyn",
"name": "ingested_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "tahewcck",
"name": "chunk_count",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "d0x4m7ow",
"name": "created_by",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("0lr0nf3rzh5tdq3");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,116 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "bm117d8jhn68xqr",
"created": "2026-05-23 13:57:33.634Z",
"updated": "2026-05-23 13:57:33.634Z",
"name": "themes",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "zl4nz5yw",
"name": "title",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "hvkfrx3i",
"name": "description",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "mfk9eat0",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"draft",
"published"
]
}
},
{
"system": false,
"id": "n8fksm3u",
"name": "source_documents",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "0lr0nf3rzh5tdq3",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
},
{
"system": false,
"id": "998ievsr",
"name": "approved_by",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "4ilud3ts",
"name": "approved_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("bm117d8jhn68xqr");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "mx3g38jd",
"name": "role",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"admin",
"employee"
]
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "pgfyrf71",
"name": "display_name",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
// remove
collection.schema.removeField("mx3g38jd")
// remove
collection.schema.removeField("pgfyrf71")
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,55 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "1ixwljuo2xqxcqj",
"created": "2026-05-23 13:58:56.098Z",
"updated": "2026-05-23 13:58:56.098Z",
"name": "topics",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "kkjhfk5h",
"name": "theme",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "bm117d8jhn68xqr",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "ygnod47v",
"name": "body",
"type": "editor",
"required": false,
"presentable": false,
"unique": false,
"options": {
"convertUrls": false
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("1ixwljuo2xqxcqj");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,102 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "5gl7qj7aky6wjl9",
"created": "2026-05-23 14:00:42.375Z",
"updated": "2026-05-23 14:00:42.375Z",
"name": "badges",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "edwrmvav",
"name": "key",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "2wpmab8o",
"name": "tier",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"bronze",
"silver",
"gold",
"legendary",
"content"
]
}
},
{
"system": false,
"id": "hknbw5l3",
"name": "label",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "mq6imou8",
"name": "description",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "l8vkthft",
"name": "icon",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("5gl7qj7aky6wjl9");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,114 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "1cbepol4z1jxb20",
"created": "2026-05-23 14:00:42.280Z",
"updated": "2026-05-23 14:00:42.280Z",
"name": "curriculum_versions",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "x70h1e1v",
"name": "version",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "0rbdmeyi",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"draft",
"active",
"superseded"
]
}
},
{
"system": false,
"id": "s8btpnp7",
"name": "generated_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "d297xuk1",
"name": "approved_by",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "zmzzcv6z",
"name": "approved_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "fsn8cgni",
"name": "generation_notes",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("1cbepol4z1jxb20");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,129 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "9ivwlfc584lp63w",
"created": "2026-05-23 14:00:42.294Z",
"updated": "2026-05-23 14:00:42.294Z",
"name": "curriculum_weeks",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "ano3xevy",
"name": "curriculum_version",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1cbepol4z1jxb20",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "p7odhccm",
"name": "week_number",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "l0w7ienh",
"name": "theme",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "bm117d8jhn68xqr",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "37yncqmv",
"name": "topics",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1ixwljuo2xqxcqj",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
},
{
"system": false,
"id": "ut2ilwjx",
"name": "topic_order",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2097152
}
},
{
"system": false,
"id": "wdleuro6",
"name": "estimated_duration_minutes",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "omddyhqe",
"name": "admin_notes",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("9ivwlfc584lp63w");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,86 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "52xqp11w4t0y68u",
"created": "2026-05-23 14:00:42.405Z",
"updated": "2026-05-23 14:00:42.405Z",
"name": "employee_badges",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "uyiom4ry",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "hmafhq29",
"name": "badge",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "5gl7qj7aky6wjl9",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "5a11syeq",
"name": "earned_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "ekcq8kkw",
"name": "cycle",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("52xqp11w4t0y68u");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,100 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "516qnoxsmeomkte",
"created": "2026-05-23 14:00:42.311Z",
"updated": "2026-05-23 14:00:42.311Z",
"name": "employee_curriculum_state",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "4v8ps3fa",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "trhrgfoz",
"name": "current_cycle",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "g39vexbc",
"name": "current_week",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "ca7taibg",
"name": "start_date",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "lpkyfwga",
"name": "active_version",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1cbepol4z1jxb20",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("516qnoxsmeomkte");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,131 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "o9gcrudod9g510u",
"created": "2026-05-23 14:00:42.352Z",
"updated": "2026-05-23 14:00:42.352Z",
"name": "gamification_profiles",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "szwphybr",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "ifgzeuez",
"name": "total_commits",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "bbife1f5",
"name": "current_level",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"intern",
"junior",
"medior",
"senior",
"staff",
"principal"
]
}
},
{
"system": false,
"id": "f2aujqrw",
"name": "current_streak_weeks",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "vlcxsuv6",
"name": "longest_streak_weeks",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "eolnad0g",
"name": "types_used",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2097152
}
},
{
"system": false,
"id": "e2voqddw",
"name": "last_active_week",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("o9gcrudod9g510u");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,137 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "li75ivkr87g3r94",
"created": "2026-05-23 14:00:42.269Z",
"updated": "2026-05-23 14:00:42.269Z",
"name": "micro_learnings",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "ihu0d60m",
"name": "topic",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1ixwljuo2xqxcqj",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "wdz1hhs6",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"concept_explainer",
"scenario_quiz",
"misconceptions",
"how_to",
"comparison_card",
"reflection_prompt",
"flashcard_set",
"case_study",
"glossary_anchor",
"myth_vs_evidence"
]
}
},
{
"system": false,
"id": "1ccr13b7",
"name": "content",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2097152
}
},
{
"system": false,
"id": "vdti3gns",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"queued",
"generated",
"published",
"rejected"
]
}
},
{
"system": false,
"id": "hxxcrfw8",
"name": "generation_model",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"system": false,
"id": "grwpzlhc",
"name": "generated_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "wexudgfp",
"name": "published_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("li75ivkr87g3r94");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,111 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "ilkfmv9xibmekna",
"created": "2026-05-23 14:00:42.453Z",
"updated": "2026-05-23 14:00:42.453Z",
"name": "milestone_cards",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "31f7wcxc",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "uyhilsbd",
"name": "cycle",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "47ivigbu",
"name": "week",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "yai47mow",
"name": "total_commits",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "je3vf6wh",
"name": "streak_weeks",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "xvtxl0nj",
"name": "badge_keys",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2097152
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("ilkfmv9xibmekna");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,116 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "57kc85afcdkjlje",
"created": "2026-05-23 14:00:42.328Z",
"updated": "2026-05-23 14:00:42.328Z",
"name": "session_completions",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "atqsid6s",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "2waq3kfq",
"name": "topic",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1ixwljuo2xqxcqj",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "tf1bapvu",
"name": "micro_learning",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "li75ivkr87g3r94",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "dvmc5jih",
"name": "week_number",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "wblewojy",
"name": "cycle",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
},
{
"system": false,
"id": "izxhly6t",
"name": "completed_at",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("57kc85afcdkjlje");
return dao.deleteCollection(collection);
})

View File

@@ -0,0 +1,266 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("1ixwljuo2xqxcqj")
// remove
collection.schema.removeField("kkjhfk5h")
// remove
collection.schema.removeField("ygnod47v")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "wo6r0pay",
"name": "theme",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "bm117d8jhn68xqr",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "8bnzwbwk",
"name": "title",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "lwhabbnq",
"name": "body",
"type": "editor",
"required": false,
"presentable": false,
"unique": false,
"options": {
"convertUrls": false
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "8tjttsyu",
"name": "difficulty",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"introductory",
"intermediate",
"advanced"
]
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "qngdg6m0",
"name": "complexity_weight",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "dccnzcbc",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"draft",
"published"
]
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "3rkaaglp",
"name": "key_terms",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2097152
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "byhuuhif",
"name": "qdrant_chunk_ids",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2097152
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "8h7ygavu",
"name": "related_topics",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1ixwljuo2xqxcqj",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "dwhl1pqg",
"name": "prerequisite_topics",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1ixwljuo2xqxcqj",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "38phv8cm",
"name": "contrast_topics",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "1ixwljuo2xqxcqj",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("1ixwljuo2xqxcqj")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "kkjhfk5h",
"name": "theme",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "bm117d8jhn68xqr",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "ygnod47v",
"name": "body",
"type": "editor",
"required": false,
"presentable": false,
"unique": false,
"options": {
"convertUrls": false
}
}))
// remove
collection.schema.removeField("wo6r0pay")
// remove
collection.schema.removeField("8bnzwbwk")
// remove
collection.schema.removeField("lwhabbnq")
// remove
collection.schema.removeField("8tjttsyu")
// remove
collection.schema.removeField("qngdg6m0")
// remove
collection.schema.removeField("dccnzcbc")
// remove
collection.schema.removeField("3rkaaglp")
// remove
collection.schema.removeField("byhuuhif")
// remove
collection.schema.removeField("8h7ygavu")
// remove
collection.schema.removeField("dwhl1pqg")
// remove
collection.schema.removeField("38phv8cm")
return dao.saveCollection(collection)
})

4
app/services/curriculum/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.err

1550
app/services/curriculum/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "curriculum",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.24",
"fastify": "^4",
"pocketbase": "^0.21",
"uuid": "^9",
"zod": "^3"
},
"devDependencies": {
"@types/node": "^20",
"@types/uuid": "^9",
"dotenv": "^16",
"tsx": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,200 @@
import { getPocketBase } from '../lib/pocketbase.js';
import { anthropic, MODELS } from '../lib/anthropic.js';
import { sequenceTopics } from './sequence.js';
import { buildCycleSystemRules, buildCycleUserPrompt } from './cycle.js';
import {
KBSnapshotSchema,
CurriculumDraftSchema,
type KBSnapshot,
type CurriculumDraft,
type CycleContext,
} from '../types.js';
const BASE_SYSTEM_PROMPT = `You are a curriculum designer. Your task is to distribute a set of learning
Themes across 26 weekly sessions to create an effective learning journey.
Output ONLY valid JSON matching the schema provided. No preamble, no
explanation, no markdown fences.
Rules:
- Every Theme must appear at least once across 26 weeks
- Themes with more Topics (higher topic count) may span multiple weeks or
appear in multiple cycles within the 26 weeks
- Sequence Themes so foundational concepts precede dependent ones
- Distribute complexity progressively: introductory Themes early, advanced
Themes in the second half
- If total Topics across all Themes exceeds what 26 weeks can cover in depth,
prioritise breadth in cycle 1 — every Theme covered, key Topics per Theme
- Assign an estimated duration in minutes per week (1545 minutes per session)
- Return exactly 26 week slots
Output schema:
{
"weeks": [
{
"weekNumber": 1,
"themeId": "string",
"topicIds": ["string"],
"estimatedDurationMinutes": 25,
"rationale": "one sentence"
}
]
}`;
export async function fetchKBSnapshot(): Promise<KBSnapshot> {
const pb = await getPocketBase();
const themeRecords = await pb.collection('themes').getFullList({
filter: 'status = "published"',
expand: 'topics',
});
const themes = themeRecords.map(theme => {
const expandedTopics = (theme['expand'] as Record<string, unknown>)?.['topics'];
const rawTopics = Array.isArray(expandedTopics) ? expandedTopics : [];
const topics = rawTopics.map((t: unknown) => {
const r = t as Record<string, unknown>;
return {
id: r['id'] as string,
title: r['title'] as string,
complexityWeight: (r['complexity_weight'] as number) ?? 1,
difficulty: (r['difficulty'] as string) ?? 'introductory',
prerequisiteTopics: (r['prerequisite_topics'] as string[]) ?? [],
relatedTopics: (r['related_topics'] as string[]) ?? [],
contrastTopics: (r['contrast_topics'] as string[]) ?? [],
};
});
return {
id: theme['id'] as string,
title: theme['title'] as string,
description: (theme['description'] as string) ?? '',
topics,
};
});
return KBSnapshotSchema.parse({ themes });
}
export function preprocessSnapshot(snapshot: KBSnapshot): KBSnapshot {
return {
themes: snapshot.themes.map(theme => ({
...theme,
topics: sequenceTopics(theme.topics),
})),
};
}
async function callAI(
systemPrompt: string,
userPrompt: string,
): Promise<CurriculumDraft> {
for (let attempt = 0; attempt < 2; attempt++) {
const message = await anthropic.messages.create({
model: MODELS.SONNET,
max_tokens: 4000,
temperature: 0,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
});
const textBlock = message.content.find(b => b.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
if (attempt === 0) continue;
throw new Error('No text block in AI response');
}
let parsed: unknown;
try {
parsed = JSON.parse(textBlock.text);
} catch {
if (attempt === 0) continue;
throw new Error('AI response is not valid JSON');
}
const result = CurriculumDraftSchema.safeParse(parsed);
if (!result.success) {
if (attempt === 0) continue;
throw new Error(`AI response failed Zod validation: ${result.error.message}`);
}
return result.data;
}
throw new Error('AI generation failed after 2 attempts');
}
function validateDraftAgainstSnapshot(draft: CurriculumDraft, snapshot: KBSnapshot): void {
const themeIds = new Set(snapshot.themes.map(t => t.id));
const topicIds = new Set(snapshot.themes.flatMap(t => t.topics.map(p => p.id)));
for (const week of draft.weeks) {
if (!themeIds.has(week.themeId)) {
throw new Error(`Unknown themeId in week ${week.weekNumber}: ${week.themeId}`);
}
for (const topicId of week.topicIds) {
if (!topicIds.has(topicId)) {
throw new Error(`Unknown topicId in week ${week.weekNumber}: ${topicId}`);
}
}
}
}
export async function writeDraftToPocketBase(
draft: CurriculumDraft,
reason: string,
): Promise<string> {
const pb = await getPocketBase();
// Determine next version number
const existing = await pb.collection('curriculum_versions').getFullList({
sort: '-version',
perPage: 1,
});
const latestVersion = existing[0] ? (existing[0]['version'] as number) : 0;
const versionRecord = await pb.collection('curriculum_versions').create({
version: latestVersion + 1,
status: 'draft',
generated_at: new Date().toISOString(),
generation_notes: reason,
});
for (const week of draft.weeks) {
await pb.collection('curriculum_weeks').create({
curriculum_version: versionRecord.id,
week_number: week.weekNumber,
theme: week.themeId,
topics: week.topicIds,
topic_order: week.topicIds.map((_, i) => i),
estimated_duration_minutes: week.estimatedDurationMinutes,
admin_notes: week.rationale,
});
}
return versionRecord.id;
}
export async function buildCurriculum(
reason: string,
cycleCtx?: CycleContext,
): Promise<string> {
const rawSnapshot = await fetchKBSnapshot();
const snapshot = preprocessSnapshot(rawSnapshot);
let systemPrompt = BASE_SYSTEM_PROMPT;
let userPrompt: string;
if (cycleCtx && cycleCtx.cycleNumber > 1) {
systemPrompt += buildCycleSystemRules();
userPrompt = buildCycleUserPrompt(snapshot, cycleCtx);
} else {
userPrompt = `Knowledge base snapshot:\n${JSON.stringify(snapshot)}\n\nGenerate a 26-week curriculum schedule.`;
}
const draft = await callAI(systemPrompt, userPrompt);
validateDraftAgainstSnapshot(draft, snapshot);
return writeDraftToPocketBase(draft, reason);
}

View File

@@ -0,0 +1,20 @@
import type { CycleContext, KBSnapshot } from '../types.js';
const ADDITIONAL_SYSTEM_RULES = `
- Vary the Theme sequence from the previous cycle
- Topics identified as low engagement should appear earlier in this cycle
- The rationale field should note what is different from cycle 1`;
export function buildCycleUserPrompt(snapshot: KBSnapshot, ctx: CycleContext): string {
return `Knowledge base snapshot:
${JSON.stringify(snapshot)}
Cycle context:
${JSON.stringify(ctx)}
Generate a 26-week curriculum schedule.`;
}
export function buildCycleSystemRules(): string {
return ADDITIONAL_SYSTEM_RULES;
}

View File

@@ -0,0 +1,49 @@
import type { KBTopic } from '../types.js';
export function sequenceTopics(topics: KBTopic[]): KBTopic[] {
if (topics.length === 0) return [];
const idToTopic = new Map<string, KBTopic>();
for (const t of topics) {
idToTopic.set(t.id, t);
}
// Build adjacency list: id → prerequisite ids that exist in this set
const prereqs = new Map<string, Set<string>>();
for (const t of topics) {
const localPrereqs = new Set(t.prerequisiteTopics.filter(id => idToTopic.has(id)));
prereqs.set(t.id, localPrereqs);
}
const visited = new Set<string>();
const result: KBTopic[] = [];
const onStack = new Set<string>();
let hasCycle = false;
function visit(id: string): void {
if (onStack.has(id)) {
hasCycle = true;
return;
}
if (visited.has(id)) return;
onStack.add(id);
for (const prereqId of prereqs.get(id) ?? []) {
visit(prereqId);
}
onStack.delete(id);
visited.add(id);
const topic = idToTopic.get(id);
if (topic) result.push(topic);
}
for (const t of topics) {
visit(t.id);
}
if (hasCycle) {
// Fall back to complexity_weight ascending
return [...topics].sort((a, b) => a.complexityWeight - b.complexityWeight);
}
return result;
}

View File

@@ -0,0 +1,18 @@
import 'dotenv/config';
import Fastify from 'fastify';
import { curriculumRoutes } from './routes/curriculum.js';
import { employeeRoutes } from './routes/employee.js';
const app = Fastify({ logger: true });
await app.register(curriculumRoutes);
await app.register(employeeRoutes);
const port = parseInt(process.env['CURRICULUM_PORT'] ?? '3003', 10);
try {
await app.listen({ port, host: '0.0.0.0' });
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,46 @@
import { v4 as uuid } from 'uuid';
import { buildCurriculum } from '../generator/build.js';
import type { GenerationJob } from '../types.js';
const jobs = new Map<string, GenerationJob>();
export function createJob(triggeredBy: string, reason: 'new_topics' | 'manual'): GenerationJob {
const job: GenerationJob = {
id: uuid(),
triggeredBy,
reason,
status: 'queued',
versionId: null,
error: null,
createdAt: new Date(),
updatedAt: new Date(),
};
jobs.set(job.id, job);
void runPipeline(job.id);
return job;
}
export function getJob(id: string): GenerationJob | undefined {
return jobs.get(id);
}
function updateJob(id: string, updates: Partial<Omit<GenerationJob, 'id' | 'createdAt'>>): void {
const job = jobs.get(id);
if (!job) return;
jobs.set(id, { ...job, ...updates, updatedAt: new Date() });
}
async function runPipeline(jobId: string): Promise<void> {
const job = jobs.get(jobId);
if (!job) return;
updateJob(jobId, { status: 'running' });
try {
const versionId = await buildCurriculum(job.reason);
updateJob(jobId, { status: 'done', versionId });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
updateJob(jobId, { status: 'failed', error });
}
}

View File

@@ -0,0 +1,9 @@
import Anthropic from '@anthropic-ai/sdk';
export const anthropic = new Anthropic({
apiKey: process.env['ANTHROPIC_API_KEY'],
});
export const MODELS = {
SONNET: 'claude-sonnet-4-20250514',
} as const;

View File

@@ -0,0 +1,14 @@
import PocketBase from 'pocketbase';
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
const pb = new PocketBase(POCKETBASE_URL);
export async function getPocketBase(): Promise<PocketBase> {
if (!pb.authStore.isValid) {
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
}
return pb;
}

View File

@@ -0,0 +1,174 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { createJob, getJob } from '../jobs/queue.js';
import { applyVersion } from '../versioning/apply.js';
import { getPocketBase } from '../lib/pocketbase.js';
const GenerateBodySchema = z.object({
triggeredBy: z.string(),
reason: z.enum(['new_topics', 'manual']),
});
const PatchWeekBodySchema = z.object({
theme: z.string().optional(),
topics: z.array(z.string()).optional(),
topic_order: z.array(z.number()).optional(),
admin_notes: z.string().optional(),
estimated_duration_minutes: z.number().min(15).max(45).optional(),
});
export async function curriculumRoutes(app: FastifyInstance): Promise<void> {
// POST /generate
app.post('/generate', async (request, reply) => {
const body = GenerateBodySchema.safeParse(request.body);
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
}
const job = createJob(body.data.triggeredBy, body.data.reason);
return reply.status(202).send({ jobId: job.id, status: job.status });
});
// GET /status/:jobId
app.get<{ Params: { jobId: string } }>('/status/:jobId', async (request, reply) => {
const job = getJob(request.params.jobId);
if (!job) {
return reply.status(404).send({ error: 'Job not found' });
}
return reply.send(job);
});
// GET /current
app.get('/current', async (_request, reply) => {
const pb = await getPocketBase();
const versions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "active"',
});
if (versions.length === 0) {
return reply.status(404).send({ error: 'No active curriculum version' });
}
const version = versions[0]!;
const weeks = await pb.collection('curriculum_weeks').getFullList({
filter: `curriculum_version = "${version.id}"`,
sort: 'week_number',
expand: 'theme,topics',
});
return reply.send({ version, weeks });
});
// GET /preview
app.get('/preview', async (_request, reply) => {
const pb = await getPocketBase();
const versions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "draft"',
sort: '-version',
perPage: 1,
});
if (versions.length === 0) {
return reply.status(404).send({ error: 'No draft curriculum version' });
}
const version = versions[0]!;
const weeks = await pb.collection('curriculum_weeks').getFullList({
filter: `curriculum_version = "${version.id}"`,
sort: 'week_number',
expand: 'theme,topics',
});
const themeIds = new Set(weeks.map(w => w['theme'] as string));
const topicIds = new Set(weeks.flatMap(w => (w['topics'] as string[]) ?? []));
const coverageStats = {
themesTotal: themeIds.size,
themesCovered: themeIds.size,
topicsTotal: topicIds.size,
topicsCovered: topicIds.size,
};
return reply.send({
version: version['version'],
weeks: weeks.map(w => {
const expand = (w['expand'] as Record<string, unknown>) ?? {};
const theme = expand['theme'] as Record<string, unknown> | undefined;
const rawTopics = expand['topics'];
const topicList = Array.isArray(rawTopics) ? rawTopics : [];
return {
weekNumber: w['week_number'],
theme: theme ? { id: theme['id'], title: theme['title'] } : null,
topics: topicList.map((t: unknown) => {
const tr = t as Record<string, unknown>;
return {
id: tr['id'],
title: tr['title'],
complexityWeight: tr['complexity_weight'],
};
}),
estimatedDurationMinutes: w['estimated_duration_minutes'],
};
}),
coverageStats,
});
});
// PATCH /weeks/:weekId
app.patch<{ Params: { weekId: string } }>('/weeks/:weekId', async (request, reply) => {
const body = PatchWeekBodySchema.safeParse(request.body);
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
}
const pb = await getPocketBase();
let week;
try {
week = await pb.collection('curriculum_weeks').getOne(request.params.weekId);
} catch {
return reply.status(404).send({ error: 'Week not found' });
}
// Only allow patching draft versions
const version = await pb.collection('curriculum_versions').getOne(week['curriculum_version'] as string);
if (version['status'] !== 'draft') {
return reply.status(409).send({ error: 'Can only edit weeks belonging to a draft version' });
}
const updates: Record<string, unknown> = {};
if (body.data.theme !== undefined) updates['theme'] = body.data.theme;
if (body.data.topics !== undefined) updates['topics'] = body.data.topics;
if (body.data.topic_order !== undefined) updates['topic_order'] = body.data.topic_order;
if (body.data.admin_notes !== undefined) updates['admin_notes'] = body.data.admin_notes;
if (body.data.estimated_duration_minutes !== undefined) {
updates['estimated_duration_minutes'] = body.data.estimated_duration_minutes;
}
const updated = await pb.collection('curriculum_weeks').update(request.params.weekId, updates);
return reply.send(updated);
});
// POST /confirm
app.post('/confirm', async (_request, reply) => {
const pb = await getPocketBase();
const drafts = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "draft"',
sort: '-version',
perPage: 1,
});
if (drafts.length === 0) {
return reply.status(404).send({ error: 'No draft curriculum to confirm' });
}
const draftId = drafts[0]!.id;
await applyVersion(draftId);
return reply.send({ applied: draftId, status: 'active' });
});
}

View File

@@ -0,0 +1,140 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getPocketBase } from '../lib/pocketbase.js';
import type { EmployeeState } from '../types.js';
const AdvanceBodySchema = z.object({
completedWeek: z.number().min(1).max(26),
});
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
// GET /state/:userId
app.get<{ Params: { userId: string } }>('/state/:userId', async (request, reply) => {
const pb = await getPocketBase();
const records = await pb.collection('employee_curriculum_state').getFullList({
filter: `user = "${request.params.userId}"`,
expand: 'active_version',
});
if (records.length === 0) {
return reply.status(404).send({ error: 'Employee curriculum state not found' });
}
const state = records[0]!;
const currentWeek = state['current_week'] as number;
const activeVersionId = state['active_version'] as string;
// Fetch the week record for next session
const nextWeekRecords = await pb.collection('curriculum_weeks').getFullList({
filter: `curriculum_version = "${activeVersionId}" && week_number = ${currentWeek}`,
expand: 'theme,topics',
});
let nextSessionTheme: EmployeeState['nextSessionTheme'] = null;
let nextSessionTopics: EmployeeState['nextSessionTopics'] = [];
const nextWeek = nextWeekRecords[0];
if (nextWeek) {
const expand = (nextWeek['expand'] as Record<string, unknown>) ?? {};
const theme = expand['theme'] as Record<string, unknown> | undefined;
if (theme) {
nextSessionTheme = { id: theme['id'] as string, title: theme['title'] as string };
}
const rawTopics = expand['topics'];
if (Array.isArray(rawTopics)) {
nextSessionTopics = rawTopics.map((t: unknown) => {
const tr = t as Record<string, unknown>;
return {
id: tr['id'] as string,
title: tr['title'] as string,
complexityWeight: (tr['complexity_weight'] as number) ?? 1,
};
});
}
}
const employeeState: EmployeeState = {
userId: request.params.userId,
currentCycle: state['current_cycle'] as number,
currentWeek,
startDate: state['start_date'] as string,
activeVersionId,
nextSessionTheme,
nextSessionTopics,
};
return reply.send(employeeState);
});
// POST /advance/:userId
app.post<{ Params: { userId: string } }>('/advance/:userId', async (request, reply) => {
const body = AdvanceBodySchema.safeParse(request.body);
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
}
const pb = await getPocketBase();
const records = await pb.collection('employee_curriculum_state').getFullList({
filter: `user = "${request.params.userId}"`,
});
if (records.length === 0) {
return reply.status(404).send({ error: 'Employee curriculum state not found' });
}
const state = records[0]!;
const currentWeek = state['current_week'] as number;
const currentCycle = state['current_cycle'] as number;
if (body.data.completedWeek !== currentWeek) {
return reply.status(409).send({
error: 'completedWeek does not match employee current_week',
expected: currentWeek,
received: body.data.completedWeek,
});
}
let newWeek: number;
let newCycle: number;
let newActiveVersion: string;
if (currentWeek === 26) {
// Cycle transition
newWeek = 1;
newCycle = currentCycle + 1;
// Use current active curriculum version for new cycle
const activeVersions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "active"',
perPage: 1,
});
newActiveVersion = activeVersions[0]?.id ?? (state['active_version'] as string);
} else {
newWeek = currentWeek + 1;
newCycle = currentCycle;
newActiveVersion = state['active_version'] as string;
}
const updates: Record<string, unknown> = {
current_week: newWeek,
current_cycle: newCycle,
active_version: newActiveVersion,
};
if (currentWeek === 26) {
updates['start_date'] = new Date().toISOString();
}
await pb.collection('employee_curriculum_state').update(state.id, updates);
return reply.send({
userId: request.params.userId,
previousWeek: currentWeek,
currentWeek: newWeek,
currentCycle: newCycle,
cycleTransition: currentWeek === 26,
});
});
}

View File

@@ -0,0 +1,125 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// KB snapshot — input to generator
// ---------------------------------------------------------------------------
export const KBTopicSchema = z.object({
id: z.string(),
title: z.string(),
complexityWeight: z.number().min(1).max(5),
difficulty: z.string(),
prerequisiteTopics: z.array(z.string()),
relatedTopics: z.array(z.string()),
contrastTopics: z.array(z.string()),
});
export const KBThemeSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
topics: z.array(KBTopicSchema),
});
export const KBSnapshotSchema = z.object({
themes: z.array(KBThemeSchema),
});
export type KBTopic = z.infer<typeof KBTopicSchema>;
export type KBTheme = z.infer<typeof KBThemeSchema>;
export type KBSnapshot = z.infer<typeof KBSnapshotSchema>;
// ---------------------------------------------------------------------------
// Curriculum draft — AI output
// ---------------------------------------------------------------------------
export const CurriculumWeekDraftSchema = z.object({
weekNumber: z.number().min(1).max(26),
themeId: z.string(),
topicIds: z.array(z.string()),
estimatedDurationMinutes: z.number().min(15).max(45),
rationale: z.string(),
});
export const CurriculumDraftSchema = z.object({
weeks: z.array(CurriculumWeekDraftSchema).length(26),
});
export type CurriculumWeekDraft = z.infer<typeof CurriculumWeekDraftSchema>;
export type CurriculumDraft = z.infer<typeof CurriculumDraftSchema>;
// ---------------------------------------------------------------------------
// Employee state
// ---------------------------------------------------------------------------
export interface EmployeeState {
userId: string;
currentCycle: number;
currentWeek: number;
startDate: string;
activeVersionId: string;
nextSessionTheme: { id: string; title: string } | null;
nextSessionTopics: { id: string; title: string; complexityWeight: number }[];
}
// ---------------------------------------------------------------------------
// Job tracking
// ---------------------------------------------------------------------------
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
export interface GenerationJob {
id: string;
triggeredBy: string;
reason: 'new_topics' | 'manual';
status: JobStatus;
versionId: string | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Cycle variant context
// ---------------------------------------------------------------------------
export interface CycleContext {
cycleNumber: number;
employeeHistory: {
typesUsed: string[];
typesNotUsed: string[];
lowEngagementTopics: string[];
};
}
// ---------------------------------------------------------------------------
// PocketBase record shapes
// ---------------------------------------------------------------------------
export interface CurriculumVersionRecord {
id: string;
version: number;
status: 'draft' | 'active' | 'superseded';
generated_at: string;
generation_notes: string;
}
export interface CurriculumWeekRecord {
id: string;
curriculum_version: string;
week_number: number;
theme: string;
topics: string[];
topic_order: number[];
estimated_duration_minutes: number;
admin_notes: string;
}
export interface EmployeeStateRecord {
id: string;
user: string;
current_cycle: number;
current_week: number;
start_date: string;
active_version: string;
}

View File

@@ -0,0 +1,25 @@
import { getPocketBase } from '../lib/pocketbase.js';
export async function applyVersion(newVersionId: string): Promise<void> {
const pb = await getPocketBase();
// Get all employees
const employees = await pb.collection('employee_curriculum_state').getFullList();
for (const emp of employees) {
await pb.collection('employee_curriculum_state').update(emp.id, {
active_version: newVersionId,
});
}
// Supersede old active version
const activeVersions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "active"',
});
for (const v of activeVersions) {
await pb.collection('curriculum_versions').update(v.id, { status: 'superseded' });
}
// Activate new version
await pb.collection('curriculum_versions').update(newVersionId, { status: 'active' });
}

View File

@@ -0,0 +1,25 @@
import { getPocketBase } from '../lib/pocketbase.js';
/**
* Returns the set of week numbers that are "frozen" for an employee —
* weeks they have already started or completed, derived from their current_week.
* Weeks < current_week are rendered from session_completions and must not be
* replaced by a new curriculum version.
*/
export async function getFrozenWeeks(userId: string): Promise<Set<number>> {
const pb = await getPocketBase();
const records = await pb.collection('employee_curriculum_state').getFullList({
filter: `user = "${userId}"`,
});
const state = records[0];
if (!state) return new Set();
const currentWeek = state['current_week'] as number;
const frozen = new Set<number>();
for (let w = 1; w < currentWeek; w++) {
frozen.add(w);
}
return frozen;
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

4
app/services/generation/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.err

1550
app/services/generation/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "generation",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.24",
"fastify": "^4",
"pocketbase": "^0.21",
"uuid": "^9",
"zod": "^3"
},
"devDependencies": {
"@types/node": "^20",
"@types/uuid": "^9",
"dotenv": "^16",
"tsx": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,18 @@
import 'dotenv/config';
import Fastify from 'fastify';
import { generateRoutes } from './routes/generate.js';
import { publishRoutes } from './routes/publish.js';
const app = Fastify({ logger: true });
await app.register(generateRoutes);
await app.register(publishRoutes);
const port = parseInt(process.env['GENERATION_PORT'] ?? '3002', 10);
try {
await app.listen({ port, host: '0.0.0.0' });
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,168 @@
import { v4 as uuid } from 'uuid';
import { getPocketBase } from '../lib/pocketbase.js';
import { generateMicroLearning } from '../pipeline/generate.js';
import {
MICRO_LEARNING_TYPES,
type GenerationJob,
type JobProgress,
type TopicRecord,
} from '../types.js';
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
const jobs = new Map<string, GenerationJob>();
const DEFAULT_PROGRESS: JobProgress = {
topicsTotal: 0,
topicsProcessed: 0,
itemsTotal: 0,
itemsGenerated: 0,
itemsFailed: 0,
};
export function createJob(themeId: string): GenerationJob {
const job: GenerationJob = {
id: uuid(),
themeId,
status: 'queued',
progress: { ...DEFAULT_PROGRESS },
error: null,
createdAt: new Date(),
updatedAt: new Date(),
};
jobs.set(job.id, job);
void runPipeline(job.id);
return job;
}
export function getJob(id: string): GenerationJob | undefined {
return jobs.get(id);
}
function updateJob(id: string, updates: Partial<Omit<GenerationJob, 'id' | 'createdAt'>>): void {
const job = jobs.get(id);
if (!job) return;
jobs.set(id, { ...job, ...updates, updatedAt: new Date() });
}
function mergeProgress(id: string, partial: Partial<JobProgress>): void {
const job = jobs.get(id);
if (!job) return;
updateJob(id, { progress: { ...job.progress, ...partial } });
}
// ---------------------------------------------------------------------------
// Pipeline orchestration
// ---------------------------------------------------------------------------
async function runPipeline(jobId: string): Promise<void> {
const job = jobs.get(jobId);
if (!job) return;
updateJob(jobId, { status: 'running' });
let topics: TopicRecord[];
try {
topics = await fetchPublishedTopics(job.themeId);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
updateJob(jobId, { status: 'failed', error: `topic_fetch_failed: ${reason}` });
return;
}
const totalItems = topics.length * MICRO_LEARNING_TYPES.length;
mergeProgress(jobId, {
topicsTotal: topics.length,
itemsTotal: totalItems,
});
// Pre-create all micro_learning records with status: queued
const recordIds = await preCreateRecords(topics);
let generated = 0;
let failed = 0;
for (let ti = 0; ti < topics.length; ti++) {
const topic = topics[ti];
if (!topic) continue;
for (const type of MICRO_LEARNING_TYPES) {
const recordId = recordIds.get(`${topic.id}:${type}`);
if (!recordId) continue;
try {
const content = await generateMicroLearning(topic, type);
await updateMicroLearning(recordId, 'generated', content);
generated++;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await updateMicroLearning(recordId, 'failed', null, reason);
failed++;
}
mergeProgress(jobId, { itemsGenerated: generated, itemsFailed: failed });
}
mergeProgress(jobId, { topicsProcessed: ti + 1 });
}
updateJob(jobId, { status: 'done' });
}
// ---------------------------------------------------------------------------
// PocketBase helpers
// ---------------------------------------------------------------------------
async function fetchPublishedTopics(themeId: string): Promise<TopicRecord[]> {
const pb = await getPocketBase();
const records = await pb.collection('topics').getFullList({
filter: `theme = "${themeId}" && status = "published"`,
});
return records.map(r => ({
id: r['id'] as string,
title: r['title'] as string,
body: r['body'] as string,
difficulty: r['difficulty'] as TopicRecord['difficulty'],
key_terms: (r['key_terms'] as string[] | null) ?? [],
status: r['status'] as string,
}));
}
async function preCreateRecords(
topics: TopicRecord[],
): Promise<Map<string, string>> {
const pb = await getPocketBase();
const map = new Map<string, string>();
for (const topic of topics) {
for (const type of MICRO_LEARNING_TYPES) {
const record = await pb.collection('micro_learnings').create({
topic: topic.id,
type,
content: null,
status: 'queued',
generation_model: 'claude-sonnet-4-20250514',
});
map.set(`${topic.id}:${type}`, record.id);
}
}
return map;
}
async function updateMicroLearning(
recordId: string,
status: 'generated' | 'failed',
content: unknown,
_errorNote?: string,
): Promise<void> {
const pb = await getPocketBase();
await pb.collection('micro_learnings').update(recordId, {
status,
content: content ?? null,
generated_at: status === 'generated' ? new Date().toISOString() : null,
});
}

View File

@@ -0,0 +1,9 @@
import Anthropic from '@anthropic-ai/sdk';
export const anthropic = new Anthropic({
apiKey: process.env['ANTHROPIC_API_KEY'],
});
export const MODELS = {
SONNET: 'claude-sonnet-4-20250514',
} as const;

View File

@@ -0,0 +1,14 @@
import PocketBase from 'pocketbase';
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
const pb = new PocketBase(POCKETBASE_URL);
export async function getPocketBase(): Promise<PocketBase> {
if (!pb.authStore.isValid) {
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
}
return pb;
}

View File

@@ -0,0 +1,163 @@
import { anthropic, MODELS } from '../lib/anthropic.js';
import {
CONTENT_SCHEMAS,
TYPE_LABELS,
type MicroLearningType,
type TopicRecord,
} from '../types.js';
const SYSTEM_PROMPT = `You are a learning content designer. Your task is to generate structured learning content for a specific topic in an employee learning platform.
Output ONLY valid JSON matching the schema provided. No preamble, no explanation, no markdown fences.
The content should be accurate, practical, and appropriate for the stated difficulty level. Tone: professional but accessible.`;
function buildUserPrompt(
topic: TopicRecord,
type: MicroLearningType,
schemaDescription: string,
strict: boolean,
): string {
const base = `Topic: ${topic.title}
Difficulty: ${topic.difficulty}
Body:
${topic.body}
Key terms: ${topic.key_terms.join(', ')}
Generate a ${TYPE_LABELS[type]} for this topic.
Output schema:
${schemaDescription}`;
return strict ? base + '\n\nRespond with valid JSON only, no other text.' : base;
}
const SCHEMA_DESCRIPTIONS: Record<MicroLearningType, string> = {
concept_explainer: `{
"paragraphs": ["2 to 3 paragraphs explaining the concept in plain language"],
"example": "one concrete real-world example"
}`,
scenario_quiz: `{
"scenario": "a realistic workplace scenario",
"options": [
{ "label": "A", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "B", "text": "answer text", "correct": true, "explanation": "why" },
{ "label": "C", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "D", "text": "answer text", "correct": false, "explanation": "why" }
]
}
Rules: exactly 4 options, exactly 1 marked correct: true.`,
misconceptions: `{
"items": [
{ "misconception": "common wrong belief", "correction": "accurate explanation" }
]
}
Rules: 3 to 5 items.`,
how_to: `{
"steps": [
{ "number": 1, "instruction": "what to do" }
]
}
Rules: 3 to 8 steps.`,
comparison_card: `{
"subject_a": "first concept or approach",
"subject_b": "second concept or approach",
"dimensions": [
{ "label": "dimension name", "a": "how A differs", "b": "how B differs" }
]
}
Rules: 3 to 6 dimensions.`,
reflection_prompt: `{
"prompt": "open-ended question for the employee to reflect on",
"model_answer": "a thoughtful example answer the employee can compare against"
}`,
flashcard_set: `{
"cards": [
{ "question": "question text", "answer": "answer text" }
]
}
Rules: 5 to 10 cards.`,
case_study: `{
"scenario": "a detailed real-world scenario (150+ words)",
"questions": ["discussion question 1", "discussion question 2"]
}
Rules: 2 to 4 questions.`,
glossary_anchor: `{
"term": "the key term",
"definition": "precise definition",
"correct_use": "example sentence showing correct use",
"misuse": "common incorrect usage to avoid"
}`,
myth_vs_evidence: `{
"myth": "a commonly held misconception about this topic",
"evidence": "the evidence-based counterpoint",
"sources": ["source or reference if applicable — use empty array if none"]
}`,
};
async function callClaude(prompt: string): Promise<string> {
const response = await anthropic.messages.create({
model: MODELS.SONNET,
max_tokens: 2000,
temperature: 0,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
});
const block = response.content[0];
if (!block || block.type !== 'text') {
throw new Error('unexpected response format from Claude');
}
return block.text;
}
async function parseAndValidate(raw: string, type: MicroLearningType): Promise<unknown> {
const schema = CONTENT_SCHEMAS[type];
const parsed: unknown = JSON.parse(raw);
return schema.parse(parsed);
}
export async function generateMicroLearning(
topic: TopicRecord,
type: MicroLearningType,
): Promise<unknown> {
const schemaDesc = SCHEMA_DESCRIPTIONS[type];
// For glossary_anchor, hint Claude to use the first key term
const topicWithHint: TopicRecord =
type === 'glossary_anchor' && topic.key_terms.length > 0
? { ...topic, body: topic.body + `\n\nAnchor term: ${topic.key_terms[0]}` }
: topic;
const prompt = buildUserPrompt(topicWithHint, type, schemaDesc, false);
let raw: string;
try {
raw = await callClaude(prompt);
} catch (err) {
throw new Error(`Claude API error: ${err instanceof Error ? err.message : String(err)}`);
}
// First parse attempt
try {
return await parseAndValidate(raw, type);
} catch {
// Retry with strict prompt
const strictPrompt = buildUserPrompt(topicWithHint, type, schemaDesc, true);
let raw2: string;
try {
raw2 = await callClaude(strictPrompt);
} catch (err) {
throw new Error(`Claude API error on retry: ${err instanceof Error ? err.message : String(err)}`);
}
try {
return await parseAndValidate(raw2, type);
} catch (err) {
throw new Error(
`validation failed after retry: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}

View File

@@ -0,0 +1,36 @@
import type { FastifyInstance } from 'fastify';
import { createJob, getJob } from '../jobs/queue.js';
import { GenerateBodySchema } from '../types.js';
export async function generateRoutes(app: FastifyInstance): Promise<void> {
app.post('/generate', async (request, reply) => {
const parsed = GenerateBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: 'invalid request', details: parsed.error.issues });
}
const { themeId } = parsed.data;
const job = createJob(themeId);
return reply.status(202).send({
jobId: job.id,
status: job.status,
topicsFound: job.progress.topicsTotal,
totalItems: job.progress.itemsTotal,
});
});
app.get<{ Params: { jobId: string } }>('/status/:jobId', async (request, reply) => {
const job = getJob(request.params.jobId);
if (!job) {
return reply.status(404).send({ error: 'job not found' });
}
return reply.send({
jobId: job.id,
status: job.status,
progress: job.progress,
error: job.error,
});
});
}

View File

@@ -0,0 +1,44 @@
import type { FastifyInstance } from 'fastify';
import { getPocketBase } from '../lib/pocketbase.js';
import { PublishBodySchema } from '../types.js';
export async function publishRoutes(app: FastifyInstance): Promise<void> {
app.patch<{ Params: { id: string } }>('/micro-learnings/:id', async (request, reply) => {
const parsed = PublishBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: 'invalid request', details: parsed.error.issues });
}
const { id } = request.params;
const { status: newStatus } = parsed.data;
const pb = await getPocketBase();
let existing: Record<string, unknown>;
try {
existing = await pb.collection('micro_learnings').getOne(id) as Record<string, unknown>;
} catch {
return reply.status(404).send({ error: 'micro learning not found' });
}
if (existing['status'] !== 'generated') {
return reply.status(400).send({
error: 'only generated records can be published or rejected',
currentStatus: existing['status'],
});
}
const updates: Record<string, unknown> = { status: newStatus };
if (newStatus === 'published') {
updates['published_at'] = new Date().toISOString();
}
const updated = await pb.collection('micro_learnings').update(id, updates);
return reply.send({
id: updated.id,
status: updated['status'],
published_at: updated['published_at'] ?? null,
});
});
}

View File

@@ -0,0 +1,201 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Micro learning types
// ---------------------------------------------------------------------------
export const MICRO_LEARNING_TYPES = [
'concept_explainer',
'scenario_quiz',
'misconceptions',
'how_to',
'comparison_card',
'reflection_prompt',
'flashcard_set',
'case_study',
'glossary_anchor',
'myth_vs_evidence',
] as const;
export type MicroLearningType = (typeof MICRO_LEARNING_TYPES)[number];
// ---------------------------------------------------------------------------
// Content schemas — validated against AI output before PocketBase write
// ---------------------------------------------------------------------------
export const ConceptExplainerSchema = z.object({
paragraphs: z.array(z.string().min(10)).min(2).max(3),
example: z.string().min(20),
});
export const ScenarioQuizSchema = z.object({
scenario: z.string().min(30),
options: z
.array(
z.object({
label: z.enum(['A', 'B', 'C', 'D']),
text: z.string().min(5),
correct: z.boolean(),
explanation: z.string().min(10),
}),
)
.length(4)
.refine(opts => opts.filter(o => o.correct).length === 1, {
message: 'exactly one correct option required',
}),
});
export const MisconceptionsSchema = z.object({
items: z
.array(
z.object({
misconception: z.string().min(10),
correction: z.string().min(10),
}),
)
.min(3)
.max(5),
});
export const HowToSchema = z.object({
steps: z
.array(
z.object({
number: z.number().int().positive(),
instruction: z.string().min(10),
}),
)
.min(3)
.max(8),
});
export const ComparisonCardSchema = z.object({
subject_a: z.string().min(2),
subject_b: z.string().min(2),
dimensions: z
.array(
z.object({
label: z.string().min(2),
a: z.string().min(5),
b: z.string().min(5),
}),
)
.min(3)
.max(6),
});
export const ReflectionPromptSchema = z.object({
prompt: z.string().min(20),
model_answer: z.string().min(50),
});
export const FlashcardSetSchema = z.object({
cards: z
.array(
z.object({
question: z.string().min(5),
answer: z.string().min(5),
}),
)
.min(5)
.max(10),
});
export const CaseStudySchema = z.object({
scenario: z.string().min(150),
questions: z.array(z.string().min(10)).min(2).max(4),
});
export const GlossaryAnchorSchema = z.object({
term: z.string().min(2),
definition: z.string().min(20),
correct_use: z.string().min(20),
misuse: z.string().min(20),
});
export const MythVsEvidenceSchema = z.object({
myth: z.string().min(20),
evidence: z.string().min(30),
sources: z.array(z.string()),
});
// Map type → schema for lookup
export const CONTENT_SCHEMAS: Record<MicroLearningType, z.ZodTypeAny> = {
concept_explainer: ConceptExplainerSchema,
scenario_quiz: ScenarioQuizSchema,
misconceptions: MisconceptionsSchema,
how_to: HowToSchema,
comparison_card: ComparisonCardSchema,
reflection_prompt: ReflectionPromptSchema,
flashcard_set: FlashcardSetSchema,
case_study: CaseStudySchema,
glossary_anchor: GlossaryAnchorSchema,
myth_vs_evidence: MythVsEvidenceSchema,
};
// Map type → human-readable label for prompts
export const TYPE_LABELS: Record<MicroLearningType, string> = {
concept_explainer: 'Concept Explainer',
scenario_quiz: 'Scenario Quiz',
misconceptions: 'Misconceptions',
how_to: 'How-To Guide',
comparison_card: 'Comparison Card',
reflection_prompt: 'Reflection Prompt',
flashcard_set: 'Flashcard Set',
case_study: 'Case Study',
glossary_anchor: 'Glossary Anchor',
myth_vs_evidence: 'Myth vs Evidence',
};
// ---------------------------------------------------------------------------
// PocketBase: Topic (fetched from PB before generation)
// ---------------------------------------------------------------------------
export interface TopicRecord {
id: string;
title: string;
body: string;
difficulty: 'introductory' | 'intermediate' | 'advanced';
key_terms: string[];
status: string;
}
// ---------------------------------------------------------------------------
// Job system
// ---------------------------------------------------------------------------
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
export interface JobProgress {
topicsTotal: number;
topicsProcessed: number;
itemsTotal: number;
itemsGenerated: number;
itemsFailed: number;
}
export interface GenerationJob {
id: string;
themeId: string;
status: JobStatus;
progress: JobProgress;
error: string | null;
createdAt: Date;
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// API request schemas (Zod — validates external input)
// ---------------------------------------------------------------------------
export const GenerateBodySchema = z.object({
themeId: z.string().min(1),
});
export type GenerateBody = z.infer<typeof GenerateBodySchema>;
export const PublishBodySchema = z.object({
status: z.enum(['published', 'rejected']),
});
export type PublishBody = z.infer<typeof PublishBodySchema>;

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

1888
app/services/ingestion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"name": "ingestion", "name": "ingestion",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
@@ -10,21 +11,22 @@
"migrate:qdrant": "tsx src/migrations/002_qdrant_setup.ts" "migrate:qdrant": "tsx src/migrations/002_qdrant_setup.ts"
}, },
"dependencies": { "dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24", "@anthropic-ai/sdk": "^0.24",
"openai": "^4",
"@qdrant/js-client-rest": "^1.9", "@qdrant/js-client-rest": "^1.9",
"pocketbase": "^0.21", "fastify": "^4",
"openai": "^4",
"pdf-parse": "^1.1", "pdf-parse": "^1.1",
"pocketbase": "^0.21",
"uuid": "^9", "uuid": "^9",
"zod": "^3" "zod": "^3"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"tsx": "^4",
"dotenv": "^16",
"@types/node": "^20", "@types/node": "^20",
"@types/pdf-parse": "^1.1", "@types/pdf-parse": "^1.1",
"@types/uuid": "^9" "@types/uuid": "^9",
"dotenv": "^16",
"pdfkit": "^0.18.0",
"tsx": "^4",
"typescript": "^5"
} }
} }

View File

@@ -0,0 +1,16 @@
C:\Users\RaymondVerhoef\Gitea\learning-platform\app\services\ingestion\node_modules\.bin\tsx:2
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
^^^^^^^
SyntaxError: missing ) after argument list
at wrapSafe (node:internal/modules/cjs/loader:1662:18)
at Module._compile (node:internal/modules/cjs/loader:1704:20)
at Object..js (node:internal/modules/cjs/loader:1895:10)
at Module.load (node:internal/modules/cjs/loader:1465:32)
at Function._load (node:internal/modules/cjs/loader:1282:12)
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:235:24)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
at node:internal/main/run_main_module:36:49
Node.js v22.16.0

View File

@@ -46,7 +46,7 @@ const field = {
}), }),
json: (name: string): FieldDef => ({ json: (name: string): FieldDef => ({
name, type: 'json', required: false, options: {}, name, type: 'json', required: false, options: { maxSize: 2097152 },
}), }),
date: (name: string): FieldDef => ({ date: (name: string): FieldDef => ({
@@ -146,18 +146,17 @@ async function run(): Promise<void> {
const usersCol = await pb.collections.getFirstListItem<CollectionModel>('name="users"'); const usersCol = await pb.collections.getFirstListItem<CollectionModel>('name="users"');
ids.set('users', usersCol.id); ids.set('users', usersCol.id);
const hasRole = usersCol.schema.some(s => s.name === 'role'); const existingFieldNames = new Set(usersCol.schema.map((s: { name: string }) => s.name));
if (!hasRole) { const fieldsToAdd: FieldDef[] = [];
if (!existingFieldNames.has('role')) fieldsToAdd.push(field.select('role', ['admin', 'employee'], true));
if (!existingFieldNames.has('display_name')) fieldsToAdd.push(field.text('display_name'));
if (fieldsToAdd.length > 0) {
const updateBody: Record<string, unknown> = { const updateBody: Record<string, unknown> = {
schema: [ schema: [...usersCol.schema, ...fieldsToAdd],
...usersCol.schema,
field.select('role', ['admin', 'employee'], true),
field.text('display_name'),
field.file('avatar', ['image/jpeg', 'image/png', 'image/webp']),
],
}; };
await pb.collections.update(usersCol.id, updateBody); await pb.collections.update(usersCol.id, updateBody);
console.log(' extended with role, display_name, avatar'); console.log(` extended with: ${fieldsToAdd.map((f: FieldDef) => f.name).join(', ')}`);
} else { } else {
console.log(' skip users (already extended)'); console.log(' skip users (already extended)');
} }

View File

@@ -26,11 +26,16 @@ export async function embedAndStore(
writtenTopics: WrittenTopic[], writtenTopics: WrittenTopic[],
onProgress: (embedded: number) => void, onProgress: (embedded: number) => void,
): Promise<void> { ): Promise<void> {
// Build chunk → topic mapping // Build chunk → topic mapping.
// The AI labels chunks as [CHUNK-<uuid>] so sourceChunkIds may carry that prefix;
// strip it so lookups match the bare UUID used as the Qdrant point ID.
const normalise = (id: string): string => id.replace(/^CHUNK-/i, '');
const chunkTopicMap = new Map<string, string>(); const chunkTopicMap = new Map<string, string>();
const chunkThemeMap = new Map<string, string>(); const chunkThemeMap = new Map<string, string>();
for (const topic of writtenTopics) { for (const topic of writtenTopics) {
for (const chunkId of topic.sourceChunkIds) { for (const rawId of topic.sourceChunkIds) {
const chunkId = normalise(rawId);
chunkTopicMap.set(chunkId, topic.id); chunkTopicMap.set(chunkId, topic.id);
chunkThemeMap.set(chunkId, topic.themeId); chunkThemeMap.set(chunkId, topic.themeId);
} }
@@ -94,7 +99,9 @@ export async function embedAndStore(
// Update topics.qdrant_chunk_ids in PocketBase // Update topics.qdrant_chunk_ids in PocketBase
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
for (const topic of writtenTopics) { for (const topic of writtenTopics) {
const qdrantIds = topic.sourceChunkIds.filter(id => chunkTopicMap.get(id) === topic.id); const qdrantIds = topic.sourceChunkIds
.map(id => normalise(id))
.filter(id => chunkTopicMap.get(id) === topic.id);
if (qdrantIds.length > 0) { if (qdrantIds.length > 0) {
await updateTopicQdrantIds(topic.id, qdrantIds); await updateTopicQdrantIds(topic.id, qdrantIds);
} }

View File

@@ -61,7 +61,7 @@ function buildUserPrompt(chunks: Chunk[], filename: string, format: DocumentForm
async function callClaude(chunks: Chunk[], filename: string, format: DocumentFormat, strict: boolean): Promise<DraftKB> { async function callClaude(chunks: Chunk[], filename: string, format: DocumentFormat, strict: boolean): Promise<DraftKB> {
const response = await anthropic.messages.create({ const response = await anthropic.messages.create({
model: MODELS.SONNET, model: MODELS.SONNET,
max_tokens: 8000, max_tokens: 16000,
temperature: 0, temperature: 0,
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: buildUserPrompt(chunks, filename, format, strict) }], messages: [{ role: 'user', content: buildUserPrompt(chunks, filename, format, strict) }],
@@ -76,12 +76,14 @@ async function callClaude(chunks: Chunk[], filename: string, format: DocumentFor
try { try {
parsed = JSON.parse(textBlock.text); parsed = JSON.parse(textBlock.text);
} catch { } catch {
console.error(`[structure] JSON parse failed (strict=${strict}), response length=${textBlock.text.length}, stop_reason=${response.stop_reason}`);
if (strict) throw new Error('structure_extraction_failed'); if (strict) throw new Error('structure_extraction_failed');
return callClaude(chunks, filename, format, true); return callClaude(chunks, filename, format, true);
} }
const result = DraftKBSchema.safeParse(parsed); const result = DraftKBSchema.safeParse(parsed);
if (!result.success) { if (!result.success) {
console.error(`[structure] Zod validation failed (strict=${strict}):`, JSON.stringify(result.error.issues.slice(0,3)));
if (strict) throw new Error('structure_extraction_failed'); if (strict) throw new Error('structure_extraction_failed');
return callClaude(chunks, filename, format, true); return callClaude(chunks, filename, format, true);
} }

View File

@@ -82,6 +82,7 @@ export interface WrittenTopic {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface SourceChunkPayload { export interface SourceChunkPayload {
[key: string]: unknown;
source_document_id: string; source_document_id: string;
chunk_index: number; chunk_index: number;
text: string; text: string;
@@ -91,6 +92,7 @@ export interface SourceChunkPayload {
} }
export interface TopicSummaryPayload { export interface TopicSummaryPayload {
[key: string]: unknown;
topic_id: string; topic_id: string;
theme_id: string; theme_id: string;
title: string; title: string;

View File

@@ -0,0 +1,605 @@
# Comprehensive Guide to Enterprise Software Architecture
## Introduction to Software Architecture
Software architecture defines the high-level structure of a software system. It encompasses the decisions made about the organization of a system, the selection of structural elements and their interfaces, and the composition of these elements.
### Microservices Architecture Part 1
Microservices is an architectural style that structures applications as a collection of small, independently deployable services. Each service runs in its own process and communicates through lightweight APIs.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Event-Driven Architecture Part 1
Event-driven architecture is a software design pattern where components communicate through events. Producers publish events to an event broker, and consumers subscribe to receive relevant events asynchronously.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Domain-Driven Design Part 1
Domain-driven design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### CQRS Pattern Part 1
Command Query Responsibility Segregation separates read and write operations for a data store. The read and write sides can be scaled independently and use different models optimized for their specific purpose.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Event Sourcing Part 1
Event sourcing stores the state of a business entity as a sequence of state-changing events. Instead of storing just the current state, the complete history of all events is stored.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Saga Pattern Part 1
The Saga pattern manages distributed transactions in microservices. It breaks a long transaction into a sequence of smaller local transactions, each with compensating actions for rollback.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### API Gateway Pattern Part 1
An API gateway is a single entry point for client requests in a microservices architecture. It handles cross-cutting concerns like authentication, rate limiting, and request routing.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Service Mesh Part 1
A service mesh is a dedicated infrastructure layer for handling service-to-service communication. It provides features like load balancing, service discovery, health checking, and observability.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Hexagonal Architecture Part 1
Hexagonal architecture, also known as Ports and Adapters, separates the application core from external concerns. The domain logic sits in the center, surrounded by ports and adapters.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Clean Architecture Part 1
Clean architecture organizes code into concentric layers with dependencies pointing inward. The innermost layer contains enterprise business rules, followed by application rules, interface adapters, and frameworks.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Repository Pattern Part 1
The repository pattern provides an abstraction layer between the data access layer and the business logic. It centralizes data access logic and provides a consistent interface for querying data.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Unit of Work Pattern Part 1
The Unit of Work pattern maintains a list of objects affected by a business transaction. It coordinates the writing of changes and resolves concurrency problems.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Factory Pattern Part 1
The factory pattern provides an interface for creating objects in a superclass while allowing subclasses to alter the type of objects created. It promotes loose coupling between object creation and usage.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Observer Pattern Part 1
The observer pattern defines a one-to-many dependency between objects. When one object changes state, all its dependents are notified and updated automatically.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Strategy Pattern Part 1
The strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Decorator Pattern Part 1
The decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Circuit Breaker Pattern Part 1
The circuit breaker pattern prevents cascading failures in distributed systems. It wraps calls to external services and monitors for failures, opening the circuit when a threshold is exceeded.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Bulkhead Pattern Part 1
The bulkhead pattern isolates elements of an application into pools so that if one fails, the others will continue to function. It limits the impact of a failure by containing it within a section.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Retry Pattern Part 1
The retry pattern enables an application to retry a failed operation. It handles transient failures that might occur when connecting to a service or network resource.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Throttling Pattern Part 1
The throttling pattern controls the consumption of resources used by an instance of an application. It allows resources to be fairly distributed and prevents any single instance from consuming too many resources.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Cache-Aside Pattern Part 1
The cache-aside pattern loads data into the cache on demand from the data store. It improves performance by keeping frequently accessed data in cache while allowing the data store to be the source of truth.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Sharding Pattern Part 1
The sharding pattern divides a data store into horizontal partitions called shards. Each shard can be maintained on a separate server, allowing the load to be distributed across multiple machines.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Data Lake Architecture Part 1
A data lake is a centralized repository that stores all structured and unstructured data at any scale. It enables you to store data as-is without first having to structure it.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Lambda Architecture Part 1
Lambda architecture is a data-processing design that handles massive quantities of data. It uses both batch and real-time processing methods, providing a balance between latency and throughput.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Kappa Architecture Part 1
Kappa architecture is a simplification of the lambda architecture. It removes the batch layer and processes everything as a stream, using a replayable log as the source of truth.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Microservices Architecture Part 2
Microservices is an architectural style that structures applications as a collection of small, independently deployable services. Each service runs in its own process and communicates through lightweight APIs.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Event-Driven Architecture Part 2
Event-driven architecture is a software design pattern where components communicate through events. Producers publish events to an event broker, and consumers subscribe to receive relevant events asynchronously.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Domain-Driven Design Part 2
Domain-driven design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## CQRS Pattern Part 2
Command Query Responsibility Segregation separates read and write operations for a data store. The read and write sides can be scaled independently and use different models optimized for their specific purpose.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Event Sourcing Part 2
Event sourcing stores the state of a business entity as a sequence of state-changing events. Instead of storing just the current state, the complete history of all events is stored.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Saga Pattern Part 2
The Saga pattern manages distributed transactions in microservices. It breaks a long transaction into a sequence of smaller local transactions, each with compensating actions for rollback.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## API Gateway Pattern Part 2
An API gateway is a single entry point for client requests in a microservices architecture. It handles cross-cutting concerns like authentication, rate limiting, and request routing.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Service Mesh Part 2
A service mesh is a dedicated infrastructure layer for handling service-to-service communication. It provides features like load balancing, service discovery, health checking, and observability.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Hexagonal Architecture Part 2
Hexagonal architecture, also known as Ports and Adapters, separates the application core from external concerns. The domain logic sits in the center, surrounded by ports and adapters.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Clean Architecture Part 2
Clean architecture organizes code into concentric layers with dependencies pointing inward. The innermost layer contains enterprise business rules, followed by application rules, interface adapters, and frameworks.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Repository Pattern Part 2
The repository pattern provides an abstraction layer between the data access layer and the business logic. It centralizes data access logic and provides a consistent interface for querying data.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Unit of Work Pattern Part 2
The Unit of Work pattern maintains a list of objects affected by a business transaction. It coordinates the writing of changes and resolves concurrency problems.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Factory Pattern Part 2
The factory pattern provides an interface for creating objects in a superclass while allowing subclasses to alter the type of objects created. It promotes loose coupling between object creation and usage.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Observer Pattern Part 2
The observer pattern defines a one-to-many dependency between objects. When one object changes state, all its dependents are notified and updated automatically.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Strategy Pattern Part 2
The strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Decorator Pattern Part 2
The decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Circuit Breaker Pattern Part 2
The circuit breaker pattern prevents cascading failures in distributed systems. It wraps calls to external services and monitors for failures, opening the circuit when a threshold is exceeded.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Bulkhead Pattern Part 2
The bulkhead pattern isolates elements of an application into pools so that if one fails, the others will continue to function. It limits the impact of a failure by containing it within a section.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Retry Pattern Part 2
The retry pattern enables an application to retry a failed operation. It handles transient failures that might occur when connecting to a service or network resource.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Throttling Pattern Part 2
The throttling pattern controls the consumption of resources used by an instance of an application. It allows resources to be fairly distributed and prevents any single instance from consuming too many resources.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Cache-Aside Pattern Part 2
The cache-aside pattern loads data into the cache on demand from the data store. It improves performance by keeping frequently accessed data in cache while allowing the data store to be the source of truth.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Sharding Pattern Part 2
The sharding pattern divides a data store into horizontal partitions called shards. Each shard can be maintained on a separate server, allowing the load to be distributed across multiple machines.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Data Lake Architecture Part 2
A data lake is a centralized repository that stores all structured and unstructured data at any scale. It enables you to store data as-is without first having to structure it.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Lambda Architecture Part 2
Lambda architecture is a data-processing design that handles massive quantities of data. It uses both batch and real-time processing methods, providing a balance between latency and throughput.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Kappa Architecture Part 2
Kappa architecture is a simplification of the lambda architecture. It removes the batch layer and processes everything as a stream, using a replayable log as the source of truth.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Microservices Architecture Part 3
Microservices is an architectural style that structures applications as a collection of small, independently deployable services. Each service runs in its own process and communicates through lightweight APIs.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Event-Driven Architecture Part 3
Event-driven architecture is a software design pattern where components communicate through events. Producers publish events to an event broker, and consumers subscribe to receive relevant events asynchronously.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Domain-Driven Design Part 3
Domain-driven design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## CQRS Pattern Part 3
Command Query Responsibility Segregation separates read and write operations for a data store. The read and write sides can be scaled independently and use different models optimized for their specific purpose.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Event Sourcing Part 3
Event sourcing stores the state of a business entity as a sequence of state-changing events. Instead of storing just the current state, the complete history of all events is stored.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Saga Pattern Part 3
The Saga pattern manages distributed transactions in microservices. It breaks a long transaction into a sequence of smaller local transactions, each with compensating actions for rollback.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## API Gateway Pattern Part 3
An API gateway is a single entry point for client requests in a microservices architecture. It handles cross-cutting concerns like authentication, rate limiting, and request routing.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Service Mesh Part 3
A service mesh is a dedicated infrastructure layer for handling service-to-service communication. It provides features like load balancing, service discovery, health checking, and observability.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Hexagonal Architecture Part 3
Hexagonal architecture, also known as Ports and Adapters, separates the application core from external concerns. The domain logic sits in the center, surrounded by ports and adapters.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Clean Architecture Part 3
Clean architecture organizes code into concentric layers with dependencies pointing inward. The innermost layer contains enterprise business rules, followed by application rules, interface adapters, and frameworks.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Repository Pattern Part 3
The repository pattern provides an abstraction layer between the data access layer and the business logic. It centralizes data access logic and provides a consistent interface for querying data.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Unit of Work Pattern Part 3
The Unit of Work pattern maintains a list of objects affected by a business transaction. It coordinates the writing of changes and resolves concurrency problems.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Factory Pattern Part 3
The factory pattern provides an interface for creating objects in a superclass while allowing subclasses to alter the type of objects created. It promotes loose coupling between object creation and usage.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Observer Pattern Part 3
The observer pattern defines a one-to-many dependency between objects. When one object changes state, all its dependents are notified and updated automatically.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Strategy Pattern Part 3
The strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Decorator Pattern Part 3
The decorator pattern attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Circuit Breaker Pattern Part 3
The circuit breaker pattern prevents cascading failures in distributed systems. It wraps calls to external services and monitors for failures, opening the circuit when a threshold is exceeded.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Bulkhead Pattern Part 3
The bulkhead pattern isolates elements of an application into pools so that if one fails, the others will continue to function. It limits the impact of a failure by containing it within a section.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Retry Pattern Part 3
The retry pattern enables an application to retry a failed operation. It handles transient failures that might occur when connecting to a service or network resource.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Throttling Pattern Part 3
The throttling pattern controls the consumption of resources used by an instance of an application. It allows resources to be fairly distributed and prevents any single instance from consuming too many resources.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Cache-Aside Pattern Part 3
The cache-aside pattern loads data into the cache on demand from the data store. It improves performance by keeping frequently accessed data in cache while allowing the data store to be the source of truth.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Sharding Pattern Part 3
The sharding pattern divides a data store into horizontal partitions called shards. Each shard can be maintained on a separate server, allowing the load to be distributed across multiple machines.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
### Data Lake Architecture Part 3
A data lake is a centralized repository that stores all structured and unstructured data at any scale. It enables you to store data as-is without first having to structure it.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Lambda Architecture Part 3
Lambda architecture is a data-processing design that handles massive quantities of data. It uses both batch and real-time processing methods, providing a balance between latency and throughput.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.
## Kappa Architecture Part 3
Kappa architecture is a simplification of the lambda architecture. It removes the batch layer and processes everything as a stream, using a replayable log as the source of truth.
This concept is fundamental to building scalable distributed systems. Organizations that adopt this pattern report significant improvements in system reliability and maintainability. The implementation requires careful consideration of the trade-offs involved.
When implementing this pattern, teams must consider the operational overhead, the learning curve, and the impact on the existing codebase. Success depends on having the right tooling, monitoring, and team expertise in place.

View File

@@ -0,0 +1,2 @@
%PDF-1.4
This is not a valid PDF file. It contains garbage content that pdf-parse cannot handle.

View File

@@ -0,0 +1,6 @@
{
"documentId": "i9ue488qb30owl4",
"filename": "sample.md",
"format": "md",
"filePath": "C:\\Users\\RaymondVerhoef\\Gitea\\learning-platform\\app\\services\\ingestion\\test-files\\sample.md"
}

View File

@@ -0,0 +1,72 @@
# Introduction to TypeScript
TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale.
## Type System
TypeScript adds optional static typing and class-based object-oriented programming to the language. Types provide a way to describe the shape of an object, providing better documentation, and allowing TypeScript to validate that your code is working correctly.
### Basic Types
TypeScript supports several basic types including `string`, `number`, `boolean`, `array`, and `tuple`. These types allow you to add type annotations to your variables and function parameters.
## Interfaces
Interfaces define the shape of an object in TypeScript. They are a powerful way to define contracts within your code as well as contracts with code outside of your project.
```typescript
interface User {
name: string;
age: number;
email?: string;
}
```
An interface can define optional properties using the `?` operator. This is useful when some properties may or may not be present.
## Classes
TypeScript supports full class-based object-oriented programming with inheritance, interfaces, and access modifiers. Classes provide a clean and reusable way to create objects.
```typescript
class Animal {
private name: string;
constructor(name: string) {
this.name = name;
}
public move(distance: number = 0): void {
console.log(`${this.name} moved ${distance}m.`);
}
}
```
Access modifiers (`public`, `private`, `protected`) control visibility of class members.
## Generics
Generics provide a way to make components work with any data type and not restrict to one data type. They allow users to consume these components and use their own types.
```typescript
function identity<T>(arg: T): T {
return arg;
}
```
Generics are particularly useful for building reusable data structures and functions that work across multiple types.
## Enums
Enumerations allow you to define a set of named constants. TypeScript provides both numeric and string-based enums.
```typescript
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
```
String enums are more readable than numeric enums because they provide meaningful string values at runtime.

View File

@@ -0,0 +1,55 @@
%PDF-1.4
1 0 obj
<</Type /Catalog /Pages 2 0 R>>
endobj
2 0 obj
<</Type /Pages /Kids [3 0 R] /Count 1>>
endobj
3 0 obj
<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources <</Font <</F1 4 0 R>>>> /Contents 5 0 R>>
endobj
4 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Helvetica>>
endobj
5 0 obj
<</Length 582>>
stream
BT
/F1 14 Tf
50 750 Td
(Introduction to Docker Containers) Tj
0 -25 Td
/F1 12 Tf
(Docker is a platform for developing shipping and running applications.) Tj
0 -18 Td
(Containers are lightweight portable units that include all dependencies.) Tj
0 -25 Td
/F1 14 Tf
(Container Images) Tj
0 -25 Td
/F1 12 Tf
(A Docker image is a read-only template used to create containers.) Tj
0 -18 Td
(Images are built from a Dockerfile with build instructions.) Tj
0 -25 Td
/F1 14 Tf
(Docker Compose) Tj
0 -25 Td
/F1 12 Tf
(Docker Compose defines multi-container applications in compose.yml.) Tj
ET
endstream
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000056 00000 n
0000000111 00000 n
0000000231 00000 n
0000000299 00000 n
trailer
<</Size 6 /Root 1 0 R>>
startxref
930
%%EOF

View File

@@ -0,0 +1,31 @@
Introduction to Software Architecture Patterns
Software architecture patterns are reusable solutions to commonly occurring problems in software architecture within a given context. They are similar to design patterns but with a broader scope.
The most common architecture patterns include layered architecture, event-driven architecture, microservices, and service-oriented architecture. Each pattern has its own strengths and weaknesses that make it more or less appropriate for different use cases.
Layered Architecture
The layered architecture pattern organizes code into layers of functionality. The most common is the n-tier architecture with presentation, business logic, and data access layers. Each layer has a specific role and responsibility.
The presentation layer handles all user interface and browser communication logic. The business logic layer executes specific business rules associated with the request. The data access layer handles all aspects of the database interaction.
Benefits of the layered approach include separation of concerns, ease of testing, and good maintainability. Each layer can be developed and maintained independently, reducing the complexity of the system.
Event-Driven Architecture
Event-driven architecture is a software design pattern in which decoupled applications can asynchronously publish and subscribe to events via an event broker. This pattern promotes loose coupling and scalability.
Events represent things that have happened in the system. An event producer publishes an event to an event broker. Event consumers subscribe to event types and receive notifications when events are published.
Message queues like RabbitMQ and Apache Kafka are commonly used as event brokers. They provide durable, reliable messaging between producers and consumers, even when consumers are temporarily offline.
Microservices Architecture
Microservices is an architectural style that structures an application as a collection of small, independently deployable services. Each service is focused on a specific business capability.
Services communicate over well-defined APIs, usually HTTP REST or message queues. Each service can be deployed, scaled, and updated independently. This allows teams to work on different services without affecting the others.
Container orchestration platforms like Kubernetes are commonly used to manage microservices deployments. They handle service discovery, load balancing, and automatic scaling.
The challenges of microservices include distributed system complexity, data consistency, and operational overhead. Teams need to invest in DevOps tooling and practices to manage microservices effectively.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
{
"documentId": "gjntkc43rs6s3e3",
"filename": "sample.md",
"format": "md",
"filePath": "C:\\Users\\RaymondVerhoef\\Gitea\\learning-platform\\app\\services\\ingestion\\test-files\\sample.md"
}

407
docs/curriculum-spec.md Normal file
View File

@@ -0,0 +1,407 @@
# Curriculum service spec
## Responsibility
Generates a versioned 26-week learning schedule from the published knowledge
base. Manages perpetual cycling, version transitions, and employee curriculum
state. Handles regeneration when the KB changes.
---
## Service location
```
app/services/curriculum/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ ├── curriculum.ts POST /generate, GET /current, GET /preview
│ │ └── employee.ts GET /state/:userId, POST /advance/:userId
│ ├── generator/
│ │ ├── build.ts KB graph → 26-week schedule (AI call)
│ │ ├── sequence.ts prerequisite + complexity ordering
│ │ └── cycle.ts cycle 2+ variation logic
│ ├── versioning/
│ │ ├── apply.ts apply new version to active employees
│ │ └── freeze.ts protect completed weeks
│ └── lib/
│ ├── pocketbase.ts
│ └── anthropic.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## API surface
### POST /generate
Triggers curriculum generation from current published KB.
Called by admin app after confirming regeneration.
Request:
```json
{
"triggeredBy": "string",
"reason": "new_topics" | "manual"
}
```
Response (202 Accepted):
```json
{
"jobId": "string",
"status": "queued"
}
```
---
### GET /preview
Returns proposed new curriculum before admin confirms.
Called by admin app to show preview before regeneration is applied.
Response:
```json
{
"version": 3,
"weeks": [
{
"weekNumber": 1,
"theme": { "id": "string", "title": "string" },
"topics": [
{ "id": "string", "title": "string", "complexityWeight": 2 }
],
"estimatedDurationMinutes": 25
}
],
"coverageStats": {
"themesTotal": 8,
"themesCovered": 8,
"topicsTotal": 42,
"topicsCovered": 42
}
}
```
---
### GET /current
Returns the currently active curriculum version with all week slots.
---
### GET /state/:userId
Returns an employee's current curriculum state.
Response:
```json
{
"userId": "string",
"currentCycle": 1,
"currentWeek": 7,
"startDate": "2026-01-15T00:00:00Z",
"activeVersionId": "string",
"nextSessionTheme": { "id": "string", "title": "string" },
"nextSessionTopics": []
}
```
---
### POST /advance/:userId
Called by progress service when an employee completes a week.
Increments currentWeek, handles cycle transition at week 26.
Request:
```json
{
"completedWeek": 7
}
```
---
## Curriculum generation
### Input
All published Themes and Topics retrieved from PocketBase:
```typescript
type KBSnapshot = {
themes: {
id: string
title: string
description: string
topics: {
id: string
title: string
complexityWeight: number // 15
difficulty: string
prerequisiteTopics: string[] // topic IDs
relatedTopics: string[]
contrastTopics: string[]
}[]
}[]
}
```
---
### Pre-processing: sequence topics within themes
Before the AI call, the service resolves topic ordering within each Theme
using a topological sort on prerequisite relationships.
```
For each Theme:
Build directed graph: prerequisite_topics edges
Topological sort → ordered topic list
If cycle detected (should not occur but handle): log warning, fall back to
complexity_weight ascending order
```
This pre-processing means the AI does not need to reason about prerequisites —
it receives already-ordered topic lists and focuses on Theme sequencing.
---
### AI call: Theme sequencing across 26 weeks
System prompt:
```
You are a curriculum designer. Your task is to distribute a set of learning
Themes across 26 weekly sessions to create an effective learning journey.
Output ONLY valid JSON matching the schema provided. No preamble, no
explanation, no markdown fences.
Rules:
- Every Theme must appear at least once across 26 weeks
- Themes with more Topics (higher topic count) may span multiple weeks or
appear in multiple cycles within the 26 weeks
- Sequence Themes so foundational concepts precede dependent ones
- Distribute complexity progressively: introductory Themes early, advanced
Themes in the second half
- If total Topics across all Themes exceeds what 26 weeks can cover in depth,
prioritise breadth in cycle 1 — every Theme covered, key Topics per Theme
- Assign an estimated duration in minutes per week (1545 minutes per session)
- Return exactly 26 week slots
```
User prompt:
```
Knowledge base snapshot:
{KBSnapshot as JSON}
Generate a 26-week curriculum schedule.
```
Output schema:
```typescript
type CurriculumDraft = {
weeks: {
weekNumber: number // 126
themeId: string
topicIds: string[] // ordered subset of theme's topics
estimatedDurationMinutes: number
rationale: string // one sentence — shown to admin in preview
}[]
}
```
AI call configuration:
```typescript
{
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
temperature: 0
}
```
Validation: Zod schema on output. Check all themeIds and topicIds exist in
the KB snapshot before writing. Reject and retry once on validation failure.
---
### Write to PocketBase
```
Create curriculum_versions record {
version: latest + 1,
status: 'draft',
generated_at: now,
generation_notes: reason
}
For each week in CurriculumDraft:
Create curriculum_weeks record {
curriculum_version: versionId,
week_number: weekNumber,
theme: themeId,
topics: topicIds,
topic_order: [0, 1, 2, ...],
estimated_duration_minutes: value,
admin_notes: ''
}
Set curriculum_versions.status → 'draft'
Notify admin: preview available at GET /preview
```
Draft version is not applied until admin confirms via POST /generate confirm.
---
## Versioning and regeneration
### Applying a new version
When admin confirms, `apply.ts` runs:
```
Get all employees from employee_curriculum_state
For each employee:
frozenWeek = employee.current_week
Update employee_curriculum_state:
active_version = new version ID
Note: completed weeks are protected by current_week value
The frontend only renders weeks >= current_week from active_version
Weeks < current_week are rendered from session_completions history
(immutable records — not from curriculum_weeks)
Set old curriculum_versions.status → 'superseded'
Set new curriculum_versions.status → 'active'
```
Completed weeks are never stored against a curriculum version — they live
in session_completions. The version only determines future week content.
---
## Perpetual cycling
### Week 26 completion → cycle transition
When progress service calls POST /advance/:userId with completedWeek: 26:
```
employee.currentCycle += 1
employee.currentWeek = 1
employee.startDate = now
employee.activeVersion = current active version
Generate cycle variant (see below)
```
### Cycle variant generation
Cycle 2+ is not identical to cycle 1. The AI call receives additional context:
Additional fields in user prompt for cycle 2+:
```json
{
"cycleNumber": 2,
"employeeHistory": {
"typesUsed": ["concept_explainer", "scenario_quiz", "how_to"],
"typesNotUsed": ["case_study", "myth_vs_evidence", "comparison_card"],
"lowEngagementTopics": ["topic-id-1", "topic-id-2"]
}
}
```
Additional rules added to system prompt for cycle 2+:
```
- Vary the Theme sequence from the previous cycle
- Topics identified as low engagement should appear earlier in this cycle
- The rationale field should note what is different from cycle 1
```
Low engagement is determined by: topics where the employee completed only
one micro learning type (minimum engagement). Retrieved from session_completions
by progress service and passed to curriculum service on cycle transition.
---
## Admin curriculum editor
The curriculum editor in the admin app (built in frontend phase) calls:
- GET /preview to display the proposed schedule
- PATCH /weeks/:weekId to update theme or topic assignment
- POST /confirm to apply the version
The PATCH route allows admin to:
- Reassign a Theme to a different week (swap two weeks)
- Add or remove Topics from a week's topic list
- Edit admin_notes per week
Changes made via PATCH update the draft curriculum_weeks records before
the version is confirmed and applied.
---
## Environment variables
```
ANTHROPIC_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
CURRICULUM_PORT=3003
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24",
"pocketbase": "^0.21",
"zod": "^3",
"uuid": "^9"
}
}
```
---
## TypeScript strict mode requirements
- No `any` types
- KBSnapshot typed explicitly — validated against PocketBase response
- CurriculumDraft validated through Zod before any PocketBase writes
- Topological sort implemented with explicit typed graph structure
---
## What this service does NOT do
- Does not generate micro learnings → generation service
- Does not record completions → progress service
- Does not serve KB content → frontend reads PocketBase directly
- Does not handle auth → PocketBase + frontend
---
## Testing checkpoints
1. Generate curriculum from a KB with 5+ themes → confirm 26 weeks produced
2. Confirm all themes appear at least once
3. Confirm topic order within a week respects prerequisites
4. Add a new theme to KB → trigger regeneration → confirm employee at week 5
sees weeks 15 unchanged, weeks 626 updated
5. Advance employee through week 26 → confirm cycle 2 starts with varied sequence
6. Admin edits week 3 theme → confirm patch updates draft before confirmation

701
docs/frontend-spec.md Normal file
View File

@@ -0,0 +1,701 @@
# Frontend spec
## Responsibility
Single Next.js 14 codebase serving two distinct role-based experiences:
- `/admin/*` — content administration (document upload, KB review, curriculum)
- `/app/*` — employee learning experience (sessions, library, R42, gamification)
Mobile-first. Designed for 375px width, scales up. Installable as a PWA.
---
## Location
```
app/frontend/
├── src/
│ ├── app/ Next.js app router
│ │ ├── layout.tsx root layout — global stylesheet import
│ │ ├── page.tsx redirect → role-based landing
│ │ ├── admin/
│ │ │ ├── layout.tsx admin shell (sidebar nav)
│ │ │ ├── page.tsx admin dashboard
│ │ │ ├── documents/
│ │ │ │ └── page.tsx document upload + ingestion status
│ │ │ ├── knowledge/
│ │ │ │ ├── page.tsx theme batch review list
│ │ │ │ └── [themeId]/page.tsx theme detail + topic edit
│ │ │ └── curriculum/
│ │ │ └── page.tsx curriculum editor + regeneration
│ │ ├── app/
│ │ │ ├── layout.tsx employee shell (bottom nav + R42)
│ │ │ ├── page.tsx redirect → /app/session
│ │ │ ├── session/
│ │ │ │ └── page.tsx current week session
│ │ │ ├── library/
│ │ │ │ ├── page.tsx knowledge library browse
│ │ │ │ └── [topicId]/page.tsx topic detail
│ │ │ └── profile/
│ │ │ └── page.tsx gamification profile + heatmap + badges
│ │ ├── auth/
│ │ │ └── page.tsx login (PocketBase auth)
│ │ └── api/ Next.js API routes (thin proxies only)
│ ├── components/
│ │ ├── admin/ admin-specific components
│ │ ├── employee/ employee-specific components
│ │ ├── micro-learnings/ one component per micro learning type
│ │ ├── r42/ R42 chatbot components
│ │ ├── gamification/ heatmap, badges, leaderboard
│ │ └── ui/ shared primitives
│ ├── lib/
│ │ ├── pocketbase.ts PocketBase client (browser)
│ │ ├── services.ts typed API calls to backend services
│ │ ├── auth.ts auth helpers + role guards
│ │ └── hooks/ custom React hooks
│ └── types/
│ └── index.ts shared TypeScript types
├── public/
│ ├── manifest.json PWA manifest
│ ├── sw.js service worker (generated)
│ └── icons/ PWA icons (192, 512)
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
└── .env.example
```
---
## Stylesheet integration
`/stylesheet.css` lives at the repo root — not inside `app/frontend/`.
Import it as the first global stylesheet in `src/app/layout.tsx`:
```tsx
import '../../../stylesheet.css' // path from app/frontend/src/app/
import './globals.css' // Tailwind directives second
```
Rules:
- stylesheet.css is the authoritative visual style — never override it
- Where Tailwind utility classes conflict with stylesheet.css rules,
stylesheet.css wins
- Tailwind is used for layout, spacing, and elements not covered by the
stylesheet — match the visual language (spacing scale, colour, type) of
the existing stylesheet when doing so
- Inspect stylesheet.css before implementing any component — use its CSS
custom properties (if any) rather than hardcoding values
---
## PWA configuration
### next.config.js
Use `next-pwa` package to generate service worker and manifest wiring:
```javascript
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development'
})
module.exports = withPWA({
reactStrictMode: true,
})
```
### public/manifest.json
```json
{
"name": "Learning Platform",
"short_name": "Learn",
"description": "Employee knowledge and learning",
"start_url": "/app",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"orientation": "portrait",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
]
}
```
Note: set `theme_color` and `background_color` to match stylesheet.css
primary background after inspecting the file.
### Service worker caching strategy
- Static assets: cache-first
- PocketBase API calls: network-first, fall back to cache
- Backend service calls: network-only (no caching for dynamic content)
---
## Auth
PocketBase handles auth. Two roles: `admin` and `employee`.
### Login flow
```
/auth page → email + password form
PocketBase authWithPassword()
Store token in PocketBase SDK (persists in localStorage)
Read user.role from auth record
role === 'admin' → redirect to /admin
role === 'employee' → redirect to /app
```
### Route guards
Implement as Next.js middleware (`middleware.ts` at app root):
```typescript
// Admin routes: require role === 'admin'
// Employee routes: require role === 'employee'
// Unauthenticated: redirect to /auth
// Wrong role: redirect to correct landing
```
### PocketBase client (browser)
```typescript
// lib/pocketbase.ts
import PocketBase from 'pocketbase'
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL)
```
Use `pb.authStore` for auth state. Use `pb.collection().getFullList()` etc.
for direct PocketBase reads. The frontend reads KB content (topics, micro
learnings) directly from PocketBase — it does not proxy through backend services.
### Service calls
Backend services (ingestion, generation, curriculum, chat, progress) are called
via typed fetch wrappers in `lib/services.ts`:
```typescript
// Example
export async function postComplete(payload: CompletePayload) {
const res = await fetch(`${PROGRESS_URL}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error(`Complete failed: ${res.status}`)
return res.json() as Promise<CompleteResponse>
}
```
All service response types imported from `types/index.ts`.
---
## Admin app
### Shell layout (`admin/layout.tsx`)
Sidebar navigation on desktop, top navigation on mobile.
Nav items:
- Documents
- Knowledge base
- Curriculum
- (link back to employee app)
### Documents page (`admin/documents/page.tsx`)
**Upload section**
- Drag-and-drop file input: accepts .pdf, .md, .txt
- On upload: POST file to PocketBase storage →
then POST to ingestion service `/ingest` with document metadata
- Show upload confirmation with filename
**Job status list**
- Poll GET /status/:jobId every 3 seconds while status is not done/failed
- Show per-job progress:
- Status badge: queued / extracting / chunking / structuring / embedding / done / failed
- Progress bar derived from chunksEmbedded / chunksTotal
- On done: "N themes, N topics ready for review" → link to knowledge base
- On failed: error reason in red, no retry (admin re-uploads)
- Stop polling when status === 'done' or 'failed'
**Document history**
- List of all source_documents from PocketBase
- Columns: filename, format, status, ingested_at, chunk_count
---
### Knowledge base page (`admin/knowledge/page.tsx`)
Lists all Themes with status indicator.
**Theme card**
```
[Theme title] [status badge: draft / published]
N topics · from: filename.pdf
[Approve batch] [Edit] [Reject]
```
Approve batch:
- Calls PocketBase to set theme.status → 'published', all child topics → 'published'
- Triggers generation service: POST /generate-all with themeId
- Shows toast: "Generation queued for N topics"
Reject:
- Sets theme.status → 'rejected'
- Removes from list
Edit → navigates to `/admin/knowledge/[themeId]`
**Theme detail page (`admin/knowledge/[themeId]/page.tsx`)**
Displays all Topics in the Theme as editable cards.
Topic card fields (all editable inline):
- title (text input)
- body (textarea — rich enough for paragraphs, no full rich text editor needed)
- difficulty (select: introductory / intermediate / advanced)
- key_terms (tag input — comma-separated)
- related_topics (multi-select from published topics)
- prerequisite_topics (multi-select)
Save button per card — calls PocketBase PATCH on the topic record.
Below topic list: [Approve batch] button — approves all topics in the theme.
**Micro learning generation status**
After batch approval, show generation status per topic:
```
Concept explainer ✓ published
Scenario quiz ⏳ generating
Comparison card ✓ published
...
```
Poll micro_learnings collection filtered by topic until all 10 are published.
---
### Curriculum page (`admin/curriculum/page.tsx`)
**Current curriculum view**
26 weeks displayed as a list. Each week shows:
```
Week 7
[Theme: Holacratic roles]
Topics: Role definitions · Circle structure · Lead link responsibilities
Estimated: 25 min
[Edit week] [Admin notes]
```
**Regeneration banner**
When a pending regeneration is queued:
```
⚠ 8 new topics added. A new curriculum version is ready to preview.
[Preview changes] [Confirm regeneration] [Dismiss]
```
Preview: shows proposed schedule with diff highlighting — weeks that changed
are highlighted, weeks that stay the same are dimmed.
Confirm: calls POST /generate confirm on curriculum service →
applies new version to all active employees.
**Drag-to-reorder**
Each week row is draggable. Reordering calls PATCH /weeks/:weekId on the
curriculum service to swap theme assignments.
**Admin notes**
Inline text input per week — saved to curriculum_weeks.admin_notes.
---
## Employee app
### Shell layout (`app/layout.tsx`)
Bottom navigation bar (mobile-first):
```
[Session] [Library] [Profile]
```
R42 floating button: fixed position, bottom-right, above the nav bar.
Z-index above all content.
### Session page (`app/session/page.tsx`)
**Week header**
```
Week 7 of 26 · Cycle 1
[Theme title: Holacratic roles]
[Progress bar: N of 26 weeks complete]
```
**Topic list**
Each topic in the week's theme rendered as a card:
```
[Topic title]
[difficulty badge] [estimated: 10 min]
Choose how to learn this topic:
[Concept explainer] [Scenario quiz] [How-to] ...
(only published types shown as buttons)
[Completed types: ✓ Concept explainer]
```
Selecting a type opens the micro learning inline (no navigation — expands in
place on mobile). Employee reads/completes it, then taps [Mark complete].
On mark complete:
- POST to progress service `/complete`
- Response displays: commits earned + any new badges as a toast notification
- Topic card updates to show type as completed (✓)
- All types in topic completable in one session
**Week complete state**
When all topics in the week have at least one completed type:
```
🚀 Week 7 complete
You earned N commits
[Continue to Week 8]
```
Continue button calls POST /advance/:userId on curriculum service.
---
### Micro learning components
One component per type in `components/micro-learnings/`.
Each receives the `content` JSON field from the micro_learnings record.
| Component | Key interactions |
|---|---|
| ConceptExplainer | Render paragraphs + example — read only |
| ScenarioQuiz | Select option → reveal explanation — stateful |
| Misconceptions | Accordion: tap misconception to reveal correction |
| HowTo | Numbered steps — tap step to check it off |
| ComparisonCard | Two-column table — swipeable on mobile |
| ReflectionPrompt | Open text area → reveal model answer on submit |
| FlashcardSet | Flip card interaction — swipe through deck |
| CaseStudy | Scenario text + open questions — read only |
| GlossaryAnchor | Term card with definition + examples |
| MythVsEvidence | Myth card → tap to reveal evidence |
All components are self-contained. They receive content JSON and emit an
`onComplete` callback. They do not call any services directly.
```typescript
type MicroLearningProps = {
content: unknown // typed per component
onComplete: () => void
}
```
---
### Knowledge library (`app/library/page.tsx`)
**Browse view**
All published topics, grouped by Theme.
Search input: filters by title and key_terms in real time (client-side).
Filter chips: by difficulty (introductory / intermediate / advanced).
Each topic shown as a card:
```
[Topic title]
[Theme] · [difficulty badge]
[key terms as chips]
```
Tap → navigate to topic detail.
**Topic detail (`app/library/[topicId]/page.tsx`)**
```
[Topic title]
[Theme] · [difficulty]
[Topic body — rendered as paragraphs]
Key terms: [chip] [chip] [chip]
Related topics: [card] [card]
Prerequisite for: [card] [card]
How to learn this topic:
[micro learning type buttons — same as session view]
```
Completing a micro learning from the library records the completion via
progress service. Week_number is set to the employee's current week.
---
### Profile page (`app/profile/page.tsx`)
**Header**
```
[Display name]
[Level badge: Junior] [N commits]
[Current streak: 5 weeks] [Longest: 8 weeks]
```
**Heatmap**
GitHub-style contribution graph.
26 columns (weeks) × rows implied by completions per week.
Cell colour: 0 completions = lightest, 5+ completions = darkest.
Tap a cell → tooltip: "Week N · N completions".
Scrollable horizontally on mobile if needed.
Implementation: render as SVG or CSS grid — no charting library required.
```typescript
// Data from GET /profile/:userId → heatmap[]
// Colour scale: 4 levels based on completions count
// 0: var(--heatmap-0)
// 1: var(--heatmap-1)
// 2-3: var(--heatmap-2)
// 4+: var(--heatmap-3)
// Use CSS custom properties — values derived from stylesheet.css palette
```
**Badges**
Grid of earned badges. Unearned badges shown as locked (greyed out).
Tap badge → tooltip with award condition.
```
🥉 First commit ✓
🥈 Five sessions ✓
🥇 On a streak 🔒 (13 week streak needed)
⭐ Shipped 🔒
---
🏷 Governance nerd ✓
🏷 Deep reader 🔒 (3/5 case studies)
```
**Leaderboard tab**
Toggle between "My profile" and "Leaderboard".
Leaderboard: table of all employees from GET /leaderboard.
Columns: Name · Commits · Streak · Types used · Badges · Level.
Not ranked 1N. No sorting by the user — display order is commits descending.
Current employee row is highlighted.
**Activity feed tab**
Third tab: "Feed".
Milestone cards from GET /feed.
Most recent first.
```
🚀 Alex shipped the full curriculum
26 weeks · 847 commits · 3 badges
Longest streak: 18 weeks
[timestamp]
```
---
## R42 chatbot components (`components/r42/`)
### R42Button
Fixed position, bottom-right, above bottom nav bar.
Circle button with R42 label or icon.
Tap → opens R42Drawer.
```tsx
// Position: fixed, bottom: calc(nav-height + 16px), right: 16px
// Z-index: above all content, below modals
```
### R42Drawer
Slides up from bottom on mobile (sheet pattern).
On desktop: expands to a side panel.
```
[R42 header bar] [close ×]
─────────────────────────────────────────────
[Response area — scrollable]
Based on: [Holacratic roles ×] [Circle structure ×]
─────────────────────────────────────────────
[Type a question...] [Send →]
```
**State machine:**
```
idle → loading (query sent) → streaming → done
↘ out_of_scope
```
**Streaming implementation:**
```typescript
// POST /chat with fetch, read SSE stream
const response = await fetch(`${CHAT_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, userId })
})
const reader = response.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const lines = decoder.decode(value).split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const event = JSON.parse(line.slice(6))
if (event.type === 'chunk') appendText(event.text)
if (event.type === 'citations') setCitations(event.topics)
if (event.type === 'out_of_scope') setOutOfScope(event.text)
if (event.type === 'done') setDone()
}
}
```
**Citations**
Rendered as tappable pills below the response.
Tap → closes R42Drawer, navigates to `/app/library/[topicId]`.
**Out of scope response**
Render as a muted message (not an error state):
"This doesn't appear to be covered in the knowledge base.
You can browse the full library in the Knowledge section."
**Stateless by design**
Conversation cleared on drawer close. No history persisted.
Input cleared on send.
---
## Mobile-first layout rules
All layout decisions start at 375px and scale up.
- Bottom navigation: fixed, height 56px, icons + labels
- R42 button: 48px circle, positioned above nav bar
- Session topic cards: full width, stack vertically
- Micro learning components: full width, no horizontal scroll except
ComparisonCard (swipeable)
- Heatmap: horizontal scroll container on narrow screens
- Leaderboard table: horizontally scrollable on mobile, sticky name column
- Drawer/sheet pattern for R42 on mobile, side panel on desktop (breakpoint: 768px)
- Tap targets: minimum 44×44px on all interactive elements
- No hover-only interactions — all hover states have tap equivalents
---
## Environment variables
```
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
NEXT_PUBLIC_INGESTION_URL=http://localhost:3001
NEXT_PUBLIC_GENERATION_URL=http://localhost:3002
NEXT_PUBLIC_CURRICULUM_URL=http://localhost:3003
NEXT_PUBLIC_CHAT_URL=http://localhost:3004
NEXT_PUBLIC_PROGRESS_URL=http://localhost:3005
```
---
## Dependencies
```json
{
"dependencies": {
"next": "14",
"react": "^18",
"react-dom": "^18",
"pocketbase": "^0.21",
"next-pwa": "^5",
"zod": "^3"
},
"devDependencies": {
"typescript": "^5",
"tailwindcss": "^3",
"autoprefixer": "^10",
"postcss": "^8",
"@types/react": "^18",
"@types/node": "^20"
}
}
```
No component library. No charting library. No drag-and-drop library —
implement curriculum drag-to-reorder with native HTML5 drag API.
The heatmap is SVG or CSS grid — no D3.
---
## TypeScript strict mode requirements
- No `any` types
- All PocketBase collection responses typed against data-model.md schemas
- All service API responses typed against response types from each service spec
- Micro learning content JSON typed per type using discriminated union:
```typescript
type MicroLearningContent =
| { type: 'concept_explainer'; paragraphs: string[]; example: string }
| { type: 'scenario_quiz'; scenario: string; options: QuizOption[] }
| { type: 'misconceptions'; items: MisconceptionItem[] }
// ... all 10 types
```
- SSE event types as discriminated union
- No implicit any on event handlers
---
## What the frontend does NOT do
- Does not run AI calls directly — all AI goes through backend services
- Does not write to Qdrant — embedding is the ingestion service's responsibility
- Does not implement auth logic — delegates entirely to PocketBase SDK
- Does not implement curriculum generation — calls curriculum service
---
## Testing checkpoints
### Admin app
1. Upload a PDF → ingestion job created → status polls and updates → done state shows link
2. Theme batch appears after ingestion → approve → generation queued
3. Edit a topic title and body → save → changes persisted in PocketBase
4. Curriculum renders 26 weeks → drag week 3 and week 5 → order persists
5. Regeneration banner appears → preview shows → confirm applies new version
### Employee app
6. Login as employee → redirected to /app/session → correct week shown
7. Select micro learning type → content renders → mark complete → commits toast shown
8. Complete all topics in week → week complete state shown → advance to next week
9. Library browse → search filters results → topic detail renders body + related topics
10. Profile page → heatmap renders for current cycle → badges show locked/unlocked state
11. Leaderboard tab → all employees shown → current employee row highlighted
12. R42 button visible on every screen → opens drawer → question answered with citations
13. R42 citation tap → navigates to correct topic in library
14. Out-of-scope question → muted message shown, no citations
15. All screens render correctly at 375px width — no horizontal overflow except
intentional scroll containers

469
docs/gamification-spec.md Normal file
View File

@@ -0,0 +1,469 @@
# Gamification and progress service spec
## Responsibility
Records session completions, calculates XP (commits), manages levels and
streaks, evaluates badge conditions, generates milestone cards, and serves
leaderboard data. All gamification data is public to all employees.
---
## Service location
```
app/services/progress/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ ├── completions.ts POST /complete
│ │ ├── profile.ts GET /profile/:userId
│ │ ├── leaderboard.ts GET /leaderboard
│ │ └── feed.ts GET /feed
│ ├── engine/
│ │ ├── xp.ts commit calculation per type
│ │ ├── level.ts level thresholds + promotion
│ │ ├── streak.ts streak evaluation
│ │ ├── badges.ts badge condition evaluation
│ │ └── milestone.ts milestone card generation
│ └── lib/
│ └── pocketbase.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## API surface
### POST /complete
Called by the frontend when an employee completes a micro learning.
Request:
```json
{
"userId": "string",
"topicId": "string",
"microLearningId": "string",
"microLearningType": "string",
"weekNumber": 7,
"cycle": 1
}
```
Response:
```json
{
"commitsEarned": 15,
"totalCommits": 342,
"levelBefore": "junior",
"levelAfter": "junior",
"streakWeeks": 5,
"newBadges": [
{ "key": "deep_reader", "label": "Deep reader", "tier": "content" }
],
"milestoneCard": null
}
```
`newBadges` is empty array if no badges earned this completion.
`milestoneCard` is populated only at weeks 13 and 26.
---
### GET /profile/:userId
Returns full gamification profile for an employee.
Response:
```json
{
"userId": "string",
"displayName": "string",
"totalCommits": 342,
"level": "junior",
"currentStreakWeeks": 5,
"longestStreakWeeks": 8,
"typesUsed": ["concept_explainer", "scenario_quiz", "how_to"],
"typesNotUsed": ["case_study", "myth_vs_evidence"],
"badges": [
{ "key": "bronze_1", "label": "First commit", "tier": "bronze", "earnedAt": "..." }
],
"heatmap": [
{ "week": 1, "completions": 3 },
{ "week": 2, "completions": 0 }
]
}
```
Heatmap returns 26 entries per cycle. `completions` = number of micro
learning types completed that week.
---
### GET /leaderboard
Returns all employee gamification profiles for leaderboard rendering.
Not paginated at 150 employees.
Response:
```json
{
"employees": [
{
"userId": "string",
"displayName": "string",
"totalCommits": 847,
"currentStreakWeeks": 18,
"typesUsedCount": 9,
"badgeCount": 5,
"level": "senior"
}
]
}
```
Sorted by totalCommits descending. Frontend renders as multi-dimension
table — not a ranked list.
---
### GET /feed
Returns recent milestone cards for the public activity feed.
Most recent first.
Response:
```json
{
"milestones": [
{
"userId": "string",
"displayName": "string",
"cycle": 1,
"week": 26,
"totalCommits": 847,
"streakWeeks": 18,
"badges": ["on_streak", "shipped"],
"createdAt": "string"
}
]
}
```
---
## XP (commits) calculation
Each micro learning type earns a different number of commits based on
cognitive effort required.
```typescript
const COMMITS_PER_TYPE: Record<MicroLearningType, number> = {
concept_explainer: 10,
glossary_anchor: 10,
misconceptions: 15,
how_to: 15,
flashcard_set: 15,
comparison_card: 20,
reflection_prompt: 20,
scenario_quiz: 25,
myth_vs_evidence: 25,
case_study: 30,
}
```
First completion of a type the employee has never used before: +5 bonus
commits (rewards type diversity).
Duplicate completion (same topic + same type in same cycle): 0 commits.
Check session_completions before awarding — idempotent.
---
## Levels
Thresholds based on cumulative commits across all cycles.
```typescript
const LEVEL_THRESHOLDS = {
intern: 0,
junior: 100,
medior: 300,
senior: 600,
staff: 1000,
principal: 1500,
}
```
Level evaluated after every commit update. Level can only increase —
never decreases between cycles.
---
## Streak
Counts consecutive weeks with at least one completion.
Evaluation logic on every POST /complete:
```typescript
const lastActiveWeek = profile.last_active_week
const currentWeek = completionWeekNumber
if (currentWeek === lastActiveWeek) {
// Same week — streak unchanged
} else if (currentWeek === lastActiveWeek + 1) {
// Consecutive — increment
streak += 1
} else {
// Gap — reset to 1
streak = 1
}
profile.last_active_week = currentWeek
profile.longest_streak_weeks = Math.max(streak, profile.longest_streak_weeks)
```
Week number resets to 1 on cycle start. Streak does not reset on cycle
transition — a streak spanning the cycle boundary is maintained.
---
## Badges
### Badge seed data
Seeded into PocketBase badges collection at startup.
```typescript
const BADGE_DEFINITIONS = [
{ key: 'first_commit', tier: 'bronze',
label: 'First commit',
description: 'Complete any session' },
{ key: 'five_sessions', tier: 'silver',
label: 'Five sessions',
description: 'Complete 5 sessions using 5 different micro learning types' },
{ key: 'on_streak', tier: 'gold',
label: 'On a streak',
description: 'Complete 13 sessions without skipping a week' },
{ key: 'shipped', tier: 'legendary',
label: 'Shipped',
description: 'Complete all 26 sessions using all 10 micro learning types' },
{ key: 'governance_nerd', tier: 'content',
label: 'Governance nerd',
description: 'Complete all topics in the holacratic structure theme' },
{ key: 'process_architect', tier: 'content',
label: 'Process architect',
description: 'Complete all topics in the internal processes theme' },
{ key: 'deep_reader', tier: 'content',
label: 'Deep reader',
description: 'Use the case study micro learning type 5 or more times' },
{ key: 'handbook_expert', tier: 'content',
label: 'Handbook expert',
description: 'Complete all topics in the employee handbook theme' },
{ key: 'type_collector', tier: 'content',
label: 'Type collector',
description: 'Use all 10 micro learning types at least once' },
]
```
Content badges are theme-specific. Theme association resolved at runtime
by matching badge key to theme title pattern — not hardcoded to theme IDs.
```typescript
const THEME_BADGE_PATTERNS: Record<string, string> = {
'governance_nerd': 'holacrat',
'process_architect': 'process',
'handbook_expert': 'handbook',
}
```
Case-insensitive substring match on theme title.
---
### Badge evaluation
Run after every POST /complete. Check all conditions, award unearned badges.
```typescript
async function evaluateBadges(userId: string, profile: GamificationProfile):
Promise<BadgeDefinition[]> {
const earnedKeys = await getEarnedBadgeKeys(userId)
const newBadges: string[] = []
if (!earnedKeys.includes('first_commit')) {
const count = await countCompletions(userId)
if (count >= 1) newBadges.push('first_commit')
}
if (!earnedKeys.includes('five_sessions')) {
const sessions = await countUniqueSessions(userId)
if (sessions >= 5 && profile.types_used.length >= 5)
newBadges.push('five_sessions')
}
if (!earnedKeys.includes('on_streak')) {
if (profile.longest_streak_weeks >= 13) newBadges.push('on_streak')
}
if (!earnedKeys.includes('shipped')) {
const cycleComplete = await isCycleComplete(userId, profile.current_cycle)
if (cycleComplete && profile.types_used.length === 10)
newBadges.push('shipped')
}
if (!earnedKeys.includes('deep_reader')) {
const count = await countTypeCompletions(userId, 'case_study')
if (count >= 5) newBadges.push('deep_reader')
}
if (!earnedKeys.includes('type_collector')) {
if (profile.types_used.length === 10) newBadges.push('type_collector')
}
for (const [badgeKey, pattern] of Object.entries(THEME_BADGE_PATTERNS)) {
if (!earnedKeys.includes(badgeKey)) {
const complete = await isThemeComplete(userId, pattern)
if (complete) newBadges.push(badgeKey)
}
}
for (const key of newBadges) {
await awardBadge(userId, key, profile.current_cycle)
}
return newBadges.map(k => BADGE_DEFINITIONS.find(b => b.key === k)!)
}
```
---
## Milestone cards
Generated at weeks 13 and 26 of each cycle.
```typescript
async function generateMilestoneCard(
userId: string,
weekNumber: number,
cycle: number,
profile: GamificationProfile
): Promise<MilestoneCard | null> {
if (weekNumber !== 13 && weekNumber !== 26) return null
const exists = await milestoneExists(userId, cycle, weekNumber)
if (exists) return null
const badges = await getEarnedBadges(userId)
return await pocketbase.collection('milestone_cards').create({
user: userId,
cycle,
week: weekNumber,
total_commits: profile.total_commits,
streak_weeks: profile.current_streak_weeks,
badge_keys: badges.map(b => b.key),
created_at: new Date().toISOString()
})
}
```
---
## Heatmap data
Built from session_completions filtered by userId + cycle.
Returns 26 entries — one per week.
```typescript
async function buildHeatmap(userId: string, cycle: number):
Promise<HeatmapEntry[]> {
const completions = await pocketbase
.collection('session_completions')
.getFullList({ filter: `user="${userId}" && cycle=${cycle}` })
const byWeek = groupBy(completions, c => c.week_number)
return Array.from({ length: 26 }, (_, i) => ({
week: i + 1,
completions: byWeek[i + 1]?.length ?? 0
}))
}
```
---
## Environment variables
```
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
PROGRESS_PORT=3005
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"pocketbase": "^0.21",
"zod": "^3"
}
}
```
No AI calls. No Qdrant. No OpenAI. Pure business logic over PocketBase.
---
## TypeScript strict mode requirements
- No `any` types
- MicroLearningType as explicit string union
- Badge keys as explicit string union matching BADGE_DEFINITIONS
- COMMITS_PER_TYPE keyed by MicroLearningType — compile-time exhaustiveness
- HeatmapEntry, MilestoneCard, BadgeDefinition typed explicitly
---
## What this service does NOT do
- Does not generate content
- Does not handle curriculum scheduling → curriculum service
- Does not serve KB or micro learning data → frontend reads PocketBase
- Does not handle auth → PocketBase + frontend
---
## Testing checkpoints
1. POST /complete for new user → first_commit badge awarded, commits added
2. POST /complete same topic + type twice → 0 commits second call (idempotent)
3. Complete 5 sessions with 5 types → five_sessions badge awarded
4. Simulate 13 consecutive weekly completions → on_streak badge awarded
5. Skip a week → streak resets to 1
6. Complete all topics in a theme → content badge awarded
7. Use all 10 types → type_collector badge awarded
8. Complete week 13 → milestone_card created and returned in response
9. GET /leaderboard → all employees returned with correct fields
10. GET /feed → milestone cards ordered most recent first
11. Cycle transition: week 26 complete → streak spans boundary, level preserved,
heatmap resets for new cycle

583
docs/generation-spec.md Normal file
View File

@@ -0,0 +1,583 @@
# Generation service spec
## Responsibility
Accepts a Theme ID from the admin app (on batch approval) and generates all 10
micro learning types for every published Topic in that Theme. One Claude Sonnet 4
call per type per topic. All outputs validated through Zod schemas before write.
This service runs entirely server-side. The admin app calls it via REST. All AI
calls go through the Anthropic API. No generation logic lives in the frontend.
---
## Service location
```
app/services/generation/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ ├── generate.ts POST /generate, GET /status/:jobId
│ │ └── publish.ts PATCH /micro-learnings/:id
│ ├── pipeline/
│ │ └── generate.ts per-type generation logic
│ ├── jobs/
│ │ └── queue.ts async job queue (in-memory)
│ ├── lib/
│ │ ├── pocketbase.ts PocketBase client
│ │ └── anthropic.ts Anthropic client
│ └── types.ts shared TypeScript types + Zod schemas
├── package.json
├── tsconfig.json
├── .env.example
└── .gitignore
```
---
## API surface
### POST /generate
Triggered by admin app when a Theme batch is approved.
Request:
```json
{
"themeId": "string"
}
```
Response (202 Accepted):
```json
{
"jobId": "string",
"status": "queued",
"topicsFound": 5,
"totalItems": 50
}
```
Processing is async. The admin app polls job status.
Behaviour:
- Fetches all published Topics for the given themeId
- Creates one micro_learnings record per topic per type with status `queued`
- Generates each item sequentially; updates status to `generated` on success
- On failure: sets individual item status to `failed`, continues remaining items
- Job completes when all items are either `generated` or `failed`
---
### GET /status/:jobId
Returns current job progress.
Response:
```json
{
"jobId": "string",
"status": "queued" | "running" | "done" | "failed",
"progress": {
"topicsTotal": 5,
"topicsProcessed": 3,
"itemsTotal": 50,
"itemsGenerated": 28,
"itemsFailed": 2
},
"error": "string | null"
}
```
---
### PATCH /micro-learnings/:id
Admin publishes or rejects an individual micro learning.
Request:
```json
{
"status": "published" | "rejected"
}
```
Response (200 OK):
```json
{
"id": "string",
"status": "published" | "rejected",
"published_at": "datetime | null"
}
```
Rules:
- Only `generated` records can be published or rejected
- `published_at` set on publish, left null on reject
- Returns 400 if record is not in `generated` status
- Returns 404 if record not found
---
## Generation pipeline
### Input
For each Topic in the approved Theme:
```
topic.title: string
topic.body: string
topic.key_terms: string[]
topic.difficulty: 'introductory' | 'intermediate' | 'advanced'
```
### Output
10 micro_learnings records per topic, one per type.
---
## AI call configuration
```typescript
{
model: 'claude-sonnet-4-20250514',
max_tokens: 2000,
temperature: 0 // deterministic structured output
}
```
One call per type per topic. Do not batch multiple types into one call — isolated
calls are easier to retry and validate independently.
---
## Prompt strategy
### System prompt (all types)
```
You are a learning content designer. Your task is to generate structured learning
content for a specific topic in an employee learning platform.
Output ONLY valid JSON matching the schema provided. No preamble, no explanation,
no markdown fences.
The content should be accurate, practical, and appropriate for the stated
difficulty level. Tone: professional but accessible.
```
### User prompt template (all types)
```
Topic: {topic.title}
Difficulty: {topic.difficulty}
Body:
{topic.body}
Key terms: {topic.key_terms.join(', ')}
Generate a {type_label} for this topic.
Output schema:
{JSON.stringify(schemaDescription)}
```
---
## Type-specific prompts and schemas
### concept_explainer
Type label: `Concept Explainer`
Schema description:
```json
{
"paragraphs": ["2 to 3 paragraphs explaining the concept in plain language"],
"example": "one concrete real-world example"
}
```
Zod schema:
```typescript
z.object({
paragraphs: z.array(z.string()).min(2).max(3),
example: z.string().min(20)
})
```
---
### scenario_quiz
Type label: `Scenario Quiz`
Schema description:
```json
{
"scenario": "a realistic workplace scenario",
"options": [
{ "label": "A", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "B", "text": "answer text", "correct": true, "explanation": "why" },
{ "label": "C", "text": "answer text", "correct": false, "explanation": "why" },
{ "label": "D", "text": "answer text", "correct": false, "explanation": "why" }
]
}
```
Rules: exactly 4 options, exactly 1 correct.
Zod schema:
```typescript
z.object({
scenario: z.string().min(30),
options: z.array(z.object({
label: z.enum(['A', 'B', 'C', 'D']),
text: z.string().min(5),
correct: z.boolean(),
explanation: z.string().min(10)
})).length(4).refine(
opts => opts.filter(o => o.correct).length === 1,
{ message: 'exactly one correct option required' }
)
})
```
---
### misconceptions
Type label: `Misconceptions`
Schema description:
```json
{
"items": [
{ "misconception": "common wrong belief", "correction": "accurate explanation" }
]
}
```
Rules: 3 to 5 items.
Zod schema:
```typescript
z.object({
items: z.array(z.object({
misconception: z.string().min(10),
correction: z.string().min(10)
})).min(3).max(5)
})
```
---
### how_to
Type label: `How-To Guide`
Schema description:
```json
{
"steps": [
{ "number": 1, "instruction": "what to do" }
]
}
```
Rules: 3 to 8 steps.
Zod schema:
```typescript
z.object({
steps: z.array(z.object({
number: z.number().int().positive(),
instruction: z.string().min(10)
})).min(3).max(8)
})
```
---
### comparison_card
Type label: `Comparison Card`
Schema description:
```json
{
"subject_a": "first concept or approach",
"subject_b": "second concept or approach",
"dimensions": [
{ "label": "dimension name", "a": "how A differs", "b": "how B differs" }
]
}
```
Rules: 3 to 6 dimensions.
Zod schema:
```typescript
z.object({
subject_a: z.string().min(2),
subject_b: z.string().min(2),
dimensions: z.array(z.object({
label: z.string().min(2),
a: z.string().min(5),
b: z.string().min(5)
})).min(3).max(6)
})
```
---
### reflection_prompt
Type label: `Reflection Prompt`
Schema description:
```json
{
"prompt": "open-ended question for the employee to reflect on",
"model_answer": "a thoughtful example answer the employee can compare against"
}
```
Zod schema:
```typescript
z.object({
prompt: z.string().min(20),
model_answer: z.string().min(50)
})
```
---
### flashcard_set
Type label: `Flashcard Set`
Schema description:
```json
{
"cards": [
{ "question": "question text", "answer": "answer text" }
]
}
```
Rules: 5 to 10 cards.
Zod schema:
```typescript
z.object({
cards: z.array(z.object({
question: z.string().min(5),
answer: z.string().min(5)
})).min(5).max(10)
})
```
---
### case_study
Type label: `Case Study`
Schema description:
```json
{
"scenario": "a detailed real-world scenario (150+ words)",
"questions": ["discussion or reflection question 1", "discussion or reflection question 2"]
}
```
Rules: 2 to 4 questions.
Zod schema:
```typescript
z.object({
scenario: z.string().min(150),
questions: z.array(z.string().min(10)).min(2).max(4)
})
```
---
### glossary_anchor
Type label: `Glossary Anchor`
Schema description:
```json
{
"term": "the key term",
"definition": "precise definition",
"correct_use": "example sentence showing correct use",
"misuse": "common incorrect usage to avoid"
}
```
Prompt addition: use the first key term from `topic.key_terms` as the anchor term.
Zod schema:
```typescript
z.object({
term: z.string().min(2),
definition: z.string().min(20),
correct_use: z.string().min(20),
misuse: z.string().min(20)
})
```
---
### myth_vs_evidence
Type label: `Myth vs Evidence`
Schema description:
```json
{
"myth": "a commonly held misconception about this topic",
"evidence": "the evidence-based counterpoint",
"sources": ["source or reference if applicable — leave empty array if none"]
}
```
Zod schema:
```typescript
z.object({
myth: z.string().min(20),
evidence: z.string().min(30),
sources: z.array(z.string())
})
```
---
## Error handling
**Per item:**
- JSON parse failure → retry once with stricter prompt ("respond with valid JSON only, no other text")
- Second failure → set micro_learning status to `failed`, log raw response, continue to next item
- Zod validation failure → same as parse failure: retry once, then `failed`
- Anthropic API error (rate limit / timeout) → exponential backoff, 3 retries, then `failed`
**Per job:**
- If all items for a topic fail → log, continue to next topic
- Job status becomes `done` when all items processed, regardless of individual failures
- Job status becomes `failed` only if the initial topic fetch fails (PocketBase error before generation starts)
---
## PocketBase write
For each generated item:
```typescript
{
topic: topicId,
type: type, // one of the 10 type enum values
content: validatedContent, // JSON, validated by Zod
status: 'generated',
generation_model: 'claude-sonnet-4-20250514',
generated_at: new Date().toISOString()
}
```
Create record with status `queued` before generation starts.
Update to `generated` (with content) or `failed` after attempt.
---
## Job lifecycle
```
POST /generate received
Fetch published Topics for Theme
Create micro_learning records: status = queued
Job created → status: running
For each topic:
For each of 10 types:
Claude call → validate → write content → status = generated
All items processed
Job status: done
```
On topic fetch failure:
```
status: failed
error: { reason: 'topic_fetch_failed', detail: ... }
```
---
## Environment variables required
```
ANTHROPIC_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
GENERATION_PORT=3002
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@anthropic-ai/sdk": "^0.24",
"pocketbase": "^0.21",
"uuid": "^9",
"zod": "^3"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"tsx": "^4"
}
}
```
---
## TypeScript strict mode requirements
- No `any` types
- All Claude response parsing through Zod schema validation before PocketBase write
- All PocketBase writes typed against micro_learnings schema from data-model.md
- Content type is `unknown` after JSON.parse — always validate through Zod before use
---
## What this service does NOT do
- Does not extract or chunk source documents → ingestion service
- Does not build or schedule the curriculum → curriculum service
- Does not handle admin auth → PocketBase + admin app
- Does not embed content into Qdrant → ingestion service handles all embeddings
- Does not serve R42 queries → chat service
---
## Testing checkpoints
1. Call POST /generate with a themeId that has 2 published topics → verify 20 micro_learning records created
2. All 10 types generated for each topic → verify content JSON parses correctly
3. All Zod schemas pass for each of the 10 types
4. PATCH /micro-learnings/:id with `published` → verify status + published_at updated
5. PATCH /micro-learnings/:id with `rejected` → verify status updated, published_at null
6. Force a JSON parse error (mock) → verify retry logic fires once, then sets status to `failed`
7. GET /status/:jobId during processing → verify progress counters increment correctly

336
docs/r42-spec.md Normal file
View File

@@ -0,0 +1,336 @@
# R42 chat service spec
## Responsibility
Handles all R42 chatbot interactions. Receives employee queries, retrieves
relevant KB chunks from Qdrant, generates grounded responses using Claude
Haiku 4.5, and streams the result to the frontend. Stateless — no chat
history is persisted.
---
## Service location
```
app/services/chat/
├── src/
│ ├── index.ts entry point, Fastify server
│ ├── routes/
│ │ └── chat.ts POST /chat (streaming)
│ ├── retrieval/
│ │ ├── embed.ts query → embedding
│ │ ├── search.ts Qdrant nearest-neighbour search
│ │ └── merge.ts merge + rank results from both collections
│ ├── prompt/
│ │ └── build.ts assemble system + user prompt with context
│ └── lib/
│ ├── qdrant.ts
│ ├── pocketbase.ts
│ ├── anthropic.ts
│ └── openai.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## API surface
### POST /chat
Single route. Streams response back to client using server-sent events (SSE).
Request:
```json
{
"query": "string",
"userId": "string"
}
```
Response: SSE stream
```
Content-Type: text/event-stream
data: {"type": "chunk", "text": "Holacratic roles "}
data: {"type": "chunk", "text": "are defined as..."}
data: {"type": "citations", "topics": [{"id": "abc", "title": "Holacratic roles"}]}
data: {"type": "done"}
```
Error response (non-streaming, returned before stream starts):
```json
{
"error": "query_too_short" | "user_not_found" | "retrieval_failed",
"message": "string"
}
```
---
## Retrieval pipeline
### Step 1 — Embed query
Embed the employee query using OpenAI text-embedding-3-small (1536 dimensions).
Same model used during ingestion — vectors are comparable.
```typescript
const queryVector = await embedText(query) // float[1536]
```
---
### Step 2 — Qdrant search
Search both collections in parallel:
```typescript
// source_chunks: primary retrieval — grounded in source material
const chunkResults = await qdrant.search('source_chunks', {
vector: queryVector,
limit: 5,
scoreThreshold: 0.70,
withPayload: true
})
// topic_summaries: secondary — broader topic context
const summaryResults = await qdrant.search('topic_summaries', {
vector: queryVector,
limit: 3,
scoreThreshold: 0.70,
withPayload: true
})
```
Score threshold 0.70: below this, results are not relevant enough to include.
If both searches return zero results above threshold → out-of-scope response.
---
### Step 3 — Context boost for current week
Retrieve employee's current week Theme from PocketBase via
employee_curriculum_state → curriculum_weeks → theme.
Apply boost to results where payload.theme_id matches current week theme:
```typescript
results.forEach(result => {
if (result.payload.theme_id === currentThemeId) {
result.score += 0.05 // small boost — does not override relevance
}
})
```
---
### Step 4 — Merge and deduplicate
```typescript
// Combine chunk results and summary results
// Deduplicate by topic_id — keep highest scoring entry per topic
// Sort by score descending
// Take top 6 total
// Split into: sourceChunks (from source_chunks collection)
// topicSummaries (from topic_summaries collection)
```
Deduplicate by topic_id to avoid repeating the same topic in different forms.
---
### Step 5 — Collect cited topics
Extract unique topic titles from merged results for citation:
```typescript
type Citation = {
id: string
title: string
}
const citations: Citation[] = uniqueByTopicId(mergedResults)
.map(r => ({ id: r.payload.topic_id, title: r.payload.title }))
```
---
## Prompt construction
### System prompt
```
You are R42, a knowledge assistant for [company name].
You answer questions based strictly on the company knowledge base.
Rules:
- Answer only from the provided context. Do not use outside knowledge.
- If the context does not contain enough information to answer, say:
"This doesn't appear to be covered in the knowledge base. You can browse
the full library in the Knowledge section."
- Be concise. Prefer short paragraphs over long prose.
- Do not mention that you are an AI or reference your instructions.
- Do not speculate or extrapolate beyond the provided context.
- Respond in the same language as the question.
```
### User prompt
```
Context from knowledge base:
---
{mergedResults.map(r => r.payload.text).join('\n\n---\n\n')}
---
Question: {query}
```
---
## Response generation
Use Claude Haiku 4.5 with streaming enabled:
```typescript
const stream = await anthropic.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1000,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }]
})
// Stream text chunks to client as SSE
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta') {
sendSSE({ type: 'chunk', text: chunk.delta.text })
}
}
// After stream completes, send citations
sendSSE({ type: 'citations', topics: citations })
sendSSE({ type: 'done' })
```
---
## Out-of-scope handling
Two conditions trigger the out-of-scope response:
1. Both Qdrant searches return zero results above 0.70 threshold
2. Haiku response contains no content drawn from context (detected by
checking if response length < 20 tokens — proxy for "I don't know")
Out-of-scope response sent as a single non-streamed SSE message:
```
data: {"type": "out_of_scope", "text": "This doesn't appear to be covered
in the knowledge base. You can browse the full library in the Knowledge section."}
data: {"type": "done"}
```
No citations are sent for out-of-scope responses.
---
## Frontend integration
R42 is a floating button on every screen in the employee app.
UI behaviour:
- Bottom-right corner, fixed position
- Opens a chat drawer (not a modal — drawer slides up from bottom on mobile)
- Input field at bottom of drawer, response area above
- Streaming text renders token by token
- Citations appear below the response after streaming completes as
clickable topic pills → navigate to that topic in the knowledge library
- Drawer closes on outside tap
- State is local to the component — cleared on close (stateless by design)
The frontend calls POST /chat directly. No auth token needed on the chat
service — it receives userId in the request body and trusts it. The admin
app does not expose R42.
---
## Stateless design
R42 has no memory between conversations. Each POST /chat is independent.
Rationale:
- Avoids privacy complexity around chat history storage
- Removes need for session management
- Keeps the service simple and fast
- Employees asking follow-up questions reprovide context naturally
If multi-turn conversation is needed in a future iteration, maintain
conversation history in the frontend component state and pass the last
N messages in the request body. The service does not need to change.
---
## Environment variables
```
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
POCKETBASE_URL=
POCKETBASE_ADMIN_EMAIL=
POCKETBASE_ADMIN_PASSWORD=
QDRANT_URL=
QDRANT_API_KEY=
CHAT_PORT=3004
```
---
## Dependencies
```json
{
"dependencies": {
"fastify": "^4",
"@fastify/sse": "^2",
"@anthropic-ai/sdk": "^0.24",
"openai": "^4",
"@qdrant/js-client-rest": "^1.9",
"pocketbase": "^0.21",
"zod": "^3"
}
}
```
---
## TypeScript strict mode requirements
- No `any` types
- Qdrant search results typed explicitly including payload fields
- SSE event types defined as a discriminated union
- Citation type explicit — not inferred from payload
---
## What this service does NOT do
- Does not persist chat history
- Does not generate or serve micro learning content
- Does not handle admin queries — admin app has no R42 access
- Does not handle auth — trusts userId from request body
---
## Testing checkpoints
1. POST /chat with a query matching a published topic → confirm relevant
chunks retrieved (score > 0.70) and response references topic content
2. POST /chat with an out-of-scope query → confirm out-of-scope response
returned, no citations sent
3. Confirm citations array contains correct topic titles matching retrieved chunks
4. Confirm SSE stream delivers chunks progressively (not batched)
5. Confirm current-week boost: same query returns higher-ranked result for
current week theme topic vs equally relevant topic from different theme
6. POST /chat with userId whose current week has no matching topic →
confirm boost does not break retrieval, general results returned