Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f655cc0
feat: add internship roadmap UI
sharanu-patil33 Jul 19, 2026
ce48c71
feat: add Bronze and Silver phase to internship roadmap
sharanu-patil33 Jul 19, 2026
f8ef295
Add email notifications for question answered/resolved events
Ashishpatra0-0 Jul 20, 2026
7647e42
fixed unable to edit/delete my own posts
Not4Rohith Jul 22, 2026
ef9d750
Update InternshipRoadmapPage.tsx
Akshay-Vishwakarmaa Jul 23, 2026
3ff4c95
add the matrix mystics and their links
sharanu-patil33 Jul 23, 2026
9f7cbc5
Fix overlapping buttons/text in search feedback popup
Ashishpatra0-0 Jul 24, 2026
175a75e
add the all phase2 project and their mentors
sharanu-patil33 Jul 24, 2026
9c1fd23
add all step links
sharanu-patil33 Jul 24, 2026
6e193e7
styling of the page
sharanu-patil33 Jul 24, 2026
ec11c5b
Add promote to FAQ feature for Admins
karn-ayushi Jul 24, 2026
159a3ce
Enhance community search bar with real-time filtering and clear button
mvchad08-crypto Jul 25, 2026
88709b4
Add popular search tags, result count banner, and custom empty search…
mvchad08-crypto Jul 25, 2026
07203a8
Add keyboard shortcut '/', recent search history, and enhanced UI badges
mvchad08-crypto Jul 25, 2026
a2aca34
Clean up JSX tree, fix duplicate tags, and refine search empty state
mvchad08-crypto Jul 25, 2026
0502820
Add search result counter, quick filter tags, and custom empty search…
mvchad08-crypto Jul 25, 2026
64ebf19
feat: add draft persistence for community posts
Jul 26, 2026
a417789
fix(community): remove duplicate search clear button and hardcoded po…
mvchad08-crypto Jul 26, 2026
e7d462b
Necessary FAQ SearchBar optimizations
Not4Rohith Jul 26, 2026
e1c9d0c
reverted lost comments
Not4Rohith Jul 26, 2026
dfd0597
Merge pull request #5 from sharanu-patil33/FAQ-SearchBar-Optimizations
sharanu-patil33 Jul 27, 2026
91c8af4
Complete Internship Roadmap UI
sharanu-patil33 Jul 27, 2026
f9c0d80
Merge pull request #6 from sharanu-patil33/feature/internship-roadmap-ui
sharanu-patil33 Jul 27, 2026
8944787
Resolve merge conflict and merge my-questions into main
sharanu-patil33 Jul 27, 2026
8b7a5e7
Merge pull request #1 from samruddhichate3012s/main
sharanu-patil33 Jul 27, 2026
56712ef
Merge pull request #3 from sharanu-patil33/fix/search-feedback-popup-…
sharanu-patil33 Jul 27, 2026
2bb3f45
Merge pull request #2 from sharanu-patil33/feature/email-notifications
sharanu-patil33 Jul 27, 2026
4dd9273
Merge pull request #8 from sharanu-patil33/promote-to-FAQ
sharanu-patil33 Jul 27, 2026
9966317
fix(ai): normalize RRF scores before merging with knowledge scores in…
ANIL-Dudi Jul 23, 2026
6a74d05
Merge pull request #10 from ANIL-Dudi/fix/rag-cross-source-score-norm…
ANIL-Dudi Jul 27, 2026
a3d597f
Merge pull request #9 from sharanu-patil33/feature/community-search-fix
sharanu-patil33 Jul 27, 2026
e8d7adf
docs: update README with project overview and Phase 1 enhancements
sharanu-patil33 Jul 28, 2026
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
388 changes: 317 additions & 71 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"mongoose": "^8.0.3",
"morgan": "^1.10.1",
"multer": "^2.1.1",
"nodemailer": "^9.0.3",
"obscenity": "^0.4.6",
"openai": "^6.39.0",
"pdf-parse": "^2.4.5",
Expand All @@ -74,6 +75,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.9.1",
"@types/nodemailer": "^8.0.1",
"@types/range-parser": "^1.2.7",
"@types/uuid": "^10.0.0",
"nodemon": "^3.0.2",
Expand Down
103 changes: 102 additions & 1 deletion apps/backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,107 @@ export const getCommunityPosts = async (req: Request, res: Response): Promise<vo
}
};

// POST /api/admin/community/:id/promote-to-faq
//
// Admin-discretion promotion: the admin looks at a community post's
// question + answer and decides — on their own judgment alone — to
// publish it as an FAQ, right now. Unlike the community-promotions
// pipeline in promotion.service.ts (which requires quality/engagement
// score gates, a review window, and AI validation), this path has
// NO eligibility checks. The only hard requirement is that the post
// actually has an answer to promote — there's nothing to publish
// without one.
export const promoteCommunityPostToFAQ = async (req: Request, res: Response): Promise<void> => {
try {
const post = await CommunityPost.findById(req.params.id);
if (!post) {
res.status(404).json({ message: 'Post not found.' });
return;
}
// Per-program scope check — same pattern as deleteCommunityPost above.
if (post.batchId && req.programContext?.batchId &&
post.batchId.toString() !== req.programContext.batchId) {
res.status(404).json({ message: 'Post not found.' });
return;
}

const answer = (post.answer ?? '').trim();
if (!answer) {
res.status(400).json({ message: 'This post has no answer yet — nothing to promote.' });
return;
}

// Idempotent: don't create a duplicate FAQ if this post was already promoted.
const existing = await FAQ.findOne({ sourceCommunityPostId: post._id });
if (existing) {
res.status(409).json({ message: 'This post has already been promoted to an FAQ.', faq: existing });
return;
}

// Best-effort embedding — never block the promotion on it.
let embedding: number[] | undefined;
try {
const text = `Question: ${post.title}. Answer: ${answer}`;
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('generateEmbedding timeout (5s)')), 5000)
);
embedding = (await Promise.race([generateEmbedding(text), timeout])) as any;
} catch (embErr) {
console.warn('[admin] promoteCommunityPostToFAQ: embedding skipped:', (embErr as Error).message);
}

const now = new Date();
const faq = await FAQ.create({
question: sanitizeHtml(post.title),
answer: sanitizeHtml(answer),
category: 'Community',
tags: post.tags ?? [],
status: 'approved',
embedding,
batchId: post.batchId ?? null,
createdBy: post.author,
trustLevel: 'expert', // straight to "Admin Approved" — an admin made the call directly
sourceType: 'community_promotion',
sourceCommunityPostId: post._id,
promotedAt: now,
objectionStatus: 'none',
promotionMetadata: {
upvotesAtPromotion: post.upvotes?.length ?? 0,
communityAnswerAuthorId: post.answerAuthorId ?? null,
promotedBy: req.user!._id,
},
});

// Fast-forward the post's lifecycle straight to converted_to_faq —
// skip the community_accepted / ai_validated / admin_accepted stages
// since none of those gates applied here.
post.lifecycle ??= { status: 'open', statusHistory: [] } as any;
const fromStatus = post.lifecycle.status ?? 'open';
post.lifecycle.statusHistory ??= [];
(post.lifecycle.statusHistory as any).push({
from: fromStatus,
to: 'converted_to_faq',
changedBy: req.user!._id,
changedAt: now,
note: 'Promoted directly to FAQ by admin (discretionary, no eligibility checks)',
});
post.lifecycle.status = 'converted_to_faq';
post.lifecycle.convertedToFaqAt = now;
await post.save();

await Promise.all([
invalidateCache().catch((e) => console.error('[admin] invalidateCache after promote:', e)),
]);
invalidatePublicCaches();

await logAction(req.user!._id.toString(), 'promote_community_post_to_faq', faq._id.toString(), 'faq', faq.question);

res.json({ message: 'Post promoted to FAQ.', faq });
} catch (error) {
res.status(500).json({ message: 'Server error', error: (error as Error).message });
}
};

// DELETE /api/admin/community/:id
export const deleteCommunityPost = async (req: Request, res: Response): Promise<void> => {
try {
Expand All @@ -703,4 +804,4 @@ export const deleteCommunityPost = async (req: Request, res: Response): Promise<
} catch (error) {
res.status(500).json({ message: 'Server error', /* error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined */ });
}
};
};
7 changes: 6 additions & 1 deletion apps/backend/src/modules/admin/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getUserActivityChart,
getCommunityPosts,
deleteCommunityPost,
promoteCommunityPostToFAQ,
} from './admin.controller.js';
import {
getCommunityPendingFAQs,
Expand Down Expand Up @@ -175,6 +176,10 @@ router.patch('/faq/:id', updateFAQ);
router.patch('/faqs/:id', updateFAQ);
router.delete('/faq/:id', deleteFAQ);
router.delete('/community/:id', deleteCommunityPost);
// Admin-discretion: instantly promote a community post's Q&A to FAQ,
// no eligibility checks (contrast with /community-promotions/* below,
// which is the gated auto-pipeline).
router.post('/community/:id/promote-to-faq', promoteCommunityPostToFAQ);

// FAQ promotion management (trust levels) — from promotionService
router.get('/faqs/community-pending', getCommunityPendingFAQs);
Expand All @@ -190,4 +195,4 @@ router.get('/community-promotions/queue', getPromotionQueue);
router.get('/queue/stats', getQueueStats);
router.get('/queue/jobs/:id', getQueueJob);

export default router;
export default router;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* rag.score-normalization.test — regression guard for the cross-source
* score scale bug in the /ask-ai RAG pipeline.
*
* Bug: runRag() in rag.service.ts merges hits from three retrieval
* sources and sorts them by `.score` to decide citation order AND
* which sources survive the MAX_CONTEXT_CHARS truncation. FAQ/Community
* hits carry a Reciprocal Rank Fusion score (max ~0.033, see
* searchFaqs()/searchCommunity()); Knowledge hits carry an independent
* cosine-similarity-based score already normalized to [0, 1] (see
* scoreAndSort() in knowledge-base.service.ts). Sorting these unmodified
* meant a barely-relevant Knowledge hit (score just over its 0.05 floor)
* always outranked even a rank-#1-in-both-searches FAQ/Community hit —
* silently degrading answer quality.
*
* Fix: normalizeRrfScore() rescales the RRF score onto the same [0, 1]
* ceiling as the Knowledge score before the merged sources are sorted.
* These tests pin that behavior down with pure-function assertions —
* no Mongo/Atlas Search/HTTP required.
*/

import { describe, it, expect } from 'vitest';
import { normalizeRrfScore, RRF_SCORE_CEILING } from '../rag.service.js';

describe('normalizeRrfScore', () => {
it('maps the RRF ceiling (rank #1 in both vector + text search) to exactly 1', () => {
const bestPossibleRrf = 1 / 60 + 1 / 60; // rank 0 in both lists
expect(normalizeRrfScore(bestPossibleRrf)).toBeCloseTo(1, 10);
});

it('maps a single rank-#1 hit (only in one of the two lists) to 0.5', () => {
const singleListTopRank = 1 / 60;
expect(normalizeRrfScore(singleListTopRank)).toBeCloseTo(0.5, 10);
});

it('never exceeds 1 even if given an out-of-range value', () => {
expect(normalizeRrfScore(RRF_SCORE_CEILING * 5)).toBe(1);
});

it('demonstrates the pre-fix bug is resolved: a top-ranked FAQ hit now outranks a barely-passing Knowledge hit', () => {
const topFaqRrfScore = 1 / 60 + 1 / 60; // best possible FAQ RRF score
const weakKnowledgeScore = 0.06; // just above the 0.05 floor in scoreAndSort()

// Before the fix, sources were compared as raw scores:
const rawFaqScore = topFaqRrfScore; // ~0.0333
expect(rawFaqScore < weakKnowledgeScore).toBe(true); // this was the bug — FAQ always lost

// After the fix, scores are normalized onto a comparable [0, 1] scale:
const normalizedFaqScore = normalizeRrfScore(topFaqRrfScore);
expect(normalizedFaqScore > weakKnowledgeScore).toBe(true); // FAQ now correctly outranks it
});
});
43 changes: 38 additions & 5 deletions apps/backend/src/modules/ai/rag.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ export interface RagSource {
snippet: string;
/** URL the client can deep-link to. */
url: string;
/** Confidence in [0, 1] (vector cosine + keyword overlap). */
/**
* Confidence in [0, 1], normalized across sources so scores are
* comparable for merged sort/truncation (see normalizeRrfScore()
* below for how FAQ/Community RRF scores are rescaled onto this
* same ceiling as the Knowledge cosine-based score).
*/
score: number;
}

Expand All @@ -45,6 +50,20 @@ export interface RagResult {
}

const TOP_K_PER_SOURCE = 4;

/**
* FAQ/Community hits are scored via Reciprocal Rank Fusion — the sum of
* 1/(60+rank) across the vector-search and text-search result lists. Its
* theoretical ceiling is 1/60 + 1/60 = 1/30 (a document ranked #1 in both
* lists). Knowledge hits (see scoreAndSort() in knowledge-base.service.ts)
* are scored on an independent, already-normalized [0, 1] cosine-similarity
* scale. Exported as a pure function so the rescaling can be unit tested
* without standing up Mongo/Atlas Search.
*/
export const RRF_SCORE_CEILING = 2 / 60; // = 1/30
export function normalizeRrfScore(raw: number): number {
return Math.min(1, raw / RRF_SCORE_CEILING);
}
const MAX_CONTEXT_CHARS = 14000; // leave headroom under typical 16k context windows

interface FaqHit { _id: unknown; question: string; answer: string; category?: string; trustLevel?: string; score: number }
Expand Down Expand Up @@ -269,22 +288,36 @@ export async function runRag(question: string, attachments: RagAttachment[] = []
]);

// Normalize each source into the common shape.
//
// IMPORTANT: faqHits/postHits carry a Reciprocal Rank Fusion score
// (sum of 1/(60+rank) across vector+text search) whose theoretical
// ceiling is 1/60 + 1/60 = 1/30 ≈ 0.033. knowledgeHits carry a
// cosine-similarity-based score already normalized to [0, 1]
// (see scoreAndSort() in knowledge-base.service.ts). Sorting these
// scores together unmodified means a barely-relevant Knowledge hit
// (score just over its 0.05 floor) always outranks even a
// rank-1-in-both-searches FAQ/Community hit (score capped at 0.033) —
// silently degrading citation order and, worse, causing strong
// FAQ/Community sources to be dropped first by the MAX_CONTEXT_CHARS
// truncation in buildContext(). Rescale RRF scores onto the same
// [0, 1] ceiling as the knowledge score so all three sources are
// comparable before the merged sort (see normalizeRrfScore() above).
const sources: RagSource[] = [
...faqHits.map((h) => ({
id: `faq:${String(h._id)}`,
type: 'faq' as const,
title: h.question,
snippet: h.answer.slice(0, 600),
url: `/faq/${String(h._id)}`,
score: h.score,
score: normalizeRrfScore(h.score),
})),
...postHits.map((h) => ({
id: `community:${String(h._id)}`,
type: 'community' as const,
title: h.title,
snippet: h.body.slice(0, 600),
url: `/community?post=${String(h._id)}`,
score: h.score,
score: normalizeRrfScore(h.score),
})),
...knowledgeHits.map((h) => ({
id: `knowledge:${h._id}`,
Expand All @@ -295,11 +328,11 @@ export async function runRag(question: string, attachments: RagAttachment[] = []
// or to the admin KB if we know the meeting. For now, a stable
// deep-link to a future /knowledge/:id is best-effort.
url: `/community?post=${h._id}`,
score: h.score,
score: h.score, // already normalized to [0, 1] in scoreAndSort()
})),
];

// Re-rank by score so the LLM sees the strongest sources first.
// Re-rank by (now comparable) score so the LLM sees the strongest sources first.
sources.sort((a, b) => b.score - a.score);

// If we found nothing at all, skip the LLM call — just say "no answer".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export const resolvePost = async (req: Request, res: Response): Promise<void> =>
recipientId: post.author,
eventType: 'post_resolved',
link: `/community?post=${post._id}`,
title: 'Your question was resolved!',
title: post.title,
message: `Your question "${post.title}" has been answered: ${post.answer}`,
}).catch((err) => {
communityLog.warn(`[post] Failed to dispatch post resolved notification: ${(err as Error).message}`);
});
Expand Down
20 changes: 20 additions & 0 deletions apps/backend/src/scripts/testEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* scripts/testEmail.ts
* One-off script to verify the email utility works before wiring it
* into the real notification flow. Run with:
* pnpm --filter yaksha-faq-backend exec tsx src/scripts/testEmail.ts
*/
import 'dotenv/config';
import { sendEmail } from '../utils/email/sendEmail.js';

async function main() {
const result = await sendEmail({
to: 'test@example.com', // Mailtrap catches all emails regardless of the "to" address
subject: 'Test email from CSFAQ local setup',
text: 'If you see this in your Mailtrap inbox, the email utility works!',
});
console.log('Email sent successfully:', result);
process.exit(result ? 0 : 1);
}

main();
76 changes: 71 additions & 5 deletions apps/backend/src/services/notifications.service.ts

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions apps/backend/src/utils/email/sendEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* utils/email/sendEmail.ts
*
* Small, self-contained email-sending utility using nodemailer.
* Added as a new capability alongside the existing in-app-only
* notifications.service.ts — does not modify any existing logic.
*
* Usage:
* import { sendEmail } from '../utils/email/sendEmail.js';
* await sendEmail({
* to: 'user@example.com',
* subject: 'Your question was answered',
* text: 'Someone replied to your question on Yaksha FAQ.',
* });
*/
import nodemailer from 'nodemailer';
import { logger } from '../http/logger.js';

interface SendEmailInput {
to: string;
subject: string;
text: string;
html?: string;
}

let transporter: nodemailer.Transporter | null = null;

function getTransporter(): nodemailer.Transporter | null {
if (!process.env.MAIL_HOST || !process.env.MAIL_USER || !process.env.MAIL_PASS) {
logger.warn('[email] MAIL_HOST/MAIL_USER/MAIL_PASS not fully configured — email sending disabled.');
return null;
}
if (!transporter) {
transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: Number(process.env.MAIL_PORT ?? 2525),
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS,
},
});
}
return transporter;
}

export async function sendEmail(input: SendEmailInput): Promise<boolean> {
const t = getTransporter();
if (!t) return false;

try {
await t.sendMail({
from: '"Yaksha FAQ Portal" <no-reply@yaksha-faq.local>',
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
logger.info(`[email] sent to ${input.to}: ${input.subject}`);
return true;
} catch (err) {
logger.error(`[email] failed to send to ${input.to}: ${(err as Error).message}`);
return false;
}
}
Loading