/** * 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: " ... on/for/about ". // 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: " 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, }; }