Spaces:
Sleeping
Sleeping
| <script lang="ts"> | |
| import { onMount } from 'svelte' | |
| import { questions, rfpData, nextStep, prevStep, isLoading, referenceDocuments } from '../stores/appStore' | |
| import { generateAnswer as generateAIAnswer } from '../utils/aiGenerator' | |
| import { backendService } from '../utils/backendService' | |
| import { suggestionEngine } from '../utils/suggestionEngine' | |
| import { vectorStorage } from '../utils/vectorStorage' | |
| import type { RFPAnswer } from '../stores/appStore' | |
| import RFPAnalyzer from './RFPAnalyzer.svelte' | |
| let selectedQuestions: Set<number> = new Set() | |
| let generatedAnswers: RFPAnswer[] = [] | |
| let selectedCategory: string = 'all' // Default to all categories | |
| let currentQuestionIndex: number = 0 | |
| let showAnalyzer: boolean = false // Toggle for RFP Analyzer view | |
| // Answer type selection | |
| let selectedAnswerType: string = 'ai' // 'ai' for AI answer, 'previous' for previous answer | |
| $: questionsToProcess = $questions | |
| $: allSelected = selectedQuestions.size === questionsToProcess.length | |
| $: currentQuestion = questionsToProcess[currentQuestionIndex] | |
| $: currentAnswerId = `answer-${currentQuestionIndex}` | |
| $: currentAnswer = $rfpData.answers?.find(a => a.id === currentAnswerId) | |
| // Track formatted answers | |
| let formattedAnswers = new Map() | |
| let formattingInProgress = new Set() | |
| // Additional information for AI processing | |
| let additionalInfo = "" | |
| // Auto-generate answers when current question changes | |
| $: if (currentQuestion && !currentAnswer && !$isLoading && $rfpData.extractedData?.length > 0) { | |
| console.log(`🤖 Auto-generating answer for question ${currentQuestionIndex + 1}: "${currentQuestion.text.substring(0, 50)}..."`) | |
| generateAnswer() | |
| } | |
| // Auto-select all questions when questions change | |
| $: if (questionsToProcess.length > 0 && selectedQuestions.size === 0) { | |
| selectedQuestions = new Set(questionsToProcess.map((_, index) => index)) | |
| } | |
| // Get available categories from reference documents | |
| $: availableCategories = [...new Set($referenceDocuments.map(doc => doc.category))].filter(Boolean) | |
| function toggleQuestion(index: number) { | |
| if (selectedQuestions.has(index)) { | |
| selectedQuestions.delete(index) | |
| } else { | |
| selectedQuestions.add(index) | |
| } | |
| selectedQuestions = new Set(selectedQuestions) | |
| } | |
| function toggleAllQuestions() { | |
| if (allSelected) { | |
| selectedQuestions.clear() | |
| } else { | |
| selectedQuestions = new Set(questionsToProcess.map((_, index) => index)) | |
| } | |
| selectedQuestions = new Set(selectedQuestions) | |
| } | |
| function nextQuestion() { | |
| if (currentQuestionIndex < questionsToProcess.length - 1) { | |
| currentQuestionIndex++ | |
| } | |
| } | |
| function prevQuestion() { | |
| if (currentQuestionIndex > 0) { | |
| currentQuestionIndex-- | |
| } | |
| } | |
| function goToQuestion(index: number) { | |
| currentQuestionIndex = index | |
| } | |
| // Function to reprocess answer with additional information | |
| async function reprocessAnswerWithAdditionalInfo(answer) { | |
| if (!answer || !additionalInfo.trim()) return | |
| isLoading.set(true) | |
| console.log('🔄 Reprocessing answer with additional information:', additionalInfo.trim()) | |
| try { | |
| // Combine original answer with additional information | |
| const enhancedContext = `${answer.aiGeneratedAnswer || answer.finalAnswer} | |
| Additional Requirements/Context: | |
| ${additionalInfo.trim()}` | |
| // Format the enhanced answer using the AI API | |
| const formatResult = await backendService.formatAnswer( | |
| answer.question, | |
| enhancedContext, | |
| `Enhanced with user input: ${additionalInfo.trim().substring(0, 100)}...` | |
| ) | |
| if (formatResult.success) { | |
| // Update the answer with the enhanced version | |
| rfpData.update(data => { | |
| const answers = data.answers || [] | |
| const answerIndex = answers.findIndex(a => a.id === answer.id) | |
| if (answerIndex >= 0) { | |
| answers[answerIndex] = { | |
| ...answers[answerIndex], | |
| finalAnswer: formatResult.formattedAnswer, | |
| isFormatted: true, | |
| hasAdditionalInfo: true, | |
| additionalInfo: additionalInfo.trim() | |
| } | |
| } | |
| return { ...data, answers } | |
| }) | |
| // Clear the additional info input | |
| additionalInfo = "" | |
| console.log('✅ Answer reprocessed successfully with additional information') | |
| } else { | |
| console.warn('Failed to reprocess answer:', formatResult) | |
| } | |
| } catch (error) { | |
| console.error('Error reprocessing answer with additional info:', error) | |
| } finally { | |
| isLoading.set(false) | |
| } | |
| } | |
| // Function to format a previous answer using AI | |
| async function formatPreviousAnswer(answer, questionText) { | |
| const answerId = `${answer.docName}-${answer.question.substring(0, 50)}` | |
| if (formattingInProgress.has(answerId)) { | |
| console.log('⏳ Already formatting this answer:', answerId) | |
| return // Already formatting this answer | |
| } | |
| if (formattedAnswers.has(answerId)) { | |
| console.log('✅ Answer already formatted:', answerId) | |
| return formattedAnswers.get(answerId) // Already formatted | |
| } | |
| console.log('🚀 Starting format for answer:', answerId) | |
| formattingInProgress.add(answerId) | |
| formattingInProgress = formattingInProgress // Trigger reactivity | |
| try { | |
| console.log('📝 Formatting previous answer:', answer.question.substring(0, 60) + '...') | |
| console.log('📄 Answer text length:', answer.answer?.length || 0) | |
| console.log('🔧 Question text:', questionText || answer.question) | |
| const result = await backendService.formatAnswer( | |
| questionText || answer.question, | |
| answer.answer, | |
| `Document: ${answer.docName}, Page: ${answer.pageNumber}` | |
| ) | |
| console.log('📥 Format result received:', result) | |
| if (result.success) { | |
| const formatted = { | |
| ...answer, | |
| formattedAnswer: result.formattedAnswer, | |
| originalAnswer: result.originalAnswer | |
| } | |
| formattedAnswers.set(answerId, formatted) | |
| formattedAnswers = formattedAnswers // Trigger reactivity | |
| console.log('✅ Successfully formatted answer for:', answerId) | |
| return formatted | |
| } else { | |
| console.warn('⚠️ Failed to format answer:', result) | |
| return answer | |
| } | |
| } catch (error) { | |
| console.error('❌ Error formatting previous answer:', error) | |
| console.error('❌ Error details:', { | |
| message: error.message, | |
| stack: error.stack, | |
| answerId, | |
| questionText: questionText || answer.question | |
| }) | |
| return answer | |
| } finally { | |
| console.log('🏁 Cleaning up format process for:', answerId) | |
| formattingInProgress.delete(answerId) | |
| formattingInProgress = formattingInProgress // Trigger reactivity | |
| } | |
| } | |
| // Function to extract relevant previous answers from reference documents | |
| function getPreviousAnswers(questionText: string) { | |
| console.log('🔍 HYBRID SEARCH: Regex + Vector for question:', questionText) | |
| if (!questionText || questionText.length < 10) { | |
| console.log('❌ Question too short for search') | |
| return [] | |
| } | |
| const allMatches = [] | |
| // ============================================ | |
| // STAGE 1: REGEX PATTERN MATCHING (Fast & Precise) | |
| // ============================================ | |
| console.log('📋 STAGE 1: Trying regex pattern matching...') | |
| if (referenceDocsWithContent && referenceDocsWithContent.length > 0) { | |
| for (const doc of referenceDocsWithContent) { | |
| if (selectedCategory !== 'all' && doc.category !== selectedCategory) { | |
| continue | |
| } | |
| if (!doc.content || doc.content.length < 50) continue | |
| // Try to find exact question match using regex | |
| const questionAnswerPairs = extractQuestionAnswerPairs(doc.content, doc.name, questionText) | |
| for (const pair of questionAnswerPairs) { | |
| // Check for exact or very high similarity match | |
| const similarity = calculateSimpleTextSimilarity(questionText.toLowerCase(), pair.question.toLowerCase()) | |
| const percentage = Math.round(similarity * 100) | |
| if (percentage >= 85) { // High confidence regex match | |
| console.log(` ✅ REGEX MATCH: ${doc.name} - ${percentage}% - "${pair.question.substring(0, 60)}..."`) | |
| allMatches.push({ | |
| docName: doc.name, | |
| question: pair.question, | |
| answer: pair.answer, | |
| content: `Q: ${pair.question}\n\nA: ${pair.answer}`, | |
| matchScore: similarity, | |
| matchPercentage: percentage, | |
| category: doc.category || 'General', | |
| pageNumber: pair.pageNumber || 1, | |
| sectionType: percentage >= 95 ? 'exact-match' : 'high-confidence-match', | |
| source: 'regex-pattern', | |
| isExactMatch: percentage >= 95, | |
| extractionStrategy: pair.extractionStrategy || 'regex' | |
| }) | |
| // If exact match (95%+), return immediately - no need for vector search | |
| if (percentage >= 95) { | |
| console.log('🎯 EXACT REGEX MATCH - Returning immediately') | |
| return [allMatches[allMatches.length - 1]] | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // STAGE 2: VECTOR SEARCH (Semantic & Fuzzy) | |
| // ============================================ | |
| console.log('🧠 STAGE 2: Trying vector semantic search...') | |
| try { | |
| const searchOptions = { | |
| topK: 10, // Increased from 5 to get more results | |
| categoryFilter: selectedCategory === 'all' ? undefined : selectedCategory, | |
| expandQuery: true | |
| } | |
| const searchResults = vectorStorage.semanticSearch(questionText, searchOptions) | |
| console.log(` ✅ Found ${searchResults.length} vector matches`) | |
| // Add vector results | |
| searchResults.forEach(result => { | |
| const score = Math.round(result.score * 100) | |
| console.log(` 📄 ${result.chunk.metadata.source}: ${score}% match`) | |
| console.log(` 🔑 Keywords: ${result.matchedKeywords.join(', ')}`) | |
| allMatches.push({ | |
| docName: result.chunk.metadata.source, | |
| question: 'Relevant Content', // Vector finds content, not Q&A | |
| answer: result.chunk.content, | |
| content: result.chunk.content, | |
| matchScore: result.score, | |
| matchPercentage: score, | |
| category: result.chunk.metadata.category || 'General', | |
| matchedKeywords: result.matchedKeywords, | |
| pageNumber: result.chunk.metadata.pageNumber || 1, | |
| chunkIndex: result.chunk.metadata.chunkIndex, | |
| sectionType: 'vector-match', | |
| source: 'vector-search', | |
| isExactMatch: false | |
| }) | |
| }) | |
| } catch (error) { | |
| console.error('❌ Vector search failed:', error) | |
| } | |
| // ============================================ | |
| // COMBINE & RANK RESULTS | |
| // ============================================ | |
| console.log(`📊 Total matches: ${allMatches.length} (regex + vector)`) | |
| // Log all match scores for debugging | |
| if (allMatches.length > 0) { | |
| console.log('📈 All match scores:') | |
| allMatches.forEach(m => { | |
| console.log(` ${m.docName}: ${m.matchPercentage}% (${m.source})`) | |
| }) | |
| } | |
| // Filter weak matches - lowered to 10% to catch semantic matches | |
| const strongMatches = allMatches.filter(match => match.matchPercentage >= 10) | |
| if (strongMatches.length === 0) { | |
| console.log('⚠️ No strong matches found (minimum 10% required)') | |
| return [] | |
| } | |
| console.log(`✅ ${strongMatches.length} matches above 10% threshold`) | |
| // Prioritize regex matches, then vector matches | |
| const sortedMatches = strongMatches.sort((a, b) => { | |
| // Exact matches first | |
| if (a.isExactMatch && !b.isExactMatch) return -1 | |
| if (!a.isExactMatch && b.isExactMatch) return 1 | |
| // Regex matches before vector matches | |
| if (a.source === 'regex-pattern' && b.source === 'vector-search') return -1 | |
| if (a.source === 'vector-search' && b.source === 'regex-pattern') return 1 | |
| // Then by score | |
| return b.matchPercentage - a.matchPercentage | |
| }) | |
| const topMatches = sortedMatches.slice(0, 3) | |
| console.log('🏆 Top 3 matches:') | |
| topMatches.forEach(m => { | |
| console.log(` ${m.source === 'regex-pattern' ? '📋' : '🧠'} ${m.docName}: ${m.matchPercentage}% (${m.source})`) | |
| }) | |
| return topMatches | |
| } | |
| // Simple text similarity for regex results | |
| function calculateSimpleTextSimilarity(text1: string, text2: string): number { | |
| const words1 = text1.split(/\s+/).filter(w => w.length > 2) | |
| const words2 = text2.split(/\s+/).filter(w => w.length > 2) | |
| const set1 = new Set(words1) | |
| const set2 = new Set(words2) | |
| let matchCount = 0 | |
| set1.forEach(word => { | |
| if (set2.has(word)) matchCount++ | |
| }) | |
| return matchCount / Math.max(set1.size, set2.size) | |
| } | |
| // Function to deduplicate similar answers | |
| function deduplicateAnswers(matches: any[]) { | |
| const uniqueMatches = [] | |
| const seenAnswers = new Set() | |
| for (const match of matches) { | |
| // Create a normalized version of the answer for comparison | |
| const normalizedAnswer = match.answer?.toLowerCase() | |
| .replace(/\s+/g, ' ') | |
| .replace(/[^\w\s]/g, '') | |
| .trim() || '' | |
| // Skip if we've seen a very similar answer | |
| let isDuplicate = false | |
| for (const seenAnswer of seenAnswers) { | |
| const similarity = calculateTextSimilarity(normalizedAnswer, seenAnswer) | |
| if (similarity > 0.8) { // 80% similarity threshold | |
| isDuplicate = true | |
| break | |
| } | |
| } | |
| if (!isDuplicate && normalizedAnswer.length > 50) { | |
| uniqueMatches.push(match) | |
| seenAnswers.add(normalizedAnswer) | |
| } | |
| } | |
| return uniqueMatches | |
| } | |
| // Simple text similarity calculation | |
| function calculateTextSimilarity(text1: string, text2: string): number { | |
| const words1 = new Set(text1.split(' ').filter(w => w.length > 3)) | |
| const words2 = new Set(text2.split(' ').filter(w => w.length > 3)) | |
| const intersection = new Set([...words1].filter(x => words2.has(x))) | |
| const union = new Set([...words1, ...words2]) | |
| return union.size > 0 ? intersection.size / union.size : 0 | |
| } | |
| // Enhanced question-answer pair extraction | |
| function extractQuestionAnswerPairs(content: string, docName: string, searchQuestion?: string) { | |
| const pairs = [] | |
| console.log(`📄 Extracting Q&A pairs from ${docName}...`) | |
| // FIX: Normalize PDF text that has spaces between every character | |
| // Common OCR/PDF extraction issue: "E d g e M a r k e t" -> "EdgeMarket" | |
| // Check if content has this pattern | |
| const sampleText = content.substring(0, 500) | |
| const spacedChars = (sampleText.match(/\b[A-Za-z]\s(?=[A-Za-z]\s)/g) || []).length | |
| const totalChars = sampleText.replace(/\s/g, '').length | |
| // If more than 20% of characters are spaced individually, fix it | |
| if (spacedChars > totalChars * 0.2) { | |
| console.log(` 🔧 Detected spaced character formatting (${Math.round(spacedChars/totalChars*100)}% spaced), fixing...`) | |
| // Remove spaces between individual letters while preserving word boundaries | |
| // "S h e l b y C o u n t y" -> "Shelby County" | |
| content = content.replace(/([A-Za-z])\s+(?=[A-Za-z](?:\s[A-Za-z]|\b))/g, '$1') | |
| // Clean up excessive whitespace | |
| content = content.replace(/\s{3,}/g, ' ').replace(/\s{2}/g, ' ') | |
| console.log(` ✅ Text normalized, sample: "${content.substring(0, 100)}"`) | |
| } | |
| // Strategy 1: Split by numbered questions (1., 2., 3., etc.) | |
| const numberedSections = content.split(/(?=^\s*\d+\.)/m) | |
| for (let i = 0; i < numberedSections.length; i++) { | |
| const section = numberedSections[i].trim() | |
| if (section.length < 20) continue | |
| // Extract question from numbered format | |
| const questionMatch = section.match(/^\s*(\d+)\.\s*(.+?)(?:\n\n|\n[A-Z]|\n\d+\.|\s{3,}|$)/s) | |
| if (questionMatch) { | |
| const questionNumber = questionMatch[1] | |
| const questionText = questionMatch[2].trim() | |
| // Extract answer (everything after the question) | |
| let answerText = section.replace(questionMatch[0], '').trim() | |
| // Clean up answer text | |
| answerText = answerText.replace(/^\n+/, '').replace(/\n+$/, '') | |
| if (questionText.length > 10 && answerText.length > 20) { | |
| pairs.push({ | |
| questionNumber: parseInt(questionNumber), | |
| question: questionText, | |
| answer: answerText, | |
| pageNumber: Math.max(1, Math.ceil(i / 300)) + 1, // More realistic page estimate | |
| sectionIndex: i | |
| }) | |
| console.log(` ✅ Q${questionNumber}: "${questionText.substring(0, 50)}..." -> ${answerText.length} chars`) | |
| } | |
| } | |
| } | |
| // Strategy 2: Look for Q: A: patterns | |
| const qaMatches = content.match(/Q[:\s]+(.*?)(?:\n|\r\n)A[:\s]+(.*?)(?=Q[:\s]+|$)/gs) | |
| if (qaMatches) { | |
| qaMatches.forEach((match, index) => { | |
| const parts = match.match(/Q[:\s]+(.*?)(?:\n|\r\n)A[:\s]+(.*)/s) | |
| if (parts) { | |
| pairs.push({ | |
| questionNumber: pairs.length + 1, | |
| question: parts[1].trim(), | |
| answer: parts[2].trim(), | |
| pageNumber: Math.max(1, Math.ceil(index / 300)) + 1, // More realistic page estimate | |
| sectionIndex: index | |
| }) | |
| } | |
| }) | |
| } | |
| // Strategy 3: Look for section headers with content (for RFP documents) | |
| // Split content into paragraphs and look for header-like text followed by content | |
| const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 0) | |
| for (let i = 0; i < paragraphs.length - 1; i++) { | |
| const currentPara = paragraphs[i].trim() | |
| const nextPara = paragraphs[i + 1]?.trim() | |
| // Check if current paragraph looks like a question/header | |
| const isQuestionLike = ( | |
| currentPara.length > 10 && currentPara.length < 200 && // Reasonable length for question | |
| (currentPara.includes('?') || // Contains question mark | |
| currentPara.match(/^[A-Z][^.!?]*[A-Za-z]$/) || // Capitalized statement without ending punctuation | |
| currentPara.match(/Company|Background|Experience|Profile|Founded|Describe|Provide|Explain/i)) && // Common RFP terms | |
| !currentPara.match(/^\d+\./) && // Not already a numbered question | |
| nextPara && nextPara.length > 50 // Has substantial content following | |
| ) | |
| if (isQuestionLike && nextPara) { | |
| // Collect answer content (current and potentially next few paragraphs) | |
| let answerContent = nextPara | |
| let j = i + 2 | |
| while (j < paragraphs.length && j < i + 5) { // Look ahead max 3 more paragraphs | |
| const para = paragraphs[j]?.trim() | |
| if (para && para.length > 20 && !para.match(/^[A-Z][^.!?]*[A-Za-z]$/)) { | |
| answerContent += '\n\n' + para | |
| j++ | |
| } else { | |
| break | |
| } | |
| } | |
| if (answerContent.length > 50) { | |
| pairs.push({ | |
| questionNumber: pairs.length + 1, | |
| question: currentPara, | |
| answer: answerContent, | |
| pageNumber: Math.max(1, Math.ceil(i / 250)) + 1, // More realistic page estimate | |
| sectionIndex: i, | |
| extractionStrategy: 'section-header' | |
| }) | |
| console.log(` ✅ Header Q: "${currentPara.substring(0, 50)}..." -> ${answerContent.length} chars`) | |
| } | |
| } | |
| } | |
| // Strategy 4: Direct text matching - Look for the exact question in content | |
| const currentQuestionText = searchQuestion?.trim() | |
| if (currentQuestionText && currentQuestionText.length > 10) { | |
| const searchText = currentQuestionText.toLowerCase() | |
| let contentLower = content.toLowerCase() | |
| // SKIP PDF FRONT MATTER - Find where real content starts | |
| const executiveSummaryIndex = contentLower.indexOf('executive summar') | |
| const tabCIndex = contentLower.indexOf('tab c:') | |
| const contentStartIndex = Math.max(0, executiveSummaryIndex, tabCIndex) | |
| if (contentStartIndex > 0) { | |
| console.log(`📄 Skipping ${contentStartIndex} chars of front matter (cover page, TOC)`) | |
| content = content.substring(contentStartIndex) | |
| contentLower = content.toLowerCase() | |
| } | |
| const exactMatchIndex = contentLower.indexOf(searchText) | |
| if (exactMatchIndex !== -1) { | |
| // Find the end of the question text in the document | |
| const questionEndIndex = exactMatchIndex + searchText.length | |
| // Extract answer content starting after the question | |
| let answerStartIndex = questionEndIndex | |
| // Skip any immediate whitespace or formatting characters after the question | |
| while (answerStartIndex < content.length && | |
| (content[answerStartIndex] === ' ' || content[answerStartIndex] === '\n' || | |
| content[answerStartIndex] === '\r' || content[answerStartIndex] === '\t')) { | |
| answerStartIndex++ | |
| } | |
| // Extract ONLY the immediate answer content below the question | |
| let answerEndIndex = answerStartIndex | |
| let sentenceCount = 0 | |
| let paragraphCount = 0 | |
| let foundContent = false | |
| // Look for a focused answer - stop at logical boundaries | |
| while (answerEndIndex < content.length) { | |
| const currentChar = content[answerEndIndex] | |
| const nextChars = content.substring(answerEndIndex, answerEndIndex + 100).toLowerCase() | |
| const currentSection = content.substring(answerStartIndex, answerEndIndex) | |
| // Count sentences and paragraphs to limit content | |
| if (currentChar === '.' || currentChar === '!' || currentChar === '?') { | |
| sentenceCount++ | |
| } | |
| if (currentChar === '\n' && content[answerEndIndex + 1] === '\n') { | |
| paragraphCount++ | |
| } | |
| // CONSERVATIVE stopping criteria - extract ONLY direct answer | |
| if (foundContent && ( | |
| // Stop at new questions or major sections | |
| nextChars.match(/^\s*\d+\.\s*[A-Z]/) || // New numbered question/section | |
| nextChars.match(/^[A-Z][A-Z\s&]{8,}:/) || // Section headers like "COMPANY DETAILS:" | |
| nextChars.includes('question:') || | |
| nextChars.includes('response:') || | |
| nextChars.includes('answer:') || | |
| nextChars.match(/^\s*q\d+/i) || // Q1, Q2, etc. | |
| nextChars.match(/^\s*section\s+[a-z\d]/i) || // Section markers | |
| // Stop at metadata sections and tables | |
| nextChars.includes('company details') || | |
| nextChars.includes('year founded') || | |
| nextChars.includes('total employees') || | |
| nextChars.includes('contact information') || | |
| nextChars.includes('business address') || | |
| nextChars.includes('incorporation') || | |
| nextChars.match(/entity\s+duration/i) || // Contract tables | |
| nextChars.match(/here is a sample/i) || // Sample sections | |
| nextChars.includes('entity') && nextChars.includes('duration') && nextChars.includes('value') || | |
| // MUCH MORE CONSERVATIVE - Stop after short focused answer | |
| sentenceCount >= 3 || // Max 3 sentences only | |
| paragraphCount >= 2 || // Max 2 paragraphs only | |
| (answerEndIndex - answerStartIndex > 500) || // Max 500 chars (very focused!) | |
| // Stop at table-like data or lists of specs | |
| nextChars.match(/^\s*\|\s*\w+\s*\|/) || // Table rows | |
| nextChars.match(/^\s*[•·-]\s*\w+:/) || // Spec lists | |
| // Stop at new page indicators or RFP metadata | |
| nextChars.includes('page ') || | |
| nextChars.includes('continued on') || | |
| nextChars.includes('tab ') || | |
| nextChars.match(/rfp #\d/i) || | |
| nextChars.includes('proposal due date') | |
| )) { | |
| break | |
| } | |
| if (currentChar.trim() !== '' && !currentChar.match(/[\r\n\t]/)) { | |
| foundContent = true | |
| } | |
| answerEndIndex++ | |
| } | |
| // Extract and format the answer content | |
| let answerContent = content.substring(answerStartIndex, answerEndIndex) | |
| // Basic cleanup first | |
| answerContent = answerContent | |
| .replace(/\s+/g, ' ') // Normalize whitespace | |
| .replace(/\s([.,!?;:])/g, '$1') // Fix punctuation spacing | |
| .replace(/([.!?])\s*([A-Z])/g, '$1\n\n$2') // Add paragraph breaks after sentences | |
| .replace(/(\d+\.)\s*([A-Z])/g, '\n\n$1 $2') // Format numbered lists | |
| .replace(/([·•])\s*/g, '\n• ') // Format bullet points | |
| .replace(/\n\s*\n\s*\n+/g, '\n\n') // Clean up multiple line breaks | |
| .trim() | |
| // Enhanced AI formatting using Ollama (async - will be processed later) | |
| const processAnswerWithAI = async (rawAnswer: string, question: string) => { | |
| try { | |
| console.log(`Formatting answer with AI for: "${question.substring(0, 50)}..."`); | |
| const formatResult = await backendService.formatAnswer(question, rawAnswer, docName); | |
| if (formatResult.success && formatResult.formattedAnswer !== rawAnswer) { | |
| console.log(`AI formatting improved answer quality`); | |
| // Update the stored answer with formatted version | |
| const pairIndex = pairs.findIndex(p => p.question === question); | |
| if (pairIndex !== -1) { | |
| pairs[pairIndex].answer = formatResult.formattedAnswer; | |
| pairs[pairIndex].aiFormatted = true; | |
| pairs[pairIndex].originalAnswer = rawAnswer; | |
| } | |
| } | |
| } catch (error) { | |
| console.warn('AI formatting failed, using basic cleanup:', error); | |
| } | |
| }; | |
| // Estimate page number more accurately (assuming ~3000 chars per page) | |
| const estimatedPage = Math.floor(exactMatchIndex / 3000) + 1 | |
| const answerPair = { | |
| questionNumber: pairs.length + 1, | |
| question: currentQuestionText, | |
| answer: answerContent, | |
| pageNumber: estimatedPage, | |
| sectionIndex: pairs.length, | |
| extractionStrategy: 'exact-match', | |
| matchScore: 100, | |
| documentSource: docName, | |
| exactMatch: true, | |
| aiFormatted: false, | |
| originalAnswer: answerContent | |
| }; | |
| pairs.push(answerPair); | |
| // Process with AI formatting in the background | |
| processAnswerWithAI(answerContent, currentQuestionText); | |
| console.log(` ✅ EXACT MATCH: "${currentQuestionText.substring(0, 50)}..." -> 100% match found on page ${estimatedPage}`) | |
| console.log(` 📄 Source: ${docName}`) | |
| console.log(` 📝 Answer length: ${answerContent.length} characters`) | |
| console.log(` 📝 Answer preview: "${answerContent.substring(0, 200)}..."`) | |
| console.log(` 🤖 AI formatting queued for processing`) | |
| // Return early if we found an exact match - no need to look for other strategies | |
| return pairs | |
| } | |
| } | |
| console.log(` 📊 Total extracted: ${pairs.length} Q&A pairs`) | |
| return pairs | |
| } | |
| // Advanced similarity calculation | |
| function calculateAdvancedSimilarity(q1: string, q2: string, pair?: any): number { | |
| const clean1 = q1.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim() | |
| const clean2 = q2.toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim() | |
| // If this pair was extracted with exact match strategy, give it 100% | |
| if (pair?.extractionStrategy === 'exact-match' && pair?.matchScore === 100) { | |
| return 1.0 | |
| } | |
| // Exact match | |
| if (clean1 === clean2) return 1.0 | |
| // Check if one contains the other (high similarity for partial matches) | |
| if (clean1.includes(clean2) || clean2.includes(clean1)) { | |
| const longer = clean1.length > clean2.length ? clean1 : clean2 | |
| const shorter = clean1.length > clean2.length ? clean2 : clean1 | |
| return Math.max(0.85, shorter.length / longer.length) | |
| } | |
| // Word-based similarity | |
| const words1 = clean1.split(' ').filter(w => w.length > 2) | |
| const words2 = clean2.split(' ').filter(w => w.length > 2) | |
| if (words1.length === 0 || words2.length === 0) return 0 | |
| const commonWords = words1.filter(w => words2.includes(w)) | |
| const wordSimilarity = commonWords.length / Math.max(words1.length, words2.length) | |
| // Substring similarity | |
| const longer = clean1.length > clean2.length ? clean1 : clean2 | |
| const shorter = clean1.length > clean2.length ? clean2 : clean1 | |
| let substringScore = 0 | |
| if (longer.includes(shorter)) substringScore = 0.3 | |
| // Key term matching (boost for important business terms) | |
| const keyTerms = ['company', 'background', 'experience', 'profile', 'corporate', 'founded', 'healthcare', 'education', 'staffing', 'management', 'services', 'approach', 'methodology', 'process'] | |
| const keyTermMatches = keyTerms.filter(term => | |
| clean1.includes(term) && clean2.includes(term) | |
| ) | |
| const keyTermScore = keyTermMatches.length * 0.05 | |
| // Length similarity bonus | |
| const lengthRatio = Math.min(clean1.length, clean2.length) / Math.max(clean1.length, clean2.length) | |
| const lengthScore = lengthRatio * 0.1 | |
| const totalSimilarity = wordSimilarity + substringScore + keyTermScore + lengthScore | |
| return Math.min(totalSimilarity, 1.0) | |
| } | |
| // Find matched words between questions | |
| function findMatchedWords(q1: string, q2: string): string[] { | |
| const words1 = q1.toLowerCase().split(/\s+/).filter(w => w.length > 2) | |
| const words2 = q2.toLowerCase().split(/\s+/).filter(w => w.length > 2) | |
| return words1.filter(w => words2.includes(w)) | |
| } | |
| // Content-based matching fallback | |
| function findContentMatches(doc: any, questionText: string) { | |
| const matches = [] | |
| const questionLower = questionText.toLowerCase() | |
| const keyWords = questionLower.split(/\s+/).filter(w => w.length > 3) | |
| const sections = doc.content.split(/\n\s*\n+/) | |
| for (let i = 0; i < sections.length; i++) { | |
| const section = sections[i].trim() | |
| if (section.length < 50) continue | |
| const sectionLower = section.toLowerCase() | |
| let matchCount = 0 | |
| const matchedWords = [] | |
| for (const word of keyWords) { | |
| if (sectionLower.includes(word)) { | |
| matchCount++ | |
| matchedWords.push(word) | |
| } | |
| } | |
| // STRICTER THRESHOLD: Need at least 40% keyword match OR 5+ keywords | |
| const matchPercentage = (matchCount / keyWords.length) * 100 | |
| if (matchCount >= 5 || matchPercentage >= 40) { | |
| const percentage = Math.min(Math.round(matchPercentage), 95) | |
| matches.push({ | |
| docName: doc.name, | |
| question: 'Content Match', | |
| answer: section, | |
| content: section.substring(0, 600) + (section.length > 600 ? '...' : ''), | |
| matchScore: matchCount / keyWords.length, | |
| matchPercentage: percentage, | |
| category: doc.category || 'General', | |
| matchedWords: matchedWords, | |
| pageNumber: Math.max(1, Math.ceil(i / 200)) + 1, // More realistic page estimate | |
| sectionType: 'content-match', | |
| source: 'content-fallback', | |
| isExactMatch: false | |
| }) | |
| } | |
| } | |
| return matches | |
| } | |
| // Get previous answers for current question | |
| $: { | |
| console.log('🔄 Reactive update triggered for previousAnswers') | |
| console.log('Current question:', currentQuestion?.text) | |
| console.log('Reference docs with content available:', referenceDocsWithContent?.length || 0) | |
| if (referenceDocsWithContent && referenceDocsWithContent.length > 0) { | |
| console.log('Content preview:', referenceDocsWithContent[0].content?.substring(0, 100)) | |
| } | |
| } | |
| $: previousAnswers = (currentQuestion && referenceDocsWithContent?.length > 0) ? getPreviousAnswers(currentQuestion.text) : [] | |
| // Load reference documents with content on component mount | |
| let referenceDocsWithContent: Array<{ | |
| id: string | |
| name: string | |
| category: string | |
| content: string | |
| fileType: string | |
| }> = [] | |
| async function loadReferenceDocuments() { | |
| try { | |
| console.log('📚 Loading reference documents with content...') | |
| const docs = await backendService.getPreviousRFPs() | |
| console.log('Found documents:', docs.map(d => d.name)) | |
| // Clear existing vector storage | |
| vectorStorage.clear() | |
| console.log('🗑️ Cleared vector storage') | |
| const docsWithContent = [] | |
| for (const doc of docs) { | |
| try { | |
| const docWithContent = await backendService.getDocumentContent(doc.id) | |
| console.log(`Loaded content for ${docWithContent.name}: ${docWithContent.content?.length || 0} characters`) | |
| docsWithContent.push(docWithContent) | |
| // Add document to vector storage | |
| if (docWithContent.content && docWithContent.content.length > 100) { | |
| await vectorStorage.addDocument(docWithContent.content, { | |
| source: docWithContent.name, | |
| category: docWithContent.category || 'General' | |
| }) | |
| console.log(`✅ Added ${docWithContent.name} to vector storage`) | |
| } | |
| } catch (error) { | |
| console.error(`Failed to load content for ${doc.name}:`, error) | |
| } | |
| } | |
| referenceDocsWithContent = docsWithContent | |
| // Show vector storage stats | |
| const stats = vectorStorage.getStats() | |
| console.log('📊 Vector Storage Stats:', stats) | |
| // Trigger reactive update after documents are loaded | |
| console.log('📡 Triggering reactive update after document loading') | |
| if (currentQuestion) { | |
| previousAnswers = getPreviousAnswers(currentQuestion.text) | |
| } | |
| } catch (error) { | |
| console.error('Error loading reference documents:', error) | |
| } | |
| } | |
| // Load documents on component mount | |
| onMount(() => { | |
| loadReferenceDocuments() | |
| }) | |
| async function generateAnswer() { | |
| if (!currentQuestion) return | |
| isLoading.set(true) | |
| const categoryFilter = selectedCategory === 'all' ? undefined : selectedCategory | |
| console.log(`Generating answer for question ${currentQuestionIndex + 1} using ${categoryFilter ? `${categoryFilter} category only` : 'all categories'}`) | |
| try { | |
| // Start with basic answer generation | |
| const aiResponse = await generateAIAnswer(currentQuestion.text, $rfpData.extractedData, undefined, categoryFilter) | |
| const answer: RFPAnswer = { | |
| id: `answer-${currentQuestionIndex}`, | |
| question: currentQuestion.text, | |
| aiGeneratedAnswer: aiResponse.generatedAnswer, | |
| finalAnswer: aiResponse.generatedAnswer, | |
| isFinalized: false, | |
| citations: aiResponse.citations.map(c => `${c.source} (Page ${c.pageNumber})`), | |
| source: aiResponse.citations[0]?.source, | |
| pageNumber: aiResponse.citations[0]?.pageNumber | |
| } | |
| // Update the store with the initial answer | |
| rfpData.update(data => { | |
| const existingAnswers = data.answers || [] | |
| const existingIndex = existingAnswers.findIndex(a => a.id === answer.id) | |
| if (existingIndex >= 0) { | |
| existingAnswers[existingIndex] = answer | |
| } else { | |
| existingAnswers.push(answer) | |
| } | |
| return { ...data, answers: existingAnswers } | |
| }) | |
| // Now format the answer in real-time using AI | |
| console.log('Formatting generated answer in real-time...') | |
| try { | |
| const formatResult = await backendService.formatAnswer( | |
| currentQuestion.text, | |
| aiResponse.generatedAnswer, | |
| `Reference from: ${aiResponse.citations.map(c => `${c.source} (Page ${c.pageNumber})`).join(', ')}` | |
| ) | |
| if (formatResult.success) { | |
| // Update the answer with formatted version | |
| rfpData.update(data => { | |
| const answers = data.answers || [] | |
| const answerIndex = answers.findIndex(a => a.id === answer.id) | |
| if (answerIndex >= 0) { | |
| answers[answerIndex] = { | |
| ...answers[answerIndex], | |
| finalAnswer: formatResult.formattedAnswer, | |
| aiGeneratedAnswer: formatResult.originalAnswer, | |
| isFormatted: true | |
| } | |
| } | |
| return { ...data, answers } | |
| }) | |
| console.log('✅ Answer formatted successfully in real-time') | |
| } else { | |
| console.warn('Failed to format answer:', formatResult) | |
| } | |
| } catch (formatError) { | |
| console.error('Error formatting answer:', formatError) | |
| // Continue with unformatted answer | |
| } | |
| // Move to next question or complete | |
| if (currentQuestionIndex < questionsToProcess.length - 1) { | |
| nextQuestion() | |
| } else { | |
| // All questions processed, go to next step | |
| nextStep() | |
| } | |
| } catch (error) { | |
| console.error('Error generating answer:', error) | |
| } finally { | |
| isLoading.set(false) | |
| } | |
| } | |
| // Function to use a previous answer directly | |
| async function usePreviousAnswer(previousAnswer: any) { | |
| if (!currentQuestion || !previousAnswer) return | |
| isLoading.set(true) | |
| try { | |
| const answer: RFPAnswer = { | |
| id: `answer-${currentQuestionIndex}`, | |
| question: currentQuestion.text, | |
| aiGeneratedAnswer: previousAnswer.answer, | |
| finalAnswer: previousAnswer.answer, | |
| isFinalized: false, | |
| citations: [`${previousAnswer.docName} (Page ${previousAnswer.pageNumber})`], | |
| source: previousAnswer.docName, | |
| pageNumber: previousAnswer.pageNumber | |
| } | |
| // Update the store with the previous answer | |
| rfpData.update(data => { | |
| const existingAnswers = data.answers || [] | |
| const existingIndex = existingAnswers.findIndex(a => a.id === answer.id) | |
| if (existingIndex >= 0) { | |
| existingAnswers[existingIndex] = answer | |
| } else { | |
| existingAnswers.push(answer) | |
| } | |
| return { ...data, answers: existingAnswers } | |
| }) | |
| console.log('✅ Previous answer used successfully') | |
| // Move to next question or complete | |
| if (currentQuestionIndex < questionsToProcess.length - 1) { | |
| nextQuestion() | |
| } else { | |
| // All questions processed, go to next step | |
| nextStep() | |
| } | |
| } catch (error) { | |
| console.error('Error using previous answer:', error) | |
| } finally { | |
| isLoading.set(false) | |
| } | |
| } | |
| </script> | |
| <div class="max-w-6xl mx-auto"> | |
| <div class="bg-gray-800 rounded-lg shadow-lg p-8"> | |
| <!-- View Toggle Buttons --> | |
| <div class="mb-6 flex gap-3"> | |
| <button | |
| on:click={() => showAnalyzer = false} | |
| class="px-4 py-2 rounded-lg font-medium transition-colors {!showAnalyzer ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" | |
| > | |
| 📝 Standard Answer Generation | |
| </button> | |
| <button | |
| on:click={() => showAnalyzer = true} | |
| class="px-4 py-2 rounded-lg font-medium transition-colors {showAnalyzer ? 'bg-purple-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}" | |
| > | |
| 🔍 Advanced RFP Analysis | |
| </button> | |
| </div> | |
| {#if showAnalyzer} | |
| <!-- RFP Analyzer View --> | |
| <div class="bg-gray-700 rounded-lg p-6 border border-gray-600"> | |
| <h3 class="text-xl font-bold text-white mb-4">🔍 Advanced RFP Question Analysis</h3> | |
| <p class="text-gray-300 mb-6"> | |
| Get comprehensive analysis with structured previous answers, AI-generated responses considering stress points, gap analysis, and actionable suggestions. | |
| </p> | |
| <RFPAnalyzer | |
| question={currentQuestion?.text || ''} | |
| currentRFPContext={$rfpData.extractedData?.map(d => d.text).join('\n') || ''} | |
| /> | |
| </div> | |
| {:else} | |
| <!-- Standard View (existing content) --> | |
| <!-- Configuration --> | |
| <div class="mb-8"> | |
| <!-- Reference Category Selection --> | |
| {#if availableCategories.length > 0} | |
| <div class="mb-6 p-4 bg-gray-700 rounded-lg border border-gray-600"> | |
| <h4 class="text-sm font-semibold text-white mb-3"> | |
| Reference Document Category | |
| </h4> | |
| <div class="flex flex-wrap gap-2"> | |
| <label class="flex items-center"> | |
| <input | |
| type="radio" | |
| name="category" | |
| value="all" | |
| bind:group={selectedCategory} | |
| class="mr-2 text-blue-600" | |
| /> | |
| <span class="text-sm text-gray-200 px-2 py-1 bg-gray-800 rounded border border-gray-600"> | |
| All Categories ({$referenceDocuments.length} docs) | |
| </span> | |
| </label> | |
| {#each availableCategories as category} | |
| {@const categoryDocs = $referenceDocuments.filter(doc => doc.category === category)} | |
| <label class="flex items-center"> | |
| <input | |
| type="radio" | |
| name="category" | |
| value={category} | |
| bind:group={selectedCategory} | |
| class="mr-2 text-blue-600" | |
| /> | |
| <span class="text-sm text-gray-200 px-2 py-1 bg-gray-800 rounded border border-gray-600"> | |
| {category === 'it-staffing' ? 'IT Staffing' : category === 'ai-rfps' ? 'AI RFPs' : category} ({categoryDocs.length} docs) | |
| </span> | |
| </label> | |
| {/each} | |
| </div> | |
| <p class="text-xs text-gray-300 mt-2"> | |
| AI will only reference documents from the selected category when generating answers. | |
| </p> | |
| </div> | |
| {/if} | |
| <!-- Current Question Display --> | |
| <div class="mb-6"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h4 class="text-lg font-semibold text-white"> | |
| Question {currentQuestionIndex + 1} of {questionsToProcess.length} | |
| </h4> | |
| <div class="flex items-center space-x-2"> | |
| <button | |
| on:click={prevQuestion} | |
| disabled={currentQuestionIndex === 0} | |
| class="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| ← Previous | |
| </button> | |
| <button | |
| on:click={nextQuestion} | |
| disabled={currentQuestionIndex >= questionsToProcess.length - 1} | |
| class="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Next → | |
| </button> | |
| </div> | |
| </div> | |
| {#if currentQuestion} | |
| <div class="p-4 border border-gray-600 rounded-lg bg-gray-700"> | |
| <div class="text-gray-200 mb-4"> | |
| {currentQuestion.text} | |
| </div> | |
| <!-- Answer Type Selection --> | |
| <div class="mb-6 p-4 bg-gray-800 rounded-lg border border-gray-600"> | |
| <h4 class="text-sm font-semibold text-white mb-3"> | |
| Choose Answer Source | |
| </h4> | |
| <div class="flex gap-4"> | |
| <label class="flex items-center"> | |
| <input | |
| type="radio" | |
| name="answerType" | |
| value="ai" | |
| bind:group={selectedAnswerType} | |
| class="mr-2 text-blue-600" | |
| /> | |
| <span class="text-sm text-gray-200 px-3 py-2 bg-gray-700 rounded border border-gray-600"> | |
| Generate AI Answer | |
| </span> | |
| </label> | |
| <label class="flex items-center"> | |
| <input | |
| type="radio" | |
| name="answerType" | |
| value="previous" | |
| bind:group={selectedAnswerType} | |
| class="mr-2 text-blue-600" | |
| /> | |
| <span class="text-sm text-gray-200 px-3 py-2 bg-gray-700 rounded border border-gray-600"> | |
| Use Previous Answer | |
| </span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- Three-part answer display --> | |
| <div class="space-y-4"> | |
| <!-- 1. Previous RFP Answers --> | |
| <div class="bg-gray-800 rounded-lg p-4 border border-gray-600"> | |
| <h5 class="text-sm font-semibold text-gray-300 mb-3 flex items-center"> | |
| <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | |
| </svg> | |
| Previous RFP Answers | |
| </h5> | |
| <div class="text-gray-400 text-sm"> | |
| {#if previousAnswers.length > 0} | |
| <div class="space-y-3"> | |
| {#each previousAnswers as answer, index} | |
| <div class="bg-gray-700 p-4 rounded border border-gray-600 | |
| {answer.isExactMatch ? 'border-green-500 bg-green-900/10' : ''} | |
| {answer.matchPercentage >= 80 ? 'border-blue-500 bg-blue-900/10' : ''} | |
| {answer.matchPercentage >= 60 ? 'border-yellow-500 bg-yellow-900/10' : ''}"> | |
| <!-- Header with match percentage --> | |
| <div class="flex items-center justify-between mb-3"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="font-medium text-gray-200 text-sm"> | |
| 📄 {answer.documentSource || answer.docName} | |
| </div> | |
| <div class="flex items-center space-x-1"> | |
| <div class="text-lg font-bold | |
| {answer.matchPercentage >= 95 ? 'text-green-400' : ''} | |
| {answer.matchPercentage >= 80 && answer.matchPercentage < 95 ? 'text-blue-400' : ''} | |
| {answer.matchPercentage >= 60 && answer.matchPercentage < 80 ? 'text-yellow-400' : ''} | |
| {answer.matchPercentage < 60 ? 'text-orange-400' : ''}"> | |
| {answer.matchPercentage}% | |
| </div> | |
| <div class="text-xs text-gray-400">match</div> | |
| </div> | |
| </div> | |
| <div class="flex items-center space-x-2"> | |
| <span class="text-xs | |
| {answer.isExactMatch ? 'text-green-400 bg-green-900/20' : 'text-blue-400 bg-blue-900/20'} | |
| px-2 py-1 rounded"> | |
| {answer.category || 'General'} | |
| </span> | |
| <span class="text-xs text-green-400 px-2 py-1 bg-green-900/20 rounded font-bold"> | |
| 📍 Page {answer.pageNumber || 'N/A'} | |
| </span> | |
| {#if answer.source === 'regex-pattern'} | |
| <span class="text-xs text-orange-400 px-2 py-1 bg-orange-900/20 rounded font-bold"> | |
| 📋 Regex Match | |
| </span> | |
| {:else if answer.source === 'vector-search'} | |
| <span class="text-xs text-purple-400 px-2 py-1 bg-purple-900/20 rounded font-bold"> | |
| 🧠 Vector Match | |
| </span> | |
| {/if} | |
| {#if answer.isExactMatch} | |
| <span class="text-xs text-green-400 px-2 py-1 bg-green-900/20 rounded font-bold"> | |
| ✅ EXACT MATCH | |
| </span> | |
| {/if} | |
| {#if answer.extractionStrategy} | |
| <span class="text-xs text-purple-400 px-2 py-1 bg-purple-900/20 rounded"> | |
| {answer.extractionStrategy} | |
| </span> | |
| {/if} | |
| </div> | |
| </div> | |
| <!-- Question and Answer Content --> | |
| {#if answer.question && answer.answer} | |
| <div class="space-y-2"> | |
| <!-- Show matched keywords for vector search --> | |
| {#if answer.source === 'vector-search' && answer.matchedKeywords && answer.matchedKeywords.length > 0} | |
| <div class="flex items-center gap-2 flex-wrap"> | |
| <span class="text-xs text-gray-400">Matched keywords:</span> | |
| {#each answer.matchedKeywords as keyword} | |
| <span class="text-xs px-2 py-0.5 bg-purple-900/30 text-purple-300 rounded"> | |
| {keyword} | |
| </span> | |
| {/each} | |
| </div> | |
| {/if} | |
| {#if answer.question !== 'Relevant Content'} | |
| <div class="text-xs text-gray-400 font-medium">Original Question:</div> | |
| <div class="text-gray-300 text-sm italic bg-gray-800 p-2 rounded border-l-4 border-blue-500"> | |
| "{answer.question}" | |
| </div> | |
| {/if} | |
| <!-- Format Answer Button --> | |
| <div class="flex items-center justify-between"> | |
| <div class="text-xs text-gray-400 font-medium mt-3">Complete Answer:</div> | |
| <div class="flex gap-2"> | |
| {#if selectedAnswerType === 'previous'} | |
| <button | |
| class="text-xs px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-1" | |
| on:click={() => usePreviousAnswer(answer)} | |
| disabled={$isLoading} | |
| > | |
| <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| <span>Use This Answer</span> | |
| </button> | |
| {/if} | |
| <button | |
| class="text-xs px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-1" | |
| on:click={() => formatPreviousAnswer(answer, currentQuestion?.text)} | |
| disabled={formattingInProgress.has(`${answer.docName}-${answer.question.substring(0, 50)}`)} | |
| > | |
| {#if formattingInProgress.has(`${answer.docName}-${answer.question.substring(0, 50)}`)} | |
| <svg class="w-3 h-3 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> | |
| </svg> | |
| <span>Formatting...</span> | |
| {:else} | |
| <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> | |
| </svg> | |
| <span>Format with AI</span> | |
| {/if} | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Show formatted answer if available --> | |
| {#if formattedAnswers.has(`${answer.docName}-${answer.question.substring(0, 50)}`)} | |
| {@const formattedAnswer = formattedAnswers.get(`${answer.docName}-${answer.question.substring(0, 50)}`)} | |
| <!-- Formatted Answer --> | |
| <div class="text-gray-200 text-sm leading-relaxed bg-gradient-to-r from-green-900/20 to-blue-900/20 p-4 rounded border-l-4 border-green-400"> | |
| <div class="flex items-center mb-2"> | |
| <svg class="w-4 h-4 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> | |
| </svg> | |
| <span class="text-xs font-semibold text-green-400">AI-Formatted Answer</span> | |
| </div> | |
| <div class="prose prose-sm max-w-none text-gray-200"> | |
| {#each (formattedAnswer.formattedAnswer || formattedAnswer.answer).split('\n\n') as paragraph} | |
| {#if paragraph.trim()} | |
| <p class="mb-3 last:mb-0">{@html paragraph.trim().replace(/\n/g, '<br>')}</p> | |
| {/if} | |
| {/each} | |
| </div> | |
| </div> | |
| <!-- Original Answer (collapsed by default) --> | |
| <details class="text-gray-200 text-sm leading-relaxed"> | |
| <summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300 mb-2">View Original Answer</summary> | |
| <div class="bg-gray-800 p-4 rounded border-l-4 border-gray-500"> | |
| <div class="prose prose-sm max-w-none text-gray-300"> | |
| {#each answer.answer.split('\n\n') as paragraph} | |
| {#if paragraph.trim()} | |
| <p class="mb-3 last:mb-0">{@html paragraph.trim().replace(/\n/g, '<br>')}</p> | |
| {/if} | |
| {/each} | |
| </div> | |
| </div> | |
| </details> | |
| {:else} | |
| <!-- Original Answer (when not formatted) --> | |
| <div class="text-gray-200 text-sm leading-relaxed bg-gray-800 p-4 rounded border-l-4 border-green-500"> | |
| <div class="prose prose-sm max-w-none text-gray-200"> | |
| {#each answer.answer.split('\n\n') as paragraph} | |
| {#if paragraph.trim()} | |
| <p class="mb-3 last:mb-0">{@html paragraph.trim().replace(/\n/g, '<br>')}</p> | |
| {/if} | |
| {/each} | |
| </div> | |
| </div> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div class="text-gray-200 text-sm leading-relaxed"> | |
| {answer.content} | |
| </div> | |
| {/if} | |
| <!-- Match Details --> | |
| <div class="mt-3 pt-3 border-t border-gray-600 flex items-center justify-between"> | |
| <div class="text-xs text-gray-400"> | |
| <span class="font-medium">Matched words:</span> | |
| {#if answer.matchedWords && answer.matchedWords.length > 0} | |
| <span class="text-blue-300">{answer.matchedWords.slice(0, 5).join(', ')}</span> | |
| {#if answer.matchedWords.length > 5} | |
| <span class="text-gray-500"> +{answer.matchedWords.length - 5} more</span> | |
| {/if} | |
| {:else} | |
| <span class="text-gray-500">Content-based match</span> | |
| {/if} | |
| </div> | |
| <div class="text-xs text-gray-500"> | |
| Type: {answer.sectionType || 'content'} | Strategy: {answer.extractionStrategy || answer.source || 'extraction'} | |
| {#if answer.documentSource} | |
| | Source: {answer.documentSource} | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| {/each} | |
| <!-- Summary --> | |
| <div class="mt-4 p-3 bg-gray-800 rounded border border-gray-600"> | |
| <div class="text-xs text-gray-400"> | |
| <strong>Search Summary:</strong> Found {previousAnswers.length} relevant matches | |
| {#if previousAnswers.some(a => a.isExactMatch)} | |
| <span class="text-green-400 font-bold"> • Contains exact question match!</span> | |
| {/if} | |
| {#if previousAnswers.filter(a => a.matchPercentage >= 80).length > 0} | |
| <span class="text-blue-400"> • {previousAnswers.filter(a => a.matchPercentage >= 80).length} high-confidence matches</span> | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| {:else} | |
| <div class="text-gray-500 italic"> | |
| {#if referenceDocsWithContent.length === 0} | |
| <div class="space-y-2"> | |
| <div>No reference documents loaded yet...</div> | |
| <div class="text-xs">Documents available: {$referenceDocuments.length}</div> | |
| {#if $referenceDocuments.length > 0} | |
| <div class="text-xs"> | |
| {#each $referenceDocuments as doc} | |
| <div>• {doc.name} ({doc.fileType || 'unknown type'})</div> | |
| {/each} | |
| </div> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div class="space-y-2"> | |
| <div>No relevant previous answers found for this question</div> | |
| <div class="text-xs">Searched {referenceDocsWithContent.length} documents with content:</div> | |
| <div class="text-xs"> | |
| {#each referenceDocsWithContent as doc} | |
| <div>• {doc.name}: {doc.content?.length || 0} characters</div> | |
| {/each} | |
| </div> | |
| <div class="text-xs text-blue-400 mt-2">Check browser console for detailed search logs</div> | |
| </div> | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| </div> | |
| <!-- 2. AI Generated Answer --> | |
| <div class="bg-gray-800 rounded-lg p-4 border border-gray-600"> | |
| <h5 class="text-sm font-semibold text-gray-300 mb-3 flex items-center"> | |
| <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> | |
| </svg> | |
| AI Generated Answer | |
| {#if currentAnswer && currentAnswer.isFormatted} | |
| <span class="ml-2 text-xs bg-green-600 text-white px-2 py-1 rounded-full">AI-Formatted</span> | |
| {/if} | |
| {#if currentAnswer && currentAnswer.hasAdditionalInfo} | |
| <span class="ml-2 text-xs bg-purple-600 text-white px-2 py-1 rounded-full">Enhanced</span> | |
| {/if} | |
| </h5> | |
| <div class="text-gray-400 text-sm"> | |
| {#if currentAnswer && (currentAnswer.finalAnswer || currentAnswer.aiGeneratedAnswer)} | |
| <div class="bg-gray-700 p-3 rounded border border-gray-600"> | |
| {#if currentAnswer.isFormatted && currentAnswer.finalAnswer} | |
| <!-- Show formatted answer with enhanced styling --> | |
| <div class="bg-gradient-to-r from-green-900/20 to-blue-900/20 p-4 rounded border-l-4 border-green-400 mb-3"> | |
| <div class="flex items-center mb-2"> | |
| <svg class="w-4 h-4 text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> | |
| </svg> | |
| <span class="text-xs font-semibold text-green-400">AI-Enhanced Answer</span> | |
| </div> | |
| <div class="text-gray-200 leading-relaxed whitespace-pre-wrap">{currentAnswer.finalAnswer}</div> | |
| </div> | |
| <!-- Show original answer in collapsible section --> | |
| <details class="mt-3"> | |
| <summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300 mb-2">View Original Generated Answer</summary> | |
| <div class="bg-gray-800 p-3 rounded border border-gray-500"> | |
| <div class="text-gray-300 leading-relaxed whitespace-pre-wrap">{currentAnswer.aiGeneratedAnswer}</div> | |
| </div> | |
| </details> | |
| {:else} | |
| <!-- Show regular generated answer --> | |
| <div class="text-gray-200 leading-relaxed whitespace-pre-wrap">{currentAnswer.finalAnswer || currentAnswer.aiGeneratedAnswer}</div> | |
| {/if} | |
| {#if currentAnswer.citations && currentAnswer.citations.length > 0} | |
| <div class="mt-3 pt-3 border-t border-gray-600"> | |
| <div class="text-xs text-blue-400 font-medium mb-1">Citations:</div> | |
| <div class="space-y-1"> | |
| {#each currentAnswer.citations as citation} | |
| <div class="text-xs text-blue-300">• {citation}</div> | |
| {/each} | |
| </div> | |
| </div> | |
| {/if} | |
| <!-- Additional Information Input --> | |
| <div class="mt-4 pt-3 border-t border-gray-600"> | |
| <div class="text-xs text-gray-400 font-medium mb-2">Add Additional Information for AI Processing:</div> | |
| <div class="space-y-2"> | |
| <textarea | |
| bind:value={additionalInfo} | |
| placeholder="Enter any additional context, requirements, or specific details you want the AI to include when processing this answer..." | |
| class="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-md text-gray-200 text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none" | |
| rows="3" | |
| ></textarea> | |
| <button | |
| class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md transition-colors duration-200 flex items-center space-x-2" | |
| on:click={() => reprocessAnswerWithAdditionalInfo(currentAnswer)} | |
| disabled={$isLoading || !additionalInfo.trim()} | |
| > | |
| {#if $isLoading} | |
| <svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> | |
| </svg> | |
| <span>Processing...</span> | |
| {:else} | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> | |
| </svg> | |
| <span>Reprocess with Additional Info</span> | |
| {/if} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {:else if $isLoading} | |
| <div class="bg-gray-700 p-3 rounded border border-gray-600"> | |
| <div class="flex items-center space-x-2"> | |
| <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div> | |
| <div class="text-gray-300">Generating and formatting AI answer in real-time...</div> | |
| </div> | |
| </div> | |
| {:else} | |
| <div class="bg-gray-700 p-3 rounded border border-gray-600"> | |
| <div class="text-gray-400">AI-generated answer will appear here after clicking "Generate Answer"...</div> | |
| </div> | |
| {/if} | |
| </div> | |
| </div> | |
| <!-- 3. Suggestions --> | |
| <div class="bg-gray-800 rounded-lg p-4 border border-gray-600"> | |
| <h5 class="text-sm font-semibold text-gray-300 mb-3 flex items-center"> | |
| <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> | |
| </svg> | |
| Suggestions | |
| </h5> | |
| <div class="text-gray-400 text-sm"> | |
| <div class="space-y-2"> | |
| <div class="bg-gray-700 p-3 rounded border border-gray-600"> | |
| <div class="text-gray-300 text-xs">• Include specific metrics and KPIs</div> | |
| </div> | |
| <div class="bg-gray-700 p-3 rounded border border-gray-600"> | |
| <div class="text-gray-300 text-xs">• Add relevant case studies or examples</div> | |
| </div> | |
| <div class="bg-gray-700 p-3 rounded border border-gray-600"> | |
| <div class="text-gray-300 text-xs">• Mention compliance and security standards</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/if} | |
| <!-- Question Navigation --> | |
| <div class="mt-4"> | |
| <div class="flex flex-wrap gap-2"> | |
| {#each questionsToProcess as question, index} | |
| <button | |
| on:click={() => goToQuestion(index)} | |
| class="px-3 py-1 text-sm rounded {index === currentQuestionIndex ? 'bg-blue-600 text-white' : 'bg-gray-600 text-gray-300 hover:bg-gray-500'}" | |
| > | |
| Q{index + 1} | |
| </button> | |
| {/each} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- AI Generation Info --> | |
| <div class="mb-8 p-6 bg-gray-800 rounded-lg border border-gray-600"> | |
| <h4 class="text-lg font-semibold text-white mb-3"> | |
| AI Answer Generation | |
| </h4> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-200"> | |
| <div class="flex items-center"> | |
| <svg class="w-4 h-4 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| Searches previous RFP responses for relevant content | |
| </div> | |
| <div class="flex items-center"> | |
| <svg class="w-4 h-4 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| Provides citations with source and page numbers | |
| </div> | |
| <div class="flex items-center"> | |
| <svg class="w-4 h-4 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| Suggests improvements based on new RFP context | |
| </div> | |
| <div class="flex items-center"> | |
| <svg class="w-4 h-4 mr-2 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| Allows full editing before finalization | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Loading State --> | |
| {#if $isLoading} | |
| <div class="flex flex-col items-center justify-center p-8 space-y-4"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> | |
| <div class="text-center"> | |
| <div class="text-lg font-medium text-white">Generating AI Answers</div> | |
| <div class="text-gray-300">This may take a few moments...</div> | |
| </div> | |
| <div class="w-full max-w-md bg-gray-700 rounded-full h-2"> | |
| <div class="bg-blue-600 h-2 rounded-full animate-pulse" style="width: 60%"></div> | |
| </div> | |
| </div> | |
| {/if} | |
| <!-- Navigation Buttons --> | |
| <div class="flex justify-between"> | |
| <button | |
| class="px-6 py-3 bg-gray-700 text-gray-300 rounded-lg font-medium hover:bg-gray-600 transition-colors" | |
| on:click={prevStep} | |
| disabled={$isLoading} | |
| > | |
| <svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> | |
| </svg> | |
| Back to Upload | |
| </button> | |
| <button | |
| class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" | |
| disabled={!currentQuestion || $isLoading || (selectedAnswerType === 'previous' && previousAnswers.length === 0)} | |
| on:click={generateAnswer} | |
| > | |
| {#if selectedAnswerType === 'ai'} | |
| Generate AI Answer for Q{currentQuestionIndex + 1} | |
| {#if selectedCategory !== 'all'} | |
| <span class="text-xs">({selectedCategory === 'it-staffing' ? 'IT Staffing' : selectedCategory === 'ai-rfps' ? 'AI RFPs' : selectedCategory})</span> | |
| {/if} | |
| <svg class="w-5 h-5 ml-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> | |
| </svg> | |
| {:else} | |
| {#if previousAnswers.length > 0} | |
| Select Previous Answer Above ↑ | |
| <svg class="w-5 h-5 ml-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| {:else} | |
| No Previous Answers Available | |
| <svg class="w-5 h-5 ml-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 15.5c-.77.833.192 2.5 1.732 2.5z" /> | |
| </svg> | |
| {/if} | |
| {/if} | |
| </button> | |
| </div> | |
| {/if} | |
| <!-- End of Standard View --> | |
| </div> | |
| </div> | |