DocDoeAI / src /components /study-chat /chat-intent.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
22.2 kB
/**
* Classifies a user message into a response mode so the chat can:
* - route greetings/casual messages to chat AI without requiring sources
* - answer generally for topic questions (API without source IDs)
* - show a clean upload CTA for doc-specific requests with no source
* - show clarify options for genuinely ambiguous short messages
* - ground answers in selected sources when present
*/
import type { ApiDocument } from "@/lib/api/types";
import { defaultStudyIntent, type StudyIntent } from "@/lib/study-intents";
import type { ChatMessage } from "./types";
// ── Types ─────────────────────────────────────────────────────────────────────
export type ResponseMode =
| "casual"
| "general_answer"
| "needs_source"
| "source_grounded"
| "tool_ready"
| "clarify";
export type AnswerMode =
| "casual_chat"
| "general_ai_answer"
| "web_search_answer"
| "source_grounded_answer"
| "source_required_upload_prompt"
| "tool_generation"
| "clarification";
export type AnswerModeResult = {
mode: AnswerMode;
intent: string;
requiresSource: boolean;
shouldUseWeb: boolean;
canAnswerGenerally: boolean;
topic: string | null;
toolType: StudyIntent | null;
userFacingNextStep: string | null;
};
export type AnswerModeSource = {
title?: string | null;
file_name?: string | null;
};
export type ChatIntentResult = {
/** Best-guess StudyIntent for downstream routing. */
intent: StudyIntent;
responseMode: ResponseMode;
answerMode?: AnswerMode;
shouldUseWeb?: boolean;
userFacingNextStep?: string | null;
requiresSource: boolean;
canAnswerGenerally: boolean;
/**
* Pre-built reply text for local UX prompts such as needs_source.
* Null means the API should generate the answer.
*/
immediateReply: string | null;
topic: string | null;
};
// ── Patterns ──────────────────────────────────────────────────────────────────
const GREETING_RE =
/^\s*(hi+|hey+|hello+|yo+|sup|bro|howdy|hola|namaste|good\s+(morning|evening|afternoon|day|night)|start|begin|let'?s\s+go|ready)\W*$/i;
// Matches "hi + something" β€” user starts with a greeting word then asks a question
const GREETING_PREFIX_RE =
/^\s*(hi+|hey+|hello+|yo+|bro|sup)\b[\s,!.]*/i;
const CAPABILITY_RE =
/\b(what\s+can\s+you\s+do|what\s+do\s+you\s+do|who\s+are\s+you|what(?:'s|\s+is)\s+your\s+name|are\s+you\s+(an?\s+)?(ai|bot|chatbot|robot|assistant)|what\s+are\s+you|how\s+(do|to|did|does|can|should|would|could|might|shall)\s+(i\s+)?use|how\s+(does|do|would|should)\s+(this|it|docdoe|the\s+app)\s+work|help\s+me\s+study|what\s+is\s+this\s+(app|tool)|your\s+features?|your\s+capabilities?|introduce\s+yourself|how\s+does\s+docdoe\s+work)\b/i;
const CASUAL_INTRO_RE =
/^\s*(hi+|hey+|hello+|yo+|bro)\b[\s,!.]*(my\s+name\s+is|i'?m|i\s+am)\b/i;
// Patterns that explicitly reference an already-uploaded or "this" document.
// Does NOT match generic notes/quiz/explain requests that happen to lack a source.
const DOC_REF_RE =
/\b(this|that|the|my|uploaded|selected)\s+(pdf|doc(?:ument)?|file|chapter|notes?|textbook|paper|source|material|page)\b|\b(from|based\s+on|in|using|explain|summarize|summarise|extract\s+from)\s+(this|that|my|the\s+uploaded|the\s+selected)\b/i;
const PYQ_RE =
/\b(pyq|previous[\s-]year|previous\s+question\s+papers?|past\s+papers?|10[\s-]year\s+(?:pyq|pattern|paper)|analyze\s+papers?|paper\s+signal|exam\s+pattern|repeated?\s+questions?|predict\s+questions?)\b/i;
const UNCLEAR_RE =
/^\s*(ok(?:ay)?|yeah|yep|yes|no|hmm?|uh|sure|got\s*it|that\s+one|do\s+it|go\s+ahead|continue|next|done|fine|proceed|thanks?|thank\s+you|thx|ty|cool|nice|great|good)\s*[.!?]?\s*$/i;
const NOTES_RE =
// allow any adjective(s) between the verb and "notes": exam notes, study
// notes, scoring notes, short notes, chapter notes, etc.
/\b(make|generate|create|write|give\s+me|build|prepare)\s+(me\s+)?(some\s+|a\s+)?(?:[a-z-]+\s+){0,3}notes?\b|\bnotes?\s+(on|for|about|of)\b/i;
const QUIZ_RE =
/\b(make|create|generate|give\s+me|build)\s+(a\s+|some\s+)?(quiz|practice\s+(quiz|test)|test)\b|\b(test\s+me|quiz\s+me|ask\s+me\s+questions?)\b/i;
const FLASHCARD_RE =
/\b(make|create|generate|give\s+me)\s+(some\s+)?(flash\s?cards?|memory\s+cards?|revision\s+cards?)\b|\b(flash\s?cards?|memory\s+cards?|revision\s+cards?)\s+(on|for|about|of)\b/i;
const VIDEO_RE =
/\b(make|create|build|generate)\s+(a\s+)?(study\s+)?video\b|(video\s+(plan|for|on|about|of))\b/i;
const STUDY_PLAN_RE =
/\b(study\s+plan|revision\s+plan|last\s+night|one\s+night|overnight|tonight|tomorrow|cram|crash\s+course|schedule|timetable|what\s+should\s+i\s+study|learn\b.*\bnight|finish\b.*\bnight|complete\b.*\bnight)\b/i;
const EXAM_ANSWER_RE =
/\b(exam\s+mode|exam\s+answer|board\s+style|marks?\s+answer|[1-9]\s*[- ]?mark|five\s+mark|answer\s+format)\b/i;
const EXPLICIT_WEB_REQUEST_RE =
/\b(?:search|browse|look\s+up|check)\s+(?:(?:the|on)\s+)?(?:internet|web|online)\b|\b(?:internet|web|online)\s+search\b/i;
const CURRENT_INFO_TOPIC_RE =
/\b(?:latest|current|today(?:'s)?|recent|updated|new)\b.{0,80}\b(?:news|notices?|announcements?|results?|exam\s+dates?|(?:official|board|exam)\s+(?:timetables?|schedules?)|syllabus|prices?|rates?|status|weather|scores?|rankings?|releases?|deadlines?|admissions?|scholarships?|polic(?:y|ies)|rules?|guidelines?)\b|\b(?:news|notices?|announcements?|results?|exam\s+dates?|(?:official|board|exam)\s+(?:timetables?|schedules?)|syllabus|prices?|rates?|status|weather|scores?|rankings?|releases?|deadlines?|admissions?|scholarships?|polic(?:y|ies)|rules?|guidelines?)\b.{0,80}\b(?:today|latest|current|recent|updated)\b/i;
const CURRENT_ROLE_RE =
/\b(?:who\s+is|who's|name)\s+(?:the\s+)?(?:current|latest)\s+(?:president|prime\s+minister|minister|chief\s+minister|governor|chief\s+executive|ceo|chairperson|head)\b/i;
const YEARLY_UPDATE_RE =
/\b20\d{2}\s+(?:update|notice|announcement|result|syllabus|timetable|schedule)\b/i;
function asksForLiveWebInformation(message: string, hasSource: boolean) {
if (EXPLICIT_WEB_REQUEST_RE.test(message)) return true;
if (hasSource) return false;
return CURRENT_INFO_TOPIC_RE.test(message) || CURRENT_ROLE_RE.test(message) || YEARLY_UPDATE_RE.test(message);
}
const PLACEHOLDER_TOPIC_RE =
/\b(topic\s+i\s+type|topic\s+you\s+type|topic\s+first|the\s+topic|break\s+it\s+into\s+scenes|matched\s+to\s+your\s+material)\b/i;
// ── Helpers ───────────────────────────────────────────────────────────────────
function cleanTopicCandidate(value: string) {
return value
.replace(/[?.!,]+$/g, "")
// strip leading request filler ("make a", "give me some", "please a", etc.)
.replace(/^((?:please|make|create|generate|build|write|prepare|give\s+me|a|an|the|some)\s+)+/gi, "")
.replace(/\b(fully|completely|properly|fast|quickly|from\s+scratch|from\s+zero)\b/gi, " ")
.replace(/\b(?:in|within|by|before|for)\s+(?:one\s+night|tonight|tomorrow|the\s+exam|my\s+exam|exam)\b.*$/i, " ")
.replace(/\b(?:one\s+night|overnight|tonight|tomorrow|cram|crash\s+course)\b.*$/i, " ")
.replace(/\s+/g, " ")
.trim();
}
function extractTopic(message: string): string | null {
const patterns: RegExp[] = [
// Tool requests with a preposition: "<tool words> ... on/for/about <topic>".
// Runs first so "study video for calculus" β†’ "calculus" (not "video for calculus").
/(?:notes?|quiz(?:zes)?|flash\s?cards?|memory\s+cards?|revision\s+cards?|video(?:\s+plan)?|answer|questions?|test|summary)\s+(?:me\s+)?(?:on|for|about|of)\s+(.+)/i,
// Direct teach/learn verbs.
/(?:i\s+(?:want|need|have)\s+to\s+learn\s+|i\s+must\s+learn\s+|help\s+me\s+(?:learn|understand)\s+|teach\s+(?:me\s+)?|explain\s+(?:me\s+)?|what\s+is\s+|what\s+are\s+|define\s+|tell\s+me\s+about\s+|about\s+|learn\s+|finish\s+|complete\s+|revise\s+|study\s+|summarize\s+|summarise\s+|prove\s+|derive\s+|derivation\s+of\s+)(.+)/i,
// Trailing form: "<topic> notes/quiz/flashcards/...".
/^(.+?)\s+(?:notes?|quiz|flash\s?cards?|explained?|summary|definition|meaning)\s*$/i,
];
const STOP_WORDS = new Set(["make", "create", "generate", "build", "write", "give", "me", "some", "a", "an", "study", "video", "memory", "card", "cards"]);
for (const p of patterns) {
const m = message.match(p);
if (m?.[1]) {
const topic = cleanTopicCandidate(m[1]);
const lc = topic.toLowerCase();
if (
topic.length > 2 &&
topic.length < 120 &&
!STOP_WORDS.has(lc) &&
!/^(this|that|my|the|it|everything|all of it)$/i.test(topic)
) {
return topic;
}
}
}
return null;
}
function isDocRef(message: string): boolean {
return DOC_REF_RE.test(message);
}
function sourceTitleMentioned(message: string, selectedSources: readonly AnswerModeSource[]): boolean {
const lower = message.toLowerCase();
return selectedSources.some((source) => {
const title = (source.title || source.file_name || "")
.toLowerCase()
.replace(/\.[a-z0-9]+$/i, "")
.trim();
return title.length >= 4 && lower.includes(title);
});
}
function toolTypeForMessage(lower: string): StudyIntent | null {
if (PYQ_RE.test(lower)) return "analyze_pyq";
if (NOTES_RE.test(lower)) return "generate_notes";
if (QUIZ_RE.test(lower)) return "generate_quiz";
if (FLASHCARD_RE.test(lower)) return "generate_flashcards";
if (VIDEO_RE.test(lower)) return "create_video_plan";
if (STUDY_PLAN_RE.test(lower)) return "create_revision_plan";
if (EXAM_ANSWER_RE.test(lower)) return "generate_exam_answer";
return null;
}
function uploadPromptForTool(toolType: StudyIntent | null): string {
if (toolType === "analyze_pyq" || toolType === "predict_questions") {
return "Upload previous question papers first. I'll find repeated topics, marks weightage, trends, and likely questions from those papers.";
}
if (toolType === "generate_notes" || toolType === "summarize_source") {
return "Tell me the topic, paste text, or upload a chapter. I can create scoring notes from any of those.";
}
if (toolType === "create_video_plan" || toolType === "render_video") {
return "Tell me the topic first, or select a source if you want the video matched to your material.";
}
return "Tell me the topic, goal, or time left. Uploading is optional unless you want source-matched output.";
}
function mapAnswerModeToResponseMode(mode: AnswerMode): ResponseMode {
switch (mode) {
case "casual_chat":
return "casual";
case "source_required_upload_prompt":
return "needs_source";
case "source_grounded_answer":
return "source_grounded";
case "tool_generation":
return "tool_ready";
case "clarification":
return "clarify";
case "web_search_answer":
case "general_ai_answer":
default:
return "general_answer";
}
}
function promptForMode(result: AnswerModeResult): string | null {
if (result.mode === "source_required_upload_prompt") {
return result.userFacingNextStep;
}
return null;
}
export function detectAnswerMode({
message,
selectedSources = [],
activeTool = defaultStudyIntent,
conversationContext = [],
webSearchEnabled = false,
}: {
message: string;
selectedSources?: readonly AnswerModeSource[];
activeTool?: StudyIntent;
conversationContext?: readonly ChatMessage[];
webSearchEnabled?: boolean;
}): AnswerModeResult {
const trimmed = message.trim();
// Pure greeting ("hi", "hello bro") or "Hi, my name is X" stay casual.
if (GREETING_RE.test(trimmed) || CASUAL_INTRO_RE.test(trimmed)) {
return {
mode: "casual_chat",
intent: "casual",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: false,
topic: null,
toolType: null,
userFacingNextStep: null,
};
}
// "Hi teach me +2 matrix maths" β€” a greeting prefix in front of a REAL ask.
// Strip the greeting and process the actual request, otherwise a friendly
// "Hi" would downgrade a genuine study question to the casual welcome card.
let working = trimmed;
const greetingPrefixMatch = GREETING_PREFIX_RE.exec(trimmed);
if (greetingPrefixMatch) {
const rest = trimmed.slice(greetingPrefixMatch[0].length).trim();
if (rest.length >= 2 && !GREETING_RE.test(rest)) {
working = rest;
}
}
const lower = working.toLowerCase();
const hasSource = selectedSources.length > 0;
const topic = extractTopic(working);
const explicitSourceRequest = isDocRef(working) || sourceTitleMentioned(working, selectedSources);
const toolType = toolTypeForMessage(lower);
const asksForCurrentInfo = asksForLiveWebInformation(lower, hasSource);
if (CAPABILITY_RE.test(working)) {
return {
mode: "casual_chat",
intent: "help",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: false,
topic: null,
toolType: null,
userFacingNextStep: null,
};
}
if (UNCLEAR_RE.test(working)) {
const lastAssistant = [...conversationContext].reverse().find(
(m) => m.role === "assistant" && m.card && m.card.kind !== "loading" && m.card.kind !== "error",
);
if (lastAssistant && /^(yes|yep|yeah|sure|do it|go ahead|continue|next|proceed)$/i.test(working)) {
return {
mode: "general_ai_answer",
intent: "continuation",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: true,
topic: null,
toolType: activeTool,
userFacingNextStep: null,
};
}
if (/^(ok(?:ay)?|thanks?|thank you|cool|nice|got it|fine)$/i.test(working)) {
return {
mode: "casual_chat",
intent: "casual",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: false,
topic: null,
toolType: null,
userFacingNextStep: null,
};
}
return {
mode: "clarification",
intent: "ambiguous",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: false,
topic: null,
toolType: activeTool,
userFacingNextStep: "Tell me the topic, or upload/select a source if you want textbook-matched output.",
};
}
if (explicitSourceRequest && !hasSource) {
return {
mode: "source_required_upload_prompt",
intent: toolType ? "tool_request" : "source_specific",
requiresSource: true,
shouldUseWeb: false,
canAnswerGenerally: false,
topic,
toolType,
userFacingNextStep: toolType ? uploadPromptForTool(toolType) : "Upload/select the PDF or source first, then I'll explain it clearly.",
};
}
// A live/current-information request is an evidence route, not a study-plan
// generator merely because the sentence also contains "timetable" or
// "schedule". Put it ahead of tool detection so official notices and exam
// dates reach web retrieval. Personal requests such as "update my plan" and
// "what should I study today?" do not satisfy the narrow web predicate.
if (asksForCurrentInfo) {
return {
mode: "web_search_answer",
intent: "current_info",
requiresSource: false,
shouldUseWeb: webSearchEnabled,
canAnswerGenerally: true,
topic: topic ?? working.slice(0, 80),
toolType: null,
userFacingNextStep: webSearchEnabled ? null : "I can answer generally, but live internet search is not connected yet.",
};
}
if (toolType) {
if (toolType === "create_video_plan" && topic && PLACEHOLDER_TOPIC_RE.test(topic) && !hasSource) {
return {
mode: "clarification",
intent: "tool_request",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: false,
topic: null,
toolType,
userFacingNextStep: uploadPromptForTool(toolType),
};
}
// PYQ: always run the tool β€” it returns REAL downloadable Kerala previous
// papers (no source needed). If a source IS attached it also analyzes it.
if (toolType === "analyze_pyq" || toolType === "predict_questions") {
return {
mode: "tool_generation",
intent: "tool_request",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: true,
topic,
toolType,
userFacingNextStep: null,
};
}
if (!topic && !hasSource && !asksForCurrentInfo) {
if (toolType === "create_revision_plan") {
return {
mode: "tool_generation",
intent: "tool_request",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: true,
topic: "your topic",
toolType,
userFacingNextStep: null,
};
}
return {
mode: "clarification",
intent: "tool_request",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: false,
topic: null,
toolType,
userFacingNextStep: "Tell me the topic first. You can type it, paste text, or upload a source if you want textbook-matched output.",
};
}
return {
mode: "tool_generation",
intent: "tool_request",
requiresSource: explicitSourceRequest || (!topic && hasSource),
shouldUseWeb: asksForCurrentInfo && webSearchEnabled,
canAnswerGenerally: Boolean(topic) || !explicitSourceRequest,
topic: topic ?? working.slice(0, 80),
toolType,
userFacingNextStep: asksForCurrentInfo && !webSearchEnabled
? "I can answer generally, but live internet search is not connected yet."
: null,
};
}
if (explicitSourceRequest && hasSource) {
return {
mode: "source_grounded_answer",
intent: "source_specific",
requiresSource: true,
shouldUseWeb: false,
canAnswerGenerally: false,
topic,
toolType: null,
userFacingNextStep: null,
};
}
return {
mode: "general_ai_answer",
intent: "general_topic",
requiresSource: false,
shouldUseWeb: false,
canAnswerGenerally: true,
topic: topic ?? working.slice(0, 80),
toolType: activeTool === defaultStudyIntent ? "teach_topic" : activeTool,
userFacingNextStep: null,
};
}
// ── Main export ───────────────────────────────────────────────────────────────
function buildCasualReply(
intent: string,
selectedSources: ApiDocument[],
message: string,
): string {
const hasSource = selectedSources.length > 0;
const sourceHint = hasSource
? `You have ${selectedSources.length} source${selectedSources.length > 1 ? "s" : ""} ready.`
: "Upload a textbook, notes, or question paper, or just ask me any topic.";
// Identity / name question
if (
/\b(what(?:'s|\s+is)\s+your\s+name|who\s+are\s+you|are\s+you\s+(an?\s+)?(ai|bot|chatbot|robot)|what\s+are\s+you)\b/i.test(message)
) {
return `I'm **DocDoe Knowledge Brain** β€” an exam intelligence system for Indian board and competitive exams.\n\nI retrieve from your uploaded sources, apply mark-scheme logic, and generate explanations, notes, quizzes, flashcards, PYQ analysis, study paths, and video plans.\n\n${sourceHint}`;
}
// General greeting
if (/^\s*(hi+|hey+|hello+|yo+|sup|bro|howdy|hola|ready|start)\b/i.test(message)) {
return hasSource
? `${selectedSources.length} source${selectedSources.length > 1 ? "s are" : " is"} active. Ask a question, or generate notes, a quiz, flashcards, a revision plan, or a video plan from your material.`
: "DocDoe Knowledge Brain is ready. Ask any topic for a grounded explanation, or upload a textbook, notes, or previous paper for source-matched exam output.";
}
// Capability / help
if (intent === "help") {
return `DocDoe provides seven study workflows: chapter explanations, scoring notes, practice quizzes, flashcards, PYQ pattern analysis, revision plans, and study video plans.\n\nAttach a source for textbook-matched answers, or ask a topic for general exam intelligence.`;
}
// Generic acknowledgement (ok, thanks, etc.)
return `Understood. What should we work on next β€” explain a topic, generate notes, run a quiz, or attach a source?`;
}
export function detectStudyChatIntent(
message: string,
selectedSources: ApiDocument[],
activeTool: StudyIntent,
conversationContext: ChatMessage[],
): ChatIntentResult {
const detection = detectAnswerMode({
message,
selectedSources,
activeTool,
conversationContext,
webSearchEnabled: false,
});
const responseMode = mapAnswerModeToResponseMode(detection.mode);
// For casual mode: always build an immediateReply so the welcome card renders
// without hitting the backend. For source prompts, use the structured CTA.
let immediateReply: string | null = null;
if (detection.mode === "casual_chat") {
immediateReply = buildCasualReply(detection.intent, selectedSources, message);
} else {
immediateReply = promptForMode(detection);
}
// When no tool is detected, a fresh general/source answer must NOT inherit a
// stale tool from the previous turn (e.g. asking a topic right after a PYQ
// request). Default to teach_topic / explain_source instead of activeTool.
const intent =
detection.toolType ??
(detection.mode === "source_grounded_answer" || detection.mode === "source_required_upload_prompt"
? "explain_source"
: "teach_topic");
return {
intent,
responseMode,
answerMode: detection.mode,
shouldUseWeb: detection.shouldUseWeb,
userFacingNextStep: detection.userFacingNextStep,
requiresSource: detection.requiresSource,
canAnswerGenerally: detection.canAnswerGenerally,
immediateReply,
topic: detection.topic,
};
}