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({
-
+
Export
-
+
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({
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireView.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireView.tsx
new file mode 100644
index 000000000..2db3ed02f
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireView.tsx
@@ -0,0 +1,194 @@
+'use client';
+
+import { QuestionnaireResults } from './QuestionnaireResults';
+import { QuestionnaireSidebar } from './QuestionnaireSidebar';
+import { QuestionnaireUpload } from './QuestionnaireUpload';
+
+interface QuestionnaireViewProps {
+ // Common props
+ orgId: string;
+ results: Array<{
+ question: string;
+ answer: string | null;
+ sources: any;
+ failedToGenerate?: boolean;
+ status?: 'untouched' | 'generated' | 'manual';
+ _originalIndex?: number;
+ }> | null;
+ filteredResults: Array<{
+ question: string;
+ answer: string | null;
+ sources: any;
+ failedToGenerate?: boolean;
+ status?: 'untouched' | 'generated' | 'manual';
+ _originalIndex?: number;
+ }> | null;
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ editingIndex: number | null;
+ editingAnswer: string;
+ setEditingAnswer: (answer: string) => void;
+ expandedSources: Set;
+ questionStatuses: Map;
+ answeringQuestionIndex: number | null;
+ answerQueue?: number[];
+ hasClickedAutoAnswer: boolean;
+ isLoading: boolean;
+ isAutoAnswering: boolean;
+ isExporting: boolean;
+ isSaving?: boolean;
+ savingIndex?: number | null;
+ totalCount: number;
+ answeredCount: number;
+ progressPercentage: number;
+ onAutoAnswer: () => void;
+ onAnswerSingleQuestion: (index: number) => void;
+ onEditAnswer: (index: number) => void;
+ onSaveAnswer: (index: number) => void;
+ onCancelEdit: () => void;
+ onExport: (format: 'xlsx' | 'csv' | 'pdf') => void;
+ onToggleSource: (index: number) => void;
+
+ // New questionnaire specific props (optional)
+ selectedFile?: File | null;
+ onFileSelect?: (acceptedFiles: File[], rejectedFiles: any[]) => void;
+ onFileRemove?: () => void;
+ onParse?: () => void;
+ parseStatus?: 'uploading' | 'starting' | 'queued' | 'analyzing' | 'processing' | null;
+ showExitDialog?: boolean;
+ onShowExitDialogChange?: (show: boolean) => void;
+ onExit?: () => void;
+
+ // Existing questionnaire specific props (optional)
+ filename?: string;
+ description?: string;
+}
+
+export function QuestionnaireView({
+ orgId,
+ results,
+ filteredResults,
+ searchQuery,
+ setSearchQuery,
+ editingIndex,
+ editingAnswer,
+ setEditingAnswer,
+ expandedSources,
+ questionStatuses,
+ answeringQuestionIndex,
+ answerQueue = [],
+ hasClickedAutoAnswer,
+ isLoading,
+ isAutoAnswering,
+ isExporting,
+ isSaving,
+ savingIndex,
+ totalCount,
+ answeredCount,
+ progressPercentage,
+ onAutoAnswer,
+ onAnswerSingleQuestion,
+ onEditAnswer,
+ onSaveAnswer,
+ onCancelEdit,
+ onExport,
+ onToggleSource,
+ // New questionnaire props
+ selectedFile,
+ onFileSelect,
+ onFileRemove,
+ onParse,
+ parseStatus,
+ showExitDialog = false,
+ onShowExitDialogChange,
+ onExit,
+ // Existing questionnaire props
+ filename,
+ description,
+}: QuestionnaireViewProps) {
+ const hasResults = results && results.length > 0;
+ const isNewQuestionnaire = selectedFile !== undefined && onFileSelect !== undefined;
+
+ // Show upload UI only for new questionnaire when there are no results
+ if (isNewQuestionnaire && !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.
+
+
+
+
+ {})}
+ onParse={onParse ?? (() => {})}
+ isLoading={isLoading}
+ parseStatus={parseStatus ?? null}
+ orgId={orgId}
+ />
+
+
+
+
+
+
+ );
+ }
+
+ // Show results (for both new and existing questionnaires)
+ return (
+
+
+
+ {filename || 'Security Questionnaire'}
+
+
+ {description ||
+ "Review and manage answers for this questionnaire"}
+
+
+
}
+ answeringQuestionIndex={answeringQuestionIndex}
+ answerQueue={answerQueue}
+ hasClickedAutoAnswer={hasClickedAutoAnswer}
+ isLoading={isLoading}
+ isAutoAnswering={isAutoAnswering}
+ isExporting={isExporting}
+ isSaving={isSaving}
+ savingIndex={savingIndex}
+ showExitDialog={showExitDialog}
+ onShowExitDialogChange={onShowExitDialogChange ?? (() => {})}
+ onExit={onExit ?? (() => {})}
+ onAutoAnswer={onAutoAnswer}
+ onAnswerSingleQuestion={onAnswerSingleQuestion}
+ onEditAnswer={onEditAnswer}
+ onSaveAnswer={onSaveAnswer}
+ onCancelEdit={onCancelEdit}
+ onExport={onExport}
+ onToggleSource={onToggleSource}
+ totalCount={totalCount}
+ answeredCount={answeredCount}
+ progressPercentage={progressPercentage}
+ />
+
+ );
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/SecurityQuestionnaireBreadcrumb.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/SecurityQuestionnaireBreadcrumb.tsx
deleted file mode 100644
index 3ae039ce4..000000000
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/SecurityQuestionnaireBreadcrumb.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-'use client';
-
-import { FileQuestion } from 'lucide-react';
-
-export function SecurityQuestionnaireBreadcrumb() {
- return (
-
-
-
-
-
- Overview
-
-
-
-
- );
-}
-
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts
index 541796671..58d7ac3e7 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/usePersistGeneratedAnswers.ts
@@ -113,7 +113,10 @@ export function usePersistGeneratedAnswers;
- const answerKeys = Object.keys(meta).filter((key) => key.startsWith('answer_'));
+ // Exclude _sources keys - they are handled separately
+ const answerKeys = Object.keys(meta).filter((key) =>
+ key.startsWith('answer_') && !key.endsWith('_sources')
+ );
answerKeys.forEach((key) => {
if (processedMetadataAnswersRef.current.has(key)) {
@@ -130,12 +133,15 @@ export function usePersistGeneratedAnswers r.originalIndex === answerData.questionIndex);
if (!resultMatch?.questionAnswerId) {
pendingUpdatesWaitingForIdRef.current.set(answerData.questionIndex, {
answer: answerData.answer || '',
- sources: answerData.sources,
+ sources: sourcesToUse,
});
return;
}
@@ -144,7 +150,7 @@ export function usePersistGeneratedAnswers 0
+ ? result.sources
+ : (prevResult?.sources && prevResult.sources.length > 0 ? prevResult.sources : []);
+
pendingResultsUpdatesRef.current.set(answerKey, {
questionAnswerId: result.questionAnswerId,
answer: result.answer,
- sources: result.sources,
+ sources: sourcesToSave,
});
}
});
@@ -233,7 +246,10 @@ export function usePersistGeneratedAnswers;
- const answerKeys = Object.keys(meta).filter((key) => key.startsWith('answer_'));
+ // Exclude _sources keys - they are handled separately
+ const answerKeys = Object.keys(meta).filter((key) =>
+ key.startsWith('answer_') && !key.endsWith('_sources')
+ );
if (!answerKeys.length) {
return;
@@ -251,6 +267,7 @@ export function usePersistGeneratedAnswers;
};
+
return answer;
})
.filter((answer): answer is NonNullable => Boolean(answer))
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireActions.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireActions.ts
index d093b8078..7685ffa05 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireActions.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireActions.ts
@@ -1,7 +1,7 @@
'use client';
import { useAction } from 'next-safe-action/hooks';
-import { useCallback, useTransition } from 'react';
+import { useCallback, useEffect, useRef, useTransition } from 'react';
import type { FileRejection } from 'react-dropzone';
import { toast } from 'sonner';
import { exportQuestionnaire } from '../actions/export-questionnaire';
@@ -20,6 +20,7 @@ interface UseQuestionnaireActionsProps {
setEditingAnswer: (answer: string) => void;
setResults: React.Dispatch>;
setExpandedSources: React.Dispatch>>;
+ isParseProcessStarted: boolean;
setIsParseProcessStarted: (started: boolean) => void;
setIsAutoAnswerProcessStarted: (started: boolean) => void;
isAutoAnswerProcessStartedRef: React.MutableRefObject;
@@ -64,6 +65,7 @@ export function useQuestionnaireActions({
setEditingAnswer,
setResults,
setExpandedSources,
+ isParseProcessStarted,
setIsParseProcessStarted,
setIsAutoAnswerProcessStarted,
isAutoAnswerProcessStartedRef,
@@ -124,30 +126,79 @@ export function useQuestionnaireActions({
}
}, [setSelectedFile]);
+ // ✅ Double-click protection using useRef
+ const isParsingRef = useRef(false);
+
const handleParse = async () => {
- // Clear old parse state before starting new parse to prevent token mismatch
- setParseTaskId(null);
- setParseToken(null);
- setIsParseProcessStarted(true);
-
- if (selectedFile) {
- const reader = new FileReader();
- reader.onloadend = async () => {
- const dataUrl = reader.result as string;
- const base64 = dataUrl.split(',')[1];
- const fileType = selectedFile.type || 'application/octet-stream';
-
- await uploadFileAction.execute({
- fileName: selectedFile.name,
- fileType,
- fileData: base64,
- organizationId: orgId,
- });
- };
- reader.readAsDataURL(selectedFile);
+ // ✅ DOUBLE-CLICK PROTECTION
+ if (isParsingRef.current) {
+ toast.warning('Analysis is already in progress. Please wait...');
+ return;
+ }
+
+ // ✅ Check if parsing is already in progress via state
+ if (isParseProcessStarted) {
+ toast.warning('Please wait for the current analysis to complete');
+ return;
+ }
+
+ // Set parsing flag
+ isParsingRef.current = true;
+
+ try {
+ // Clear old parse state before starting new parse to prevent token mismatch
+ setParseTaskId(null);
+ setParseToken(null);
+ setIsParseProcessStarted(true);
+
+ if (selectedFile) {
+ const reader = new FileReader();
+ reader.onloadend = async () => {
+ try {
+ const dataUrl = reader.result as string;
+ const base64 = dataUrl.split(',')[1];
+ const fileType = selectedFile.type || 'application/octet-stream';
+
+ await uploadFileAction.execute({
+ fileName: selectedFile.name,
+ fileType,
+ fileData: base64,
+ organizationId: orgId,
+ });
+ } catch (error) {
+ // Reset flag on error
+ isParsingRef.current = false;
+ setIsParseProcessStarted(false);
+ console.error('Error uploading file:', error);
+ toast.error('Failed to upload file. Please try again.');
+ }
+ };
+ reader.onerror = () => {
+ isParsingRef.current = false;
+ setIsParseProcessStarted(false);
+ toast.error('Failed to read file. Please try again.');
+ };
+ reader.readAsDataURL(selectedFile);
+ } else {
+ // If file is not selected, reset flag
+ isParsingRef.current = false;
+ setIsParseProcessStarted(false);
+ }
+ } catch (error) {
+ isParsingRef.current = false;
+ setIsParseProcessStarted(false);
+ console.error('Error in handleParse:', error);
+ toast.error('Failed to start analysis. Please try again.');
}
};
+ // ✅ Reset flag when parsing is completed
+ useEffect(() => {
+ if (!isParseProcessStarted) {
+ isParsingRef.current = false;
+ }
+ }, [isParseProcessStarted]);
+
const handleAutoAnswer = () => {
// Prevent "Auto Answer All" if a single question is currently being answered
if (answeringQuestionIndex !== null) {
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts
index 374f9c182..7d5d8d84d 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts
@@ -56,33 +56,7 @@ export function useQuestionnaireAutoAnswer({
const [isPending, startTransition] = useTransition();
- // Debug logging for run tracking
- useEffect(() => {
- console.log('[AutoAnswer] Hook state:', {
- hasToken: !!autoAnswerToken,
- hasRun: !!autoAnswerRun,
- runId: autoAnswerRun?.id,
- runStatus: autoAnswerRun?.status,
- hasMetadata: !!autoAnswerRun?.metadata,
- metadataKeys: autoAnswerRun?.metadata ? Object.keys(autoAnswerRun.metadata as Record).length : 0,
- isTriggering: isAutoAnswerTriggering,
- hasError: !!autoAnswerError,
- });
-
- if (autoAnswerRun?.metadata) {
- const meta = autoAnswerRun.metadata as Record;
- const answerKeys = Object.keys(meta).filter((key) => key.startsWith('answer_'));
- const statusKeys = Object.keys(meta).filter((key) => key.startsWith('question_') && key.endsWith('_status'));
- console.log('[AutoAnswer] Metadata keys:', {
- answerKeys,
- statusKeys,
- allKeys: Object.keys(meta).slice(0, 20), // First 20 keys for debugging
- });
- }
- }, [autoAnswerToken, autoAnswerRun?.id, autoAnswerRun?.status, autoAnswerRun?.metadata, isAutoAnswerTriggering, autoAnswerError]);
- // Track processed metadata to avoid infinite loops
- const processedMetadataRef = useRef('');
// Track which run ID we're currently processing for single questions
const currentRunIdRef = useRef(null);
// Track which run IDs we've already processed completion for (to prevent infinite loops)
@@ -97,91 +71,77 @@ export function useQuestionnaireAutoAnswer({
useEffect(() => {
if (answeringQuestionIndex !== null && autoAnswerRun?.id) {
currentRunIdRef.current = autoAnswerRun.id;
- processedMetadataRef.current = ''; // Clear processed metadata for new run
} else if (answeringQuestionIndex === null) {
currentRunIdRef.current = null; // Clear when no single question is active
}
}, [answeringQuestionIndex, autoAnswerRun?.id]);
- // Handle incremental answer updates from metadata (real-time)
- // This shows answers and statuses as individual questions complete
- useEffect(() => {
- // Read individual answers and statuses from metadata keys
- // Each answer-question task updates parent metadata when it starts and completes
+ // Extract answers and statuses from metadata using useMemo (like OnboardingTracker)
+ // This ensures React re-renders whenever metadata changes
+ const metadataAnswers = useMemo(() => {
if (!autoAnswerRun?.metadata || !resultsRef.current) {
- if (autoAnswerRun && !autoAnswerRun.metadata) {
- console.log('[AutoAnswer] Run exists but no metadata yet', {
- runId: autoAnswerRun.id,
- status: autoAnswerRun.status,
- });
- }
- return;
+ return {
+ answers: [],
+ statuses: new Map()
+ };
}
// For single question operations, only process metadata from the current run
- // This prevents metadata from previous runs (like "Auto Answer All") from interfering
if (answeringQuestionIndex !== null) {
if (currentRunIdRef.current && autoAnswerRun.id !== currentRunIdRef.current) {
- return; // Skip metadata from different runs
+ return {
+ answers: [],
+ statuses: new Map(),
+ sources: new Map()
+ };
}
}
const meta = autoAnswerRun.metadata as Record;
- // Create a hash of current metadata values to detect actual changes
- // Include both keys and values to catch when metadata content changes
- const answerKeys = Object.keys(meta).filter((key) => key.startsWith('answer_')).sort();
+ // Get all answer keys and status keys from metadata
+ // Exclude _sources keys - they are handled separately
+ const answerKeys = Object.keys(meta).filter((key) =>
+ key.startsWith('answer_') && !key.endsWith('_sources')
+ ).sort();
const statusKeys = Object.keys(meta).filter((key) => key.startsWith('question_') && key.endsWith('_status')).sort();
- // Debug logging
- if (answerKeys.length > 0) {
- console.log('[AutoAnswer] Found answer keys in metadata:', {
- answerKeys,
- answerCount: answerKeys.length,
- runId: autoAnswerRun.id,
- status: autoAnswerRun.status,
- });
- }
-
- // Build hash from actual values, not just keys
- const answerValues = answerKeys.map((key) => {
- const answer = meta[key] as { questionIndex?: number; answer?: string | null } | undefined;
- if (answer) {
- const value = `${answer.questionIndex}:${answer.answer ? 'has-answer' : 'no-answer'}`;
- console.log('[AutoAnswer] Answer value for hash:', { key, value, answerData: answer });
- return value;
- }
- return null;
- }).filter(Boolean);
-
- const statusValues = statusKeys.map((key) => {
- const status = meta[key];
- return `${key}:${status}`;
- });
-
- const metadataHash = JSON.stringify({
- answerCount: answerKeys.length,
- answerValues,
- statusCount: statusKeys.length,
- statusValues,
- });
+ // Extract all answers from metadata
+ const answers = answerKeys
+ .map((key) => {
+ const rawValue = meta[key];
+
+ if (!rawValue || typeof rawValue !== 'object') {
+ return undefined;
+ }
- // Skip if we've already processed this exact metadata state
- if (processedMetadataRef.current === metadataHash) {
- console.log('[AutoAnswer] Skipping duplicate metadata hash');
- return;
- }
- console.log('[AutoAnswer] Processing new metadata:', {
- hash: metadataHash.substring(0, 100),
- answerKeysCount: answerKeys.length,
- statusKeysCount: statusKeys.length,
- });
- processedMetadataRef.current = metadataHash;
+ const answerData = rawValue as {
+ questionIndex?: number;
+ question?: string;
+ answer?: string | null;
+ sources?: Array<{
+ sourceType: string;
+ sourceName?: string;
+ score: number;
+ }>;
+ };
+
+ if (typeof answerData.questionIndex !== 'number') {
+ return undefined;
+ }
- const isSingleQuestion = answeringQuestionIndex !== null;
-
- // Build status map from individual status keys
- // For single question operations, only process status for that specific question
+ return {
+ metadataKey: key,
+ questionIndex: answerData.questionIndex,
+ question: answerData.question || '',
+ answer: answerData.answer ?? null,
+ sources: answerData.sources || [],
+ };
+ })
+ .filter((answer): answer is NonNullable => answer !== undefined)
+ .sort((a, b) => a.questionIndex - b.questionIndex);
+
+ // Extract statuses
const statusMap = new Map();
statusKeys.forEach((key) => {
const match = key.match(/^question_(\d+)_status$/);
@@ -189,9 +149,9 @@ export function useQuestionnaireAutoAnswer({
const questionIndex = parseInt(match[1], 10);
// If this is a single question operation, only process status for that question
- if (isSingleQuestion && answeringQuestionIndex !== null) {
+ if (answeringQuestionIndex !== null) {
if (questionIndex !== answeringQuestionIndex) {
- return; // Skip status updates for other questions
+ return;
}
}
@@ -202,12 +162,38 @@ export function useQuestionnaireAutoAnswer({
}
});
- // Update question statuses from metadata (individual spinners start at different times)
- if (statusMap.size > 0) {
+ return { answers, statuses: statusMap };
+ }, [autoAnswerRun?.metadata, autoAnswerRun?.id, answeringQuestionIndex]);
+
+ // Apply metadata updates to state whenever metadataAnswers changes
+ // This pattern matches OnboardingTracker - React automatically re-renders when metadata changes
+ useEffect(() => {
+ if (!resultsRef.current) {
+ // Still update statuses even if no results
+ if (metadataAnswers.statuses.size > 0) {
+ setQuestionStatuses((prev) => {
+ const newStatuses = new Map(prev);
+ let hasChanges = false;
+ metadataAnswers.statuses.forEach((status, questionIndex) => {
+ if (prev.get(questionIndex) !== status) {
+ newStatuses.set(questionIndex, status);
+ hasChanges = true;
+ }
+ });
+ return hasChanges ? newStatuses : prev;
+ });
+ }
+ return;
+ }
+
+ const isSingleQuestion = answeringQuestionIndex !== null;
+
+ // Update statuses first
+ if (metadataAnswers.statuses.size > 0) {
setQuestionStatuses((prev) => {
const newStatuses = new Map(prev);
let hasChanges = false;
- statusMap.forEach((status, questionIndex) => {
+ metadataAnswers.statuses.forEach((status, questionIndex) => {
if (prev.get(questionIndex) !== status) {
newStatuses.set(questionIndex, status);
hasChanges = true;
@@ -217,158 +203,62 @@ export function useQuestionnaireAutoAnswer({
});
}
- // Extract and update answers (reuse answerKeys from above)
- if (answerKeys.length > 0) {
- const answers = answerKeys
- .map((key) => {
- const rawValue = meta[key];
-
- // Handle case where metadata value might be a string (shouldn't happen, but be defensive)
- if (typeof rawValue === 'string') {
- console.warn('[AutoAnswer] Unexpected string value in metadata:', { key, value: rawValue });
- return undefined;
- }
-
- // Handle case where metadata value is null or undefined
- if (!rawValue || typeof rawValue !== 'object') {
- console.warn('[AutoAnswer] Invalid metadata value:', { key, value: rawValue, type: typeof rawValue });
- return undefined;
- }
-
- const answerData = rawValue as {
- questionIndex?: number;
- question?: string;
- answer?: string | null;
- sources?: Array<{
- sourceType: string;
- sourceName?: string;
- score: number;
- }>;
- };
-
- // Validate that answerData has required fields
- if (typeof answerData.questionIndex !== 'number') {
- console.warn('[AutoAnswer] Missing questionIndex in answer data:', { key, answerData });
- return undefined;
- }
-
- return {
- questionIndex: answerData.questionIndex,
- question: answerData.question || '',
- answer: answerData.answer ?? null,
- sources: answerData.sources || [],
- };
- })
- .filter((answer): answer is NonNullable => answer !== undefined)
- .sort((a, b) => a.questionIndex - b.questionIndex);
-
- if (answers.length > 0) {
- console.log('[AutoAnswer] Processing answers:', {
- answersCount: answers.length,
- answers: answers.map((a) => ({
- questionIndex: a.questionIndex,
- hasAnswer: !!a.answer,
- answerLength: a.answer?.length || 0,
- })),
- });
-
+ // Update answers - process each answer individually
setResults((prevResults) => {
if (!prevResults) {
- console.warn('[AutoAnswer] No previous results to update');
return prevResults;
}
const updatedResults = [...prevResults];
let hasChanges = false;
+ let updatedCount = 0;
+ let skippedCount = 0;
- answers.forEach((answer) => {
+ metadataAnswers.answers.forEach((answer) => {
// For single question operations, only process answers for that specific question
if (isSingleQuestion && answeringQuestionIndex !== null) {
- // Strict check: must match exactly
if (answer.questionIndex !== answeringQuestionIndex) {
- return; // Skip answers for other questions
+ return;
}
}
const targetIndex = answer.questionIndex;
- // Verify we're updating the correct question
- // For single question operations, double-check the index matches
- if (isSingleQuestion && answeringQuestionIndex !== null) {
- if (targetIndex !== answeringQuestionIndex) {
- console.warn('[AutoAnswer] Index mismatch in single question update:', {
- targetIndex,
- answeringQuestionIndex,
- answerQuestionIndex: answer.questionIndex,
- });
- return; // Skip if index doesn't match (safety check)
- }
- }
-
- // Safety check: ensure targetIndex is valid
+ // Safety check
if (targetIndex < 0 || targetIndex >= updatedResults.length) {
- console.warn('[AutoAnswer] Invalid questionIndex in answer update:', {
- targetIndex,
- resultsLength: updatedResults.length,
- answerQuestionIndex: answer.questionIndex,
- });
return;
}
const currentAnswer = updatedResults[targetIndex]?.answer;
const originalQuestion = updatedResults[targetIndex]?.question;
- console.log('[AutoAnswer] Updating answer:', {
- targetIndex,
- currentAnswer: currentAnswer ? `${currentAnswer.substring(0, 50)}...` : null,
- newAnswer: answer.answer ? `${answer.answer.substring(0, 50)}...` : null,
- hasAnswer: !!answer.answer,
- });
-
- // Verify we're updating the correct question by checking question text matches
- // This is an extra safety check to prevent updating wrong questions
- if (originalQuestion && answer.question) {
- // For single question operations, verify question text matches
- if (isSingleQuestion && answeringQuestionIndex !== null) {
- const expectedQuestion = resultsRef.current?.[answeringQuestionIndex]?.question;
- if (expectedQuestion && answer.question.trim() !== expectedQuestion.trim()) {
- console.warn('[AutoAnswer] Question text mismatch in single question update:', {
- targetIndex,
- answeringQuestionIndex,
- expectedQuestion: expectedQuestion.substring(0, 50),
- answerQuestion: answer.question.substring(0, 50),
- });
- // Still update if indices match - question text might be slightly different
- // But log for debugging
- }
- }
- }
-
- // Always preserve the original question text from the results array
- // Don't use answer.question as it might be formatted differently or from a different question
- // This prevents question text from being overwritten incorrectly
+ // Skip only if we already have the exact same answer (both non-null and equal)
+ // Always update if: answer changed, or going from null to answer, or answer to null
+ if (currentAnswer !== null && currentAnswer === answer.answer) {
+ // Already has this exact non-null answer, skip to avoid unnecessary updates
+ return;
+ }
if (answer.answer) {
- if (currentAnswer !== answer.answer) {
- console.log('[AutoAnswer] Setting answer for question', targetIndex);
+ // Update successful answer immediately - show it as soon as it's available
+ // Sources will be updated separately from answer_${questionIndex}_sources metadata key
+ // Preserve existing sources - don't overwrite them with empty array
+ const existingSources = updatedResults[targetIndex]?.sources || [];
+
updatedResults[targetIndex] = {
- ...updatedResults[targetIndex], // Preserve status and other fields
- question: originalQuestion || answer.question, // Preserve original question text
+ ...updatedResults[targetIndex],
+ question: originalQuestion || answer.question,
answer: answer.answer,
- sources: answer.sources,
+ sources: existingSources, // Keep existing sources, they'll be updated separately
failedToGenerate: false,
};
hasChanges = true;
} else {
- console.log('[AutoAnswer] Answer unchanged for question', targetIndex);
- }
- } else {
- // Only update if answer is still null (don't overwrite existing answers)
+ // Update failed answer only if no answer exists yet
if (!currentAnswer) {
- console.log('[AutoAnswer] Marking question as failed to generate', targetIndex);
updatedResults[targetIndex] = {
...updatedResults[targetIndex],
- question: originalQuestion || answer.question, // Preserve original question text
+ question: originalQuestion || answer.question,
answer: null,
failedToGenerate: true,
};
@@ -377,29 +267,79 @@ export function useQuestionnaireAutoAnswer({
}
});
- if (hasChanges) {
- console.log('[AutoAnswer] Results updated successfully');
- } else {
- console.log('[AutoAnswer] No changes detected in results');
- }
+ return hasChanges ? updatedResults : prevResults;
+ });
+ }, [metadataAnswers, answeringQuestionIndex]);
+
+ // Update sources from final output when available
+ // This ensures sources are updated even if they weren't in metadata
+ useEffect(() => {
+ if (
+ autoAnswerRun?.status === 'COMPLETED' &&
+ autoAnswerRun.output &&
+ autoAnswerRun.output.answers
+ ) {
+ const answers = autoAnswerRun.output.answers as
+ | Array<{
+ questionIndex: number;
+ sources?: Array<{
+ sourceType: string;
+ sourceName?: string;
+ score: number;
+ }>;
+ }>
+ | undefined;
+
+ if (answers && Array.isArray(answers)) {
+ setResults((prevResults) => {
+ if (!prevResults) return prevResults;
+
+ const updatedResults = [...prevResults];
+ let hasChanges = false;
+
+ answers.forEach((answer) => {
+ if (answer.sources && answer.sources.length > 0) {
+ const directIndex =
+ answer.questionIndex >= 0 && answer.questionIndex < updatedResults.length
+ ? answer.questionIndex
+ : -1;
+
+ const fallbackIndex =
+ directIndex === -1
+ ? updatedResults.findIndex((r, idx) => {
+ const candidate =
+ (r as { originalIndex?: number; _originalIndex?: number }).originalIndex ??
+ (r as { originalIndex?: number; _originalIndex?: number })._originalIndex ??
+ idx;
+ return candidate === answer.questionIndex;
+ })
+ : directIndex;
+
+ if (fallbackIndex >= 0 && fallbackIndex < updatedResults.length) {
+ const currentSources = updatedResults[fallbackIndex]?.sources || [];
+ const sourcesChanged =
+ JSON.stringify(currentSources) !== JSON.stringify(answer.sources);
+
+ if (sourcesChanged) {
+ updatedResults[fallbackIndex] = {
+ ...updatedResults[fallbackIndex],
+ sources: answer.sources,
+ };
+ hasChanges = true;
+ }
+ }
+ }
+ });
return hasChanges ? updatedResults : prevResults;
});
- } else {
- console.log('[AutoAnswer] No answers found to process');
}
}
- }, [
- autoAnswerRun?.metadata,
- answeringQuestionIndex,
- questionnaireId,
- saveAnswersBatch,
- // Don't include results, setResults, setQuestionStatuses, setAnsweringQuestionIndex in deps
- // results is only used for existence check, setState functions are stable
- ]);
+ }, [autoAnswerRun?.status, autoAnswerRun?.output, setResults]);
// Handle final completion - read ALL answers from final output
- // This is the primary source of truth since metadata may not be reliable
+ // This is a fallback to ensure all answers are shown even if metadata updates were missed
+ // Primary source is incremental metadata updates above, which show answers as they complete
useEffect(() => {
if (
autoAnswerRun?.status === 'COMPLETED' &&
@@ -424,12 +364,6 @@ export function useQuestionnaireAutoAnswer({
// Mark this run as processed to prevent infinite loops
processedCompletionRef.current.add(autoAnswerRun.id);
- console.log('[AutoAnswer] Task completed, updating ALL answers from final output:', {
- answersCount: answers.length,
- answeredCount: answers.filter((a) => a.answer).length,
- runId: autoAnswerRun.id,
- });
-
// Update results from final output - merge new answers with existing ones
// The orchestrator only returns answers for questions it processed (unanswered ones)
// So we merge them with existing answers
@@ -448,25 +382,31 @@ export function useQuestionnaireAutoAnswer({
newAnswersMap.forEach((answer, targetIndex) => {
// Safety check: ensure targetIndex is valid
if (targetIndex < 0 || targetIndex >= updatedResults.length) {
- console.warn('[AutoAnswer] Invalid questionIndex in final output:', {
- targetIndex,
- resultsLength: updatedResults.length,
- });
return;
}
const originalQuestion = updatedResults[targetIndex]?.question;
const currentAnswer = updatedResults[targetIndex]?.answer;
+ const currentSources = updatedResults[targetIndex]?.sources || [];
// Update with new answer from orchestrator
if (answer.answer) {
- // Only update if answer changed
- if (currentAnswer !== answer.answer) {
+ // Always update sources from final output if they exist, even if answer is the same
+ // This ensures sources are available even if they weren't in metadata
+ const sourcesToUse = answer.sources && answer.sources.length > 0
+ ? answer.sources
+ : currentSources;
+
+ // Update if answer changed or sources changed
+ const answerChanged = currentAnswer !== answer.answer;
+ const sourcesChanged = JSON.stringify(currentSources) !== JSON.stringify(sourcesToUse);
+
+ if (answerChanged || sourcesChanged) {
updatedResults[targetIndex] = {
...updatedResults[targetIndex], // Preserve status and other fields
question: originalQuestion || answer.question,
answer: answer.answer,
- sources: answer.sources,
+ sources: sourcesToUse,
failedToGenerate: false,
};
hasChanges = true;
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail.ts
deleted file mode 100644
index 68927d0f6..000000000
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail.ts
+++ /dev/null
@@ -1,529 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
-import { useQuestionnaireActions } from './useQuestionnaireActions';
-import { useQuestionnaireAutoAnswer } from './useQuestionnaireAutoAnswer';
-import { useQuestionnaireSingleAnswer } from './useQuestionnaireSingleAnswer';
-import type { QuestionAnswer } from '../components/types';
-import { createTriggerToken } from '../actions/create-trigger-token';
-import { updateQuestionnaireAnswer } from '../actions/update-questionnaire-answer';
-import { deleteQuestionnaireAnswer } from '../actions/delete-questionnaire-answer';
-import { useAction } from 'next-safe-action/hooks';
-import { useRouter } from 'next/navigation';
-import { toast } from 'sonner';
-import { usePersistGeneratedAnswers } from './usePersistGeneratedAnswers';
-
-interface QuestionnaireQuestionAnswer {
- id: string;
- question: string;
- answer: string | null;
- status: 'untouched' | 'generated' | 'manual';
- questionIndex: number;
- sources: any;
-}
-
-type QuestionnaireResult = QuestionAnswer & {
- originalIndex: number;
- questionAnswerId: string;
- status: 'untouched' | 'generated' | 'manual';
-};
-
-interface UseQuestionnaireDetailProps {
- questionnaireId: string;
- organizationId: string;
- initialQuestions: QuestionnaireQuestionAnswer[];
-}
-
-export function useQuestionnaireDetail({
- questionnaireId,
- organizationId,
- initialQuestions,
-}: UseQuestionnaireDetailProps) {
- const router = useRouter();
-
- // Initialize results from database
- const [results, setResults] = useState(() =>
- initialQuestions.map((q) => ({
- question: q.question,
- answer: q.answer ?? null, // Preserve null instead of converting to empty string
- originalIndex: q.questionIndex,
- sources: q.sources ? (Array.isArray(q.sources) ? q.sources : []) : [],
- questionAnswerId: q.id,
- status: q.status,
- failedToGenerate: false, // Initialize failedToGenerate
- }))
- );
-
- // State management (same as useQuestionnaireState)
- const [editingIndex, setEditingIndex] = useState(null);
- const [editingAnswer, setEditingAnswer] = useState('');
- const [expandedSources, setExpandedSources] = useState>(new Set());
- const [questionStatuses, setQuestionStatuses] = useState>(new Map());
- const [answeringQuestionIndex, setAnsweringQuestionIndex] = useState(null);
- const [isAutoAnswerProcessStarted, setIsAutoAnswerProcessStarted] = useState(false);
- const [hasClickedAutoAnswer, setHasClickedAutoAnswer] = useState(false);
- const [autoAnswerToken, setAutoAnswerToken] = useState(null);
- const [singleAnswerToken, setSingleAnswerToken] = useState(null);
- const [searchQuery, setSearchQuery] = useState('');
- const isAutoAnswerProcessStartedRef = useRef(false);
-
- // Refs to capture values for save callback
- const saveIndexRef = useRef(null);
- const saveAnswerRef = useRef('');
-
- // Actions - use callbacks that reference the refs
- const updateAnswerAction = useAction(updateQuestionnaireAnswer, {
- onSuccess: () => {
- // Only handle manual saves (when saveIndexRef is set)
- if (saveIndexRef.current !== null) {
- const index = saveIndexRef.current;
- const answer = saveAnswerRef.current;
-
- // Update local state optimistically
- setResults((prev) =>
- prev.map((r, i) => {
- if (i === index) {
- const trimmedAnswer = answer.trim();
- // If answer is empty, reset failedToGenerate and allow auto-fill
- // If answer has content, mark as manual
- return {
- ...r,
- answer: trimmedAnswer || null,
- status: trimmedAnswer ? ('manual' as const) : ('untouched' as const),
- failedToGenerate: false, // Reset failedToGenerate when user saves (even if empty)
- };
- }
- return r;
- })
- );
-
- setEditingIndex(null);
- setEditingAnswer('');
- toast.success('Answer saved');
- router.refresh();
-
- // Reset refs
- saveIndexRef.current = null;
- saveAnswerRef.current = '';
- }
- },
- onError: ({ error }) => {
- console.error('Failed to update answer:', error);
- if (saveIndexRef.current !== null) {
- toast.error(`Failed to save answer: ${error instanceof Error ? error.message : 'Unknown error'}`);
- saveIndexRef.current = null;
- saveAnswerRef.current = '';
- }
- },
- });
- const deleteAnswerAction = useAction(deleteQuestionnaireAnswer);
- // Note: We don't use orchestratorAction here - we use triggerAutoAnswer directly from useQuestionnaireAutoAnswer
- // This ensures we get a trackable run via useRealtimeTaskTrigger
-
- // Create trigger tokens
- useEffect(() => {
- const fetchTokens = async () => {
- const [autoTokenResult, singleTokenResult] = await Promise.all([
- createTriggerToken('vendor-questionnaire-orchestrator'),
- createTriggerToken('answer-question'),
- ]);
-
- if (autoTokenResult.success && autoTokenResult.token) {
- setAutoAnswerToken(autoTokenResult.token);
- }
- if (singleTokenResult.success && singleTokenResult.token) {
- setSingleAnswerToken(singleTokenResult.token);
- }
- };
-
- fetchTokens();
- }, []);
-
- // Auto-answer hook (same as useQuestionnaireParser)
- const autoAnswer = useQuestionnaireAutoAnswer({
- autoAnswerToken,
- results: results as QuestionAnswer[] | null,
- answeringQuestionIndex,
- isAutoAnswerProcessStarted,
- isAutoAnswerProcessStartedRef,
- setIsAutoAnswerProcessStarted,
- setResults: setResults as Dispatch>,
- setQuestionStatuses,
- setAnsweringQuestionIndex,
- questionnaireId,
- });
-
- // Wrapper for setResults that handles QuestionnaireResult[] with originalIndex
- const setResultsWrapper = useCallback((updater: React.SetStateAction) => {
- setResults((prevResults) => {
- if (!prevResults) {
- const newResults = typeof updater === 'function' ? updater(null) : updater;
- if (!newResults) return prevResults; // Return empty array instead of null
- // Convert QuestionAnswer[] to QuestionnaireResult[]
- return newResults.map((r, index) => ({
- question: r.question,
- answer: r.answer ?? null, // Preserve null instead of converting to empty string
- originalIndex: index,
- sources: r.sources || [],
- questionAnswerId: '',
- status: 'untouched' as const,
- failedToGenerate: r.failedToGenerate ?? false, // Preserve failedToGenerate
- }));
- }
-
- const questionAnswerResults = prevResults.map((r) => ({
- question: r.question,
- answer: r.answer,
- sources: r.sources,
- failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result
- _originalIndex: r.originalIndex, // Preserve originalIndex
- }));
-
- const newResults = typeof updater === 'function'
- ? updater(questionAnswerResults)
- : updater;
-
- if (!newResults) return prevResults; // Return previous results instead of null
-
- // Map back to QuestionnaireResult[] preserving originalIndex
- return newResults.map((newR, index) => {
- const originalIndex = (newR as any)._originalIndex !== undefined
- ? (newR as any)._originalIndex
- : index;
- const existingResult = prevResults.find((r) => r.originalIndex === originalIndex);
- if (existingResult) {
- return {
- ...existingResult,
- question: newR.question,
- answer: newR.answer ?? null, // Preserve null instead of converting to empty string
- sources: newR.sources,
- failedToGenerate: newR.failedToGenerate ?? false, // Preserve failedToGenerate
- };
- }
- // Fallback: create new result (shouldn't happen)
- return {
- question: newR.question,
- answer: newR.answer ?? null, // Preserve null instead of converting to empty string
- originalIndex,
- sources: newR.sources || [],
- questionAnswerId: '',
- status: 'untouched' as const,
- failedToGenerate: newR.failedToGenerate ?? false, // Preserve failedToGenerate
- };
- });
- });
- }, []);
-
- // Single answer hook (same as useQuestionnaireParser)
- const singleAnswer = useQuestionnaireSingleAnswer({
- singleAnswerToken,
- results: results.map((r) => ({
- question: r.question,
- answer: r.answer,
- sources: r.sources,
- failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result
- _originalIndex: r.originalIndex, // Pass originalIndex for reference
- })) as QuestionAnswer[],
- answeringQuestionIndex,
- setResults: setResultsWrapper,
- setQuestionStatuses,
- setAnsweringQuestionIndex,
- questionnaireId,
- });
-
- // Expose isSingleAnswerTriggering for isLoading calculation
- const isSingleAnswerTriggering = singleAnswer.isSingleAnswerTriggering;
-
- // Reuse the same actions hook (but adapt it for detail page)
- const actions = useQuestionnaireActions({
- orgId: organizationId,
- selectedFile: null,
- results: results as QuestionAnswer[] | null,
- editingAnswer,
- expandedSources,
- setSelectedFile: () => {},
- setEditingIndex,
- setEditingAnswer,
- setResults: setResults as Dispatch>,
- setExpandedSources,
- setIsParseProcessStarted: () => {},
- setIsAutoAnswerProcessStarted,
- isAutoAnswerProcessStartedRef,
- setHasClickedAutoAnswer,
- answeringQuestionIndex,
- setAnsweringQuestionIndex,
- setQuestionStatuses,
- questionnaireId,
- setParseTaskId: () => {},
- setParseToken: () => {},
- uploadFileAction: { execute: async () => {}, status: 'idle' as const },
- parseAction: { execute: async () => {}, status: 'idle' as const },
- triggerAutoAnswer: autoAnswer.triggerAutoAnswer,
- triggerSingleAnswer: singleAnswer.triggerSingleAnswer,
- } as Parameters[0]);
-
- const persistenceAction = {
- execute: () => {},
- executeAsync: (input: Parameters[0]) =>
- updateAnswerAction.executeAsync(input),
- };
-
- usePersistGeneratedAnswers({
- questionnaireId,
- results: results as QuestionAnswer[] | null,
- setResults: setResults as Dispatch>,
- autoAnswerRun: autoAnswer.autoAnswerRun ?? null,
- updateAnswerAction: persistenceAction as any,
- setQuestionStatuses,
- });
-
- // Override handleAutoAnswer to include orchestrator call
- const handleAutoAnswer = () => {
- if (answeringQuestionIndex !== null) {
- toast.warning('Please wait for the current question to finish before answering all questions');
- return;
- }
-
- setHasClickedAutoAnswer(true);
- setIsAutoAnswerProcessStarted(true);
- isAutoAnswerProcessStartedRef.current = true;
-
- // Filter out questions with manual answers
- const questionsToAnswer = results
- .filter((r) => r.status !== 'manual' && (!r.answer || r.answer.trim().length === 0))
- .map((r) => ({
- question: r.question,
- answer: r.answer || null,
- _originalIndex: r.originalIndex,
- }));
-
- if (questionsToAnswer.length === 0) {
- toast.info('All questions already have answers');
- setIsAutoAnswerProcessStarted(false);
- isAutoAnswerProcessStartedRef.current = false;
- return;
- }
-
- // Optimistic UI update: immediately show spinners for all unanswered questions
- setQuestionStatuses((prev) => {
- const newStatuses = new Map(prev);
- questionsToAnswer.forEach((q) => {
- if (q._originalIndex !== undefined) {
- newStatuses.set(q._originalIndex, 'processing');
- }
- });
- return newStatuses;
- });
-
- // Call triggerAutoAnswer directly - this will trigger the task AND give us a trackable run
- // The useRealtimeTaskTrigger hook will handle the actual task triggering
- try {
- autoAnswer.triggerAutoAnswer({
- vendorId: `org_${organizationId}`,
- organizationId,
- questionsAndAnswers: questionsToAnswer.map((q) => ({
- question: q.question,
- answer: q.answer,
- _originalIndex: q._originalIndex, // Pass _originalIndex for orchestrator to use
- })) as any,
- });
- console.log('Triggered auto-answer, run should be available soon');
- } catch (error) {
- console.error('Failed to trigger auto-answer:', error);
- toast.error('Failed to start auto-answer process');
- setIsAutoAnswerProcessStarted(false);
- isAutoAnswerProcessStartedRef.current = false;
- // Reset question statuses
- setQuestionStatuses((prev) => {
- const newStatuses = new Map(prev);
- questionsToAnswer.forEach((q) => {
- if (q._originalIndex !== undefined) {
- newStatuses.delete(q._originalIndex);
- }
- });
- return newStatuses;
- });
- }
- };
-
- // Override handleAnswerSingleQuestion to set processing status immediately
- const handleAnswerSingleQuestion = (index: number) => {
- if (isAutoAnswerProcessStarted || answeringQuestionIndex !== null) {
- return;
- }
-
- const result = results.find((r) => r.originalIndex === index);
- if (!result) {
- return;
- }
-
- // Allow auto-fill even if status is 'manual' - user may have cleared the answer
- // Only prevent if there's already an answer (non-empty)
- if (result.status === 'manual' && result.answer && result.answer.trim().length > 0) {
- return;
- }
-
- setAnsweringQuestionIndex(index);
-
- // Set status to processing immediately to show spinner
- setQuestionStatuses((prev) => {
- const newStatuses = new Map(prev);
- newStatuses.set(index, 'processing');
- return newStatuses;
- });
-
- singleAnswer.triggerSingleAnswer({
- question: result.question,
- organizationId,
- questionIndex: index,
- totalQuestions: results.length,
- });
- };
-
- // Handle delete manual answer
- const handleDeleteAnswer = async (questionAnswerId: string, questionIndex: number) => {
- try {
- await deleteAnswerAction.execute({
- questionnaireId,
- questionAnswerId,
- });
-
- setResults((prev) =>
- prev.map((r) =>
- r.originalIndex === questionIndex
- ? { ...r, answer: '', status: 'untouched' as const }
- : r
- )
- );
-
- toast.success('Answer deleted. You can now generate a new answer.');
- router.refresh();
- } catch (error) {
- console.error('Failed to delete answer:', error);
- toast.error('Failed to delete answer');
- }
- };
-
- // Handle save answer (override to save to database)
- const handleSaveAnswer = (index: number) => {
- // The index passed is the array index from QuestionnaireResultsTable
- // Find the result at that array position
- const result = results[index];
-
- if (!result) {
- console.error('Cannot save answer: result not found at index', {
- index,
- resultsLength: results.length,
- results: results.map((r, i) => ({ i, originalIndex: r.originalIndex, questionAnswerId: r.questionAnswerId }))
- });
- toast.error('Cannot find question to save');
- return;
- }
-
- if (!result.questionAnswerId) {
- console.error('Cannot save answer: questionAnswerId not found', { index, result });
- toast.error('Cannot save answer: missing question ID');
- return;
- }
-
- // Store values in refs for the callback
- saveIndexRef.current = index;
- saveAnswerRef.current = editingAnswer;
-
- // Execute the save - callbacks will use the refs
- updateAnswerAction.execute({
- questionnaireId,
- questionAnswerId: result.questionAnswerId,
- answer: editingAnswer.trim(),
- });
- };
-
- const filteredResults = useMemo(() => {
- if (!searchQuery.trim()) return results;
- const query = searchQuery.toLowerCase();
- return results.filter(
- (r) =>
- r.question.toLowerCase().includes(query) ||
- (r.answer && r.answer.toLowerCase().includes(query))
- );
- }, [results, searchQuery]);
-
- const answeredCount = useMemo(() => {
- return results.filter((r) => r.answer && r.answer.trim().length > 0).length;
- }, [results]);
-
- const progressPercentage = useMemo(() => {
- if (results.length === 0) return 0;
- return Math.round((answeredCount / results.length) * 100);
- }, [answeredCount, results.length]);
-
- const isAutoAnswering = useMemo(() => {
- return (
- isAutoAnswerProcessStarted &&
- hasClickedAutoAnswer &&
- (autoAnswer.autoAnswerRun?.status === 'EXECUTING' ||
- autoAnswer.autoAnswerRun?.status === 'QUEUED' ||
- autoAnswer.autoAnswerRun?.status === 'WAITING')
- );
- }, [isAutoAnswerProcessStarted, hasClickedAutoAnswer, autoAnswer.autoAnswerRun?.status]);
-
- // Calculate isLoading based on answer generation status
- const isLoading = useMemo(() => {
- // Check if any question is being processed
- const hasProcessingQuestions = Array.from(questionStatuses.values()).some(
- (status) => status === 'processing'
- );
-
- // Check if single answer is being triggered
- const isSingleAnswerTriggering = singleAnswer.isSingleAnswerTriggering;
-
- // Check if auto answer is being triggered or running
- const isAutoAnswerTriggering = autoAnswer.isAutoAnswerTriggering;
- const isAutoAnswerRunActive =
- autoAnswer.autoAnswerRun?.status === 'EXECUTING' ||
- autoAnswer.autoAnswerRun?.status === 'QUEUED' ||
- autoAnswer.autoAnswerRun?.status === 'WAITING';
-
- return hasProcessingQuestions || isSingleAnswerTriggering || isAutoAnswerTriggering || isAutoAnswerRunActive;
- }, [
- questionStatuses,
- isSingleAnswerTriggering,
- autoAnswer.isAutoAnswerTriggering,
- autoAnswer.autoAnswerRun?.status,
- ]);
-
- // Check if saving is in progress
- const isSaving = updateAnswerAction.status === 'executing';
- const savingIndex = isSaving && saveIndexRef.current !== null ? saveIndexRef.current : null;
-
- return {
- orgId: organizationId,
- results,
- searchQuery,
- setSearchQuery,
- editingIndex,
- editingAnswer,
- setEditingAnswer,
- expandedSources,
- questionStatuses,
- answeringQuestionIndex,
- hasClickedAutoAnswer,
- isLoading,
- isAutoAnswering,
- isExporting: actions.exportAction.status === 'executing',
- isSaving,
- savingIndex,
- filteredResults,
- answeredCount,
- totalCount: results.length,
- progressPercentage,
- handleAutoAnswer,
- handleAnswerSingleQuestion,
- handleEditAnswer: actions.handleEditAnswer,
- handleSaveAnswer,
- handleCancelEdit: actions.handleCancelEdit,
- handleExport: actions.handleExport,
- handleToggleSource: actions.handleToggleSource,
- };
-}
-
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/index.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/index.ts
new file mode 100644
index 000000000..9369bcbb8
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/index.ts
@@ -0,0 +1,3 @@
+export { useQuestionnaireDetail } from './useQuestionnaireDetail';
+export type { UseQuestionnaireDetailProps, QuestionnaireResult } from './types';
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/types.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/types.ts
new file mode 100644
index 000000000..1dc57e2d6
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/types.ts
@@ -0,0 +1,23 @@
+import type { QuestionAnswer } from '../../components/types';
+
+export interface QuestionnaireQuestionAnswer {
+ id: string;
+ question: string;
+ answer: string | null;
+ status: 'untouched' | 'generated' | 'manual';
+ questionIndex: number;
+ sources: any;
+}
+
+export type QuestionnaireResult = QuestionAnswer & {
+ originalIndex: number;
+ questionAnswerId: string;
+ status: 'untouched' | 'generated' | 'manual';
+};
+
+export interface UseQuestionnaireDetailProps {
+ questionnaireId: string;
+ organizationId: string;
+ initialQuestions: QuestionnaireQuestionAnswer[];
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts
new file mode 100644
index 000000000..0da764d8c
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts
@@ -0,0 +1,282 @@
+'use client';
+
+import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
+import { useQuestionnaireActions } from '../useQuestionnaireActions';
+import { useQuestionnaireAutoAnswer } from '../useQuestionnaireAutoAnswer';
+import { useQuestionnaireSingleAnswer } from '../useQuestionnaireSingleAnswer';
+import type { QuestionAnswer } from '../../components/types';
+import { usePersistGeneratedAnswers } from '../usePersistGeneratedAnswers';
+import { useQuestionnaireDetailState } from './useQuestionnaireDetailState';
+import { useQuestionnaireDetailHandlers } from './useQuestionnaireDetailHandlers';
+import type { UseQuestionnaireDetailProps } from './types';
+
+export function useQuestionnaireDetail({
+ questionnaireId,
+ organizationId,
+ initialQuestions,
+}: UseQuestionnaireDetailProps) {
+ const state = useQuestionnaireDetailState({
+ initialQuestions,
+ questionnaireId,
+ });
+
+ // Auto-answer hook
+ const autoAnswer = useQuestionnaireAutoAnswer({
+ autoAnswerToken: state.autoAnswerToken,
+ results: state.results as QuestionAnswer[] | null,
+ answeringQuestionIndex: state.answeringQuestionIndex,
+ isAutoAnswerProcessStarted: state.isAutoAnswerProcessStarted,
+ isAutoAnswerProcessStartedRef: state.isAutoAnswerProcessStartedRef,
+ setIsAutoAnswerProcessStarted: state.setIsAutoAnswerProcessStarted,
+ setResults: state.setResults as Dispatch>,
+ setQuestionStatuses: state.setQuestionStatuses as Dispatch<
+ SetStateAction>
+ >,
+ setAnsweringQuestionIndex: state.setAnsweringQuestionIndex,
+ questionnaireId,
+ });
+
+ // Wrapper for setResults that handles QuestionnaireResult[] with originalIndex
+ const setResultsWrapper = useCallback((updater: React.SetStateAction) => {
+ state.setResults((prevResults) => {
+ if (!prevResults) {
+ const newResults = typeof updater === 'function' ? updater(null) : updater;
+ if (!newResults) return prevResults;
+ return newResults.map((r, index) => ({
+ question: r.question,
+ answer: r.answer ?? null,
+ originalIndex: index,
+ sources: r.sources || [],
+ questionAnswerId: '',
+ status: 'untouched' as const,
+ failedToGenerate: r.failedToGenerate ?? false,
+ }));
+ }
+
+ const questionAnswerResults = prevResults.map((r) => ({
+ question: r.question,
+ answer: r.answer,
+ sources: r.sources,
+ failedToGenerate: (r as any).failedToGenerate ?? false,
+ _originalIndex: r.originalIndex,
+ }));
+
+ const newResults =
+ typeof updater === 'function' ? updater(questionAnswerResults) : updater;
+
+ if (!newResults) return prevResults;
+
+ return newResults.map((newR, index) => {
+ const originalIndex =
+ (newR as any)._originalIndex !== undefined ? (newR as any)._originalIndex : index;
+ const existingResult = prevResults.find((r) => r.originalIndex === originalIndex);
+ if (existingResult) {
+ return {
+ ...existingResult,
+ question: newR.question,
+ answer: newR.answer ?? null,
+ sources: newR.sources,
+ failedToGenerate: newR.failedToGenerate ?? false,
+ };
+ }
+ return {
+ question: newR.question,
+ answer: newR.answer ?? null,
+ originalIndex,
+ sources: newR.sources || [],
+ questionAnswerId: '',
+ status: 'untouched' as const,
+ failedToGenerate: newR.failedToGenerate ?? false,
+ };
+ });
+ });
+ }, [state.setResults]);
+
+ // Single answer hook
+ const singleAnswer = useQuestionnaireSingleAnswer({
+ results: state.results.map((r) => ({
+ question: r.question,
+ answer: r.answer,
+ sources: r.sources,
+ failedToGenerate: (r as any).failedToGenerate ?? false,
+ _originalIndex: r.originalIndex,
+ })) as QuestionAnswer[],
+ answeringQuestionIndex: state.answeringQuestionIndex,
+ setResults: setResultsWrapper,
+ setQuestionStatuses: state.setQuestionStatuses as Dispatch<
+ SetStateAction>
+ >,
+ setAnsweringQuestionIndex: state.setAnsweringQuestionIndex,
+ questionnaireId,
+ });
+
+ // Actions hook
+ const actions = useQuestionnaireActions({
+ orgId: organizationId,
+ selectedFile: null,
+ results: state.results as QuestionAnswer[] | null,
+ editingAnswer: state.editingAnswer,
+ expandedSources: state.expandedSources,
+ setSelectedFile: () => {},
+ setEditingIndex: state.setEditingIndex,
+ setEditingAnswer: state.setEditingAnswer,
+ setResults: state.setResults as Dispatch>,
+ setExpandedSources: state.setExpandedSources,
+ isParseProcessStarted: state.isParseProcessStarted,
+ setIsParseProcessStarted: state.setIsParseProcessStarted,
+ setIsAutoAnswerProcessStarted: state.setIsAutoAnswerProcessStarted,
+ isAutoAnswerProcessStartedRef: state.isAutoAnswerProcessStartedRef,
+ setHasClickedAutoAnswer: state.setHasClickedAutoAnswer,
+ answeringQuestionIndex: state.answeringQuestionIndex,
+ setAnsweringQuestionIndex: state.setAnsweringQuestionIndex,
+ setQuestionStatuses: state.setQuestionStatuses as Dispatch<
+ SetStateAction>
+ >,
+ questionnaireId,
+ setParseTaskId: () => {},
+ setParseToken: () => {},
+ uploadFileAction: { execute: async () => {}, status: 'idle' as const },
+ parseAction: { execute: async () => {}, status: 'idle' as const },
+ triggerAutoAnswer: autoAnswer.triggerAutoAnswer,
+ triggerSingleAnswer: singleAnswer.triggerSingleAnswer,
+ });
+
+ const persistenceAction = {
+ execute: () => {},
+ executeAsync: (input: Parameters[0]) =>
+ state.updateAnswerAction.executeAsync(input),
+ };
+
+ usePersistGeneratedAnswers({
+ questionnaireId,
+ results: state.results as QuestionAnswer[] | null,
+ setResults: state.setResults as Dispatch>,
+ autoAnswerRun: autoAnswer.autoAnswerRun ?? null,
+ updateAnswerAction: persistenceAction as any,
+ setQuestionStatuses: state.setQuestionStatuses as Dispatch<
+ SetStateAction>
+ >,
+ });
+
+ // Handlers
+ const handlers = useQuestionnaireDetailHandlers({
+ questionnaireId,
+ organizationId,
+ results: state.results,
+ answeringQuestionIndex: state.answeringQuestionIndex,
+ isAutoAnswerProcessStarted: state.isAutoAnswerProcessStarted,
+ isAutoAnswerProcessStartedRef: state.isAutoAnswerProcessStartedRef,
+ setHasClickedAutoAnswer: state.setHasClickedAutoAnswer,
+ setIsAutoAnswerProcessStarted: state.setIsAutoAnswerProcessStarted,
+ setAnsweringQuestionIndex: state.setAnsweringQuestionIndex,
+ setQuestionStatuses: state.setQuestionStatuses,
+ setResults: state.setResults,
+ editingAnswer: state.editingAnswer,
+ setEditingIndex: state.setEditingIndex,
+ setEditingAnswer: state.setEditingAnswer,
+ saveIndexRef: state.saveIndexRef,
+ saveAnswerRef: state.saveAnswerRef,
+ updateAnswerAction: state.updateAnswerAction,
+ deleteAnswerAction: state.deleteAnswerAction,
+ router: state.router,
+ triggerAutoAnswer: autoAnswer.triggerAutoAnswer,
+ triggerSingleAnswer: singleAnswer.triggerSingleAnswer,
+ answerQueue: state.answerQueue,
+ setAnswerQueue: state.setAnswerQueue,
+ answerQueueRef: state.answerQueueRef,
+ });
+
+ // Computed values
+ const filteredResults = useMemo(() => {
+ if (!state.searchQuery.trim()) return state.results;
+ const query = state.searchQuery.toLowerCase();
+ return state.results.filter(
+ (r) =>
+ r.question.toLowerCase().includes(query) ||
+ (r.answer && r.answer.toLowerCase().includes(query))
+ );
+ }, [state.results, state.searchQuery]);
+
+ const answeredCount = useMemo(() => {
+ return state.results.filter((r) => r.answer && r.answer.trim().length > 0).length;
+ }, [state.results]);
+
+ const progressPercentage = useMemo(() => {
+ if (state.results.length === 0) return 0;
+ return Math.round((answeredCount / state.results.length) * 100);
+ }, [answeredCount, state.results.length]);
+
+ const isAutoAnswering = useMemo(() => {
+ return (
+ state.isAutoAnswerProcessStarted &&
+ state.hasClickedAutoAnswer &&
+ (autoAnswer.autoAnswerRun?.status === 'EXECUTING' ||
+ autoAnswer.autoAnswerRun?.status === 'QUEUED' ||
+ autoAnswer.autoAnswerRun?.status === 'WAITING')
+ );
+ }, [
+ state.isAutoAnswerProcessStarted,
+ state.hasClickedAutoAnswer,
+ autoAnswer.autoAnswerRun?.status,
+ ]);
+
+ const isLoading = useMemo(() => {
+ const hasProcessingQuestions = Array.from(state.questionStatuses.values()).some(
+ (status) => status === 'processing'
+ );
+ const isSingleAnswerTriggering = singleAnswer.isSingleAnswerTriggering;
+ const isAutoAnswerTriggering = autoAnswer.isAutoAnswerTriggering;
+ const isAutoAnswerRunActive =
+ autoAnswer.autoAnswerRun?.status === 'EXECUTING' ||
+ autoAnswer.autoAnswerRun?.status === 'QUEUED' ||
+ autoAnswer.autoAnswerRun?.status === 'WAITING';
+
+ return (
+ hasProcessingQuestions ||
+ isSingleAnswerTriggering ||
+ isAutoAnswerTriggering ||
+ isAutoAnswerRunActive
+ );
+ }, [
+ state.questionStatuses,
+ singleAnswer.isSingleAnswerTriggering,
+ autoAnswer.isAutoAnswerTriggering,
+ autoAnswer.autoAnswerRun?.status,
+ ]);
+
+ const isSaving = state.updateAnswerAction.status === 'executing';
+ const savingIndex =
+ isSaving && state.saveIndexRef.current !== null ? state.saveIndexRef.current : null;
+
+ return {
+ orgId: organizationId,
+ results: state.results,
+ searchQuery: state.searchQuery,
+ setSearchQuery: state.setSearchQuery,
+ editingIndex: state.editingIndex,
+ editingAnswer: state.editingAnswer,
+ setEditingAnswer: state.setEditingAnswer,
+ expandedSources: state.expandedSources,
+ questionStatuses: state.questionStatuses,
+ answeringQuestionIndex: state.answeringQuestionIndex,
+ answerQueue: state.answerQueue,
+ hasClickedAutoAnswer: state.hasClickedAutoAnswer,
+ isLoading,
+ isAutoAnswering,
+ isExporting: actions.exportAction.status === 'executing',
+ isSaving,
+ savingIndex,
+ filteredResults,
+ answeredCount,
+ totalCount: state.results.length,
+ progressPercentage,
+ handleAutoAnswer: handlers.handleAutoAnswer,
+ handleAnswerSingleQuestion: handlers.handleAnswerSingleQuestion,
+ handleEditAnswer: actions.handleEditAnswer,
+ handleSaveAnswer: handlers.handleSaveAnswer,
+ handleCancelEdit: actions.handleCancelEdit,
+ handleExport: actions.handleExport,
+ handleToggleSource: actions.handleToggleSource,
+ };
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts
new file mode 100644
index 000000000..23745d715
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts
@@ -0,0 +1,310 @@
+'use client';
+
+import { useCallback, useEffect } from 'react';
+import { toast } from 'sonner';
+import type { QuestionnaireResult } from './types';
+import type { Dispatch, SetStateAction } from 'react';
+
+interface UseQuestionnaireDetailHandlersProps {
+ questionnaireId: string;
+ organizationId: string;
+ results: QuestionnaireResult[];
+ answeringQuestionIndex: number | null;
+ isAutoAnswerProcessStarted: boolean;
+ isAutoAnswerProcessStartedRef: React.MutableRefObject;
+ setHasClickedAutoAnswer: (clicked: boolean) => void;
+ setIsAutoAnswerProcessStarted: (started: boolean) => void;
+ setAnsweringQuestionIndex: (index: number | null) => void;
+ setQuestionStatuses: Dispatch<
+ SetStateAction>
+ >;
+ setResults: Dispatch>;
+ editingAnswer: string;
+ setEditingIndex: (index: number | null) => void;
+ setEditingAnswer: (answer: string) => void;
+ saveIndexRef: React.MutableRefObject;
+ saveAnswerRef: React.MutableRefObject;
+ updateAnswerAction: {
+ execute: (payload: {
+ questionnaireId: string;
+ questionAnswerId: string;
+ answer: string;
+ }) => void;
+ };
+ deleteAnswerAction: {
+ execute: (
+ payload: { questionnaireId: string; questionAnswerId: string }
+ ) => Promise | void;
+ };
+ router: { refresh: () => void };
+ triggerAutoAnswer: (payload: {
+ vendorId: string;
+ organizationId: string;
+ questionsAndAnswers: any[];
+ }) => void;
+ triggerSingleAnswer: (payload: {
+ question: string;
+ organizationId: string;
+ questionIndex: number;
+ totalQuestions: number;
+ }) => void;
+ answerQueue: number[];
+ setAnswerQueue: Dispatch>;
+ answerQueueRef: React.MutableRefObject;
+}
+
+export function useQuestionnaireDetailHandlers({
+ questionnaireId,
+ organizationId,
+ results,
+ answeringQuestionIndex,
+ isAutoAnswerProcessStarted,
+ isAutoAnswerProcessStartedRef,
+ setHasClickedAutoAnswer,
+ setIsAutoAnswerProcessStarted,
+ setAnsweringQuestionIndex,
+ setQuestionStatuses,
+ setResults,
+ editingAnswer,
+ setEditingIndex,
+ setEditingAnswer,
+ saveIndexRef,
+ saveAnswerRef,
+ updateAnswerAction,
+ deleteAnswerAction,
+ router,
+ triggerAutoAnswer,
+ triggerSingleAnswer,
+ answerQueue,
+ setAnswerQueue,
+ answerQueueRef,
+}: UseQuestionnaireDetailHandlersProps) {
+ const handleAutoAnswer = () => {
+ if (answeringQuestionIndex !== null) {
+ toast.warning('Please wait for the current question to finish before answering all questions');
+ return;
+ }
+
+ // Clear queue when starting batch operation
+ setAnswerQueue([]);
+
+ setHasClickedAutoAnswer(true);
+ setIsAutoAnswerProcessStarted(true);
+ isAutoAnswerProcessStartedRef.current = true;
+
+ const questionsToAnswer = results
+ .filter((r) => r.status !== 'manual' && (!r.answer || r.answer.trim().length === 0))
+ .map((r) => ({
+ question: r.question,
+ answer: r.answer || null,
+ _originalIndex: r.originalIndex,
+ }));
+
+ if (questionsToAnswer.length === 0) {
+ toast.info('All questions already have answers');
+ setIsAutoAnswerProcessStarted(false);
+ isAutoAnswerProcessStartedRef.current = false;
+ return;
+ }
+
+ setQuestionStatuses((prev) => {
+ const newStatuses = new Map(prev);
+ questionsToAnswer.forEach((q) => {
+ if (q._originalIndex !== undefined) {
+ newStatuses.set(q._originalIndex, 'processing');
+ }
+ });
+ return newStatuses;
+ });
+
+ try {
+ triggerAutoAnswer({
+ vendorId: `org_${organizationId}`,
+ organizationId,
+ questionsAndAnswers: questionsToAnswer.map((q) => ({
+ question: q.question,
+ answer: q.answer,
+ _originalIndex: q._originalIndex,
+ })) as any,
+ });
+ } catch (error) {
+ console.error('Failed to trigger auto-answer:', error);
+ toast.error('Failed to start auto-answer process');
+ setIsAutoAnswerProcessStarted(false);
+ isAutoAnswerProcessStartedRef.current = false;
+ setQuestionStatuses((prev) => {
+ const newStatuses = new Map(prev);
+ questionsToAnswer.forEach((q) => {
+ if (q._originalIndex !== undefined) {
+ newStatuses.delete(q._originalIndex);
+ }
+ });
+ return newStatuses;
+ });
+ }
+ };
+
+ const processNextInQueue = useCallback(() => {
+ // If there's already a question being processed, don't start a new one
+ if (answeringQuestionIndex !== null) {
+ return;
+ }
+
+ // Get the next question from queue
+ const queue = answerQueueRef.current;
+ if (queue.length === 0) {
+ return;
+ }
+
+ const nextIndex = queue[0];
+ const result = results.find((r) => r.originalIndex === nextIndex);
+
+ if (!result) {
+ // Remove invalid index from queue
+ setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex));
+ // Try next one
+ setTimeout(() => processNextInQueue(), 0);
+ return;
+ }
+
+ // Skip if already answered manually
+ if (result.status === 'manual' && result.answer && result.answer.trim().length > 0) {
+ // Remove from queue
+ setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex));
+ // Try next one
+ setTimeout(() => processNextInQueue(), 0);
+ return;
+ }
+
+ // Remove from queue and start processing
+ setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex));
+ setAnsweringQuestionIndex(nextIndex);
+
+ setQuestionStatuses((prev) => {
+ const newStatuses = new Map(prev);
+ newStatuses.set(nextIndex, 'processing');
+ return newStatuses;
+ });
+
+ triggerSingleAnswer({
+ question: result.question,
+ organizationId,
+ questionIndex: nextIndex,
+ totalQuestions: results.length,
+ });
+ }, [answeringQuestionIndex, results, organizationId, setAnswerQueue, setAnsweringQuestionIndex, setQuestionStatuses, triggerSingleAnswer]);
+
+ const handleAnswerSingleQuestion = (index: number) => {
+ // Don't allow adding to queue if batch operation is running
+ if (isAutoAnswerProcessStarted) {
+ return;
+ }
+
+ const result = results.find((r) => r.originalIndex === index);
+ if (!result) {
+ return;
+ }
+
+ // Skip if already answered manually
+ if (result.status === 'manual' && result.answer && result.answer.trim().length > 0) {
+ return;
+ }
+
+ // Check if already in queue
+ const queue = answerQueueRef.current;
+ if (queue.includes(index)) {
+ return; // Already queued
+ }
+
+ // Check if currently being processed
+ if (answeringQuestionIndex === index) {
+ return; // Already processing
+ }
+
+ // Add to queue
+ setAnswerQueue((prev) => [...prev, index]);
+
+ // If no question is currently being processed, start processing immediately
+ if (answeringQuestionIndex === null) {
+ processNextInQueue();
+ }
+ };
+
+ // Auto-process next question in queue when current question finishes
+ useEffect(() => {
+ // When answeringQuestionIndex becomes null (question finished), process next in queue
+ if (answeringQuestionIndex === null && answerQueue.length > 0) {
+ // Small delay to ensure state updates are complete
+ const timeoutId = setTimeout(() => {
+ processNextInQueue();
+ }, 100);
+ return () => clearTimeout(timeoutId);
+ }
+ }, [answeringQuestionIndex, answerQueue, processNextInQueue]);
+
+ const handleDeleteAnswer = async (questionAnswerId: string, questionIndex: number) => {
+ try {
+ await Promise.resolve(
+ deleteAnswerAction.execute({
+ questionnaireId,
+ questionAnswerId,
+ })
+ );
+
+ setResults((prev) =>
+ prev.map((r) =>
+ r.originalIndex === questionIndex
+ ? { ...r, answer: '', status: 'untouched' as const }
+ : r
+ )
+ );
+
+ toast.success('Answer deleted. You can now generate a new answer.');
+ router.refresh();
+ } catch (error) {
+ console.error('Failed to delete answer:', error);
+ toast.error('Failed to delete answer');
+ }
+ };
+
+ const handleSaveAnswer = (index: number) => {
+ const result = results[index];
+
+ if (!result) {
+ console.error('Cannot save answer: result not found at index', {
+ index,
+ resultsLength: results.length,
+ results: results.map((r, i) => ({
+ i,
+ originalIndex: r.originalIndex,
+ questionAnswerId: r.questionAnswerId,
+ })),
+ });
+ toast.error('Cannot find question to save');
+ return;
+ }
+
+ if (!result.questionAnswerId) {
+ console.error('Cannot save answer: questionAnswerId not found', { index, result });
+ toast.error('Cannot save answer: missing question ID');
+ return;
+ }
+
+ saveIndexRef.current = index;
+ saveAnswerRef.current = editingAnswer;
+
+ updateAnswerAction.execute({
+ questionnaireId,
+ questionAnswerId: result.questionAnswerId,
+ answer: editingAnswer.trim(),
+ });
+ };
+
+ return {
+ handleAutoAnswer,
+ handleAnswerSingleQuestion,
+ handleDeleteAnswer,
+ handleSaveAnswer,
+ };
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts
new file mode 100644
index 000000000..5a4970cf6
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts
@@ -0,0 +1,155 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { useAction } from 'next-safe-action/hooks';
+import { useRouter } from 'next/navigation';
+import { updateQuestionnaireAnswer } from '../../actions/update-questionnaire-answer';
+import { deleteQuestionnaireAnswer } from '../../actions/delete-questionnaire-answer';
+import { createTriggerToken } from '../../actions/create-trigger-token';
+import type { QuestionnaireResult, QuestionnaireQuestionAnswer } from './types';
+
+interface UseQuestionnaireDetailStateProps {
+ initialQuestions: QuestionnaireQuestionAnswer[];
+ questionnaireId: string;
+}
+
+export function useQuestionnaireDetailState({
+ initialQuestions,
+ questionnaireId,
+}: UseQuestionnaireDetailStateProps) {
+ const router = useRouter();
+
+ // Initialize results from database
+ const [results, setResults] = useState(() =>
+ initialQuestions.map((q) => ({
+ question: q.question,
+ answer: q.answer ?? null,
+ originalIndex: q.questionIndex,
+ sources: q.sources ? (Array.isArray(q.sources) ? q.sources : []) : [],
+ questionAnswerId: q.id,
+ status: q.status,
+ failedToGenerate: false,
+ }))
+ );
+
+ // State management
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [editingAnswer, setEditingAnswer] = useState('');
+ const [expandedSources, setExpandedSources] = useState>(new Set());
+ const [questionStatuses, setQuestionStatuses] = useState<
+ Map
+ >(new Map());
+ const [answeringQuestionIndex, setAnsweringQuestionIndex] = useState(null);
+ const [isAutoAnswerProcessStarted, setIsAutoAnswerProcessStarted] = useState(false);
+ const [isParseProcessStarted, setIsParseProcessStarted] = useState(false);
+ const [hasClickedAutoAnswer, setHasClickedAutoAnswer] = useState(false);
+ const [autoAnswerToken, setAutoAnswerToken] = useState(null);
+ const [singleAnswerToken, setSingleAnswerToken] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const isAutoAnswerProcessStartedRef = useRef(false);
+
+ // Queue for single question answers - allows users to click multiple questions
+ // Questions will be processed sequentially
+ const [answerQueue, setAnswerQueue] = useState([]);
+ const answerQueueRef = useRef([]);
+
+ // Refs to capture values for save callback
+ const saveIndexRef = useRef(null);
+ const saveAnswerRef = useRef('');
+
+ // Actions
+ const updateAnswerAction = useAction(updateQuestionnaireAnswer, {
+ onSuccess: () => {
+ if (saveIndexRef.current !== null) {
+ const index = saveIndexRef.current;
+ const answer = saveAnswerRef.current;
+
+ setResults((prev) =>
+ prev.map((r, i) => {
+ if (i === index) {
+ const trimmedAnswer = answer.trim();
+ return {
+ ...r,
+ answer: trimmedAnswer || null,
+ status: trimmedAnswer ? ('manual' as const) : ('untouched' as const),
+ failedToGenerate: false,
+ // Preserve sources when manually editing answer
+ sources: r.sources || [],
+ };
+ }
+ return r;
+ })
+ );
+
+ setEditingIndex(null);
+ setEditingAnswer('');
+ router.refresh();
+
+ saveIndexRef.current = null;
+ saveAnswerRef.current = '';
+ }
+ },
+ onError: ({ error }) => {
+ console.error('Failed to update answer:', error);
+ if (saveIndexRef.current !== null) {
+ saveIndexRef.current = null;
+ saveAnswerRef.current = '';
+ }
+ },
+ });
+
+ const deleteAnswerAction = useAction(deleteQuestionnaireAnswer);
+
+ // Create trigger token for auto-answer (single question answers now use server action)
+ useEffect(() => {
+ const fetchToken = async () => {
+ const autoTokenResult = await createTriggerToken('vendor-questionnaire-orchestrator');
+
+ if (autoTokenResult.success && autoTokenResult.token) {
+ setAutoAnswerToken(autoTokenResult.token);
+ }
+ };
+
+ fetchToken();
+ }, []);
+
+ // Sync queue ref with state
+ useEffect(() => {
+ answerQueueRef.current = answerQueue;
+ }, [answerQueue]);
+
+ return {
+ results,
+ setResults,
+ editingIndex,
+ setEditingIndex,
+ editingAnswer,
+ setEditingAnswer,
+ expandedSources,
+ setExpandedSources,
+ questionStatuses,
+ setQuestionStatuses,
+ answeringQuestionIndex,
+ setAnsweringQuestionIndex,
+ isAutoAnswerProcessStarted,
+ setIsAutoAnswerProcessStarted,
+ isParseProcessStarted,
+ setIsParseProcessStarted,
+ hasClickedAutoAnswer,
+ setHasClickedAutoAnswer,
+ autoAnswerToken,
+ singleAnswerToken,
+ searchQuery,
+ setSearchQuery,
+ isAutoAnswerProcessStartedRef,
+ saveIndexRef,
+ saveAnswerRef,
+ updateAnswerAction,
+ deleteAnswerAction,
+ router,
+ answerQueue,
+ setAnswerQueue,
+ answerQueueRef,
+ };
+}
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParse.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParse.ts
index 6941778d4..c350c7bd3 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParse.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParse.ts
@@ -16,8 +16,6 @@ interface UseQuestionnaireParseProps {
parseToken: string | null;
autoAnswerToken: string | null;
setAutoAnswerToken: (token: string | null) => void;
- singleAnswerToken: string | null;
- setSingleAnswerToken: (token: string | null) => void;
setIsParseProcessStarted: (started: boolean) => void;
setParseTaskId: (id: string | null) => void;
setParseToken: (token: string | null) => void;
@@ -36,8 +34,6 @@ export function useQuestionnaireParse({
parseToken,
autoAnswerToken,
setAutoAnswerToken,
- singleAnswerToken,
- setSingleAnswerToken,
setIsParseProcessStarted,
setParseTaskId,
setParseToken,
@@ -62,18 +58,6 @@ export function useQuestionnaireParse({
}
}, [autoAnswerToken, setAutoAnswerToken]);
- // Get trigger token for single answer (can trigger and read)
- useEffect(() => {
- async function getSingleAnswerToken() {
- const result = await createTriggerToken('answer-question');
- if (result.success && result.token) {
- setSingleAnswerToken(result.token);
- }
- }
- if (!singleAnswerToken) {
- getSingleAnswerToken();
- }
- }, [singleAnswerToken, setSingleAnswerToken]);
// Track parse task with realtime hook
const { run: parseRun, error: parseError } = useRealtimeRun(
@@ -95,24 +79,28 @@ export function useQuestionnaireParse({
const questionnaireId = run.output.questionnaireId as string | undefined;
if (questionsAndAnswers && Array.isArray(questionsAndAnswers)) {
- const initializedResults = questionsAndAnswers.map((qa) => ({
- ...qa,
- failedToGenerate: false,
- }));
- setResults(initializedResults);
- setExtractedContent(extractedContent || null);
- setQuestionStatuses(new Map());
- setHasClickedAutoAnswer(false);
if (questionnaireId) {
+ // Navigate immediately to avoid showing results on new_questionnaire page
+ // The detail page will load the data from the database
setQuestionnaireId(questionnaireId);
- // Redirect to questionnaire detail page after successful parse
- setTimeout(() => {
- router.push(`/${orgId}/security-questionnaire/${questionnaireId}`);
- }, 500); // Small delay to show success toast
+ toast.success(
+ `Successfully parsed ${questionsAndAnswers.length} question-answer pairs`,
+ );
+ router.push(`/${orgId}/security-questionnaire/${questionnaireId}`);
+ } else {
+ // Fallback: if no questionnaireId, set results locally (shouldn't happen)
+ const initializedResults = questionsAndAnswers.map((qa) => ({
+ ...qa,
+ failedToGenerate: false,
+ }));
+ setResults(initializedResults);
+ setExtractedContent(extractedContent || null);
+ setQuestionStatuses(new Map());
+ setHasClickedAutoAnswer(false);
+ toast.success(
+ `Successfully parsed ${questionsAndAnswers.length} question-answer pairs`,
+ );
}
- toast.success(
- `Successfully parsed ${questionsAndAnswers.length} question-answer pairs`,
- );
} else {
toast.error('Parsed data is missing questions');
}
@@ -153,6 +141,7 @@ export function useQuestionnaireParse({
return;
}
+ // ✅ Do NOT reset isParseProcessStarted here - task is started, need to wait for completion
// Clear old token before setting new task ID to prevent using wrong token with new run
setParseToken(null);
setParseTaskId(taskId);
@@ -160,12 +149,16 @@ export function useQuestionnaireParse({
const tokenResult = await createRunReadToken(taskId);
if (tokenResult.success && tokenResult.token) {
setParseToken(tokenResult.token);
+ // ✅ Token created successfully - useRealtimeRun will connect and track the task
+ // isParseProcessStarted remains true until task completion (in onComplete)
} else {
+ // ✅ Only if token creation failed - reset state
setIsParseProcessStarted(false);
- toast.error('Failed to create read token for parse task');
+ toast.error('Failed to create read token for parse task. The task may still be running - please check Trigger.dev dashboard.');
}
},
onError: ({ error }) => {
+ // ✅ Only on task start error - reset state
setIsParseProcessStarted(false);
console.error('Parse action error:', error);
toast.error(error.serverError || 'Failed to start parse questionnaire');
@@ -180,6 +173,7 @@ export function useQuestionnaireParse({
const fileType = responseData?.fileType;
if (s3Key && fileType) {
+ // ✅ isParseProcessStarted remains true - task continues
parseAction.execute({
inputType: 's3',
s3Key,
@@ -187,10 +181,14 @@ export function useQuestionnaireParse({
fileType,
});
} else {
+ // ✅ Only if S3 key is missing - reset state
+ setIsParseProcessStarted(false);
toast.error('Failed to get S3 key after upload');
}
},
onError: ({ error }) => {
+ // ✅ On upload error - reset state
+ setIsParseProcessStarted(false);
console.error('Upload action error:', error);
toast.error(error.serverError || 'Failed to upload file');
},
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts
index ebe54cb8a..2a6f4cd54 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { useQuestionnaireActions } from './useQuestionnaireActions';
import { useQuestionnaireAutoAnswer } from './useQuestionnaireAutoAnswer';
import { useQuestionnaireParse } from './useQuestionnaireParse';
@@ -15,8 +15,6 @@ export function useQuestionnaireParser() {
parseToken: state.parseToken,
autoAnswerToken: state.autoAnswerToken,
setAutoAnswerToken: state.setAutoAnswerToken,
- singleAnswerToken: state.singleAnswerToken,
- setSingleAnswerToken: state.setSingleAnswerToken,
setIsParseProcessStarted: state.setIsParseProcessStarted,
setParseTaskId: state.setParseTaskId,
setParseToken: state.setParseToken,
@@ -36,17 +34,20 @@ export function useQuestionnaireParser() {
isAutoAnswerProcessStartedRef: state.isAutoAnswerProcessStartedRef,
setIsAutoAnswerProcessStarted: state.setIsAutoAnswerProcessStarted,
setResults: state.setResults,
- setQuestionStatuses: state.setQuestionStatuses,
+ setQuestionStatuses: state.setQuestionStatuses as Dispatch<
+ SetStateAction>
+ >,
setAnsweringQuestionIndex: state.setAnsweringQuestionIndex,
questionnaireId: state.questionnaireId,
});
const singleAnswer = useQuestionnaireSingleAnswer({
- singleAnswerToken: state.singleAnswerToken,
results: state.results,
answeringQuestionIndex: state.answeringQuestionIndex,
setResults: state.setResults,
- setQuestionStatuses: state.setQuestionStatuses,
+ setQuestionStatuses: state.setQuestionStatuses as Dispatch<
+ SetStateAction>
+ >,
setAnsweringQuestionIndex: state.setAnsweringQuestionIndex,
questionnaireId: state.questionnaireId,
});
@@ -63,6 +64,7 @@ export function useQuestionnaireParser() {
setEditingAnswer: state.setEditingAnswer,
setResults: state.setResults,
setExpandedSources: state.setExpandedSources,
+ isParseProcessStarted: state.isParseProcessStarted, // ✅ Added
setIsParseProcessStarted: state.setIsParseProcessStarted,
setIsAutoAnswerProcessStarted: state.setIsAutoAnswerProcessStarted,
isAutoAnswerProcessStartedRef: state.isAutoAnswerProcessStartedRef,
@@ -78,7 +80,26 @@ export function useQuestionnaireParser() {
triggerSingleAnswer: singleAnswer.triggerSingleAnswer,
});
+ // ✅ Improved isLoading logic - always shows loading until task completion
const isLoading = useMemo(() => {
+ // If parsing process has started, show loading until explicit completion
+ if (state.isParseProcessStarted) {
+ // Check if task is completed
+ const isCompleted =
+ parse.parseRun?.status === 'COMPLETED' ||
+ parse.parseRun?.status === 'FAILED' ||
+ parse.parseRun?.status === 'CANCELED';
+
+ // If task is completed, hide loading
+ if (isCompleted) {
+ return false;
+ }
+
+ // Otherwise show loading (even if parseRun is not created yet)
+ return true;
+ }
+
+ // Additional checks for reliability
const isUploading = parse.uploadFileAction.status === 'executing';
const isParseActionExecuting = parse.parseAction.status === 'executing';
const isParseRunActive =
@@ -86,30 +107,10 @@ export function useQuestionnaireParser() {
parse.parseRun?.status === 'QUEUED' ||
parse.parseRun?.status === 'WAITING';
- if (isParseRunActive) {
+ if (isParseRunActive || isParseActionExecuting || isUploading) {
return true;
}
- if (isParseActionExecuting) {
- return true;
- }
-
- if (isUploading) {
- return true;
- }
-
- if (state.isParseProcessStarted && !parse.parseRun) {
- return true;
- }
-
- if (
- parse.parseRun?.status === 'COMPLETED' ||
- parse.parseRun?.status === 'FAILED' ||
- parse.parseRun?.status === 'CANCELED'
- ) {
- return false;
- }
-
return false;
}, [
parse.uploadFileAction.status,
@@ -143,26 +144,39 @@ export function useQuestionnaireParser() {
state.setShowExitDialog(false);
};
- // Get raw parsing status from actions/task
+ // ✅ Improved rawParseStatus logic - accounts for all statuses including cold start
const rawParseStatus = useMemo(() => {
- if (parse.uploadFileAction.status === 'executing') {
- return 'uploading';
- }
- if (parse.parseAction.status === 'executing') {
- return 'starting';
- }
- if (parse.parseRun?.status === 'QUEUED') {
- return 'queued';
- }
- if (parse.parseRun?.status === 'EXECUTING') {
- return 'analyzing';
- }
- if (parse.parseRun?.status === 'WAITING') {
- return 'processing';
- }
- // Keep showing status if parsing process started but no run yet
- if (state.isParseProcessStarted && !parse.parseRun) {
- return 'starting';
+ // If parsing process has started, always show status
+ if (state.isParseProcessStarted) {
+ // Check statuses in priority order
+ if (parse.uploadFileAction.status === 'executing') {
+ return 'uploading';
+ }
+ if (parse.parseAction.status === 'executing') {
+ return 'starting';
+ }
+ if (parse.parseRun?.status === 'QUEUED') {
+ return 'queued';
+ }
+ if (parse.parseRun?.status === 'EXECUTING') {
+ return 'analyzing';
+ }
+ if (parse.parseRun?.status === 'WAITING') {
+ return 'processing';
+ }
+ // If task is not created yet but process has started - show starting
+ if (!parse.parseRun) {
+ return 'starting';
+ }
+ // If task is completed but isParseProcessStarted is still true - show processing
+ // (will be reset in onComplete)
+ if (
+ parse.parseRun?.status === 'COMPLETED' ||
+ parse.parseRun?.status === 'FAILED' ||
+ parse.parseRun?.status === 'CANCELED'
+ ) {
+ return null; // Task completed, status will be reset
+ }
}
return null;
}, [
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts
index 2189ba06e..c01d4feda 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts
@@ -1,15 +1,13 @@
'use client';
-import { useRealtimeTaskTrigger } from '@trigger.dev/react-hooks';
-import type { answerQuestion } from '@/jobs/tasks/vendors/answer-question';
-import { useEffect, useTransition } from 'react';
-import { toast } from 'sonner';
import { useAction } from 'next-safe-action/hooks';
+import { answerSingleQuestionAction } from '../actions/answer-single-question';
import { saveAnswerAction } from '../actions/save-answer';
import type { QuestionAnswer } from '../components/types';
+import { toast } from 'sonner';
+import { useTransition, useEffect } from 'react';
interface UseQuestionnaireSingleAnswerProps {
- singleAnswerToken: string | null;
results: QuestionAnswer[] | null;
answeringQuestionIndex: number | null;
setResults: React.Dispatch>;
@@ -21,7 +19,6 @@ interface UseQuestionnaireSingleAnswerProps {
}
export function useQuestionnaireSingleAnswer({
- singleAnswerToken,
results,
answeringQuestionIndex,
setResults,
@@ -29,125 +26,69 @@ export function useQuestionnaireSingleAnswer({
setAnsweringQuestionIndex,
questionnaireId,
}: UseQuestionnaireSingleAnswerProps) {
- // Use realtime task trigger for single question answer
- const {
- submit: triggerSingleAnswer,
- run: singleAnswerRun,
- error: singleAnswerError,
- isLoading: isSingleAnswerTriggering,
- } = useRealtimeTaskTrigger('answer-question', {
- accessToken: singleAnswerToken || undefined,
- enabled: !!singleAnswerToken,
- });
-
- // Action for saving answer
- const saveAnswer = useAction(saveAnswerAction, {
- onError: ({ error }) => {
- console.error('Error saving answer:', error);
- },
- });
-
- const [isPending, startTransition] = useTransition();
-
- // Set status to processing when task starts or is triggering
- useEffect(() => {
- if (answeringQuestionIndex !== null) {
- const shouldBeProcessing =
- isSingleAnswerTriggering ||
- singleAnswerRun?.status === 'EXECUTING' ||
- singleAnswerRun?.status === 'QUEUED' ||
- singleAnswerRun?.status === 'WAITING';
-
- if (shouldBeProcessing) {
- setQuestionStatuses((prev) => {
- const newStatuses = new Map(prev);
- // Ensure status is set to processing when task is running or triggering
- const currentStatus = prev.get(answeringQuestionIndex);
- if (currentStatus !== 'processing') {
- newStatuses.set(answeringQuestionIndex, 'processing');
- return newStatuses;
- }
- return prev;
- });
- }
- }
- }, [singleAnswerRun?.status, answeringQuestionIndex, isSingleAnswerTriggering, setQuestionStatuses]);
+ // Use server action to answer single question directly
+ const answerQuestion = useAction(answerSingleQuestionAction, {
+ onSuccess: ({ data }) => {
+ if (!data?.data || answeringQuestionIndex === null) return;
- // Handle single answer completion
- useEffect(() => {
- if (singleAnswerRun?.status === 'COMPLETED' && singleAnswerRun.output && answeringQuestionIndex !== null) {
- const output = singleAnswerRun.output as {
- success: boolean;
- questionIndex: number;
- question: string;
- answer: string | null;
- sources?: Array<{
- sourceType: string;
- sourceName?: string;
- score: number;
- }>;
- };
+ const output = data.data;
// Verify we're processing the correct question
if (output.questionIndex !== answeringQuestionIndex) {
- return; // Skip if this is not the question we're currently answering
+ return;
}
- if (output.success && output.answer) {
+ if (data.success && output.answer) {
const targetIndex = output.questionIndex;
- // Verify we're updating the correct question
- if (targetIndex === answeringQuestionIndex && targetIndex >= 0) {
- // Update the results with the answer
- setResults((prevResults) => {
- if (!prevResults) return prevResults;
-
- const updatedResults = [...prevResults];
-
- // Try to find by questionIndex first (for QuestionnaireResult with originalIndex)
- // Otherwise use array index
- let resultIndex = -1;
- for (let i = 0; i < updatedResults.length; i++) {
- const result = updatedResults[i] as any;
- if (result.originalIndex === targetIndex || result._originalIndex === targetIndex) {
- resultIndex = i;
- break;
- }
- }
-
- // Fallback to array index if not found by originalIndex
- if (resultIndex === -1 && targetIndex < updatedResults.length) {
- resultIndex = targetIndex;
- }
-
- if (resultIndex >= 0 && resultIndex < updatedResults.length) {
- const existingResult = updatedResults[resultIndex];
- updatedResults[resultIndex] = {
- ...existingResult,
- question: existingResult.question, // Preserve original question text
- answer: output.answer,
- sources: output.sources,
- failedToGenerate: false,
- };
- return updatedResults;
+ // Update the results with the answer
+ setResults((prevResults) => {
+ if (!prevResults) return prevResults;
+
+ const updatedResults = [...prevResults];
+
+ // Try to find by questionIndex first (for QuestionnaireResult with originalIndex)
+ // Otherwise use array index
+ let resultIndex = -1;
+ for (let i = 0; i < updatedResults.length; i++) {
+ const result = updatedResults[i] as any;
+ if (result.originalIndex === targetIndex || result._originalIndex === targetIndex) {
+ resultIndex = i;
+ break;
}
+ }
- return prevResults;
- });
+ // Fallback to array index if not found by originalIndex
+ if (resultIndex === -1 && targetIndex < updatedResults.length) {
+ resultIndex = targetIndex;
+ }
- // Save answer to database (outside of setState)
- if (questionnaireId && output.answer) {
- // Use startTransition to defer the save call to avoid rendering issues
- startTransition(() => {
- saveAnswer.execute({
- questionnaireId,
- questionIndex: targetIndex,
- answer: output.answer!,
- sources: output.sources,
- status: 'generated',
- });
- });
+ if (resultIndex >= 0 && resultIndex < updatedResults.length) {
+ const existingResult = updatedResults[resultIndex];
+ updatedResults[resultIndex] = {
+ ...existingResult,
+ question: existingResult.question, // Preserve original question text
+ answer: output.answer,
+ sources: output.sources,
+ failedToGenerate: false,
+ };
+ return updatedResults;
}
+
+ return prevResults;
+ });
+
+ // Save answer to database
+ if (questionnaireId && output.answer) {
+ startTransition(() => {
+ saveAnswer.execute({
+ questionnaireId,
+ questionIndex: targetIndex,
+ answer: output.answer!,
+ sources: output.sources,
+ status: 'generated',
+ });
+ });
}
// Mark question as completed
@@ -192,45 +133,60 @@ export function useQuestionnaireSingleAnswer({
// Reset answering index
setAnsweringQuestionIndex(null);
- }
- }, [singleAnswerRun?.status, singleAnswerRun?.output, answeringQuestionIndex, questionnaireId, saveAnswer, setResults, setQuestionStatuses, setAnsweringQuestionIndex]);
+ },
+ onError: ({ error }) => {
+ if (answeringQuestionIndex !== null) {
+ setQuestionStatuses((prev) => {
+ const newStatuses = new Map(prev);
+ newStatuses.set(answeringQuestionIndex, 'completed');
+ return newStatuses;
+ });
+ setAnsweringQuestionIndex(null);
+ toast.error(`Failed to generate answer: ${error.serverError || 'Unknown error'}`);
+ }
+ },
+ });
- // Handle single answer errors
- useEffect(() => {
- if (singleAnswerError && answeringQuestionIndex !== null) {
- setQuestionStatuses((prev) => {
- const newStatuses = new Map(prev);
- newStatuses.set(answeringQuestionIndex, 'completed');
- return newStatuses;
- });
- setAnsweringQuestionIndex(null);
- toast.error(`Failed to generate answer: ${singleAnswerError.message}`);
- }
- }, [singleAnswerError, answeringQuestionIndex, setQuestionStatuses, setAnsweringQuestionIndex]);
+ // Action for saving answer
+ const saveAnswer = useAction(saveAnswerAction, {
+ onError: ({ error }) => {
+ console.error('Error saving answer:', error);
+ },
+ });
+
+ const [isPending, startTransition] = useTransition();
- // Handle task failures and cancellations
+ // Set status to processing when action is executing
useEffect(() => {
- if ((singleAnswerRun?.status === 'FAILED' || singleAnswerRun?.status === 'CANCELED') && answeringQuestionIndex !== null) {
+ if (answeringQuestionIndex !== null && answerQuestion.status === 'executing') {
setQuestionStatuses((prev) => {
const newStatuses = new Map(prev);
- newStatuses.set(answeringQuestionIndex, 'completed');
- return newStatuses;
+ const currentStatus = prev.get(answeringQuestionIndex);
+ if (currentStatus !== 'processing') {
+ newStatuses.set(answeringQuestionIndex, 'processing');
+ return newStatuses;
+ }
+ return prev;
});
- setAnsweringQuestionIndex(null);
-
- const errorMessage =
- singleAnswerRun.error instanceof Error
- ? singleAnswerRun.error.message
- : typeof singleAnswerRun.error === 'string'
- ? singleAnswerRun.error
- : 'Task failed or was canceled';
- toast.error(`Failed to generate answer: ${errorMessage}`);
}
- }, [singleAnswerRun?.status, singleAnswerRun?.error, answeringQuestionIndex, setQuestionStatuses, setAnsweringQuestionIndex]);
+ }, [answeringQuestionIndex, answerQuestion.status, setQuestionStatuses]);
+
+ const triggerSingleAnswer = (payload: {
+ question: string;
+ organizationId: string;
+ questionIndex: number;
+ totalQuestions: number;
+ }) => {
+ answerQuestion.execute({
+ question: payload.question,
+ questionIndex: payload.questionIndex,
+ totalQuestions: payload.totalQuestions,
+ });
+ };
return {
triggerSingleAnswer,
- isSingleAnswerTriggering,
+ isSingleAnswerTriggering: answerQuestion.status === 'executing',
};
}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/ContextSection.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/ContextSection.tsx
index 79090b867..497c3aa82 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/ContextSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/ContextSection.tsx
@@ -1,13 +1,11 @@
'use client';
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@comp/ui/accordion';
import { Button } from '@comp/ui/button';
import { Card } from '@comp/ui';
import { isJSON } from '@/lib/utils';
import { ChevronLeft, ChevronRight, ExternalLink, MessageSquare } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
-import { useRef } from 'react';
import { usePagination } from '../../hooks/usePagination';
interface ContextSectionProps {
@@ -64,7 +62,6 @@ function formatAnswer(answer: string): string {
export function ContextSection({ contextEntries }: ContextSectionProps) {
const params = useParams();
const orgId = params.orgId as string;
- const sectionRef = useRef(null);
const validEntries = contextEntries.filter((entry) => hasValidAnswer(entry.answer));
@@ -73,29 +70,9 @@ export function ContextSection({ contextEntries }: ContextSectionProps) {
itemsPerPage: 5,
});
- const handleAccordionChange = (value: string) => {
- // If opening (value is set), scroll to section
- if (value === 'context' && sectionRef.current) {
- setTimeout(() => {
- sectionRef.current?.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- }, 100); // Small delay to allow accordion animation to start
- }
- };
-
return (
-
-
-
-
+
+
Context
@@ -103,15 +80,15 @@ export function ContextSection({ contextEntries }: ContextSectionProps) {
({validEntries.length})
-
-
+
+
{validEntries.length === 0 ? (
No context entries found
) : (
<>
-
+
{paginatedItems.map((entry) => {
const formattedAnswer = formatAnswer(entry.answer);
return (
@@ -120,9 +97,9 @@ export function ContextSection({ contextEntries }: ContextSectionProps) {
href={`/${orgId}/settings/context-hub`}
target="_blank"
rel="noopener noreferrer"
- className="group rounded-md border border-border bg-background p-3 transition-colors hover:bg-muted/50 hover:border-primary/50"
+ className="group rounded-md border border-border bg-background p-3 transition-colors hover:bg-muted/50 hover:border-primary/50 h-[82px] flex items-center"
>
-
+
{entry.question}
@@ -133,7 +110,7 @@ export function ContextSection({ contextEntries }: ContextSectionProps) {
)}
-
+
);
@@ -170,9 +147,7 @@ export function ContextSection({ contextEntries }: ContextSectionProps) {
)}
>
)}
-
-
-
+
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx
index 0b0d64782..afb59e7b5 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx
@@ -16,7 +16,7 @@ import { Card } from '@comp/ui';
import { ChevronLeft, ChevronRight, ExternalLink, PenTool, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
-import { useRef, useState } from 'react';
+import { useRef, useState, useEffect } from 'react';
import { usePagination } from '../../hooks/usePagination';
import { format } from 'date-fns';
import { useAction } from 'next-safe-action/hooks';
@@ -38,6 +38,7 @@ export function ManualAnswersSection({ manualAnswers }: ManualAnswersSectionProp
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
const [answerIdToDelete, setAnswerIdToDelete] = useState
(null);
const [isDeleting, setIsDeleting] = useState(false);
+ const [accordionValue, setAccordionValue] = useState('');
const deleteAction = useAction(deleteManualAnswer, {
onSuccess: ({ data }) => {
@@ -110,9 +111,57 @@ export function ManualAnswersSection({ manualAnswers }: ManualAnswersSectionProp
}
};
+ // Handle hash navigation on mount and when hash changes
+ useEffect(() => {
+ const handleHashNavigation = () => {
+ const hash = window.location.hash;
+ if (hash && hash.startsWith('#manual-answer-')) {
+ const manualAnswerId = hash.replace('#manual-answer-', '');
+ const answerElement = document.getElementById(`manual-answer-${manualAnswerId}`);
+
+ if (answerElement) {
+ // Open accordion first
+ setAccordionValue('manual-answers');
+
+ // Scroll to the specific manual answer after accordion opens
+ setTimeout(() => {
+ answerElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ // Highlight the element briefly
+ answerElement.classList.add('ring-2', 'ring-primary', 'ring-offset-2');
+ setTimeout(() => {
+ answerElement.classList.remove('ring-2', 'ring-primary', 'ring-offset-2');
+ }, 2000);
+ }, 300); // Wait for accordion animation
+ }
+ }
+ };
+
+ // Check hash on mount
+ handleHashNavigation();
+
+ // Listen for hash changes
+ window.addEventListener('hashchange', handleHashNavigation);
+
+ return () => {
+ window.removeEventListener('hashchange', handleHashNavigation);
+ };
+ }, []);
+
return (
-
+ {
+ setAccordionValue(value);
+ handleAccordionChange(value);
+ }}
+ >
@@ -136,6 +185,7 @@ export function ManualAnswersSection({ manualAnswers }: ManualAnswersSectionProp
return (
{/* Published Policies and Context Sections - Side by Side */}
-
+
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/published-policies/components/PublishedPoliciesSection.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/published-policies/components/PublishedPoliciesSection.tsx
index a5679268a..383361881 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/published-policies/components/PublishedPoliciesSection.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/published-policies/components/PublishedPoliciesSection.tsx
@@ -1,12 +1,10 @@
'use client';
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@comp/ui/accordion';
import { Button } from '@comp/ui/button';
import { Card } from '@comp/ui';
import { ChevronLeft, ChevronRight, ExternalLink, FileText } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
-import { useRef } from 'react';
import { usePagination } from '../../hooks/usePagination';
interface PublishedPoliciesSectionProps {
@@ -16,59 +14,38 @@ interface PublishedPoliciesSectionProps {
export function PublishedPoliciesSection({ policies }: PublishedPoliciesSectionProps) {
const params = useParams();
const orgId = params.orgId as string;
- const sectionRef = useRef
(null);
const { currentPage, totalPages, paginatedItems, handlePageChange } = usePagination({
items: policies,
itemsPerPage: 5,
});
- const handleAccordionChange = (value: string) => {
- // If opening (value is set), scroll to section
- if (value === 'published-policies' && sectionRef.current) {
- setTimeout(() => {
- sectionRef.current?.scrollIntoView({
- behavior: 'smooth',
- block: 'start',
- });
- }, 100); // Small delay to allow accordion animation to start
- }
- };
-
return (
-
-
-
-
+
+
Published Policies
({policies.length})
-
-
+
+
{policies.length === 0 ? (
No published policies found
) : (
<>
-
+
{paginatedItems.map((policy) => (
-
+
{policy.name}
@@ -79,7 +56,7 @@ export function PublishedPoliciesSection({ policies }: PublishedPoliciesSectionP
)}
-
+
))}
@@ -115,9 +92,7 @@ export function PublishedPoliciesSection({ policies }: PublishedPoliciesSectionP
)}
>
)}
-
-
-
+
);
}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx
index 351ba687b..4ef5acee9 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx
@@ -39,7 +39,7 @@ export default async function SecurityQuestionnairePage() {
description={
"Automatically answer security questionnaires with the information we have about your company. Upload questionnaires from vendors and we'll extract the questions and provide answers based on your policies and organizational details."
}
- ctaDisabled={true}
+ ctaDisabled={false}
cta={'Publish policies'}
ctaTooltip="To use this feature you need to publish policies first"
href={`/${organizationId}/policies/all`}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/utils/deduplicate-sources.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/utils/deduplicate-sources.ts
index 014975db7..419671e4c 100644
--- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/utils/deduplicate-sources.ts
+++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/utils/deduplicate-sources.ts
@@ -4,7 +4,7 @@
* Deduplication rules:
* - Policies: Same policy name = same source (deduplicated by policyName)
* - Context: All context entries = single "Context Q&A" source
- * - Manual Answers: All manual answers = single "Manual Answer" source
+ * - Manual Answers: Each manual answer is a separate source (deduplicated by sourceId)
* - Knowledge Base Documents: Deduplicated by sourceId (each document is a separate source)
* - Other sources: Deduplicated by sourceId
*/
@@ -15,6 +15,7 @@ export interface Source {
sourceId?: string;
policyName?: string;
documentName?: string;
+ manualAnswerQuestion?: string;
score: number;
}
@@ -23,16 +24,26 @@ export interface Source {
* For each source type, uses appropriate deduplication key:
* - Policies: policyName
* - Context: "Context Q&A" (all grouped together)
- * - Manual Answers: "Manual Answer" (all grouped together)
+ * - Manual Answers: sourceId (each manual answer is separate)
* - Knowledge Base Documents: sourceId (each document is separate)
* - Others: sourceId
*
* When duplicates are found, keeps the one with the highest score.
*/
export function deduplicateSources(sources: Source[]): Source[] {
+ // Return empty array if no sources provided
+ if (!sources || sources.length === 0) {
+ return [];
+ }
+
const sourceMap = new Map
();
for (const source of sources) {
+ // Skip sources without required fields
+ if (!source.sourceType) {
+ continue;
+ }
+
let deduplicationKey: string;
// Determine deduplication key based on source type
@@ -43,8 +54,10 @@ export function deduplicateSources(sources: Source[]): Source[] {
// Context: all context entries are grouped as one source
deduplicationKey = 'context:all';
} else if (source.sourceType === 'manual_answer') {
- // Manual Answers: all manual answers are grouped as one source
- deduplicationKey = 'manual_answer:all';
+ // Manual Answers: each manual answer is a separate source (like knowledge_base_document)
+ // This prevents one manual answer from appearing as source for all questions
+ // Use sourceId if available, otherwise fallback to unknown
+ deduplicationKey = `manual_answer:${source.sourceId || 'unknown'}`;
} else if (source.sourceType === 'knowledge_base_document') {
// Knowledge Base Documents: deduplicate by sourceId (each document is separate)
deduplicationKey = `knowledge_base_document:${source.sourceId || 'unknown'}`;
@@ -57,20 +70,33 @@ export function deduplicateSources(sources: Source[]): Source[] {
const existing = sourceMap.get(deduplicationKey);
if (!existing || source.score > existing.score) {
// Create a normalized source with appropriate sourceName
- // Preserve documentName if available (it might be missing in some chunks)
+ // Preserve documentName and manualAnswerQuestion if available (they might be missing in some chunks)
+ // Always regenerate sourceName to ensure it uses the latest metadata (especially manualAnswerQuestion)
const normalizedSource: Source = {
...source,
documentName: source.documentName || existing?.documentName,
- sourceName: getSourceDisplayName({
- ...source,
- documentName: source.documentName || existing?.documentName,
- }),
+ manualAnswerQuestion: source.manualAnswerQuestion || existing?.manualAnswerQuestion,
+ // Don't use source.sourceName - regenerate it to ensure it uses manualAnswerQuestion
+ sourceName: undefined, // Will be set by getSourceDisplayName
};
+ normalizedSource.sourceName = getSourceDisplayName(normalizedSource);
sourceMap.set(deduplicationKey, normalizedSource);
- } else if (existing && source.documentName && !existing.documentName) {
- // If existing source doesn't have documentName but new one does, update it
- existing.documentName = source.documentName;
- existing.sourceName = getSourceDisplayName(existing);
+ } else if (existing) {
+ // Update existing source if new one has missing metadata
+ let needsUpdate = false;
+ if (source.documentName && !existing.documentName) {
+ existing.documentName = source.documentName;
+ needsUpdate = true;
+ }
+ if (source.manualAnswerQuestion && !existing.manualAnswerQuestion) {
+ existing.manualAnswerQuestion = source.manualAnswerQuestion;
+ needsUpdate = true;
+ }
+ // Always regenerate sourceName to ensure it uses the latest metadata
+ // Especially important for manual_answer to show the question preview
+ if (needsUpdate || !existing.sourceName || existing.sourceType === 'manual_answer') {
+ existing.sourceName = getSourceDisplayName(existing);
+ }
}
}
@@ -91,6 +117,21 @@ function getSourceDisplayName(source: Source): string {
}
if (source.sourceType === 'manual_answer') {
+ // Show question from manual answer if available for better identification
+ // This helps distinguish between different manual answers
+ if (source.manualAnswerQuestion) {
+ const preview = source.manualAnswerQuestion.length > 50
+ ? source.manualAnswerQuestion.substring(0, 50) + '...'
+ : source.manualAnswerQuestion;
+ return `Manual Answer (${preview})`;
+ }
+ // Fallback: use sourceId if available to distinguish different manual answers
+ if (source.sourceId) {
+ const shortId = source.sourceId.length > 8
+ ? source.sourceId.substring(source.sourceId.length - 8)
+ : source.sourceId;
+ return `Manual Answer (${shortId})`;
+ }
return 'Manual Answer';
}
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx
index 30ce09888..5d2f55660 100644
--- a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx
+++ b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx
@@ -1,26 +1,19 @@
'use client';
-import { useState } from 'react';
-import { ArrowRight, Check, Copy } from 'lucide-react';
-import { toast } from 'sonner';
-import Link from 'next/link';
import { Button } from '@comp/ui/button';
import { Card } from '@comp/ui/card';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip';
+import { Check, Copy } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
export function BookingStep({
- email,
- name,
company,
orgId,
- complianceFrameworks,
hasAccess,
}: {
- email: string;
- name: string;
company: string;
orgId: string;
- complianceFrameworks: string[];
hasAccess: boolean;
}) {
const [isCopied, setIsCopied] = useState(false);
@@ -28,19 +21,17 @@ export function BookingStep({
const title = !hasAccess ? `Let's get ${company} approved` : 'Talk to us to upgrade';
const description = !hasAccess
- ? `A quick 20-minute call with our team to understand your compliance needs and approve your organization for access.`
+ ? `Please copy and share the Org ID below in your with your Customer Success Rep in Slack`
: `A quick 20-minute call with our team to understand your compliance needs and upgrade your plan.`;
- const cta = !hasAccess ? 'Book Your Demo' : 'Book a Call';
-
const handleCopyOrgId = async () => {
if (isCopied) return;
-
+
try {
await navigator.clipboard.writeText(orgId);
setIsCopied(true);
toast.success('Org ID copied to clipboard');
-
+
// Reset after 3 seconds
setTimeout(() => {
setIsCopied(false);
@@ -74,7 +65,7 @@ export function BookingStep({
variant="outline"
className="text-xs rounded-tl-none rounded-bl-none"
onClick={handleCopyOrgId}
- aria-label={isCopied ? "Copied!" : "Copy Org ID"}
+ aria-label={isCopied ? 'Copied!' : 'Copy Org ID'}
>
{isCopied ? (
@@ -84,31 +75,11 @@ export function BookingStep({
- {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
+