import { supabase } from "../lib/supabaseClient";
import { enrichSubmissionsWithOriginalData } from "./dashboardService";
import { persistentCache } from "./cacheService";
// Helper to get active Gemini API key (from environment or session storage)
export function getGeminiApiKey() {
const envKey = import.meta.env.VITE_GEMINI_API_KEY;
if (envKey && envKey.trim().length > 0) {
return envKey;
}
return sessionStorage.getItem("temp_gemini_api_key") || "";
}
// ─── Gemini API Callers ──────────────────────────────────────────────
export async function generateEmbedding(text) {
const cleanText = String(text || "").trim().replace(/\n/g, " ");
if (!cleanText) {
throw new Error("Cannot generate embedding for empty text.");
}
const { data, error } = await supabase.functions.invoke("gemini-proxy", {
body: { action: "embed", text: cleanText }
});
if (error) {
console.error("[generateEmbedding] Proxy call failed:", error);
throw error;
}
const values = data?.embedding?.values;
if (!values || !Array.isArray(values) || values.length === 0) {
throw new Error("Embedding API response did not contain vector values.");
}
return values; // 768 float array
}
/**
* Generate match explanation using models/gemini-2.0-flash via proxy.
*/
export async function generateExplanation(
queryTitle,
queryAbstract,
candidateName,
candidateDetails,
candidateItems
) {
const prompt = `You are an AI academic co-author matchmaking assistant for IILM University.
A faculty member is looking for a collaborator for the following proposed research idea:
Proposed Research Title: "${queryTitle}"
Proposed Abstract/Idea: "${queryAbstract}"
We found a potential co-author match:
Name: ${candidateName}
Designation: ${candidateDetails.designation || "Faculty Member"}
School/Department: ${candidateDetails.school || "N/A"}
Specialization: ${candidateDetails.specialization || "N/A"}
Overlapping Specializations/Keywords: ${candidateDetails.specialization || "N/A"}
Here are their most relevant publications, patents, or projects matching the query:
${candidateItems.map((item, idx) => `- [${item.type}] "${item.title}" (Relevance Score: ${(item.similarity * 100).toFixed(0)}%)`).join("\n")}
Please write a highly professional, dynamic, and specific explanation of why this faculty member is a great fit.
Rules:
1. Keep the explanation concise (2 to 3 sentences max).
2. Point out specific matching topics or methodologies based on the candidate's publications/projects.
3. Highlight complementary expertise if applicable (e.g., if the user is working on machine learning applied to medicine, and this candidate has medical domain expertise, state how their clinical background complements the technical proposal).
4. Write in a supportive academic tone. Do NOT use generic text or placeholders.`;
const models = ["gemini-3.5-flash", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-2.0-flash-lite"];
let explanation = "";
for (const model of models) {
try {
const { data, error } = await supabase.functions.invoke("gemini-proxy", {
body: {
action: "generate",
prompt,
generationConfig: {
maxOutputTokens: 180,
temperature: 0.3,
},
model,
}
});
if (!error && data) {
explanation = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || "";
if (explanation) break;
} else {
console.warn(`[generateExplanation] Model ${model} failed:`, error || data);
}
} catch (err) {
console.warn(`[generateExplanation] Exception for ${model}:`, err.message);
}
}
return explanation || `Recommended based on their research in ${candidateDetails.specialization || "related fields"} and matching publications: ${candidateItems.map(i => `"${i.title}"`).slice(0, 2).join(", ")}.`;
}
// ─── Document Vectorization Formatters ───────────────────────────────
function formatRecordToText(type, record, userName) {
if (type === "profile") {
return `Faculty Profile of ${userName}:
Designation: ${record.designation || ""}
School: ${record.school_faculty || ""}
Specialization: ${record.area_of_specialization || ""}
Highest Qualification: ${record.highest_qualification || ""}
Orcid: ${record.orcid_id || ""}
Scopus: ${record.scopus_id || ""}`.trim();
}
if (type === "journal_publications") {
return `Journal Publication by ${userName}:
Title: ${record.title_of_paper || ""}
Journal: ${record.journal_name || ""}
Publisher: ${record.publisher || ""}
Keywords/Metadata: ${record.remarks || ""}
Quartile: ${record.quartile || ""}`.trim();
}
if (type === "conference_publications") {
return `Conference Paper by ${userName}:
Title: ${record.title_of_paper || ""}
Conference: ${record.conference_name || ""}
Organizing Body: ${record.organizing_body || ""}
Type: ${record.type || ""}`.trim();
}
if (type === "patents") {
return `Patent by ${userName}:
Title: ${record.title_of_patent || ""}
Inventors: ${record.inventors || ""}
Status: ${record.patent_status || ""}
Type: ${record.patent_type || ""}`.trim();
}
if (type === "book_chapters") {
return `Book Chapter by ${userName}:
Title: ${record.chapter_title || ""}
Book: ${record.book_title || ""}
Publisher: ${record.publisher || ""}`.trim();
}
if (type === "books_authored") {
return `Book Authored by ${userName}:
Title: ${record.book_title || ""}
Publisher: ${record.publisher || ""}
Type: ${record.authored_or_edited || ""}`.trim();
}
if (type === "consultancy_projects") {
return `Consultancy Project by ${userName}:
Title: ${record.title || record.purpose || ""}
Client: ${record.client || ""}
Status: ${record.status || ""}`.trim();
}
if (type === "faculty_achievements") {
return `Achievement by ${userName}:
Title: ${record.title || record.name_of_award || ""}
Category: ${record.category || ""}
Description: ${record.brief_description || ""}`.trim();
}
if (type === "industry_collaboration") {
return `Industry Collaboration by ${userName}:
Industry Name: ${record.industry_name || ""}
Nature: ${record.nature_of_collaboration || ""}
Outcomes: ${record.outcomes || ""}`.trim();
}
// Fallback
return `${type} by ${userName}: ${record.title || record.name || ""}`;
}
// ─── Similarity Math Fallback ────────────────────────────────────────
function cosineSimilarity(vecA, vecB) {
let dotProduct = 0.0;
let normA = 0.0;
let normB = 0.0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] * vecA[i];
normB += vecB[i] * vecB[i];
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
// ─── Index Syncing ───────────────────────────────────────────────────
/**
* Synchronize the current user's profile and approved research items into the embeddings table.
* If the user is an admin or is running manually, they can sync their own items.
* Skips already indexed items based on source_id / user_id constraints.
*/
export async function syncUserResearchEmbeddings(userId, apiKey, onProgress) {
const key = apiKey || getGeminiApiKey();
if (!key) throw new Error("API Key missing");
if (onProgress) onProgress("Fetching user records...", 0);
// 1. Fetch user profile
const { data: user, error: userError } = await supabase
.from("users")
.select("*, faculty_profiles(*)")
.eq("id", userId)
.single();
if (userError || !user) throw new Error("User profile not found");
const fProfile = Array.isArray(user.faculty_profiles) ? user.faculty_profiles[0] : user.faculty_profiles;
// 2. Fetch all approved submissions for this user
const { data: submissions, error: subError } = await supabase
.from("unified_submissions")
.select("*")
.eq("user_id", userId)
.in("dean_status", ["approved", "resubmitted", "pending"]); // fetch active research items
if (subError) throw new Error("Failed to fetch submissions");
// 3. Fetch existing indexed embeddings for this user to avoid double embedding
const { data: existingEmbeddings } = await supabase
.from("faculty_research_embeddings")
.select("source_id, type")
.eq("user_id", userId);
const indexedSet = new Set();
(existingEmbeddings || []).forEach(e => {
if (e.source_id) {
indexedSet.add(`${e.type}:${e.source_id}`);
}
});
// Enrich the raw submissions data
const enrichedMap = await enrichSubmissionsWithOriginalData(submissions || []);
const queue = [];
// Add profile embedding if not already indexed
// To support standard unique constraint on (source_id, type), we use the userId as source_id for profile entries.
if (fProfile && fProfile.area_of_specialization && !indexedSet.has(`profile:${userId}`)) {
queue.push({
type: "profile",
source_id: userId,
content: formatRecordToText("profile", fProfile, user.name),
});
}
// Add research items if not already indexed
submissions.forEach(sub => {
const key = `${sub.table_name}:${sub.id}`;
if (!indexedSet.has(key)) {
const originalRecord = enrichedMap.get(`${sub.table_name}:${sub.id}`);
if (originalRecord) {
queue.push({
type: sub.table_name,
source_id: sub.id,
content: formatRecordToText(sub.table_name, originalRecord, user.name),
});
}
}
});
if (queue.length === 0) {
if (onProgress) onProgress("Everything up to date!", 100);
return { synced: 0 };
}
let successCount = 0;
for (let i = 0; i < queue.length; i++) {
const task = queue[i];
const percent = Math.round((i / queue.length) * 100);
if (onProgress) onProgress(`Vectorizing ${task.type}... (${i + 1}/${queue.length})`, percent);
try {
const embedding = await generateEmbedding(task.content, key);
const payload = {
user_id: userId,
type: task.type,
source_id: task.source_id,
content: task.content,
embedding: embedding,
};
const query = supabase
.from("faculty_research_embeddings")
.upsert(payload, { onConflict: "source_id,type" });
const { error: upsertErr } = await query;
if (upsertErr) {
console.error(`Failed to save embedding for ${task.type}:`, upsertErr);
} else {
successCount++;
}
} catch (e) {
console.error(`Error processing embedding queue item ${i}:`, e);
}
// Delay a bit to prevent rate limit on free tier Gemini API keys
await new Promise(r => setTimeout(r, 300));
}
if (onProgress) onProgress(`Successfully synchronized ${successCount} items!`, 100);
return { synced: successCount };
}
// ─── Co-Author Finder logic ──────────────────────────────────────────
/**
* Perform semantic search to find suitable co-authors.
*/
export async function findCoAuthors(
{ title, abstract, sourcePublicationId, filters, excludeSelf = true },
apiKey
) {
const key = apiKey || getGeminiApiKey();
if (!key) throw new Error("API Key missing");
const { data: { session } } = await supabase.auth.getSession();
const currentUserId = session?.user?.id || "anonymous";
// Create a unique cache key based on user, inputs, filters, and excludeSelf
const cacheKeyParts = {
userId: currentUserId,
title: title || "",
abstract: abstract || "",
sourcePublicationId: sourcePublicationId || "",
filters: filters || {},
excludeSelf
};
const cacheKey = `coauthor_matching:${JSON.stringify(cacheKeyParts)}`;
return persistentCache.get(cacheKey, async () => {
// 1. Build Query Text
let queryText = "";
if (sourcePublicationId) {
// Fetch source publication
const { data: pubData, error: pubErr } = await supabase
.from("unified_submissions")
.select("*")
.eq("id", sourcePublicationId)
.single();
if (pubErr || !pubData) throw new Error("Selected publication not found");
const enriched = await enrichSubmissionsWithOriginalData([pubData]);
const original = enriched.get(`${pubData.table_name}:${pubData.id}`);
queryText = original
? formatRecordToText(pubData.table_name, original, "Selected Paper")
: pubData.title;
} else {
queryText = `Research Idea:
Title: ${title}
Abstract: ${abstract}`;
}
// 2. Generate Embedding for Query
const queryEmbedding = await generateEmbedding(queryText, key);
// 3. Search embeddings
// First attempt: Supabase RPC
let matchResults = [];
let rpcFailed = false;
const threshold = (filters.minMatchScore || 40) / 100;
try {
const { data, error } = await supabase.rpc("match_research_embeddings", {
query_embedding: queryEmbedding,
match_threshold: threshold,
match_count: 80,
exclude_user_id: excludeSelf && currentUserId ? currentUserId : "00000000-0000-0000-0000-000000000000",
});
if (error) {
console.warn("match_research_embeddings RPC failed. Falling back to client-side search:", error);
rpcFailed = true;
} else {
matchResults = data || [];
}
} catch (e) {
console.warn("RPC match call exception, using client fallback:", e);
rpcFailed = true;
}
// Fallback: Client-side cosine similarity search
if (rpcFailed) {
let query = supabase.from("faculty_research_embeddings").select("*");
if (excludeSelf && currentUserId) {
query = query.neq("user_id", currentUserId);
}
const { data: allEmbeds, error: fetchErr } = await query;
if (fetchErr) throw new Error("Failed to load embeddings for matching: " + fetchErr.message);
(allEmbeds || []).forEach(item => {
// Parse vector string if returned as string (e.g. '[0.1, 0.2]')
let itemEmbedding = item.embedding;
if (typeof itemEmbedding === "string") {
try {
itemEmbedding = JSON.parse(itemEmbedding.replace(/{/g, "[").replace(/}/g, "]"));
} catch {
// ignore
}
}
if (Array.isArray(itemEmbedding)) {
const similarity = cosineSimilarity(queryEmbedding, itemEmbedding);
if (similarity > threshold) {
matchResults.push({
id: item.id,
user_id: item.user_id,
type: item.type,
source_id: item.source_id,
content: item.content,
similarity: similarity,
});
}
}
});
}
if (matchResults.length === 0) return [];
// 4. Retrieve candidate profiles and filter
const candidateUserIds = [...new Set(matchResults.map(r => r.user_id))];
// Fetch users details
const { data: users, error: usersErr } = await supabase
.from("users")
.select("*")
.in("id", candidateUserIds);
if (usersErr) throw new Error("Failed to fetch user records for candidates");
// Fetch faculty profiles separately (bypass join restrictions/quirks)
const { data: profiles, error: profilesErr } = await supabase
.from("faculty_profiles")
.select("*")
.in("user_id", candidateUserIds);
if (profilesErr) console.warn("Failed to fetch faculty profiles for candidates:", profilesErr);
// Map profiles by user_id
const profilesMap = {};
(profiles || []).forEach(p => {
profilesMap[p.user_id] = p;
});
// Compile map of users
const usersMap = {};
(users || []).forEach(u => {
const fProfile = profilesMap[u.id];
// Account active check: requires faculty_profiles record to exist
const isActive = !!fProfile;
usersMap[u.id] = {
...u,
profile: fProfile || {},
isActive,
};
});
// 5. Group match items by candidate and aggregate scores
const candidateGroups = {};
matchResults.forEach(match => {
const userObj = usersMap[match.user_id];
if (!userObj || !userObj.isActive) return;
// Apply basic filter criteria early to save LLM budget
if (filters.school && filters.school !== "All") {
const userSchool = userObj.school || userObj.profile?.school_faculty || "";
if (!userSchool.toLowerCase().includes(filters.school.toLowerCase())) return;
}
if (filters.designation && filters.designation !== "All") {
const userDesignation = userObj.profile?.designation || "";
if (userDesignation.toLowerCase() !== filters.designation.toLowerCase()) return;
}
if (filters.yearsOfExperience) {
const experience = Number(userObj.profile?.years_of_experience || 0);
if (experience < Number(filters.yearsOfExperience)) return;
}
if (!candidateGroups[match.user_id]) {
candidateGroups[match.user_id] = {
userId: match.user_id,
name: userObj.name,
email: userObj.email,
school: userObj.school || userObj.profile?.school_faculty || "Not Assigned",
designation: userObj.profile?.designation || "Faculty Member",
specialization: userObj.profile?.area_of_specialization || "General Research",
profileImage: userObj.profile?.profile_image || null,
yearsOfExperience: userObj.profile?.years_of_experience || 0,
maxSimilarity: 0,
matchingItems: [],
};
}
// Extract item details
let displayTitle = "";
if (match.type === "profile") {
displayTitle = `Faculty Specialization: ${candidateGroups[match.user_id].specialization}`;
} else {
// Parse clean title from content string
const lines = match.content.split("\n");
const titleLine = lines.find(l => l.trim().startsWith("Title:"));
displayTitle = titleLine ? titleLine.replace(/^\s*Title:\s*/, "").trim() : "Untitled Sub";
}
candidateGroups[match.user_id].matchingItems.push({
type: match.type,
sourceId: match.source_id,
title: displayTitle,
similarity: match.similarity,
});
if (match.similarity > candidateGroups[match.user_id].maxSimilarity) {
candidateGroups[match.user_id].maxSimilarity = match.similarity;
}
});
// Convert map to list and sort by maxSimilarity descending
const candidates = Object.values(candidateGroups);
candidates.sort((a, b) => b.maxSimilarity - a.maxSimilarity);
// Limit to top recommendations for LLM budget efficiency
const topCandidates = candidates.slice(0, filters.limit || 5);
// 6. Generate explanations for top candidates dynamically using Gemini 2.5 Flash
const finalRecommendations = await Promise.all(
topCandidates.map(async candidate => {
// Pick top 3 matching items to send to the prompt
const sortedItems = [...candidate.matchingItems].sort((a, b) => b.similarity - a.similarity);
const explanation = await generateExplanation(
title || "Selected Publication Research Topic",
abstract || "Abstract matching",
candidate.name,
{
designation: candidate.designation,
school: candidate.school,
specialization: candidate.specialization,
},
sortedItems.slice(0, 3),
key
);
return {
...candidate,
matchScore: Math.round(candidate.maxSimilarity * 100),
explanation,
matchingItems: sortedItems,
};
})
);
return finalRecommendations;
}, 24 * 60 * 60 * 1000);
}
/**
* Updates years of experience for a faculty member.
* Operates standalone, allowing experience updates directly from the Finder.
*/
export async function updateFacultyExperience(userId, years) {
const numYears = parseInt(years, 10);
if (isNaN(numYears) || numYears < 0) throw new Error("Invalid years of experience");
// Upsert profile or update
const { data: existing } = await supabase
.from("faculty_profiles")
.select("id")
.eq("user_id", userId)
.maybeSingle();
if (existing) {
const { error } = await supabase
.from("faculty_profiles")
.update({ years_of_experience: numYears })
.eq("user_id", userId);
if (error) throw error;
} else {
const { error } = await supabase
.from("faculty_profiles")
.insert({ user_id: userId, years_of_experience: numYears });
if (error) throw error;
}
return { success: true };
}
/**
* Generates an insights-driven AI analysis for a specific faculty member's profile.
* Compiles their profile details and approved research submissions and calls Gemini 2.5 Flash.
*/
export async function generateFacultyProfileAnalysis(userId, apiKey) {
const key = apiKey || getGeminiApiKey();
if (!key) throw new Error("API Key missing");
// 1. Fetch user core info & profile details
const { data: user, error: userError } = await supabase
.from("users")
.select("*, faculty_profiles(*)")
.eq("id", userId)
.single();
if (userError || !user) throw new Error("User or profile not found");
const fProfile = Array.isArray(user.faculty_profiles) ? user.faculty_profiles[0] : user.faculty_profiles;
// 2. Fetch all approved submissions
const { data: submissions, error: subError } = await supabase
.from("unified_submissions")
.select("*")
.eq("user_id", userId)
.in("dean_status", ["approved", "resubmitted", "pending"]);
if (subError) throw new Error("Failed to fetch faculty submissions");
// Enrich original records (same helper we use in syncing)
const enrichedMap = await enrichSubmissionsWithOriginalData(submissions || []);
// 3. Compile a clean textual summary of the faculty member's entire body of work
let textSummary = `FACULTY NAME: ${user.name}
DESIGNATION: ${fProfile?.designation || "Faculty Member"}
SCHOOL: ${user.school || fProfile?.school_faculty || "Not Assigned"}
SPECIALIZATION: ${fProfile?.area_of_specialization || "General Research"}
EXPERIENCE: ${fProfile?.years_of_experience || 0} years
QUALIFICATION: ${fProfile?.highest_qualification || "N/A"}\n\n`;
textSummary += `=== RESEARCH OUTPUT & CONTRIBUTIONS ===\n`;
if (submissions && submissions.length > 0) {
submissions.forEach((sub, idx) => {
const originalRecord = enrichedMap.get(`${sub.table_name}:${sub.id}`);
if (originalRecord) {
textSummary += `\n[Asset #${idx + 1}: ${sub.table_name.replace(/_/g, " ").toUpperCase()}]
${formatRecordToText(sub.table_name, originalRecord, user.name)}\n`;
}
});
} else {
textSummary += `No publication or research assets indexed in the system yet.\n`;
}
// 4. Construct prompt for Gemini 2.5 Flash
const prompt = `You are an elite academic panel analyst evaluating a faculty member's research footprint.
Analyze the following faculty member's profile summary and complete body of work:
${textSummary}
Generate a premium, professional, and insights-driven analysis in Markdown format. The analysis MUST contain the following four structured sections:
## 1. Research Focus & Core Domain
- Synthesize a concise summary of their main research themes, keywords, and domain focus.
## 2. Key Academic Highlights
- Highlight notable aspects of their output (e.g. types of publications, patents, book chapters, or collaborations). Mention specific topics or papers from their record.
## 3. Core Strengths
- Summarize 2-3 specific technical and domain strengths based on their publications and profile.
## 4. Collaboration & Synergy Areas
- Identify specific research topics or emerging fields where this faculty member would be an ideal co-author, project partner, or mentor for other colleagues.
Rules:
1. Do NOT include any top-level title (e.g. "Academic Profile Evaluation"). Start directly with the first heading "## 1. Research Focus & Core Domain".
2. Do NOT output markdown dividers (such as "---", "--", or "- --").
3. Keep the tone highly professional, academic, constructive, and inspiring. Do not use generic placeholders. Refer specifically to details from their provided body of work. Return ONLY the markdown.`;
// 5. Call Gemini 2.0 Flash
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent?key=${key}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
})
});
if (!response.ok) {
const errText = await response.text();
console.error("Gemini API error during profile analysis:", errText);
throw new Error("Failed to generate AI analysis: " + response.statusText);
}
const result = await response.json();
const text = result?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("Empty response received from Gemini API");
return text.trim();
}
Workflows from the Neura Market marketplace related to this Gemini resource