pi-interview 0.8.1 → 0.8.2
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/form/script.js +35 -23
- package/index.ts +86 -14
- package/package.json +1 -1
- package/server.ts +34 -10
package/form/script.js
CHANGED
|
@@ -529,6 +529,33 @@
|
|
|
529
529
|
return typeof option === "string" ? option : option.label;
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
+
function normalizeRecommendationMatchText(value) {
|
|
533
|
+
return value.normalize("NFC").trim();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function resolveRecommendedLabels(recommended, options) {
|
|
537
|
+
if (!recommended || !Array.isArray(options)) return [];
|
|
538
|
+
|
|
539
|
+
const labelsByNormalized = new Map();
|
|
540
|
+
options.forEach((option) => {
|
|
541
|
+
const label = getOptionLabel(option);
|
|
542
|
+
const normalized = normalizeRecommendationMatchText(label);
|
|
543
|
+
if (!normalized || labelsByNormalized.has(normalized)) return;
|
|
544
|
+
labelsByNormalized.set(normalized, label);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const resolved = [];
|
|
548
|
+
const candidates = Array.isArray(recommended) ? recommended : [recommended];
|
|
549
|
+
candidates.forEach((candidate) => {
|
|
550
|
+
if (typeof candidate !== "string") return;
|
|
551
|
+
const match = labelsByNormalized.get(normalizeRecommendationMatchText(candidate));
|
|
552
|
+
if (match && !resolved.includes(match)) {
|
|
553
|
+
resolved.push(match);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
return resolved;
|
|
557
|
+
}
|
|
558
|
+
|
|
532
559
|
function questionCanClarifyOption(question) {
|
|
533
560
|
return (question.type === "single" || question.type === "multi")
|
|
534
561
|
&& Array.isArray(question.options)
|
|
@@ -629,11 +656,12 @@
|
|
|
629
656
|
}
|
|
630
657
|
|
|
631
658
|
function syncRecommendations(question, options) {
|
|
632
|
-
const optionLabels = options.map((option) => getOptionLabel(option));
|
|
633
659
|
if (!question.recommended) return;
|
|
660
|
+
const resolvedRecommended = resolveRecommendedLabels(question.recommended, options);
|
|
634
661
|
|
|
635
662
|
if (question.type === "single") {
|
|
636
|
-
if (
|
|
663
|
+
if (resolvedRecommended.length > 0) {
|
|
664
|
+
question.recommended = resolvedRecommended[0];
|
|
637
665
|
return;
|
|
638
666
|
}
|
|
639
667
|
delete question.recommended;
|
|
@@ -647,15 +675,12 @@
|
|
|
647
675
|
return;
|
|
648
676
|
}
|
|
649
677
|
|
|
650
|
-
|
|
651
|
-
? question.recommended
|
|
652
|
-
: [question.recommended]).filter((option) => optionLabels.includes(option));
|
|
653
|
-
if (nextRecommended.length === 0) {
|
|
678
|
+
if (resolvedRecommended.length === 0) {
|
|
654
679
|
delete question.recommended;
|
|
655
680
|
delete question.conviction;
|
|
656
681
|
return;
|
|
657
682
|
}
|
|
658
|
-
question.recommended =
|
|
683
|
+
question.recommended = resolvedRecommended;
|
|
659
684
|
}
|
|
660
685
|
|
|
661
686
|
function makeClientId(prefix = "id") {
|
|
@@ -1481,12 +1506,7 @@
|
|
|
1481
1506
|
text.className = "option-item-label";
|
|
1482
1507
|
text.textContent = optionLabel;
|
|
1483
1508
|
|
|
1484
|
-
const
|
|
1485
|
-
const recommendedList = Array.isArray(recommended)
|
|
1486
|
-
? recommended
|
|
1487
|
-
: recommended
|
|
1488
|
-
? [recommended]
|
|
1489
|
-
: [];
|
|
1509
|
+
const recommendedList = resolveRecommendedLabels(question.recommended, question.options || []);
|
|
1490
1510
|
const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
|
|
1491
1511
|
|
|
1492
1512
|
if (recommendedList.includes(optionLabel)) {
|
|
@@ -3567,9 +3587,7 @@
|
|
|
3567
3587
|
}
|
|
3568
3588
|
}
|
|
3569
3589
|
|
|
3570
|
-
// Pre-populate form from saved interview answers
|
|
3571
3590
|
function populateFromSavedAnswers(savedAnswers) {
|
|
3572
|
-
// Convert ResponseItem[] to Record for existing populateForm()
|
|
3573
3591
|
const valueMap = {};
|
|
3574
3592
|
savedAnswers.forEach((ans) => {
|
|
3575
3593
|
const question = questions.find((q) => q.id === ans.id);
|
|
@@ -3579,7 +3597,6 @@
|
|
|
3579
3597
|
});
|
|
3580
3598
|
populateForm(valueMap);
|
|
3581
3599
|
|
|
3582
|
-
// Restore attachments to attachPathState
|
|
3583
3600
|
savedAnswers.forEach((ans) => {
|
|
3584
3601
|
if (ans.attachments && ans.attachments.length > 0) {
|
|
3585
3602
|
attachPathState.set(ans.id, [...ans.attachments]);
|
|
@@ -3595,7 +3612,6 @@
|
|
|
3595
3612
|
}
|
|
3596
3613
|
});
|
|
3597
3614
|
|
|
3598
|
-
// Restore image paths for image-type questions
|
|
3599
3615
|
savedAnswers.forEach((ans) => {
|
|
3600
3616
|
const question = questions.find((q) => q.id === ans.id);
|
|
3601
3617
|
if (question?.type === "image" && ans.value) {
|
|
@@ -3608,7 +3624,6 @@
|
|
|
3608
3624
|
}
|
|
3609
3625
|
});
|
|
3610
3626
|
|
|
3611
|
-
// Update done states for multi-select
|
|
3612
3627
|
questions.forEach((q) => {
|
|
3613
3628
|
if (q.type === "multi") {
|
|
3614
3629
|
updateDoneState(q.id);
|
|
@@ -3627,13 +3642,13 @@
|
|
|
3627
3642
|
const reader = new FileReader();
|
|
3628
3643
|
reader.onload = () => {
|
|
3629
3644
|
if (typeof reader.result !== "string") {
|
|
3630
|
-
reject(new Error(
|
|
3645
|
+
reject(new Error(`Failed to read file: unexpected FileReader result type ${typeof reader.result}`));
|
|
3631
3646
|
return;
|
|
3632
3647
|
}
|
|
3633
3648
|
const parts = reader.result.split(",");
|
|
3634
3649
|
resolve(parts[1] || "");
|
|
3635
3650
|
};
|
|
3636
|
-
reader.onerror = () => reject(new Error("Failed to read file"));
|
|
3651
|
+
reader.onerror = () => reject(new Error(reader.error?.message || "Failed to read file"));
|
|
3637
3652
|
reader.readAsDataURL(file);
|
|
3638
3653
|
});
|
|
3639
3654
|
}
|
|
@@ -3674,7 +3689,6 @@
|
|
|
3674
3689
|
return { responses, images };
|
|
3675
3690
|
}
|
|
3676
3691
|
|
|
3677
|
-
// Save interview snapshot
|
|
3678
3692
|
async function saveInterview(options = {}) {
|
|
3679
3693
|
const { submitted = false } = options;
|
|
3680
3694
|
|
|
@@ -3759,8 +3773,6 @@
|
|
|
3759
3773
|
return;
|
|
3760
3774
|
}
|
|
3761
3775
|
|
|
3762
|
-
// Auto-save on successful submit (fire-and-forget)
|
|
3763
|
-
// Note: data is window.__INTERVIEW_DATA__, result is server response
|
|
3764
3776
|
if (data.autoSaveOnSubmit !== false) {
|
|
3765
3777
|
saveInterview({ submitted: true });
|
|
3766
3778
|
}
|
package/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type AskModelOption,
|
|
19
19
|
type OptionInsightResult,
|
|
20
20
|
} from "./server.js";
|
|
21
|
-
import { getOptionLabel, isRichOption, validateQuestions, sanitizeLLMJSON, type OptionValue, type QuestionsFile } from "./schema.js";
|
|
21
|
+
import { getOptionLabel, isRichOption, validateQuestions, sanitizeLLMJSON, type OptionValue, type Question, type QuestionsFile } from "./schema.js";
|
|
22
22
|
import { loadSettings, type InterviewThemeSettings } from "./settings.js";
|
|
23
23
|
|
|
24
24
|
interface GlimpseWindow {
|
|
@@ -715,28 +715,100 @@ function hasAnswerValue(value: ResponseItem["value"]): boolean {
|
|
|
715
715
|
return typeof value === "string" && value.trim() !== "";
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
-
function
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
718
|
+
function hasResponseContent(response: ResponseItem): boolean {
|
|
719
|
+
return hasAnswerValue(response.value) || !!response.attachments?.length;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function summarizeResponseValue(question: Question, response: ResponseItem): string {
|
|
723
|
+
if (question.type === "image") {
|
|
724
|
+
if (Array.isArray(response.value)) {
|
|
725
|
+
return response.value.length === 1 ? "1 image attached" : `${response.value.length} images attached`;
|
|
726
|
+
}
|
|
727
|
+
if (typeof response.value === "string" && response.value.trim() !== "") {
|
|
728
|
+
return "1 image attached";
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (hasAnswerValue(response.value)) {
|
|
733
|
+
return String(formatResponseValue(response.value));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (response.attachments?.length) {
|
|
737
|
+
return response.attachments.length === 1 ? "1 attachment included" : `${response.attachments.length} attachments included`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return "";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
interface AgentResponseItem {
|
|
744
|
+
id: string;
|
|
745
|
+
question: string;
|
|
746
|
+
type: Question["type"];
|
|
747
|
+
value: ResponseItem["value"];
|
|
748
|
+
attachments?: string[];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function buildAnsweredAgentResponseItems(
|
|
752
|
+
responses: ResponseItem[],
|
|
753
|
+
questions: Question[],
|
|
754
|
+
): AgentResponseItem[] {
|
|
755
|
+
const responseById = new Map<string, ResponseItem>();
|
|
756
|
+
for (const response of responses) {
|
|
757
|
+
if (!response || typeof response.id !== "string") continue;
|
|
758
|
+
responseById.set(response.id, response);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return questions
|
|
762
|
+
.map((question) => {
|
|
763
|
+
const response = responseById.get(question.id);
|
|
764
|
+
if (!response || !hasResponseContent(response)) return null;
|
|
765
|
+
return {
|
|
766
|
+
id: question.id,
|
|
767
|
+
question: question.question,
|
|
768
|
+
type: question.type,
|
|
769
|
+
value: response.value,
|
|
770
|
+
attachments: response.attachments?.length ? [...response.attachments] : undefined,
|
|
771
|
+
} satisfies AgentResponseItem;
|
|
772
|
+
})
|
|
773
|
+
.filter((item): item is AgentResponseItem => item !== null);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function formatAnsweredResponsesForAgent(
|
|
777
|
+
responses: ResponseItem[],
|
|
778
|
+
questions: Question[],
|
|
779
|
+
): string {
|
|
780
|
+
const answeredItems = buildAnsweredAgentResponseItems(responses, questions);
|
|
781
|
+
if (answeredItems.length === 0) return "(none)";
|
|
782
|
+
const questionById = new Map(questions.map((question) => [question.id, question]));
|
|
783
|
+
const responseById = new Map(responses.map((response) => [response.id, response]));
|
|
784
|
+
|
|
785
|
+
const summary = answeredItems
|
|
786
|
+
.map((item) => {
|
|
787
|
+
const question = questionById.get(item.id);
|
|
788
|
+
const response = responseById.get(item.id);
|
|
789
|
+
if (!question || !response) {
|
|
790
|
+
return `- ${item.question}`;
|
|
791
|
+
}
|
|
792
|
+
let line = `- ${item.question}: ${summarizeResponseValue(question, response)}`;
|
|
793
|
+
if (item.attachments?.length) {
|
|
794
|
+
line += ` [attachments: ${item.attachments.join(", ")}]`;
|
|
726
795
|
}
|
|
727
796
|
return line;
|
|
728
797
|
})
|
|
729
798
|
.join("\n");
|
|
799
|
+
|
|
800
|
+
const json = JSON.stringify(answeredItems, null, 2);
|
|
801
|
+
return `${summary}\n\nStructured response data:\n\n\`\`\`json\n${json}\n\`\`\``;
|
|
730
802
|
}
|
|
731
803
|
|
|
732
804
|
function hasAnyAnswers(responses: ResponseItem[]): boolean {
|
|
733
805
|
if (!responses || responses.length === 0) return false;
|
|
734
|
-
return responses.some((resp) => !!resp &&
|
|
806
|
+
return responses.some((resp) => !!resp && hasResponseContent(resp));
|
|
735
807
|
}
|
|
736
808
|
|
|
737
809
|
function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
|
|
738
810
|
if (!responses) return [];
|
|
739
|
-
return responses.filter((resp) => !!resp &&
|
|
811
|
+
return responses.filter((resp) => !!resp && hasResponseContent(resp));
|
|
740
812
|
}
|
|
741
813
|
|
|
742
814
|
export default function (pi: ExtensionAPI) {
|
|
@@ -855,21 +927,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
855
927
|
|
|
856
928
|
let text = "";
|
|
857
929
|
if (status === "completed") {
|
|
858
|
-
text = `User completed the interview form.\n\
|
|
930
|
+
text = `User completed the interview form.\n\nAnswered responses:\n${formatAnsweredResponsesForAgent(responses, questionsData.questions)}`;
|
|
859
931
|
} else if (status === "cancelled") {
|
|
860
932
|
if (cancelReason === "stale") {
|
|
861
933
|
text =
|
|
862
934
|
"Interview session ended due to lost heartbeat.\n\nQuestions saved to: ~/.pi/interview-recovery/";
|
|
863
935
|
} else if (hasAnyAnswers(responses)) {
|
|
864
936
|
const answered = filterAnsweredResponses(responses);
|
|
865
|
-
text = `User cancelled the interview with partial responses:\n${
|
|
937
|
+
text = `User cancelled the interview with partial responses.\n\nAnswered responses:\n${formatAnsweredResponsesForAgent(answered, questionsData.questions)}\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
|
|
866
938
|
} else {
|
|
867
939
|
text = "User skipped the interview without providing answers. Proceed with your best judgment - use recommended options where specified, make reasonable choices elsewhere. Don't ask for clarification unless absolutely necessary.";
|
|
868
940
|
}
|
|
869
941
|
} else if (status === "timeout") {
|
|
870
942
|
if (hasAnyAnswers(responses)) {
|
|
871
943
|
const answered = filterAnsweredResponses(responses);
|
|
872
|
-
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\
|
|
944
|
+
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nAnswered responses before timeout:\n${formatAnsweredResponsesForAgent(answered, questionsData.questions)}\n\nQuestions saved to: ~/.pi/interview-recovery/\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
|
|
873
945
|
} else {
|
|
874
946
|
text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
|
|
875
947
|
}
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -432,12 +432,41 @@ function ensureQuestionId(
|
|
|
432
432
|
return { ok: true, question };
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
function
|
|
435
|
+
function normalizeRecommendationMatchText(value: string): string {
|
|
436
|
+
return value.normalize("NFC").trim();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function resolveRecommendedLabels(
|
|
440
|
+
recommended: Question["recommended"],
|
|
441
|
+
options: OptionValue[]
|
|
442
|
+
): string[] {
|
|
443
|
+
if (!recommended) return [];
|
|
436
444
|
const optionLabels = options.map((option) => getOptionLabel(option));
|
|
445
|
+
const labelsByNormalized = new Map<string, string>();
|
|
446
|
+
for (const label of optionLabels) {
|
|
447
|
+
const normalized = normalizeRecommendationMatchText(label);
|
|
448
|
+
if (!normalized || labelsByNormalized.has(normalized)) continue;
|
|
449
|
+
labelsByNormalized.set(normalized, label);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const resolved: string[] = [];
|
|
453
|
+
for (const candidate of Array.isArray(recommended) ? recommended : [recommended]) {
|
|
454
|
+
if (typeof candidate !== "string") continue;
|
|
455
|
+
const match = labelsByNormalized.get(normalizeRecommendationMatchText(candidate));
|
|
456
|
+
if (match && !resolved.includes(match)) {
|
|
457
|
+
resolved.push(match);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return resolved;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function syncRecommendations(question: Question, options: OptionValue[]): void {
|
|
437
464
|
if (!question.recommended) return;
|
|
465
|
+
const resolvedRecommended = resolveRecommendedLabels(question.recommended, options);
|
|
438
466
|
|
|
439
467
|
if (question.type === "single") {
|
|
440
|
-
if (
|
|
468
|
+
if (resolvedRecommended.length > 0) {
|
|
469
|
+
question.recommended = resolvedRecommended[0];
|
|
441
470
|
return;
|
|
442
471
|
}
|
|
443
472
|
delete question.recommended;
|
|
@@ -451,15 +480,12 @@ function syncRecommendations(question: Question, options: OptionValue[]): void {
|
|
|
451
480
|
return;
|
|
452
481
|
}
|
|
453
482
|
|
|
454
|
-
|
|
455
|
-
? question.recommended
|
|
456
|
-
: [question.recommended]).filter((option) => optionLabels.includes(option));
|
|
457
|
-
if (nextRecommended.length === 0) {
|
|
483
|
+
if (resolvedRecommended.length === 0) {
|
|
458
484
|
delete question.recommended;
|
|
459
485
|
delete question.conviction;
|
|
460
486
|
return;
|
|
461
487
|
}
|
|
462
|
-
question.recommended =
|
|
488
|
+
question.recommended = resolvedRecommended;
|
|
463
489
|
}
|
|
464
490
|
|
|
465
491
|
function makeOptionKey(): string {
|
|
@@ -924,9 +950,7 @@ function recommendedIndicatorHtml(q: Question): string {
|
|
|
924
950
|
}
|
|
925
951
|
|
|
926
952
|
function savedAnswerItemHtml(text: string, q: Question): string {
|
|
927
|
-
const recs =
|
|
928
|
-
? q.recommended
|
|
929
|
-
: q.recommended ? [q.recommended] : [];
|
|
953
|
+
const recs = q.options ? resolveRecommendedLabels(q.recommended, q.options) : [];
|
|
930
954
|
const indicator = recs.includes(text) ? " " + recommendedIndicatorHtml(q) : "";
|
|
931
955
|
return escapeHtml(text) + indicator;
|
|
932
956
|
}
|