pi-interview 0.6.1 → 0.8.0
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 +42 -25
- package/form/script.js +1417 -387
- package/form/styles.css +420 -17
- package/index.ts +412 -65
- package/package.json +1 -1
- package/schema.ts +81 -20
- package/server.ts +766 -90
package/server.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, renameSyn
|
|
|
6
6
|
import { mkdir, writeFile, copyFile } from "node:fs/promises";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import type
|
|
9
|
+
import { getOptionLabel, type Question, type QuestionsFile, type MediaBlock, type OptionValue } from "./schema.js";
|
|
10
10
|
|
|
11
11
|
function getGitBranch(cwd: string): string | null {
|
|
12
12
|
try {
|
|
@@ -178,12 +178,45 @@ function saveToRecovery(
|
|
|
178
178
|
return filePath;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
export interface ChoiceResponseValue {
|
|
182
|
+
option: string;
|
|
183
|
+
note?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type ResponseValue = string | string[] | ChoiceResponseValue | ChoiceResponseValue[];
|
|
187
|
+
|
|
181
188
|
export interface ResponseItem {
|
|
182
189
|
id: string;
|
|
183
|
-
value:
|
|
190
|
+
value: ResponseValue;
|
|
184
191
|
attachments?: string[];
|
|
185
192
|
}
|
|
186
193
|
|
|
194
|
+
export interface SavedOptionInsight {
|
|
195
|
+
id: string;
|
|
196
|
+
questionId: string;
|
|
197
|
+
optionKey: string;
|
|
198
|
+
optionText: string;
|
|
199
|
+
prompt: string;
|
|
200
|
+
summary: string;
|
|
201
|
+
bullets?: string[];
|
|
202
|
+
suggestedText?: string;
|
|
203
|
+
modelUsed?: string | null;
|
|
204
|
+
createdAt?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface AskModelOption {
|
|
208
|
+
value: string;
|
|
209
|
+
provider: string;
|
|
210
|
+
label: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface OptionInsightResult {
|
|
214
|
+
summary: string;
|
|
215
|
+
bullets?: string[];
|
|
216
|
+
suggestedText?: string;
|
|
217
|
+
modelUsed?: string | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
187
220
|
export interface InterviewServerOptions {
|
|
188
221
|
questions: QuestionsFile;
|
|
189
222
|
sessionToken: string;
|
|
@@ -196,7 +229,11 @@ export interface InterviewServerOptions {
|
|
|
196
229
|
snapshotDir?: string;
|
|
197
230
|
autoSaveOnSubmit?: boolean;
|
|
198
231
|
savedAnswers?: ResponseItem[];
|
|
232
|
+
savedOptionInsights?: SavedOptionInsight[];
|
|
233
|
+
optionKeysByQuestion?: Record<string, string[]>;
|
|
199
234
|
canGenerate?: boolean;
|
|
235
|
+
askModels?: AskModelOption[];
|
|
236
|
+
defaultAskModel?: string | null;
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
export interface InterviewServerCallbacks {
|
|
@@ -207,7 +244,14 @@ export interface InterviewServerCallbacks {
|
|
|
207
244
|
existingOptions: string[],
|
|
208
245
|
signal: AbortSignal,
|
|
209
246
|
mode: "add" | "review",
|
|
210
|
-
) => Promise<{ options:
|
|
247
|
+
) => Promise<{ options: OptionValue[]; question?: string }>;
|
|
248
|
+
onOptionInsight?: (
|
|
249
|
+
questionId: string,
|
|
250
|
+
option: OptionValue,
|
|
251
|
+
prompt: string,
|
|
252
|
+
modelOverride: string | null,
|
|
253
|
+
signal: AbortSignal,
|
|
254
|
+
) => Promise<OptionInsightResult>;
|
|
211
255
|
}
|
|
212
256
|
|
|
213
257
|
export interface InterviewServerHandle {
|
|
@@ -387,11 +431,12 @@ function ensureQuestionId(
|
|
|
387
431
|
return { ok: true, question };
|
|
388
432
|
}
|
|
389
433
|
|
|
390
|
-
function syncRecommendations(question: Question, options:
|
|
434
|
+
function syncRecommendations(question: Question, options: OptionValue[]): void {
|
|
435
|
+
const optionLabels = options.map((option) => getOptionLabel(option));
|
|
391
436
|
if (!question.recommended) return;
|
|
392
437
|
|
|
393
438
|
if (question.type === "single") {
|
|
394
|
-
if (typeof question.recommended === "string" &&
|
|
439
|
+
if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
|
|
395
440
|
return;
|
|
396
441
|
}
|
|
397
442
|
delete question.recommended;
|
|
@@ -407,7 +452,7 @@ function syncRecommendations(question: Question, options: string[]): void {
|
|
|
407
452
|
|
|
408
453
|
const nextRecommended = (Array.isArray(question.recommended)
|
|
409
454
|
? question.recommended
|
|
410
|
-
: [question.recommended]).filter((option) =>
|
|
455
|
+
: [question.recommended]).filter((option) => optionLabels.includes(option));
|
|
411
456
|
if (nextRecommended.length === 0) {
|
|
412
457
|
delete question.recommended;
|
|
413
458
|
delete question.conviction;
|
|
@@ -416,6 +461,246 @@ function syncRecommendations(question: Question, options: string[]): void {
|
|
|
416
461
|
question.recommended = nextRecommended;
|
|
417
462
|
}
|
|
418
463
|
|
|
464
|
+
function makeOptionKey(): string {
|
|
465
|
+
return `opt-${randomUUID()}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function buildOptionKeysByQuestion(
|
|
469
|
+
questionsList: Question[],
|
|
470
|
+
initial: Record<string, string[]> | undefined,
|
|
471
|
+
): Record<string, string[]> {
|
|
472
|
+
const next: Record<string, string[]> = {};
|
|
473
|
+
for (const question of questionsList) {
|
|
474
|
+
if (question.type !== "single" && question.type !== "multi") continue;
|
|
475
|
+
if (!question.options) continue;
|
|
476
|
+
const existing = initial?.[question.id];
|
|
477
|
+
if (Array.isArray(existing) && existing.length === question.options.length && existing.every((key) => typeof key === "string" && key.trim().length > 0)) {
|
|
478
|
+
next[question.id] = [...existing];
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
next[question.id] = question.options.map(() => makeOptionKey());
|
|
482
|
+
}
|
|
483
|
+
return next;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getOptionIndexByKey(
|
|
487
|
+
question: Question,
|
|
488
|
+
optionKeysByQuestion: Record<string, string[]>,
|
|
489
|
+
optionKey: string,
|
|
490
|
+
): number {
|
|
491
|
+
if ((question.type !== "single" && question.type !== "multi") || !question.options) {
|
|
492
|
+
return -1;
|
|
493
|
+
}
|
|
494
|
+
const keys = optionKeysByQuestion[question.id] ?? [];
|
|
495
|
+
return keys.indexOf(optionKey);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function setOptionLabel(option: OptionValue, label: string): OptionValue {
|
|
499
|
+
return typeof option === "string" ? label : { ...option, label };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isChoiceResponseValue(value: unknown): value is ChoiceResponseValue {
|
|
503
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof (value as ChoiceResponseValue).option === "string";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function normalizeChoiceResponseValue(value: unknown): ChoiceResponseValue | null {
|
|
507
|
+
if (!isChoiceResponseValue(value)) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const option = value.option.trim();
|
|
511
|
+
if (!option) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
const note = typeof value.note === "string" ? value.note.trim() : "";
|
|
515
|
+
return note ? { option, note } : { option };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function cloneResponseValue(value: ResponseValue): ResponseValue {
|
|
519
|
+
if (Array.isArray(value)) {
|
|
520
|
+
return value.map((item) => isChoiceResponseValue(item)
|
|
521
|
+
? { ...item }
|
|
522
|
+
: item);
|
|
523
|
+
}
|
|
524
|
+
if (isChoiceResponseValue(value)) {
|
|
525
|
+
return { ...value };
|
|
526
|
+
}
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function normalizeResponseItem(
|
|
531
|
+
question: Question,
|
|
532
|
+
item: { id?: unknown; value?: unknown; attachments?: unknown },
|
|
533
|
+
): { ok: true; response: ResponseItem } | { ok: false; error: string } {
|
|
534
|
+
const response: ResponseItem = { id: question.id, value: "" };
|
|
535
|
+
|
|
536
|
+
if (question.type === "image") {
|
|
537
|
+
if (Array.isArray(item.value) && item.value.every((value) => typeof value === "string")) {
|
|
538
|
+
response.value = item.value;
|
|
539
|
+
}
|
|
540
|
+
} else if (question.type === "single") {
|
|
541
|
+
if (item.value === "") {
|
|
542
|
+
response.value = "";
|
|
543
|
+
} else {
|
|
544
|
+
const value = normalizeChoiceResponseValue(item.value);
|
|
545
|
+
if (!value) {
|
|
546
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
547
|
+
}
|
|
548
|
+
response.value = value;
|
|
549
|
+
}
|
|
550
|
+
} else if (question.type === "multi") {
|
|
551
|
+
if (!Array.isArray(item.value)) {
|
|
552
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
553
|
+
}
|
|
554
|
+
const values: ChoiceResponseValue[] = [];
|
|
555
|
+
for (const value of item.value) {
|
|
556
|
+
const normalized = normalizeChoiceResponseValue(value);
|
|
557
|
+
if (!normalized) {
|
|
558
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
559
|
+
}
|
|
560
|
+
values.push(normalized);
|
|
561
|
+
}
|
|
562
|
+
response.value = values;
|
|
563
|
+
} else {
|
|
564
|
+
if (typeof item.value !== "string") {
|
|
565
|
+
return { ok: false, error: `Invalid response value for ${question.id}` };
|
|
566
|
+
}
|
|
567
|
+
response.value = item.value;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (Array.isArray(item.attachments) && item.attachments.every((attachment) => typeof attachment === "string")) {
|
|
571
|
+
response.attachments = item.attachments;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return { ok: true, response };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function normalizeResponseItems(
|
|
578
|
+
responsesInput: unknown[],
|
|
579
|
+
questionById: Map<string, Question>,
|
|
580
|
+
): { ok: true; responses: ResponseItem[] } | { ok: false; field?: string; error: string } {
|
|
581
|
+
const responses: ResponseItem[] = [];
|
|
582
|
+
for (const item of responsesInput) {
|
|
583
|
+
if (!item || typeof item !== "object" || typeof (item as { id?: unknown }).id !== "string") continue;
|
|
584
|
+
const typedItem = item as { id: string; value?: unknown; attachments?: unknown };
|
|
585
|
+
const questionCheck = ensureQuestionId(typedItem.id, questionById);
|
|
586
|
+
if (questionCheck.ok === false) {
|
|
587
|
+
return { ok: false, error: questionCheck.error, field: typedItem.id };
|
|
588
|
+
}
|
|
589
|
+
const normalized = normalizeResponseItem(questionCheck.question, typedItem);
|
|
590
|
+
if (normalized.ok === false) {
|
|
591
|
+
return { ok: false, error: normalized.error, field: typedItem.id };
|
|
592
|
+
}
|
|
593
|
+
responses.push(normalized.response);
|
|
594
|
+
}
|
|
595
|
+
return { ok: true, responses };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeGeneratedOptionValues(options: OptionValue[]): OptionValue[] {
|
|
599
|
+
const uniqueOptions: OptionValue[] = [];
|
|
600
|
+
const indexByLabel = new Map<string, number>();
|
|
601
|
+
for (const option of options) {
|
|
602
|
+
const label = getOptionLabel(option).trim();
|
|
603
|
+
if (!label) continue;
|
|
604
|
+
const normalizedOption = setOptionLabel(option, label);
|
|
605
|
+
const labelKey = label.toLowerCase();
|
|
606
|
+
const existingIndex = indexByLabel.get(labelKey);
|
|
607
|
+
if (existingIndex === undefined) {
|
|
608
|
+
indexByLabel.set(labelKey, uniqueOptions.length);
|
|
609
|
+
uniqueOptions.push(normalizedOption);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (typeof uniqueOptions[existingIndex] === "string" && typeof normalizedOption !== "string") {
|
|
613
|
+
uniqueOptions[existingIndex] = normalizedOption;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return uniqueOptions;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function reconcileOptionKeysByLabel(
|
|
620
|
+
previousOptions: OptionValue[],
|
|
621
|
+
previousKeys: string[],
|
|
622
|
+
nextOptions: OptionValue[],
|
|
623
|
+
): string[] {
|
|
624
|
+
const availableKeysByLabel = new Map<string, string[]>();
|
|
625
|
+
for (let index = 0; index < previousOptions.length; index++) {
|
|
626
|
+
const key = previousKeys[index];
|
|
627
|
+
if (!key) continue;
|
|
628
|
+
const label = getOptionLabel(previousOptions[index]).trim();
|
|
629
|
+
const existing = availableKeysByLabel.get(label);
|
|
630
|
+
if (existing) {
|
|
631
|
+
existing.push(key);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
availableKeysByLabel.set(label, [key]);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return nextOptions.map((option) => {
|
|
638
|
+
const matchingKeys = availableKeysByLabel.get(getOptionLabel(option).trim());
|
|
639
|
+
if (!matchingKeys || matchingKeys.length === 0) {
|
|
640
|
+
return makeOptionKey();
|
|
641
|
+
}
|
|
642
|
+
return matchingKeys.shift() ?? makeOptionKey();
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function normalizeSavedOptionInsights(input: unknown): SavedOptionInsight[] {
|
|
647
|
+
if (!Array.isArray(input)) return [];
|
|
648
|
+
const normalized: SavedOptionInsight[] = [];
|
|
649
|
+
for (const item of input) {
|
|
650
|
+
if (!item || typeof item !== "object") continue;
|
|
651
|
+
const raw = item as Record<string, unknown>;
|
|
652
|
+
if (
|
|
653
|
+
typeof raw.id !== "string" ||
|
|
654
|
+
typeof raw.questionId !== "string" ||
|
|
655
|
+
typeof raw.optionKey !== "string" ||
|
|
656
|
+
typeof raw.optionText !== "string" ||
|
|
657
|
+
typeof raw.prompt !== "string" ||
|
|
658
|
+
typeof raw.summary !== "string"
|
|
659
|
+
) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const bullets = Array.isArray(raw.bullets)
|
|
663
|
+
? raw.bullets.filter((bullet): bullet is string => typeof bullet === "string" && bullet.trim().length > 0)
|
|
664
|
+
: undefined;
|
|
665
|
+
normalized.push({
|
|
666
|
+
id: raw.id,
|
|
667
|
+
questionId: raw.questionId,
|
|
668
|
+
optionKey: raw.optionKey,
|
|
669
|
+
optionText: raw.optionText,
|
|
670
|
+
prompt: raw.prompt,
|
|
671
|
+
summary: raw.summary,
|
|
672
|
+
bullets: bullets && bullets.length > 0 ? bullets : undefined,
|
|
673
|
+
suggestedText: typeof raw.suggestedText === "string" ? raw.suggestedText : undefined,
|
|
674
|
+
modelUsed: typeof raw.modelUsed === "string" ? raw.modelUsed : raw.modelUsed === null ? null : undefined,
|
|
675
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : undefined,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return normalized;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function renderSavedOptionInsightsHtml(insights: SavedOptionInsight[]): string {
|
|
682
|
+
if (insights.length === 0) return "";
|
|
683
|
+
return `<div class="saved-option-insights">${insights.map((insight) => {
|
|
684
|
+
const bulletsHtml = insight.bullets && insight.bullets.length > 0
|
|
685
|
+
? `<ul>${insight.bullets.map((bullet) => `<li>${escapeHtml(bullet)}</li>`).join("")}</ul>`
|
|
686
|
+
: "";
|
|
687
|
+
const suggestionHtml = insight.suggestedText
|
|
688
|
+
? `<div class="saved-option-insight-suggestion"><span>Suggested rewrite</span><code>${escapeHtml(insight.suggestedText)}</code></div>`
|
|
689
|
+
: "";
|
|
690
|
+
const metaParts = [
|
|
691
|
+
insight.optionText ? `Option: ${escapeHtml(insight.optionText)}` : "",
|
|
692
|
+
insight.modelUsed ? `Model: ${escapeHtml(insight.modelUsed)}` : "",
|
|
693
|
+
].filter(Boolean);
|
|
694
|
+
return `<article class="saved-option-insight">
|
|
695
|
+
<div class="saved-option-insight-prompt">${escapeHtml(insight.prompt)}</div>
|
|
696
|
+
${metaParts.length > 0 ? `<div class="saved-option-insight-meta">${metaParts.join(" · ")}</div>` : ""}
|
|
697
|
+
<p>${escapeHtml(insight.summary)}</p>
|
|
698
|
+
${bulletsHtml}
|
|
699
|
+
${suggestionHtml}
|
|
700
|
+
</article>`;
|
|
701
|
+
}).join("")}</div>`;
|
|
702
|
+
}
|
|
703
|
+
|
|
419
704
|
// HTML generation for saved interviews
|
|
420
705
|
interface SavedFromMeta {
|
|
421
706
|
cwd: string;
|
|
@@ -437,6 +722,155 @@ function escapeHtml(str: string): string {
|
|
|
437
722
|
.replace(/"/g, """);
|
|
438
723
|
}
|
|
439
724
|
|
|
725
|
+
function isMarkdownLang(lang: string | undefined): boolean {
|
|
726
|
+
if (!lang) return false;
|
|
727
|
+
const normalized = lang.trim().toLowerCase();
|
|
728
|
+
return normalized === "md" || normalized === "markdown";
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function renderLightMarkdownHtml(text: string): string {
|
|
732
|
+
let html = escapeHtml(text);
|
|
733
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
734
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
735
|
+
html = html.replace(/\n/g, "<br>");
|
|
736
|
+
html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
|
|
737
|
+
return html;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function renderMarkdownPreviewHtml(markdown: string): string {
|
|
741
|
+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
742
|
+
const html: string[] = [];
|
|
743
|
+
const paragraph: string[] = [];
|
|
744
|
+
let listType: "ul" | "ol" | null = null;
|
|
745
|
+
let inFence = false;
|
|
746
|
+
let fenceLang = "";
|
|
747
|
+
let fenceLines: string[] = [];
|
|
748
|
+
|
|
749
|
+
const flushParagraph = () => {
|
|
750
|
+
if (paragraph.length === 0) return;
|
|
751
|
+
html.push(`<p>${renderLightMarkdownHtml(paragraph.join(" "))}</p>`);
|
|
752
|
+
paragraph.length = 0;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const closeList = () => {
|
|
756
|
+
if (!listType) return;
|
|
757
|
+
html.push(listType === "ol" ? "</ol>" : "</ul>");
|
|
758
|
+
listType = null;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
for (const rawLine of lines) {
|
|
762
|
+
const line = rawLine ?? "";
|
|
763
|
+
|
|
764
|
+
if (inFence) {
|
|
765
|
+
if (/^```/.test(line.trim())) {
|
|
766
|
+
html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
|
767
|
+
inFence = false;
|
|
768
|
+
fenceLang = "";
|
|
769
|
+
fenceLines = [];
|
|
770
|
+
} else {
|
|
771
|
+
fenceLines.push(line);
|
|
772
|
+
}
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
|
|
777
|
+
if (fenceStart) {
|
|
778
|
+
flushParagraph();
|
|
779
|
+
closeList();
|
|
780
|
+
inFence = true;
|
|
781
|
+
fenceLang = fenceStart[1] || "";
|
|
782
|
+
fenceLines = [];
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (!line.trim()) {
|
|
787
|
+
flushParagraph();
|
|
788
|
+
closeList();
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
|
|
793
|
+
if (headingMatch) {
|
|
794
|
+
flushParagraph();
|
|
795
|
+
closeList();
|
|
796
|
+
const level = headingMatch[1].length;
|
|
797
|
+
html.push(`<h${level}>${renderLightMarkdownHtml(headingMatch[2].trim())}</h${level}>`);
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const quoteMatch = line.match(/^>\s?(.*)$/);
|
|
802
|
+
if (quoteMatch) {
|
|
803
|
+
flushParagraph();
|
|
804
|
+
closeList();
|
|
805
|
+
html.push(`<blockquote><p>${renderLightMarkdownHtml(quoteMatch[1])}</p></blockquote>`);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
810
|
+
if (orderedMatch) {
|
|
811
|
+
flushParagraph();
|
|
812
|
+
if (listType !== "ol") {
|
|
813
|
+
closeList();
|
|
814
|
+
html.push("<ol>");
|
|
815
|
+
listType = "ol";
|
|
816
|
+
}
|
|
817
|
+
html.push(`<li>${renderLightMarkdownHtml(orderedMatch[1])}</li>`);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
|
|
822
|
+
if (unorderedMatch) {
|
|
823
|
+
flushParagraph();
|
|
824
|
+
if (listType !== "ul") {
|
|
825
|
+
closeList();
|
|
826
|
+
html.push("<ul>");
|
|
827
|
+
listType = "ul";
|
|
828
|
+
}
|
|
829
|
+
html.push(`<li>${renderLightMarkdownHtml(unorderedMatch[1])}</li>`);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
closeList();
|
|
834
|
+
paragraph.push(line.trim());
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (inFence) {
|
|
838
|
+
html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
flushParagraph();
|
|
842
|
+
closeList();
|
|
843
|
+
return html.join("\n");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function renderContentBlockHtml(content: Question["content"] | undefined): string {
|
|
847
|
+
if (!content?.source) return "";
|
|
848
|
+
|
|
849
|
+
const markdownPreview = isMarkdownLang(content.lang) && content.showSource !== true;
|
|
850
|
+
const headerParts: string[] = [];
|
|
851
|
+
if (content.title) {
|
|
852
|
+
headerParts.push(`<span class="code-block-title">${escapeHtml(content.title)}</span>`);
|
|
853
|
+
}
|
|
854
|
+
if (content.file) {
|
|
855
|
+
headerParts.push(`<span class="code-block-file">${escapeHtml(content.file)}</span>`);
|
|
856
|
+
}
|
|
857
|
+
if (content.lines) {
|
|
858
|
+
headerParts.push(`<span class="code-block-lines">L${escapeHtml(content.lines)}</span>`);
|
|
859
|
+
}
|
|
860
|
+
if (content.lang && content.lang !== "diff") {
|
|
861
|
+
headerParts.push(`<span class="code-block-lang">${escapeHtml(content.lang)}</span>`);
|
|
862
|
+
}
|
|
863
|
+
const headerHtml = headerParts.length > 0
|
|
864
|
+
? `<div class="code-block-header">${headerParts.join("")}</div>`
|
|
865
|
+
: "";
|
|
866
|
+
|
|
867
|
+
if (markdownPreview) {
|
|
868
|
+
return `<div class="code-block markdown-content-block">${headerHtml}<div class="markdown-preview">${renderMarkdownPreviewHtml(content.source)}</div></div>`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return `<div class="code-block">${headerHtml}<pre class="saved-code"><code>${escapeHtml(content.source)}</code></pre></div>`;
|
|
872
|
+
}
|
|
873
|
+
|
|
440
874
|
function renderMediaCaptionHtml(media: MediaBlock): string {
|
|
441
875
|
if (!media.caption) return "";
|
|
442
876
|
return `<div class="media-caption">${escapeHtml(media.caption)}</div>`;
|
|
@@ -496,6 +930,13 @@ function savedAnswerItemHtml(text: string, q: Question): string {
|
|
|
496
930
|
return escapeHtml(text) + indicator;
|
|
497
931
|
}
|
|
498
932
|
|
|
933
|
+
function savedChoiceAnswerHtml(value: ChoiceResponseValue, question: Question): string {
|
|
934
|
+
const noteHtml = value.note
|
|
935
|
+
? `<div class="saved-answer-note">${escapeHtml(value.note)}</div>`
|
|
936
|
+
: "";
|
|
937
|
+
return `<div class="saved-answer-choice">${savedAnswerItemHtml(value.option, question)}${noteHtml}</div>`;
|
|
938
|
+
}
|
|
939
|
+
|
|
499
940
|
function weightClasses(q: Question): string {
|
|
500
941
|
const classes = ["saved-question"];
|
|
501
942
|
if (q.type === "info") classes.push("info-panel");
|
|
@@ -534,7 +975,11 @@ async function copyMediaImages(questionsList: Question[], imagesDir: string, cwd
|
|
|
534
975
|
return rewritten;
|
|
535
976
|
}
|
|
536
977
|
|
|
537
|
-
function renderQuestionsHtml(
|
|
978
|
+
function renderQuestionsHtml(
|
|
979
|
+
questionsList: Question[],
|
|
980
|
+
answers: ResponseItem[],
|
|
981
|
+
optionInsights: SavedOptionInsight[],
|
|
982
|
+
): string {
|
|
538
983
|
const answerMap = new Map(answers.map((a) => [a.id, a]));
|
|
539
984
|
let questionNum = 0;
|
|
540
985
|
return questionsList
|
|
@@ -543,17 +988,19 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
543
988
|
if (showNumber) questionNum++;
|
|
544
989
|
const numPrefix = showNumber ? `${questionNum}. ` : "";
|
|
545
990
|
const mediaHtml = renderMediaListHtml(q.media);
|
|
991
|
+
const optionInsightsHtml = renderSavedOptionInsightsHtml(
|
|
992
|
+
optionInsights.filter((insight) => insight.questionId === q.id),
|
|
993
|
+
);
|
|
546
994
|
|
|
547
995
|
if (q.type === "info") {
|
|
548
|
-
const
|
|
549
|
-
? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
|
|
550
|
-
: "";
|
|
996
|
+
const contentHtml = renderContentBlockHtml(q.content);
|
|
551
997
|
return `
|
|
552
998
|
<div class="${weightClasses(q)}">
|
|
553
999
|
<h2>${escapeHtml(q.question)}</h2>
|
|
554
1000
|
${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
|
|
555
|
-
${
|
|
1001
|
+
${contentHtml}
|
|
556
1002
|
${mediaHtml}
|
|
1003
|
+
${optionInsightsHtml}
|
|
557
1004
|
</div>
|
|
558
1005
|
`;
|
|
559
1006
|
}
|
|
@@ -571,17 +1018,22 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
571
1018
|
.map((p) => `<img src="${escapeHtml(p)}" alt="uploaded image">`)
|
|
572
1019
|
.join("")}</div>`;
|
|
573
1020
|
} else if (q.type === "multi") {
|
|
574
|
-
const items = Array.isArray(value) ? value : [
|
|
1021
|
+
const items = Array.isArray(value) ? value.filter(isChoiceResponseValue) : [];
|
|
575
1022
|
answerHtml = `<div class="saved-answer"><ul>${items
|
|
576
|
-
.map((
|
|
1023
|
+
.map((item) => `<li>${savedChoiceAnswerHtml(item, q)}</li>`)
|
|
577
1024
|
.join("")}</ul></div>`;
|
|
1025
|
+
if (items.length === 0) {
|
|
1026
|
+
answerHtml = '<div class="saved-answer empty">(no answer)</div>';
|
|
1027
|
+
}
|
|
1028
|
+
} else if (q.type === "single") {
|
|
1029
|
+
answerHtml = isChoiceResponseValue(value)
|
|
1030
|
+
? `<div class="saved-answer">${savedChoiceAnswerHtml(value, q)}</div>`
|
|
1031
|
+
: '<div class="saved-answer empty">(no answer)</div>';
|
|
578
1032
|
} else {
|
|
579
1033
|
answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
|
|
580
1034
|
}
|
|
581
1035
|
|
|
582
|
-
const
|
|
583
|
-
? `<pre class="saved-code"><code>${escapeHtml(q.codeBlock.code)}</code></pre>`
|
|
584
|
-
: "";
|
|
1036
|
+
const contentHtml = renderContentBlockHtml(q.content);
|
|
585
1037
|
|
|
586
1038
|
const attachHtml =
|
|
587
1039
|
attachments.length > 0
|
|
@@ -598,8 +1050,9 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
|
|
|
598
1050
|
<div class="${weightClasses(q)}">
|
|
599
1051
|
<h2>${numPrefix}${escapeHtml(q.question)}</h2>
|
|
600
1052
|
${contextHtml}
|
|
601
|
-
${
|
|
1053
|
+
${contentHtml}
|
|
602
1054
|
${mediaHtml}
|
|
1055
|
+
${optionInsightsHtml}
|
|
603
1056
|
${answerHtml}
|
|
604
1057
|
${attachHtml}
|
|
605
1058
|
</div>
|
|
@@ -660,8 +1113,11 @@ const SAVED_VIEW_STYLES = `
|
|
|
660
1113
|
padding: 12px;
|
|
661
1114
|
background: var(--bg-body);
|
|
662
1115
|
border-radius: var(--radius);
|
|
663
|
-
overflow-x:
|
|
1116
|
+
overflow-x: hidden;
|
|
664
1117
|
font-size: 13px;
|
|
1118
|
+
white-space: pre-wrap;
|
|
1119
|
+
overflow-wrap: anywhere;
|
|
1120
|
+
word-break: break-word;
|
|
665
1121
|
}
|
|
666
1122
|
.saved-answer {
|
|
667
1123
|
color: var(--fg);
|
|
@@ -670,6 +1126,55 @@ const SAVED_VIEW_STYLES = `
|
|
|
670
1126
|
border-radius: var(--radius);
|
|
671
1127
|
white-space: pre-wrap;
|
|
672
1128
|
}
|
|
1129
|
+
.saved-option-insights {
|
|
1130
|
+
display: grid;
|
|
1131
|
+
gap: 10px;
|
|
1132
|
+
margin: 14px 0;
|
|
1133
|
+
}
|
|
1134
|
+
.saved-option-insight {
|
|
1135
|
+
border: 1px solid var(--border-muted);
|
|
1136
|
+
border-radius: 12px;
|
|
1137
|
+
padding: 12px;
|
|
1138
|
+
background: color-mix(in srgb, var(--bg-body) 82%, transparent);
|
|
1139
|
+
}
|
|
1140
|
+
.saved-option-insight-prompt {
|
|
1141
|
+
font-family: var(--font-mono);
|
|
1142
|
+
font-size: 11px;
|
|
1143
|
+
color: var(--accent);
|
|
1144
|
+
margin-bottom: 4px;
|
|
1145
|
+
}
|
|
1146
|
+
.saved-option-insight-meta {
|
|
1147
|
+
font-size: 11px;
|
|
1148
|
+
color: var(--fg-muted);
|
|
1149
|
+
margin-bottom: 8px;
|
|
1150
|
+
}
|
|
1151
|
+
.saved-option-insight p {
|
|
1152
|
+
margin: 0;
|
|
1153
|
+
}
|
|
1154
|
+
.saved-option-insight ul {
|
|
1155
|
+
margin: 8px 0 0;
|
|
1156
|
+
padding-left: 18px;
|
|
1157
|
+
}
|
|
1158
|
+
.saved-option-insight-suggestion {
|
|
1159
|
+
margin-top: 10px;
|
|
1160
|
+
display: grid;
|
|
1161
|
+
gap: 4px;
|
|
1162
|
+
}
|
|
1163
|
+
.saved-option-insight-suggestion span {
|
|
1164
|
+
font-size: 10px;
|
|
1165
|
+
text-transform: uppercase;
|
|
1166
|
+
letter-spacing: 0.06em;
|
|
1167
|
+
color: var(--fg-muted);
|
|
1168
|
+
}
|
|
1169
|
+
.saved-option-insight-suggestion code {
|
|
1170
|
+
display: block;
|
|
1171
|
+
padding: 8px 10px;
|
|
1172
|
+
border-radius: 8px;
|
|
1173
|
+
background: var(--bg-body);
|
|
1174
|
+
font-family: var(--font-mono);
|
|
1175
|
+
white-space: pre-wrap;
|
|
1176
|
+
overflow-wrap: anywhere;
|
|
1177
|
+
}
|
|
673
1178
|
.saved-answer.empty {
|
|
674
1179
|
color: var(--fg-dim);
|
|
675
1180
|
font-style: italic;
|
|
@@ -678,6 +1183,14 @@ const SAVED_VIEW_STYLES = `
|
|
|
678
1183
|
margin: 0;
|
|
679
1184
|
padding-left: 20px;
|
|
680
1185
|
}
|
|
1186
|
+
.saved-answer-choice {
|
|
1187
|
+
display: grid;
|
|
1188
|
+
gap: 4px;
|
|
1189
|
+
}
|
|
1190
|
+
.saved-answer-note {
|
|
1191
|
+
color: var(--fg-muted);
|
|
1192
|
+
font-size: 12px;
|
|
1193
|
+
}
|
|
681
1194
|
.saved-images, .saved-attachments {
|
|
682
1195
|
margin-top: 12px;
|
|
683
1196
|
display: flex;
|
|
@@ -721,11 +1234,13 @@ const SAVED_VIEW_STYLES = `
|
|
|
721
1234
|
function generateSavedHtml(options: {
|
|
722
1235
|
questions: QuestionsFile;
|
|
723
1236
|
answers: ResponseItem[];
|
|
1237
|
+
optionInsights: SavedOptionInsight[];
|
|
1238
|
+
optionKeysByQuestion: Record<string, string[]>;
|
|
724
1239
|
meta: SavedInterviewMeta;
|
|
725
1240
|
baseStyles: string;
|
|
726
1241
|
themeCss: string;
|
|
727
1242
|
}): string {
|
|
728
|
-
const { questions: questionsData, answers, meta, baseStyles, themeCss } = options;
|
|
1243
|
+
const { questions: questionsData, answers, optionInsights, optionKeysByQuestion, meta, baseStyles, themeCss } = options;
|
|
729
1244
|
const title = questionsData.title || "Interview";
|
|
730
1245
|
|
|
731
1246
|
// Build the data object for embedding
|
|
@@ -734,13 +1249,15 @@ function generateSavedHtml(options: {
|
|
|
734
1249
|
description: questionsData.description,
|
|
735
1250
|
questions: questionsData.questions,
|
|
736
1251
|
savedAnswers: answers,
|
|
1252
|
+
savedOptionInsights: optionInsights,
|
|
1253
|
+
optionKeysByQuestion,
|
|
737
1254
|
savedAt: meta.savedAt,
|
|
738
1255
|
wasSubmitted: meta.wasSubmitted,
|
|
739
1256
|
savedFrom: meta.savedFrom,
|
|
740
1257
|
};
|
|
741
1258
|
|
|
742
1259
|
const embeddedJson = safeInlineJSON(dataForEmbedding);
|
|
743
|
-
const questionsHtml = renderQuestionsHtml(questionsData.questions, answers);
|
|
1260
|
+
const questionsHtml = renderQuestionsHtml(questionsData.questions, answers, optionInsights);
|
|
744
1261
|
const savedDate = new Date(meta.savedAt).toLocaleString();
|
|
745
1262
|
const statusClass = meta.wasSubmitted ? "submitted" : "draft";
|
|
746
1263
|
const statusText = meta.wasSubmitted ? "Submitted" : "Draft";
|
|
@@ -788,6 +1305,8 @@ export async function startInterviewServer(
|
|
|
788
1305
|
for (const question of questions.questions) {
|
|
789
1306
|
questionById.set(question.id, question);
|
|
790
1307
|
}
|
|
1308
|
+
const optionKeysByQuestion = buildOptionKeysByQuestion(questions.questions, options.optionKeysByQuestion);
|
|
1309
|
+
const initialSavedOptionInsights = normalizeSavedOptionInsights(options.savedOptionInsights);
|
|
791
1310
|
|
|
792
1311
|
function getMediaList(q: Question): MediaBlock[] {
|
|
793
1312
|
if (!q.media) return [];
|
|
@@ -917,8 +1436,12 @@ export async function startInterviewServer(
|
|
|
917
1436
|
toggleHotkey: themeConfig.toggleHotkey,
|
|
918
1437
|
},
|
|
919
1438
|
savedAnswers: options.savedAnswers,
|
|
1439
|
+
savedOptionInsights: initialSavedOptionInsights,
|
|
1440
|
+
optionKeysByQuestion,
|
|
920
1441
|
autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
|
|
921
1442
|
canGenerate: options.canGenerate ?? false,
|
|
1443
|
+
askModels: options.askModels ?? [],
|
|
1444
|
+
defaultAskModel: options.defaultAskModel ?? null,
|
|
922
1445
|
});
|
|
923
1446
|
const html = TEMPLATE
|
|
924
1447
|
.replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
|
|
@@ -1080,7 +1603,7 @@ export async function startInterviewServer(
|
|
|
1080
1603
|
}
|
|
1081
1604
|
|
|
1082
1605
|
const payload = body as {
|
|
1083
|
-
responses?:
|
|
1606
|
+
responses?: unknown[];
|
|
1084
1607
|
images?: Array<{ id: string; filename: string; mimeType: string; data: string; isAttachment?: boolean }>;
|
|
1085
1608
|
};
|
|
1086
1609
|
|
|
@@ -1092,50 +1615,16 @@ export async function startInterviewServer(
|
|
|
1092
1615
|
return;
|
|
1093
1616
|
}
|
|
1094
1617
|
|
|
1095
|
-
const
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
const question = questionCheck.question;
|
|
1104
|
-
|
|
1105
|
-
const resp: ResponseItem = { id: item.id, value: "" };
|
|
1106
|
-
|
|
1107
|
-
if (question.type === "image") {
|
|
1108
|
-
if (Array.isArray(item.value) && item.value.every((v) => typeof v === "string")) {
|
|
1109
|
-
resp.value = item.value;
|
|
1110
|
-
}
|
|
1111
|
-
} else if (question.type === "multi") {
|
|
1112
|
-
if (!Array.isArray(item.value) || item.value.some((v) => typeof v !== "string")) {
|
|
1113
|
-
sendJson(res, 400, {
|
|
1114
|
-
ok: false,
|
|
1115
|
-
error: `Invalid response value for ${item.id}`,
|
|
1116
|
-
field: item.id,
|
|
1117
|
-
});
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
resp.value = item.value;
|
|
1121
|
-
} else {
|
|
1122
|
-
if (typeof item.value !== "string") {
|
|
1123
|
-
sendJson(res, 400, {
|
|
1124
|
-
ok: false,
|
|
1125
|
-
error: `Invalid response value for ${item.id}`,
|
|
1126
|
-
field: item.id,
|
|
1127
|
-
});
|
|
1128
|
-
return;
|
|
1129
|
-
}
|
|
1130
|
-
resp.value = item.value;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
if (Array.isArray(item.attachments) && item.attachments.every((a) => typeof a === "string")) {
|
|
1134
|
-
resp.attachments = item.attachments;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
responses.push(resp);
|
|
1618
|
+
const normalizedResponses = normalizeResponseItems(responsesInput, questionById);
|
|
1619
|
+
if (normalizedResponses.ok === false) {
|
|
1620
|
+
sendJson(res, 400, {
|
|
1621
|
+
ok: false,
|
|
1622
|
+
error: normalizedResponses.error,
|
|
1623
|
+
field: normalizedResponses.field,
|
|
1624
|
+
});
|
|
1625
|
+
return;
|
|
1138
1626
|
}
|
|
1627
|
+
const responses = normalizedResponses.responses;
|
|
1139
1628
|
|
|
1140
1629
|
for (const image of imagesInput) {
|
|
1141
1630
|
if (!image || typeof image.id !== "string") continue;
|
|
@@ -1208,7 +1697,8 @@ export async function startInterviewServer(
|
|
|
1208
1697
|
// Note: don't check `completed` - allow save after submit
|
|
1209
1698
|
|
|
1210
1699
|
const payload = body as {
|
|
1211
|
-
responses?:
|
|
1700
|
+
responses?: unknown[];
|
|
1701
|
+
savedOptionInsights?: SavedOptionInsight[];
|
|
1212
1702
|
images?: Array<{
|
|
1213
1703
|
id: string;
|
|
1214
1704
|
filename: string;
|
|
@@ -1220,8 +1710,18 @@ export async function startInterviewServer(
|
|
|
1220
1710
|
};
|
|
1221
1711
|
|
|
1222
1712
|
const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
|
|
1713
|
+
const savedOptionInsights = normalizeSavedOptionInsights(payload.savedOptionInsights);
|
|
1223
1714
|
const imagesInput = Array.isArray(payload.images) ? payload.images : [];
|
|
1224
1715
|
const submitted = payload.submitted === true;
|
|
1716
|
+
const normalizedResponses = normalizeResponseItems(responsesInput, questionById);
|
|
1717
|
+
if (normalizedResponses.ok === false) {
|
|
1718
|
+
sendJson(res, 400, {
|
|
1719
|
+
ok: false,
|
|
1720
|
+
error: normalizedResponses.error,
|
|
1721
|
+
field: normalizedResponses.field,
|
|
1722
|
+
});
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1225
1725
|
|
|
1226
1726
|
const snapshotBaseDir = options.snapshotDir ?? SNAPSHOTS_DIR;
|
|
1227
1727
|
|
|
@@ -1241,10 +1741,10 @@ export async function startInterviewServer(
|
|
|
1241
1741
|
await mkdir(snapshotPath, { recursive: true });
|
|
1242
1742
|
|
|
1243
1743
|
// Process responses - make a deep copy to avoid mutating input
|
|
1244
|
-
const savedResponses: ResponseItem[] =
|
|
1245
|
-
...
|
|
1246
|
-
value:
|
|
1247
|
-
attachments:
|
|
1744
|
+
const savedResponses: ResponseItem[] = normalizedResponses.responses.map((response) => ({
|
|
1745
|
+
...response,
|
|
1746
|
+
value: cloneResponseValue(response.value),
|
|
1747
|
+
attachments: response.attachments ? [...response.attachments] : undefined,
|
|
1248
1748
|
}));
|
|
1249
1749
|
|
|
1250
1750
|
// Process uploaded images - save to images/ subfolder
|
|
@@ -1303,6 +1803,8 @@ export async function startInterviewServer(
|
|
|
1303
1803
|
const html = generateSavedHtml({
|
|
1304
1804
|
questions: snapshotQuestions,
|
|
1305
1805
|
answers: savedResponses,
|
|
1806
|
+
optionInsights: savedOptionInsights,
|
|
1807
|
+
optionKeysByQuestion,
|
|
1306
1808
|
meta,
|
|
1307
1809
|
baseStyles: STYLES,
|
|
1308
1810
|
themeCss,
|
|
@@ -1334,7 +1836,6 @@ export async function startInterviewServer(
|
|
|
1334
1836
|
|
|
1335
1837
|
const payload = body as {
|
|
1336
1838
|
questionId?: string;
|
|
1337
|
-
existingOptions?: string[];
|
|
1338
1839
|
mode?: string;
|
|
1339
1840
|
};
|
|
1340
1841
|
|
|
@@ -1348,14 +1849,8 @@ export async function startInterviewServer(
|
|
|
1348
1849
|
sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
|
|
1349
1850
|
return;
|
|
1350
1851
|
}
|
|
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
|
-
}
|
|
1355
1852
|
|
|
1356
|
-
const existingOptions =
|
|
1357
|
-
? payload.existingOptions.filter((o): o is string => typeof o === "string")
|
|
1358
|
-
: [];
|
|
1853
|
+
const existingOptions = question.options.map((option) => getOptionLabel(option).trim());
|
|
1359
1854
|
|
|
1360
1855
|
const mode = payload.mode === "review" ? "review" : "add";
|
|
1361
1856
|
|
|
@@ -1373,34 +1868,40 @@ export async function startInterviewServer(
|
|
|
1373
1868
|
mode,
|
|
1374
1869
|
);
|
|
1375
1870
|
|
|
1376
|
-
const uniqueOptions
|
|
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
|
-
}
|
|
1871
|
+
const uniqueOptions = normalizeGeneratedOptionValues(result.options);
|
|
1386
1872
|
|
|
1387
1873
|
const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
|
|
1388
1874
|
const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
|
|
1875
|
+
let nextOptionKeys = optionKeysByQuestion[payload.questionId] ?? [];
|
|
1876
|
+
let appliedOptions: OptionValue[] = [];
|
|
1389
1877
|
if (storedQuestion) {
|
|
1390
1878
|
if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
|
|
1879
|
+
const previousOptions = [...storedQuestion.options];
|
|
1880
|
+
const previousKeys = [...(optionKeysByQuestion[payload.questionId] ?? [])];
|
|
1391
1881
|
storedQuestion.question = reviewedQuestion;
|
|
1392
1882
|
storedQuestion.options = uniqueOptions;
|
|
1393
1883
|
syncRecommendations(storedQuestion, uniqueOptions);
|
|
1884
|
+
nextOptionKeys = reconcileOptionKeysByLabel(previousOptions, previousKeys, uniqueOptions);
|
|
1885
|
+
optionKeysByQuestion[payload.questionId] = nextOptionKeys;
|
|
1886
|
+
appliedOptions = uniqueOptions;
|
|
1394
1887
|
} else if (mode === "add") {
|
|
1395
|
-
const existingKeys = new Set(
|
|
1396
|
-
const newOptions = uniqueOptions.filter((option) => !existingKeys.has(option.toLowerCase()));
|
|
1888
|
+
const existingKeys = new Set(storedQuestion.options.map((option) => getOptionLabel(option).trim().toLowerCase()));
|
|
1889
|
+
const newOptions = uniqueOptions.filter((option) => !existingKeys.has(getOptionLabel(option).trim().toLowerCase()));
|
|
1397
1890
|
if (newOptions.length > 0) {
|
|
1398
1891
|
storedQuestion.options = storedQuestion.options.concat(newOptions);
|
|
1892
|
+
nextOptionKeys = (optionKeysByQuestion[payload.questionId] ?? []).concat(newOptions.map(() => makeOptionKey()));
|
|
1893
|
+
optionKeysByQuestion[payload.questionId] = nextOptionKeys;
|
|
1399
1894
|
}
|
|
1895
|
+
appliedOptions = newOptions;
|
|
1400
1896
|
}
|
|
1401
1897
|
}
|
|
1402
1898
|
|
|
1403
|
-
sendJson(res, 200, {
|
|
1899
|
+
sendJson(res, 200, {
|
|
1900
|
+
ok: true,
|
|
1901
|
+
options: appliedOptions,
|
|
1902
|
+
question: reviewedQuestion,
|
|
1903
|
+
optionKeys: nextOptionKeys,
|
|
1904
|
+
});
|
|
1404
1905
|
} catch (err) {
|
|
1405
1906
|
if (controller.signal.aborted) {
|
|
1406
1907
|
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|
|
@@ -1412,6 +1913,181 @@ export async function startInterviewServer(
|
|
|
1412
1913
|
return;
|
|
1413
1914
|
}
|
|
1414
1915
|
|
|
1916
|
+
if (method === "POST" && url.pathname === "/option-insight") {
|
|
1917
|
+
const body = await parseBodyOrRespond();
|
|
1918
|
+
if (!body) return;
|
|
1919
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1920
|
+
if (completed) {
|
|
1921
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (!callbacks.onOptionInsight) {
|
|
1926
|
+
sendJson(res, 501, { ok: false, error: "Option insight not available" });
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const payload = body as {
|
|
1931
|
+
questionId?: string;
|
|
1932
|
+
optionKey?: string;
|
|
1933
|
+
prompt?: string;
|
|
1934
|
+
model?: string | null;
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
if (typeof payload.questionId !== "string") {
|
|
1938
|
+
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (typeof payload.optionKey !== "string") {
|
|
1942
|
+
sendJson(res, 400, { ok: false, error: "Missing optionKey" });
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (typeof payload.prompt !== "string" || payload.prompt.trim().length === 0) {
|
|
1946
|
+
sendJson(res, 400, { ok: false, error: "Prompt is required" });
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const questionCheck = ensureQuestionId(payload.questionId, questionById);
|
|
1951
|
+
if (questionCheck.ok === false) {
|
|
1952
|
+
sendJson(res, 400, { ok: false, error: questionCheck.error });
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const question = questionCheck.question;
|
|
1956
|
+
const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
|
|
1957
|
+
if (optionIndex === -1 || !question.options || optionIndex >= question.options.length) {
|
|
1958
|
+
sendJson(res, 400, { ok: false, error: "Invalid option for insight" });
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
const controller = new AbortController();
|
|
1963
|
+
res.on("close", () => {
|
|
1964
|
+
if (!res.writableEnded) controller.abort();
|
|
1965
|
+
});
|
|
1966
|
+
touchHeartbeat();
|
|
1967
|
+
|
|
1968
|
+
try {
|
|
1969
|
+
const option = question.options[optionIndex];
|
|
1970
|
+
const optionText = getOptionLabel(option);
|
|
1971
|
+
const result = await callbacks.onOptionInsight(
|
|
1972
|
+
payload.questionId,
|
|
1973
|
+
option,
|
|
1974
|
+
payload.prompt.trim(),
|
|
1975
|
+
typeof payload.model === "string" ? payload.model : null,
|
|
1976
|
+
controller.signal,
|
|
1977
|
+
);
|
|
1978
|
+
sendJson(res, 200, { ok: true, optionText, ...result });
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
if (controller.signal.aborted) {
|
|
1981
|
+
sendJson(res, 409, { ok: false, error: "Request cancelled" });
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
const message = err instanceof Error ? err.message : "Option insight failed";
|
|
1985
|
+
sendJson(res, 500, { ok: false, error: message });
|
|
1986
|
+
}
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (method === "POST" && url.pathname === "/option-action") {
|
|
1991
|
+
const body = await parseBodyOrRespond();
|
|
1992
|
+
if (!body) return;
|
|
1993
|
+
if (!validateTokenBody(body, sessionToken, res)) return;
|
|
1994
|
+
if (completed) {
|
|
1995
|
+
sendJson(res, 409, { ok: false, error: "Session closed" });
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const payload = body as {
|
|
2000
|
+
questionId?: string;
|
|
2001
|
+
optionKey?: string;
|
|
2002
|
+
action?: string;
|
|
2003
|
+
text?: string;
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
if (typeof payload.questionId !== "string") {
|
|
2007
|
+
sendJson(res, 400, { ok: false, error: "Missing questionId" });
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (typeof payload.optionKey !== "string") {
|
|
2011
|
+
sendJson(res, 400, { ok: false, error: "Missing optionKey" });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
touchHeartbeat();
|
|
2015
|
+
|
|
2016
|
+
const questionCheck = ensureQuestionId(payload.questionId, questionById);
|
|
2017
|
+
if (questionCheck.ok === false) {
|
|
2018
|
+
sendJson(res, 400, { ok: false, error: questionCheck.error });
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
const question = questionCheck.question;
|
|
2022
|
+
if ((question.type !== "single" && question.type !== "multi") || !question.options) {
|
|
2023
|
+
sendJson(res, 400, { ok: false, error: "Invalid question for option actions" });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
|
|
2028
|
+
if (optionIndex === -1) {
|
|
2029
|
+
sendJson(res, 400, { ok: false, error: "Invalid option for action" });
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
const currentOptions = [...question.options];
|
|
2034
|
+
const currentKeys = [...(optionKeysByQuestion[question.id] ?? [])];
|
|
2035
|
+
const action = payload.action;
|
|
2036
|
+
const rawText = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
2037
|
+
|
|
2038
|
+
if (action === "move-up") {
|
|
2039
|
+
if (optionIndex === 0) {
|
|
2040
|
+
sendJson(res, 400, { ok: false, error: "Option is already at the top" });
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
[currentOptions[optionIndex - 1], currentOptions[optionIndex]] = [currentOptions[optionIndex], currentOptions[optionIndex - 1]];
|
|
2044
|
+
[currentKeys[optionIndex - 1], currentKeys[optionIndex]] = [currentKeys[optionIndex], currentKeys[optionIndex - 1]];
|
|
2045
|
+
} else if (action === "replace-text") {
|
|
2046
|
+
if (!rawText) {
|
|
2047
|
+
sendJson(res, 400, { ok: false, error: "Replacement text is required" });
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
const duplicateIndex = currentOptions.findIndex((option, index) => index !== optionIndex && getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase());
|
|
2051
|
+
if (duplicateIndex !== -1) {
|
|
2052
|
+
sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const previousText = getOptionLabel(currentOptions[optionIndex]);
|
|
2056
|
+
currentOptions[optionIndex] = setOptionLabel(currentOptions[optionIndex], rawText);
|
|
2057
|
+
if (question.type === "single" && question.recommended === previousText) {
|
|
2058
|
+
question.recommended = rawText;
|
|
2059
|
+
} else if (question.type === "multi" && Array.isArray(question.recommended)) {
|
|
2060
|
+
question.recommended = question.recommended.map((option) => option === previousText ? rawText : option);
|
|
2061
|
+
}
|
|
2062
|
+
} else if (action === "add-option") {
|
|
2063
|
+
if (!rawText) {
|
|
2064
|
+
sendJson(res, 400, { ok: false, error: "New option text is required" });
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (currentOptions.some((option) => getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase())) {
|
|
2068
|
+
sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
const newOptionKey = makeOptionKey();
|
|
2072
|
+
currentOptions.splice(optionIndex + 1, 0, rawText);
|
|
2073
|
+
currentKeys.splice(optionIndex + 1, 0, newOptionKey);
|
|
2074
|
+
} else {
|
|
2075
|
+
sendJson(res, 400, { ok: false, error: "Unsupported option action" });
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
question.options = currentOptions;
|
|
2080
|
+
optionKeysByQuestion[question.id] = currentKeys;
|
|
2081
|
+
syncRecommendations(question, currentOptions);
|
|
2082
|
+
|
|
2083
|
+
sendJson(res, 200, {
|
|
2084
|
+
ok: true,
|
|
2085
|
+
question,
|
|
2086
|
+
optionKeys: currentKeys,
|
|
2087
|
+
});
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1415
2091
|
sendText(res, 404, "Not found");
|
|
1416
2092
|
} catch (err) {
|
|
1417
2093
|
const message = err instanceof Error ? err.message : "Server error";
|