Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
386 changes: 380 additions & 6 deletions HandBook/2025/data_quality_report.md

Large diffs are not rendered by default.

66,087 changes: 66,087 additions & 0 deletions HandBook/2025/zscore_cutoffs_2025.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 14 additions & 4 deletions backend/src/db/normalize-handbook-2025.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import {
const __dirname = dirname(fileURLToPath(import.meta.url));
const WORKSPACE_ROOT = join(__dirname, "../../..");
const HANDBOOK_2025_DIR = join(WORKSPACE_ROOT, "HandBook", "2025");
const STAGING_FILE = join(WORKSPACE_ROOT, "backend", "data", "handbooks", "staging", "2025_26_en.json");
const STAGING_DIR = join(WORKSPACE_ROOT, "backend", "data", "handbooks", "staging");
const STAGING_FILES = [
join(STAGING_DIR, "2025_26_en.json"), // Section 9 cutoffs for 2024/25
join(STAGING_DIR, "2024_25_en.json"), // Section 9 cutoffs for 2023/24
join(STAGING_DIR, "2023_24_en.json"), // Section 9 cutoffs for 2022/23
];

function readJson<T>(path: string): T {
return JSON.parse(readFileSync(path, "utf-8")) as T;
Expand All @@ -31,9 +36,14 @@ function main() {
const rawCoursesFile = readJson<{ courses: RawCourseRecord[] }>(join(HANDBOOK_2025_DIR, "courses.json"));
const rawUniversitiesFile = readJson<unknown>(join(HANDBOOK_2025_DIR, "universities.json"));
const rawEligibilityRules = readJson<RawEligibilityRule[]>(join(HANDBOOK_2025_DIR, "eligibility_rules.json"));
const stagedRows = existsSync(STAGING_FILE)
? readJson<{ programmes: RawStagedProgrammeRow[] }>(STAGING_FILE).programmes
: [];

const stagedRows: RawStagedProgrammeRow[] = [];
for (const stagingPath of STAGING_FILES) {
if (existsSync(stagingPath)) {
const file = readJson<{ programmes: RawStagedProgrammeRow[] }>(stagingPath);
stagedRows.push(...file.programmes);
}
}

const rawCourses = rawCoursesFile.courses;
const courses = rawCourses.map((course) => normalizeCourseRecord(course));
Expand Down
141 changes: 113 additions & 28 deletions backend/src/lib/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function generateChatResponse(
}

const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
const model = genAI.getGenerativeModel({ model: "gemini-3.6-flash" });

const prompt = context
? `${SYSTEM_PROMPT}\n\nStudent context: ${context}\n\nStudent question: ${message}`
Expand All @@ -42,46 +42,131 @@ export async function generateRoadmapWithAI(params: {
if (!apiKey) return null;

const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
// Multi-model cascade: try gemini-3.6-flash first, fallback to gemini-3.5-flash-lite if 503 high demand
const candidateModels = ["gemini-3.6-flash", "gemini-3.5-flash-lite"];

const prompt = `${SYSTEM_PROMPT}

Generate a personalized career roadmap for a Sri Lankan student studying:
- Degree: ${params.degreeName}
- Type: ${params.degreeType}
- Faculty: ${params.faculty}
You are an expert Sri Lankan Higher Education and Career Advisory AI.
Generate a comprehensive, highly realistic, and actionable academic and career execution plan for a Sri Lankan university student:

Degree Information:
- Degree Programme: ${params.degreeName}
- Discipline / Faculty: ${params.faculty || params.degreeType}
- University: ${params.universityName}
- Duration: ${params.durationYears} years
${params.stream ? `- A/L Stream: ${params.stream}` : ""}
${params.zscore !== undefined ? `- Z-Score: ${params.zscore}` : ""}
- Duration: ${params.durationYears} Years
${params.stream ? `- Student A/L Stream: ${params.stream}` : ""}
${params.zscore !== undefined ? `- Student A/L Z-Score: ${params.zscore}` : ""}

Provide deep, practical advice tailored specifically to the Sri Lankan context and global career pathways:
1. Academic progression year-by-year:
- Specific foundational and advanced course modules taught in Sri Lankan universities for this degree
- Critical technical software, lab instruments, programming languages, or analytical tools (e.g., R, SPSS, Python, MATLAB, HACCP/ISO standards, CAD, specialized tools)
- Extracurricular involvement, student clubs (IEEE, Rotaract, Gavel, AIESEC, Science Society, agricultural/engineering societies), hackathons/competitions
- Industry internship strategy: specific Sri Lankan industries, reputable companies (e.g. Mas, Brandix, Dialog, IFS, WSO2, Nestlé, Fonterra, Cargills, Hayleys, John Keells, state research institutes like CRI, RRI, SLINTEC, NARA, ITI, etc.)
- Final-year research thesis ideas addressing real-world Sri Lankan or regional challenges
2. Career trajectory after graduation:
- "After Graduation": Entry-level titles, graduate trainee programs, top employers in Sri Lanka, realistic starting salary range in LKR/month.
- "3 Years Later": Mid-level role, key career advancements, industry certifications, expected salary range in LKR/month.
- "5 Years Later": Senior specialist or managerial position, postgraduate pathways (MSc / MPhil / PhD in Sri Lanka or fully-funded scholarships abroad like Erasmus, Chevening, Australia Awards, DAAD, Canada, US).
- "10 Years Later": Industry leader, executive director, entrepreneur, or international consultant role.

Return ONLY valid JSON with this exact structure:
Return ONLY valid JSON matching this schema:
{
"years": [
{ "year": 1, "milestones": ["...", "..."] }
{
"year": 1,
"milestones": [
"Concise, highly specific milestone 1 with concrete skills & actions",
"Milestone 2 with clubs/societies & academic focus",
"Milestone 3 with practical tools & foundation building"
]
}
],
"afterGraduation": [
{ "timeframe": "After Graduation", "role": "..." },
{ "timeframe": "3 Years Later", "role": "..." },
{ "timeframe": "5 Years Later", "role": "..." },
{ "timeframe": "10 Years Later", "role": "..." }
{
"timeframe": "After Graduation",
"role": "Specific role and employer types in Sri Lanka (Salary: LKR XX,XXX - XX,XXX/month) with initial duties."
},
{
"timeframe": "3 Years Later",
"role": "Mid-level role and professional achievements (Salary: LKR XXX,XXX - XXX,XXX/month)."
},
{
"timeframe": "5 Years Later",
"role": "Senior role or postgraduate / overseas scholarship path (Salary: LKR XXX,XXX+ / month or overseas stipends)."
},
{
"timeframe": "10 Years Later",
"role": "Executive leadership, consultancy, or global entrepreneurship role."
}
]
}

Include ${params.durationYears} year entries. Each year should have 3-4 milestones covering learning, internships (year 3+), portfolio building, and job preparation.`;
Provide exactly ${params.durationYears} year entries (years 1 to ${params.durationYears}), each containing 3 to 4 detailed and actionable milestones.`;

try {
const result = await model.generateContent(prompt);
const text = result.response.text();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
return JSON.parse(jsonMatch[0]) as {
years: Array<{ year: number; milestones: string[] }>;
afterGraduation: Array<{ timeframe: string; role: string }>;
};
} catch {
return null;
for (const modelName of candidateModels) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: { responseMimeType: "application/json" },
});

const result = await model.generateContent(prompt);
const text = result.response.text();
const jsonMatch = text.match(/[\{\[][\s\S]*[\}\]]/);
if (!jsonMatch) continue;
const parsed = JSON.parse(jsonMatch[0]) as any;

let rawYears: any[] = [];
let rawAfter: any[] = [];

if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item.years || item.milestones) {
rawYears.push(item);
} else if (item.afterGraduation) {
rawAfter = item.afterGraduation;
}
}
} else {
const data = parsed.careerRoadmap || parsed.roadmap || parsed;
rawYears = Array.isArray(data.years) ? data.years : [];
rawAfter = Array.isArray(data.afterGraduation) ? data.afterGraduation : [];
}

if (rawYears.length === 0) continue;

const years = rawYears.map((y: any, idx: number) => {
let yearNum = idx + 1;
if (typeof y.year === "number") yearNum = y.year;
else if (typeof y.years === "string") {
const m = y.years.match(/\d+/);
if (m) yearNum = parseInt(m[0], 10);
}
return {
year: yearNum,
milestones: Array.isArray(y.milestones)
? y.milestones.map((m: any) => (typeof m === "string" ? m : m.title || m.name || JSON.stringify(m)))
: [],
};
});

const afterGraduation = rawAfter.map((ag: any) => ({
timeframe: ag.timeframe || ag.period || "After Graduation",
role: ag.role || ag.title || "Specialist",
}));

return {
years,
afterGraduation,
};
} catch (error: any) {
console.warn(`[gemini] Model ${modelName} attempt failed:`, error?.message || error);
}
}

return null;
}

export async function explainCutoffPrediction(params: {
Expand Down Expand Up @@ -115,7 +200,7 @@ export async function explainCutoffPrediction(params: {
}

const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
const model = genAI.getGenerativeModel({ model: "gemini-3.6-flash" });

const prompt = `${SYSTEM_PROMPT}

Expand Down
5 changes: 3 additions & 2 deletions backend/src/load-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ if (existsSync(envPath)) {
const match = line.match(/^\s*([^#][^=]+)=(.*)$/);
if (!match) continue;
const key = match[1].trim();
if (process.env[key] === undefined) {
process.env[key] = match[2].trim();
const val = match[2].trim();
if (process.env[key] === undefined || process.env[key] === "") {
process.env[key] = val;
}
}
}
26 changes: 17 additions & 9 deletions backend/src/routes/roadmaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,24 @@ router.post("/roadmaps/generate", aiRateLimiter, requireAiAuth, optionalAuth, as

let years: Array<{ year: number; milestones: string[] }>;
let afterGraduation: Array<{ timeframe: string; role: string }>;
let aiRoadmap: Awaited<ReturnType<typeof generateRoadmapWithAI>> = null;

const aiRoadmap = await generateRoadmapWithAI({
degreeName: courseRow.degreeName,
degreeType: templateCategory,
faculty: courseRow.faculty ?? "",
universityName: courseRow.universityName ?? "",
durationYears,
stream,
zscore,
});
console.log("[roadmaps] Generating roadmap for:", courseRow.degreeName, "Faculty:", templateCategory);
try {
aiRoadmap = await generateRoadmapWithAI({
degreeName: courseRow.degreeName,
degreeType: templateCategory,
faculty: courseRow.faculty ?? "",
universityName: courseRow.universityName ?? "",
durationYears,
stream,
zscore,
});
console.log("[roadmaps] Gemini AI completed successfully:", !!aiRoadmap);
} catch (err) {
console.error("[roadmaps] AI generation error:", err);
aiRoadmap = null;
}

if (aiRoadmap) {
years = aiRoadmap.years.slice(0, durationYears);
Expand Down
44 changes: 34 additions & 10 deletions backend/src/routes/saved.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,26 @@ router.get("/dashboard/recent-searches", requireAuth, async (req, res) => {
.from(recentSearchesTable)
.where(eq(recentSearchesTable.userId, req.user!.userId))
.orderBy(desc(recentSearchesTable.createdAt))
.limit(10);
.limit(20);

// Deduplicate by query text (case-insensitive) and return at most 5
const seen = new Set<string>();
const uniqueSearches = [];
for (const r of rows) {
const norm = r.query.trim().toLowerCase();
if (!seen.has(norm)) {
seen.add(norm);
uniqueSearches.push({
id: r.id,
query: r.query,
filters: r.filters ? JSON.parse(r.filters) : null,
createdAt: r.createdAt,
});
if (uniqueSearches.length >= 5) break;
}
}

res.json(
rows.map((r) => ({
id: r.id,
query: r.query,
filters: r.filters ? JSON.parse(r.filters) : null,
createdAt: r.createdAt,
})),
);
res.json(uniqueSearches);
});

router.post("/dashboard/recent-searches", requireAuth, async (req, res) => {
Expand All @@ -248,11 +258,17 @@ router.post("/dashboard/recent-searches", requireAuth, async (req, res) => {
return;
}

const queryTrimmed = parsed.data.query.trim();
if (queryTrimmed.length < 2) {
res.status(400).json({ error: "Search query too short" });
return;
}

const [search] = await db
.insert(recentSearchesTable)
.values({
userId: req.user!.userId,
query: parsed.data.query,
query: queryTrimmed,
filters: parsed.data.filters ? JSON.stringify(parsed.data.filters) : null,
})
.returning();
Expand All @@ -265,4 +281,12 @@ router.post("/dashboard/recent-searches", requireAuth, async (req, res) => {
});
});

router.delete("/dashboard/recent-searches", requireAuth, async (req, res) => {
await db
.delete(recentSearchesTable)
.where(eq(recentSearchesTable.userId, req.user!.userId));

res.json({ success: true });
});

export default router;
2 changes: 1 addition & 1 deletion frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import "@/hooks/use-auth";
import NotFound from "@/pages/not-found";
import Home from "@/pages/home";
import Profile from "@/pages/profile";
import Settings from "@/pages/settings";
import Dashboard from "@/pages/dashboard";
import Courses from "@/pages/courses";
import CourseDetail from "@/pages/course-detail";
Expand Down Expand Up @@ -117,6 +118,7 @@ function Router() {
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/profile" component={Profile} />
<Route path="/settings" component={Settings} />
<Route path="/dashboard" component={ProtectedDashboard} />
<Route path="/universities/:id" component={Universities} />
<Route path="/universities" component={Universities} />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * from "./generated/api";
export * from "./generated/api.schemas";
export { setBaseUrl, setAuthTokenGetter, ApiError } from "./custom-fetch";
export { setBaseUrl, setAuthTokenGetter, ApiError, customFetch } from "./custom-fetch";
export type { AuthTokenGetter } from "./custom-fetch";
export * from "./checker";
export * from "./admin";
17 changes: 10 additions & 7 deletions frontend/src/components/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,19 @@ export function LanguageSwitcher({ className, variant = "select" }: LanguageSwit
}

return (
<div className={cn("flex items-center gap-1.5", className)}>
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<div className={cn("relative inline-flex items-center", className)}>
<Select value={language} onValueChange={handleLanguageChange}>
<SelectTrigger className="h-8 w-[110px] text-xs bg-card/60 border-[hsl(var(--border))]">
<SelectValue placeholder="Language" />
<SelectTrigger
className="h-8 px-2 rounded-full border border-border bg-card hover:bg-muted/80 transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20 gap-1"
aria-label="Select language"
title="Select language"
>
<Globe className="h-4 w-4 text-foreground shrink-0" />
</SelectTrigger>
<SelectContent align="end">
<SelectContent align="end" className="min-w-[130px]">
{supportedLanguages.map((lang) => (
<SelectItem key={lang.code} value={lang.code} className="text-xs">
<span className="font-medium">{lang.nativeName}</span>{" "}
<SelectItem key={lang.code} value={lang.code} className="text-xs cursor-pointer py-1.5">
<span className="font-semibold">{lang.nativeName}</span>{" "}
<span className="text-muted-foreground text-[10px]">({lang.label})</span>
</SelectItem>
))}
Expand Down
Loading
Loading