Spaces:
Sleeping
Sleeping
File size: 8,500 Bytes
5bd3663 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | /**
* Recommendation pipeline.
*
* Steps:
* 1. Compute semantic similarity via HF Inference API (all-MiniLM-L6-v2).
* 2. Retrieve top-5 candidates by cosine similarity.
* 3. LLM re-ranking (Kimi-K2.5 via HF Router) -> top-3 with explanations.
* Falls back to rule-based ranking if LLM call fails.
*/
import { CONTENT_ITEMS } from "./data.js";
import { retrieveTopK } from "./embeddings.js";
// ---------------------------------------------------------------------------
// Style -> preferred formats mapping
// ---------------------------------------------------------------------------
const STYLE_FORMAT_MAP = {
visual: new Set(["video"]),
reading: new Set(["slides", "lecture"]),
"hands-on": new Set(["video", "lecture"]),
};
const DIFFICULTY_ORDER = { Beginner: 0, Intermediate: 1, Advanced: 2 };
// ---------------------------------------------------------------------------
// Rule-based re-rank (fallback)
// ---------------------------------------------------------------------------
function ruleRerank(profile, candidates) {
const userTags = new Set(profile.interest_tags || []);
const prefFormats = STYLE_FORMAT_MAP[profile.learning_style] || new Set();
const prefDiff = DIFFICULTY_ORDER[profile.preferred_difficulty] ?? 1;
const scored = candidates
.filter((c) => {
if ((profile.viewed_content_ids || []).includes(c.id)) return false;
if (c.duration_minutes > profile.time_per_day) return false;
return true;
})
.map((item) => {
const itemTags = new Set(item.tags);
const overlap =
[...userTags].filter((t) => itemTags.has(t)).length /
Math.max(userTags.size, 1);
const fmtBonus = prefFormats.has(item.format) ? 0.2 : 0;
const diffPenalty =
Math.abs((DIFFICULTY_ORDER[item.difficulty] ?? 1) - prefDiff) * 0.15;
const score = overlap + fmtBonus - diffPenalty;
const common = [...userTags].filter((t) => itemTags.has(t));
return { item, score, common };
})
.sort((a, b) => b.score - a.score)
.slice(0, 3);
return scored.map((s, i) => ({
rank: i + 1,
id: s.item.id,
title: s.item.title,
format: s.item.format,
difficulty: s.item.difficulty,
duration_minutes: s.item.duration_minutes,
tags: s.item.tags,
explanation: `Matched on tags (${s.common.join(", ")}), format fits your ${profile.learning_style} style, and difficulty is ${s.item.difficulty}.`,
match_score: Math.round(s.score * 1000) / 1000,
}));
}
// ---------------------------------------------------------------------------
// LLM re-rank via HuggingFace Router
// ---------------------------------------------------------------------------
const LLM_API_KEY =
process.env.HF_TOKEN || process.env.OPENROUTER_API_KEY || "";
const LLM_MODEL = process.env.LLM_MODEL || "moonshotai/Kimi-K2.5:novita";
const LLM_BASE_URL =
process.env.LLM_BASE_URL ||
"https://router.huggingface.co/v1/chat/completions";
const SYSTEM_PROMPT = `You are an expert educational content recommender. Re-rank the candidate items for the learner below and return the top 3 as a strict JSON array.
CONSTRAINTS:
1. Prefer items matching the learner's preferred difficulty.
2. Each item's duration_minutes must fit the learner's time_per_day budget.
3. Favour formats that suit the learning style (visual -> video, reading -> slides/lecture, hands-on -> video/lecture).
4. Never recommend already-viewed content.
5. Provide a concise, personalised explanation (1-2 sentences) per pick.
Return ONLY a JSON array with exactly 3 objects, each having:
rank (int 1-3), id (int), title (str), format (str), difficulty (str),
duration_minutes (int), tags (list[str]), explanation (str).
No text outside the JSON array.`;
async function llmRerank(profile, candidates) {
const userPrompt = [
"### Learner Profile",
`- Name: ${profile.name}`,
`- Goal: ${profile.goal}`,
`- Learning style: ${profile.learning_style}`,
`- Preferred difficulty: ${profile.preferred_difficulty}`,
`- Time per day: ${profile.time_per_day} minutes`,
`- Interests: ${(profile.interest_tags || []).join(", ")}`,
`- Already viewed IDs: ${JSON.stringify(profile.viewed_content_ids || [])}`,
"",
"### Candidate Items",
"```json",
JSON.stringify(
candidates.map(({ _simScore, ...rest }) => rest),
null,
2
),
"```",
"",
"Re-rank and return the top 3 as a JSON array.",
].join("\n");
const resp = await fetch(LLM_BASE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: LLM_MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: 0.3,
max_tokens: 2048,
}),
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`LLM returned ${resp.status}: ${body}`);
}
const data = await resp.json();
const msg = data.choices?.[0]?.message;
let text = (msg?.content || msg?.reasoning || "").trim();
if (!text) throw new Error("LLM returned empty response.");
// Strip markdown fences
text = text.replace(/```(?:json)?\s*/g, "").replace(/```/g, "").trim();
// Extract JSON array
const match = text.match(/\[[\s\S]*\]/);
if (!match) throw new Error("No JSON array in LLM response.");
const parsed = JSON.parse(match[0]);
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error("Invalid LLM JSON.");
}
const candMap = Object.fromEntries(candidates.map((c) => [c.id, c]));
return parsed.slice(0, 3).map((item, i) => {
const fb = candMap[item.id];
return {
rank: i + 1,
id: item.id,
title: item.title || fb?.title || "",
format: item.format || fb?.format || "",
difficulty: item.difficulty || fb?.difficulty || "",
duration_minutes: item.duration_minutes || fb?.duration_minutes || 0,
tags: item.tags || fb?.tags || [],
explanation: item.explanation || "Recommended based on your profile.",
};
});
}
// ---------------------------------------------------------------------------
// Main pipeline
// ---------------------------------------------------------------------------
export async function recommend(profile) {
const pipelineLog = [];
const tStart = performance.now();
// Steps 1-2 — semantic similarity retrieval via all-MiniLM-L6-v2
const t0 = performance.now();
const retrieval = await retrieveTopK(
profile,
CONTENT_ITEMS,
5,
profile.viewed_content_ids || []
);
const candidates = retrieval.candidates;
const retrievalMs = Math.round(performance.now() - t0);
const eligible = CONTENT_ITEMS.length - (profile.viewed_content_ids || []).length;
pipelineLog.push({
step: "Semantic similarity retrieval",
status: "done",
detail: `Retrieved top-5 from ${eligible} items via ${retrieval.method} (${retrievalMs}ms)`,
duration_ms: retrievalMs,
});
// Step 4 — LLM re-rank or rule-based fallback
let recommendations;
let reasoning = null;
let method = "rule-based";
if (LLM_API_KEY) {
try {
const t3 = performance.now();
recommendations = await llmRerank(profile, candidates);
const llmMs = Math.round(performance.now() - t3);
method = "llm";
reasoning = "LLM-based constraint-aware re-ranking.";
pipelineLog.push({
step: "LLM re-ranking",
status: "done",
detail: `Ranked via LLM -> top 3 (${llmMs}ms)`,
duration_ms: llmMs,
});
} catch (err) {
console.warn(
"[llm_rerank] Failed:",
err.message,
"— falling back to rules."
);
recommendations = null;
}
}
if (!recommendations) {
const t4 = performance.now();
recommendations = ruleRerank(profile, candidates);
const ruleMs = Math.round(performance.now() - t4);
method = "rule-based";
reasoning =
"Rule-based scoring: tag overlap + format match + difficulty proximity.";
pipelineLog.push({
step: "Rule-based ranking",
status: "done",
detail: `Ranked via rules -> top 3 (${ruleMs}ms)`,
duration_ms: ruleMs,
});
}
const totalMs = Math.round(performance.now() - tStart);
return {
user_id: profile.user_id || "custom",
recommendations,
pipeline_log: pipelineLog,
llm_reasoning: reasoning,
total_duration_ms: totalMs,
};
}
|