Skip to content

Commit f8da7a4

Browse files
authored
Merge pull request #1037 from robertocarlous/feat/ai-learning-path-recommender
2 parents 2df04a6 + d923da7 commit f8da7a4

13 files changed

Lines changed: 874 additions & 82 deletions

server/src/controllers/recommendations.controller.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Response } from "express"
1+
import { type Request, type Response } from "express"
22
import { logger } from "../lib/logger"
33
import { type AuthRequest } from "../middleware/auth.middleware"
44
import {
@@ -8,6 +8,11 @@ import {
88

99
const log = logger.child({ module: "recommendations" })
1010

11+
function parseLimit(value: unknown): number {
12+
const limitParam = typeof value === "string" ? parseInt(value, 10) : 4
13+
return !isNaN(limitParam) && limitParam > 0 ? limitParam : 4
14+
}
15+
1116
export const getLearnerRecommendations = async (
1217
req: AuthRequest,
1318
res: Response,
@@ -19,10 +24,7 @@ export const getLearnerRecommendations = async (
1924
return
2025
}
2126

22-
const limitParam =
23-
typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 4
24-
const limit = !isNaN(limitParam) && limitParam > 0 ? limitParam : 4
25-
27+
const limit = parseLimit(req.query.limit)
2628
const recommendations = await getRecommendations(walletAddress, limit)
2729

2830
res.status(200).json({ data: recommendations })
@@ -32,6 +34,27 @@ export const getLearnerRecommendations = async (
3234
}
3335
}
3436

37+
export const getRecommendationsForAddress = async (
38+
req: Request,
39+
res: Response,
40+
): Promise<void> => {
41+
try {
42+
const address = req.params.address
43+
if (!address) {
44+
res.status(400).json({ error: "address is required" })
45+
return
46+
}
47+
48+
const limit = parseLimit(req.query.limit)
49+
const recommendations = await getRecommendations(address, limit)
50+
51+
res.status(200).json({ data: recommendations })
52+
} catch (error) {
53+
log.error({ err: error }, "Failed to get recommendations for address")
54+
res.status(500).json({ error: "Internal server error" })
55+
}
56+
}
57+
3558
export const engageRecommendation = async (
3659
req: AuthRequest,
3760
res: Response,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- ============================================================
2+
-- Migration 026: Course prerequisite path rules (for recommendations)
3+
-- ============================================================
4+
5+
-- Declares the recommended learning path between courses, e.g. Soroban
6+
-- requires Stellar Basics first. Distinct from the hard enrollment gate in
7+
-- courses.prerequisites — this table drives recommendation scoring/gating.
8+
CREATE TABLE IF NOT EXISTS course_prerequisites (
9+
course_slug TEXT NOT NULL REFERENCES courses(slug) ON DELETE CASCADE,
10+
requires_slug TEXT NOT NULL REFERENCES courses(slug) ON DELETE CASCADE,
11+
PRIMARY KEY (course_slug, requires_slug)
12+
);
13+
14+
CREATE INDEX IF NOT EXISTS idx_course_prerequisites_course_slug
15+
ON course_prerequisites (course_slug);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- ============================================================
2+
-- Undo Migration 026: Course prerequisite path rules
3+
-- ============================================================
4+
5+
DROP TABLE IF EXISTS course_prerequisites;

server/src/lib/api-response-cache.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,28 @@ import Redis from "ioredis"
88
* - /api/leaderboard: 300s
99
* - /api/treasury/stats: 60s
1010
* - /api/courses: 600s
11+
* - /api/recommendations/:address: 60s
1112
*
1213
* Cache keys are namespaced per endpoint type, and include the full URL
13-
* (path + query) to ensure distinct responses are cached separately.
14+
* (path + query) to ensure distinct responses are cached separately. Since
15+
* the learner address lives in the URL path, recommendations are naturally
16+
* cached per learner.
1417
*
1518
* When `REDIS_URL` is not configured, this falls back to an in-process memory
1619
* cache (useful for local dev + unit tests).
1720
*/
1821

19-
export type ApiCacheType = "leaderboard" | "treasury_stats" | "courses"
22+
export type ApiCacheType =
23+
| "leaderboard"
24+
| "treasury_stats"
25+
| "courses"
26+
| "recommendations"
2027

2128
export const API_RESPONSE_CACHE_TTLS: Record<ApiCacheType, number> = {
2229
leaderboard: 300,
2330
treasury_stats: 60,
2431
courses: 600,
32+
recommendations: 60,
2533
}
2634

2735
const PREFIX = "learnvault:api-cache:"

server/src/routes/recommendations.routes.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,53 @@
11
import { Router } from "express"
22
import {
33
getLearnerRecommendations,
4+
getRecommendationsForAddress,
45
engageRecommendation,
56
} from "../controllers/recommendations.controller"
6-
import { createRequireAuth } from "../middleware/auth.middleware"
7+
import { apiResponseCache } from "../middleware/api-response-cache.middleware"
8+
import {
9+
createOptionalAuth,
10+
createRequireAuth,
11+
} from "../middleware/auth.middleware"
712
import { type JwtService } from "../services/jwt.service"
813

914
export const createRecommendationsRouter = (jwtService: JwtService): Router => {
1015
const router = Router()
1116
const authMiddleware = createRequireAuth(jwtService)
17+
const optionalAuth = createOptionalAuth(jwtService)
1218

1319
router.get("/recommendations", authMiddleware, getLearnerRecommendations)
20+
21+
/**
22+
* @openapi
23+
* /api/recommendations/{address}:
24+
* get:
25+
* tags: [Recommendations]
26+
* summary: Get personalized course recommendations for a learner
27+
* description: Returns a ranked list of recommended next courses with a human-readable reason per item, based on completed courses, path rules, and co-occurrence with similar learners.
28+
* parameters:
29+
* - in: path
30+
* name: address
31+
* required: true
32+
* schema: { type: string }
33+
* - in: query
34+
* name: limit
35+
* schema: { type: integer }
36+
* responses:
37+
* 200:
38+
* description: Ranked recommendations
39+
* 400:
40+
* $ref: '#/components/responses/BadRequestError'
41+
* 500:
42+
* $ref: '#/components/responses/InternalServerError'
43+
*/
44+
router.get(
45+
"/recommendations/:address",
46+
optionalAuth,
47+
apiResponseCache("recommendations"),
48+
getRecommendationsForAddress,
49+
)
50+
1451
router.post("/recommendations/engage", authMiddleware, engageRecommendation)
1552

1653
return router

server/src/services/credential.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { pool } from "../db/index"
22
import { milestoneStore } from "../db/milestone-store"
3+
import { invalidateApiResponseCacheType } from "../lib/api-response-cache"
34
import { pinJsonToIPFS } from "./pinata.service"
45
import {
56
stellarContractService,
@@ -65,6 +66,12 @@ async function mintCertificateIfComplete(
6566
VALUES ($1, $2, $3, $4)`,
6667
[mintResult.tokenId, scholarAddress, courseId, tokenUri],
6768
)
69+
70+
// New completion invalidates cached recommendations so the learner's
71+
// next fetch reflects their updated history.
72+
await invalidateApiResponseCacheType("recommendations").catch(() => {
73+
/* Non-fatal */
74+
})
6875
}
6976

7077
return {

0 commit comments

Comments
 (0)