pi-interview 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/form/script.js +48 -3
- package/form/styles.css +5 -3
- package/index.ts +118 -37
- package/package.json +1 -1
- package/server.ts +64 -5
package/README.md
CHANGED
|
@@ -38,7 +38,8 @@ Restart pi to load the extension.
|
|
|
38
38
|
- **Session Status Bar**: Shows project path, git branch, and session ID for identification
|
|
39
39
|
- **Image Support**: Drag & drop anywhere on question, file picker, paste image or path
|
|
40
40
|
- **Path Normalization**: Handles shell-escaped paths (`\ `) and macOS screenshot filenames (narrow no-break space before AM/PM)
|
|
41
|
-
- **Generate & Review Options**: Single/multi-select questions show "✦ Generate more" (appends new choices) and "↻ Review options" (
|
|
41
|
+
- **Generate & Review Options**: Single/multi-select questions show "✦ Generate more" (appends new choices) and "↻ Review options" (reviews options and rewrites the question for clarity) buttons powered by an LLM
|
|
42
|
+
- **Tool Discoverability (pi v0.59+)**: Registers a `promptSnippet` so `interview` remains eligible for inclusion in pi's default `Available tools` prompt section
|
|
42
43
|
- **Themes**: Built-in default + optional light/dark + custom theme CSS
|
|
43
44
|
|
|
44
45
|
## How It Works
|
package/form/script.js
CHANGED
|
@@ -401,6 +401,35 @@
|
|
|
401
401
|
return typeof option === "object" && option !== null && "label" in option;
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
function syncRecommendations(question, options) {
|
|
405
|
+
if (!question.recommended) return;
|
|
406
|
+
|
|
407
|
+
if (question.type === "single") {
|
|
408
|
+
if (typeof question.recommended === "string" && options.includes(question.recommended)) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
delete question.recommended;
|
|
412
|
+
delete question.conviction;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (question.type !== "multi") {
|
|
417
|
+
delete question.recommended;
|
|
418
|
+
delete question.conviction;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const nextRecommended = (Array.isArray(question.recommended)
|
|
423
|
+
? question.recommended
|
|
424
|
+
: [question.recommended]).filter((option) => options.includes(option));
|
|
425
|
+
if (nextRecommended.length === 0) {
|
|
426
|
+
delete question.recommended;
|
|
427
|
+
delete question.conviction;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
question.recommended = nextRecommended;
|
|
431
|
+
}
|
|
432
|
+
|
|
404
433
|
function renderCodeBlock(block) {
|
|
405
434
|
if (!block || !block.code) return null;
|
|
406
435
|
|
|
@@ -1394,6 +1423,7 @@
|
|
|
1394
1423
|
|
|
1395
1424
|
function createGenerateMoreUI(question, list) {
|
|
1396
1425
|
if (!data.canGenerate) return null;
|
|
1426
|
+
if (question.options.some(isRichOption)) return null;
|
|
1397
1427
|
|
|
1398
1428
|
const container = document.createElement("div");
|
|
1399
1429
|
container.className = "generate-more";
|
|
@@ -1499,6 +1529,10 @@
|
|
|
1499
1529
|
}
|
|
1500
1530
|
|
|
1501
1531
|
if (mode === "review") {
|
|
1532
|
+
if (typeof result.question !== "string" || !result.question.trim()) {
|
|
1533
|
+
throw new Error("No revised question returned");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1502
1536
|
const seen = new Set();
|
|
1503
1537
|
const revisedOptions = result.options.filter((option) => {
|
|
1504
1538
|
const key = option.toLowerCase().trim();
|
|
@@ -1510,6 +1544,14 @@
|
|
|
1510
1544
|
throw new Error("No valid options returned for review");
|
|
1511
1545
|
}
|
|
1512
1546
|
|
|
1547
|
+
question.question = result.question.trim();
|
|
1548
|
+
question.options = revisedOptions;
|
|
1549
|
+
syncRecommendations(question, revisedOptions);
|
|
1550
|
+
const title = list.closest('.question-card')?.querySelector('.question-title');
|
|
1551
|
+
if (title) {
|
|
1552
|
+
title.innerHTML = renderLightMarkdown(question.question);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1513
1555
|
list
|
|
1514
1556
|
.querySelectorAll('.option-item:not(.option-other):not(.done-item)')
|
|
1515
1557
|
.forEach((el) => el.remove());
|
|
@@ -1520,7 +1562,7 @@
|
|
|
1520
1562
|
if (question.type === "multi") updateDoneState(question.id);
|
|
1521
1563
|
debounceSave();
|
|
1522
1564
|
showStatus(
|
|
1523
|
-
revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
|
|
1565
|
+
"Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
|
|
1524
1566
|
2500,
|
|
1525
1567
|
);
|
|
1526
1568
|
} else {
|
|
@@ -1536,6 +1578,7 @@
|
|
|
1536
1578
|
if (newOptions.length === 0) {
|
|
1537
1579
|
showStatus("All generated options already exist", 3000);
|
|
1538
1580
|
} else {
|
|
1581
|
+
question.options = question.options.concat(newOptions);
|
|
1539
1582
|
newOptions.forEach((optionText, i) => {
|
|
1540
1583
|
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1541
1584
|
list.insertBefore(optionEl, container);
|
|
@@ -2578,7 +2621,8 @@
|
|
|
2578
2621
|
}
|
|
2579
2622
|
} catch (err) {
|
|
2580
2623
|
if (!submitted) {
|
|
2581
|
-
|
|
2624
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2625
|
+
showSaveError(`Failed to save interview: ${message}`);
|
|
2582
2626
|
}
|
|
2583
2627
|
return false;
|
|
2584
2628
|
}
|
|
@@ -2659,7 +2703,8 @@
|
|
|
2659
2703
|
if (isNetworkError(err)) {
|
|
2660
2704
|
showSessionExpired();
|
|
2661
2705
|
} else {
|
|
2662
|
-
|
|
2706
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2707
|
+
showGlobalError(`Failed to submit responses: ${message}`);
|
|
2663
2708
|
submitBtn.disabled = false;
|
|
2664
2709
|
}
|
|
2665
2710
|
}
|
package/form/styles.css
CHANGED
|
@@ -1391,7 +1391,7 @@ button {
|
|
|
1391
1391
|
.code-block pre {
|
|
1392
1392
|
margin: 0;
|
|
1393
1393
|
padding: 0.75rem;
|
|
1394
|
-
overflow-x:
|
|
1394
|
+
overflow-x: hidden;
|
|
1395
1395
|
line-height: 1.5;
|
|
1396
1396
|
}
|
|
1397
1397
|
|
|
@@ -1404,7 +1404,8 @@ button {
|
|
|
1404
1404
|
|
|
1405
1405
|
.code-block-lines-container {
|
|
1406
1406
|
display: table;
|
|
1407
|
-
|
|
1407
|
+
width: 100%;
|
|
1408
|
+
table-layout: fixed;
|
|
1408
1409
|
}
|
|
1409
1410
|
|
|
1410
1411
|
.code-block-line {
|
|
@@ -1425,7 +1426,8 @@ button {
|
|
|
1425
1426
|
|
|
1426
1427
|
.code-block-line-content {
|
|
1427
1428
|
display: table-cell;
|
|
1428
|
-
white-space: pre;
|
|
1429
|
+
white-space: pre-wrap;
|
|
1430
|
+
overflow-wrap: anywhere;
|
|
1429
1431
|
padding-right: 0.75rem;
|
|
1430
1432
|
}
|
|
1431
1433
|
|
package/index.ts
CHANGED
|
@@ -246,6 +246,9 @@ const PREFERRED_GENERATE_MODELS = [
|
|
|
246
246
|
const GENERATE_OPTIONS_SYSTEM_PROMPT =
|
|
247
247
|
"You generate interview answer options. Return only a JSON array of strings. Do not include explanations or markdown.";
|
|
248
248
|
|
|
249
|
+
const REVIEW_QUESTION_SYSTEM_PROMPT =
|
|
250
|
+
"You review interview questions and answer options. Preserve intent. Return only JSON with a rewritten question string and an options array.";
|
|
251
|
+
|
|
249
252
|
function formatModelRef(model: GenerateModelCandidate): string {
|
|
250
253
|
return `${model.provider}/${model.id}`;
|
|
251
254
|
}
|
|
@@ -307,8 +310,8 @@ export function extractGenerateResponseText(
|
|
|
307
310
|
return text;
|
|
308
311
|
}
|
|
309
312
|
|
|
310
|
-
|
|
311
|
-
const start = text.indexOf(
|
|
313
|
+
function extractJSONBlock(text: string, openChar: "[" | "{", closeChar: "]" | "}"): string {
|
|
314
|
+
const start = text.indexOf(openChar);
|
|
312
315
|
if (start === -1) return text;
|
|
313
316
|
|
|
314
317
|
let depth = 0;
|
|
@@ -337,11 +340,11 @@ export function extractJSONArray(text: string): string {
|
|
|
337
340
|
inString = true;
|
|
338
341
|
continue;
|
|
339
342
|
}
|
|
340
|
-
if (char ===
|
|
343
|
+
if (char === openChar) {
|
|
341
344
|
depth++;
|
|
342
345
|
continue;
|
|
343
346
|
}
|
|
344
|
-
if (char !==
|
|
347
|
+
if (char !== closeChar) {
|
|
345
348
|
continue;
|
|
346
349
|
}
|
|
347
350
|
|
|
@@ -354,9 +357,17 @@ export function extractJSONArray(text: string): string {
|
|
|
354
357
|
return text;
|
|
355
358
|
}
|
|
356
359
|
|
|
357
|
-
export function
|
|
360
|
+
export function extractJSONArray(text: string): string {
|
|
361
|
+
return extractJSONBlock(text, "[", "]");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function extractJSONObject(text: string): string {
|
|
365
|
+
return extractJSONBlock(text, "{", "}");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function createGenerateContext(prompt: string, systemPrompt = GENERATE_OPTIONS_SYSTEM_PROMPT) {
|
|
358
369
|
return {
|
|
359
|
-
systemPrompt
|
|
370
|
+
systemPrompt,
|
|
360
371
|
messages: [{
|
|
361
372
|
role: "user" as const,
|
|
362
373
|
content: [{ type: "text" as const, text: prompt }],
|
|
@@ -365,14 +376,7 @@ export function createGenerateContext(prompt: string) {
|
|
|
365
376
|
};
|
|
366
377
|
}
|
|
367
378
|
|
|
368
|
-
|
|
369
|
-
let parsed: unknown;
|
|
370
|
-
try {
|
|
371
|
-
parsed = JSON.parse(extractJSONArray(text));
|
|
372
|
-
} catch (err) {
|
|
373
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
374
|
-
throw new Error(`Failed to parse generated options: ${detail}`);
|
|
375
|
-
}
|
|
379
|
+
function normalizeGeneratedOptions(parsed: unknown): string[] {
|
|
376
380
|
if (!Array.isArray(parsed)) {
|
|
377
381
|
throw new Error("Expected array of options");
|
|
378
382
|
}
|
|
@@ -389,7 +393,41 @@ export function parseGeneratedOptions(text: string): string[] {
|
|
|
389
393
|
return options;
|
|
390
394
|
}
|
|
391
395
|
|
|
392
|
-
function
|
|
396
|
+
export function parseGeneratedOptions(text: string): string[] {
|
|
397
|
+
let parsed: unknown;
|
|
398
|
+
try {
|
|
399
|
+
parsed = JSON.parse(extractJSONArray(text));
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
402
|
+
throw new Error(`Failed to parse generated options: ${detail}`);
|
|
403
|
+
}
|
|
404
|
+
return normalizeGeneratedOptions(parsed);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function parseReviewedQuestion(text: string): { question: string; options: string[] } {
|
|
408
|
+
let parsed: unknown;
|
|
409
|
+
try {
|
|
410
|
+
parsed = JSON.parse(extractJSONObject(text));
|
|
411
|
+
} catch (err) {
|
|
412
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
413
|
+
throw new Error(`Failed to parse reviewed question: ${detail}`);
|
|
414
|
+
}
|
|
415
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
416
|
+
throw new Error("Expected reviewed question object");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const review = parsed as Record<string, unknown>;
|
|
420
|
+
if (typeof review.question !== "string" || !review.question.trim()) {
|
|
421
|
+
throw new Error("Reviewed question must include a non-empty question string");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
question: review.question.trim(),
|
|
426
|
+
options: normalizeGeneratedOptions(review.options),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
|
|
393
431
|
// Extract JSON from <script id="pi-interview-data">
|
|
394
432
|
const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
395
433
|
if (!match) {
|
|
@@ -406,11 +444,13 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
|
|
|
406
444
|
|
|
407
445
|
const raw = data as Record<string, unknown>;
|
|
408
446
|
const validated = validateQuestions(data);
|
|
447
|
+
const questionTypeById = new Map(validated.questions.map((question) => [question.id, question.type]));
|
|
409
448
|
|
|
410
|
-
// Resolve relative image paths to absolute based on HTML file location
|
|
449
|
+
// Resolve relative image paths to absolute based on HTML file location.
|
|
450
|
+
// Only image-question values are treated as paths; text/single/multi values must stay literal.
|
|
411
451
|
const snapshotDir = path.dirname(filePath);
|
|
412
452
|
const savedAnswers = Array.isArray(raw.savedAnswers)
|
|
413
|
-
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir)
|
|
453
|
+
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir, questionTypeById)
|
|
414
454
|
: undefined;
|
|
415
455
|
|
|
416
456
|
// Validate savedFrom if present
|
|
@@ -436,26 +476,28 @@ function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile
|
|
|
436
476
|
};
|
|
437
477
|
}
|
|
438
478
|
|
|
439
|
-
function resolveAnswerPaths(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
479
|
+
function resolveAnswerPaths(
|
|
480
|
+
answers: ResponseItem[],
|
|
481
|
+
baseDir: string,
|
|
482
|
+
questionTypeById: Map<string, "single" | "multi" | "text" | "image" | "info">,
|
|
483
|
+
): ResponseItem[] {
|
|
484
|
+
return answers.map((ans) => {
|
|
485
|
+
const questionType = questionTypeById.get(ans.id);
|
|
486
|
+
return {
|
|
487
|
+
...ans,
|
|
488
|
+
value: questionType === "image" ? resolvePathValue(ans.value, baseDir) : ans.value,
|
|
489
|
+
attachments: ans.attachments?.map((attachmentPath) => resolveImagePath(attachmentPath, baseDir)),
|
|
490
|
+
};
|
|
491
|
+
});
|
|
445
492
|
}
|
|
446
493
|
|
|
447
494
|
function resolveImagePath(p: string, baseDir: string): string {
|
|
448
495
|
if (!p) return p;
|
|
449
|
-
// Skip URLs
|
|
450
|
-
if (p.includes("://")) return p;
|
|
451
|
-
// Expand ~ first
|
|
496
|
+
// Skip URLs and data/file URIs
|
|
497
|
+
if (p.includes("://") || p.startsWith("data:") || p.startsWith("file:")) return p;
|
|
452
498
|
const expanded = expandHome(p);
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return expanded;
|
|
456
|
-
}
|
|
457
|
-
// Resolve relative path against snapshot directory
|
|
458
|
-
return path.join(baseDir, p);
|
|
499
|
+
if (path.isAbsolute(expanded)) return expanded;
|
|
500
|
+
return path.join(baseDir, expanded);
|
|
459
501
|
}
|
|
460
502
|
|
|
461
503
|
function resolvePathValue(value: string | string[], baseDir: string): string | string[] {
|
|
@@ -523,6 +565,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
523
565
|
"Questions can have a codeBlock field to display code above options. Types: single (radio), multi (checkbox), text (textarea), image (file upload), info (non-interactive). " +
|
|
524
566
|
'Media blocks: { type: "image", src, alt, caption }, { type: "table", table: { headers, rows, highlights }, caption }, { type: "chart", chart: { type, data, options }, caption }, { type: "mermaid", mermaid: "graph LR\\n..." }, { type: "html", html }. ' +
|
|
525
567
|
"Info type is a non-interactive content panel for displaying context with media. Media position: above (default), below, side (two-column).",
|
|
568
|
+
promptSnippet:
|
|
569
|
+
"Gather structured user input through an interactive form for requirements, tradeoffs, or multi-dimensional decisions.",
|
|
526
570
|
parameters: InterviewParams,
|
|
527
571
|
|
|
528
572
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -669,6 +713,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
669
713
|
return parseGeneratedOptions(extractGenerateResponseText(modelRef, response));
|
|
670
714
|
};
|
|
671
715
|
|
|
716
|
+
const reviewQuestion = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
|
|
717
|
+
const modelRef = formatModelRef(model);
|
|
718
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
719
|
+
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
720
|
+
if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
|
|
721
|
+
|
|
722
|
+
const response = await complete(
|
|
723
|
+
model,
|
|
724
|
+
createGenerateContext(prompt, REVIEW_QUESTION_SYSTEM_PROMPT),
|
|
725
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
return parseReviewedQuestion(extractGenerateResponseText(modelRef, response));
|
|
729
|
+
};
|
|
730
|
+
|
|
672
731
|
onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
|
|
673
732
|
const question = questionsData.questions.find((q) => q.id === questionId);
|
|
674
733
|
if (!question) throw new Error(`Unknown question: ${questionId}`);
|
|
@@ -687,18 +746,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
687
746
|
recommended = `\nRecommended: ${value}`;
|
|
688
747
|
}
|
|
689
748
|
prompt = [
|
|
690
|
-
"Review
|
|
691
|
-
"
|
|
749
|
+
"Review this interview question and its options.",
|
|
750
|
+
"Rewrite the question so it is easier to understand while preserving the original intent.",
|
|
751
|
+
"Review the options the same way you already would: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
|
|
752
|
+
"Return ONLY JSON in this format:",
|
|
753
|
+
'{"question":"Clearer question text","options":["Option A","Option B","Option C"]}',
|
|
692
754
|
"",
|
|
693
755
|
questionsData.title ? `Interview: ${questionsData.title}` : null,
|
|
756
|
+
questionsData.description ? `Interview context: ${questionsData.description}` : null,
|
|
694
757
|
`Question: ${question.question}`,
|
|
695
|
-
question.context ? `
|
|
758
|
+
question.context ? `Question context: ${question.context}` : null,
|
|
696
759
|
recommended || null,
|
|
697
760
|
"",
|
|
698
761
|
"Current options:",
|
|
699
762
|
existingList,
|
|
700
|
-
"",
|
|
701
|
-
'Format: ["Option A", "Option B", "Option C"]',
|
|
702
763
|
].filter((line) => line !== null).join("\n");
|
|
703
764
|
} else {
|
|
704
765
|
prompt = [
|
|
@@ -715,6 +776,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
715
776
|
].filter((line) => line !== null).join("\n");
|
|
716
777
|
}
|
|
717
778
|
|
|
779
|
+
if (mode === "review") {
|
|
780
|
+
let result: { question: string; options: string[] };
|
|
781
|
+
try {
|
|
782
|
+
result = await reviewQuestion(generateModel, prompt, generateSignal);
|
|
783
|
+
} catch (err) {
|
|
784
|
+
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
result = await reviewQuestion(fallbackGenerateModel, prompt, generateSignal);
|
|
789
|
+
} catch (fallbackErr) {
|
|
790
|
+
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
791
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
792
|
+
throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return result;
|
|
797
|
+
}
|
|
798
|
+
|
|
718
799
|
let options: string[];
|
|
719
800
|
try {
|
|
720
801
|
options = await generateOptions(generateModel, prompt, generateSignal);
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -207,7 +207,7 @@ export interface InterviewServerCallbacks {
|
|
|
207
207
|
existingOptions: string[],
|
|
208
208
|
signal: AbortSignal,
|
|
209
209
|
mode: "add" | "review",
|
|
210
|
-
) => Promise<{ options: string[] }>;
|
|
210
|
+
) => Promise<{ options: string[]; question?: string }>;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
export interface InterviewServerHandle {
|
|
@@ -387,6 +387,35 @@ function ensureQuestionId(
|
|
|
387
387
|
return { ok: true, question };
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
function syncRecommendations(question: Question, options: string[]): void {
|
|
391
|
+
if (!question.recommended) return;
|
|
392
|
+
|
|
393
|
+
if (question.type === "single") {
|
|
394
|
+
if (typeof question.recommended === "string" && options.includes(question.recommended)) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
delete question.recommended;
|
|
398
|
+
delete question.conviction;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (question.type !== "multi") {
|
|
403
|
+
delete question.recommended;
|
|
404
|
+
delete question.conviction;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const nextRecommended = (Array.isArray(question.recommended)
|
|
409
|
+
? question.recommended
|
|
410
|
+
: [question.recommended]).filter((option) => options.includes(option));
|
|
411
|
+
if (nextRecommended.length === 0) {
|
|
412
|
+
delete question.recommended;
|
|
413
|
+
delete question.conviction;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
question.recommended = nextRecommended;
|
|
417
|
+
}
|
|
418
|
+
|
|
390
419
|
// HTML generation for saved interviews
|
|
391
420
|
interface SavedFromMeta {
|
|
392
421
|
cwd: string;
|
|
@@ -1258,9 +1287,7 @@ export async function startInterviewServer(
|
|
|
1258
1287
|
}
|
|
1259
1288
|
|
|
1260
1289
|
// Copy local media images to snapshot and rewrite paths
|
|
1261
|
-
const rewrittenQuestions = await copyMediaImages(
|
|
1262
|
-
questions.questions, imagesPath, cwd
|
|
1263
|
-
);
|
|
1290
|
+
const rewrittenQuestions = await copyMediaImages(questions.questions, imagesPath, cwd);
|
|
1264
1291
|
const snapshotQuestions: QuestionsFile = {
|
|
1265
1292
|
...questions,
|
|
1266
1293
|
questions: rewrittenQuestions,
|
|
@@ -1321,6 +1348,10 @@ export async function startInterviewServer(
|
|
|
1321
1348
|
sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
|
|
1322
1349
|
return;
|
|
1323
1350
|
}
|
|
1351
|
+
if (question.options.some((option) => typeof option !== "string")) {
|
|
1352
|
+
sendJson(res, 400, { ok: false, error: "Generation is not available for rich options" });
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1324
1355
|
|
|
1325
1356
|
const existingOptions = Array.isArray(payload.existingOptions)
|
|
1326
1357
|
? payload.existingOptions.filter((o): o is string => typeof o === "string")
|
|
@@ -1341,7 +1372,35 @@ export async function startInterviewServer(
|
|
|
1341
1372
|
controller.signal,
|
|
1342
1373
|
mode,
|
|
1343
1374
|
);
|
|
1344
|
-
|
|
1375
|
+
|
|
1376
|
+
const uniqueOptions: string[] = [];
|
|
1377
|
+
const seenOptions = new Set<string>();
|
|
1378
|
+
for (const option of result.options) {
|
|
1379
|
+
const trimmed = option.trim();
|
|
1380
|
+
if (!trimmed) continue;
|
|
1381
|
+
const key = trimmed.toLowerCase();
|
|
1382
|
+
if (seenOptions.has(key)) continue;
|
|
1383
|
+
seenOptions.add(key);
|
|
1384
|
+
uniqueOptions.push(trimmed);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
|
|
1388
|
+
const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
|
|
1389
|
+
if (storedQuestion) {
|
|
1390
|
+
if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
|
|
1391
|
+
storedQuestion.question = reviewedQuestion;
|
|
1392
|
+
storedQuestion.options = uniqueOptions;
|
|
1393
|
+
syncRecommendations(storedQuestion, uniqueOptions);
|
|
1394
|
+
} else if (mode === "add") {
|
|
1395
|
+
const existingKeys = new Set(existingOptions.map((option) => option.trim().toLowerCase()));
|
|
1396
|
+
const newOptions = uniqueOptions.filter((option) => !existingKeys.has(option.toLowerCase()));
|
|
1397
|
+
if (newOptions.length > 0) {
|
|
1398
|
+
storedQuestion.options = storedQuestion.options.concat(newOptions);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
sendJson(res, 200, { ok: true, options: uniqueOptions, question: reviewedQuestion });
|
|
1345
1404
|
} catch (err) {
|
|
1346
1405
|
if (controller.signal.aborted) {
|
|
1347
1406
|
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|