diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts index 49280a4ea..e64730d3e 100644 --- a/apps/app/src/actions/safe-action.ts +++ b/apps/app/src/actions/safe-action.ts @@ -83,7 +83,18 @@ export const authActionClient = actionClientWithMeta const headersList = await headers(); let remaining: number | undefined; - if (ratelimit) { + // Exclude answer saving actions from rate limiting + // These actions are user-initiated and should not be rate limited + const excludedActions = [ + 'save-questionnaire-answer', + 'update-questionnaire-answer', + 'save-manual-answer', + 'save-questionnaire-answers-batch', + ]; + + const shouldRateLimit = !excludedActions.includes(metadata.name); + + if (ratelimit && shouldRateLimit) { const { success, remaining: rateLimitRemaining } = await ratelimit.limit( `${headersList.get('x-forwarded-for')}-${metadata.name}`, ); @@ -283,7 +294,18 @@ export const authActionClientWithoutOrg = actionClientWithMeta const headersList = await headers(); let remaining: number | undefined; - if (ratelimit) { + // Exclude answer saving actions from rate limiting + // These actions are user-initiated and should not be rate limited + const excludedActions = [ + 'save-questionnaire-answer', + 'update-questionnaire-answer', + 'save-manual-answer', + 'save-questionnaire-answers-batch', + ]; + + const shouldRateLimit = !excludedActions.includes(metadata.name); + + if (ratelimit && shouldRateLimit) { const { success, remaining: rateLimitRemaining } = await ratelimit.limit( `${headersList.get('x-forwarded-for')}-${metadata.name}`, ); diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx index 471f28808..94cf176d8 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { QuestionnaireResults } from '../../components/QuestionnaireResults'; +import { QuestionnaireView } from '../../components/QuestionnaireView'; import { useQuestionnaireDetail } from '../../hooks/useQuestionnaireDetail'; interface QuestionnaireDetailClientProps { @@ -33,6 +33,7 @@ export function QuestionnaireDetailClient({ expandedSources, questionStatuses, answeringQuestionIndex, + answerQueue, hasClickedAutoAnswer, isLoading, isAutoAnswering, @@ -57,60 +58,52 @@ export function QuestionnaireDetailClient({ }); return ( -
-
-

{filename}

-

- Review and manage answers for this questionnaire -

-
- ({ - question: r.question, - answer: r.answer, - sources: r.sources, - failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result - status: (r as any).status ?? 'untouched', // Preserve status field for UI behavior - _originalIndex: (r as any).originalIndex ?? index, // Preserve originalIndex for reference, fallback to map index - }))} - filteredResults={filteredResults?.map((r, index) => ({ - question: r.question, - answer: r.answer, - sources: r.sources, - failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result - status: (r as any).status ?? 'untouched', // Preserve status field for UI behavior - _originalIndex: (r as any).originalIndex ?? index, // Preserve originalIndex for reference, fallback to map index - }))} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} - editingIndex={editingIndex} - editingAnswer={editingAnswer} - onEditingAnswerChange={setEditingAnswer} - expandedSources={expandedSources} - questionStatuses={questionStatuses} - answeringQuestionIndex={answeringQuestionIndex} - hasClickedAutoAnswer={hasClickedAutoAnswer} - isLoading={isLoading} - isAutoAnswering={isAutoAnswering} - isExporting={isExporting} - isSaving={isSaving} - savingIndex={savingIndex} - showExitDialog={false} - onShowExitDialogChange={() => {}} - onExit={() => {}} - onAutoAnswer={handleAutoAnswer} - onAnswerSingleQuestion={handleAnswerSingleQuestion} - onEditAnswer={handleEditAnswer} - onSaveAnswer={handleSaveAnswer} - onCancelEdit={handleCancelEdit} - onExport={handleExport} - onToggleSource={handleToggleSource} - totalCount={totalCount} - answeredCount={answeredCount} - progressPercentage={progressPercentage} - /> -
+ ({ + question: r.question, + answer: r.answer, + sources: r.sources, + failedToGenerate: (r as any).failedToGenerate ?? false, + status: (r as any).status ?? 'untouched', + _originalIndex: (r as any).originalIndex ?? index, + }))} + filteredResults={filteredResults?.map((r, index) => ({ + question: r.question, + answer: r.answer, + sources: r.sources, + failedToGenerate: (r as any).failedToGenerate ?? false, + status: (r as any).status ?? 'untouched', + _originalIndex: (r as any).originalIndex ?? index, + }))} + searchQuery={searchQuery} + setSearchQuery={setSearchQuery} + editingIndex={editingIndex} + editingAnswer={editingAnswer} + setEditingAnswer={setEditingAnswer} + expandedSources={expandedSources} + questionStatuses={questionStatuses as Map} + answeringQuestionIndex={answeringQuestionIndex} + answerQueue={answerQueue} + hasClickedAutoAnswer={hasClickedAutoAnswer} + isLoading={isLoading} + isAutoAnswering={isAutoAnswering} + isExporting={isExporting} + isSaving={isSaving} + savingIndex={savingIndex} + totalCount={totalCount} + answeredCount={answeredCount} + progressPercentage={progressPercentage} + onAutoAnswer={handleAutoAnswer} + onAnswerSingleQuestion={handleAnswerSingleQuestion} + onEditAnswer={handleEditAnswer} + onSaveAnswer={handleSaveAnswer} + onCancelEdit={handleCancelEdit} + onExport={handleExport} + onToggleSource={handleToggleSource} + filename={filename} + description="Review and manage answers for this questionnaire" + /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx index 77808dd13..022f57ec7 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx @@ -2,8 +2,6 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { QuestionnaireResults } from '../components/QuestionnaireResults'; -import { useQuestionnaireDetail } from '../hooks/useQuestionnaireDetail'; import { getQuestionnaireById } from './data/queries'; import { QuestionnaireDetailClient } from './components/QuestionnaireDetailClient'; diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/answer-single-question.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/answer-single-question.ts new file mode 100644 index 000000000..ba60e148a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/answer-single-question.ts @@ -0,0 +1,71 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { answerQuestion } from '@/jobs/tasks/vendors/answer-question'; +import { z } from 'zod'; +import { headers } from 'next/headers'; +import { revalidatePath } from 'next/cache'; + +const inputSchema = z.object({ + question: z.string(), + questionIndex: z.number(), + totalQuestions: z.number(), +}); + +export const answerSingleQuestionAction = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'answer-single-question', + track: { + event: 'answer-single-question', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { question, questionIndex, totalQuestions } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + const organizationId = session.activeOrganizationId; + + try { + // Call answerQuestion function directly + const result = await answerQuestion( + { + question, + organizationId, + questionIndex, + totalQuestions, + }, + { + useMetadata: false, + }, + ); + + // Revalidate the page to show updated answer + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: result.success, + data: { + questionIndex: result.questionIndex, + question: result.question, + answer: result.answer, + sources: result.sources, + error: result.error, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to answer question', + }; + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts index 0774151a9..08bb3256f 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts @@ -90,8 +90,10 @@ export const saveAnswerAction = authActionClient }, }); - // If status is manual and answer exists, also save to SecurityQuestionnaireManualAnswer - if (status === 'manual' && answer && answer.trim().length > 0 && existingQuestion.question) { + const shouldPersistManualAnswer = + status === 'manual' && answer && answer.trim().length > 0 && existingQuestion.question; + + if (shouldPersistManualAnswer) { try { const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({ where: { diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts index ad55ed9b3..6589c3a10 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts @@ -12,6 +12,19 @@ const updateAnswerSchema = z.object({ questionnaireId: z.string(), questionAnswerId: z.string(), answer: z.string(), + status: z.enum(['generated', 'manual']).optional().default('manual'), + sources: z + .array( + z.object({ + sourceType: z.string(), + sourceName: z.string().optional(), + sourceId: z.string().optional(), + policyName: z.string().optional(), + documentName: z.string().optional(), + score: z.number(), + }), + ) + .optional(), }); export const updateQuestionnaireAnswer = authActionClient @@ -25,7 +38,7 @@ export const updateQuestionnaireAnswer = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { questionnaireId, questionAnswerId, answer } = parsedInput; + const { questionnaireId, questionAnswerId, answer, status, sources } = parsedInput; const { activeOrganizationId } = ctx.session; const userId = ctx.user.id; @@ -67,6 +80,9 @@ export const updateQuestionnaireAnswer = authActionClient }; } + // Store the previous status to determine if this was written from scratch + const previousStatus = questionAnswer.status; + // Update the answer await db.questionnaireQuestionAnswer.update({ where: { @@ -74,14 +90,18 @@ export const updateQuestionnaireAnswer = authActionClient }, data: { answer: answer.trim() || null, - status: 'manual', - updatedBy: userId || null, + status: status === 'generated' ? 'generated' : 'manual', + sources: sources ? (sources as any) : null, + generatedAt: status === 'generated' ? new Date() : null, + updatedBy: status === 'manual' ? userId || null : null, updatedAt: new Date(), }, }); - // Also save to SecurityQuestionnaireManualAnswer if answer exists - if (answer && answer.trim().length > 0 && questionAnswer.question) { + const shouldPersistManualAnswer = + status === 'manual' && answer && answer.trim().length > 0 && questionAnswer.question; + + if (shouldPersistManualAnswer) { try { const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({ where: { diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/ManualAnswerLink.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/ManualAnswerLink.tsx new file mode 100644 index 000000000..46727807f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/ManualAnswerLink.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { LinkIcon } from 'lucide-react'; +import Link from 'next/link'; + +interface ManualAnswerLinkProps { + manualAnswerId: string; + sourceName: string; + orgId: string; + className?: string; +} + +export function ManualAnswerLink({ + manualAnswerId, + sourceName, + orgId, + className = 'font-medium text-primary hover:underline inline-flex items-center gap-1', +}: ManualAnswerLinkProps) { + // Link to knowledge base page with hash anchor to scroll to specific manual answer + const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base#manual-answer-${manualAnswerId}`; + + return ( + + {sourceName} + + + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx index 831cf1b90..07581a8ee 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx @@ -1,9 +1,7 @@ 'use client'; import { useQuestionnaireParser } from '../hooks/useQuestionnaireParser'; -import { QuestionnaireResults } from './QuestionnaireResults'; -import { QuestionnaireSidebar } from './QuestionnaireSidebar'; -import { QuestionnaireUpload } from './QuestionnaireUpload'; +import { QuestionnaireView } from './QuestionnaireView'; export function QuestionnaireParser() { const { @@ -42,74 +40,54 @@ export function QuestionnaireParser() { handleToggleSource, } = useQuestionnaireParser(); - const hasResults = results && results.length > 0; + const normalizedResults = + results?.map((result) => ({ + ...result, + sources: result.sources ?? [], + })) ?? null; - if (!hasResults) { - return ( -
-
-

- Security Questionnaire -

-

- Automatically analyze and answer questionnaires using AI. Upload questionnaires from - vendors, and our system will extract questions and generate answers based on your - organization's policies and documentation. -

-
-
-
- setSelectedFile(null)} - onParse={handleParse} - isLoading={isLoading} - parseStatus={parseStatus} - orgId={orgId} - /> -
-
- -
-
-
- ); - } + const normalizedFilteredResults = + filteredResults?.map((result) => ({ + ...result, + sources: result.sources ?? [], + })) ?? null; return ( -
-

Security Questionnaire

- -
+ setSelectedFile(null)} + onParse={handleParse} + parseStatus={parseStatus} + showExitDialog={showExitDialog} + onShowExitDialogChange={setShowExitDialog} + onExit={confirmReset} + /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx index 3f276b371..8850ea3d9 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx @@ -19,6 +19,7 @@ interface QuestionnaireResultsProps { expandedSources: Set; questionStatuses: Map; answeringQuestionIndex: number | null; + answerQueue?: number[]; hasClickedAutoAnswer: boolean; isLoading: boolean; isAutoAnswering: boolean; @@ -52,6 +53,7 @@ export function QuestionnaireResults({ expandedSources, questionStatuses, answeringQuestionIndex, + answerQueue = [], hasClickedAutoAnswer, isLoading, isAutoAnswering, @@ -111,6 +113,7 @@ export function QuestionnaireResults({ expandedSources={expandedSources} questionStatuses={questionStatuses} answeringQuestionIndex={answeringQuestionIndex} + answerQueue={answerQueue} isAutoAnswering={isAutoAnswering} hasClickedAutoAnswer={hasClickedAutoAnswer} isSaving={isSaving} @@ -133,6 +136,7 @@ export function QuestionnaireResults({ expandedSources={expandedSources} questionStatuses={questionStatuses} answeringQuestionIndex={answeringQuestionIndex} + answerQueue={answerQueue} isAutoAnswering={isAutoAnswering} hasClickedAutoAnswer={hasClickedAutoAnswer} isSaving={isSaving} diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx index ced669304..2d5aa88b9 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import type { QuestionAnswer } from './types'; import { deduplicateSources } from '../utils/deduplicate-sources'; import { KnowledgeBaseDocumentLink } from './KnowledgeBaseDocumentLink'; +import { ManualAnswerLink } from './ManualAnswerLink'; interface QuestionnaireResultsCardsProps { orgId: string; @@ -18,6 +19,7 @@ interface QuestionnaireResultsCardsProps { expandedSources: Set; questionStatuses: Map; answeringQuestionIndex: number | null; + answerQueue?: number[]; isAutoAnswering: boolean; hasClickedAutoAnswer: boolean; isSaving?: boolean; @@ -39,6 +41,7 @@ export function QuestionnaireResultsCards({ expandedSources, questionStatuses, answeringQuestionIndex, + answerQueue = [], isAutoAnswering, hasClickedAutoAnswer, isSaving, @@ -63,6 +66,8 @@ export function QuestionnaireResultsCards({ const uniqueSources = qa.sources ? deduplicateSources(qa.sources) : []; const isEditing = editingIndex === safeIndex; const questionStatus = questionStatuses.get(safeIndex); + // Check if question is in queue (waiting to be processed) + const isQueued = answerQueue.includes(safeIndex); // Determine if this question is being processed // It's processing if: // 1. Status is explicitly 'processing' @@ -138,6 +143,11 @@ export function QuestionnaireResultsCards({ Finding answer... + ) : isQueued ? ( +
+ + Finding answer... +
) : (
{!qa.failedToGenerate && ( @@ -204,6 +214,8 @@ export function QuestionnaireResultsCards({ const isPolicy = source.sourceType === 'policy' && source.sourceId; const isKnowledgeBaseDocument = source.sourceType === 'knowledge_base_document' && source.sourceId; + const isManualAnswer = + source.sourceType === 'manual_answer' && source.sourceId; const sourceContent = source.sourceName || source.sourceType; return ( @@ -225,6 +237,12 @@ export function QuestionnaireResultsCards({ sourceName={sourceContent} orgId={orgId} /> + ) : isManualAnswer && source.sourceId ? ( + ) : ( {sourceContent} diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsHeader.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsHeader.tsx index d14ca9e34..96ada8255 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsHeader.tsx @@ -154,30 +154,30 @@ export function QuestionnaireResultsHeader({ - - + onExport('xlsx')} - disabled={isExporting || isLoading} + disabled={isExporting} > Excel onExport('csv')} - disabled={isExporting || isLoading} + disabled={isExporting} > CSV onExport('pdf')} - disabled={isExporting || isLoading} + disabled={isExporting} > PDF diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx index 8e3e877d2..0c827dbc4 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx @@ -8,6 +8,7 @@ import Link from 'next/link'; import type { QuestionAnswer } from './types'; import { deduplicateSources } from '../utils/deduplicate-sources'; import { KnowledgeBaseDocumentLink } from './KnowledgeBaseDocumentLink'; +import { ManualAnswerLink } from './ManualAnswerLink'; interface QuestionnaireResultsTableProps { orgId: string; @@ -19,6 +20,7 @@ interface QuestionnaireResultsTableProps { expandedSources: Set; questionStatuses: Map; answeringQuestionIndex: number | null; + answerQueue?: number[]; isAutoAnswering: boolean; hasClickedAutoAnswer: boolean; isSaving?: boolean; @@ -40,6 +42,7 @@ export function QuestionnaireResultsTable({ expandedSources, questionStatuses, answeringQuestionIndex, + answerQueue = [], isAutoAnswering, hasClickedAutoAnswer, isSaving, @@ -70,6 +73,8 @@ export function QuestionnaireResultsTable({ const safeIndex = originalIndex >= 0 ? originalIndex : index; const isEditing = editingIndex === safeIndex; const questionStatus = questionStatuses.get(safeIndex); + // Check if question is in queue (waiting to be processed) + const isQueued = answerQueue.includes(safeIndex); // Determine if this question is being processed // It's processing if: // 1. Status is explicitly 'processing' @@ -145,6 +150,11 @@ export function QuestionnaireResultsTable({ Finding answer...
+ ) : isQueued ? ( +
+ + Finding answer... +
) : qa.failedToGenerate ? (

@@ -217,6 +227,8 @@ export function QuestionnaireResultsTable({ const isPolicy = source.sourceType === 'policy' && source.sourceId; const isKnowledgeBaseDocument = source.sourceType === 'knowledge_base_document' && source.sourceId; + const isManualAnswer = + source.sourceType === 'manual_answer' && source.sourceId; const sourceContent = source.sourceName || source.sourceType; return ( @@ -241,6 +253,13 @@ export function QuestionnaireResultsTable({ orgId={orgId} className="text-primary hover:underline flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed" /> + ) : isManualAnswer && source.sourceId ? ( + ) : ( {sourceContent} )} diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireUpload.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireUpload.tsx index 2ed367dfe..a1656f500 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireUpload.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireUpload.tsx @@ -19,6 +19,7 @@ interface QuestionnaireUploadProps { isLoading: boolean; parseStatus: 'uploading' | 'starting' | 'queued' | 'analyzing' | 'processing' | null; orgId: string; + hasResults?: boolean; } export function QuestionnaireUpload({ @@ -28,6 +29,7 @@ export function QuestionnaireUpload({ onParse, isLoading, parseStatus, + hasResults = false, }: QuestionnaireUploadProps) { return (

@@ -99,7 +101,7 @@ export function QuestionnaireUpload({
-

{isCopied ? "Copied!" : "Copy Org ID"}

+

{isCopied ? 'Copied!' : 'Copy Org ID'}

- - {/* CTA Button */} -
- - - -
- - {/* Already spoke to us section */} -
-

- Already had a demo? Ask your point of contact to activate your account. -

-
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index db8841713..067bd5381 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -51,31 +51,11 @@ export default async function UpgradePage({ params }: PageProps) { redirect(`/${orgId}`); } - const frameworkInstances = await db.frameworkInstance.findMany({ - where: { - organizationId: orgId, - }, - include: { - framework: true, - }, - }); - - const complianceFrameworks = frameworkInstances.map((framework) => - framework.framework.name.toLowerCase().replaceAll(' ', ''), - ); - return ( <>
- +
); diff --git a/apps/app/src/components/file-uploader.tsx b/apps/app/src/components/file-uploader.tsx index f0c2e14fb..7f026d92c 100644 --- a/apps/app/src/components/file-uploader.tsx +++ b/apps/app/src/components/file-uploader.tsx @@ -185,7 +185,7 @@ export function FileUploader(props: FileUploaderProps) { const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount; return ( -
+
{ - // Use any to avoid TypeScript narrowing issues, then assert correct type - const r = result as any as SimilarContentResult; - let sourceName: string | undefined; - if (r.policyName) { - sourceName = `Policy: ${r.policyName}`; - } else if (r.vendorName && r.questionnaireQuestion) { - sourceName = `Questionnaire: ${r.vendorName}`; - } else if (r.contextQuestion) { - sourceName = 'Context Q&A'; - } else if ((r.sourceType as string) === 'manual_answer') { - sourceName = 'Manual Answer'; - } - // Don't set sourceName for knowledge_base_document - let deduplication function handle it with filename - - return { - sourceType: r.sourceType, - sourceName, - sourceId: r.sourceId, - policyName: r.policyName, - documentName: r.documentName, - score: r.score, - }; - }), - ); - // If no relevant content found, return null if (similarContent.length === 0) { logger.warn('No similar content found in vector database', { @@ -76,6 +46,53 @@ export async function generateAnswerWithRAG( return { answer: null, sources: [] }; } + // Extract sources information and deduplicate using universal utility + // Multiple chunks from the same source (same policy/context/manual answer/knowledge base document) should appear as a single source + // Note: sourceName is set for some types, but knowledge_base_document will be handled by deduplication function + const sourcesBeforeDedup = similarContent.map((result) => { + // Use any to avoid TypeScript narrowing issues, then assert correct type + const r = result as any as SimilarContentResult; + let sourceName: string | undefined; + if (r.policyName) { + sourceName = `Policy: ${r.policyName}`; + } else if (r.vendorName && r.questionnaireQuestion) { + sourceName = `Questionnaire: ${r.vendorName}`; + } else if (r.contextQuestion) { + sourceName = 'Context Q&A'; + } else if ((r.sourceType as string) === 'manual_answer') { + // Don't set sourceName here - let deduplicateSources handle it with manualAnswerQuestion + // This ensures we show the question preview if available + sourceName = undefined; + } + // Don't set sourceName for knowledge_base_document - let deduplication function handle it with filename + + return { + sourceType: r.sourceType, + sourceName, + sourceId: r.sourceId, + policyName: r.policyName, + documentName: r.documentName, + manualAnswerQuestion: r.manualAnswerQuestion, + score: r.score, + }; + }); + + const sources = deduplicateSources(sourcesBeforeDedup); + + logger.info('Sources extracted and deduplicated', { + question: question.substring(0, 100), + organizationId, + similarContentCount: similarContent.length, + sourcesBeforeDedupCount: sourcesBeforeDedup.length, + sourcesAfterDedupCount: sources.length, + sources: sources.map((s) => ({ + type: s.sourceType, + name: s.sourceName, + score: s.score, + sourceId: s.sourceId?.substring(0, 30), + })), + }); + // Build context from retrieved content const contextParts = similarContent.map((result, index) => { // Use any to avoid TypeScript narrowing issues, then assert correct type @@ -141,9 +158,26 @@ Answer the question based ONLY on the provided context, using first person plura trimmedAnswer.toLowerCase().includes('no evidence') || trimmedAnswer.toLowerCase().includes('not found in the context') ) { + logger.warn('Answer indicates no evidence found', { + question: question.substring(0, 100), + answer: trimmedAnswer.substring(0, 100), + sourcesCount: sources.length, + }); return { answer: null, sources: [] }; } + // Safety check: if we have an answer but no sources, log a warning + // This shouldn't happen if LLM follows instructions, but we log it for debugging + if (sources.length === 0 && trimmedAnswer) { + logger.warn('Answer generated but no sources found - this may indicate LLM used general knowledge', { + question: question.substring(0, 100), + answer: trimmedAnswer.substring(0, 100), + similarContentCount: similarContent.length, + sourcesBeforeDedupCount: sourcesBeforeDedup.length, + }); + // Still return the answer, but without sources + } + return { answer: trimmedAnswer, sources }; } catch (error) { logger.error('Failed to generate answer with RAG', { diff --git a/apps/app/src/jobs/tasks/vendors/answer-question.ts b/apps/app/src/jobs/tasks/vendors/answer-question.ts index ad8816956..c248ebf69 100644 --- a/apps/app/src/jobs/tasks/vendors/answer-question.ts +++ b/apps/app/src/jobs/tasks/vendors/answer-question.ts @@ -1,131 +1,167 @@ -import { logger, metadata, task } from '@trigger.dev/sdk'; import { syncOrganizationEmbeddings } from '@/lib/vector'; +import { logger, metadata, task } from '@trigger.dev/sdk'; import { generateAnswerWithRAG } from './answer-question-helpers'; -export const answerQuestion= task({ - id: 'answer-question', - machine: 'large-2x', - retry: { - maxAttempts: 3, - }, - run: async (payload: { - question: string; - organizationId: string; - questionIndex: number; - totalQuestions: number; - }) => { - logger.info('🚀 Starting to process question', { - questionIndex: payload.questionIndex, - totalQuestions: payload.totalQuestions, - question: payload.question.substring(0, 100), - organizationId: payload.organizationId, - }); +export interface AnswerQuestionPayload { + question: string; + organizationId: string; + questionIndex: number; + totalQuestions: number; +} + +export interface AnswerQuestionResult { + success: boolean; + questionIndex: number; + question: string; + answer: string | null; + sources: Array<{ + sourceType: string; + sourceName?: string; + score: number; + }>; + error?: string; +} - // Update metadata to mark this question as processing - // This allows frontend to show spinner for this specific question when it starts - // Note: When called directly (not as child), metadata.parent is null, so use metadata directly - if (metadata.parent) { - metadata.parent.set(`question_${payload.questionIndex}_status`, 'processing'); - } else { - metadata.set(`question_${payload.questionIndex}_status`, 'processing'); +export interface AnswerQuestionOptions { + /** + * Whether to push updates to Trigger.dev metadata. + * Disable when running outside of a Trigger task (e.g. server actions). + */ + useMetadata?: boolean; +} + +/** + * Core function to answer a question - can be called directly or wrapped in a task + */ +export async function answerQuestion( + payload: AnswerQuestionPayload, + options: AnswerQuestionOptions = {}, +): Promise { + const { useMetadata = true } = options; + + const withMetadata = (fn: () => void) => { + if (!useMetadata) { + return; } try { - // Sync organization embeddings before generating answer - // Uses incremental sync: only updates what changed (much faster than full sync) - // Lock mechanism prevents concurrent syncs for the same organization - try { - await syncOrganizationEmbeddings(payload.organizationId); - logger.info('Organization embeddings synced successfully', { - organizationId: payload.organizationId, - }); - } catch (error) { - logger.warn('Failed to sync organization embeddings', { - organizationId: payload.organizationId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - // Continue with existing embeddings if sync fails - } - - logger.info('🔍 Calling generateAnswerWithRAG', { + fn(); + } catch (error) { + logger.warn('Metadata operation failed – continuing without metadata', { questionIndex: payload.questionIndex, + error: error instanceof Error ? error.message : 'Unknown error', }); + } + }; - const result = await generateAnswerWithRAG( - payload.question, - payload.organizationId, - ); + logger.info('🚀 Starting to process question', { + questionIndex: payload.questionIndex, + totalQuestions: payload.totalQuestions, + question: payload.question.substring(0, 100), + organizationId: payload.organizationId, + }); - logger.info('✅ Successfully generated answer', { - questionIndex: payload.questionIndex, - hasAnswer: !!result.answer, - sourcesCount: result.sources.length, - }); + // Update metadata to mark this question as processing + // This allows frontend to show spinner for this specific question when it starts + withMetadata(() => { + metadata.set(`question_${payload.questionIndex}_status`, 'processing'); + }); - const answerData = { - questionIndex: payload.questionIndex, - question: payload.question, - answer: result.answer, - sources: result.sources, - }; - - // Update metadata with this answer immediately - // This allows frontend to show answers as they complete individually - // When called directly (not as child), use metadata directly instead of metadata.parent - if (metadata.parent) { - metadata.parent.set(`answer_${payload.questionIndex}`, answerData); - metadata.parent.set(`question_${payload.questionIndex}_status`, 'completed'); - metadata.parent.increment('questionsCompleted', 1); - metadata.parent.increment('questionsRemaining', -1); - } else { - // Direct call: update metadata directly for frontend to read - metadata.set(`answer_${payload.questionIndex}`, answerData); - metadata.set(`question_${payload.questionIndex}_status`, 'completed'); - } - - return { - success: true, - questionIndex: payload.questionIndex, - question: payload.question, - answer: result.answer, - sources: result.sources, - }; + const buildMetadataAnswerPayload = (answerValue: string | null) => ({ + questionIndex: payload.questionIndex, + question: payload.question, + answer: answerValue, + // Sources are NOT included in metadata to avoid blocking incremental updates + // Sources will be available in the final output and updated separately + sources: [], + }); + + try { + // Sync organization embeddings before generating answer + // Uses incremental sync: only updates what changed (much faster than full sync) + // Lock mechanism prevents concurrent syncs for the same organization + try { + await syncOrganizationEmbeddings(payload.organizationId); + logger.info('Organization embeddings synced successfully', { + organizationId: payload.organizationId, + }); } catch (error) { - logger.error('❌ Failed to answer question', { - questionIndex: payload.questionIndex, + logger.warn('Failed to sync organization embeddings', { + organizationId: payload.organizationId, error: error instanceof Error ? error.message : 'Unknown error', - errorStack: error instanceof Error ? error.stack : undefined, }); - - const failedAnswerData = { - questionIndex: payload.questionIndex, - question: payload.question, - answer: null, - sources: [], - }; - - // Update metadata even on failure - // When called directly (not as child), use metadata directly instead of metadata.parent - if (metadata.parent) { - metadata.parent.set(`answer_${payload.questionIndex}`, failedAnswerData); - metadata.parent.set(`question_${payload.questionIndex}_status`, 'completed'); - metadata.parent.increment('questionsCompleted', 1); - metadata.parent.increment('questionsRemaining', -1); - } else { - // Direct call: update metadata directly for frontend to read - metadata.set(`answer_${payload.questionIndex}`, failedAnswerData); - metadata.set(`question_${payload.questionIndex}_status`, 'completed'); - } - - return { - success: false, - questionIndex: payload.questionIndex, - question: payload.question, - answer: null, - sources: [], - error: error instanceof Error ? error.message : 'Unknown error', - }; + // Continue with existing embeddings if sync fails } + + logger.info('🔍 Calling generateAnswerWithRAG', { + questionIndex: payload.questionIndex, + }); + + const result = await generateAnswerWithRAG(payload.question, payload.organizationId); + + // Update metadata with this answer immediately + // This allows frontend to show answers as they complete individually + // Sources are NOT included in metadata to avoid blocking incremental updates + // Sources will be available in the final output + const metadataAnswer = buildMetadataAnswerPayload(result.answer); + + withMetadata(() => { + metadata.set(`answer_${payload.questionIndex}`, metadataAnswer); + metadata.set(`question_${payload.questionIndex}_status`, 'completed'); + metadata.increment('questionsCompleted', 1); + metadata.increment('questionsRemaining', -1); + }); + + return { + success: true, + questionIndex: payload.questionIndex, + question: payload.question, + answer: result.answer, + sources: result.sources, + }; + } catch (error) { + logger.error('❌ Failed to answer question', { + questionIndex: payload.questionIndex, + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + }); + + const failedAnswerData = buildMetadataAnswerPayload(null); + + // Update metadata even on failure + withMetadata(() => { + metadata.set(`answer_${payload.questionIndex}`, failedAnswerData); + metadata.set(`question_${payload.questionIndex}_status`, 'completed'); + metadata.increment('questionsCompleted', 1); + metadata.increment('questionsRemaining', -1); + }); + + return { + success: false, + questionIndex: payload.questionIndex, + question: payload.question, + answer: null, + sources: [], + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Trigger.dev task wrapper for frontend use (single question answers) + * This wraps the answerQuestion function so it can be triggered from the frontend + */ +export const answerQuestionTask = task({ + id: 'answer-question', + retry: { + maxAttempts: 3, + }, + run: async (payload: { + question: string; + organizationId: string; + questionIndex: number; + totalQuestions: number; + }) => { + return await answerQuestion(payload); }, }); - diff --git a/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts b/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts index e04949345..b4492e96c 100644 --- a/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts +++ b/apps/app/src/jobs/tasks/vendors/parse-questionnaire.ts @@ -1,9 +1,9 @@ -import { logger, task } from '@trigger.dev/sdk'; import { extractS3KeyFromUrl } from '@/app/s3'; import { env } from '@/env.mjs'; -import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { openai } from '@ai-sdk/openai'; +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { db } from '@db'; +import { logger, task } from '@trigger.dev/sdk'; import { generateObject, generateText, jsonSchema } from 'ai'; import * as XLSX from 'xlsx'; // Sync moved to answer generation tasks for better performance @@ -16,12 +16,9 @@ interface QuestionAnswer { /** * Extracts content from a file using various methods based on file type */ -async function extractContentFromFile( - fileData: string, - fileType: string, -): Promise { +async function extractContentFromFile(fileData: string, fileType: string): Promise { const fileBuffer = Buffer.from(fileData, 'base64'); - + // Handle Excel files (.xlsx, .xls) if ( fileType === 'application/vnd.ms-excel' || @@ -31,38 +28,40 @@ async function extractContentFromFile( try { const excelStartTime = Date.now(); const fileSizeMB = (fileBuffer.length / (1024 * 1024)).toFixed(2); - + logger.info('Processing Excel file', { fileType, fileSizeMB, }); - + const workbook = XLSX.read(fileBuffer, { type: 'buffer' }); - + // Process sheets sequentially (XLSX is synchronous, but this is still fast) // For very large files, sheets are processed one by one to avoid memory issues const sheets: string[] = []; - + for (const sheetName of workbook.SheetNames) { const worksheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); - + // Convert to readable text format const sheetText = jsonData .map((row: any) => { if (Array.isArray(row)) { - return row.filter((cell) => cell !== null && cell !== undefined && cell !== '').join(' | '); + return row + .filter((cell) => cell !== null && cell !== undefined && cell !== '') + .join(' | '); } return String(row); }) .filter((line: string) => line.trim() !== '') .join('\n'); - + if (sheetText.trim()) { sheets.push(`Sheet: ${sheetName}\n${sheetText}`); } } - + const extractionTime = ((Date.now() - excelStartTime) / 1000).toFixed(2); logger.info('Excel file processed', { fileSizeMB, @@ -70,13 +69,15 @@ async function extractContentFromFile( extractedLength: sheets.join('\n\n').length, extractionTimeSeconds: extractionTime, }); - + return sheets.join('\n\n'); } catch (error) { - throw new Error(`Failed to parse Excel file: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Failed to parse Excel file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } } - + // Handle CSV files if (fileType === 'text/csv' || fileType === 'text/comma-separated-values') { try { @@ -85,19 +86,23 @@ async function extractContentFromFile( const lines = text.split('\n').filter((line) => line.trim() !== ''); return lines.join('\n'); } catch (error) { - throw new Error(`Failed to parse CSV file: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Failed to parse CSV file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } } - + // Handle plain text files if (fileType === 'text/plain' || fileType.startsWith('text/')) { try { return fileBuffer.toString('utf-8'); } catch (error) { - throw new Error(`Failed to read text file: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Failed to read text file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } } - + // Handle Word documents - try to use OpenAI vision API if ( fileType === 'application/msword' || @@ -107,7 +112,7 @@ async function extractContentFromFile( 'Word documents (.docx) are best converted to PDF or image format for parsing. Alternatively, use a URL to view the document.', ); } - + // For images and PDFs, use OpenAI vision API // Note: To detect poor PDF text extraction quality (for hybrid approach): // 1. Check text density: words per page < 50 suggests poor extraction @@ -117,22 +122,22 @@ async function extractContentFromFile( // 5. Missing expected patterns: if document should have tables/forms but none detected const isImage = fileType.startsWith('image/'); const isPdf = fileType === 'application/pdf'; - + if (isImage || isPdf) { const base64Data = fileData; const mimeType = fileType; const fileSizeMB = (Buffer.from(fileData, 'base64').length / (1024 * 1024)).toFixed(2); - + logger.info('Extracting content from PDF/image using vision API', { fileType: mimeType, fileSizeMB, }); - + const startTime = Date.now(); - + try { const { text } = await generateText({ - model: openai('gpt-5-mini'), + model: openai('gpt-5.1-mini'), messages: [ { role: 'user', @@ -149,14 +154,14 @@ async function extractContentFromFile( }, ], }); - + const extractionTime = ((Date.now() - startTime) / 1000).toFixed(2); logger.info('Content extracted from PDF/image', { fileType: mimeType, extractedLength: text.length, extractionTimeSeconds: extractionTime, }); - + return text; } catch (error) { const extractionTime = ((Date.now() - startTime) / 1000).toFixed(2); @@ -166,10 +171,12 @@ async function extractContentFromFile( extractionTimeSeconds: extractionTime, error: error instanceof Error ? error.message : 'Unknown error', }); - throw new Error(`Failed to extract content: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Failed to extract content: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } } - + // For other file types that might be binary formats, provide helpful error message throw new Error( `Unsupported file type: ${fileType}. Supported formats: PDF, images (PNG, JPG, etc.), Excel (.xlsx, .xls), CSV, text files (.txt), and Word documents (.docx - convert to PDF for best results).`, @@ -183,7 +190,7 @@ async function extractContentFromUrl(url: string): Promise { if (!env.FIRECRAWL_API_KEY) { throw new Error('Firecrawl API key is not configured'); } - + try { const initialResponse = await fetch('https://api.firecrawl.dev/v1/extract', { method: 'POST', @@ -193,28 +200,29 @@ async function extractContentFromUrl(url: string): Promise { }, body: JSON.stringify({ urls: [url], - prompt: 'Extract all text content from this page, including any questions and answers, forms, or questionnaire data.', + prompt: + 'Extract all text content from this page, including any questions and answers, forms, or questionnaire data.', scrapeOptions: { onlyMainContent: true, removeBase64Images: true, }, }), }); - + const initialData = await initialResponse.json(); - + if (!initialData.success || !initialData.id) { throw new Error('Failed to start Firecrawl extraction'); } - + const jobId = initialData.id; const maxWaitTime = 1000 * 60 * 5; // 5 minutes const pollInterval = 5000; // 5 seconds const startTime = Date.now(); - + while (Date.now() - startTime < maxWaitTime) { await new Promise((resolve) => setTimeout(resolve, pollInterval)); - + const statusResponse = await fetch(`https://api.firecrawl.dev/v1/extract/${jobId}`, { method: 'GET', headers: { @@ -222,9 +230,9 @@ async function extractContentFromUrl(url: string): Promise { Authorization: `Bearer ${env.FIRECRAWL_API_KEY}`, }, }); - + const statusData = await statusResponse.json(); - + if (statusData.status === 'completed' && statusData.data) { // Extract text from the response const extractedData = statusData.data; @@ -238,16 +246,16 @@ async function extractContentFromUrl(url: string): Promise { } return JSON.stringify(extractedData); } - + if (statusData.status === 'failed') { throw new Error('Firecrawl extraction failed'); } - + if (statusData.status === 'cancelled') { throw new Error('Firecrawl extraction was cancelled'); } } - + throw new Error('Firecrawl extraction timed out'); } catch (error) { throw error instanceof Error ? error : new Error('Failed to extract content from URL'); @@ -267,29 +275,29 @@ async function extractContentFromAttachment( organizationId, }, }); - + if (!attachment) { throw new Error('Attachment not found'); } - + const bucketName = process.env.APP_AWS_BUCKET_NAME; if (!bucketName) { throw new Error('APP_AWS_BUCKET_NAME environment variable is not set in Trigger.dev.'); } - + const key = extractS3KeyFromUrl(attachment.url); const s3Client = createS3Client(); const getCommand = new GetObjectCommand({ Bucket: bucketName, Key: key, }); - + const response = await s3Client.send(getCommand); - + if (!response.Body) { throw new Error('Failed to retrieve attachment from S3'); } - + // Convert stream to buffer const chunks: Uint8Array[] = []; for await (const chunk of response.Body as any) { @@ -297,14 +305,13 @@ async function extractContentFromAttachment( } const buffer = Buffer.concat(chunks); const base64Data = buffer.toString('base64'); - + // Determine file type from attachment or content type const fileType = - response.ContentType || - (attachment.type === 'image' ? 'image/png' : 'application/pdf'); - + response.ContentType || (attachment.type === 'image' ? 'image/png' : 'application/pdf'); + const content = await extractContentFromFile(base64Data, fileType); - + return { content, fileType }; } @@ -340,9 +347,11 @@ async function extractContentFromS3Key( fileType: string, ): Promise<{ content: string; fileType: string }> { const questionnaireBucket = process.env.APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET; - + if (!questionnaireBucket) { - throw new Error('Questionnaire upload bucket is not configured. Please set APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET environment variable in Trigger.dev.'); + throw new Error( + 'Questionnaire upload bucket is not configured. Please set APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET environment variable in Trigger.dev.', + ); } const s3Client = createS3Client(); @@ -351,13 +360,13 @@ async function extractContentFromS3Key( Bucket: questionnaireBucket, Key: s3Key, }); - + const response = await s3Client.send(getCommand); - + if (!response.Body) { throw new Error('Failed to retrieve file from S3'); } - + // Convert stream to buffer const chunks: Uint8Array[] = []; for await (const chunk of response.Body as any) { @@ -365,19 +374,23 @@ async function extractContentFromS3Key( } const buffer = Buffer.concat(chunks); const base64Data = buffer.toString('base64'); - + // Use provided fileType or determine from content type const detectedFileType = response.ContentType || fileType || 'application/octet-stream'; - + const content = await extractContentFromFile(base64Data, detectedFileType); - + return { content, fileType: detectedFileType }; } /** * Parses questions and answers from a single chunk of content */ -async function parseChunkQuestionsAndAnswers(chunk: string, chunkIndex: number, totalChunks: number): Promise { +async function parseChunkQuestionsAndAnswers( + chunk: string, + chunkIndex: number, + totalChunks: number, +): Promise { const { object } = await generateObject({ model: openai('gpt-5-mini'), // Fastest model for structured extraction (20-40% faster than GPT-4o-mini) mode: 'json', @@ -394,10 +407,7 @@ async function parseChunkQuestionsAndAnswers(chunk: string, chunkIndex: number, description: 'The question text', }, answer: { - anyOf: [ - { type: 'string' }, - { type: 'null' }, - ], + anyOf: [{ type: 'string' }, { type: 'null' }], description: 'The answer to the question. Use null if no answer is provided.', }, }, @@ -407,22 +417,33 @@ async function parseChunkQuestionsAndAnswers(chunk: string, chunkIndex: number, }, required: ['questionsAndAnswers'], }), - system: `Extract question-answer pairs from vendor questionnaires. Return structured pairs. Use null for missing answers.`, - prompt: totalChunks > 1 - ? `Extract question-answer pairs from chunk ${chunkIndex + 1} of ${totalChunks}: + system: `You parse vendor questionnaires. Return only genuine question text paired with its answer. +- Ignore table headers, column labels, metadata rows, or placeholder words such as "Question", "Company Name", "Department", "Assessment Date", "Name of Assessor". +- A valid question is a meaningful sentence (usually ends with '?' or starts with interrogatives like What/Why/How/When/Where/Is/Are/Do/Does/Can/Will/Should). +- Do not fabricate answers; if no answer is provided, set answer to null. +- Keep the original question wording but trim whitespace.`, + prompt: + totalChunks > 1 + ? `Chunk ${chunkIndex + 1} of ${totalChunks}. +Instructions: +- Extract only question → answer pairs that represent real questions. +- Ignore rows or cells that contain only headers/labels (e.g. "Company Name", "Department", "Assessment Date", "Question", "Answer") or other metadata. +- If an answer is blank, set it to null. -${chunk} +Chunk content: +${chunk}` + : `Instructions: +- Extract all meaningful question → answer pairs from the following content. +- Ignore rows or cells that contain only headers/labels (e.g. "Company Name", "Department", "Assessment Date", "Question", "Answer", "Name of Assessor"). +- Keep only entries that are actual questions (end with '?' or start with interrogative words). +- If an answer is blank, set it to null. -Return all question-answer pairs found in this chunk.` - : `Extract all question-answer pairs from: - -${chunk} - -Return a structured list of questions and their corresponding answers.`, +Content: +${chunk}`, }); - + const parsed = (object as { questionsAndAnswers: QuestionAnswer[] }).questionsAndAnswers; - + // Post-process to ensure empty strings are converted to null return parsed.map((qa) => ({ question: qa.question, @@ -435,88 +456,59 @@ Return a structured list of questions and their corresponding answers.`, * Optimized to handle large content by chunking and processing in parallel */ async function parseQuestionsAndAnswers(content: string): Promise { - // GPT-5-mini can handle ~128k tokens, chunk at 100k tokens for efficiency - // 1 token ≈ 4 characters, so 100k tokens ≈ 400k characters - const MAX_CHUNK_SIZE_CHARS = 400_000; // Increased for fewer API calls - const MIN_CHUNK_SIZE_CHARS = 10_000; // Don't chunk if content is small - - // If content is small, process directly - if (content.length <= MIN_CHUNK_SIZE_CHARS) { - logger.info('Processing content directly (small size)', { - contentLength: content.length, + // GPT-5-mini can handle ~128k tokens. Chunk by individual questions (1 question = 1 chunk) for parallel processing. + const MAX_CHUNK_SIZE_CHARS = 80_000; + const MIN_CHUNK_SIZE_CHARS = 5_000; + const MAX_QUESTIONS_PER_CHUNK = 1; // Each chunk contains exactly one question + + const chunkInfos = buildQuestionAwareChunks(content, { + maxChunkChars: MAX_CHUNK_SIZE_CHARS, + minChunkChars: MIN_CHUNK_SIZE_CHARS, + maxQuestionsPerChunk: MAX_QUESTIONS_PER_CHUNK, + }); + + if (chunkInfos.length === 0) { + logger.warn('No content found after preprocessing, returning empty result'); + return []; + } + + if (chunkInfos.length === 1) { + logger.info('Processing content as a single chunk', { + contentLength: chunkInfos[0].content.length, + estimatedQuestions: chunkInfos[0].questionCount, }); - return parseChunkQuestionsAndAnswers(content, 0, 1); + return parseChunkQuestionsAndAnswers(chunkInfos[0].content, 0, 1); } - - // Chunk large content - logger.info('Chunking large content for parallel processing', { + + const totalEstimatedQuestions = chunkInfos.reduce((sum, chunk) => sum + chunk.questionCount, 0); + + logger.info('Chunking content by individual questions (1 question per chunk) for parallel processing', { contentLength: content.length, - estimatedChunks: Math.ceil(content.length / MAX_CHUNK_SIZE_CHARS), + totalChunks: chunkInfos.length, + questionsPerChunk: 1, // Each chunk contains exactly one question }); - - const chunks: string[] = []; - let start = 0; - - while (start < content.length) { - const end = Math.min(start + MAX_CHUNK_SIZE_CHARS, content.length); - let chunk = content.slice(start, end); - - // Try to break at smart boundaries for better context - // Prefer breaking after question marks (preserves Q&A pairs) - if (end < content.length && chunk.length > MAX_CHUNK_SIZE_CHARS * 0.8) { - let breakPoint = -1; - - // First try: break after question mark (best for Q&A content) - const lastQuestionMark = chunk.lastIndexOf('?'); - if (lastQuestionMark > MAX_CHUNK_SIZE_CHARS * 0.7) { - // Find end of line after question mark - const afterQuestion = chunk.indexOf('\n', lastQuestionMark); - breakPoint = afterQuestion !== -1 ? afterQuestion + 1 : lastQuestionMark + 1; - } - - // Fallback: break at paragraph boundaries - if (breakPoint === -1) { - const lastDoubleNewline = chunk.lastIndexOf('\n\n'); - const lastSingleNewline = chunk.lastIndexOf('\n'); - breakPoint = Math.max(lastDoubleNewline, lastSingleNewline); - } - - if (breakPoint > MAX_CHUNK_SIZE_CHARS * 0.7) { - chunk = chunk.slice(0, breakPoint + 1); - } - } - - if (chunk.trim().length > 0) { - chunks.push(chunk.trim()); - } - - start = end; - } - - logger.info('Content chunked, processing in parallel', { - totalChunks: chunks.length, - }); - - // Process ALL chunks in parallel for maximum speed - // GPT-5-mini has high rate limits and is faster, so we can process all at once + + // Process all chunks in parallel for maximum speed const parseStartTime = Date.now(); - const allPromises = chunks.map((chunk, index) => - parseChunkQuestionsAndAnswers(chunk, index, chunks.length), + const allPromises = chunkInfos.map((chunk, index) => + parseChunkQuestionsAndAnswers(chunk.content, index, chunkInfos.length), ); - + const allResults = await Promise.all(allPromises); const parseTime = ((Date.now() - parseStartTime) / 1000).toFixed(2); - + + const totalRawQuestions = allResults.reduce((sum, chunk) => sum + chunk.length, 0); + logger.info('All chunks processed in parallel', { - totalChunks: chunks.length, + totalChunks: chunkInfos.length, parseTimeSeconds: parseTime, - totalQuestions: allResults.flat().length, + totalQuestions: totalRawQuestions, }); - + // Deduplicate questions (same question might appear in multiple chunks) // Use Map for O(1) lookups and preserve order const seenQuestions = new Map(); - + for (const qaArray of allResults) { for (const qa of qaArray) { const normalizedQuestion = qa.question.toLowerCase().trim(); @@ -526,20 +518,116 @@ async function parseQuestionsAndAnswers(content: string): Promise { + const chunkText = currentChunk.join('\n').trim(); + if (!chunkText) { + return; + } + chunks.push({ + content: chunkText, + questionCount: 1, // Each chunk contains exactly one question + }); + currentChunk = []; + currentQuestionFound = false; + }; + + for (const line of lines) { + const trimmedLine = line.trim(); + const isEmpty = trimmedLine.length === 0; + const looksLikeQuestion = !isEmpty && looksLikeQuestionLine(trimmedLine); + + // If we find a new question and we already have a question in the current chunk, start a new chunk + if (looksLikeQuestion && currentQuestionFound && currentChunk.length > 0) { + pushChunk(); + } + + // Add line to current chunk (including empty lines for context) + if (!isEmpty || currentChunk.length > 0) { + currentChunk.push(line); + } + + // Mark that we've found a question in this chunk + if (looksLikeQuestion) { + currentQuestionFound = true; + } + } + + // Push the last chunk if it has content + if (currentChunk.length > 0) { + pushChunk(); + } + + // If no questions were detected, return the entire content as a single chunk + return chunks.length > 0 + ? chunks + : [ + { + content: trimmedContent, + questionCount: estimateQuestionCount(trimmedContent), + }, + ]; +} + +function looksLikeQuestionLine(line: string): boolean { + const questionSuffix = /[??]\s*$/; + const explicitQuestionPrefix = /^(?:\d+\s*[\).\]]\s*)?(?:question|q)\b/i; + const interrogativePrefix = + /^(?:what|why|how|when|where|is|are|does|do|can|will|should|list|describe|explain)\b/i; + + return ( + questionSuffix.test(line) || explicitQuestionPrefix.test(line) || interrogativePrefix.test(line) + ); +} + +function estimateQuestionCount(text: string): number { + const questionMarks = text.match(/[??]/g)?.length ?? 0; + if (questionMarks > 0) { + return questionMarks; + } + const lines = text.split(/\r?\n/).filter((line) => looksLikeQuestionLine(line.trim())); + if (lines.length > 0) { + return lines.length; + } + // Fallback heuristic: assume roughly one question per 1200 chars + return Math.max(1, Math.floor(text.length / 1200)); +} + export const parseQuestionnaireTask = task({ id: 'parse-questionnaire', - machine: 'large-2x', retry: { maxAttempts: 2, }, @@ -558,7 +646,7 @@ export const parseQuestionnaireTask = task({ s3Key?: string; }) => { const taskStartTime = Date.now(); - + logger.info('Starting parse questionnaire task', { inputType: payload.inputType, organizationId: payload.organizationId, @@ -567,22 +655,19 @@ export const parseQuestionnaireTask = task({ try { // Note: Sync is now done during answer generation for better performance // Parsing is fast and doesn't need embeddings - + let extractedContent: string; - + // Extract content based on input type switch (payload.inputType) { case 'file': { if (!payload.fileData || !payload.fileType) { throw new Error('File data and file type are required for file input'); } - extractedContent = await extractContentFromFile( - payload.fileData, - payload.fileType, - ); + extractedContent = await extractContentFromFile(payload.fileData, payload.fileType); break; } - + case 'url': { if (!payload.url) { throw new Error('URL is required for URL input'); @@ -590,7 +675,7 @@ export const parseQuestionnaireTask = task({ extractedContent = await extractContentFromUrl(payload.url); break; } - + case 'attachment': { if (!payload.attachmentId) { throw new Error('Attachment ID is required for attachment input'); @@ -602,35 +687,32 @@ export const parseQuestionnaireTask = task({ extractedContent = result.content; break; } - + case 's3': { if (!payload.s3Key || !payload.fileType) { throw new Error('S3 key and file type are required for S3 input'); } - const result = await extractContentFromS3Key( - payload.s3Key, - payload.fileType, - ); + const result = await extractContentFromS3Key(payload.s3Key, payload.fileType); extractedContent = result.content; break; } - + default: throw new Error(`Unsupported input type: ${payload.inputType}`); } - + logger.info('Content extracted successfully', { inputType: payload.inputType, contentLength: extractedContent.length, }); - + // Parse questions and answers from extracted content const parseStartTime = Date.now(); const questionsAndAnswers = await parseQuestionsAndAnswers(extractedContent); const parseTime = ((Date.now() - parseStartTime) / 1000).toFixed(2); - + const totalTime = ((Date.now() - taskStartTime) / 1000).toFixed(2); - + logger.info('Questions and answers parsed', { questionCount: questionsAndAnswers.length, parseTimeSeconds: parseTime, @@ -683,11 +765,11 @@ export const parseQuestionnaireTask = task({ // Frontend can handle saving later questionnaireId = ''; } - + // NOTE: We no longer add questionnaire Q&A pairs to the vector database // They are not used as a source for generating answers (only Policy and Context are used) // This prevents cluttering the vector DB with potentially outdated questionnaire answers - // + // // If you need to use questionnaire Q&A as a source in the future, uncomment this block: /* const vendorName = 'Security Questionnaire'; @@ -738,7 +820,7 @@ export const parseQuestionnaireTask = task({ // Don't fail parsing if vector DB addition fails } */ - + return { success: true, questionnaireId, @@ -750,10 +832,7 @@ export const parseQuestionnaireTask = task({ error: error instanceof Error ? error.message : 'Unknown error', errorStack: error instanceof Error ? error.stack : undefined, }); - throw error instanceof Error - ? error - : new Error('Failed to parse questionnaire'); + throw error instanceof Error ? error : new Error('Failed to parse questionnaire'); } }, }); - diff --git a/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts b/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts index 2310a78a4..7d0695b9d 100644 --- a/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts +++ b/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts @@ -1,12 +1,12 @@ -import { logger, metadata, task } from '@trigger.dev/sdk'; import { syncOrganizationEmbeddings } from '@/lib/vector'; +import { logger, metadata, task } from '@trigger.dev/sdk'; import { answerQuestion } from './answer-question'; -const BATCH_SIZE = 500; // Process 500 (prev. used) 10 questions at a time +// Process all questions in parallel by calling answerQuestion directly as a function +// This allows metadata updates to happen incrementally as questions complete export const vendorQuestionnaireOrchestratorTask = task({ id: 'vendor-questionnaire-orchestrator', - machine: 'large-2x', retry: { maxAttempts: 3, }, @@ -42,9 +42,9 @@ export const vendorQuestionnaireOrchestratorTask = task({ // Filter questions that need answers (skip already answered) // Preserve original index if provided (for single question answers) const questionsToAnswer = payload.questionsAndAnswers - .map((qa, index) => ({ - ...qa, - index: (qa as any)._originalIndex !== undefined ? (qa as any)._originalIndex : index + .map((qa, index) => ({ + ...qa, + index: (qa as any)._originalIndex !== undefined ? (qa as any)._originalIndex : index, })) .filter((qa) => !qa.answer || qa.answer.trim().length === 0); @@ -57,9 +57,7 @@ export const vendorQuestionnaireOrchestratorTask = task({ metadata.set('questionsTotal', questionsToAnswer.length); metadata.set('questionsCompleted', 0); metadata.set('questionsRemaining', questionsToAnswer.length); - metadata.set('currentBatch', 0); - metadata.set('totalBatches', Math.ceil(questionsToAnswer.length / BATCH_SIZE)); - + // Initialize individual question statuses - all start as 'pending' // Each question will update its own status to 'processing' when it starts // and 'completed' when it finishes @@ -67,7 +65,20 @@ export const vendorQuestionnaireOrchestratorTask = task({ metadata.set(`question_${qa.index}_status`, 'pending'); }); - // Process questions in batches of 10 + // Process all questions in parallel by calling answerQuestion directly + // This allows metadata updates to happen incrementally as questions complete + const results = await Promise.all( + questionsToAnswer.map((qa) => + answerQuestion({ + question: qa.question, + organizationId: payload.organizationId, + questionIndex: qa.index, + totalQuestions: payload.questionsAndAnswers.length, + }), + ), + ); + + // Process results const allAnswers: Array<{ questionIndex: number; question: string; @@ -77,84 +88,12 @@ export const vendorQuestionnaireOrchestratorTask = task({ sourceName?: string; score: number; }>; - }> = []; - - for (let i = 0; i < questionsToAnswer.length; i += BATCH_SIZE) { - const batch = questionsToAnswer.slice(i, i + BATCH_SIZE); - const batchNumber = Math.floor(i / BATCH_SIZE) + 1; - const totalBatches = Math.ceil(questionsToAnswer.length / BATCH_SIZE); - - logger.info(`Processing batch ${batchNumber}/${totalBatches}`, { - batchSize: batch.length, - questionIndices: batch.map((q) => q.index), - }); - - // Update metadata - metadata.set('currentBatch', batchNumber); - - // Use batchTriggerAndWait - this runs tasks in parallel and waits for all to complete - const batchItems = batch.map((qa) => ({ - payload: { - question: qa.question, - organizationId: payload.organizationId, - questionIndex: qa.index, - totalQuestions: payload.questionsAndAnswers.length, - }, - })); - - const batchHandle = await answerQuestion.batchTriggerAndWait(batchItems); - - // Process batch results - batchHandle has a .runs property with the results array - batchHandle.runs.forEach((run, batchIdx) => { - const qa = batch[batchIdx]; - - if (run.ok && run.output) { - const taskResult = run.output; - if (taskResult.success && taskResult.answer) { - allAnswers.push({ - questionIndex: qa.index, - question: qa.question, - answer: taskResult.answer, - sources: taskResult.sources, - }); - } else { - allAnswers.push({ - questionIndex: qa.index, - question: qa.question, - answer: null, - sources: [], - }); - } - } else { - // Task failed - error is only available when run.ok is false - const errorMessage = run.ok === false && run.error - ? (run.error instanceof Error ? run.error.message : String(run.error)) - : 'Unknown error'; - - logger.error('Task failed', { - questionIndex: qa.index, - error: errorMessage, - }); - allAnswers.push({ - questionIndex: qa.index, - question: qa.question, - answer: null, - sources: [], - }); - } - }); - - // Note: Individual answers and progress counters are updated in metadata - // by each answer-question task via metadata.parent.set() and metadata.parent.increment() - // This allows frontend to show answers as they complete individually - // No need to update counters here - they're already updated by individual tasks - - logger.info(`Batch ${batchNumber}/${totalBatches} completed`, { - batchSize: batch.length, - totalAnswersSoFar: allAnswers.length, - remaining: questionsToAnswer.length - allAnswers.length, - }); - } + }> = results.map((result) => ({ + questionIndex: result.questionIndex, + question: result.question, + answer: result.answer, + sources: result.sources, + })); logger.info('Auto-answer questionnaire completed', { vendorId: payload.vendorId, @@ -171,4 +110,3 @@ export const vendorQuestionnaireOrchestratorTask = task({ }; }, }); - diff --git a/apps/app/src/lib/vector/core/count-embeddings.ts b/apps/app/src/lib/vector/core/count-embeddings.ts index 821129b8f..2ec106599 100644 --- a/apps/app/src/lib/vector/core/count-embeddings.ts +++ b/apps/app/src/lib/vector/core/count-embeddings.ts @@ -30,7 +30,7 @@ export async function countEmbeddings( const results = await vectorIndex.query({ vector: queryEmbedding, - topK: 1000, // Max allowed by Upstash Vector + topK: 100, // Max allowed by Upstash Vector includeMetadata: true, }); @@ -101,7 +101,7 @@ export async function listManualAnswerEmbeddings( const results = await vectorIndex.query({ vector: queryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); diff --git a/apps/app/src/lib/vector/core/find-existing-embeddings.ts b/apps/app/src/lib/vector/core/find-existing-embeddings.ts index ff1284010..0ef2ae303 100644 --- a/apps/app/src/lib/vector/core/find-existing-embeddings.ts +++ b/apps/app/src/lib/vector/core/find-existing-embeddings.ts @@ -48,7 +48,7 @@ export async function findEmbeddingsForSource( const orgQueryEmbedding = await generateEmbedding(organizationId); const orgResults = await vectorIndex.query({ vector: orgQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -84,7 +84,7 @@ export async function findEmbeddingsForSource( const sourceQueryEmbedding = await generateEmbedding(sourceId); const sourceResults = await vectorIndex.query({ vector: sourceQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -122,7 +122,7 @@ export async function findEmbeddingsForSource( const combinedQueryEmbedding = await generateEmbedding(combinedQuery); const combinedResults = await vectorIndex.query({ vector: combinedQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -160,7 +160,7 @@ export async function findEmbeddingsForSource( const docNameQueryEmbedding = await generateEmbedding(documentName); const docNameResults = await vectorIndex.query({ vector: docNameQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -221,7 +221,7 @@ export async function findEmbeddingsForSource( const contentQueryEmbedding = await generateEmbedding(contentQuery); const contentResults = await vectorIndex.query({ vector: contentQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -251,7 +251,7 @@ export async function findEmbeddingsForSource( const filenameQueryEmbedding = await generateEmbedding(chunkDocumentName); const filenameResults = await vectorIndex.query({ vector: filenameQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -306,7 +306,7 @@ export async function findEmbeddingsForSource( const genericQueryEmbedding = await generateEmbedding(genericQuery); const genericResults = await vectorIndex.query({ vector: genericQueryEmbedding, - topK: 1000, + topK: 100, includeMetadata: true, }); @@ -389,7 +389,7 @@ export async function findAllOrganizationEmbeddings( // Respect Upstash Vector limit of 1000 const results = await vectorIndex.query({ vector: queryEmbedding, - topK: 1000, // Max allowed by Upstash Vector + topK: 100, // Max allowed by Upstash Vector includeMetadata: true, }); diff --git a/apps/app/src/lib/vector/core/find-similar.ts b/apps/app/src/lib/vector/core/find-similar.ts index 233099a51..7767ac531 100644 --- a/apps/app/src/lib/vector/core/find-similar.ts +++ b/apps/app/src/lib/vector/core/find-similar.ts @@ -16,6 +16,7 @@ export interface SimilarContentResult { vendorName?: string; questionnaireQuestion?: string; documentName?: string; + manualAnswerQuestion?: string; } /** @@ -48,7 +49,7 @@ export async function findSimilarContent( // so we'll filter results after retrieval const results = await vectorIndex.query({ vector: queryEmbedding, - topK: limit * 2, // Get more results to account for filtering + topK: 100, // Get more results to account for filtering includeMetadata: true, }); @@ -81,6 +82,7 @@ export async function findSimilarContent( vendorName: metadata?.vendorName, questionnaireQuestion: metadata?.questionnaireQuestion, documentName: metadata?.documentName, + manualAnswerQuestion: metadata?.manualAnswerQuestion, }; }); diff --git a/apps/app/src/lib/vector/core/upsert-embedding.ts b/apps/app/src/lib/vector/core/upsert-embedding.ts index 61dfadd77..7aaa6cca1 100644 --- a/apps/app/src/lib/vector/core/upsert-embedding.ts +++ b/apps/app/src/lib/vector/core/upsert-embedding.ts @@ -17,6 +17,7 @@ export interface EmbeddingMetadata { vendorName?: string; questionnaireQuestion?: string; documentName?: string; + manualAnswerQuestion?: string; updatedAt?: string; // ISO timestamp for incremental sync comparison } @@ -62,6 +63,7 @@ export async function upsertEmbedding( ...(metadata.vendorId && { vendorId: metadata.vendorId }), ...(metadata.vendorName && { vendorName: metadata.vendorName }), ...(metadata.questionnaireQuestion && { questionnaireQuestion: metadata.questionnaireQuestion }), + ...(metadata.manualAnswerQuestion && { manualAnswerQuestion: metadata.manualAnswerQuestion }), ...(metadata.documentName && { documentName: metadata.documentName }), ...(metadata.updatedAt && { updatedAt: metadata.updatedAt }), }; @@ -171,6 +173,9 @@ export async function batchUpsertEmbeddings( ...(item.metadata.questionnaireQuestion && { questionnaireQuestion: item.metadata.questionnaireQuestion, }), + ...(item.metadata.manualAnswerQuestion && { + manualAnswerQuestion: item.metadata.manualAnswerQuestion, + }), ...(item.metadata.documentName && { documentName: item.metadata.documentName }), ...(item.metadata.updatedAt && { updatedAt: item.metadata.updatedAt }), }, diff --git a/apps/app/src/lib/vector/sync/sync-manual-answer.ts b/apps/app/src/lib/vector/sync/sync-manual-answer.ts index fcddffa55..fd4cebf91 100644 --- a/apps/app/src/lib/vector/sync/sync-manual-answer.ts +++ b/apps/app/src/lib/vector/sync/sync-manual-answer.ts @@ -61,6 +61,7 @@ export async function syncManualAnswerToVector( sourceType: 'manual_answer', sourceId: manualAnswerId, content: text, + manualAnswerQuestion: manualAnswer.question, // Store question for source identification updatedAt: manualAnswer.updatedAt.toISOString(), }); diff --git a/apps/app/src/lib/vector/sync/sync-organization.ts b/apps/app/src/lib/vector/sync/sync-organization.ts index 342fe5ba3..7a8480de1 100644 --- a/apps/app/src/lib/vector/sync/sync-organization.ts +++ b/apps/app/src/lib/vector/sync/sync-organization.ts @@ -259,7 +259,7 @@ async function performSync(organizationId: string): Promise { return; // Skip empty context } - const chunks = chunkText(contextText, 500, 50); + const chunks = chunkText(contextText, 8000, 50); if (chunks.length === 0) { return; // Skip if no chunks @@ -358,6 +358,7 @@ async function performSync(organizationId: string): Promise { sourceType: 'manual_answer' as const, sourceId: ma.id, content: text, + manualAnswerQuestion: ma.question, // Store question for source identification updatedAt, }, }; diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 9b8df5d76..9c080c692 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -15,7 +15,7 @@ "groups": [ { "group": "Get Started", - "pages": ["introduction", "automated-evidence", "device-agent"] + "pages": ["introduction", "automated-evidence", "device-agent", "security-questionnaire"] } ] }, diff --git a/packages/docs/security-questionnaire.mdx b/packages/docs/security-questionnaire.mdx new file mode 100644 index 000000000..b465e3eea --- /dev/null +++ b/packages/docs/security-questionnaire.mdx @@ -0,0 +1,128 @@ +--- +title: "Security Questionnaire" +description: "Automatically answer security questionnaires using AI-powered analysis of your organization's policies and documentation" +--- + +### About Security Questionnaire + +The Security Questionnaire feature allows you to automatically analyze and answer vendor security questionnaires using AI. Upload questionnaires from vendors, and our system will extract questions and generate answers based on your organization's published policies and documentation. + +**Key Benefits:** +- **Automated Question Extraction**: Upload questionnaire files and let AI extract all questions automatically +- **Intelligent Answer Generation**: Answers are generated based on your published policies and organizational context +- **Manual Review & Editing**: Review and edit answers before exporting +- **Knowledge Base Integration**: Leverage your organization's knowledge base for comprehensive answers + +### Prerequisites + +Before using the Security Questionnaire feature, ensure you have: + +1. **Published Policies**: You must have at least one published policy in your organization + - Navigate to **Policies** → **All Policies** to publish policies + - Published policies are used as the source of truth for generating answers + + + If you don't have published policies, you'll see an onboarding screen prompting you to publish policies first. + + +### Getting Started + +#### Step 1: Access Security Questionnaire + +1. Navigate to **Security Questionnaire** in your organization dashboard +2. You'll see the overview page with: + - A "Create New Questionnaire" card + - History of previously parsed questionnaires + +#### Step 2: Create a New Questionnaire + +1. Click the **New Questionnaire** button +2. Upload your questionnaire file (PDF, DOCX, or other supported formats) +3. The system will automatically: + - Extract questions from the document + - Generate answers based on your published policies + - Create a structured questionnaire view + +#### Step 3: Review and Edit Answers + +Once the questionnaire is parsed: + +- **View Questions**: Browse through all extracted questions +- **Review Answers**: Check AI-generated answers for accuracy +- **Edit Answers**: Click on any answer to edit it manually +- **Add Context**: Link answers to specific knowledge base documents or policies +- **Manual Answers**: Add manual answers for questions that require custom responses + +#### Step 4: Export Questionnaire + +After reviewing and editing: + +1. Click **Export** to generate the completed questionnaire +2. The exported file will include all your answers ready for submission + +### Features + +#### Knowledge Base Integration + +The Security Questionnaire feature integrates with your organization's knowledge base: + +- **Published Policies**: Automatically references your published policies +- **Additional Documents**: Upload additional context documents for better answer generation +- **Manual Answers**: Create reusable manual answers for common questions + +#### Answer Management + + + ### Automated Answers + + Answers generated automatically by AI based on your policies and documentation. + + ### Manual Answers + + Custom answers you create for specific questions. These can be reused across questionnaires. + + ### Knowledge Base Answers + + Answers linked to specific documents in your knowledge base for traceability. + + +### Best Practices + +1. **Keep Policies Updated**: Ensure your published policies are current and comprehensive +2. **Review All Answers**: Always review AI-generated answers before exporting +3. **Add Context**: Link answers to specific policies or documents when possible +4. **Use Manual Answers**: Create manual answers for frequently asked questions +5. **Organize Knowledge Base**: Maintain a well-organized knowledge base for better answer quality + +### Troubleshooting + + + ### No Published Policies + + If you see the onboarding screen, you need to publish at least one policy first. + + Navigate to **Policies** → **All Policies** and publish your policies. + + ### Questions Not Extracted + + If questions aren't extracted properly: + - Ensure the questionnaire file is readable and not corrupted + - Try re-uploading the file + - Contact support if issues persist + + ### Answers Not Accurate + + If generated answers aren't accurate: + - Review and update your published policies + - Add additional context documents to your knowledge base + - Manually edit answers as needed + + +### Support + +For additional assistance with Security Questionnaire: + +1. Check our [Knowledge Base](https://help.trycomp.ai/security-questionnaire) +2. Contact support at [support@trycomp.ai](mailto:support@trycomp.ai) +3. Join our [Discord community](https://discord.gg/compai) for peer support +