pi-interview 0.6.2 → 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;
@@ -645,6 +930,13 @@ function savedAnswerItemHtml(text: string, q: Question): string {
645
930
  return escapeHtml(text) + indicator;
646
931
  }
647
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
+
648
940
  function weightClasses(q: Question): string {
649
941
  const classes = ["saved-question"];
650
942
  if (q.type === "info") classes.push("info-panel");
@@ -683,7 +975,11 @@ async function copyMediaImages(questionsList: Question[], imagesDir: string, cwd
683
975
  return rewritten;
684
976
  }
685
977
 
686
- function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[]): string {
978
+ function renderQuestionsHtml(
979
+ questionsList: Question[],
980
+ answers: ResponseItem[],
981
+ optionInsights: SavedOptionInsight[],
982
+ ): string {
687
983
  const answerMap = new Map(answers.map((a) => [a.id, a]));
688
984
  let questionNum = 0;
689
985
  return questionsList
@@ -692,6 +988,9 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
692
988
  if (showNumber) questionNum++;
693
989
  const numPrefix = showNumber ? `${questionNum}. ` : "";
694
990
  const mediaHtml = renderMediaListHtml(q.media);
991
+ const optionInsightsHtml = renderSavedOptionInsightsHtml(
992
+ optionInsights.filter((insight) => insight.questionId === q.id),
993
+ );
695
994
 
696
995
  if (q.type === "info") {
697
996
  const contentHtml = renderContentBlockHtml(q.content);
@@ -701,6 +1000,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
701
1000
  ${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
702
1001
  ${contentHtml}
703
1002
  ${mediaHtml}
1003
+ ${optionInsightsHtml}
704
1004
  </div>
705
1005
  `;
706
1006
  }
@@ -718,10 +1018,17 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
718
1018
  .map((p) => `<img src="${escapeHtml(p)}" alt="uploaded image">`)
719
1019
  .join("")}</div>`;
720
1020
  } else if (q.type === "multi") {
721
- const items = Array.isArray(value) ? value : [value];
1021
+ const items = Array.isArray(value) ? value.filter(isChoiceResponseValue) : [];
722
1022
  answerHtml = `<div class="saved-answer"><ul>${items
723
- .map((v) => `<li>${savedAnswerItemHtml(String(v), q)}</li>`)
1023
+ .map((item) => `<li>${savedChoiceAnswerHtml(item, q)}</li>`)
724
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>';
725
1032
  } else {
726
1033
  answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
727
1034
  }
@@ -745,6 +1052,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
745
1052
  ${contextHtml}
746
1053
  ${contentHtml}
747
1054
  ${mediaHtml}
1055
+ ${optionInsightsHtml}
748
1056
  ${answerHtml}
749
1057
  ${attachHtml}
750
1058
  </div>
@@ -818,6 +1126,55 @@ const SAVED_VIEW_STYLES = `
818
1126
  border-radius: var(--radius);
819
1127
  white-space: pre-wrap;
820
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
+ }
821
1178
  .saved-answer.empty {
822
1179
  color: var(--fg-dim);
823
1180
  font-style: italic;
@@ -826,6 +1183,14 @@ const SAVED_VIEW_STYLES = `
826
1183
  margin: 0;
827
1184
  padding-left: 20px;
828
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
+ }
829
1194
  .saved-images, .saved-attachments {
830
1195
  margin-top: 12px;
831
1196
  display: flex;
@@ -869,11 +1234,13 @@ const SAVED_VIEW_STYLES = `
869
1234
  function generateSavedHtml(options: {
870
1235
  questions: QuestionsFile;
871
1236
  answers: ResponseItem[];
1237
+ optionInsights: SavedOptionInsight[];
1238
+ optionKeysByQuestion: Record<string, string[]>;
872
1239
  meta: SavedInterviewMeta;
873
1240
  baseStyles: string;
874
1241
  themeCss: string;
875
1242
  }): string {
876
- const { questions: questionsData, answers, meta, baseStyles, themeCss } = options;
1243
+ const { questions: questionsData, answers, optionInsights, optionKeysByQuestion, meta, baseStyles, themeCss } = options;
877
1244
  const title = questionsData.title || "Interview";
878
1245
 
879
1246
  // Build the data object for embedding
@@ -882,13 +1249,15 @@ function generateSavedHtml(options: {
882
1249
  description: questionsData.description,
883
1250
  questions: questionsData.questions,
884
1251
  savedAnswers: answers,
1252
+ savedOptionInsights: optionInsights,
1253
+ optionKeysByQuestion,
885
1254
  savedAt: meta.savedAt,
886
1255
  wasSubmitted: meta.wasSubmitted,
887
1256
  savedFrom: meta.savedFrom,
888
1257
  };
889
1258
 
890
1259
  const embeddedJson = safeInlineJSON(dataForEmbedding);
891
- const questionsHtml = renderQuestionsHtml(questionsData.questions, answers);
1260
+ const questionsHtml = renderQuestionsHtml(questionsData.questions, answers, optionInsights);
892
1261
  const savedDate = new Date(meta.savedAt).toLocaleString();
893
1262
  const statusClass = meta.wasSubmitted ? "submitted" : "draft";
894
1263
  const statusText = meta.wasSubmitted ? "Submitted" : "Draft";
@@ -936,6 +1305,8 @@ export async function startInterviewServer(
936
1305
  for (const question of questions.questions) {
937
1306
  questionById.set(question.id, question);
938
1307
  }
1308
+ const optionKeysByQuestion = buildOptionKeysByQuestion(questions.questions, options.optionKeysByQuestion);
1309
+ const initialSavedOptionInsights = normalizeSavedOptionInsights(options.savedOptionInsights);
939
1310
 
940
1311
  function getMediaList(q: Question): MediaBlock[] {
941
1312
  if (!q.media) return [];
@@ -1065,8 +1436,12 @@ export async function startInterviewServer(
1065
1436
  toggleHotkey: themeConfig.toggleHotkey,
1066
1437
  },
1067
1438
  savedAnswers: options.savedAnswers,
1439
+ savedOptionInsights: initialSavedOptionInsights,
1440
+ optionKeysByQuestion,
1068
1441
  autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
1069
1442
  canGenerate: options.canGenerate ?? false,
1443
+ askModels: options.askModels ?? [],
1444
+ defaultAskModel: options.defaultAskModel ?? null,
1070
1445
  });
1071
1446
  const html = TEMPLATE
1072
1447
  .replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
@@ -1228,7 +1603,7 @@ export async function startInterviewServer(
1228
1603
  }
1229
1604
 
1230
1605
  const payload = body as {
1231
- responses?: Array<{ id: string; value: string | string[]; attachments?: string[] }>;
1606
+ responses?: unknown[];
1232
1607
  images?: Array<{ id: string; filename: string; mimeType: string; data: string; isAttachment?: boolean }>;
1233
1608
  };
1234
1609
 
@@ -1240,50 +1615,16 @@ export async function startInterviewServer(
1240
1615
  return;
1241
1616
  }
1242
1617
 
1243
- const responses: ResponseItem[] = [];
1244
- for (const item of responsesInput) {
1245
- if (!item || typeof item.id !== "string") continue;
1246
- const questionCheck = ensureQuestionId(item.id, questionById);
1247
- if (questionCheck.ok === false) {
1248
- sendJson(res, 400, { ok: false, error: questionCheck.error, field: item.id });
1249
- return;
1250
- }
1251
- const question = questionCheck.question;
1252
-
1253
- const resp: ResponseItem = { id: item.id, value: "" };
1254
-
1255
- if (question.type === "image") {
1256
- if (Array.isArray(item.value) && item.value.every((v) => typeof v === "string")) {
1257
- resp.value = item.value;
1258
- }
1259
- } else if (question.type === "multi") {
1260
- if (!Array.isArray(item.value) || item.value.some((v) => typeof v !== "string")) {
1261
- sendJson(res, 400, {
1262
- ok: false,
1263
- error: `Invalid response value for ${item.id}`,
1264
- field: item.id,
1265
- });
1266
- return;
1267
- }
1268
- resp.value = item.value;
1269
- } else {
1270
- if (typeof item.value !== "string") {
1271
- sendJson(res, 400, {
1272
- ok: false,
1273
- error: `Invalid response value for ${item.id}`,
1274
- field: item.id,
1275
- });
1276
- return;
1277
- }
1278
- resp.value = item.value;
1279
- }
1280
-
1281
- if (Array.isArray(item.attachments) && item.attachments.every((a) => typeof a === "string")) {
1282
- resp.attachments = item.attachments;
1283
- }
1284
-
1285
- 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;
1286
1626
  }
1627
+ const responses = normalizedResponses.responses;
1287
1628
 
1288
1629
  for (const image of imagesInput) {
1289
1630
  if (!image || typeof image.id !== "string") continue;
@@ -1356,7 +1697,8 @@ export async function startInterviewServer(
1356
1697
  // Note: don't check `completed` - allow save after submit
1357
1698
 
1358
1699
  const payload = body as {
1359
- responses?: ResponseItem[];
1700
+ responses?: unknown[];
1701
+ savedOptionInsights?: SavedOptionInsight[];
1360
1702
  images?: Array<{
1361
1703
  id: string;
1362
1704
  filename: string;
@@ -1368,8 +1710,18 @@ export async function startInterviewServer(
1368
1710
  };
1369
1711
 
1370
1712
  const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
1713
+ const savedOptionInsights = normalizeSavedOptionInsights(payload.savedOptionInsights);
1371
1714
  const imagesInput = Array.isArray(payload.images) ? payload.images : [];
1372
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
+ }
1373
1725
 
1374
1726
  const snapshotBaseDir = options.snapshotDir ?? SNAPSHOTS_DIR;
1375
1727
 
@@ -1389,10 +1741,10 @@ export async function startInterviewServer(
1389
1741
  await mkdir(snapshotPath, { recursive: true });
1390
1742
 
1391
1743
  // Process responses - make a deep copy to avoid mutating input
1392
- const savedResponses: ResponseItem[] = responsesInput.map((r) => ({
1393
- ...r,
1394
- value: Array.isArray(r.value) ? [...r.value] : r.value,
1395
- 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,
1396
1748
  }));
1397
1749
 
1398
1750
  // Process uploaded images - save to images/ subfolder
@@ -1451,6 +1803,8 @@ export async function startInterviewServer(
1451
1803
  const html = generateSavedHtml({
1452
1804
  questions: snapshotQuestions,
1453
1805
  answers: savedResponses,
1806
+ optionInsights: savedOptionInsights,
1807
+ optionKeysByQuestion,
1454
1808
  meta,
1455
1809
  baseStyles: STYLES,
1456
1810
  themeCss,
@@ -1482,7 +1836,6 @@ export async function startInterviewServer(
1482
1836
 
1483
1837
  const payload = body as {
1484
1838
  questionId?: string;
1485
- existingOptions?: string[];
1486
1839
  mode?: string;
1487
1840
  };
1488
1841
 
@@ -1496,14 +1849,8 @@ export async function startInterviewServer(
1496
1849
  sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
1497
1850
  return;
1498
1851
  }
1499
- if (question.options.some((option) => typeof option !== "string")) {
1500
- sendJson(res, 400, { ok: false, error: "Generation is not available for rich options" });
1501
- return;
1502
- }
1503
1852
 
1504
- const existingOptions = Array.isArray(payload.existingOptions)
1505
- ? payload.existingOptions.filter((o): o is string => typeof o === "string")
1506
- : [];
1853
+ const existingOptions = question.options.map((option) => getOptionLabel(option).trim());
1507
1854
 
1508
1855
  const mode = payload.mode === "review" ? "review" : "add";
1509
1856
 
@@ -1521,34 +1868,40 @@ export async function startInterviewServer(
1521
1868
  mode,
1522
1869
  );
1523
1870
 
1524
- const uniqueOptions: string[] = [];
1525
- const seenOptions = new Set<string>();
1526
- for (const option of result.options) {
1527
- const trimmed = option.trim();
1528
- if (!trimmed) continue;
1529
- const key = trimmed.toLowerCase();
1530
- if (seenOptions.has(key)) continue;
1531
- seenOptions.add(key);
1532
- uniqueOptions.push(trimmed);
1533
- }
1871
+ const uniqueOptions = normalizeGeneratedOptionValues(result.options);
1534
1872
 
1535
1873
  const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
1536
1874
  const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
1875
+ let nextOptionKeys = optionKeysByQuestion[payload.questionId] ?? [];
1876
+ let appliedOptions: OptionValue[] = [];
1537
1877
  if (storedQuestion) {
1538
1878
  if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
1879
+ const previousOptions = [...storedQuestion.options];
1880
+ const previousKeys = [...(optionKeysByQuestion[payload.questionId] ?? [])];
1539
1881
  storedQuestion.question = reviewedQuestion;
1540
1882
  storedQuestion.options = uniqueOptions;
1541
1883
  syncRecommendations(storedQuestion, uniqueOptions);
1884
+ nextOptionKeys = reconcileOptionKeysByLabel(previousOptions, previousKeys, uniqueOptions);
1885
+ optionKeysByQuestion[payload.questionId] = nextOptionKeys;
1886
+ appliedOptions = uniqueOptions;
1542
1887
  } else if (mode === "add") {
1543
- const existingKeys = new Set(existingOptions.map((option) => option.trim().toLowerCase()));
1544
- 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()));
1545
1890
  if (newOptions.length > 0) {
1546
1891
  storedQuestion.options = storedQuestion.options.concat(newOptions);
1892
+ nextOptionKeys = (optionKeysByQuestion[payload.questionId] ?? []).concat(newOptions.map(() => makeOptionKey()));
1893
+ optionKeysByQuestion[payload.questionId] = nextOptionKeys;
1547
1894
  }
1895
+ appliedOptions = newOptions;
1548
1896
  }
1549
1897
  }
1550
1898
 
1551
- 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
+ });
1552
1905
  } catch (err) {
1553
1906
  if (controller.signal.aborted) {
1554
1907
  sendJson(res, 409, { ok: false, error: "Request cancelled" });
@@ -1560,6 +1913,181 @@ export async function startInterviewServer(
1560
1913
  return;
1561
1914
  }
1562
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
+
1563
2091
  sendText(res, 404, "Not found");
1564
2092
  } catch (err) {
1565
2093
  const message = err instanceof Error ? err.message : "Server error";