Skip to content

Commit 0309a11

Browse files
authored
Merge pull request #2828 from trycompai/main
[comp] Production Deploy
2 parents 5c593e5 + 7a51f4c commit 0309a11

10 files changed

Lines changed: 927 additions & 886 deletions

File tree

‎apps/api/src/questionnaire/questionnaire.controller.spec.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,13 +321,16 @@ describe('QuestionnaireController', () => {
321321
source: 'internal',
322322
};
323323
mockService.uploadAndParse.mockResolvedValue({
324-
questionnaireId: 'q1',
325-
totalQuestions: 10,
324+
runId: 'run_123',
325+
publicAccessToken: 'token_123',
326326
});
327327

328328
const result = await controller.uploadAndParse(dto as any, 'org_1');
329329

330-
expect(result).toEqual({ questionnaireId: 'q1', totalQuestions: 10 });
330+
expect(result).toEqual({
331+
runId: 'run_123',
332+
publicAccessToken: 'token_123',
333+
});
331334
});
332335

333336
it('should override body organizationId with auth-derived org', async () => {
@@ -339,8 +342,8 @@ describe('QuestionnaireController', () => {
339342
source: 'internal',
340343
};
341344
mockService.uploadAndParse.mockResolvedValue({
342-
questionnaireId: 'q1',
343-
totalQuestions: 10,
345+
runId: 'run_123',
346+
publicAccessToken: 'token_123',
344347
});
345348

346349
await controller.uploadAndParse(dto as any, 'org_1');

‎apps/api/src/questionnaire/questionnaire.service.spec.ts‎

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,14 @@ jest.mock('@/trigger/questionnaire/answer-question-helpers', () => ({
3636
generateAnswerWithRAGBatch: jest.fn(),
3737
}));
3838

39+
jest.mock('@trigger.dev/sdk', () => ({
40+
tasks: {
41+
trigger: jest.fn(),
42+
},
43+
}));
44+
3945
jest.mock('./utils/content-extractor', () => ({
4046
extractContentFromFile: jest.fn(),
41-
extractQuestionsWithAI: jest.fn(),
4247
}));
4348

4449
jest.mock('./utils/question-parser', () => ({
@@ -59,13 +64,13 @@ jest.mock('./utils/questionnaire-storage', () => ({
5964
import { db } from '@db';
6065
import { syncManualAnswerToVector } from '@/vector-store/lib';
6166
import { answerQuestion } from '@/trigger/questionnaire/answer-question';
67+
import { tasks } from '@trigger.dev/sdk';
6268
import {
6369
updateAnsweredCount,
6470
persistQuestionnaireResult,
6571
uploadQuestionnaireFile,
6672
saveGeneratedAnswer,
6773
} from './utils/questionnaire-storage';
68-
import { extractQuestionsWithAI } from './utils/content-extractor';
6974
import { generateExportFile } from './utils/export-generator';
7075

7176
const mockDb = db as jest.Mocked<typeof db>;
@@ -218,7 +223,7 @@ describe('QuestionnaireService', () => {
218223
(mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(null);
219224

220225
await expect(service.deleteById('missing', 'org_1')).rejects.toThrow(
221-
'Questionnaire not found',
226+
'Questionnaire with ID missing not found',
222227
);
223228
expect(mockDb.questionnaire.delete).not.toHaveBeenCalled();
224229
});
@@ -454,16 +459,15 @@ describe('QuestionnaireService', () => {
454459
});
455460

456461
describe('uploadAndParse', () => {
457-
it('should upload file, parse questions, and persist', async () => {
462+
it('should upload file and trigger async parsing', async () => {
458463
(uploadQuestionnaireFile as jest.Mock).mockResolvedValue({
459464
s3Key: 'key',
460465
fileSize: 1024,
461466
});
462-
(extractQuestionsWithAI as jest.Mock).mockResolvedValue([
463-
{ question: 'Q1?', answer: null },
464-
{ question: 'Q2?', answer: null },
465-
]);
466-
(persistQuestionnaireResult as jest.Mock).mockResolvedValue('q1');
467+
(tasks.trigger as jest.Mock).mockResolvedValue({
468+
id: 'run_123',
469+
publicAccessToken: 'token_123',
470+
});
467471

468472
const result = await service.uploadAndParse({
469473
organizationId: 'org_1',
@@ -473,23 +477,23 @@ describe('QuestionnaireService', () => {
473477
source: 'internal',
474478
} as any);
475479

476-
expect(result).toEqual({ questionnaireId: 'q1', totalQuestions: 2 });
480+
expect(result).toEqual({
481+
runId: 'run_123',
482+
publicAccessToken: 'token_123',
483+
});
477484
expect(uploadQuestionnaireFile).toHaveBeenCalled();
478-
expect(extractQuestionsWithAI).toHaveBeenCalledWith(
479-
'base64data',
480-
'application/pdf',
481-
expect.any(Object),
482-
);
483-
expect(persistQuestionnaireResult).toHaveBeenCalled();
484-
});
485-
486-
it('should throw when persist returns null', async () => {
487-
(uploadQuestionnaireFile as jest.Mock).mockResolvedValue({
485+
expect(tasks.trigger).toHaveBeenCalledWith('parse-questionnaire', {
486+
inputType: 's3',
487+
organizationId: 'org_1',
488488
s3Key: 'key',
489+
fileName: 'test.pdf',
490+
fileType: 'application/pdf',
489491
fileSize: 1024,
490492
});
491-
(extractQuestionsWithAI as jest.Mock).mockResolvedValue([]);
492-
(persistQuestionnaireResult as jest.Mock).mockResolvedValue(null);
493+
});
494+
495+
it('should throw when upload fails', async () => {
496+
(uploadQuestionnaireFile as jest.Mock).mockResolvedValue(null);
493497

494498
await expect(
495499
service.uploadAndParse({
@@ -498,7 +502,7 @@ describe('QuestionnaireService', () => {
498502
fileType: 'application/pdf',
499503
fileData: 'base64data',
500504
} as any),
501-
).rejects.toThrow('Failed to save questionnaire');
505+
).rejects.toThrow('Failed to upload questionnaire file to S3');
502506
});
503507
});
504508

‎apps/api/src/questionnaire/questionnaire.service.ts‎

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import { generateAnswerWithRAGBatch } from '@/trigger/questionnaire/answer-quest
55
import { tasks } from '@trigger.dev/sdk';
66
import type { parseQuestionnaireTask } from '@/trigger/questionnaire/parse-questionnaire';
77
import { ParseQuestionnaireDto } from './dto/parse-questionnaire.dto';
8-
import {
9-
ExportQuestionnaireDto,
10-
type QuestionnaireExportFormat,
11-
} from './dto/export-questionnaire.dto';
8+
import { ExportQuestionnaireDto } from './dto/export-questionnaire.dto';
129
import { AnswerSingleQuestionDto } from './dto/answer-single-question.dto';
1310
import { SaveAnswerDto } from './dto/save-answer.dto';
1411
import { DeleteAnswerDto } from './dto/delete-answer.dto';
@@ -24,13 +21,9 @@ import AdmZip from 'adm-zip';
2421
// Import shared utilities
2522
import {
2623
extractContentFromFile,
27-
extractQuestionsWithAI,
2824
type ContentExtractionLogger,
2925
} from './utils/content-extractor';
30-
import {
31-
parseQuestionsAndAnswers,
32-
type QuestionAnswer as ParsedQA,
33-
} from './utils/question-parser';
26+
import { parseQuestionsAndAnswers } from './utils/question-parser';
3427
import {
3528
generateExportFile,
3629
type ExportFormat,
@@ -85,12 +78,15 @@ export class QuestionnaireService {
8578
async parseQuestionnaire(
8679
dto: ParseQuestionnaireDto,
8780
): Promise<ParsedQuestionnaireResult> {
88-
// Use faster AI-powered extraction (combines extraction + parsing in one step)
89-
const questionsAndAnswers = await extractQuestionsWithAI(
81+
const extractedContent = await extractContentFromFile(
9082
dto.fileData,
9183
dto.fileType,
9284
this.contentLogger,
9385
);
86+
const questionsAndAnswers = await parseQuestionsAndAnswers(
87+
extractedContent,
88+
this.contentLogger,
89+
);
9490

9591
return {
9692
vendorName: dto.vendorName,
@@ -122,24 +118,33 @@ export class QuestionnaireService {
122118
);
123119
}
124120

125-
console.log(Date.now(), 'Parsing questionnaire');
126-
// Use faster AI-powered extraction (combines extraction + parsing in one step)
127-
const questionsAndAnswers = await extractQuestionsWithAI(
121+
this.logger.log('Parsing questionnaire for auto-answer export');
122+
const extractedContent = await extractContentFromFile(
128123
dto.fileData,
129124
dto.fileType,
130125
this.contentLogger,
131126
);
132-
console.log(Date.now(), 'Parsed questionnaire');
127+
const questionsAndAnswers = await parseQuestionsAndAnswers(
128+
extractedContent,
129+
this.contentLogger,
130+
);
131+
this.logger.log('Parsed questionnaire for auto-answer export', {
132+
questionCount: questionsAndAnswers.length,
133+
});
133134

134-
console.log(Date.now(), 'Generating answers for questions');
135+
this.logger.log('Generating answers for parsed questionnaire', {
136+
questionCount: questionsAndAnswers.length,
137+
});
135138
const answered = await this.generateAnswersForQuestions(
136139
questionsAndAnswers.map((qa) => ({
137140
question: qa.question,
138-
answer: qa.answer,
141+
answer: null,
139142
})),
140143
dto.organizationId,
141144
);
142-
console.log(Date.now(), 'Generated answers for questions');
145+
this.logger.log('Generated questionnaire answers', {
146+
questionCount: answered.length,
147+
});
143148

144149
const vendorName = dto.vendorName || dto.fileName || 'questionnaire';
145150

@@ -186,7 +191,7 @@ export class QuestionnaireService {
186191
// Single format export (default behavior)
187192
const exportFile = await generateExportFile(
188193
answered.map((a) => ({ question: a.question, answer: a.answer })),
189-
dto.format as ExportFormat,
194+
dto.format,
190195
vendorName,
191196
);
192197

@@ -432,7 +437,7 @@ export class QuestionnaireService {
432437

433438
return await generateExportFile(
434439
questionsAndAnswers,
435-
dto.format as ExportFormat,
440+
dto.format,
436441
questionnaire.filename,
437442
);
438443
}

‎apps/api/src/questionnaire/utils/constants.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* Shared constants for questionnaire module
33
*/
44

5-
// Chunk sizes for question-aware parsing
6-
export const MAX_CHUNK_SIZE_CHARS = 80_000;
5+
// Chunk sizes for questionnaire item classification
6+
export const MAX_CHUNK_SIZE_CHARS = 25_000;
77
export const MIN_CHUNK_SIZE_CHARS = 5_000;
88
export const MAX_QUESTIONS_PER_CHUNK = 1;
9+
export const MAX_CLASSIFICATION_CONCURRENCY = 4;
910

1011
// File size limits
1112
export const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
@@ -30,10 +31,10 @@ CRITICAL RULES:
3031
8. Always write in first person plural (we, our, us) as if speaking on behalf of the organization.
3132
9. Keep answers to 1-3 sentences maximum unless the question explicitly requires more detail.`;
3233

33-
export const QUESTION_PARSING_SYSTEM_PROMPT = `You parse vendor questionnaires from Excel, PDF, or document text. Extract all items the respondent is expected to answer (questions, prompts, or compliance statements) and pair each with its answer if one exists.
34+
export const QUESTION_PARSING_SYSTEM_PROMPT = `You parse vendor questionnaires from Excel, PDF, images, CSV, or document text. Your job is to classify content and return only answerable questionnaire items.
3435
3536
What counts as an item to extract:
36-
1. Interrogative questions ending with "?" or starting with What/How/Why/When/Where/Is/Are/Do/Does/Can/Will/Should.
37+
1. Interrogative questions in any language. A question mark is helpful but not required.
3738
2. Form fields like "1.1 Vendor Name", "Contact Email", "Company Address" (numbered or labeled fields requesting information).
3839
3. Compliance/requirement statements that the respondent must confirm or describe their compliance with — vendor questionnaires often consist entirely of these. Examples:
3940
- "The organization must X"
@@ -53,10 +54,13 @@ Input format hints:
5354
Rules:
5455
1. Find the column containing the actual item text, not just IDs/numbers (e.g., skip "SQ14.3", keep the full sentence).
5556
2. Extract the FULL text of each item.
56-
3. Match each item to its Response/Answer text from the same row. If empty or missing, set answer = null.
57-
4. Skip pure section headers ("Information Security Program", "General Information") UNLESS they are also items the respondent must answer.
58-
5. Skip metadata rows (Company Name, Date, file headers).
59-
6. NEVER return zero items if the document has any rows of substantive content — extract every row that looks like an item the respondent must address.`;
57+
3. For upload-to-autofill parsing, always set saved answers to null. The user expects us to generate answers later.
58+
4. Never use scoring/options values as answers, e.g. "(Oui : 0, Non : 3)" or "(Yes : 0, No : 1)".
59+
5. Never use placeholders as answers, e.g. "A remplir", "A compléter", "To be completed".
60+
6. Do not treat guidance, instructions, examples, mode opératoire, remediation plans, or calculated score/formula cells as answerable items.
61+
7. Skip pure section headers ("Information Security Program", "General Information") UNLESS the text itself asks the respondent to provide information.
62+
8. Skip metadata rows (Company Name, Date, file headers) unless the field is clearly part of the vendor questionnaire response surface.
63+
9. Be high recall for answerable items, but do not include instructions, examples, scoring, or metadata just to avoid returning zero items.`;
6064

6165
// Vision extraction prompt for PDFs and images
6266
export const VISION_EXTRACTION_PROMPT = `Transcribe this document into plain text. Output ONLY the document's text content — no summaries, no analysis, no commentary about what the document is or whether it contains questions.

‎apps/api/src/questionnaire/utils/content-extractor.spec.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { extractContentFromFile } from './content-extractor';
22
import ExcelJS from 'exceljs';
3+
import { PDFDocument } from 'pdf-lib';
4+
import { generateText } from 'ai';
35

46
// Mock AI dependencies
57
jest.mock('@ai-sdk/openai', () => ({ openai: jest.fn() }));
@@ -50,6 +52,31 @@ describe('content-extractor: extractContentFromFile', () => {
5052
expect(result).toContain('Rating?');
5153
});
5254

55+
it('should ignore scoring columns and placeholders in BPCE-style sheets', async () => {
56+
const workbook = new ExcelJS.Workbook();
57+
const worksheet = workbook.addWorksheet('Risk assessment SSI');
58+
worksheet.getCell('B10').value =
59+
'Le prestataire effectue-t-il des revues régulières des comptes à privilèges ?';
60+
worksheet.getCell('F10').value = {
61+
formula: 'IF(E10="NON",3,0)',
62+
result: 0,
63+
};
64+
worksheet.getCell('K10').value = '(Oui : 0, Non : 3)';
65+
worksheet.getCell('M10').value = 'A remplir';
66+
worksheet.getCell('O10').value =
67+
"La gestion des comptes à privilèges consiste à contrôler l'accès aux comptes.";
68+
69+
const arrayBuffer = await workbook.xlsx.writeBuffer();
70+
const base64 = Buffer.from(arrayBuffer).toString('base64');
71+
const result = await extractContentFromFile(base64, XLSX_MIME);
72+
73+
expect(result).toContain('[B10 Question]');
74+
expect(result).toContain('revues régulières des comptes à privilèges');
75+
expect(result).not.toContain('(Oui : 0, Non : 3)');
76+
expect(result).not.toContain('A remplir');
77+
expect(result).not.toContain('[F10');
78+
});
79+
5380
it('should extract content from multiple sheets', async () => {
5481
const buffer = await createTestExcelBuffer([
5582
{
@@ -85,6 +112,32 @@ describe('content-extractor: extractContentFromFile', () => {
85112
expect(result).toContain('What is 2+2?,4');
86113
});
87114

115+
it('should fall back to OpenAI when Claude PDF extraction is overloaded', async () => {
116+
const pdf = await PDFDocument.create();
117+
pdf.addPage();
118+
const bytes = await pdf.save();
119+
const mockGenerateText = generateText as jest.Mock;
120+
mockGenerateText
121+
.mockRejectedValueOnce(new Error('Overloaded'))
122+
.mockResolvedValueOnce({ text: 'Extracted PDF text' });
123+
124+
const result = await extractContentFromFile(
125+
Buffer.from(bytes).toString('base64'),
126+
'application/pdf',
127+
);
128+
129+
expect(result).toBe('Extracted PDF text');
130+
expect(mockGenerateText).toHaveBeenCalledTimes(2);
131+
});
132+
133+
it('should reject legacy XLS files with a clear message', async () => {
134+
const base64 = Buffer.from('legacy-binary-xls').toString('base64');
135+
136+
await expect(
137+
extractContentFromFile(base64, 'application/vnd.ms-excel'),
138+
).rejects.toThrow('Legacy Excel files');
139+
});
140+
88141
it('should handle plain text files', async () => {
89142
const text = 'Some compliance document content';
90143
const base64 = Buffer.from(text).toString('base64');

0 commit comments

Comments
 (0)