diff --git a/src/index.ts b/src/index.ts index ce5d221..7ab93a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { ModmailModule } from "./modules/modmail/modmail.module.js"; import ModuleManager from "./modules/moduleManager.js"; import PastifyModule from "./modules/pastify/pastify.module.js"; import { ReactionStatsModule } from "./modules/reactionStats/reactionStats.module.js"; +import { RecommenderModule } from "./modules/recommender/recommender.module.js"; import { RolesModule } from "./modules/roles/roles.module.js"; import { ShowcaseModule } from "./modules/showcase.module.js"; import { StarboardModule } from "./modules/starboard/starboard.module.js"; @@ -75,6 +76,7 @@ export const moduleManager = new ModuleManager( AchievementsModule, ThreatDetectionModule, ReactionStatsModule, + RecommenderModule, ], ); diff --git a/src/modules/recommender/engine/data/languages.ts b/src/modules/recommender/engine/data/languages.ts new file mode 100644 index 0000000..1bc3788 --- /dev/null +++ b/src/modules/recommender/engine/data/languages.ts @@ -0,0 +1,411 @@ +import type { RecommendationTarget } from "../types.js"; + +export const languages: RecommendationTarget[] = [ + { + id: "python", + kind: "language", + name: "Python", + emoji: "🐍", + description: + "Versatile, beginner-friendly, dominant in AI and data science", + pros: [ + "Very easy to learn", + "Massive ecosystem and community", + "Best language for AI/ML", + "Huge job market", + ], + cons: [ + "Slow runtime performance", + "GIL limits true concurrency", + "Dynamic typing can hide bugs", + ], + tags: ["scripting", "ml", "web", "beginner", "mainstream"], + learningResourceIds: ["python.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/python" }, + { label: "Official Docs", url: "https://docs.python.org/3/" }, + ], + }, + { + id: "javascript", + kind: "language", + name: "JavaScript", + emoji: "🌐", + description: "The language of the web — runs everywhere", + pros: [ + "Runs in every browser", + "Enormous ecosystem (npm)", + "Full-stack with Node.js", + "Very large job market", + ], + cons: [ + "Quirky type coercion", + "No built-in type safety", + "Callback/async complexity", + ], + tags: ["web", "scripting", "fullstack", "beginner", "mainstream"], + learningResourceIds: ["javascript.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/javascript" }, + { + label: "MDN Docs", + url: "https://developer.mozilla.org/en-US/docs/Web/JavaScript", + }, + ], + }, + { + id: "typescript", + kind: "language", + name: "TypeScript", + emoji: "🔷", + description: "JavaScript with static types — safer and more scalable", + pros: [ + "Catches bugs at compile time", + "Excellent IDE support", + "Scales to large codebases", + "Growing job demand", + ], + cons: [ + "Extra compilation step", + "Complex type system to master", + "Still inherits JS quirks", + ], + tags: ["web", "fullstack", "typed", "mainstream"], + learningResourceIds: ["typescript.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/typescript" }, + { + label: "Official Handbook", + url: "https://www.typescriptlang.org/docs/handbook/", + }, + ], + }, + { + id: "java", + kind: "language", + name: "Java", + emoji: "☕", + description: "Enterprise workhorse — write once, run anywhere", + pros: [ + "Enormous job market", + "Extremely mature ecosystem", + "Strong typing and OOP", + "Minecraft modding & Android", + ], + cons: ["Verbose syntax", "Slow startup times", "Heavy boilerplate"], + tags: ["enterprise", "android", "plugins", "mainstream"], + learningResourceIds: ["java.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/java" }, + { label: "Dev.java", url: "https://dev.java/learn/" }, + ], + }, + { + id: "kotlin", + kind: "language", + name: "Kotlin", + emoji: "🟣", + description: "Modern, concise JVM language — official for Android", + pros: [ + "Much less boilerplate than Java", + "Null safety built in", + "Official Android language", + "Full Java interop", + ], + cons: [ + "Smaller community than Java", + "Slower compilation", + "Less standalone job demand", + ], + tags: ["android", "plugins", "jvm", "modern"], + learningResourceIds: ["kotlin.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/android" }, + { + label: "Kotlin Docs", + url: "https://kotlinlang.org/docs/getting-started.html", + }, + ], + }, + { + id: "c", + kind: "language", + name: "C", + emoji: "⚙️", + description: "The foundation — learn how computers actually work", + pros: [ + "Teaches memory management", + "Maximum performance", + "Basis of most operating systems", + "Tiny runtime footprint", + ], + cons: [ + "Manual memory management", + "Easy to create security bugs", + "No built-in data structures", + ], + tags: ["systems", "performance", "low-level", "educational"], + learningResourceIds: ["c.yaml"], + resources: [ + { + label: "Learn C", + url: "https://www.learn-c.org/", + }, + ], + }, + { + id: "cpp", + kind: "language", + name: "C++", + emoji: "🎮", + description: "High-performance systems language — games, engines, and more", + pros: [ + "Top performance with abstractions", + "Game industry standard", + "Huge ecosystem", + "Unreal Engine language", + ], + cons: [ + "Very complex language", + "Long compile times", + "Easy to shoot yourself in the foot", + ], + tags: ["systems", "games", "performance", "mainstream"], + learningResourceIds: ["cpp.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/cpp" }, + { label: "Learn C++", url: "https://www.learncpp.com/" }, + ], + }, + { + id: "csharp", + kind: "language", + name: "C#", + emoji: "💜", + description: "Versatile language for games (Unity), web, and enterprise", + pros: [ + "Unity game engine language", + "Excellent tooling (Visual Studio)", + "Strong .NET ecosystem", + "Good job market", + ], + cons: ["Historically Windows-centric", "Large runtime", "Can feel verbose"], + tags: ["games", "enterprise", "web", "mainstream"], + learningResourceIds: ["csharp.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/aspnet-core" }, + { + label: "Microsoft Learn", + url: "https://learn.microsoft.com/en-us/dotnet/csharp/", + }, + ], + }, + { + id: "rust", + kind: "language", + name: "Rust", + emoji: "🦀", + description: + "Safe, fast, and modern systems programming — no garbage collector", + pros: [ + "Memory safety without GC", + "Excellent performance", + "Amazing compiler error messages", + "Rapidly growing ecosystem", + ], + cons: [ + "Steep learning curve", + "Borrow checker can be frustrating", + "Longer development time", + ], + tags: ["systems", "performance", "safe", "niche", "modern"], + learningResourceIds: ["rust.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/rust" }, + { + label: "The Rust Book", + url: "https://doc.rust-lang.org/book/", + }, + ], + }, + { + id: "go", + kind: "language", + name: "Go", + emoji: "🐹", + description: + "Simple, fast, and built for concurrency — great for backends and DevOps", + pros: [ + "Very easy to learn", + "Excellent concurrency (goroutines)", + "Fast compilation", + "Strong DevOps/cloud ecosystem", + ], + cons: [ + "Limited generics (improving)", + "Verbose error handling", + "No exceptions or sum types", + ], + tags: ["backend", "devops", "cloud", "modern"], + learningResourceIds: ["go.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/golang" }, + { + label: "Go Tour", + url: "https://go.dev/tour/welcome/1", + }, + ], + }, + { + id: "zig", + kind: "language", + name: "Zig", + emoji: "⚡", + description: + "Modern low-level language — a better C with no hidden control flow", + pros: [ + "Simpler than C/C++/Rust", + "No hidden allocations", + "Great C interop", + "Comptime metaprogramming", + ], + cons: ["Still pre-1.0", "Small ecosystem", "Limited learning resources"], + tags: ["systems", "performance", "low-level", "niche", "modern"], + resources: [ + { label: "Zig Guide", url: "https://zig.guide/" }, + { label: "Ziglearn", url: "https://ziglearn.org/" }, + ], + }, + { + id: "haskell", + kind: "language", + name: "Haskell", + emoji: "λ", + description: "Pure functional programming — expand your mind", + pros: [ + "Powerful type system", + "Immutability by default", + "Teaches functional thinking", + "Great for compilers/DSLs", + ], + cons: [ + "Very steep learning curve", + "Small job market", + "Lazy evaluation can be confusing", + ], + tags: ["functional", "academic", "niche"], + resources: [ + { + label: "Learn You a Haskell", + url: "https://learnyouahaskell.com/", + }, + ], + }, + { + id: "elixir", + kind: "language", + name: "Elixir", + emoji: "💧", + description: + "Functional language for scalable, fault-tolerant systems on the BEAM", + pros: [ + "Amazing concurrency model", + "Fault tolerance built in", + "Great for real-time apps", + "Phoenix framework is excellent", + ], + cons: [ + "Small ecosystem", + "Niche job market", + "Functional paradigm shift needed", + ], + tags: ["functional", "backend", "concurrent", "niche"], + resources: [ + { label: "Elixir School", url: "https://elixirschool.com/" }, + { + label: "Official Guide", + url: "https://elixir-lang.org/getting-started/introduction.html", + }, + ], + }, + { + id: "ruby", + kind: "language", + name: "Ruby", + emoji: "💎", + description: + "Elegant, developer-friendly language — optimized for happiness", + pros: [ + "Beautiful, readable syntax", + "Rails is great for web apps", + "Strong testing culture", + "Quick prototyping", + ], + cons: [ + "Slow performance", + "Declining job market", + "Less relevant outside web", + ], + tags: ["web", "scripting", "beginner", "niche"], + resources: [ + { + label: "Ruby Guide", + url: "https://www.ruby-lang.org/en/documentation/quickstart/", + }, + { label: "Rails Tutorial", url: "https://www.railstutorial.org/" }, + ], + }, + { + id: "swift", + kind: "language", + name: "Swift", + emoji: "🍎", + description: "Apple's modern language for iOS, macOS, and beyond", + pros: [ + "Required for iOS development", + "Safe and modern syntax", + "Strong Apple ecosystem", + "Good performance", + ], + cons: [ + "Mostly Apple-only ecosystem", + "Frequent language changes", + "Limited server-side adoption", + ], + tags: ["mobile", "apple", "modern"], + learningResourceIds: ["swift.yaml"], + resources: [ + { label: "Roadmap", url: "https://roadmap.sh/ios" }, + { + label: "Swift.org", + url: "https://www.swift.org/getting-started/", + }, + ], + }, + { + id: "lua", + kind: "language", + name: "Lua", + emoji: "🌙", + description: + "Tiny, embeddable scripting language — huge in games and modding", + pros: [ + "Extremely simple to learn", + "Tiny footprint", + "Used in Roblox, WoW, Neovim", + "Great for game scripting", + ], + cons: [ + "1-indexed arrays", + "Small standard library", + "Not much use outside embedding", + ], + tags: ["scripting", "games", "embedded", "niche", "beginner"], + resources: [ + { + label: "Programming in Lua", + url: "https://www.lua.org/pil/contents.html", + }, + ], + }, +]; diff --git a/src/modules/recommender/engine/data/questions.ts b/src/modules/recommender/engine/data/questions.ts new file mode 100644 index 0000000..8c09530 --- /dev/null +++ b/src/modules/recommender/engine/data/questions.ts @@ -0,0 +1,418 @@ +import type { Question } from "../types.js"; + +export const questions: Question[] = [ + // ── Phase 0: Fast-track detector ───────────────────────────────────────── + { + id: "specific_goal", + text: "Do you already have something specific in mind?", + type: "single", + phase: 0, + skippable: false, + options: [ + { + id: "minecraft_plugins", + label: "Minecraft plugins", + emoji: "⛏️", + description: "Bukkit, Spigot, Paper plugins", + weights: { java: 30, kotlin: 25 }, + fastTrack: ["java", "kotlin"], + }, + { + id: "ios_apps", + label: "iOS / macOS apps", + emoji: "🍎", + description: "iPhone, iPad, Mac applications", + weights: { swift: 30 }, + fastTrack: ["swift"], + }, + { + id: "discord_bots", + label: "Discord bots", + emoji: "🤖", + description: "Chat bots and server automation", + weights: { + javascript: 18, + typescript: 20, + python: 18, + csharp: 12, + java: 10, + rust: 8, + go: 8, + }, + fastTrack: [ + "typescript", + "javascript", + "python", + "csharp", + "java", + "rust", + "go", + ], + }, + { + id: "roblox", + label: "Roblox games", + emoji: "🟩", + description: "Roblox Studio scripting", + weights: { lua: 30 }, + fastTrack: ["lua"], + }, + { + id: "none", + label: "Nope, help me decide!", + emoji: "🤷", + weights: {}, + }, + ], + }, + + // ── Phase 1: Main goal ────────────────────────────────────────────────── + { + id: "main_goal", + text: "What's your main goal from learning programming?", + type: "single", + phase: 1, + skippable: false, + condition: { questionId: "specific_goal", answerId: "none" }, + options: [ + { + id: "learn_computers", + label: "Learn how computers work", + emoji: "🖥️", + description: "Deep understanding of systems, memory, and hardware", + weights: { + c: 20, + cpp: 15, + rust: 15, + zig: 12, + }, + }, + { + id: "build_easy", + label: "Make something as quickly as possible", + emoji: "🚀", + description: "Get results fast without worrying about internals", + weights: { + python: 20, + javascript: 18, + ruby: 10, + lua: 8, + "web-dev-stack": 10, + }, + }, + { + id: "get_job", + label: "Get a job in tech", + emoji: "💼", + description: "Maximize employability and career prospects", + weights: { + javascript: 15, + typescript: 14, + python: 15, + java: 15, + csharp: 10, + go: 8, + "web-dev-stack": 12, + "devops-stack": 8, + }, + }, + { + id: "hobby", + label: "Just for fun / hobby", + emoji: "🎨", + description: "Explore interesting languages at your own pace", + weights: { + rust: 5, + elixir: 5, + haskell: 5, + zig: 5, + lua: 5, + "game-dev-stack": 8, + }, + }, + ], + }, + + // ── Phase 2: What to build (multi-select) ─────────────────────────────── + { + id: "what_to_build", + text: "What kind of things do you want to build? (pick all that apply)", + type: "multi", + phase: 2, + skippable: true, + condition: { questionId: "specific_goal", answerId: "none" }, + options: [ + { + id: "websites", + label: "Websites & web apps", + emoji: "🌐", + weights: { + javascript: 18, + typescript: 16, + python: 8, + ruby: 10, + "web-dev-stack": 20, + }, + }, + { + id: "games", + label: "Games", + emoji: "🎮", + weights: { + csharp: 18, + cpp: 15, + lua: 12, + rust: 5, + "game-dev-stack": 25, + }, + }, + { + id: "ai_ml", + label: "AI & machine learning", + emoji: "🧠", + weights: { + python: 22, + rust: 5, + "data-science-stack": 25, + }, + }, + { + id: "mobile_apps", + label: "Mobile apps", + emoji: "📱", + weights: { + swift: 15, + kotlin: 15, + javascript: 8, + typescript: 8, + }, + }, + { + id: "cli_tools", + label: "CLI tools & scripts", + emoji: "💻", + weights: { + python: 12, + go: 15, + rust: 14, + c: 6, + zig: 8, + }, + }, + { + id: "servers", + label: "Servers & backends", + emoji: "🖥️", + weights: { + go: 16, + java: 14, + typescript: 12, + python: 10, + elixir: 10, + rust: 8, + "devops-stack": 10, + }, + }, + { + id: "embedded", + label: "Embedded & IoT", + emoji: "🔌", + weights: { + c: 20, + cpp: 15, + rust: 14, + zig: 10, + }, + }, + { + id: "security", + label: "Security & hacking", + emoji: "🔒", + weights: { + python: 12, + c: 10, + "security-stack": 25, + }, + }, + ], + }, + + // ── Phase 3: Performance importance (scale) ───────────────────────────── + { + id: "performance", + text: "How important is raw performance to you?", + type: "scale", + phase: 3, + skippable: true, + scaleRange: [1, 5], + scaleLabels: [ + "Don't care — just get it done", + "Need it fast — every nanosecond counts", + ], + scaleWeights: { + python: [15, -10], + javascript: [12, -8], + ruby: [10, -10], + lua: [8, -5], + c: [-5, 25], + cpp: [-3, 22], + rust: [-2, 22], + zig: [-3, 20], + go: [5, 12], + java: [3, 8], + csharp: [3, 8], + kotlin: [3, 5], + typescript: [10, -5], + haskell: [0, 5], + elixir: [5, 0], + swift: [2, 10], + }, + options: [], + condition: { questionId: "specific_goal", answerId: "none" }, + }, + + // ── Phase 4: Complexity tolerance (scale) ─────────────────────────────── + { + id: "complexity", + text: "How do you feel about language complexity?", + type: "scale", + phase: 4, + skippable: true, + scaleRange: [1, 5], + scaleLabels: [ + "Keep it simple — I want to ship fast", + "Bring it on — I want power and safety", + ], + scaleWeights: { + python: [18, -5], + javascript: [15, -8], + ruby: [14, -8], + lua: [15, -3], + go: [14, 5], + c: [-5, 12], + cpp: [-8, 15], + rust: [-10, 25], + zig: [-5, 15], + haskell: [-10, 22], + elixir: [0, 12], + typescript: [5, 12], + java: [5, 5], + csharp: [5, 5], + kotlin: [8, 5], + swift: [5, 8], + }, + options: [], + condition: { questionId: "specific_goal", answerId: "none" }, + }, + + // ── Phase 5: Job market importance ────────────────────────────────────── + { + id: "job_market", + text: "How much do you care about job market popularity?", + type: "single", + phase: 5, + skippable: true, + condition: { + questionId: "main_goal", + answerId: "get_job", + negate: true, + }, + options: [ + { + id: "very_important", + label: "A lot — I want maximum employability", + emoji: "📈", + weights: { + javascript: 15, + typescript: 14, + python: 15, + java: 14, + csharp: 10, + go: 8, + "web-dev-stack": 10, + "devops-stack": 8, + haskell: -5, + zig: -5, + elixir: -3, + lua: -5, + }, + }, + { + id: "somewhat", + label: "Somewhat — nice to have", + emoji: "📊", + weights: { + javascript: 5, + typescript: 5, + python: 5, + java: 5, + go: 4, + rust: 3, + }, + }, + { + id: "not_really", + label: "Not really — I follow my interests", + emoji: "🎯", + weights: { + rust: 5, + haskell: 5, + elixir: 5, + zig: 5, + lua: 3, + }, + }, + ], + }, + + // ── Phase 6: Community & ecosystem ─────────────────────────────────────── + { + id: "community", + text: "How important is a large community and ecosystem?", + type: "single", + phase: 6, + skippable: true, + condition: { questionId: "specific_goal", answerId: "none" }, + options: [ + { + id: "essential", + label: "Essential — I need lots of libraries and help", + emoji: "👥", + weights: { + python: 12, + javascript: 12, + java: 10, + csharp: 8, + typescript: 10, + go: 6, + haskell: -5, + zig: -8, + elixir: -3, + }, + }, + { + id: "nice_to_have", + label: "Nice to have, but not a dealbreaker", + emoji: "🤝", + weights: { + rust: 5, + kotlin: 5, + swift: 3, + go: 3, + }, + }, + { + id: "dont_care", + label: "Don't care — I enjoy figuring things out", + emoji: "🧭", + weights: { + zig: 8, + haskell: 8, + elixir: 6, + rust: 4, + c: 4, + }, + }, + ], + }, +]; diff --git a/src/modules/recommender/engine/data/stacks.ts b/src/modules/recommender/engine/data/stacks.ts new file mode 100644 index 0000000..6e5e978 --- /dev/null +++ b/src/modules/recommender/engine/data/stacks.ts @@ -0,0 +1,165 @@ +import type { RecommendationTarget } from "../types.js"; + +export const stacks: RecommendationTarget[] = [ + { + id: "web-dev-stack", + kind: "stack", + name: "Web Development", + emoji: "🌍", + description: "Build websites and web applications from frontend to backend", + pros: [ + "Massive job market", + "Immediate visual results", + "Huge community and resources", + "Can work as a freelancer easily", + ], + cons: [ + "Ecosystem changes rapidly", + "Many competing frameworks", + "Full-stack requires broad knowledge", + ], + tags: ["web", "fullstack", "mainstream"], + components: [ + "HTML", + "CSS", + "JavaScript", + "Node.js", + "React/Vue/Svelte", + "Nginx", + ], + resources: [ + { label: "Frontend Roadmap", url: "https://roadmap.sh/frontend" }, + { label: "Backend Roadmap", url: "https://roadmap.sh/backend" }, + { label: "Full Stack Roadmap", url: "https://roadmap.sh/full-stack" }, + ], + }, + { + id: "devops-stack", + kind: "stack", + name: "DevOps & Cloud", + emoji: "☁️", + description: "Automate infrastructure, deployments, and operations", + pros: [ + "Very high demand and salaries", + "Infrastructure as Code", + "Cloud-native skills transfer well", + "Lots of automation", + ], + cons: [ + "Steep initial learning curve", + "Tools change very rapidly", + "Requires broad systems knowledge", + ], + tags: ["infrastructure", "cloud", "automation", "mainstream"], + components: [ + "Linux", + "Docker", + "Kubernetes", + "AWS/GCP/Azure", + "Terraform", + "Git", + "CI/CD", + ], + resources: [ + { label: "DevOps Roadmap", url: "https://roadmap.sh/devops" }, + { label: "Docker Roadmap", url: "https://roadmap.sh/docker" }, + { label: "AWS Roadmap", url: "https://roadmap.sh/aws" }, + ], + }, + { + id: "data-science-stack", + kind: "stack", + name: "Data Science & ML", + emoji: "🧠", + description: "Analyze data, build models, and create AI systems", + pros: [ + "Rapidly growing field", + "High salaries", + "Cutting-edge technology", + "Impactful work", + ], + cons: [ + "Heavy math prerequisites", + "Requires large datasets", + "GPU costs can be high", + ], + tags: ["ml", "data", "ai", "mainstream"], + components: [ + "Python", + "Jupyter", + "pandas", + "scikit-learn", + "PyTorch/TensorFlow", + "SQL", + ], + resources: [ + { + label: "AI & Data Scientist Roadmap", + url: "https://roadmap.sh/ai-data-scientist", + }, + { label: "MLOps Roadmap", url: "https://roadmap.sh/mlops" }, + ], + }, + { + id: "game-dev-stack", + kind: "stack", + name: "Game Development", + emoji: "🎮", + description: "Create games with engines, graphics, and game logic", + pros: [ + "Incredibly rewarding creative work", + "Active indie community", + "Transferable skills (graphics, physics, AI)", + "Multiple engine choices", + ], + cons: [ + "Competitive and low-paying industry", + "Complex multidisciplinary skills needed", + "Crunch culture in AAA studios", + ], + tags: ["games", "creative", "niche"], + components: [ + "Unity (C#)", + "Godot (GDScript)", + "Unreal Engine (C++)", + "Blender", + ], + resources: [ + { label: "Game Dev Roadmap", url: "https://roadmap.sh/game-developer" }, + ], + }, + { + id: "security-stack", + kind: "stack", + name: "Cybersecurity & Ethical Hacking", + emoji: "🔒", + description: + "Protect systems, find vulnerabilities, and perform penetration testing", + pros: [ + "Very high demand", + "Exciting, detective-like work", + "Great salaries", + "Always evolving challenges", + ], + cons: [ + "Requires deep systems knowledge", + "Certifications often expected", + "Constant study to stay current", + ], + tags: ["security", "hacking", "niche"], + components: [ + "Linux", + "Networking", + "Python", + "Wireshark", + "Burp Suite", + "Metasploit", + ], + resources: [ + { + label: "Cyber Security Roadmap", + url: "https://roadmap.sh/cyber-security", + }, + ], + }, +]; diff --git a/src/modules/recommender/engine/engine.test.ts b/src/modules/recommender/engine/engine.test.ts new file mode 100644 index 0000000..b9cbe01 --- /dev/null +++ b/src/modules/recommender/engine/engine.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, test } from "bun:test"; +import { languages } from "./data/languages.js"; +import { questions } from "./data/questions.js"; +import { stacks } from "./data/stacks.js"; +import { + applyAnswer, + computeResults, + createQuizSession, + getApplicableQuestionCount, + getNextQuestion, + isFastTracked, + skipQuestion, +} from "./quiz.js"; +import { + applyDiversityBias, + applyWeights, + evaluateCondition, +} from "./scoring.js"; +import type { QuizAnswer, QuizSession } from "./types.js"; + +describe("engine types and data", () => { + test("all languages have required fields", () => { + for (const lang of languages) { + expect(lang.id).toBeTruthy(); + expect(lang.kind).toBe("language"); + expect(lang.name).toBeTruthy(); + expect(lang.description).toBeTruthy(); + expect(lang.pros.length).toBeGreaterThan(0); + expect(lang.cons.length).toBeGreaterThan(0); + expect(lang.tags.length).toBeGreaterThan(0); + } + }); + + test("all stacks have required fields", () => { + for (const stack of stacks) { + expect(stack.id).toBeTruthy(); + expect(stack.kind).toBe("stack"); + expect(stack.name).toBeTruthy(); + expect(stack.components).toBeTruthy(); + expect(stack.components!.length).toBeGreaterThan(0); + } + }); + + test("all question weight target IDs reference valid targets", () => { + const targetIds = new Set([ + ...languages.map((l) => l.id), + ...stacks.map((s) => s.id), + ]); + + for (const question of questions) { + for (const option of question.options) { + for (const targetId of Object.keys(option.weights)) { + expect(targetIds.has(targetId)).toBe(true); + } + } + if (question.scaleWeights) { + for (const targetId of Object.keys(question.scaleWeights)) { + expect(targetIds.has(targetId)).toBe(true); + } + } + } + }); + + test("language IDs are unique", () => { + const ids = languages.map((l) => l.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("stack IDs are unique", () => { + const ids = stacks.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("quiz state machine", () => { + test("createQuizSession returns a fresh session", () => { + const session = createQuizSession(); + expect(session.answers).toEqual([]); + expect(session.scores).toEqual({}); + expect(session.currentQuestionIndex).toBe(0); + expect(session.fastTracked).toBe(false); + expect(session.completed).toBe(false); + }); + + test("first question is the fast-track detector (phase 0)", () => { + const session = createQuizSession(); + const question = getNextQuestion(session); + expect(question).not.toBeNull(); + expect(question!.id).toBe("specific_goal"); + expect(question!.skippable).toBe(false); + }); + + test("questions are presented in non-decreasing phase order via the engine", () => { + let session = createQuizSession(); + const phases: number[] = []; + + while (true) { + const question = getNextQuestion(session); + if (!question) break; + + phases.push(question.phase); + + if (question.id === "specific_goal") { + session = applyAnswer(session, { + questionId: question.id, + selectedOptionIds: ["none"], + }); + continue; + } + + if (question.type === "scale") { + session = applyAnswer(session, { + questionId: question.id, + selectedOptionIds: ["3"], + }); + continue; + } + + session = applyAnswer(session, { + questionId: question.id, + selectedOptionIds: [question.options[0]!.id], + }); + } + + for (let i = 1; i < phases.length; i++) { + expect(phases[i]).toBeGreaterThanOrEqual(phases[i - 1]!); + } + }); + + test("answering 'none' to fast-track leads to main_goal", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + const next = getNextQuestion(session); + expect(next).not.toBeNull(); + expect(next!.id).toBe("main_goal"); + }); + + test("normal flow completes after all applicable questions", () => { + let session = createQuizSession(); + + // Answer fast-track with "none" + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + + // Answer main goal + session = applyAnswer(session, { + questionId: "main_goal", + selectedOptionIds: ["hobby"], + }); + + // Answer what to build + session = applyAnswer(session, { + questionId: "what_to_build", + selectedOptionIds: ["games"], + }); + + // Answer performance scale + session = applyAnswer(session, { + questionId: "performance", + selectedOptionIds: ["3"], + }); + + // Answer complexity scale + session = applyAnswer(session, { + questionId: "complexity", + selectedOptionIds: ["3"], + }); + + // Answer job market (should appear since goal is "hobby", not "get_job") + session = applyAnswer(session, { + questionId: "job_market", + selectedOptionIds: ["not_really"], + }); + + // Answer community + session = applyAnswer(session, { + questionId: "community", + selectedOptionIds: ["dont_care"], + }); + + expect(session.completed).toBe(true); + expect(getNextQuestion(session)).toBeNull(); + }); + + test("non-skippable questions cannot be skipped", () => { + let session = createQuizSession(); + + session = skipQuestion(session, "specific_goal"); + + expect(session.answers).toEqual([]); + expect(session.completed).toBe(false); + expect(getNextQuestion(session)?.id).toBe("specific_goal"); + }); +}); + +describe("fast-track", () => { + test("selecting minecraft plugins fast-tracks to java/kotlin", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["minecraft_plugins"], + }); + + expect(isFastTracked(session)).toBe(true); + expect(session.completed).toBe(true); + expect(session.fastTrackTargetIds).toContain("java"); + expect(session.fastTrackTargetIds).toContain("kotlin"); + }); + + test("fast-tracked results have high percentages", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["discord_bots"], + }); + + const results = computeResults(session); + expect(results.fastTracked).toBe(true); + expect(results.recommendations.length).toBeGreaterThan(0); + expect(results.recommendations[0].percentage).toBe(100); + }); +}); + +describe("scoring", () => { + test("applyWeights adds option weights for single-choice", () => { + const answer: QuizAnswer = { + questionId: "main_goal", + selectedOptionIds: ["get_job"], + }; + const scores = applyWeights({}, answer); + expect(scores.javascript).toBe(15); + expect(scores.python).toBe(15); + expect(scores.java).toBe(15); + }); + + test("applyWeights adds option weights for multi-choice", () => { + const answer: QuizAnswer = { + questionId: "what_to_build", + selectedOptionIds: ["websites", "ai_ml"], + }; + const scores = applyWeights({}, answer); + // websites: javascript: 18, ai_ml: python: 22 + expect(scores.javascript).toBe(18); + expect(scores.python).toBe(8 + 22); // websites.python + ai_ml.python + }); + + test("applyWeights interpolates scale weights", () => { + // Performance at 1 (don't care) + const answer1: QuizAnswer = { + questionId: "performance", + selectedOptionIds: ["1"], + }; + const scores1 = applyWeights({}, answer1); + expect(scores1.python).toBe(15); // [15, -10] at t=0 → 15 + expect(scores1.c).toBe(-5); // [-5, 25] at t=0 → -5 + + // Performance at 5 (very important) + const answer5: QuizAnswer = { + questionId: "performance", + selectedOptionIds: ["5"], + }; + const scores5 = applyWeights({}, answer5); + expect(scores5.python).toBe(-10); // [15, -10] at t=1 → -10 + expect(scores5.c).toBe(25); // [-5, 25] at t=1 → 25 + }); + + test("applyWeights interpolates scale midpoint correctly", () => { + const answer: QuizAnswer = { + questionId: "performance", + selectedOptionIds: ["3"], + }; + const scores = applyWeights({}, answer); + // python: lerp(15, -10, 0.5) = 2.5 + expect(scores.python).toBeCloseTo(2.5); + // c: lerp(-5, 25, 0.5) = 10 + expect(scores.c).toBeCloseTo(10); + }); + + test("diversity bias penalizes mainstream for hobbyists", () => { + const session: QuizSession = { + answers: [{ questionId: "main_goal", selectedOptionIds: ["hobby"] }], + scores: { python: 100, rust: 100 }, + currentQuestionIndex: 1, + fastTracked: false, + completed: false, + }; + + const biased = applyDiversityBias(session.scores, session); + // Python has "mainstream" tag → 100 * 0.85 = 85 + expect(biased.python).toBeCloseTo(85); + // Rust has "niche" tag → 100 * 1.1 = 110 + expect(biased.rust).toBeCloseTo(110); + }); + + test("diversity bias does NOT apply for non-hobby goals", () => { + const session: QuizSession = { + answers: [{ questionId: "main_goal", selectedOptionIds: ["get_job"] }], + scores: { python: 100, rust: 100 }, + currentQuestionIndex: 1, + fastTracked: false, + completed: false, + }; + + const biased = applyDiversityBias(session.scores, session); + expect(biased.python).toBe(100); + expect(biased.rust).toBe(100); + }); +}); + +describe("condition evaluation", () => { + test("question with no condition always shows", () => { + const result = evaluateCondition({ condition: undefined }, { answers: [] }); + expect(result).toBe(true); + }); + + test("condition matches when answer is present", () => { + const result = evaluateCondition( + { condition: { questionId: "specific_goal", answerId: "none" } }, + { + answers: [{ questionId: "specific_goal", selectedOptionIds: ["none"] }], + }, + ); + expect(result).toBe(true); + }); + + test("condition fails when answer doesn't match", () => { + const result = evaluateCondition( + { condition: { questionId: "specific_goal", answerId: "none" } }, + { + answers: [ + { + questionId: "specific_goal", + selectedOptionIds: ["minecraft_plugins"], + }, + ], + }, + ); + expect(result).toBe(false); + }); + + test("negated condition inverts the result", () => { + const result = evaluateCondition( + { + condition: { + questionId: "main_goal", + answerId: "get_job", + negate: true, + }, + }, + { answers: [{ questionId: "main_goal", selectedOptionIds: ["hobby"] }] }, + ); + expect(result).toBe(true); + }); + + test("condition with array of acceptable answers", () => { + const result = evaluateCondition( + { + condition: { + questionId: "main_goal", + answerId: ["hobby", "build_easy"], + }, + }, + { answers: [{ questionId: "main_goal", selectedOptionIds: ["hobby"] }] }, + ); + expect(result).toBe(true); + }); + + test("job_market question is hidden when goal is get_job", () => { + const jobMarketQ = questions.find((q) => q.id === "job_market"); + expect(jobMarketQ).toBeTruthy(); + + const result = evaluateCondition(jobMarketQ!, { + answers: [ + { questionId: "specific_goal", selectedOptionIds: ["none"] }, + { questionId: "main_goal", selectedOptionIds: ["get_job"] }, + ], + }); + expect(result).toBe(false); + }); +}); + +describe("computeResults", () => { + test("returns ranked results sorted by score", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + session = applyAnswer(session, { + questionId: "main_goal", + selectedOptionIds: ["get_job"], + }); + session = { ...session, completed: true }; + + const results = computeResults(session); + expect(results.recommendations.length).toBeGreaterThan(0); + + // Check sorted descending + for (let i = 1; i < results.recommendations.length; i++) { + expect(results.recommendations[i - 1].score).toBeGreaterThanOrEqual( + results.recommendations[i].score, + ); + } + }); + + test("top result always has 100%", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + session = applyAnswer(session, { + questionId: "main_goal", + selectedOptionIds: ["learn_computers"], + }); + session = { ...session, completed: true }; + + const results = computeResults(session); + expect(results.recommendations[0].percentage).toBe(100); + }); + + test("excludes targets with non-positive scores", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + session = applyAnswer(session, { + questionId: "main_goal", + selectedOptionIds: ["learn_computers"], + }); + session = { ...session, completed: true }; + + const results = computeResults(session); + for (const rec of results.recommendations) { + expect(rec.score).toBeGreaterThan(0); + } + }); + + test("respects topN limit", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + session = applyAnswer(session, { + questionId: "main_goal", + selectedOptionIds: ["get_job"], + }); + session = { ...session, completed: true }; + + const results = computeResults(session, 3); + expect(results.recommendations.length).toBeLessThanOrEqual(3); + }); + + test("hobbyist + games gives diverse results (not just mainstream)", () => { + let session = createQuizSession(); + session = applyAnswer(session, { + questionId: "specific_goal", + selectedOptionIds: ["none"], + }); + session = applyAnswer(session, { + questionId: "main_goal", + selectedOptionIds: ["hobby"], + }); + session = applyAnswer(session, { + questionId: "what_to_build", + selectedOptionIds: ["games"], + }); + session = { ...session, completed: true }; + + const results = computeResults(session, 5); + const names = results.recommendations.map((r) => r.target.id); + + // Game-dev stack should be highly ranked + expect(names).toContain("game-dev-stack"); + // Should have at least one niche language in top 5 + const hasNiche = results.recommendations.some((r) => + r.target.tags.includes("niche"), + ); + expect(hasNiche).toBe(true); + }); +}); diff --git a/src/modules/recommender/engine/index.ts b/src/modules/recommender/engine/index.ts new file mode 100644 index 0000000..6eda9c6 --- /dev/null +++ b/src/modules/recommender/engine/index.ts @@ -0,0 +1,10 @@ +export { languages } from "./data/languages.js"; +export { questions } from "./data/questions.js"; +export { stacks } from "./data/stacks.js"; +export * from "./quiz.js"; +export { + applyDiversityBias, + applyWeights, + evaluateCondition, +} from "./scoring.js"; +export * from "./types.js"; diff --git a/src/modules/recommender/engine/quiz.ts b/src/modules/recommender/engine/quiz.ts new file mode 100644 index 0000000..4daeb1c --- /dev/null +++ b/src/modules/recommender/engine/quiz.ts @@ -0,0 +1,141 @@ +import { questions } from "./data/questions.js"; +import { + applyWeights, + computeResults as computeResultsFromScoring, + evaluateCondition, +} from "./scoring.js"; +import type { Question, QuizAnswer, QuizResult, QuizSession } from "./types.js"; + +/** Create a fresh quiz session with all scores at zero. */ +export function createQuizSession(): QuizSession { + return { + answers: [], + scores: {}, + currentQuestionIndex: 0, + fastTracked: false, + completed: false, + }; +} + +/** Get the sorted list of questions applicable to this session. */ +function getSortedQuestions(): Question[] { + return [...questions].sort((a, b) => a.phase - b.phase); +} + +/** + * Get the next question to ask, or null if the quiz is complete. + * Evaluates conditions against prior answers to skip irrelevant questions. + */ +export function getNextQuestion(session: QuizSession): Question | null { + if (session.completed || session.fastTracked) return null; + + const sorted = getSortedQuestions(); + const answeredIds = new Set(session.answers.map((a) => a.questionId)); + + for (const question of sorted) { + if (answeredIds.has(question.id)) continue; + if (evaluateCondition(question, session)) { + return question; + } + } + + return null; +} + +/** + * Apply an answer to the session. Returns a new session (immutable). + * If the answer triggers a fast-track, marks the session as completed. + */ +export function applyAnswer( + session: QuizSession, + answer: QuizAnswer, +): QuizSession { + const newScores = applyWeights(session.scores, answer); + const newAnswers = [...session.answers, answer]; + + // Check for fast-track + const sorted = getSortedQuestions(); + const question = sorted.find((q) => q.id === answer.questionId); + if (question) { + for (const optionId of answer.selectedOptionIds) { + const option = question.options.find((o) => o.id === optionId); + if (option?.fastTrack && option.fastTrack.length > 0) { + return { + answers: newAnswers, + scores: newScores, + currentQuestionIndex: session.currentQuestionIndex + 1, + fastTracked: true, + fastTrackTargetIds: option.fastTrack, + completed: true, + }; + } + } + } + + const newSession: QuizSession = { + answers: newAnswers, + scores: newScores, + currentQuestionIndex: session.currentQuestionIndex + 1, + fastTracked: false, + completed: false, + }; + + // Check if there are more questions + if (getNextQuestion(newSession) === null) { + return { ...newSession, completed: true }; + } + + return newSession; +} + +/** + * Skip the current question. Returns a new session (no score changes). + */ +export function skipQuestion( + session: QuizSession, + questionId: string, +): QuizSession { + const question = getSortedQuestions().find( + (candidate) => candidate.id === questionId, + ); + if (!question || !question.skippable) { + return session; + } + + // Record the skip as an answer with no selections (for condition tracking) + const skipAnswer: QuizAnswer = { + questionId, + selectedOptionIds: [], + }; + + const newSession: QuizSession = { + ...session, + answers: [...session.answers, skipAnswer], + currentQuestionIndex: session.currentQuestionIndex + 1, + }; + + if (getNextQuestion(newSession) === null) { + return { ...newSession, completed: true }; + } + + return newSession; +} + +/** Check if the session was fast-tracked by the latest answer. */ +export function isFastTracked(session: QuizSession): boolean { + return session.fastTracked; +} + +/** Compute final results. Delegates to scoring module. */ +export function computeResults( + session: QuizSession, + topN?: number, +): QuizResult { + return computeResultsFromScoring(session, topN); +} + +/** Get the total number of questions applicable to this session. */ +export function getApplicableQuestionCount(session: QuizSession): number { + const sorted = getSortedQuestions(); + return sorted.filter((q) => evaluateCondition(q, session)).length; +} diff --git a/src/modules/recommender/engine/scoring.ts b/src/modules/recommender/engine/scoring.ts new file mode 100644 index 0000000..b3ceed8 --- /dev/null +++ b/src/modules/recommender/engine/scoring.ts @@ -0,0 +1,195 @@ +import { languages } from "./data/languages.js"; +import { questions } from "./data/questions.js"; +import { stacks } from "./data/stacks.js"; +import type { + QuizAnswer, + QuizResult, + QuizSession, + RecommendationResult, + RecommendationTarget, +} from "./types.js"; + +const allTargets: RecommendationTarget[] = [...languages, ...stacks]; + +function getTargetById(id: string): RecommendationTarget | undefined { + return allTargets.find((t) => t.id === id); +} + +function getQuestionById(id: string) { + return questions.find((q) => q.id === id); +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Apply a single answer's weights to the score map. Returns a new scores object. */ +export function applyWeights( + scores: Record, + answer: QuizAnswer, +): Record { + const newScores = { ...scores }; + const question = getQuestionById(answer.questionId); + if (!question) return newScores; + + if ( + question.type === "scale" && + question.scaleWeights && + question.scaleRange + ) { + const value = Number.parseInt(answer.selectedOptionIds[0], 10); + const [min, max] = question.scaleRange; + const t = (value - min) / (max - min); + + for (const [targetId, [weightAtMin, weightAtMax]] of Object.entries( + question.scaleWeights, + )) { + newScores[targetId] = + (newScores[targetId] ?? 0) + lerp(weightAtMin, weightAtMax, t); + } + } else { + for (const optionId of answer.selectedOptionIds) { + const option = question.options.find((o) => o.id === optionId); + if (!option) continue; + + for (const [targetId, weight] of Object.entries(option.weights)) { + newScores[targetId] = (newScores[targetId] ?? 0) + weight; + } + } + } + + return newScores; +} + +/** Apply diversity bias for hobbyist users: penalize mainstream, boost niche. */ +export function applyDiversityBias( + scores: Record, + session: QuizSession, +): Record { + const goalAnswer = session.answers.find((a) => a.questionId === "main_goal"); + if (!goalAnswer || !goalAnswer.selectedOptionIds.includes("hobby")) { + return scores; + } + + const newScores = { ...scores }; + for (const target of allTargets) { + if (newScores[target.id] === undefined) continue; + + if (target.tags.includes("mainstream")) { + newScores[target.id] *= 0.85; + } + if (target.tags.includes("niche")) { + newScores[target.id] *= 1.1; + } + } + return newScores; +} + +/** Compute final results from a completed session. */ +export function computeResults(session: QuizSession, topN = 5): QuizResult { + const totalQuestions = getApplicableQuestionCount(session); + const answeredQuestions = session.answers.length; + + // Fast-track: return those targets at 100% + if (session.fastTracked && session.fastTrackTargetIds) { + const recommendations: RecommendationResult[] = session.fastTrackTargetIds + .map((id, index) => { + const target = getTargetById(id); + if (!target) return null; + return { + target, + score: 100 - index * 10, + percentage: 100 - index * 10, + rank: index + 1, + }; + }) + .filter((r): r is RecommendationResult => r !== null); + + return { + recommendations, + fastTracked: true, + answeredQuestions, + totalQuestions, + }; + } + + // Apply diversity bias + const scores = applyDiversityBias({ ...session.scores }, session); + + // Filter out non-positive scores, sort descending + const scored = Object.entries(scores) + .filter(([, score]) => score > 0) + .sort(([, a], [, b]) => b - a); + + if (scored.length === 0) { + return { + recommendations: [], + fastTracked: false, + answeredQuestions, + totalQuestions, + }; + } + + const maxScore = scored[0][1]; + + const recommendations: RecommendationResult[] = scored + .slice(0, topN) + .map(([targetId, score], index) => { + const target = getTargetById(targetId); + if (!target) return null; + return { + target, + score, + percentage: Math.round((score / maxScore) * 100), + rank: index + 1, + }; + }) + .filter((r): r is RecommendationResult => r !== null); + + return { + recommendations, + fastTracked: false, + answeredQuestions, + totalQuestions, + }; +} + +/** Count how many questions are applicable (conditions met) for this session. */ +function getApplicableQuestionCount(session: QuizSession): number { + let count = 0; + for (const question of questions) { + if (evaluateCondition(question, session)) { + count++; + } + } + return count; +} + +/** Check if a question's condition is met given the current answers. */ +export function evaluateCondition( + question: { + condition?: { + questionId: string; + answerId: string | string[]; + negate?: boolean; + }; + }, + session: Pick, +): boolean { + if (!question.condition) return true; + + const { questionId, answerId, negate } = question.condition; + const priorAnswer = session.answers.find((a) => a.questionId === questionId); + + const acceptedIds = Array.isArray(answerId) ? answerId : [answerId]; + + // If the dependent question was never encountered at all, condition is not met + if (!priorAnswer) { + return false; + } + + const matched = priorAnswer.selectedOptionIds.some((id) => + acceptedIds.includes(id), + ); + return negate ? !matched : matched; +} diff --git a/src/modules/recommender/engine/types.ts b/src/modules/recommender/engine/types.ts new file mode 100644 index 0000000..3edd7c8 --- /dev/null +++ b/src/modules/recommender/engine/types.ts @@ -0,0 +1,96 @@ +// ─── Recommendation Targets ────────────────────────────────────────────────── + +export type TargetKind = "language" | "stack"; + +export interface ResourceLink { + label: string; + url: string; +} + +export interface RecommendationTarget { + id: string; + kind: TargetKind; + name: string; + emoji?: string; + description: string; + pros: string[]; + cons: string[]; + tags: string[]; + resources?: ResourceLink[]; + learningResourceIds?: string[]; + /** For stacks: the component technologies */ + components?: string[]; +} + +// ─── Questions ─────────────────────────────────────────────────────────────── + +export type QuestionType = "single" | "multi" | "scale"; + +export interface QuestionOption { + id: string; + label: string; + emoji?: string; + description?: string; + /** target.id → score delta */ + weights: Record; + /** If set, immediately ends the quiz and recommends these target IDs */ + fastTrack?: string[]; +} + +export interface QuestionCondition { + questionId: string; + /** Show only if the user picked one of these answer IDs */ + answerId: string | string[]; + negate?: boolean; +} + +export interface Question { + id: string; + text: string; + type: QuestionType; + options: QuestionOption[]; + /** For 'scale' type: min and max of the scale, e.g. [1, 5] */ + scaleRange?: [number, number]; + /** Labels for the scale endpoints, e.g. ["Easy & quick", "Powerful & safe"] */ + scaleLabels?: [string, string]; + /** For 'scale' type: target.id → [weight@min, weight@max] (linearly interpolated) */ + scaleWeights?: Record; + skippable: boolean; + condition?: QuestionCondition; + /** Ordering group — lower phases are asked first */ + phase: number; +} + +// ─── Quiz Session State ────────────────────────────────────────────────────── + +export interface QuizAnswer { + questionId: string; + /** For single: 1 element. For multi: N elements. For scale: ["3"] */ + selectedOptionIds: string[]; +} + +export interface QuizSession { + answers: QuizAnswer[]; + /** target.id → accumulated score */ + scores: Record; + currentQuestionIndex: number; + fastTracked: boolean; + fastTrackTargetIds?: string[]; + completed: boolean; +} + +// ─── Results ───────────────────────────────────────────────────────────────── + +export interface RecommendationResult { + target: RecommendationTarget; + score: number; + percentage: number; + rank: number; +} + +export interface QuizResult { + recommendations: RecommendationResult[]; + fastTracked: boolean; + answeredQuestions: number; + totalQuestions: number; +} diff --git a/src/modules/recommender/recommender.command.ts b/src/modules/recommender/recommender.command.ts new file mode 100644 index 0000000..4fba2d2 --- /dev/null +++ b/src/modules/recommender/recommender.command.ts @@ -0,0 +1,30 @@ +import { ApplicationCommandType, MessageFlags } from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { createQuizSession, getNextQuestion } from "./engine/quiz.js"; +import { createSession } from "./recommender.listener.js"; +import { renderQuestionMessage } from "./recommender.render.js"; + +export const WhatLangCommand: Command = { + name: "whatlang", + description: "Find out what programming language or stack you should learn", + type: ApplicationCommandType.ChatInput, + options: [], + async handle(interaction) { + const session = createQuizSession(); + const question = getNextQuestion(session); + if (!question) return; + + const sessionKey = createSession( + interaction.user.id, + interaction.id, + session, + ); + + const message = renderQuestionMessage(question, session, sessionKey); + await interaction.reply({ + ...message, + flags: MessageFlags.Ephemeral, + withResponse: false, + }); + }, +}; diff --git a/src/modules/recommender/recommender.listener.ts b/src/modules/recommender/recommender.listener.ts new file mode 100644 index 0000000..2df815e --- /dev/null +++ b/src/modules/recommender/recommender.listener.ts @@ -0,0 +1,369 @@ +import { type GuildMember, type Interaction, MessageFlags } from "discord.js"; +import { getResource } from "../learning/resourcesCache.util.js"; +import type { EventListener } from "../module.js"; +import { + applyAnswer, + computeResults, + createQuizSession, + getNextQuestion, + skipQuestion, +} from "./engine/quiz.js"; +import type { QuizResult, QuizSession } from "./engine/types.js"; +import { + makeId, + renderLearningResourcesSelect, + renderQuestionMessage, + renderResultsMessage, + renderShareEmbed, +} from "./recommender.render.js"; + +// ─── Session storage ───────────────────────────────────────────────────────── + +interface StoredSession { + session: QuizSession; + result?: QuizResult; + userId: string; + createdAt: number; +} + +const sessions = new Map(); + +const SESSION_TTL_MS = 15 * 60 * 1000; // 15 minutes +const CLEANUP_INTERVAL_MS = 60 * 1000; // 1 minute + +/** Generate an 8-char session key from user and interaction IDs. */ +function generateSessionKey(userId: string, interactionId: string): string { + // Simple hash: take parts of both IDs + const combined = `${userId}:${interactionId}`; + let hash = 0; + for (let i = 0; i < combined.length; i++) { + const char = combined.charCodeAt(i); + hash = ((hash << 5) - hash + char) | 0; + } + return Math.abs(hash).toString(36).padStart(8, "0").slice(0, 8); +} + +/** Create a session and return the session key. */ +export function createSession( + userId: string, + interactionId: string, + session: QuizSession, +): string { + // Remove any existing session for this user + for (const [key, stored] of sessions) { + if (stored.userId === userId) { + sessions.delete(key); + } + } + + const sessionKey = generateSessionKey(userId, interactionId); + sessions.set(sessionKey, { + session, + userId, + createdAt: Date.now(), + }); + return sessionKey; +} + +function cleanupExpiredSessions() { + const now = Date.now(); + for (const [key, stored] of sessions) { + if (now - stored.createdAt > SESSION_TTL_MS) { + sessions.delete(key); + } + } +} + +/** Start the cleanup interval. Call from module onInit. */ +export function startSessionCleanup(): NodeJS.Timeout { + return setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS); +} + +// ─── Custom ID parsing ────────────────────────────────────────────────────── + +interface ParsedId { + sessionKey: string; + action: string; + payload?: string; +} + +function parseCustomId(customId: string): ParsedId | null { + if (!customId.startsWith("wl:")) return null; + const parts = customId.split(":"); + if (parts.length < 3) return null; + return { + sessionKey: parts[1], + action: parts[2], + payload: parts[3], + }; +} + +// ─── Event listener ────────────────────────────────────────────────────────── + +export const RecommenderListener: EventListener = { + async interactionCreate(_client, interaction: Interaction) { + if (!interaction.isButton() && !interaction.isStringSelectMenu()) return; + + const parsed = parseCustomId(interaction.customId); + if (!parsed) return; + + const stored = sessions.get(parsed.sessionKey); + if (!stored) { + await interaction.reply({ + content: + "This quiz session has expired. Use `/whatlang` to start a new one!", + flags: MessageFlags.Ephemeral, + withResponse: false, + }); + return; + } + + // Verify the user owns this session + if (stored.userId !== interaction.user.id) return; + + switch (parsed.action) { + case "answer": + await handleAnswer(interaction, stored, parsed); + break; + case "scale": + await handleScale(interaction, stored, parsed); + break; + case "select": + await handleSelect(interaction, stored, parsed); + break; + case "skip": + await handleSkip(interaction, stored, parsed); + break; + case "restart": + await handleRestart(interaction, stored, parsed); + break; + case "resources": + await handleResources(interaction, stored, parsed); + break; + case "learn-pick": + await handleLearnPick(interaction, stored); + break; + case "share": + await handleShare(interaction, stored); + break; + } + }, +}; + +// ─── Handlers ──────────────────────────────────────────────────────────────── + +async function handleAnswer( + interaction: Interaction, + stored: StoredSession, + parsed: ParsedId, +) { + if (!interaction.isButton()) return; + if (!parsed.payload) return; + + const currentQuestion = getNextQuestion(stored.session); + if (!currentQuestion) return; + + const newSession = applyAnswer(stored.session, { + questionId: currentQuestion.id, + selectedOptionIds: [parsed.payload], + }); + + stored.session = newSession; + await advanceOrFinish(interaction, stored, parsed.sessionKey); +} + +async function handleScale( + interaction: Interaction, + stored: StoredSession, + parsed: ParsedId, +) { + if (!interaction.isButton()) return; + if (!parsed.payload) return; + + const currentQuestion = getNextQuestion(stored.session); + if (!currentQuestion) return; + + const newSession = applyAnswer(stored.session, { + questionId: currentQuestion.id, + selectedOptionIds: [parsed.payload], + }); + + stored.session = newSession; + await advanceOrFinish(interaction, stored, parsed.sessionKey); +} + +async function handleSelect( + interaction: Interaction, + stored: StoredSession, + parsed: ParsedId, +) { + if (!interaction.isStringSelectMenu()) return; + + const currentQuestion = getNextQuestion(stored.session); + if (!currentQuestion) return; + + const newSession = applyAnswer(stored.session, { + questionId: currentQuestion.id, + selectedOptionIds: interaction.values, + }); + + stored.session = newSession; + await advanceOrFinish(interaction, stored, parsed.sessionKey); +} + +async function handleSkip( + interaction: Interaction, + stored: StoredSession, + parsed: ParsedId, +) { + if (!interaction.isButton()) return; + if (!parsed.payload) return; + + const newSession = skipQuestion(stored.session, parsed.payload); + stored.session = newSession; + await advanceOrFinish(interaction, stored, parsed.sessionKey); +} + +async function handleRestart( + interaction: Interaction, + stored: StoredSession, + parsed: ParsedId, +) { + if (!interaction.isButton()) return; + + const newSession = createQuizSession(); + const question = getNextQuestion(newSession); + if (!question) return; + + stored.session = newSession; + stored.result = undefined; + stored.createdAt = Date.now(); + + const message = renderQuestionMessage( + question, + newSession, + parsed.sessionKey, + ); + await interaction.update(message); +} + +async function handleResources( + interaction: Interaction, + stored: StoredSession, + parsed: ParsedId, +) { + if (!interaction.isButton()) return; + + const result = stored.result ?? computeResults(stored.session); + stored.result = result; + + const hasResources = result.recommendations.some( + (r) => + r.target.learningResourceIds && r.target.learningResourceIds.length > 0, + ); + + if (!hasResources) { + await interaction.reply({ + content: "No learning resources available for these recommendations yet.", + flags: MessageFlags.Ephemeral, + withResponse: false, + }); + return; + } + + const selectRow = renderLearningResourcesSelect(result, parsed.sessionKey); + await interaction.reply({ + content: "Select a language to view its learning resources:", + components: [selectRow], + flags: MessageFlags.Ephemeral, + withResponse: false, + }); +} + +async function handleLearnPick( + interaction: Interaction, + stored: StoredSession, +) { + if (!interaction.isStringSelectMenu()) return; + + const resourceId = interaction.values[0]; + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const resource = await getResource(resourceId); + if (!resource) { + await interaction.followUp({ + content: `Could not find learning resource "${resourceId}". It may not be available yet.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const { getResourceEmbed } = await import("../learning/learning.command.js"); + const embed = getResourceEmbed( + interaction.client, + resource, + interaction.user, + (interaction.member as GuildMember) ?? undefined, + ); + + await interaction.followUp({ + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); +} + +async function handleShare(interaction: Interaction, stored: StoredSession) { + if (!interaction.isButton()) return; + + const result = stored.result ?? computeResults(stored.session); + stored.result = result; + + if (result.recommendations.length === 0) { + await interaction.reply({ + content: "No results to share.", + flags: MessageFlags.Ephemeral, + withResponse: false, + }); + return; + } + + const embed = renderShareEmbed(result, interaction.user); + await interaction.reply({ + embeds: [embed], + withResponse: false, + }); +} + +// ─── Flow control ──────────────────────────────────────────────────────────── + +async function advanceOrFinish( + interaction: Interaction, + stored: StoredSession, + sessionKey: string, +) { + if (!("update" in interaction) || typeof interaction.update !== "function") + return; + + if (stored.session.completed) { + const result = computeResults(stored.session); + stored.result = result; + + const message = renderResultsMessage( + result, + sessionKey, + interaction.user, + (interaction.member as GuildMember) ?? undefined, + ); + await interaction.update(message); + } else { + const nextQuestion = getNextQuestion(stored.session); + if (!nextQuestion) return; + + const message = renderQuestionMessage( + nextQuestion, + stored.session, + sessionKey, + ); + await interaction.update(message); + } +} diff --git a/src/modules/recommender/recommender.module.ts b/src/modules/recommender/recommender.module.ts new file mode 100644 index 0000000..9370a84 --- /dev/null +++ b/src/modules/recommender/recommender.module.ts @@ -0,0 +1,15 @@ +import type Module from "../module.js"; +import { WhatLangCommand } from "./recommender.command.js"; +import { + RecommenderListener, + startSessionCleanup, +} from "./recommender.listener.js"; + +export const RecommenderModule: Module = { + name: "recommender", + commands: [WhatLangCommand], + listeners: [RecommenderListener], + async onInit() { + startSessionCleanup(); + }, +}; diff --git a/src/modules/recommender/recommender.render.ts b/src/modules/recommender/recommender.render.ts new file mode 100644 index 0000000..21999a6 --- /dev/null +++ b/src/modules/recommender/recommender.render.ts @@ -0,0 +1,381 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type EmbedBuilder, + type GuildMember, + type InteractionReplyOptions, + type InteractionUpdateOptions, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + type User, +} from "discord.js"; +import { createStandardEmbed } from "../../util/embeds.js"; +import { getApplicableQuestionCount } from "./engine/quiz.js"; +import type { + Question, + QuizResult, + QuizSession, + RecommendationResult, +} from "./engine/types.js"; + +// ─── Custom ID helpers ─────────────────────────────────────────────────────── + +export function makeId( + sessionKey: string, + action: string, + payload?: string, +): string { + return payload + ? `wl:${sessionKey}:${action}:${payload}` + : `wl:${sessionKey}:${action}`; +} + +// ─── Question rendering ────────────────────────────────────────────────────── + +export function renderQuestionMessage( + question: Question, + session: QuizSession, + sessionKey: string, +): InteractionReplyOptions & InteractionUpdateOptions { + const totalQuestions = getApplicableQuestionCount(session); + const currentIndex = session.answers.length + 1; + + const embed = createStandardEmbed() + .setTitle("What Language Should You Learn?") + .setDescription(buildQuestionDescription(question)) + .setFooter({ text: `Question ${currentIndex} of ${totalQuestions}` }); + + const components = buildQuestionComponents(question, sessionKey); + + // Add utility row (skip + restart) + const utilityRow = new ActionRowBuilder(); + if (question.skippable) { + utilityRow.addComponents( + new ButtonBuilder() + .setCustomId(makeId(sessionKey, "skip", question.id)) + .setLabel("Skip") + .setStyle(ButtonStyle.Secondary), + ); + } + utilityRow.addComponents( + new ButtonBuilder() + .setCustomId(makeId(sessionKey, "restart")) + .setLabel("Start Over") + .setStyle(ButtonStyle.Danger), + ); + components.push(utilityRow); + + return { embeds: [embed], components }; +} + +function buildQuestionDescription(question: Question): string { + let desc = `**${question.text}**`; + + if (question.type === "scale" && question.scaleLabels) { + desc += `\n\n1️⃣ ${question.scaleLabels[0]}\n5️⃣ ${question.scaleLabels[1]}`; + } + + if (question.type === "multi") { + desc += "\n\n*You can select multiple options.*"; + } + + return desc; +} + +function buildQuestionComponents( + question: Question, + sessionKey: string, +): ActionRowBuilder[] { + if (question.type === "scale") { + return buildScaleButtons(question, sessionKey); + } + + if (question.type === "multi" || question.options.length > 5) { + return buildSelectMenu(question, sessionKey); + } + + return buildOptionButtons(question, sessionKey); +} + +function buildOptionButtons( + question: Question, + sessionKey: string, +): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + let currentRow = new ActionRowBuilder(); + + for (const option of question.options) { + if (currentRow.components.length >= 5) { + rows.push(currentRow); + currentRow = new ActionRowBuilder(); + } + + const btn = new ButtonBuilder() + .setCustomId(makeId(sessionKey, "answer", option.id)) + .setLabel(option.label) + .setStyle(ButtonStyle.Primary); + + if (option.emoji) { + btn.setEmoji(option.emoji); + } + + currentRow.addComponents(btn); + } + + if (currentRow.components.length > 0) { + rows.push(currentRow); + } + + return rows; +} + +function buildScaleButtons( + _question: Question, + sessionKey: string, +): ActionRowBuilder[] { + const scaleEmojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; + const row = new ActionRowBuilder(); + + for (let i = 1; i <= 5; i++) { + row.addComponents( + new ButtonBuilder() + .setCustomId(makeId(sessionKey, "scale", String(i))) + .setLabel(String(i)) + .setEmoji(scaleEmojis[i - 1]) + .setStyle(ButtonStyle.Primary), + ); + } + + return [row]; +} + +function buildSelectMenu( + question: Question, + sessionKey: string, +): ActionRowBuilder[] { + const menu = new StringSelectMenuBuilder() + .setCustomId(makeId(sessionKey, "select")) + .setPlaceholder("Choose an option..."); + + if (question.type === "multi") { + menu.setMinValues(1).setMaxValues(question.options.length); + } + + menu.setOptions( + question.options.map((opt) => { + const builder = new StringSelectMenuOptionBuilder() + .setLabel(opt.label) + .setValue(opt.id); + + if (opt.description) { + builder.setDescription(opt.description); + } + if (opt.emoji) { + builder.setEmoji(opt.emoji); + } + + return builder; + }), + ); + + return [new ActionRowBuilder().addComponents(menu)]; +} + +// ─── Results rendering ─────────────────────────────────────────────────────── + +const rankEmojis = ["🥇", "🥈", "🥉"]; + +export function renderResultsMessage( + result: QuizResult, + sessionKey: string, + user?: User, + member?: GuildMember, +): InteractionReplyOptions & InteractionUpdateOptions { + const embed = createStandardEmbed(member ?? user).setTitle( + "Your Top Matches", + ); + + // Build ranked list + const description = result.recommendations + .map((rec) => { + const rankPrefix = + rec.rank <= 3 ? rankEmojis[rec.rank - 1] : `**${rec.rank}.**`; + const emoji = rec.target.emoji ? `${rec.target.emoji} ` : ""; + return `${rankPrefix} ${emoji}**${rec.target.name}** — ${rec.percentage}%`; + }) + .join("\n"); + + embed.setDescription( + description || "No recommendations found. Try answering more questions!", + ); + + // Add pros/cons fields for each result + for (const rec of result.recommendations.slice(0, 5)) { + const pros = rec.target.pros + .slice(0, 3) + .map((p) => `✅ ${p}`) + .join("\n"); + const cons = rec.target.cons + .slice(0, 2) + .map((c) => `⚠️ ${c}`) + .join("\n"); + + let value = ""; + if (rec.target.kind === "stack" && rec.target.components) { + value += `📦 *${rec.target.components.join(", ")}*\n`; + } + value += `${pros}\n${cons}`; + + embed.addFields({ + name: `${rec.target.emoji ?? ""} ${rec.target.name} (${rec.percentage}%)`, + value, + inline: false, + }); + } + + if (result.fastTracked) { + embed.setFooter({ text: "⚡ Fast-tracked recommendation" }); + } else { + embed.setFooter({ + text: `Based on ${result.answeredQuestions} of ${result.totalQuestions} questions`, + }); + } + + const components = buildResultComponents(result, sessionKey); + + return { embeds: [embed], components }; +} + +function buildResultComponents( + result: QuizResult, + sessionKey: string, +): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + + // Row 1: Resource link buttons (max 5 per row) + const linkButtons = buildResourceLinkButtons(result); + if (linkButtons.length > 0) { + // Chunk into rows of 5 + for (let i = 0; i < linkButtons.length; i += 5) { + const row = new ActionRowBuilder(); + row.addComponents(linkButtons.slice(i, i + 5)); + rows.push(row); + } + } + + // Check if any results have learning resource IDs + const hasLearningResources = result.recommendations.some( + (rec) => + rec.target.learningResourceIds && + rec.target.learningResourceIds.length > 0, + ); + + // Action buttons row + const actionRow = new ActionRowBuilder(); + + if (hasLearningResources) { + actionRow.addComponents( + new ButtonBuilder() + .setCustomId(makeId(sessionKey, "resources")) + .setLabel("Learning Resources") + .setEmoji("📚") + .setStyle(ButtonStyle.Success), + ); + } + + actionRow.addComponents( + new ButtonBuilder() + .setCustomId(makeId(sessionKey, "share")) + .setLabel("Share Results") + .setEmoji("📢") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(makeId(sessionKey, "restart")) + .setLabel("Start Over") + .setEmoji("🔄") + .setStyle(ButtonStyle.Danger), + ); + + rows.push(actionRow); + + // Discord limits to 5 action rows total + return rows.slice(0, 5); +} + +function buildResourceLinkButtons(result: QuizResult): ButtonBuilder[] { + const buttons: ButtonBuilder[] = []; + + for (const rec of result.recommendations.slice(0, 5)) { + if (!rec.target.resources) continue; + + for (const resource of rec.target.resources.slice(0, 2)) { + // Discord limits link buttons; keep it concise + buttons.push( + new ButtonBuilder() + .setLabel(`${rec.target.name}: ${resource.label}`) + .setURL(resource.url) + .setStyle(ButtonStyle.Link), + ); + } + } + + // Discord allows max 25 components across 5 rows, but link buttons are generous + return buttons.slice(0, 10); +} + +// ─── Share embed (non-ephemeral, condensed) ────────────────────────────────── + +export function renderShareEmbed(result: QuizResult, user: User): EmbedBuilder { + const embed = createStandardEmbed() + .setTitle(`${user.displayName}'s Language Recommendations`) + .setThumbnail(user.displayAvatarURL()); + + const description = result.recommendations + .map((rec) => { + const rankPrefix = + rec.rank <= 3 ? rankEmojis[rec.rank - 1] : `**${rec.rank}.**`; + const emoji = rec.target.emoji ? `${rec.target.emoji} ` : ""; + return `${rankPrefix} ${emoji}**${rec.target.name}** — ${rec.percentage}%\n> *${rec.target.description}*`; + }) + .join("\n\n"); + + embed.setDescription(description || "No recommendations."); + embed.setFooter({ text: "Try /whatlang to get your own recommendations!" }); + + return embed; +} + +// ─── Learning resources select menu ────────────────────────────────────────── + +export function renderLearningResourcesSelect( + result: QuizResult, + sessionKey: string, +): ActionRowBuilder { + const menu = new StringSelectMenuBuilder() + .setCustomId(makeId(sessionKey, "learn-pick")) + .setPlaceholder("Pick a language to see learning resources..."); + + const options: StringSelectMenuOptionBuilder[] = []; + + for (const rec of result.recommendations) { + if ( + !rec.target.learningResourceIds || + rec.target.learningResourceIds.length === 0 + ) + continue; + + for (const resourceId of rec.target.learningResourceIds) { + options.push( + new StringSelectMenuOptionBuilder() + .setLabel(`${rec.target.name} Resources`) + .setValue(resourceId) + .setDescription(`Learning resources for ${rec.target.name}`), + ); + } + } + + menu.setOptions(options.slice(0, 25)); // Discord max 25 options + + return new ActionRowBuilder().addComponents(menu); +}