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/README.md +5 -3
- package/form/script.js +1282 -327
- package/form/styles.css +465 -17
- package/index.ts +415 -62
- package/package.json +1 -1
- package/server.ts +612 -81
package/index.ts
CHANGED
|
@@ -8,8 +8,17 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { execSync, execFileSync } from "node:child_process";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
startInterviewServer,
|
|
13
|
+
getActiveSessions,
|
|
14
|
+
type ChoiceResponseValue,
|
|
15
|
+
type ResponseItem,
|
|
16
|
+
type InterviewServerCallbacks,
|
|
17
|
+
type SavedOptionInsight,
|
|
18
|
+
type AskModelOption,
|
|
19
|
+
type OptionInsightResult,
|
|
20
|
+
} from "./server.js";
|
|
21
|
+
import { getOptionLabel, isRichOption, validateQuestions, sanitizeLLMJSON, type OptionValue, type QuestionsFile } from "./schema.js";
|
|
13
22
|
import { loadSettings, type InterviewThemeSettings } from "./settings.js";
|
|
14
23
|
|
|
15
24
|
interface GlimpseWindow {
|
|
@@ -125,6 +134,8 @@ interface SavedFromMeta {
|
|
|
125
134
|
|
|
126
135
|
interface SavedQuestionsFile extends QuestionsFile {
|
|
127
136
|
savedAnswers?: ResponseItem[];
|
|
137
|
+
savedOptionInsights?: SavedOptionInsight[];
|
|
138
|
+
optionKeysByQuestion?: Record<string, string[]>;
|
|
128
139
|
savedAt?: string;
|
|
129
140
|
wasSubmitted?: boolean;
|
|
130
141
|
savedFrom?: SavedFromMeta;
|
|
@@ -249,6 +260,9 @@ const GENERATE_OPTIONS_SYSTEM_PROMPT =
|
|
|
249
260
|
const REVIEW_QUESTION_SYSTEM_PROMPT =
|
|
250
261
|
"You review interview questions and answer options. Preserve intent. Return only JSON with a rewritten question string and an options array.";
|
|
251
262
|
|
|
263
|
+
const OPTION_INSIGHT_SYSTEM_PROMPT =
|
|
264
|
+
"You analyze a single interview answer option. Return only JSON with this shape: {\"summary\":\"...\",\"bullets\":[\"...\"],\"suggestedText\":\"...\"}. Keep summary concise, bullets short, and omit suggestedText when no rewrite is needed.";
|
|
265
|
+
|
|
252
266
|
function formatModelRef(model: GenerateModelCandidate): string {
|
|
253
267
|
return `${model.provider}/${model.id}`;
|
|
254
268
|
}
|
|
@@ -288,6 +302,37 @@ export function selectGenerateModels<T extends GenerateModelCandidate>(
|
|
|
288
302
|
return { primary: availableModels[0] ?? null, fallback: null };
|
|
289
303
|
}
|
|
290
304
|
|
|
305
|
+
export function buildAskModelsData(
|
|
306
|
+
availableModels: Model<Api>[],
|
|
307
|
+
currentModel: Model<Api> | null,
|
|
308
|
+
primaryModel: Model<Api> | null,
|
|
309
|
+
fallbackModel: Model<Api> | null,
|
|
310
|
+
): AskModelOption[] {
|
|
311
|
+
const models: AskModelOption[] = [];
|
|
312
|
+
const seen = new Set<string>();
|
|
313
|
+
const addModel = (model: Model<Api> | null) => {
|
|
314
|
+
if (!model) return;
|
|
315
|
+
const value = `${model.provider}/${model.id}`;
|
|
316
|
+
if (seen.has(value)) return;
|
|
317
|
+
seen.add(value);
|
|
318
|
+
models.push({
|
|
319
|
+
value,
|
|
320
|
+
provider: model.provider,
|
|
321
|
+
label: model.id,
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
addModel(currentModel);
|
|
326
|
+
addModel(primaryModel);
|
|
327
|
+
addModel(fallbackModel);
|
|
328
|
+
for (const modelRef of PREFERRED_GENERATE_MODELS) {
|
|
329
|
+
const preferredModel = findModelByRef(availableModels, modelRef);
|
|
330
|
+
addModel(preferredModel);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return models;
|
|
334
|
+
}
|
|
335
|
+
|
|
291
336
|
export function extractGenerateResponseText(
|
|
292
337
|
modelRef: string,
|
|
293
338
|
response: Pick<AssistantMessage, "content" | "stopReason" | "errorMessage">,
|
|
@@ -393,6 +438,51 @@ function normalizeGeneratedOptions(parsed: unknown): string[] {
|
|
|
393
438
|
return options;
|
|
394
439
|
}
|
|
395
440
|
|
|
441
|
+
function normalizeGeneratedOptionValues(parsed: unknown): OptionValue[] {
|
|
442
|
+
if (!Array.isArray(parsed)) {
|
|
443
|
+
throw new Error("Expected array of options");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const normalizedInput = parsed
|
|
447
|
+
.map((option) => {
|
|
448
|
+
if (typeof option === "string") {
|
|
449
|
+
return option.trim();
|
|
450
|
+
}
|
|
451
|
+
if (!option || typeof option !== "object") {
|
|
452
|
+
return option;
|
|
453
|
+
}
|
|
454
|
+
const raw = option as Record<string, unknown>;
|
|
455
|
+
return {
|
|
456
|
+
...raw,
|
|
457
|
+
label: typeof raw.label === "string" ? raw.label.trim() : raw.label,
|
|
458
|
+
};
|
|
459
|
+
})
|
|
460
|
+
.filter((option) => {
|
|
461
|
+
if (typeof option === "string") {
|
|
462
|
+
return option.length > 0;
|
|
463
|
+
}
|
|
464
|
+
if (!option || typeof option !== "object") {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
return typeof (option as Record<string, unknown>).label !== "string"
|
|
468
|
+
|| (option as Record<string, unknown>).label.length > 0;
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const validated = validateQuestions({
|
|
472
|
+
questions: [{
|
|
473
|
+
id: "generated-options",
|
|
474
|
+
type: "single",
|
|
475
|
+
question: "Generated options",
|
|
476
|
+
options: normalizedInput,
|
|
477
|
+
}],
|
|
478
|
+
});
|
|
479
|
+
const options = validated.questions[0]?.options;
|
|
480
|
+
if (!options || options.length === 0) {
|
|
481
|
+
throw new Error("No valid options generated");
|
|
482
|
+
}
|
|
483
|
+
return options;
|
|
484
|
+
}
|
|
485
|
+
|
|
396
486
|
export function parseGeneratedOptions(text: string): string[] {
|
|
397
487
|
let parsed: unknown;
|
|
398
488
|
try {
|
|
@@ -404,6 +494,17 @@ export function parseGeneratedOptions(text: string): string[] {
|
|
|
404
494
|
return normalizeGeneratedOptions(parsed);
|
|
405
495
|
}
|
|
406
496
|
|
|
497
|
+
export function parseGeneratedOptionValues(text: string): OptionValue[] {
|
|
498
|
+
let parsed: unknown;
|
|
499
|
+
try {
|
|
500
|
+
parsed = JSON.parse(extractJSONArray(text));
|
|
501
|
+
} catch (err) {
|
|
502
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
503
|
+
throw new Error(`Failed to parse generated options: ${detail}`);
|
|
504
|
+
}
|
|
505
|
+
return normalizeGeneratedOptionValues(parsed);
|
|
506
|
+
}
|
|
507
|
+
|
|
407
508
|
export function parseReviewedQuestion(text: string): { question: string; options: string[] } {
|
|
408
509
|
let parsed: unknown;
|
|
409
510
|
try {
|
|
@@ -427,6 +528,63 @@ export function parseReviewedQuestion(text: string): { question: string; options
|
|
|
427
528
|
};
|
|
428
529
|
}
|
|
429
530
|
|
|
531
|
+
export function parseReviewedQuestionUpdate(text: string): { question: string; options: OptionValue[] } {
|
|
532
|
+
let parsed: unknown;
|
|
533
|
+
try {
|
|
534
|
+
parsed = JSON.parse(extractJSONObject(text));
|
|
535
|
+
} catch (err) {
|
|
536
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
537
|
+
throw new Error(`Failed to parse reviewed question: ${detail}`);
|
|
538
|
+
}
|
|
539
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
540
|
+
throw new Error("Expected reviewed question object");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const review = parsed as Record<string, unknown>;
|
|
544
|
+
if (typeof review.question !== "string" || !review.question.trim()) {
|
|
545
|
+
throw new Error("Reviewed question must include a non-empty question string");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const options = normalizeGeneratedOptionValues(review.options);
|
|
549
|
+
if (options.some((option) => !isRichOption(option))) {
|
|
550
|
+
throw new Error("Reviewed rich options must all be objects with label");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
question: review.question.trim(),
|
|
555
|
+
options,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function parseOptionInsight(text: string): OptionInsightResult {
|
|
560
|
+
let parsed: unknown;
|
|
561
|
+
try {
|
|
562
|
+
parsed = JSON.parse(extractJSONObject(text));
|
|
563
|
+
} catch (err) {
|
|
564
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
565
|
+
throw new Error(`Failed to parse option insight: ${detail}`);
|
|
566
|
+
}
|
|
567
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
568
|
+
throw new Error("Expected option insight object");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const insight = parsed as Record<string, unknown>;
|
|
572
|
+
if (typeof insight.summary !== "string" || !insight.summary.trim()) {
|
|
573
|
+
throw new Error("Option insight must include a non-empty summary string");
|
|
574
|
+
}
|
|
575
|
+
const bullets = Array.isArray(insight.bullets)
|
|
576
|
+
? insight.bullets.filter((bullet): bullet is string => typeof bullet === "string" && bullet.trim().length > 0).map((bullet) => bullet.trim())
|
|
577
|
+
: [];
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
summary: insight.summary.trim(),
|
|
581
|
+
bullets: bullets.length > 0 ? bullets : undefined,
|
|
582
|
+
suggestedText: typeof insight.suggestedText === "string" && insight.suggestedText.trim().length > 0
|
|
583
|
+
? insight.suggestedText.trim()
|
|
584
|
+
: undefined,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
430
588
|
export function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
|
|
431
589
|
// Extract JSON from <script id="pi-interview-data">
|
|
432
590
|
const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
|
|
@@ -452,6 +610,24 @@ export function loadSavedInterview(html: string, filePath: string): SavedQuestio
|
|
|
452
610
|
const savedAnswers = Array.isArray(raw.savedAnswers)
|
|
453
611
|
? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir, questionTypeById)
|
|
454
612
|
: undefined;
|
|
613
|
+
const savedOptionInsights = Array.isArray(raw.savedOptionInsights)
|
|
614
|
+
? (raw.savedOptionInsights as SavedOptionInsight[]).filter((item) =>
|
|
615
|
+
item &&
|
|
616
|
+
typeof item.id === "string" &&
|
|
617
|
+
typeof item.questionId === "string" &&
|
|
618
|
+
typeof item.optionKey === "string" &&
|
|
619
|
+
typeof item.optionText === "string" &&
|
|
620
|
+
typeof item.prompt === "string" &&
|
|
621
|
+
typeof item.summary === "string"
|
|
622
|
+
)
|
|
623
|
+
: undefined;
|
|
624
|
+
const optionKeysByQuestion = raw.optionKeysByQuestion && typeof raw.optionKeysByQuestion === "object"
|
|
625
|
+
? Object.fromEntries(
|
|
626
|
+
Object.entries(raw.optionKeysByQuestion as Record<string, unknown>)
|
|
627
|
+
.filter(([, value]) => Array.isArray(value) && value.every((key) => typeof key === "string"))
|
|
628
|
+
.map(([questionId, value]) => [questionId, [...(value as string[])]])
|
|
629
|
+
)
|
|
630
|
+
: undefined;
|
|
455
631
|
|
|
456
632
|
// Validate savedFrom if present
|
|
457
633
|
let savedFrom: SavedFromMeta | undefined;
|
|
@@ -470,6 +646,8 @@ export function loadSavedInterview(html: string, filePath: string): SavedQuestio
|
|
|
470
646
|
return {
|
|
471
647
|
...validated,
|
|
472
648
|
savedAnswers,
|
|
649
|
+
savedOptionInsights,
|
|
650
|
+
optionKeysByQuestion,
|
|
473
651
|
savedAt: typeof raw.savedAt === "string" ? raw.savedAt : undefined,
|
|
474
652
|
wasSubmitted: typeof raw.wasSubmitted === "boolean" ? raw.wasSubmitted : undefined,
|
|
475
653
|
savedFrom,
|
|
@@ -507,11 +685,41 @@ function resolvePathValue(value: string | string[], baseDir: string): string | s
|
|
|
507
685
|
return typeof value === "string" && value ? resolveImagePath(value, baseDir) : value;
|
|
508
686
|
}
|
|
509
687
|
|
|
688
|
+
function isChoiceResponseValue(value: unknown): value is ChoiceResponseValue {
|
|
689
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && typeof (value as ChoiceResponseValue).option === "string";
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function formatResponseValue(value: ResponseItem["value"]): string {
|
|
693
|
+
if (Array.isArray(value)) {
|
|
694
|
+
if (value.every(isChoiceResponseValue)) {
|
|
695
|
+
return value.map((item) => item.note ? `${item.option} (${item.note})` : item.option).join(", ");
|
|
696
|
+
}
|
|
697
|
+
return value.join(", ");
|
|
698
|
+
}
|
|
699
|
+
if (isChoiceResponseValue(value)) {
|
|
700
|
+
return value.note ? `${value.option} (${value.note})` : value.option;
|
|
701
|
+
}
|
|
702
|
+
return value;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function hasAnswerValue(value: ResponseItem["value"]): boolean {
|
|
706
|
+
if (Array.isArray(value)) {
|
|
707
|
+
if (value.every(isChoiceResponseValue)) {
|
|
708
|
+
return value.some((item) => item.option.trim() !== "");
|
|
709
|
+
}
|
|
710
|
+
return value.some((item) => typeof item === "string" && item.trim() !== "");
|
|
711
|
+
}
|
|
712
|
+
if (isChoiceResponseValue(value)) {
|
|
713
|
+
return value.option.trim() !== "";
|
|
714
|
+
}
|
|
715
|
+
return typeof value === "string" && value.trim() !== "";
|
|
716
|
+
}
|
|
717
|
+
|
|
510
718
|
function formatResponses(responses: ResponseItem[]): string {
|
|
511
719
|
if (responses.length === 0) return "(none)";
|
|
512
720
|
return responses
|
|
513
721
|
.map((resp) => {
|
|
514
|
-
const value =
|
|
722
|
+
const value = formatResponseValue(resp.value);
|
|
515
723
|
let line = `- ${resp.id}: ${value}`;
|
|
516
724
|
if (resp.attachments && resp.attachments.length > 0) {
|
|
517
725
|
line += ` [attachments: ${resp.attachments.join(", ")}]`;
|
|
@@ -523,24 +731,12 @@ function formatResponses(responses: ResponseItem[]): string {
|
|
|
523
731
|
|
|
524
732
|
function hasAnyAnswers(responses: ResponseItem[]): boolean {
|
|
525
733
|
if (!responses || responses.length === 0) return false;
|
|
526
|
-
return responses.some((resp) =>
|
|
527
|
-
if (!resp || resp.value == null) return false;
|
|
528
|
-
if (Array.isArray(resp.value)) {
|
|
529
|
-
return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
|
|
530
|
-
}
|
|
531
|
-
return typeof resp.value === "string" && resp.value.trim() !== "";
|
|
532
|
-
});
|
|
734
|
+
return responses.some((resp) => !!resp && resp.value != null && hasAnswerValue(resp.value));
|
|
533
735
|
}
|
|
534
736
|
|
|
535
737
|
function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
|
|
536
738
|
if (!responses) return [];
|
|
537
|
-
return responses.filter((resp) =>
|
|
538
|
-
if (!resp || resp.value == null) return false;
|
|
539
|
-
if (Array.isArray(resp.value)) {
|
|
540
|
-
return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
|
|
541
|
-
}
|
|
542
|
-
return typeof resp.value === "string" && resp.value.trim() !== "";
|
|
543
|
-
});
|
|
739
|
+
return responses.filter((resp) => !!resp && resp.value != null && hasAnswerValue(resp.value));
|
|
544
740
|
}
|
|
545
741
|
|
|
546
742
|
export default function (pi: ExtensionAPI) {
|
|
@@ -608,12 +804,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
608
804
|
}
|
|
609
805
|
|
|
610
806
|
let availableGenerateModels: Model<Api>[] = [];
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
// Leave generation disabled when model discovery is unavailable.
|
|
616
|
-
}
|
|
807
|
+
try {
|
|
808
|
+
availableGenerateModels = ctx.modelRegistry.getAvailable();
|
|
809
|
+
} catch {
|
|
810
|
+
// Leave generation disabled when model discovery is unavailable.
|
|
617
811
|
}
|
|
618
812
|
|
|
619
813
|
const { primary: generateModel, fallback: fallbackGenerateModel } = selectGenerateModels(
|
|
@@ -621,6 +815,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
621
815
|
ctx.model ?? null,
|
|
622
816
|
availableGenerateModels,
|
|
623
817
|
);
|
|
818
|
+
const askModels = buildAskModelsData(availableGenerateModels, ctx.model ?? null, generateModel, fallbackGenerateModel);
|
|
819
|
+
const defaultAskModel = generateModel ? formatModelRef(generateModel) : null;
|
|
624
820
|
|
|
625
821
|
// Expand ~ in snapshotDir if present
|
|
626
822
|
const snapshotDir = settings.snapshotDir
|
|
@@ -697,8 +893,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
697
893
|
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
698
894
|
|
|
699
895
|
let onGenerate: InterviewServerCallbacks["onGenerate"];
|
|
896
|
+
let onOptionInsight: InterviewServerCallbacks["onOptionInsight"];
|
|
700
897
|
if (generateModel) {
|
|
701
|
-
const generateOptions = async (
|
|
898
|
+
const generateOptions = async <T>(
|
|
899
|
+
model: Model<Api>,
|
|
900
|
+
prompt: string,
|
|
901
|
+
generateSignal: AbortSignal,
|
|
902
|
+
parse: (text: string) => T,
|
|
903
|
+
) => {
|
|
702
904
|
const modelRef = formatModelRef(model);
|
|
703
905
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
704
906
|
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
@@ -710,10 +912,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
710
912
|
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
711
913
|
);
|
|
712
914
|
|
|
713
|
-
return
|
|
915
|
+
return parse(extractGenerateResponseText(modelRef, response));
|
|
714
916
|
};
|
|
715
917
|
|
|
716
|
-
const reviewQuestion = async (
|
|
918
|
+
const reviewQuestion = async <T>(
|
|
919
|
+
model: Model<Api>,
|
|
920
|
+
prompt: string,
|
|
921
|
+
generateSignal: AbortSignal,
|
|
922
|
+
parse: (text: string) => T,
|
|
923
|
+
) => {
|
|
717
924
|
const modelRef = formatModelRef(model);
|
|
718
925
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
719
926
|
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
@@ -725,12 +932,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
932
|
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
726
933
|
);
|
|
727
934
|
|
|
728
|
-
return
|
|
935
|
+
return parse(extractGenerateResponseText(modelRef, response));
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const optionInsight = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
|
|
939
|
+
const modelRef = formatModelRef(model);
|
|
940
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
941
|
+
if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
|
|
942
|
+
if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
|
|
943
|
+
|
|
944
|
+
const response = await complete(
|
|
945
|
+
model,
|
|
946
|
+
createGenerateContext(prompt, OPTION_INSIGHT_SYSTEM_PROMPT),
|
|
947
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const parsed = parseOptionInsight(extractGenerateResponseText(modelRef, response));
|
|
951
|
+
return { ...parsed, modelUsed: modelRef };
|
|
729
952
|
};
|
|
730
953
|
|
|
731
|
-
|
|
954
|
+
onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
|
|
732
955
|
const question = questionsData.questions.find((q) => q.id === questionId);
|
|
733
956
|
if (!question) throw new Error(`Unknown question: ${questionId}`);
|
|
957
|
+
const optionValues = question.options ?? [];
|
|
958
|
+
const usesRichOptions = optionValues.some(isRichOption);
|
|
734
959
|
|
|
735
960
|
const existingList = existingOptions.length > 0
|
|
736
961
|
? existingOptions.map((option) => `- ${option}`).join("\n")
|
|
@@ -745,47 +970,94 @@ export default function (pi: ExtensionAPI) {
|
|
|
745
970
|
: question.recommended;
|
|
746
971
|
recommended = `\nRecommended: ${value}`;
|
|
747
972
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
973
|
+
if (usesRichOptions) {
|
|
974
|
+
prompt = [
|
|
975
|
+
"Review this interview question and its options.",
|
|
976
|
+
"Rewrite the question so it is easier to understand while preserving the original intent.",
|
|
977
|
+
"Review the rich options as full structured objects: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
|
|
978
|
+
"Return ONLY JSON in this format:",
|
|
979
|
+
'{"question":"Clearer question text","options":[{"label":"Option A","content":{"source":"Explanation","lang":"md"}}]}',
|
|
980
|
+
"Each option must be an object with `label` and optional `content`.",
|
|
981
|
+
"",
|
|
982
|
+
questionsData.title ? `Interview: ${questionsData.title}` : null,
|
|
983
|
+
questionsData.description ? `Interview context: ${questionsData.description}` : null,
|
|
984
|
+
`Question: ${question.question}`,
|
|
985
|
+
question.context ? `Question context: ${question.context}` : null,
|
|
986
|
+
recommended || null,
|
|
987
|
+
"",
|
|
988
|
+
"Current options JSON:",
|
|
989
|
+
JSON.stringify(optionValues, null, 2),
|
|
990
|
+
].filter((line) => line !== null).join("\n");
|
|
991
|
+
} else {
|
|
992
|
+
prompt = [
|
|
993
|
+
"Review this interview question and its options.",
|
|
994
|
+
"Rewrite the question so it is easier to understand while preserving the original intent.",
|
|
995
|
+
"Review the options the same way you already would: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
|
|
996
|
+
"Return ONLY JSON in this format:",
|
|
997
|
+
'{"question":"Clearer question text","options":["Option A","Option B","Option C"]}',
|
|
998
|
+
"",
|
|
999
|
+
questionsData.title ? `Interview: ${questionsData.title}` : null,
|
|
1000
|
+
questionsData.description ? `Interview context: ${questionsData.description}` : null,
|
|
1001
|
+
`Question: ${question.question}`,
|
|
1002
|
+
question.context ? `Question context: ${question.context}` : null,
|
|
1003
|
+
recommended || null,
|
|
1004
|
+
"",
|
|
1005
|
+
"Current options:",
|
|
1006
|
+
existingList,
|
|
1007
|
+
].filter((line) => line !== null).join("\n");
|
|
1008
|
+
}
|
|
764
1009
|
} else {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1010
|
+
if (usesRichOptions) {
|
|
1011
|
+
prompt = [
|
|
1012
|
+
"Generate 3 new, distinct options for this question.",
|
|
1013
|
+
"Return ONLY a JSON array.",
|
|
1014
|
+
"Each item may be either a short option string or an object with `label` and optional `content`.",
|
|
1015
|
+
"Use an object when a new option needs supporting detail or example content.",
|
|
1016
|
+
"",
|
|
1017
|
+
`Question: ${question.question}`,
|
|
1018
|
+
question.context ? `Context: ${question.context}` : null,
|
|
1019
|
+
"",
|
|
1020
|
+
"Existing options JSON (do NOT repeat labels):",
|
|
1021
|
+
JSON.stringify(optionValues, null, 2),
|
|
1022
|
+
"",
|
|
1023
|
+
'Format: ["Option A", {"label":"Option B","content":{"source":"Explanation","lang":"md"}}]',
|
|
1024
|
+
].filter((line) => line !== null).join("\n");
|
|
1025
|
+
} else {
|
|
1026
|
+
prompt = [
|
|
1027
|
+
"Generate 3 new, distinct options for this question.",
|
|
1028
|
+
"Return ONLY a JSON array of short option strings. No explanation, no markdown.",
|
|
1029
|
+
"",
|
|
1030
|
+
`Question: ${question.question}`,
|
|
1031
|
+
question.context ? `Context: ${question.context}` : null,
|
|
1032
|
+
"",
|
|
1033
|
+
"Existing options (do NOT repeat):",
|
|
1034
|
+
existingList,
|
|
1035
|
+
"",
|
|
1036
|
+
'Format: ["Option A", "Option B", "Option C"]',
|
|
1037
|
+
].filter((line) => line !== null).join("\n");
|
|
1038
|
+
}
|
|
777
1039
|
}
|
|
778
1040
|
|
|
779
1041
|
if (mode === "review") {
|
|
780
|
-
let result: { question: string; options:
|
|
1042
|
+
let result: { question: string; options: OptionValue[] };
|
|
781
1043
|
try {
|
|
782
|
-
result = await reviewQuestion(
|
|
1044
|
+
result = await reviewQuestion(
|
|
1045
|
+
generateModel,
|
|
1046
|
+
prompt,
|
|
1047
|
+
generateSignal,
|
|
1048
|
+
usesRichOptions ? parseReviewedQuestionUpdate : parseReviewedQuestion,
|
|
1049
|
+
);
|
|
783
1050
|
} catch (err) {
|
|
784
1051
|
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
785
1052
|
throw err;
|
|
786
1053
|
}
|
|
787
1054
|
try {
|
|
788
|
-
result = await reviewQuestion(
|
|
1055
|
+
result = await reviewQuestion(
|
|
1056
|
+
fallbackGenerateModel,
|
|
1057
|
+
prompt,
|
|
1058
|
+
generateSignal,
|
|
1059
|
+
usesRichOptions ? parseReviewedQuestionUpdate : parseReviewedQuestion,
|
|
1060
|
+
);
|
|
789
1061
|
} catch (fallbackErr) {
|
|
790
1062
|
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
791
1063
|
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
@@ -796,15 +1068,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
796
1068
|
return result;
|
|
797
1069
|
}
|
|
798
1070
|
|
|
799
|
-
let options:
|
|
1071
|
+
let options: OptionValue[];
|
|
800
1072
|
try {
|
|
801
|
-
options = await generateOptions(
|
|
1073
|
+
options = await generateOptions(
|
|
1074
|
+
generateModel,
|
|
1075
|
+
prompt,
|
|
1076
|
+
generateSignal,
|
|
1077
|
+
usesRichOptions ? parseGeneratedOptionValues : parseGeneratedOptions,
|
|
1078
|
+
);
|
|
802
1079
|
} catch (err) {
|
|
803
1080
|
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
804
1081
|
throw err;
|
|
805
1082
|
}
|
|
806
1083
|
try {
|
|
807
|
-
options = await generateOptions(
|
|
1084
|
+
options = await generateOptions(
|
|
1085
|
+
fallbackGenerateModel,
|
|
1086
|
+
prompt,
|
|
1087
|
+
generateSignal,
|
|
1088
|
+
usesRichOptions ? parseGeneratedOptionValues : parseGeneratedOptions,
|
|
1089
|
+
);
|
|
808
1090
|
} catch (fallbackErr) {
|
|
809
1091
|
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
810
1092
|
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
@@ -814,6 +1096,72 @@ export default function (pi: ExtensionAPI) {
|
|
|
814
1096
|
|
|
815
1097
|
return { options };
|
|
816
1098
|
};
|
|
1099
|
+
|
|
1100
|
+
const getExplicitModel = (modelOverride: string): Model<Api> => {
|
|
1101
|
+
const slashIndex = modelOverride.indexOf("/");
|
|
1102
|
+
if (slashIndex <= 0 || slashIndex === modelOverride.length - 1) {
|
|
1103
|
+
throw new Error(`Invalid model override: ${modelOverride}. Use provider/model-id.`);
|
|
1104
|
+
}
|
|
1105
|
+
const selectedModel = ctx.modelRegistry.find(
|
|
1106
|
+
modelOverride.slice(0, slashIndex),
|
|
1107
|
+
modelOverride.slice(slashIndex + 1),
|
|
1108
|
+
);
|
|
1109
|
+
if (!selectedModel) {
|
|
1110
|
+
throw new Error(`Model not found: ${modelOverride}`);
|
|
1111
|
+
}
|
|
1112
|
+
return selectedModel;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
onOptionInsight = async (questionId, option, prompt, modelOverride, depth, generateSignal) => {
|
|
1116
|
+
const question = questionsData.questions.find((q) => q.id === questionId);
|
|
1117
|
+
if (!question) throw new Error(`Unknown question: ${questionId}`);
|
|
1118
|
+
const optionText = getOptionLabel(option);
|
|
1119
|
+
const optionContent = typeof option === "string" ? null : option.content;
|
|
1120
|
+
|
|
1121
|
+
const depthInstructions = {
|
|
1122
|
+
quick: "Keep the analysis very brief: a one-sentence summary and at most one bullet point.",
|
|
1123
|
+
standard: "Be concrete and concise. A short summary and a few bullet points.",
|
|
1124
|
+
deep: "Provide a thorough analysis: detailed summary, multiple bullet points covering tradeoffs, risks, and edge cases.",
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const questionPrompt = [
|
|
1128
|
+
"Analyze this single interview answer option.",
|
|
1129
|
+
depthInstructions[depth as keyof typeof depthInstructions] || depthInstructions.standard,
|
|
1130
|
+
"Explain what is good or risky about the option, and suggest a rewrite only if it would materially improve clarity.",
|
|
1131
|
+
"Return ONLY JSON with summary, bullets, and optional suggestedText.",
|
|
1132
|
+
"",
|
|
1133
|
+
questionsData.title ? `Interview: ${questionsData.title}` : null,
|
|
1134
|
+
questionsData.description ? `Interview context: ${questionsData.description}` : null,
|
|
1135
|
+
`Question: ${question.question}`,
|
|
1136
|
+
question.context ? `Question context: ${question.context}` : null,
|
|
1137
|
+
`Option: ${optionText}`,
|
|
1138
|
+
optionContent?.title ? `Option content title: ${optionContent.title}` : null,
|
|
1139
|
+
optionContent?.file ? `Option content file: ${optionContent.file}` : null,
|
|
1140
|
+
optionContent?.lines ? `Option content lines: ${optionContent.lines}` : null,
|
|
1141
|
+
optionContent?.lang ? `Option content language: ${optionContent.lang}` : null,
|
|
1142
|
+
optionContent?.source ? `Option content:\n${optionContent.source}` : null,
|
|
1143
|
+
`User request: ${prompt}`,
|
|
1144
|
+
].filter((line) => line !== null).join("\n");
|
|
1145
|
+
|
|
1146
|
+
if (modelOverride) {
|
|
1147
|
+
return await optionInsight(getExplicitModel(modelOverride), questionPrompt, generateSignal);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
return await optionInsight(generateModel, questionPrompt, generateSignal);
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
if (!fallbackGenerateModel || generateSignal.aborted) {
|
|
1154
|
+
throw err;
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
return await optionInsight(fallbackGenerateModel, questionPrompt, generateSignal);
|
|
1158
|
+
} catch (fallbackErr) {
|
|
1159
|
+
const primaryMessage = err instanceof Error ? err.message : String(err);
|
|
1160
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
1161
|
+
throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
817
1165
|
}
|
|
818
1166
|
|
|
819
1167
|
startInterviewServer(
|
|
@@ -829,7 +1177,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
829
1177
|
snapshotDir,
|
|
830
1178
|
autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
|
|
831
1179
|
savedAnswers: questionsData.savedAnswers,
|
|
1180
|
+
savedOptionInsights: questionsData.savedOptionInsights,
|
|
1181
|
+
optionKeysByQuestion: questionsData.optionKeysByQuestion,
|
|
832
1182
|
canGenerate: generateModel !== null,
|
|
1183
|
+
askModels,
|
|
1184
|
+
defaultAskModel,
|
|
833
1185
|
},
|
|
834
1186
|
{
|
|
835
1187
|
onSubmit: (responses) => finish("completed", responses),
|
|
@@ -838,6 +1190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
838
1190
|
? finish("timeout", partialResponses ?? [])
|
|
839
1191
|
: finish("cancelled", partialResponses ?? [], reason),
|
|
840
1192
|
onGenerate,
|
|
1193
|
+
onOptionInsight,
|
|
841
1194
|
}
|
|
842
1195
|
)
|
|
843
1196
|
.then(async (handle) => {
|