pi-interview 0.6.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,15 @@ 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
+ depth: string,
254
+ signal: AbortSignal,
255
+ ) => Promise<OptionInsightResult>;
211
256
  }
212
257
 
213
258
  export interface InterviewServerHandle {
@@ -387,11 +432,12 @@ function ensureQuestionId(
387
432
  return { ok: true, question };
388
433
  }
389
434
 
390
- function syncRecommendations(question: Question, options: string[]): void {
435
+ function syncRecommendations(question: Question, options: OptionValue[]): void {
436
+ const optionLabels = options.map((option) => getOptionLabel(option));
391
437
  if (!question.recommended) return;
392
438
 
393
439
  if (question.type === "single") {
394
- if (typeof question.recommended === "string" && options.includes(question.recommended)) {
440
+ if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
395
441
  return;
396
442
  }
397
443
  delete question.recommended;
@@ -407,7 +453,7 @@ function syncRecommendations(question: Question, options: string[]): void {
407
453
 
408
454
  const nextRecommended = (Array.isArray(question.recommended)
409
455
  ? question.recommended
410
- : [question.recommended]).filter((option) => options.includes(option));
456
+ : [question.recommended]).filter((option) => optionLabels.includes(option));
411
457
  if (nextRecommended.length === 0) {
412
458
  delete question.recommended;
413
459
  delete question.conviction;
@@ -416,6 +462,246 @@ function syncRecommendations(question: Question, options: string[]): void {
416
462
  question.recommended = nextRecommended;
417
463
  }
418
464
 
465
+ function makeOptionKey(): string {
466
+ return `opt-${randomUUID()}`;
467
+ }
468
+
469
+ function buildOptionKeysByQuestion(
470
+ questionsList: Question[],
471
+ initial: Record<string, string[]> | undefined,
472
+ ): Record<string, string[]> {
473
+ const next: Record<string, string[]> = {};
474
+ for (const question of questionsList) {
475
+ if (question.type !== "single" && question.type !== "multi") continue;
476
+ if (!question.options) continue;
477
+ const existing = initial?.[question.id];
478
+ if (Array.isArray(existing) && existing.length === question.options.length && existing.every((key) => typeof key === "string" && key.trim().length > 0)) {
479
+ next[question.id] = [...existing];
480
+ continue;
481
+ }
482
+ next[question.id] = question.options.map(() => makeOptionKey());
483
+ }
484
+ return next;
485
+ }
486
+
487
+ function getOptionIndexByKey(
488
+ question: Question,
489
+ optionKeysByQuestion: Record<string, string[]>,
490
+ optionKey: string,
491
+ ): number {
492
+ if ((question.type !== "single" && question.type !== "multi") || !question.options) {
493
+ return -1;
494
+ }
495
+ const keys = optionKeysByQuestion[question.id] ?? [];
496
+ return keys.indexOf(optionKey);
497
+ }
498
+
499
+ function setOptionLabel(option: OptionValue, label: string): OptionValue {
500
+ return typeof option === "string" ? label : { ...option, label };
501
+ }
502
+
503
+ function isChoiceResponseValue(value: unknown): value is ChoiceResponseValue {
504
+ return !!value && typeof value === "object" && !Array.isArray(value) && typeof (value as ChoiceResponseValue).option === "string";
505
+ }
506
+
507
+ function normalizeChoiceResponseValue(value: unknown): ChoiceResponseValue | null {
508
+ if (!isChoiceResponseValue(value)) {
509
+ return null;
510
+ }
511
+ const option = value.option.trim();
512
+ if (!option) {
513
+ return null;
514
+ }
515
+ const note = typeof value.note === "string" ? value.note.trim() : "";
516
+ return note ? { option, note } : { option };
517
+ }
518
+
519
+ function cloneResponseValue(value: ResponseValue): ResponseValue {
520
+ if (Array.isArray(value)) {
521
+ return value.map((item) => isChoiceResponseValue(item)
522
+ ? { ...item }
523
+ : item);
524
+ }
525
+ if (isChoiceResponseValue(value)) {
526
+ return { ...value };
527
+ }
528
+ return value;
529
+ }
530
+
531
+ function normalizeResponseItem(
532
+ question: Question,
533
+ item: { id?: unknown; value?: unknown; attachments?: unknown },
534
+ ): { ok: true; response: ResponseItem } | { ok: false; error: string } {
535
+ const response: ResponseItem = { id: question.id, value: "" };
536
+
537
+ if (question.type === "image") {
538
+ if (Array.isArray(item.value) && item.value.every((value) => typeof value === "string")) {
539
+ response.value = item.value;
540
+ }
541
+ } else if (question.type === "single") {
542
+ if (item.value === "") {
543
+ response.value = "";
544
+ } else {
545
+ const value = normalizeChoiceResponseValue(item.value);
546
+ if (!value) {
547
+ return { ok: false, error: `Invalid response value for ${question.id}` };
548
+ }
549
+ response.value = value;
550
+ }
551
+ } else if (question.type === "multi") {
552
+ if (!Array.isArray(item.value)) {
553
+ return { ok: false, error: `Invalid response value for ${question.id}` };
554
+ }
555
+ const values: ChoiceResponseValue[] = [];
556
+ for (const value of item.value) {
557
+ const normalized = normalizeChoiceResponseValue(value);
558
+ if (!normalized) {
559
+ return { ok: false, error: `Invalid response value for ${question.id}` };
560
+ }
561
+ values.push(normalized);
562
+ }
563
+ response.value = values;
564
+ } else {
565
+ if (typeof item.value !== "string") {
566
+ return { ok: false, error: `Invalid response value for ${question.id}` };
567
+ }
568
+ response.value = item.value;
569
+ }
570
+
571
+ if (Array.isArray(item.attachments) && item.attachments.every((attachment) => typeof attachment === "string")) {
572
+ response.attachments = item.attachments;
573
+ }
574
+
575
+ return { ok: true, response };
576
+ }
577
+
578
+ function normalizeResponseItems(
579
+ responsesInput: unknown[],
580
+ questionById: Map<string, Question>,
581
+ ): { ok: true; responses: ResponseItem[] } | { ok: false; field?: string; error: string } {
582
+ const responses: ResponseItem[] = [];
583
+ for (const item of responsesInput) {
584
+ if (!item || typeof item !== "object" || typeof (item as { id?: unknown }).id !== "string") continue;
585
+ const typedItem = item as { id: string; value?: unknown; attachments?: unknown };
586
+ const questionCheck = ensureQuestionId(typedItem.id, questionById);
587
+ if (questionCheck.ok === false) {
588
+ return { ok: false, error: questionCheck.error, field: typedItem.id };
589
+ }
590
+ const normalized = normalizeResponseItem(questionCheck.question, typedItem);
591
+ if (normalized.ok === false) {
592
+ return { ok: false, error: normalized.error, field: typedItem.id };
593
+ }
594
+ responses.push(normalized.response);
595
+ }
596
+ return { ok: true, responses };
597
+ }
598
+
599
+ function normalizeGeneratedOptionValues(options: OptionValue[]): OptionValue[] {
600
+ const uniqueOptions: OptionValue[] = [];
601
+ const indexByLabel = new Map<string, number>();
602
+ for (const option of options) {
603
+ const label = getOptionLabel(option).trim();
604
+ if (!label) continue;
605
+ const normalizedOption = setOptionLabel(option, label);
606
+ const labelKey = label.toLowerCase();
607
+ const existingIndex = indexByLabel.get(labelKey);
608
+ if (existingIndex === undefined) {
609
+ indexByLabel.set(labelKey, uniqueOptions.length);
610
+ uniqueOptions.push(normalizedOption);
611
+ continue;
612
+ }
613
+ if (typeof uniqueOptions[existingIndex] === "string" && typeof normalizedOption !== "string") {
614
+ uniqueOptions[existingIndex] = normalizedOption;
615
+ }
616
+ }
617
+ return uniqueOptions;
618
+ }
619
+
620
+ function reconcileOptionKeysByLabel(
621
+ previousOptions: OptionValue[],
622
+ previousKeys: string[],
623
+ nextOptions: OptionValue[],
624
+ ): string[] {
625
+ const availableKeysByLabel = new Map<string, string[]>();
626
+ for (let index = 0; index < previousOptions.length; index++) {
627
+ const key = previousKeys[index];
628
+ if (!key) continue;
629
+ const label = getOptionLabel(previousOptions[index]).trim();
630
+ const existing = availableKeysByLabel.get(label);
631
+ if (existing) {
632
+ existing.push(key);
633
+ continue;
634
+ }
635
+ availableKeysByLabel.set(label, [key]);
636
+ }
637
+
638
+ return nextOptions.map((option) => {
639
+ const matchingKeys = availableKeysByLabel.get(getOptionLabel(option).trim());
640
+ if (!matchingKeys || matchingKeys.length === 0) {
641
+ return makeOptionKey();
642
+ }
643
+ return matchingKeys.shift() ?? makeOptionKey();
644
+ });
645
+ }
646
+
647
+ function normalizeSavedOptionInsights(input: unknown): SavedOptionInsight[] {
648
+ if (!Array.isArray(input)) return [];
649
+ const normalized: SavedOptionInsight[] = [];
650
+ for (const item of input) {
651
+ if (!item || typeof item !== "object") continue;
652
+ const raw = item as Record<string, unknown>;
653
+ if (
654
+ typeof raw.id !== "string" ||
655
+ typeof raw.questionId !== "string" ||
656
+ typeof raw.optionKey !== "string" ||
657
+ typeof raw.optionText !== "string" ||
658
+ typeof raw.prompt !== "string" ||
659
+ typeof raw.summary !== "string"
660
+ ) {
661
+ continue;
662
+ }
663
+ const bullets = Array.isArray(raw.bullets)
664
+ ? raw.bullets.filter((bullet): bullet is string => typeof bullet === "string" && bullet.trim().length > 0)
665
+ : undefined;
666
+ normalized.push({
667
+ id: raw.id,
668
+ questionId: raw.questionId,
669
+ optionKey: raw.optionKey,
670
+ optionText: raw.optionText,
671
+ prompt: raw.prompt,
672
+ summary: raw.summary,
673
+ bullets: bullets && bullets.length > 0 ? bullets : undefined,
674
+ suggestedText: typeof raw.suggestedText === "string" ? raw.suggestedText : undefined,
675
+ modelUsed: typeof raw.modelUsed === "string" ? raw.modelUsed : raw.modelUsed === null ? null : undefined,
676
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : undefined,
677
+ });
678
+ }
679
+ return normalized;
680
+ }
681
+
682
+ function renderSavedOptionInsightsHtml(insights: SavedOptionInsight[]): string {
683
+ if (insights.length === 0) return "";
684
+ return `<div class="saved-option-insights">${insights.map((insight) => {
685
+ const bulletsHtml = insight.bullets && insight.bullets.length > 0
686
+ ? `<ul>${insight.bullets.map((bullet) => `<li>${escapeHtml(bullet)}</li>`).join("")}</ul>`
687
+ : "";
688
+ const suggestionHtml = insight.suggestedText
689
+ ? `<div class="saved-option-insight-suggestion"><span>Suggested rewrite</span><code>${escapeHtml(insight.suggestedText)}</code></div>`
690
+ : "";
691
+ const metaParts = [
692
+ insight.optionText ? `Option: ${escapeHtml(insight.optionText)}` : "",
693
+ insight.modelUsed ? `Model: ${escapeHtml(insight.modelUsed)}` : "",
694
+ ].filter(Boolean);
695
+ return `<article class="saved-option-insight">
696
+ <div class="saved-option-insight-prompt">${escapeHtml(insight.prompt)}</div>
697
+ ${metaParts.length > 0 ? `<div class="saved-option-insight-meta">${metaParts.join(" · ")}</div>` : ""}
698
+ <p>${escapeHtml(insight.summary)}</p>
699
+ ${bulletsHtml}
700
+ ${suggestionHtml}
701
+ </article>`;
702
+ }).join("")}</div>`;
703
+ }
704
+
419
705
  // HTML generation for saved interviews
420
706
  interface SavedFromMeta {
421
707
  cwd: string;
@@ -645,6 +931,13 @@ function savedAnswerItemHtml(text: string, q: Question): string {
645
931
  return escapeHtml(text) + indicator;
646
932
  }
647
933
 
934
+ function savedChoiceAnswerHtml(value: ChoiceResponseValue, question: Question): string {
935
+ const noteHtml = value.note
936
+ ? `<div class="saved-answer-note">${escapeHtml(value.note)}</div>`
937
+ : "";
938
+ return `<div class="saved-answer-choice">${savedAnswerItemHtml(value.option, question)}${noteHtml}</div>`;
939
+ }
940
+
648
941
  function weightClasses(q: Question): string {
649
942
  const classes = ["saved-question"];
650
943
  if (q.type === "info") classes.push("info-panel");
@@ -683,7 +976,11 @@ async function copyMediaImages(questionsList: Question[], imagesDir: string, cwd
683
976
  return rewritten;
684
977
  }
685
978
 
686
- function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[]): string {
979
+ function renderQuestionsHtml(
980
+ questionsList: Question[],
981
+ answers: ResponseItem[],
982
+ optionInsights: SavedOptionInsight[],
983
+ ): string {
687
984
  const answerMap = new Map(answers.map((a) => [a.id, a]));
688
985
  let questionNum = 0;
689
986
  return questionsList
@@ -692,6 +989,9 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
692
989
  if (showNumber) questionNum++;
693
990
  const numPrefix = showNumber ? `${questionNum}. ` : "";
694
991
  const mediaHtml = renderMediaListHtml(q.media);
992
+ const optionInsightsHtml = renderSavedOptionInsightsHtml(
993
+ optionInsights.filter((insight) => insight.questionId === q.id),
994
+ );
695
995
 
696
996
  if (q.type === "info") {
697
997
  const contentHtml = renderContentBlockHtml(q.content);
@@ -701,6 +1001,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
701
1001
  ${q.context ? `<p class="question-context">${escapeHtml(q.context)}</p>` : ""}
702
1002
  ${contentHtml}
703
1003
  ${mediaHtml}
1004
+ ${optionInsightsHtml}
704
1005
  </div>
705
1006
  `;
706
1007
  }
@@ -718,10 +1019,17 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
718
1019
  .map((p) => `<img src="${escapeHtml(p)}" alt="uploaded image">`)
719
1020
  .join("")}</div>`;
720
1021
  } else if (q.type === "multi") {
721
- const items = Array.isArray(value) ? value : [value];
1022
+ const items = Array.isArray(value) ? value.filter(isChoiceResponseValue) : [];
722
1023
  answerHtml = `<div class="saved-answer"><ul>${items
723
- .map((v) => `<li>${savedAnswerItemHtml(String(v), q)}</li>`)
1024
+ .map((item) => `<li>${savedChoiceAnswerHtml(item, q)}</li>`)
724
1025
  .join("")}</ul></div>`;
1026
+ if (items.length === 0) {
1027
+ answerHtml = '<div class="saved-answer empty">(no answer)</div>';
1028
+ }
1029
+ } else if (q.type === "single") {
1030
+ answerHtml = isChoiceResponseValue(value)
1031
+ ? `<div class="saved-answer">${savedChoiceAnswerHtml(value, q)}</div>`
1032
+ : '<div class="saved-answer empty">(no answer)</div>';
725
1033
  } else {
726
1034
  answerHtml = `<div class="saved-answer">${savedAnswerItemHtml(String(value), q)}</div>`;
727
1035
  }
@@ -745,6 +1053,7 @@ function renderQuestionsHtml(questionsList: Question[], answers: ResponseItem[])
745
1053
  ${contextHtml}
746
1054
  ${contentHtml}
747
1055
  ${mediaHtml}
1056
+ ${optionInsightsHtml}
748
1057
  ${answerHtml}
749
1058
  ${attachHtml}
750
1059
  </div>
@@ -818,6 +1127,55 @@ const SAVED_VIEW_STYLES = `
818
1127
  border-radius: var(--radius);
819
1128
  white-space: pre-wrap;
820
1129
  }
1130
+ .saved-option-insights {
1131
+ display: grid;
1132
+ gap: 10px;
1133
+ margin: 14px 0;
1134
+ }
1135
+ .saved-option-insight {
1136
+ border: 1px solid var(--border-muted);
1137
+ border-radius: 12px;
1138
+ padding: 12px;
1139
+ background: color-mix(in srgb, var(--bg-body) 82%, transparent);
1140
+ }
1141
+ .saved-option-insight-prompt {
1142
+ font-family: var(--font-mono);
1143
+ font-size: 11px;
1144
+ color: var(--accent);
1145
+ margin-bottom: 4px;
1146
+ }
1147
+ .saved-option-insight-meta {
1148
+ font-size: 11px;
1149
+ color: var(--fg-muted);
1150
+ margin-bottom: 8px;
1151
+ }
1152
+ .saved-option-insight p {
1153
+ margin: 0;
1154
+ }
1155
+ .saved-option-insight ul {
1156
+ margin: 8px 0 0;
1157
+ padding-left: 18px;
1158
+ }
1159
+ .saved-option-insight-suggestion {
1160
+ margin-top: 10px;
1161
+ display: grid;
1162
+ gap: 4px;
1163
+ }
1164
+ .saved-option-insight-suggestion span {
1165
+ font-size: 10px;
1166
+ text-transform: uppercase;
1167
+ letter-spacing: 0.06em;
1168
+ color: var(--fg-muted);
1169
+ }
1170
+ .saved-option-insight-suggestion code {
1171
+ display: block;
1172
+ padding: 8px 10px;
1173
+ border-radius: 8px;
1174
+ background: var(--bg-body);
1175
+ font-family: var(--font-mono);
1176
+ white-space: pre-wrap;
1177
+ overflow-wrap: anywhere;
1178
+ }
821
1179
  .saved-answer.empty {
822
1180
  color: var(--fg-dim);
823
1181
  font-style: italic;
@@ -826,6 +1184,14 @@ const SAVED_VIEW_STYLES = `
826
1184
  margin: 0;
827
1185
  padding-left: 20px;
828
1186
  }
1187
+ .saved-answer-choice {
1188
+ display: grid;
1189
+ gap: 4px;
1190
+ }
1191
+ .saved-answer-note {
1192
+ color: var(--fg-muted);
1193
+ font-size: 12px;
1194
+ }
829
1195
  .saved-images, .saved-attachments {
830
1196
  margin-top: 12px;
831
1197
  display: flex;
@@ -869,11 +1235,13 @@ const SAVED_VIEW_STYLES = `
869
1235
  function generateSavedHtml(options: {
870
1236
  questions: QuestionsFile;
871
1237
  answers: ResponseItem[];
1238
+ optionInsights: SavedOptionInsight[];
1239
+ optionKeysByQuestion: Record<string, string[]>;
872
1240
  meta: SavedInterviewMeta;
873
1241
  baseStyles: string;
874
1242
  themeCss: string;
875
1243
  }): string {
876
- const { questions: questionsData, answers, meta, baseStyles, themeCss } = options;
1244
+ const { questions: questionsData, answers, optionInsights, optionKeysByQuestion, meta, baseStyles, themeCss } = options;
877
1245
  const title = questionsData.title || "Interview";
878
1246
 
879
1247
  // Build the data object for embedding
@@ -882,13 +1250,15 @@ function generateSavedHtml(options: {
882
1250
  description: questionsData.description,
883
1251
  questions: questionsData.questions,
884
1252
  savedAnswers: answers,
1253
+ savedOptionInsights: optionInsights,
1254
+ optionKeysByQuestion,
885
1255
  savedAt: meta.savedAt,
886
1256
  wasSubmitted: meta.wasSubmitted,
887
1257
  savedFrom: meta.savedFrom,
888
1258
  };
889
1259
 
890
1260
  const embeddedJson = safeInlineJSON(dataForEmbedding);
891
- const questionsHtml = renderQuestionsHtml(questionsData.questions, answers);
1261
+ const questionsHtml = renderQuestionsHtml(questionsData.questions, answers, optionInsights);
892
1262
  const savedDate = new Date(meta.savedAt).toLocaleString();
893
1263
  const statusClass = meta.wasSubmitted ? "submitted" : "draft";
894
1264
  const statusText = meta.wasSubmitted ? "Submitted" : "Draft";
@@ -936,6 +1306,8 @@ export async function startInterviewServer(
936
1306
  for (const question of questions.questions) {
937
1307
  questionById.set(question.id, question);
938
1308
  }
1309
+ const optionKeysByQuestion = buildOptionKeysByQuestion(questions.questions, options.optionKeysByQuestion);
1310
+ const initialSavedOptionInsights = normalizeSavedOptionInsights(options.savedOptionInsights);
939
1311
 
940
1312
  function getMediaList(q: Question): MediaBlock[] {
941
1313
  if (!q.media) return [];
@@ -1065,8 +1437,12 @@ export async function startInterviewServer(
1065
1437
  toggleHotkey: themeConfig.toggleHotkey,
1066
1438
  },
1067
1439
  savedAnswers: options.savedAnswers,
1440
+ savedOptionInsights: initialSavedOptionInsights,
1441
+ optionKeysByQuestion,
1068
1442
  autoSaveOnSubmit: options.autoSaveOnSubmit ?? true,
1069
1443
  canGenerate: options.canGenerate ?? false,
1444
+ askModels: options.askModels ?? [],
1445
+ defaultAskModel: options.defaultAskModel ?? null,
1070
1446
  });
1071
1447
  const html = TEMPLATE
1072
1448
  .replace("<!-- __CDN_SCRIPTS__ -->", cdnScripts)
@@ -1228,7 +1604,7 @@ export async function startInterviewServer(
1228
1604
  }
1229
1605
 
1230
1606
  const payload = body as {
1231
- responses?: Array<{ id: string; value: string | string[]; attachments?: string[] }>;
1607
+ responses?: unknown[];
1232
1608
  images?: Array<{ id: string; filename: string; mimeType: string; data: string; isAttachment?: boolean }>;
1233
1609
  };
1234
1610
 
@@ -1240,50 +1616,16 @@ export async function startInterviewServer(
1240
1616
  return;
1241
1617
  }
1242
1618
 
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);
1619
+ const normalizedResponses = normalizeResponseItems(responsesInput, questionById);
1620
+ if (normalizedResponses.ok === false) {
1621
+ sendJson(res, 400, {
1622
+ ok: false,
1623
+ error: normalizedResponses.error,
1624
+ field: normalizedResponses.field,
1625
+ });
1626
+ return;
1286
1627
  }
1628
+ const responses = normalizedResponses.responses;
1287
1629
 
1288
1630
  for (const image of imagesInput) {
1289
1631
  if (!image || typeof image.id !== "string") continue;
@@ -1356,7 +1698,8 @@ export async function startInterviewServer(
1356
1698
  // Note: don't check `completed` - allow save after submit
1357
1699
 
1358
1700
  const payload = body as {
1359
- responses?: ResponseItem[];
1701
+ responses?: unknown[];
1702
+ savedOptionInsights?: SavedOptionInsight[];
1360
1703
  images?: Array<{
1361
1704
  id: string;
1362
1705
  filename: string;
@@ -1368,8 +1711,18 @@ export async function startInterviewServer(
1368
1711
  };
1369
1712
 
1370
1713
  const responsesInput = Array.isArray(payload.responses) ? payload.responses : [];
1714
+ const savedOptionInsights = normalizeSavedOptionInsights(payload.savedOptionInsights);
1371
1715
  const imagesInput = Array.isArray(payload.images) ? payload.images : [];
1372
1716
  const submitted = payload.submitted === true;
1717
+ const normalizedResponses = normalizeResponseItems(responsesInput, questionById);
1718
+ if (normalizedResponses.ok === false) {
1719
+ sendJson(res, 400, {
1720
+ ok: false,
1721
+ error: normalizedResponses.error,
1722
+ field: normalizedResponses.field,
1723
+ });
1724
+ return;
1725
+ }
1373
1726
 
1374
1727
  const snapshotBaseDir = options.snapshotDir ?? SNAPSHOTS_DIR;
1375
1728
 
@@ -1389,10 +1742,10 @@ export async function startInterviewServer(
1389
1742
  await mkdir(snapshotPath, { recursive: true });
1390
1743
 
1391
1744
  // 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,
1745
+ const savedResponses: ResponseItem[] = normalizedResponses.responses.map((response) => ({
1746
+ ...response,
1747
+ value: cloneResponseValue(response.value),
1748
+ attachments: response.attachments ? [...response.attachments] : undefined,
1396
1749
  }));
1397
1750
 
1398
1751
  // Process uploaded images - save to images/ subfolder
@@ -1451,6 +1804,8 @@ export async function startInterviewServer(
1451
1804
  const html = generateSavedHtml({
1452
1805
  questions: snapshotQuestions,
1453
1806
  answers: savedResponses,
1807
+ optionInsights: savedOptionInsights,
1808
+ optionKeysByQuestion,
1454
1809
  meta,
1455
1810
  baseStyles: STYLES,
1456
1811
  themeCss,
@@ -1482,7 +1837,6 @@ export async function startInterviewServer(
1482
1837
 
1483
1838
  const payload = body as {
1484
1839
  questionId?: string;
1485
- existingOptions?: string[];
1486
1840
  mode?: string;
1487
1841
  };
1488
1842
 
@@ -1496,14 +1850,8 @@ export async function startInterviewServer(
1496
1850
  sendJson(res, 400, { ok: false, error: "Invalid question for generation" });
1497
1851
  return;
1498
1852
  }
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
1853
 
1504
- const existingOptions = Array.isArray(payload.existingOptions)
1505
- ? payload.existingOptions.filter((o): o is string => typeof o === "string")
1506
- : [];
1854
+ const existingOptions = question.options.map((option) => getOptionLabel(option).trim());
1507
1855
 
1508
1856
  const mode = payload.mode === "review" ? "review" : "add";
1509
1857
 
@@ -1521,34 +1869,40 @@ export async function startInterviewServer(
1521
1869
  mode,
1522
1870
  );
1523
1871
 
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
- }
1872
+ const uniqueOptions = normalizeGeneratedOptionValues(result.options);
1534
1873
 
1535
1874
  const reviewedQuestion = typeof result.question === "string" ? result.question.trim() : undefined;
1536
1875
  const storedQuestion = questions.questions.find((q) => q.id === payload.questionId);
1876
+ let nextOptionKeys = optionKeysByQuestion[payload.questionId] ?? [];
1877
+ let appliedOptions: OptionValue[] = [];
1537
1878
  if (storedQuestion) {
1538
1879
  if (mode === "review" && reviewedQuestion && uniqueOptions.length > 0) {
1880
+ const previousOptions = [...storedQuestion.options];
1881
+ const previousKeys = [...(optionKeysByQuestion[payload.questionId] ?? [])];
1539
1882
  storedQuestion.question = reviewedQuestion;
1540
1883
  storedQuestion.options = uniqueOptions;
1541
1884
  syncRecommendations(storedQuestion, uniqueOptions);
1885
+ nextOptionKeys = reconcileOptionKeysByLabel(previousOptions, previousKeys, uniqueOptions);
1886
+ optionKeysByQuestion[payload.questionId] = nextOptionKeys;
1887
+ appliedOptions = uniqueOptions;
1542
1888
  } 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()));
1889
+ const existingKeys = new Set(storedQuestion.options.map((option) => getOptionLabel(option).trim().toLowerCase()));
1890
+ const newOptions = uniqueOptions.filter((option) => !existingKeys.has(getOptionLabel(option).trim().toLowerCase()));
1545
1891
  if (newOptions.length > 0) {
1546
1892
  storedQuestion.options = storedQuestion.options.concat(newOptions);
1893
+ nextOptionKeys = (optionKeysByQuestion[payload.questionId] ?? []).concat(newOptions.map(() => makeOptionKey()));
1894
+ optionKeysByQuestion[payload.questionId] = nextOptionKeys;
1547
1895
  }
1896
+ appliedOptions = newOptions;
1548
1897
  }
1549
1898
  }
1550
1899
 
1551
- sendJson(res, 200, { ok: true, options: uniqueOptions, question: reviewedQuestion });
1900
+ sendJson(res, 200, {
1901
+ ok: true,
1902
+ options: appliedOptions,
1903
+ question: reviewedQuestion,
1904
+ optionKeys: nextOptionKeys,
1905
+ });
1552
1906
  } catch (err) {
1553
1907
  if (controller.signal.aborted) {
1554
1908
  sendJson(res, 409, { ok: false, error: "Request cancelled" });
@@ -1560,6 +1914,183 @@ export async function startInterviewServer(
1560
1914
  return;
1561
1915
  }
1562
1916
 
1917
+ if (method === "POST" && url.pathname === "/option-insight") {
1918
+ const body = await parseBodyOrRespond();
1919
+ if (!body) return;
1920
+ if (!validateTokenBody(body, sessionToken, res)) return;
1921
+ if (completed) {
1922
+ sendJson(res, 409, { ok: false, error: "Session closed" });
1923
+ return;
1924
+ }
1925
+
1926
+ if (!callbacks.onOptionInsight) {
1927
+ sendJson(res, 501, { ok: false, error: "Option insight not available" });
1928
+ return;
1929
+ }
1930
+
1931
+ const payload = body as {
1932
+ questionId?: string;
1933
+ optionKey?: string;
1934
+ prompt?: string;
1935
+ model?: string | null;
1936
+ depth?: string;
1937
+ };
1938
+
1939
+ if (typeof payload.questionId !== "string") {
1940
+ sendJson(res, 400, { ok: false, error: "Missing questionId" });
1941
+ return;
1942
+ }
1943
+ if (typeof payload.optionKey !== "string") {
1944
+ sendJson(res, 400, { ok: false, error: "Missing optionKey" });
1945
+ return;
1946
+ }
1947
+ if (typeof payload.prompt !== "string" || payload.prompt.trim().length === 0) {
1948
+ sendJson(res, 400, { ok: false, error: "Prompt is required" });
1949
+ return;
1950
+ }
1951
+
1952
+ const questionCheck = ensureQuestionId(payload.questionId, questionById);
1953
+ if (questionCheck.ok === false) {
1954
+ sendJson(res, 400, { ok: false, error: questionCheck.error });
1955
+ return;
1956
+ }
1957
+ const question = questionCheck.question;
1958
+ const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
1959
+ if (optionIndex === -1 || !question.options || optionIndex >= question.options.length) {
1960
+ sendJson(res, 400, { ok: false, error: "Invalid option for insight" });
1961
+ return;
1962
+ }
1963
+
1964
+ const controller = new AbortController();
1965
+ res.on("close", () => {
1966
+ if (!res.writableEnded) controller.abort();
1967
+ });
1968
+ touchHeartbeat();
1969
+
1970
+ try {
1971
+ const option = question.options[optionIndex];
1972
+ const optionText = getOptionLabel(option);
1973
+ const result = await callbacks.onOptionInsight(
1974
+ payload.questionId,
1975
+ option,
1976
+ payload.prompt.trim(),
1977
+ typeof payload.model === "string" ? payload.model : null,
1978
+ typeof payload.depth === "string" ? payload.depth : "standard",
1979
+ controller.signal,
1980
+ );
1981
+ sendJson(res, 200, { ok: true, optionText, ...result });
1982
+ } catch (err) {
1983
+ if (controller.signal.aborted) {
1984
+ sendJson(res, 409, { ok: false, error: "Request cancelled" });
1985
+ return;
1986
+ }
1987
+ const message = err instanceof Error ? err.message : "Option insight failed";
1988
+ sendJson(res, 500, { ok: false, error: message });
1989
+ }
1990
+ return;
1991
+ }
1992
+
1993
+ if (method === "POST" && url.pathname === "/option-action") {
1994
+ const body = await parseBodyOrRespond();
1995
+ if (!body) return;
1996
+ if (!validateTokenBody(body, sessionToken, res)) return;
1997
+ if (completed) {
1998
+ sendJson(res, 409, { ok: false, error: "Session closed" });
1999
+ return;
2000
+ }
2001
+
2002
+ const payload = body as {
2003
+ questionId?: string;
2004
+ optionKey?: string;
2005
+ action?: string;
2006
+ text?: string;
2007
+ };
2008
+
2009
+ if (typeof payload.questionId !== "string") {
2010
+ sendJson(res, 400, { ok: false, error: "Missing questionId" });
2011
+ return;
2012
+ }
2013
+ if (typeof payload.optionKey !== "string") {
2014
+ sendJson(res, 400, { ok: false, error: "Missing optionKey" });
2015
+ return;
2016
+ }
2017
+ touchHeartbeat();
2018
+
2019
+ const questionCheck = ensureQuestionId(payload.questionId, questionById);
2020
+ if (questionCheck.ok === false) {
2021
+ sendJson(res, 400, { ok: false, error: questionCheck.error });
2022
+ return;
2023
+ }
2024
+ const question = questionCheck.question;
2025
+ if ((question.type !== "single" && question.type !== "multi") || !question.options) {
2026
+ sendJson(res, 400, { ok: false, error: "Invalid question for option actions" });
2027
+ return;
2028
+ }
2029
+
2030
+ const optionIndex = getOptionIndexByKey(question, optionKeysByQuestion, payload.optionKey);
2031
+ if (optionIndex === -1) {
2032
+ sendJson(res, 400, { ok: false, error: "Invalid option for action" });
2033
+ return;
2034
+ }
2035
+
2036
+ const currentOptions = [...question.options];
2037
+ const currentKeys = [...(optionKeysByQuestion[question.id] ?? [])];
2038
+ const action = payload.action;
2039
+ const rawText = typeof payload.text === "string" ? payload.text.trim() : "";
2040
+
2041
+ if (action === "move-up") {
2042
+ if (optionIndex === 0) {
2043
+ sendJson(res, 400, { ok: false, error: "Option is already at the top" });
2044
+ return;
2045
+ }
2046
+ [currentOptions[optionIndex - 1], currentOptions[optionIndex]] = [currentOptions[optionIndex], currentOptions[optionIndex - 1]];
2047
+ [currentKeys[optionIndex - 1], currentKeys[optionIndex]] = [currentKeys[optionIndex], currentKeys[optionIndex - 1]];
2048
+ } else if (action === "replace-text") {
2049
+ if (!rawText) {
2050
+ sendJson(res, 400, { ok: false, error: "Replacement text is required" });
2051
+ return;
2052
+ }
2053
+ const duplicateIndex = currentOptions.findIndex((option, index) => index !== optionIndex && getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase());
2054
+ if (duplicateIndex !== -1) {
2055
+ sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
2056
+ return;
2057
+ }
2058
+ const previousText = getOptionLabel(currentOptions[optionIndex]);
2059
+ currentOptions[optionIndex] = setOptionLabel(currentOptions[optionIndex], rawText);
2060
+ if (question.type === "single" && question.recommended === previousText) {
2061
+ question.recommended = rawText;
2062
+ } else if (question.type === "multi" && Array.isArray(question.recommended)) {
2063
+ question.recommended = question.recommended.map((option) => option === previousText ? rawText : option);
2064
+ }
2065
+ } else if (action === "add-option") {
2066
+ if (!rawText) {
2067
+ sendJson(res, 400, { ok: false, error: "New option text is required" });
2068
+ return;
2069
+ }
2070
+ if (currentOptions.some((option) => getOptionLabel(option).trim().toLowerCase() === rawText.toLowerCase())) {
2071
+ sendJson(res, 400, { ok: false, error: "An option with that text already exists" });
2072
+ return;
2073
+ }
2074
+ const newOptionKey = makeOptionKey();
2075
+ currentOptions.splice(optionIndex + 1, 0, rawText);
2076
+ currentKeys.splice(optionIndex + 1, 0, newOptionKey);
2077
+ } else {
2078
+ sendJson(res, 400, { ok: false, error: "Unsupported option action" });
2079
+ return;
2080
+ }
2081
+
2082
+ question.options = currentOptions;
2083
+ optionKeysByQuestion[question.id] = currentKeys;
2084
+ syncRecommendations(question, currentOptions);
2085
+
2086
+ sendJson(res, 200, {
2087
+ ok: true,
2088
+ question,
2089
+ optionKeys: currentKeys,
2090
+ });
2091
+ return;
2092
+ }
2093
+
1563
2094
  sendText(res, 404, "Not found");
1564
2095
  } catch (err) {
1565
2096
  const message = err instanceof Error ? err.message : "Server error";