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/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 { Question, QuestionsFile, MediaBlock } from "./schema.js";
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: string | string[];
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: string[]; question?: string }>;
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: string[]): void {
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" && options.includes(question.recommended)) {
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) => options.includes(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, "&quot;");
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(questionsList: Question[], answers: ResponseItem[]): string {
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 codeHtml = q.codeBlock
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
- ${codeHtml}
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 : [value];
1021
+ const items = Array.isArray(value) ? value.filter(isChoiceResponseValue) : [];
575
1022
  answerHtml = `<div class="saved-answer"><ul>${items
576
- .map((v) => `<li>${savedAnswerItemHtml(String(v), q)}</li>`)
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 codeHtml = q.codeBlock
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
- ${codeHtml}
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: auto;
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?: Array<{ id: string; value: string | string[]; attachments?: string[] }>;
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 responses: ResponseItem[] = [];
1096
- for (const item of responsesInput) {
1097
- if (!item || typeof item.id !== "string") continue;
1098
- const questionCheck = ensureQuestionId(item.id, questionById);
1099
- if (questionCheck.ok === false) {
1100
- sendJson(res, 400, { ok: false, error: questionCheck.error, field: item.id });
1101
- return;
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?: ResponseItem[];
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[] = responsesInput.map((r) => ({
1245
- ...r,
1246
- value: Array.isArray(r.value) ? [...r.value] : r.value,
1247
- attachments: r.attachments ? [...r.attachments] : undefined,
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 = Array.isArray(payload.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: 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
- }
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(existingOptions.map((option) => option.trim().toLowerCase()));
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, { ok: true, options: uniqueOptions, question: reviewedQuestion });
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";