@case-framework/survey-assistant 0.2.2 → 0.2.3

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.
Files changed (102) hide show
  1. package/dist/{authoring-references-DYuOAdVx.mjs → authoring-references-ChDTVnms.mjs} +8 -2
  2. package/dist/authoring-references-ChDTVnms.mjs.map +1 -0
  3. package/dist/{capabilities-DlC1l6qy.d.mts → capabilities-CnsVuJ1k.d.mts} +12 -4
  4. package/dist/capabilities-CnsVuJ1k.d.mts.map +1 -0
  5. package/dist/capabilities-default.d.mts +1 -1
  6. package/dist/capabilities-default.d.mts.map +1 -1
  7. package/dist/capabilities-default.mjs +38 -4
  8. package/dist/capabilities-default.mjs.map +1 -1
  9. package/dist/{constants-D-5m_KGB.d.mts → constants-BK1-g6pR.d.mts} +12 -7
  10. package/dist/constants-BK1-g6pR.d.mts.map +1 -0
  11. package/dist/{controller-proxy-Hkuub9dt.d.mts → controller-proxy-CIDRd8Wu.d.mts} +2 -2
  12. package/dist/{controller-proxy-Hkuub9dt.d.mts.map → controller-proxy-CIDRd8Wu.d.mts.map} +1 -1
  13. package/dist/{controller-proxy-0WJZUZYx.mjs → controller-proxy-DY36XFV3.mjs} +2 -2
  14. package/dist/{controller-proxy-0WJZUZYx.mjs.map → controller-proxy-DY36XFV3.mjs.map} +1 -1
  15. package/dist/{digest-BvXpeWcR.d.mts → digest-Dyjch_sC.d.mts} +2 -2
  16. package/dist/{digest-BvXpeWcR.d.mts.map → digest-Dyjch_sC.d.mts.map} +1 -1
  17. package/dist/digest.d.mts +1 -1
  18. package/dist/{engine-0AJrv0hI.mjs → engine-CWTuN3o2.mjs} +866 -663
  19. package/dist/engine-CWTuN3o2.mjs.map +1 -0
  20. package/dist/engine.d.mts +3 -3
  21. package/dist/engine.mjs +3 -3
  22. package/dist/fair-guidance-CEdJYccK.mjs +21 -0
  23. package/dist/fair-guidance-CEdJYccK.mjs.map +1 -0
  24. package/dist/form-authoring-DCd9XfXf.mjs +155 -0
  25. package/dist/form-authoring-DCd9XfXf.mjs.map +1 -0
  26. package/dist/{index-BcyfBeDj.d.mts → index-Bp5W_oPa.d.mts} +25 -5
  27. package/dist/index-Bp5W_oPa.d.mts.map +1 -0
  28. package/dist/{index-JXH-t2EJ.d.mts → index-CPOQgkTN.d.mts} +67 -6
  29. package/dist/index-CPOQgkTN.d.mts.map +1 -0
  30. package/dist/{index-MEOL9_Gr.d.mts → index-EDL1V168.d.mts} +98 -4
  31. package/dist/{index-MEOL9_Gr.d.mts.map → index-EDL1V168.d.mts.map} +1 -1
  32. package/dist/{index-CneD8ibB.d.mts → index-SPHf3OoJ.d.mts} +9 -3
  33. package/dist/index-SPHf3OoJ.d.mts.map +1 -0
  34. package/dist/{memory-thread-repository-1ocPOda8.mjs → memory-thread-repository-acbz6HZJ.mjs} +181 -26
  35. package/dist/memory-thread-repository-acbz6HZJ.mjs.map +1 -0
  36. package/dist/{protocol-DpCYmCjr.mjs → protocol-DATvkr1S.mjs} +125 -6
  37. package/dist/protocol-DATvkr1S.mjs.map +1 -0
  38. package/dist/protocol.d.mts +3 -3
  39. package/dist/protocol.mjs +2 -2
  40. package/dist/{react-DXK5zWNo.mjs → react-3kSE6WtS.mjs} +127 -9
  41. package/dist/react-3kSE6WtS.mjs.map +1 -0
  42. package/dist/react-integration.d.mts +1 -1
  43. package/dist/react-integration.mjs +1 -1
  44. package/dist/react.d.mts +2 -2
  45. package/dist/react.mjs +2 -2
  46. package/dist/references/assistant-operations.md +13 -18
  47. package/dist/references/embedded-forms.md +41 -2
  48. package/dist/references/expressions.md +17 -9
  49. package/dist/references/fair-by-design.md +80 -0
  50. package/dist/references/item-types.md +19 -3
  51. package/dist/references/localization.md +7 -4
  52. package/dist/references/source-material-surveys.md +6 -4
  53. package/dist/{request-context-BBzU3VdG.mjs → request-context-BEjnaaFr.mjs} +10 -5
  54. package/dist/request-context-BEjnaaFr.mjs.map +1 -0
  55. package/dist/server-agent.d.mts +26 -6
  56. package/dist/server-agent.d.mts.map +1 -1
  57. package/dist/server-agent.mjs +70 -13
  58. package/dist/server-agent.mjs.map +1 -1
  59. package/dist/server-runtime.d.mts +22 -11
  60. package/dist/server-runtime.d.mts.map +1 -1
  61. package/dist/server-runtime.mjs +47 -19
  62. package/dist/server-runtime.mjs.map +1 -1
  63. package/dist/server-tasks.d.mts +1 -1
  64. package/dist/server-tasks.mjs +4 -3
  65. package/dist/server-tasks.mjs.map +1 -1
  66. package/dist/server-tools.d.mts +1 -1
  67. package/dist/server-tools.mjs +1 -1
  68. package/dist/server.d.mts +16 -7
  69. package/dist/server.d.mts.map +1 -1
  70. package/dist/server.mjs +4 -4
  71. package/dist/storage-postgres.d.mts +1 -1
  72. package/dist/{tasks-DcfQNJwO.d.mts → tasks-DIZgr4nD.d.mts} +8 -4
  73. package/dist/tasks-DIZgr4nD.d.mts.map +1 -0
  74. package/dist/{thread-documents-BlxNjc7S.d.mts → thread-documents-C3kf0Vds.d.mts} +1 -1
  75. package/dist/{thread-documents-BlxNjc7S.d.mts.map → thread-documents-C3kf0Vds.d.mts.map} +1 -1
  76. package/dist/{thread-handlers-uddXwQjp.d.mts → thread-handlers-DZ1WsorO.d.mts} +9 -4
  77. package/dist/thread-handlers-DZ1WsorO.d.mts.map +1 -0
  78. package/dist/{tools-wgLen05n.mjs → tools-GDd5_GUh.mjs} +403 -77
  79. package/dist/tools-GDd5_GUh.mjs.map +1 -0
  80. package/dist/ui.css +1 -1
  81. package/dist/ui.d.mts +3 -3
  82. package/dist/ui.d.mts.map +1 -1
  83. package/dist/ui.mjs +152 -36
  84. package/dist/ui.mjs.map +1 -1
  85. package/docs/integration.md +73 -5
  86. package/package.json +3 -3
  87. package/dist/authoring-references-DYuOAdVx.mjs.map +0 -1
  88. package/dist/capabilities-DKbNCnBf.mjs +0 -66
  89. package/dist/capabilities-DKbNCnBf.mjs.map +0 -1
  90. package/dist/capabilities-DlC1l6qy.d.mts.map +0 -1
  91. package/dist/constants-D-5m_KGB.d.mts.map +0 -1
  92. package/dist/engine-0AJrv0hI.mjs.map +0 -1
  93. package/dist/index-BcyfBeDj.d.mts.map +0 -1
  94. package/dist/index-CneD8ibB.d.mts.map +0 -1
  95. package/dist/index-JXH-t2EJ.d.mts.map +0 -1
  96. package/dist/memory-thread-repository-1ocPOda8.mjs.map +0 -1
  97. package/dist/protocol-DpCYmCjr.mjs.map +0 -1
  98. package/dist/react-DXK5zWNo.mjs.map +0 -1
  99. package/dist/request-context-BBzU3VdG.mjs.map +0 -1
  100. package/dist/tasks-DcfQNJwO.d.mts.map +0 -1
  101. package/dist/thread-handlers-uddXwQjp.d.mts.map +0 -1
  102. package/dist/tools-wgLen05n.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
- import { P as rawSurveyDocumentSchema, c as surveyAssistantTurnFinalSchema, u as surveyChangeSetSchema } from "./protocol-DpCYmCjr.mjs";
2
- import { n as assertSurveyAssistantCapabilities } from "./capabilities-DKbNCnBf.mjs";
1
+ import { I as rawSurveyDocumentSchema, f as surveyChangeSetSchema, l as surveyAssistantTurnFinalSchema } from "./protocol-DATvkr1S.mjs";
2
+ import { a as normalizeFormAuthoringConfig, c as assertSurveyAssistantCapabilities, i as isValidSimpleFormConfig, o as transitionFormAuthoringMode, r as isCompatibleSimpleFieldUpsert, t as getFormAuthoringMode } from "./form-authoring-DCd9XfXf.mjs";
3
3
  import { z } from "zod";
4
4
  import { ContentType, Expression, ExpressionType, FunctionExpressionNames, JsonPatchError, ReservedSurveyItemTypes, SurveyItemTranslations, SurveyTranslations, ValueReferenceMethod, ValueType, applyJsonPatch, generateId, getContentPlainText, isContentEmpty, parseJsonPointer, structuredCloneMethod } from "@case-framework/survey-core";
5
5
  //#region src/engine/context.ts
@@ -483,6 +483,8 @@ const choiceOptionDetailsSchema = z.object({
483
483
  hasDisplayCondition: z.boolean().optional(),
484
484
  embeddedFormItemId: z.string().optional(),
485
485
  embeddedFormFieldGroupCount: z.number().int().nonnegative().optional(),
486
+ embeddedFormAuthoringMode: z.enum(["simple", "custom"]).optional(),
487
+ embeddedFormSimpleModeValid: z.boolean().optional(),
486
488
  embeddedFormTranslationPrefix: z.string().optional(),
487
489
  embeddedFormResponseSlotPrefix: z.string().optional()
488
490
  });
@@ -535,6 +537,8 @@ const itemConfigDetailsSchema = z.object({
535
537
  "generic"
536
538
  ]),
537
539
  summary: z.array(z.string()),
540
+ authoringMode: z.enum(["simple", "custom"]).optional(),
541
+ simpleModeValid: z.boolean().optional(),
538
542
  mainResponseSlotId: z.string().optional(),
539
543
  choiceOptions: z.array(choiceOptionDetailsSchema).optional(),
540
544
  formFieldGroups: z.array(formFieldGroupDetailsSchema).optional(),
@@ -677,7 +681,8 @@ const buildChoiceConfigDetails = (rawItem) => {
677
681
  if (!isRecord$5(option)) return;
678
682
  const id = asString(option.id);
679
683
  if (!id) return;
680
- const embeddedFormFieldGroups = asArray((isRecord$5(option.embeddedForm) ? option.embeddedForm : void 0)?.fieldGroups);
684
+ const embeddedForm = isRecord$5(option.embeddedForm) ? option.embeddedForm : void 0;
685
+ const embeddedFormFieldGroups = asArray(embeddedForm?.fieldGroups);
681
686
  const details = {
682
687
  position: index + 1,
683
688
  id,
@@ -691,6 +696,8 @@ const buildChoiceConfigDetails = (rawItem) => {
691
696
  if (embeddedFormItemId) details.embeddedFormItemId = embeddedFormItemId;
692
697
  if (embeddedFormFieldGroups.length > 0) {
693
698
  details.embeddedFormFieldGroupCount = embeddedFormFieldGroups.length;
699
+ details.embeddedFormAuthoringMode = getFormAuthoringMode(embeddedForm);
700
+ details.embeddedFormSimpleModeValid = isValidSimpleFormConfig(embeddedForm);
694
701
  details.embeddedFormTranslationPrefix = getChoiceEmbeddedFormTranslationPrefix(id);
695
702
  details.embeddedFormResponseSlotPrefix = getChoiceEmbeddedFormResponseSlotPrefix(id);
696
703
  }
@@ -754,7 +761,8 @@ const buildFormFieldDetails = (field, position, translationNamespace, responseSl
754
761
  };
755
762
  const buildFormConfigDetails = (rawItem, configOverride, translationNamespace, responseSlotPrefix) => {
756
763
  const configCandidate = configOverride ?? rawItem.config;
757
- const rawGroups = asArray((isRecord$5(configCandidate) ? configCandidate : void 0)?.fieldGroups);
764
+ const config = isRecord$5(configCandidate) ? configCandidate : void 0;
765
+ const rawGroups = asArray(config?.fieldGroups);
758
766
  if (rawItem.itemType !== "formItem" && rawGroups.length === 0) return;
759
767
  const formFieldGroups = rawGroups.map((group, groupIndex) => {
760
768
  if (!isRecord$5(group)) return;
@@ -780,9 +788,13 @@ const buildFormConfigDetails = (rawItem, configOverride, translationNamespace, r
780
788
  };
781
789
  }).filter((group) => Boolean(group));
782
790
  const fieldCount = formFieldGroups.reduce((sum, group) => sum + group.fieldCount, 0);
791
+ const authoringMode = getFormAuthoringMode(config);
792
+ const simpleModeValid = isValidSimpleFormConfig(config);
783
793
  return {
784
794
  kind: "form",
785
- summary: [`form item config: ${formFieldGroups.length} group(s), ${fieldCount} field(s)`],
795
+ summary: [`form item config: authoringMode=${authoringMode}, ${formFieldGroups.length} group(s), ${fieldCount} field(s)${simpleModeValid ? "" : "; malformed simple mode should be treated as custom"}`],
796
+ authoringMode,
797
+ simpleModeValid,
786
798
  formFieldGroups,
787
799
  listOrdering: {
788
800
  formFieldGroups: "Stored configured order; positions are one-based.",
@@ -1942,6 +1954,12 @@ const MAX_SURVEY_ASSISTANT_OPERATIONS_PER_TURN_DRAFT = 1e3;
1942
1954
  const CHOICE_ITEM_TYPE = "choiceItem";
1943
1955
  const FORM_ITEM_TYPE = "formItem";
1944
1956
  const CHOICE_OPTION_LABEL_CONTENT_KEY_PATTERN = /^options\.([^.]+)\.label$/;
1957
+ const MAX_VALIDATION_DIAGNOSTIC_VALUES = 12;
1958
+ const formatBoundedDiagnosticValues = (values, limit = MAX_VALIDATION_DIAGNOSTIC_VALUES) => {
1959
+ const shown = values.slice(0, limit);
1960
+ const suffix = shown.length < values.length ? `, … (${shown.length}/${values.length} shown in operation order)` : "";
1961
+ return `${shown.join(", ")}${suffix}`;
1962
+ };
1945
1963
  const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1946
1964
  const getTrimmedString = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
1947
1965
  const getFirstStringProperty = (record, keys) => {
@@ -2182,7 +2200,7 @@ const validateNewRawPatchLocalizedContent = ({ operation, originalSurvey, patche
2182
2200
  if (missing.length === 0) return [];
2183
2201
  return [createIssue({
2184
2202
  code: "missing-raw-patch-localized-content",
2185
- message: `The raw patch introduces respondent-visible content without its required translation. Add non-empty Content values in the same patches array for: ${missing.map(({ itemId, contentKey }) => `/translations/<locale>/itemTranslations/${itemId}/${contentKey}`).join(", ")}. Content keys use config ids exactly and are case-sensitive; for embedded fields, embeddedForms.<optionId> uses the choice option's id, not its key or embeddedForm id.`,
2203
+ message: `The raw patch introduces respondent-visible content without its required translation. Add non-empty Content values in the same patches array for: ${formatBoundedDiagnosticValues(missing.map(({ itemId, contentKey }) => `/translations/<locale>/itemTranslations/${itemId}/${contentKey}`))}. Content keys use config ids exactly and are case-sensitive; for embedded fields, embeddedForms.<optionId> uses the choice option's id, not its key or embeddedForm id.`,
2186
2204
  operationIndex: 0,
2187
2205
  operationId: getOperationId(operation),
2188
2206
  itemId: missing[0]?.itemId
@@ -2587,6 +2605,7 @@ const isSurveyLevelOperation = (operation) => [
2587
2605
  "update-survey-metadata",
2588
2606
  "update-survey-pagination",
2589
2607
  "update-survey-translation",
2608
+ "add-survey-locale",
2590
2609
  "remove-survey-locale",
2591
2610
  "set-image-asset",
2592
2611
  "update-image-asset-metadata",
@@ -2605,6 +2624,7 @@ const getOperationTarget = (operation) => {
2605
2624
  case "update-survey-metadata":
2606
2625
  case "update-survey-pagination":
2607
2626
  case "update-survey-translation":
2627
+ case "add-survey-locale":
2608
2628
  case "remove-survey-locale":
2609
2629
  case "set-image-asset":
2610
2630
  case "update-image-asset-metadata":
@@ -2714,17 +2734,26 @@ const getFormFieldGroupsForTarget = (rawItem, optionId) => {
2714
2734
  const option = getChoiceOptionsFromRawItem(rawItem).find((candidate) => candidate.id === optionId);
2715
2735
  return option ? getFormFieldGroupsFromConfig(option.embeddedForm) : void 0;
2716
2736
  };
2717
- const withFormFieldGroupsForTarget = (rawItem, fieldGroups, optionId) => {
2737
+ const getFormConfigForTarget = (rawItem, optionId) => {
2738
+ if (!optionId) return rawItem.itemType === FORM_ITEM_TYPE && isRecord$2(rawItem.config) ? rawItem.config : void 0;
2739
+ if (rawItem.itemType !== CHOICE_ITEM_TYPE) return void 0;
2740
+ const option = getChoiceOptionsFromRawItem(rawItem).find((candidate) => candidate.id === optionId);
2741
+ return option && isRecord$2(option.embeddedForm) ? option.embeddedForm : void 0;
2742
+ };
2743
+ const withFormFieldGroupsForTarget = (rawItem, fieldGroups, optionId, preserveSimpleMode = false) => {
2718
2744
  if (!optionId) {
2719
2745
  if (rawItem.itemType !== FORM_ITEM_TYPE) return void 0;
2720
2746
  const config = isRecord$2(rawItem.config) ? rawItem.config : {};
2721
2747
  return {
2722
2748
  ...rawItem,
2723
- config: {
2724
- id: rawItem.id,
2725
- ...config,
2726
- fieldGroups
2727
- }
2749
+ config: transitionFormAuthoringMode({
2750
+ config: {
2751
+ id: rawItem.id,
2752
+ ...config
2753
+ },
2754
+ fieldGroups,
2755
+ preserveSimpleMode
2756
+ })
2728
2757
  };
2729
2758
  }
2730
2759
  if (rawItem.itemType !== CHOICE_ITEM_TYPE) return void 0;
@@ -2735,11 +2764,14 @@ const withFormFieldGroupsForTarget = (rawItem, fieldGroups, optionId) => {
2735
2764
  const embeddedForm = isRecord$2(option.embeddedForm) ? option.embeddedForm : {};
2736
2765
  options[optionIndex] = {
2737
2766
  ...option,
2738
- embeddedForm: {
2739
- id: optionId,
2740
- ...embeddedForm,
2741
- fieldGroups
2742
- }
2767
+ embeddedForm: transitionFormAuthoringMode({
2768
+ config: {
2769
+ id: optionId,
2770
+ ...embeddedForm
2771
+ },
2772
+ fieldGroups,
2773
+ preserveSimpleMode
2774
+ })
2743
2775
  };
2744
2776
  return withChoiceOptionsOnRawItem(rawItem, options);
2745
2777
  };
@@ -2751,6 +2783,10 @@ const normalizeFormOperationTranslations = (operation, optionId) => ({
2751
2783
  contentKey: normalizeFormTranslationKey(translation.contentKey.trim(), optionId)
2752
2784
  }))
2753
2785
  });
2786
+ const normalizeEmbeddedFormAuthoringMode = (operation) => ({
2787
+ ...operation,
2788
+ form: normalizeFormAuthoringConfig(operation.form) ?? operation.form
2789
+ });
2754
2790
  const getItemTranslationText = ({ contentKey, itemId, locale, snapshot }) => {
2755
2791
  const content = new SurveyTranslations(snapshot.survey.translations).getItemTranslations(itemId)?.getContent(locale, contentKey);
2756
2792
  return content ? getContentPlainText(content).trim() : void 0;
@@ -2920,7 +2956,7 @@ const getChoiceCreateContentIssue = ({ operation, operationId, operationIndex, i
2920
2956
  if (missingLabelOptionIds.length === 0) return;
2921
2957
  return createIssue({
2922
2958
  code: "missing-choice-option-labels",
2923
- message: `New choice item options need visible labels. Missing labels for option id(s): ${missingLabelOptionIds.join(", ")}.`,
2959
+ message: `New choice item options need visible labels. Missing labels for option id(s): ${formatBoundedDiagnosticValues(missingLabelOptionIds)}.`,
2924
2960
  operationIndex,
2925
2961
  operationId,
2926
2962
  itemId
@@ -3001,7 +3037,7 @@ const getFormOperationContentIssue = ({ fieldGroups, itemId, operationId, operat
3001
3037
  if (missingContentKeys.length === 0) return;
3002
3038
  return createIssue({
3003
3039
  code: "missing-form-field-content",
3004
- message: "Introduced form fields and select options need visible labels. Add non-empty translations for: " + missingContentKeys.join(", ") + ".",
3040
+ message: "Introduced form fields and select options need visible labels. Add non-empty translations for: " + formatBoundedDiagnosticValues(missingContentKeys) + ".",
3005
3041
  operationIndex,
3006
3042
  operationId,
3007
3043
  itemId
@@ -3068,6 +3104,7 @@ const getMissingFieldGroupsWrapperHint = (record) => {
3068
3104
  const hasNonEmptyCreateTranslation = (translations, contentKey) => (translations ?? []).some((translation) => translation.contentKey === contentKey && getTranslationPlainText(translation).length > 0);
3069
3105
  const getFormCreateFieldLabelIssue = ({ operation, operationId, operationIndex, itemId }) => {
3070
3106
  if (operation.item.itemType !== FORM_ITEM_TYPE || !isRecord$2(operation.item.config)) return;
3107
+ if (operation.item.config.authoringMode === "custom" && Array.isArray(operation.item.config.fieldGroups) && operation.item.config.fieldGroups.length === 1 && isRecord$2(operation.item.config.fieldGroups[0]) && Array.isArray(operation.item.config.fieldGroups[0].fields) && operation.item.config.fieldGroups[0].fields.length === 0) return;
3071
3108
  const wrapperHint = getMissingFieldGroupsWrapperHint(operation.item.config);
3072
3109
  if (wrapperHint) return createIssue({
3073
3110
  code: "missing-field-groups-wrapper",
@@ -3084,7 +3121,7 @@ const getFormCreateFieldLabelIssue = ({ operation, operationId, operationIndex,
3084
3121
  });
3085
3122
  if (missingSelectOptionLabelContentKeys.length > 0) return createIssue({
3086
3123
  code: "missing-form-select-option-labels",
3087
- message: `New form item select options need visible labels. Add translations for: ` + missingSelectOptionLabelContentKeys.join(", ") + ".",
3124
+ message: `New form item select options need visible labels. Add translations for: ` + formatBoundedDiagnosticValues(missingSelectOptionLabelContentKeys) + ".",
3088
3125
  operationIndex,
3089
3126
  operationId,
3090
3127
  itemId
@@ -3092,7 +3129,7 @@ const getFormCreateFieldLabelIssue = ({ operation, operationId, operationIndex,
3092
3129
  if (missingLabelFieldIds.length === 0) return;
3093
3130
  return createIssue({
3094
3131
  code: "missing-form-field-labels",
3095
- message: `New form item fields need visible labels. Add a translation with contentKey "fields.<fieldId>.label" for field id(s): ${missingLabelFieldIds.join(", ")}.`,
3132
+ message: `New form item fields need visible labels. Add a translation with contentKey "fields.<fieldId>.label" for field id(s): ${formatBoundedDiagnosticValues(missingLabelFieldIds)}.`,
3096
3133
  operationIndex,
3097
3134
  operationId,
3098
3135
  itemId
@@ -3128,7 +3165,7 @@ const getChoiceEmbeddedFormFieldLabelIssue = ({ operation, operationId, operatio
3128
3165
  if (missingLabelContentKeys.length === 0) return;
3129
3166
  return createIssue({
3130
3167
  code: "missing-embedded-form-field-labels",
3131
- message: `New embedded form fields and select options need visible labels, otherwise respondents only see raw keys. Add translations for: ${missingLabelContentKeys.join(", ")}.`,
3168
+ message: `New embedded form fields and select options need visible labels, otherwise respondents only see raw keys. Add translations for: ${formatBoundedDiagnosticValues(missingLabelContentKeys)}.`,
3132
3169
  operationIndex,
3133
3170
  operationId,
3134
3171
  itemId
@@ -3330,6 +3367,7 @@ const normalizeOperation = (operation, resolvedItemId) => {
3330
3367
  locale: operation.locale.trim(),
3331
3368
  contentKey: operation.contentKey.trim()
3332
3369
  };
3370
+ case "add-survey-locale":
3333
3371
  case "remove-survey-locale": return {
3334
3372
  ...operation,
3335
3373
  locale: operation.locale.trim()
@@ -3411,9 +3449,11 @@ const validateSurveyOperations = ({ maxOperations = 200, operations, snapshot: s
3411
3449
  const createdKeysByParentId = /* @__PURE__ */ new Map();
3412
3450
  const plannedKeys = /* @__PURE__ */ new Map();
3413
3451
  const deletedItemIds = /* @__PURE__ */ new Set();
3452
+ const plannedLocales = new Set(snapshot.inspection.locales);
3414
3453
  const introducedLocaleWarnings = /* @__PURE__ */ new Set();
3415
3454
  const addIntroducedLocaleWarning = ({ itemId, locale, operationId, operationIndex }) => {
3416
- if (snapshot.inspection.locales.includes(locale)) return;
3455
+ if (plannedLocales.has(locale)) return;
3456
+ plannedLocales.add(locale);
3417
3457
  const warningKey = `${operationIndex}:${itemId ?? ""}:${locale}`;
3418
3458
  if (introducedLocaleWarnings.has(warningKey)) return;
3419
3459
  introducedLocaleWarnings.add(warningKey);
@@ -3680,25 +3720,45 @@ const validateSurveyOperations = ({ maxOperations = 200, operations, snapshot: s
3680
3720
  return;
3681
3721
  }
3682
3722
  break;
3683
- case "remove-survey-locale":
3684
- if (!snapshot.inspection.locales.includes(normalized.locale)) {
3723
+ case "add-survey-locale":
3724
+ if (normalized.locale.length === 0) {
3685
3725
  issues.push(createIssue({
3686
- code: "survey-locale-not-found",
3687
- message: `Locale "${normalized.locale}" is not present in the survey.`,
3726
+ code: "invalid-survey-locale",
3727
+ message: "Survey locale cannot be blank.",
3728
+ operationIndex,
3729
+ operationId
3730
+ }));
3731
+ return;
3732
+ }
3733
+ if (plannedLocales.has(normalized.locale)) {
3734
+ issues.push(createIssue({
3735
+ code: "survey-locale-already-exists",
3736
+ message: `Locale "${normalized.locale}" is already present in the survey.`,
3688
3737
  operationIndex,
3689
3738
  operationId
3690
3739
  }));
3691
3740
  return;
3692
3741
  }
3693
- if (snapshot.inspection.locales.length <= 1) {
3742
+ plannedLocales.add(normalized.locale);
3743
+ warnings.push(createIssue({
3744
+ severity: "warning",
3745
+ code: "empty-locale-registration",
3746
+ message: `This operation only registers an empty "${normalized.locale}" locale. It does not complete a request to localize or translate existing respondent-facing content; continue with real translation operations unless the user explicitly requested an empty registration.`,
3747
+ operationIndex,
3748
+ operationId
3749
+ }));
3750
+ break;
3751
+ case "remove-survey-locale":
3752
+ if (!plannedLocales.has(normalized.locale)) {
3694
3753
  issues.push(createIssue({
3695
- code: "cannot-remove-last-survey-locale",
3696
- message: "The survey's final locale cannot be removed.",
3754
+ code: "survey-locale-not-found",
3755
+ message: `Locale "${normalized.locale}" is not present in the survey.`,
3697
3756
  operationIndex,
3698
3757
  operationId
3699
3758
  }));
3700
3759
  return;
3701
3760
  }
3761
+ plannedLocales.delete(normalized.locale);
3702
3762
  break;
3703
3763
  case "update-image-asset-metadata":
3704
3764
  case "remove-image-asset":
@@ -3749,7 +3809,7 @@ const validateSurveyOperations = ({ maxOperations = 200, operations, snapshot: s
3749
3809
  const normalized = rawNormalized.kind === "update-item-translation" || rawNormalized.kind === "update-placed-image" ? {
3750
3810
  ...rawNormalized,
3751
3811
  contentKey: normalizeContentKeyForItemType(rawNormalized.contentKey, itemTypeManifestByType.get(rawItem.itemType))
3752
- } : rawNormalized.kind === "upsert-form-field-group" || rawNormalized.kind === "upsert-form-field" ? normalizeFormOperationTranslations(rawNormalized, rawNormalized.target.optionId) : rawNormalized.kind === "set-embedded-form" ? normalizeFormOperationTranslations(rawNormalized, rawNormalized.optionId) : rawNormalized;
3812
+ } : rawNormalized.kind === "upsert-form-field-group" || rawNormalized.kind === "upsert-form-field" ? normalizeFormOperationTranslations(rawNormalized, rawNormalized.target.optionId) : rawNormalized.kind === "set-embedded-form" ? normalizeEmbeddedFormAuthoringMode(normalizeFormOperationTranslations(rawNormalized, rawNormalized.optionId)) : rawNormalized;
3753
3813
  if (!itemTypeManifestByType.get(rawItem.itemType)?.supportedOperations.includes(normalized.kind)) {
3754
3814
  issues.push(createIssue({
3755
3815
  code: "operation-not-supported-for-item-type",
@@ -4076,7 +4136,12 @@ const validateSurveyOperations = ({ maxOperations = 200, operations, snapshot: s
4076
4136
  ...existingField,
4077
4137
  ...normalized.field
4078
4138
  });
4079
- const nextItem = withFormFieldGroupsForTarget(rawItem, nextGroups, normalized.target.optionId);
4139
+ const nextItem = withFormFieldGroupsForTarget(rawItem, nextGroups, normalized.target.optionId, isCompatibleSimpleFieldUpsert({
4140
+ config: getFormConfigForTarget(rawItem, normalized.target.optionId),
4141
+ groupId: normalized.groupId,
4142
+ field: normalized.field,
4143
+ index: normalized.index
4144
+ }));
4080
4145
  if (nextItem) rawItemsById.set(item.itemId, nextItem);
4081
4146
  for (const translation of normalized.translations ?? []) addIntroducedLocaleWarning({
4082
4147
  locale: translation.locale,
@@ -4453,6 +4518,16 @@ const validateSurveyOperations = ({ maxOperations = 200, operations, snapshot: s
4453
4518
  break;
4454
4519
  }
4455
4520
  const previousParentId = parentByChildId.get(item.itemId);
4521
+ if (previousParentId === parent.itemId) {
4522
+ issues.push(createIssue({
4523
+ code: "item-already-in-target-parent",
4524
+ message: "The item is already in the target parent. Omit an unchanged placement or use reorder-items with the parent's complete child order.",
4525
+ operationIndex,
4526
+ operationId,
4527
+ itemId: item.itemId
4528
+ }));
4529
+ break;
4530
+ }
4456
4531
  if (previousParentId) {
4457
4532
  const previousParent = rawItemsById.get(previousParentId);
4458
4533
  if (previousParent) rawItemsById.set(previousParentId, {
@@ -4858,7 +4933,12 @@ const applyValidatedSurveyOperationsToDraft = ({ operations, survey: inputSurvey
4858
4933
  survey
4859
4934
  });
4860
4935
  }
4861
- replaceItem(withFormFieldGroupsForTarget(item, groups, operation.target.optionId));
4936
+ replaceItem(withFormFieldGroupsForTarget(item, groups, operation.target.optionId, operation.kind === "upsert-form-field" && isCompatibleSimpleFieldUpsert({
4937
+ config: getFormConfigForTarget(item, operation.target.optionId),
4938
+ groupId: operation.groupId,
4939
+ field: operation.field,
4940
+ index: operation.index
4941
+ })));
4862
4942
  break;
4863
4943
  }
4864
4944
  case "remove-item": {
@@ -4935,6 +5015,12 @@ const applyValidatedSurveyOperationsToDraft = ({ operations, survey: inputSurvey
4935
5015
  };
4936
5016
  break;
4937
5017
  }
5018
+ case "add-survey-locale":
5019
+ survey.translations = {
5020
+ ...survey.translations ?? {},
5021
+ [operation.locale]: {}
5022
+ };
5023
+ break;
4938
5024
  case "remove-survey-locale": {
4939
5025
  const translations = { ...survey.translations ?? {} };
4940
5026
  delete translations[operation.locale];
@@ -4990,677 +5076,794 @@ const createSurveyChangeSet = ({ maxOperations, operations, snapshot, summary, t
4990
5076
  };
4991
5077
  };
4992
5078
  //#endregion
4993
- //#region src/engine/turn-survey-draft.ts
4994
- const createId = () => globalThis.crypto.randomUUID();
4995
- const operationTargets = (operation) => {
4996
- switch (operation.kind) {
4997
- case "create-item": return [`item:${operation.item.id}`];
4998
- case "update-item-key":
4999
- case "update-item-label":
5000
- case "update-item-color":
5001
- case "update-item-translation":
5002
- case "update-placed-image":
5003
- case "patch-item-config":
5004
- case "add-choice-option":
5005
- case "update-choice-option-label":
5006
- case "remove-choice-option":
5007
- case "reorder-choice-options":
5008
- case "set-embedded-form":
5009
- case "remove-embedded-form":
5010
- case "remove-item":
5011
- case "move-item": return [`item:${operation.itemId}`];
5012
- case "upsert-form-field-group":
5013
- case "remove-form-field-group":
5014
- case "reorder-form-field-groups":
5015
- case "upsert-form-field":
5016
- case "remove-form-field":
5017
- case "move-form-field": return [`item:${operation.target.itemId}`];
5018
- case "reorder-items": return [`item:${operation.parentItemId}`];
5019
- case "set-image-asset":
5020
- case "update-image-asset-metadata":
5021
- case "remove-image-asset": return [`asset:${operation.assetId}`];
5022
- case "update-survey-metadata":
5023
- case "update-survey-pagination":
5024
- case "update-survey-translation":
5025
- case "remove-survey-locale": return ["survey"];
5026
- case "survey-json-patch": return [];
5079
+ //#region src/engine/audit.ts
5080
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5081
+ const records = (value) => Array.isArray(value) ? value.filter(isRecord$1) : [];
5082
+ const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
5083
+ const QUALITY_PRINCIPLES = [
5084
+ {
5085
+ id: "purpose-alignment",
5086
+ title: "Purpose and construct alignment",
5087
+ prompt: "Does every question serve a stated objective, and does it measure the intended construct?"
5088
+ },
5089
+ {
5090
+ id: "cognitive-burden",
5091
+ title: "Respondent burden",
5092
+ prompt: "Is the survey as short and cognitively simple as its purpose allows?"
5093
+ },
5094
+ {
5095
+ id: "question-wording",
5096
+ title: "Clear, neutral wording",
5097
+ prompt: "Are questions specific, neutral, answerable, and limited to one idea without assumptions or double negatives?"
5098
+ },
5099
+ {
5100
+ id: "recall",
5101
+ title: "Recall and reference periods",
5102
+ prompt: "Are recall periods explicit, realistic, and consistent with what respondents can remember?"
5103
+ },
5104
+ {
5105
+ id: "response-options",
5106
+ title: "Response option quality",
5107
+ prompt: "Are options mutually exclusive, collectively sufficient, balanced, ordered, and consistently scaled?"
5108
+ },
5109
+ {
5110
+ id: "flow",
5111
+ title: "Flow and completeness",
5112
+ prompt: "Does the order move logically from easy/general to specific, with needed instructions and no important objective omitted?"
5113
+ },
5114
+ {
5115
+ id: "routing",
5116
+ title: "Requiredness and routing",
5117
+ prompt: "Do required fields, branching, validation, and not-applicable paths avoid forcing inaccurate answers?"
5118
+ },
5119
+ {
5120
+ id: "sensitivity",
5121
+ title: "Sensitivity and ethics",
5122
+ prompt: "Are sensitive questions justified, respectfully phrased, appropriately optional, and paired with privacy context where needed?"
5123
+ },
5124
+ {
5125
+ id: "accessibility",
5126
+ title: "Accessibility and inclusion",
5127
+ prompt: "Is language plain and inclusive, and are content, controls, and images understandable without avoidable barriers?"
5128
+ },
5129
+ {
5130
+ id: "localization",
5131
+ title: "Cross-language equivalence",
5132
+ prompt: "Do translations preserve meaning and cultural relevance rather than merely matching words?"
5133
+ },
5134
+ {
5135
+ id: "testing",
5136
+ title: "Testing and validation",
5137
+ prompt: "Has the instrument been cognitively tested, piloted, and checked for reliability or validity where the intended use requires it?"
5027
5138
  }
5139
+ ];
5140
+ const translationText = (context, locale, itemId, contentKey) => {
5141
+ const localeTranslations = isRecord$1(context.survey.translations?.[locale]) ? context.survey.translations?.[locale] : void 0;
5142
+ const itemTranslations = isRecord$1(localeTranslations?.itemTranslations) ? localeTranslations.itemTranslations : void 0;
5143
+ const content = (isRecord$1(itemTranslations?.[itemId]) ? itemTranslations[itemId] : void 0)?.[contentKey];
5144
+ return content ? getContentPlainText(content).trim() : "";
5028
5145
  };
5029
- const collectOperationTargets = (operations) => new Set(operations.flatMap(operationTargets));
5030
- /**
5031
- * Request-scoped transactional draft for one assistant response.
5032
- *
5033
- * Mutation tools submit deltas. A successful delta replaces the previous
5034
- * candidate with a newly validated, cumulative change set. A rejected delta
5035
- * leaves the accepted draft intact and blocks authorization until a later
5036
- * delta validates. Only the latest cumulative candidate can be authorized.
5037
- */
5038
- var TurnSurveyDraft = class {
5039
- turnId;
5040
- #candidates = /* @__PURE__ */ new Map();
5041
- #draftOperations = [];
5042
- #context;
5043
- #latestChangeSetId;
5044
- #unresolvedCandidates = /* @__PURE__ */ new Map();
5045
- #final = null;
5046
- constructor(turnId = createId()) {
5047
- this.turnId = turnId;
5048
- }
5049
- initialize(context) {
5050
- if (this.#context) throw new Error("The survey turn draft was initialized more than once.");
5051
- this.#context = context;
5052
- }
5053
- stage({ addressedTargets = [], operations, snapshot, summary }) {
5054
- this.#assertOpen();
5055
- if (!this.#context && snapshot && "document" in snapshot && "trustedCapabilities" in snapshot) this.#context = snapshot;
5056
- const currentContext = this.#context ?? snapshot;
5057
- const targets = new Set([...collectOperationTargets(operations), ...addressedTargets]);
5058
- const delta = createSurveyChangeSet({
5059
- operations,
5060
- snapshot: currentContext,
5061
- summary,
5062
- turnId: this.turnId
5146
+ const getGroups = (item, optionId) => {
5147
+ const config = isRecord$1(item.config) ? item.config : {};
5148
+ if (!optionId) return records(config.fieldGroups);
5149
+ const option = records(config.options).find((entry) => entry.id === optionId);
5150
+ return records(isRecord$1(option?.embeddedForm) ? option.embeddedForm.fieldGroups : void 0);
5151
+ };
5152
+ const auditSurvey = (context, options = {}) => {
5153
+ const findings = [];
5154
+ const add = (finding) => findings.push(finding);
5155
+ const items = context.survey.surveyItems;
5156
+ const itemsById = /* @__PURE__ */ new Map();
5157
+ const parentById = /* @__PURE__ */ new Map();
5158
+ const itemTypes = {};
5159
+ for (const item of items) {
5160
+ itemTypes[item.itemType] = (itemTypes[item.itemType] ?? 0) + 1;
5161
+ if (itemsById.has(item.id)) add({
5162
+ code: "duplicate-item-id",
5163
+ severity: "error",
5164
+ category: "structure",
5165
+ itemId: item.id,
5166
+ message: `Item id "${item.id}" is duplicated.`
5063
5167
  });
5064
- if (delta.status !== "validated" || !delta.validation.valid) return this.reject(delta, targets);
5065
- const cumulativeOperations = [...this.#draftOperations, ...delta.operations];
5066
- if (cumulativeOperations.length > 1e3) return this.reject(createSurveyChangeSet({
5067
- maxOperations: MAX_SURVEY_ASSISTANT_OPERATIONS_PER_TURN_DRAFT,
5068
- operations: cumulativeOperations,
5069
- snapshot: currentContext,
5070
- summary,
5071
- turnId: this.turnId
5072
- }), targets);
5073
- if (this.#context) {
5074
- const survey = applyValidatedSurveyOperationsToDraft({
5075
- operations: delta.operations,
5076
- survey: this.#context.survey
5168
+ itemsById.set(item.id, item);
5169
+ }
5170
+ for (const parent of items) {
5171
+ const seenChildren = /* @__PURE__ */ new Set();
5172
+ for (const childId of strings(isRecord$1(parent.config) ? parent.config.items : void 0)) {
5173
+ if (seenChildren.has(childId)) add({
5174
+ code: "duplicate-child-reference",
5175
+ severity: "error",
5176
+ category: "structure",
5177
+ itemId: parent.id,
5178
+ message: `Child "${childId}" is listed more than once.`
5077
5179
  });
5078
- this.#context = createSurveyAssistantEngineContext({
5079
- ...this.#context.document,
5080
- survey
5081
- }, this.#context.trustedCapabilities);
5180
+ seenChildren.add(childId);
5181
+ if (!itemsById.has(childId)) add({
5182
+ code: "missing-child",
5183
+ severity: "error",
5184
+ category: "structure",
5185
+ itemId: parent.id,
5186
+ message: `Referenced child "${childId}" does not exist.`
5187
+ });
5188
+ else if (parentById.has(childId)) add({
5189
+ code: "multiple-parents",
5190
+ severity: "error",
5191
+ category: "structure",
5192
+ itemId: childId,
5193
+ message: `Item is referenced by multiple parents.`
5194
+ });
5195
+ else parentById.set(childId, parent.id);
5082
5196
  }
5083
- const registered = surveyChangeSetSchema.parse({
5084
- ...delta,
5085
- operations: cumulativeOperations,
5086
- validation: {
5087
- valid: true,
5088
- issues: [],
5089
- warnings: delta.validation.warnings,
5090
- normalizedOperations: cumulativeOperations
5091
- },
5092
- ...this.#latestChangeSetId ? { supersedesChangeIds: [this.#latestChangeSetId] } : {}
5093
- });
5094
- this.#candidates.set(registered.id, registered);
5095
- this.#draftOperations = registered.operations;
5096
- this.#latestChangeSetId = registered.id;
5097
- for (const [changeSetId, unresolvedTargets] of this.#unresolvedCandidates) if (unresolvedTargets.size === 0 || [...unresolvedTargets].some((target) => targets.has(target))) this.#unresolvedCandidates.delete(changeSetId);
5098
- return registered;
5099
5197
  }
5100
- reject(candidate, addressedTargets = []) {
5101
- this.#assertOpen();
5102
- if (this.#candidates.has(candidate.id)) throw new Error(`Survey change "${candidate.id}" was registered more than once.`);
5103
- const registered = surveyChangeSetSchema.parse({
5104
- ...candidate,
5105
- turnId: this.turnId,
5106
- status: "rejected",
5107
- validation: {
5108
- ...candidate.validation,
5109
- valid: false
5110
- }
5111
- });
5112
- this.#candidates.set(registered.id, registered);
5113
- this.#unresolvedCandidates.set(registered.id, new Set(addressedTargets));
5114
- return registered;
5115
- }
5116
- finalize(status, createdAt = (/* @__PURE__ */ new Date()).toISOString()) {
5117
- if (this.#final) return this.#final;
5118
- const blockedChangeSetIds = [...this.#unresolvedCandidates.keys()];
5119
- const canAuthorize = status === "completed" && blockedChangeSetIds.length === 0 && this.#latestChangeSetId !== void 0;
5120
- this.#final = surveyAssistantTurnFinalSchema.parse({
5121
- protocolVersion: 1,
5122
- turnId: this.turnId,
5123
- status,
5124
- executableChangeSetIds: canAuthorize ? [this.#latestChangeSetId] : [],
5125
- ...blockedChangeSetIds.length > 0 ? { blockedChangeSetIds } : {},
5126
- createdAt
5127
- });
5128
- return this.#final;
5129
- }
5130
- getCandidate(changeSetId) {
5131
- return this.#candidates.get(changeSetId);
5132
- }
5133
- get draftOperations() {
5134
- return this.#draftOperations;
5135
- }
5136
- get context() {
5137
- return this.#context;
5138
- }
5139
- get final() {
5140
- return this.#final;
5141
- }
5142
- #assertOpen() {
5143
- if (this.#final) throw new Error("Cannot register a survey change after the turn was finalized.");
5144
- }
5145
- };
5146
- //#endregion
5147
- //#region src/engine/task-context.ts
5148
- const MAX_RELATED_ITEMS = 48;
5149
- const MAX_SIBLING_ITEMS = 80;
5150
- const MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM = 24;
5151
- const MAX_VISIBLE_TEXT_CHARS = 600;
5152
- const MAX_NAMING_EXAMPLES = 40;
5153
- const MAX_TRANSLATION_EXAMPLES = 60;
5154
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5155
- const getItemLabel = (item) => {
5156
- if (!isRecord$1(item.metadata)) return void 0;
5157
- const label = item.metadata.itemLabel;
5158
- return typeof label === "string" && label.trim().length > 0 ? label.trim() : void 0;
5159
- };
5160
- const getCenteredNeighbors = (values, focusIndex, limit) => {
5161
- if (focusIndex < 0 || limit < 1) return [];
5162
- const windowSize = Math.min(values.length, limit + 1);
5163
- const idealStart = focusIndex - Math.floor(limit / 2);
5164
- const start = Math.min(Math.max(0, idealStart), values.length - windowSize);
5165
- return values.slice(start, start + windowSize).filter((_, index) => start + index !== focusIndex);
5166
- };
5167
- const buildSurveyAssistantTaskContext = (snapshot, options) => {
5168
- const { inspection } = createSurveyAssistantEngineContext(snapshot, void 0, { focusItemId: options.focusItemId });
5169
- const itemsById = new Map(snapshot.survey.surveyItems.map((item) => [item.id, item]));
5170
- const parentById = new Map(inspection.outline.flatMap((item) => item.parentId ? [[item.itemId, item.parentId]] : []));
5171
- const childIdsByParent = /* @__PURE__ */ new Map();
5172
- for (const item of inspection.outline) {
5173
- if (!item.parentId) continue;
5174
- const childIds = childIdsByParent.get(item.parentId) ?? [];
5175
- childIds.push(item.itemId);
5176
- childIdsByParent.set(item.parentId, childIds);
5177
- }
5178
- const fullKeyById = new Map(inspection.keyIndex.map((item) => [item.itemId, item.fullKey]));
5179
- const summarizeItem = (item) => {
5180
- const allVisibleText = [];
5181
- let visibleTextValueTruncated = false;
5182
- for (const [locale, translations] of Object.entries(snapshot.survey.translations ?? {})) {
5183
- const itemTranslations = translations.itemTranslations?.[item.id];
5184
- if (!itemTranslations) continue;
5185
- for (const [contentKey, content] of Object.entries(itemTranslations)) {
5186
- const text = getContentPlainText(content).trim();
5187
- if (text) {
5188
- if (text.length > MAX_VISIBLE_TEXT_CHARS) visibleTextValueTruncated = true;
5189
- allVisibleText.push({
5190
- locale,
5191
- contentKey,
5192
- text: text.length > MAX_VISIBLE_TEXT_CHARS ? `${text.slice(0, MAX_VISIBLE_TEXT_CHARS)}...` : text
5193
- });
5194
- }
5195
- }
5196
- }
5197
- return {
5198
- id: item.id,
5199
- key: item.key,
5200
- fullKey: fullKeyById.get(item.id) ?? item.key,
5201
- itemType: item.itemType,
5202
- itemLabel: getItemLabel(item),
5203
- visibleText: allVisibleText.slice(0, MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM),
5204
- visibleTextTruncated: allVisibleText.length > MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM || visibleTextValueTruncated
5205
- };
5206
- };
5207
- const rootItemId = inspection.outline.find((item) => item.depth === 0)?.itemId;
5208
- const rootItem = rootItemId ? itemsById.get(rootItemId) : void 0;
5209
- const result = {
5210
- document: {
5211
- revision: snapshot.version.revision,
5212
- contentHash: snapshot.version.contentHash,
5213
- capabilityDigest: snapshot.version.capabilityDigest
5214
- },
5215
- survey: {
5216
- key: rootItem?.key,
5217
- locales: inspection.locales,
5218
- itemCount: inspection.itemCount,
5219
- selectedLocale: snapshot.ui.selectedLocale
5220
- }
5221
- };
5222
- const focusItem = options.focusItemId ? itemsById.get(options.focusItemId) : void 0;
5223
- if (focusItem) {
5224
- const parentId = parentById.get(focusItem.id);
5225
- const parentItem = parentId ? itemsById.get(parentId) : void 0;
5226
- const ancestorItems = [];
5227
- const seenAncestorIds = /* @__PURE__ */ new Set();
5228
- let ancestorId = parentId;
5229
- while (ancestorId && !seenAncestorIds.has(ancestorId)) {
5230
- seenAncestorIds.add(ancestorId);
5231
- const ancestor = itemsById.get(ancestorId);
5232
- if (!ancestor) break;
5233
- ancestorItems.unshift(ancestor);
5234
- ancestorId = parentById.get(ancestorId);
5235
- }
5236
- const siblingIds = parentItem ? childIdsByParent.get(parentItem.id) ?? [] : [];
5237
- const siblingItemCount = Math.max(0, siblingIds.length - 1);
5238
- const siblingItems = getCenteredNeighbors(siblingIds, siblingIds.indexOf(focusItem.id), MAX_SIBLING_ITEMS).map((itemId) => itemsById.get(itemId)).filter((item) => Boolean(item));
5239
- const allRelatedItems = [];
5240
- const seenRelatedIds = /* @__PURE__ */ new Set();
5241
- const pending = [...childIdsByParent.get(focusItem.id) ?? []];
5242
- while (pending.length > 0) {
5243
- const itemId = pending.shift();
5244
- const item = itemId ? itemsById.get(itemId) : void 0;
5245
- if (!item || seenRelatedIds.has(item.id)) continue;
5246
- seenRelatedIds.add(item.id);
5247
- allRelatedItems.push(item);
5248
- pending.push(...childIdsByParent.get(item.id) ?? []);
5198
+ const roots = items.filter((item) => !parentById.has(item.id));
5199
+ if (roots.length !== 1) add({
5200
+ code: "root-count",
5201
+ severity: "error",
5202
+ category: "structure",
5203
+ message: `Expected one survey root but found ${roots.length}.`
5204
+ });
5205
+ for (const parent of items) {
5206
+ const siblingKeys = /* @__PURE__ */ new Map();
5207
+ for (const childId of strings(isRecord$1(parent.config) ? parent.config.items : void 0)) {
5208
+ const child = itemsById.get(childId);
5209
+ if (!child) continue;
5210
+ const existing = siblingKeys.get(child.key);
5211
+ if (existing) add({
5212
+ code: "duplicate-sibling-key",
5213
+ severity: "error",
5214
+ category: "structure",
5215
+ itemId: child.id,
5216
+ message: `Key "${child.key}" duplicates sibling "${existing}".`
5217
+ });
5218
+ siblingKeys.set(child.key, child.id);
5249
5219
  }
5250
- const relatedItems = allRelatedItems.slice(0, MAX_RELATED_ITEMS);
5251
- result.focus = {
5252
- item: summarizeItem(focusItem),
5253
- parent: parentItem ? summarizeItem(parentItem) : void 0,
5254
- ancestorPath: ancestorItems.map(summarizeItem),
5255
- siblingKeys: siblingItems.map((item) => item.key),
5256
- siblingItems: siblingItems.map(summarizeItem),
5257
- siblingItemCount,
5258
- siblingItemsTruncated: siblingItemCount > siblingItems.length,
5259
- relatedItems: relatedItems.map(summarizeItem),
5260
- relatedItemCount: allRelatedItems.length,
5261
- relatedItemsTruncated: allRelatedItems.length > relatedItems.length
5262
- };
5263
5220
  }
5264
- if ((options.purpose === "suggest-item-key" || options.purpose === "suggest-item-label") && focusItem) {
5265
- const parentId = parentById.get(focusItem.id);
5266
- const siblingIds = parentId ? childIdsByParent.get(parentId) ?? [] : [];
5267
- const siblingPositionById = new Map(siblingIds.map((itemId, index) => [itemId, index]));
5268
- const focusSiblingPosition = siblingPositionById.get(focusItem.id);
5269
- const rankedItems = snapshot.survey.surveyItems.filter((item) => item.id !== focusItem.id).map((item, index) => ({
5270
- item,
5271
- index,
5272
- siblingDistance: focusSiblingPosition !== void 0 && siblingPositionById.has(item.id) ? Math.abs(siblingPositionById.get(item.id) - focusSiblingPosition) : Number.POSITIVE_INFINITY,
5273
- relationship: parentById.get(item.id) === parentId ? "sibling" : item.itemType === focusItem.itemType ? "same-type" : "survey"
5274
- })).sort((left, right) => {
5275
- const rank = {
5276
- sibling: 0,
5277
- "same-type": 1,
5278
- survey: 2
5279
- };
5280
- return rank[left.relationship] - rank[right.relationship] || left.siblingDistance - right.siblingDistance || left.index - right.index;
5281
- });
5282
- const allKeyExamples = rankedItems.map(({ item, relationship }) => ({
5283
- itemId: item.id,
5284
- itemType: item.itemType,
5285
- key: item.key,
5286
- relationship
5287
- }));
5288
- const allLabelExamples = rankedItems.flatMap(({ item, relationship }) => {
5289
- const label = getItemLabel(item);
5290
- return label ? [{
5221
+ const locales = context.inspection.locales;
5222
+ const sourceLocale = options.sourceLocale ?? context.ui.selectedLocale ?? locales[0];
5223
+ const targetLocales = options.targetLocales ?? locales.filter((locale) => locale !== sourceLocale);
5224
+ const manifests = new Map(context.capabilities.itemTypes.map((entry) => [entry.itemType, entry]));
5225
+ for (const item of items) {
5226
+ const manifest = manifests.get(item.itemType);
5227
+ if (!manifest) {
5228
+ add({
5229
+ code: "unsupported-item-type",
5230
+ severity: "warning",
5231
+ category: "structure",
5291
5232
  itemId: item.id,
5292
- itemType: item.itemType,
5293
- key: item.key,
5294
- label,
5295
- relationship
5296
- }] : [];
5233
+ message: `Item type "${item.itemType}" is not advertised by the active editor.`
5234
+ });
5235
+ continue;
5236
+ }
5237
+ for (const surface of manifest.contentSurfaces.filter((entry) => entry.requiredOnCreate && !entry.pattern)) if (!translationText(context, sourceLocale, item.id, surface.key)) add({
5238
+ code: "missing-primary-content",
5239
+ severity: "warning",
5240
+ category: "content",
5241
+ itemId: item.id,
5242
+ locale: sourceLocale,
5243
+ contentKey: surface.key,
5244
+ message: `Required respondent content "${surface.key}" is empty in ${sourceLocale}.`
5297
5245
  });
5298
- result.naming = {
5299
- keyExamples: allKeyExamples.slice(0, MAX_NAMING_EXAMPLES),
5300
- keyExampleCount: allKeyExamples.length,
5301
- keyExamplesTruncated: allKeyExamples.length > MAX_NAMING_EXAMPLES,
5302
- labelExamples: allLabelExamples.slice(0, MAX_NAMING_EXAMPLES),
5303
- labelExampleCount: allLabelExamples.length,
5304
- labelExamplesTruncated: allLabelExamples.length > MAX_NAMING_EXAMPLES
5305
- };
5306
- }
5307
- const sourceLocale = options.sourceLocale;
5308
- if (options.purpose === "translate-content" && sourceLocale) {
5309
- const relevantLocales = [sourceLocale, ...options.targetLocales ?? []];
5310
- const sourceTokens = new Set((options.sourceTexts ?? []).flatMap((text) => text.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u)).filter((token) => token.length >= 3));
5311
- const allExamples = [];
5312
- snapshot.survey.surveyItems.forEach((item, itemIndex) => {
5313
- const contentKeys = /* @__PURE__ */ new Set();
5314
- for (const locale of relevantLocales) {
5315
- const itemTranslations = snapshot.survey.translations?.[locale]?.itemTranslations?.[item.id];
5316
- for (const contentKey of Object.keys(itemTranslations ?? {})) contentKeys.add(contentKey);
5317
- }
5318
- for (const contentKey of contentKeys) {
5319
- const values = {};
5320
- for (const locale of relevantLocales) {
5321
- const content = snapshot.survey.translations?.[locale]?.itemTranslations?.[item.id]?.[contentKey];
5322
- if (!content) continue;
5323
- const text = getContentPlainText(content).trim();
5324
- if (text) values[locale] = text.slice(0, MAX_VISIBLE_TEXT_CHARS);
5325
- }
5326
- const sourceText = values[sourceLocale];
5327
- if (!sourceText || Object.keys(values).length < 2) continue;
5328
- const normalizedSource = sourceText.toLocaleLowerCase();
5329
- const score = [...sourceTokens].reduce((total, token) => total + (normalizedSource.includes(token) ? 1 : 0), 0);
5330
- allExamples.push({
5246
+ const sourceContent = isRecord$1(context.survey.translations?.[sourceLocale]?.itemTranslations?.[item.id]) ? context.survey.translations?.[sourceLocale]?.itemTranslations?.[item.id] : {};
5247
+ for (const [contentKey, content] of Object.entries(sourceContent ?? {})) {
5248
+ if (!getContentPlainText(content).trim()) continue;
5249
+ for (const locale of targetLocales) if (!translationText(context, locale, item.id, contentKey)) add({
5250
+ code: "missing-translation",
5251
+ severity: "warning",
5252
+ category: "localization",
5253
+ itemId: item.id,
5254
+ locale,
5255
+ contentKey,
5256
+ message: `Content "${contentKey}" has source text but no ${locale} translation.`
5257
+ });
5258
+ }
5259
+ const config = isRecord$1(item.config) ? item.config : {};
5260
+ if (item.itemType === "choiceItem") {
5261
+ const optionsList = records(config.options);
5262
+ if (optionsList.length === 0) add({
5263
+ code: "choice-without-options",
5264
+ severity: "error",
5265
+ category: "content",
5266
+ itemId: item.id,
5267
+ message: "Choice item has no options."
5268
+ });
5269
+ for (const option of optionsList) {
5270
+ if (typeof option.id === "string" && !translationText(context, sourceLocale, item.id, `options.${option.id}.label`)) add({
5271
+ code: "missing-option-label",
5272
+ severity: "warning",
5273
+ category: "content",
5331
5274
  itemId: item.id,
5332
- contentKey,
5333
- values,
5334
- score,
5335
- order: itemIndex
5275
+ locale: sourceLocale,
5276
+ contentKey: `options.${option.id}.label`,
5277
+ message: `Choice option "${option.id}" has no visible label.`
5336
5278
  });
5279
+ if (typeof option.id === "string" && option.embeddedForm) {
5280
+ for (const group of getGroups(item, option.id)) for (const field of records(group.fields)) if (typeof field.id === "string" && !translationText(context, sourceLocale, item.id, `embeddedForms.${option.id}.fields.${field.id}.label`)) add({
5281
+ code: "missing-field-label",
5282
+ severity: "warning",
5283
+ category: "content",
5284
+ itemId: item.id,
5285
+ locale: sourceLocale,
5286
+ contentKey: `embeddedForms.${option.id}.fields.${field.id}.label`,
5287
+ message: `Embedded field "${field.id}" has no visible label.`
5288
+ });
5289
+ }
5337
5290
  }
5291
+ }
5292
+ if (item.itemType === "formItem") {
5293
+ const groups = getGroups(item);
5294
+ if (groups.length === 0) add({
5295
+ code: "form-without-groups",
5296
+ severity: "error",
5297
+ category: "content",
5298
+ itemId: item.id,
5299
+ message: "Form item has no field groups."
5300
+ });
5301
+ for (const group of groups) for (const field of records(group.fields)) if (typeof field.id === "string" && !translationText(context, sourceLocale, item.id, `fields.${field.id}.label`)) add({
5302
+ code: "missing-field-label",
5303
+ severity: "warning",
5304
+ category: "content",
5305
+ itemId: item.id,
5306
+ locale: sourceLocale,
5307
+ contentKey: `fields.${field.id}.label`,
5308
+ message: `Form field "${field.id}" has no visible label.`
5309
+ });
5310
+ }
5311
+ }
5312
+ const assets = context.survey.assets ?? {};
5313
+ for (const [assetId, asset] of Object.entries(assets)) {
5314
+ const metadata = isRecord$1(asset.metadata) ? asset.metadata : {};
5315
+ if (asset.kind === "image" && typeof metadata.alt !== "string") add({
5316
+ code: "missing-image-alt",
5317
+ severity: "warning",
5318
+ category: "assets",
5319
+ message: `Image asset "${assetId}" has no alternative text.`,
5320
+ suggestion: "Add concise alt text, or an empty alt value when the image is purely decorative."
5338
5321
  });
5339
- allExamples.sort((left, right) => right.score - left.score || left.order - right.order);
5340
- result.translation = {
5341
- examples: allExamples.slice(0, MAX_TRANSLATION_EXAMPLES).map(({ itemId, contentKey, values }) => ({
5342
- itemId,
5343
- contentKey,
5344
- values
5345
- })),
5346
- exampleCount: allExamples.length,
5347
- examplesTruncated: allExamples.length > MAX_TRANSLATION_EXAMPLES
5348
- };
5349
5322
  }
5350
- return result;
5323
+ const limited = findings.slice(0, options.maxFindings ?? 200);
5324
+ return {
5325
+ summary: findings.reduce((acc, entry) => {
5326
+ acc[entry.severity] += 1;
5327
+ acc.total += 1;
5328
+ return acc;
5329
+ }, {
5330
+ error: 0,
5331
+ warning: 0,
5332
+ info: 0,
5333
+ total: 0
5334
+ }),
5335
+ findingCount: findings.length,
5336
+ truncated: limited.length < findings.length,
5337
+ metrics: {
5338
+ itemCount: context.inspection.itemCount,
5339
+ localeCount: locales.length,
5340
+ responseSlotCount: context.inspection.responseSlots.length,
5341
+ assetCount: Object.keys(assets).length,
5342
+ itemTypes
5343
+ },
5344
+ findings: limited,
5345
+ qualityReviewPrinciples: QUALITY_PRINCIPLES,
5346
+ note: "Technical findings are deterministic. Quality principles are a structured review rubric; apply them with human/model judgment and domain context, and do not present them as psychometric validation."
5347
+ };
5351
5348
  };
5352
5349
  //#endregion
5353
- //#region src/engine/task-result-cache.ts
5354
- const DEFAULT_TTL_MS = 900 * 1e3;
5355
- const DEFAULT_MAX_ENTRIES = 64;
5356
- /** Small in-memory LRU cache for deterministic, snapshot-scoped one-shot results. */
5357
- var SurveyAssistantTaskResultCache = class {
5358
- entries = /* @__PURE__ */ new Map();
5359
- ttlMs;
5360
- maxEntries;
5361
- now;
5362
- constructor(options = {}) {
5363
- this.ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_TTL_MS);
5364
- this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
5365
- this.now = options.now ?? Date.now;
5350
+ //#region src/engine/turn-survey-draft.ts
5351
+ const createId = () => globalThis.crypto.randomUUID();
5352
+ const operationTargets = (operation) => {
5353
+ switch (operation.kind) {
5354
+ case "create-item": return [`item:${operation.item.id}`];
5355
+ case "update-item-key":
5356
+ case "update-item-label":
5357
+ case "update-item-color":
5358
+ case "update-item-translation":
5359
+ case "update-placed-image":
5360
+ case "patch-item-config":
5361
+ case "add-choice-option":
5362
+ case "update-choice-option-label":
5363
+ case "remove-choice-option":
5364
+ case "reorder-choice-options":
5365
+ case "set-embedded-form":
5366
+ case "remove-embedded-form":
5367
+ case "remove-item":
5368
+ case "move-item": return [`item:${operation.itemId}`];
5369
+ case "upsert-form-field-group":
5370
+ case "remove-form-field-group":
5371
+ case "reorder-form-field-groups":
5372
+ case "upsert-form-field":
5373
+ case "remove-form-field":
5374
+ case "move-form-field": return [`item:${operation.target.itemId}`];
5375
+ case "reorder-items": return [`item:${operation.parentItemId}`];
5376
+ case "set-image-asset":
5377
+ case "update-image-asset-metadata":
5378
+ case "remove-image-asset": return [`asset:${operation.assetId}`];
5379
+ case "update-survey-metadata":
5380
+ case "update-survey-pagination":
5381
+ case "update-survey-translation":
5382
+ case "add-survey-locale":
5383
+ case "remove-survey-locale": return ["survey"];
5384
+ case "survey-json-patch": return operation.patches.map((patch) => `path:${patch.path}`);
5366
5385
  }
5367
- get(key) {
5368
- const entry = this.entries.get(key);
5369
- if (!entry) return void 0;
5370
- if (entry.expiresAt <= this.now()) {
5371
- this.entries.delete(key);
5372
- return;
5386
+ };
5387
+ const collectOperationTargets = (operations) => new Set(operations.flatMap(operationTargets));
5388
+ const MAX_POST_CHANGE_TARGETS = 16;
5389
+ const MAX_POST_CHANGE_ITEMS = 6;
5390
+ const MAX_POST_CHANGE_TRANSLATIONS = 12;
5391
+ const MAX_POST_CHANGE_RESPONSE_SLOTS = 12;
5392
+ const MAX_POST_CHANGE_TEXT_CHARS = 100;
5393
+ const MAX_VERIFICATION_AUDIT_FINDINGS = 8;
5394
+ const MAX_VERIFICATION_MESSAGE_CHARS = 240;
5395
+ const SUBSTANTIAL_CHANGE_ITEM_COUNT = 6;
5396
+ const SUBSTANTIAL_CHANGE_OPERATION_COUNT = 12;
5397
+ const createPostChangeProjection = (context, targets) => {
5398
+ const allTargets = [...targets];
5399
+ const requestedItemIds = allTargets.filter((target) => target.startsWith("item:")).map((target) => target.slice(5));
5400
+ const itemEntries = requestedItemIds.map((itemId) => context.inspection.keyIndex.find((entry) => entry.itemId === itemId)).filter((entry) => entry !== void 0);
5401
+ const surveyTranslations = new SurveyTranslations(context.survey.translations);
5402
+ let truncated = allTargets.length > MAX_POST_CHANGE_TARGETS || itemEntries.length > MAX_POST_CHANGE_ITEMS;
5403
+ const touchedItems = itemEntries.slice(0, MAX_POST_CHANGE_ITEMS).map((entry) => {
5404
+ const itemTranslations = surveyTranslations.getItemTranslations(entry.itemId);
5405
+ const allTranslations = (itemTranslations?.locales ?? []).flatMap((locale) => Object.entries(itemTranslations?.getAllForLocale(locale) ?? {}).map(([contentKey, content]) => {
5406
+ const textCharacters = [...getContentPlainText(content)];
5407
+ return {
5408
+ locale,
5409
+ contentKey,
5410
+ textPreview: textCharacters.slice(0, MAX_POST_CHANGE_TEXT_CHARS).join(""),
5411
+ textLength: textCharacters.length,
5412
+ truncated: textCharacters.length > MAX_POST_CHANGE_TEXT_CHARS
5413
+ };
5414
+ }));
5415
+ const translations = allTranslations.slice(0, MAX_POST_CHANGE_TRANSLATIONS);
5416
+ const allResponseSlots = Object.keys(context.responseSlotTypes).filter((slot) => slot.startsWith(`${entry.itemId}...`));
5417
+ const responseSlots = allResponseSlots.slice(0, MAX_POST_CHANGE_RESPONSE_SLOTS);
5418
+ if (allTranslations.length > translations.length || allResponseSlots.length > responseSlots.length || translations.some((translation) => translation.truncated)) truncated = true;
5419
+ return {
5420
+ itemId: entry.itemId,
5421
+ itemType: entry.itemType,
5422
+ key: entry.key,
5423
+ fullKey: entry.fullKey,
5424
+ ...entry.itemLabel ? { itemLabel: entry.itemLabel } : {},
5425
+ translationCount: allTranslations.length,
5426
+ translations,
5427
+ responseSlotCount: allResponseSlots.length,
5428
+ responseSlots
5429
+ };
5430
+ });
5431
+ return {
5432
+ version: context.version,
5433
+ itemCount: context.inspection.itemCount,
5434
+ localeCount: context.inspection.locales.length,
5435
+ locales: context.inspection.locales.slice(0, 32),
5436
+ targetCount: allTargets.length,
5437
+ touchedTargets: allTargets.slice(0, MAX_POST_CHANGE_TARGETS),
5438
+ touchedItemCount: requestedItemIds.length,
5439
+ availableTouchedItemCount: itemEntries.length,
5440
+ touchedItems,
5441
+ truncated: truncated || context.inspection.locales.length > 32
5442
+ };
5443
+ };
5444
+ const auditFindingKey = (finding) => [
5445
+ finding.code,
5446
+ finding.category,
5447
+ finding.itemId ?? "",
5448
+ finding.locale ?? "",
5449
+ finding.contentKey ?? ""
5450
+ ].join("\0");
5451
+ const isComplexOperation = (operation) => {
5452
+ if (operation.kind === "survey-json-patch" || operation.kind === "patch-item-config") return true;
5453
+ if (operation.kind !== "create-item") return false;
5454
+ return Boolean(operation.item.displayConditions || operation.item.disabledConditions || operation.item.validations || operation.item.prefills);
5455
+ };
5456
+ const createChangeVerification = ({ after, before, operations, postChange }) => {
5457
+ const beforeFindingKeys = new Set(auditSurvey(before, { maxFindings: 1e4 }).findings.map(auditFindingKey));
5458
+ const newAuditFindings = auditSurvey(after, { maxFindings: 1e4 }).findings.filter((finding) => !beforeFindingKeys.has(auditFindingKey(finding)));
5459
+ const reasons = [];
5460
+ if (operations.length > SUBSTANTIAL_CHANGE_OPERATION_COUNT || postChange.touchedItemCount > SUBSTANTIAL_CHANGE_ITEM_COUNT) reasons.push("substantial-change");
5461
+ if (operations.some(isComplexOperation)) reasons.push("complex-change");
5462
+ if (postChange.truncated) reasons.push("bounded-preview");
5463
+ if (newAuditFindings.length > 0) reasons.push("new-audit-findings");
5464
+ if (reasons.length === 0) return void 0;
5465
+ let truncated = newAuditFindings.length > MAX_VERIFICATION_AUDIT_FINDINGS;
5466
+ const auditFindings = newAuditFindings.slice(0, MAX_VERIFICATION_AUDIT_FINDINGS).map((finding) => {
5467
+ const messageCharacters = [...finding.message];
5468
+ const messagePreview = messageCharacters.slice(0, MAX_VERIFICATION_MESSAGE_CHARS).join("");
5469
+ const messageTruncated = messageCharacters.length > MAX_VERIFICATION_MESSAGE_CHARS;
5470
+ if (messageTruncated) truncated = true;
5471
+ return {
5472
+ code: finding.code,
5473
+ severity: finding.severity,
5474
+ category: finding.category,
5475
+ ...finding.itemId ? { itemId: finding.itemId } : {},
5476
+ ...finding.locale ? { locale: finding.locale } : {},
5477
+ ...finding.contentKey ? { contentKey: finding.contentKey } : {},
5478
+ messagePreview,
5479
+ messageLength: messageCharacters.length,
5480
+ truncated: messageTruncated
5481
+ };
5482
+ });
5483
+ return {
5484
+ required: true,
5485
+ reasons,
5486
+ newAuditFindingCount: newAuditFindings.length,
5487
+ auditFindings,
5488
+ truncated
5489
+ };
5490
+ };
5491
+ /**
5492
+ * Request-scoped transactional draft for one assistant response.
5493
+ *
5494
+ * Mutation tools submit deltas. A successful delta replaces the previous
5495
+ * candidate with a newly validated, cumulative change set. A rejected delta
5496
+ * leaves the accepted draft intact and blocks authorization until a later
5497
+ * delta validates. Only the latest cumulative candidate can be authorized.
5498
+ */
5499
+ var TurnSurveyDraft = class {
5500
+ turnId;
5501
+ #candidates = /* @__PURE__ */ new Map();
5502
+ #draftOperations = [];
5503
+ #context;
5504
+ #latestChangeSetId;
5505
+ #unresolvedCandidates = /* @__PURE__ */ new Map();
5506
+ #final = null;
5507
+ constructor(turnId = createId()) {
5508
+ this.turnId = turnId;
5509
+ }
5510
+ initialize(context) {
5511
+ if (this.#context) throw new Error("The survey turn draft was initialized more than once.");
5512
+ this.#context = context;
5513
+ }
5514
+ stage({ addressedTargets = [], operations, snapshot, summary }) {
5515
+ this.#assertOpen();
5516
+ if (!this.#context && snapshot && "document" in snapshot && "trustedCapabilities" in snapshot) this.#context = snapshot;
5517
+ const currentContext = this.#context ?? snapshot;
5518
+ const targets = new Set([...collectOperationTargets(operations), ...addressedTargets]);
5519
+ const delta = createSurveyChangeSet({
5520
+ operations,
5521
+ snapshot: currentContext,
5522
+ summary,
5523
+ turnId: this.turnId
5524
+ });
5525
+ if (delta.status !== "validated" || !delta.validation.valid) return this.reject(delta, targets);
5526
+ const cumulativeOperations = [...this.#draftOperations, ...delta.operations];
5527
+ if (cumulativeOperations.length > 1e3) return this.reject(createSurveyChangeSet({
5528
+ maxOperations: MAX_SURVEY_ASSISTANT_OPERATIONS_PER_TURN_DRAFT,
5529
+ operations: cumulativeOperations,
5530
+ snapshot: currentContext,
5531
+ summary,
5532
+ turnId: this.turnId
5533
+ }), targets);
5534
+ const beforeContext = this.#context;
5535
+ if (this.#context) {
5536
+ const survey = applyValidatedSurveyOperationsToDraft({
5537
+ operations: delta.operations,
5538
+ survey: this.#context.survey
5539
+ });
5540
+ this.#context = createSurveyAssistantEngineContext({
5541
+ ...this.#context.document,
5542
+ survey
5543
+ }, this.#context.trustedCapabilities);
5373
5544
  }
5374
- this.entries.delete(key);
5375
- this.entries.set(key, entry);
5376
- return entry.value;
5545
+ const postChange = this.#context ? createPostChangeProjection(this.#context, targets) : void 0;
5546
+ const verification = beforeContext && this.#context && postChange ? createChangeVerification({
5547
+ after: this.#context,
5548
+ before: beforeContext,
5549
+ operations: delta.operations,
5550
+ postChange
5551
+ }) : void 0;
5552
+ const registered = surveyChangeSetSchema.parse({
5553
+ ...delta,
5554
+ operations: cumulativeOperations,
5555
+ ...postChange ? { postChange } : {},
5556
+ ...verification ? { verification } : {},
5557
+ validation: {
5558
+ valid: true,
5559
+ issues: [],
5560
+ warnings: delta.validation.warnings,
5561
+ normalizedOperations: cumulativeOperations
5562
+ },
5563
+ ...this.#latestChangeSetId ? { supersedesChangeIds: [this.#latestChangeSetId] } : {}
5564
+ });
5565
+ this.#candidates.set(registered.id, registered);
5566
+ this.#draftOperations = registered.operations;
5567
+ this.#latestChangeSetId = registered.id;
5568
+ for (const [changeSetId, unresolvedTargets] of this.#unresolvedCandidates) if (unresolvedTargets.size === 0 || [...unresolvedTargets].some((target) => targets.has(target))) this.#unresolvedCandidates.delete(changeSetId);
5569
+ return registered;
5570
+ }
5571
+ reject(candidate, addressedTargets = []) {
5572
+ this.#assertOpen();
5573
+ if (this.#candidates.has(candidate.id)) throw new Error(`Survey change "${candidate.id}" was registered more than once.`);
5574
+ const registered = surveyChangeSetSchema.parse({
5575
+ ...candidate,
5576
+ turnId: this.turnId,
5577
+ status: "rejected",
5578
+ validation: {
5579
+ ...candidate.validation,
5580
+ valid: false
5581
+ }
5582
+ });
5583
+ this.#candidates.set(registered.id, registered);
5584
+ this.#unresolvedCandidates.set(registered.id, new Set(addressedTargets));
5585
+ return registered;
5377
5586
  }
5378
- set(key, value) {
5379
- this.entries.delete(key);
5380
- this.entries.set(key, {
5381
- expiresAt: this.now() + this.ttlMs,
5382
- value
5587
+ finalize(status, createdAt = (/* @__PURE__ */ new Date()).toISOString(), details = {}) {
5588
+ if (this.#final) return this.#final;
5589
+ const blockedChangeSetIds = [...this.#unresolvedCandidates.keys()];
5590
+ const canAuthorize = status === "completed" && blockedChangeSetIds.length === 0 && this.#latestChangeSetId !== void 0;
5591
+ this.#final = surveyAssistantTurnFinalSchema.parse({
5592
+ protocolVersion: 1,
5593
+ turnId: this.turnId,
5594
+ status,
5595
+ executableChangeSetIds: canAuthorize ? [this.#latestChangeSetId] : [],
5596
+ ...blockedChangeSetIds.length > 0 ? { blockedChangeSetIds } : {},
5597
+ ...details,
5598
+ createdAt
5383
5599
  });
5384
- while (this.entries.size > this.maxEntries) {
5385
- const oldestKey = this.entries.keys().next().value;
5386
- if (oldestKey === void 0) break;
5387
- this.entries.delete(oldestKey);
5388
- }
5600
+ return this.#final;
5389
5601
  }
5390
- clear() {
5391
- this.entries.clear();
5602
+ getCandidate(changeSetId) {
5603
+ return this.#candidates.get(changeSetId);
5604
+ }
5605
+ get draftOperations() {
5606
+ return this.#draftOperations;
5607
+ }
5608
+ get context() {
5609
+ return this.#context;
5610
+ }
5611
+ get final() {
5612
+ return this.#final;
5613
+ }
5614
+ #assertOpen() {
5615
+ if (this.#final) throw new Error("Cannot register a survey change after the turn was finalized.");
5392
5616
  }
5393
5617
  };
5394
5618
  //#endregion
5395
- //#region src/engine/audit.ts
5619
+ //#region src/engine/task-context.ts
5620
+ const MAX_RELATED_ITEMS = 48;
5621
+ const MAX_SIBLING_ITEMS = 80;
5622
+ const MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM = 24;
5623
+ const MAX_VISIBLE_TEXT_CHARS = 600;
5624
+ const MAX_NAMING_EXAMPLES = 40;
5625
+ const MAX_TRANSLATION_EXAMPLES = 60;
5396
5626
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5397
- const records = (value) => Array.isArray(value) ? value.filter(isRecord) : [];
5398
- const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
5399
- const QUALITY_PRINCIPLES = [
5400
- {
5401
- id: "purpose-alignment",
5402
- title: "Purpose and construct alignment",
5403
- prompt: "Does every question serve a stated objective, and does it measure the intended construct?"
5404
- },
5405
- {
5406
- id: "cognitive-burden",
5407
- title: "Respondent burden",
5408
- prompt: "Is the survey as short and cognitively simple as its purpose allows?"
5409
- },
5410
- {
5411
- id: "question-wording",
5412
- title: "Clear, neutral wording",
5413
- prompt: "Are questions specific, neutral, answerable, and limited to one idea without assumptions or double negatives?"
5414
- },
5415
- {
5416
- id: "recall",
5417
- title: "Recall and reference periods",
5418
- prompt: "Are recall periods explicit, realistic, and consistent with what respondents can remember?"
5419
- },
5420
- {
5421
- id: "response-options",
5422
- title: "Response option quality",
5423
- prompt: "Are options mutually exclusive, collectively sufficient, balanced, ordered, and consistently scaled?"
5424
- },
5425
- {
5426
- id: "flow",
5427
- title: "Flow and completeness",
5428
- prompt: "Does the order move logically from easy/general to specific, with needed instructions and no important objective omitted?"
5429
- },
5430
- {
5431
- id: "routing",
5432
- title: "Requiredness and routing",
5433
- prompt: "Do required fields, branching, validation, and not-applicable paths avoid forcing inaccurate answers?"
5434
- },
5435
- {
5436
- id: "sensitivity",
5437
- title: "Sensitivity and ethics",
5438
- prompt: "Are sensitive questions justified, respectfully phrased, appropriately optional, and paired with privacy context where needed?"
5439
- },
5440
- {
5441
- id: "accessibility",
5442
- title: "Accessibility and inclusion",
5443
- prompt: "Is language plain and inclusive, and are content, controls, and images understandable without avoidable barriers?"
5444
- },
5445
- {
5446
- id: "localization",
5447
- title: "Cross-language equivalence",
5448
- prompt: "Do translations preserve meaning and cultural relevance rather than merely matching words?"
5449
- },
5450
- {
5451
- id: "testing",
5452
- title: "Testing and validation",
5453
- prompt: "Has the instrument been cognitively tested, piloted, and checked for reliability or validity where the intended use requires it?"
5454
- }
5455
- ];
5456
- const translationText = (context, locale, itemId, contentKey) => {
5457
- const localeTranslations = isRecord(context.survey.translations?.[locale]) ? context.survey.translations?.[locale] : void 0;
5458
- const itemTranslations = isRecord(localeTranslations?.itemTranslations) ? localeTranslations.itemTranslations : void 0;
5459
- const content = (isRecord(itemTranslations?.[itemId]) ? itemTranslations[itemId] : void 0)?.[contentKey];
5460
- return content ? getContentPlainText(content).trim() : "";
5627
+ const getItemLabel = (item) => {
5628
+ if (!isRecord(item.metadata)) return void 0;
5629
+ const label = item.metadata.itemLabel;
5630
+ return typeof label === "string" && label.trim().length > 0 ? label.trim() : void 0;
5461
5631
  };
5462
- const getGroups = (item, optionId) => {
5463
- const config = isRecord(item.config) ? item.config : {};
5464
- if (!optionId) return records(config.fieldGroups);
5465
- const option = records(config.options).find((entry) => entry.id === optionId);
5466
- return records(isRecord(option?.embeddedForm) ? option.embeddedForm.fieldGroups : void 0);
5632
+ const getCenteredNeighbors = (values, focusIndex, limit) => {
5633
+ if (focusIndex < 0 || limit < 1) return [];
5634
+ const windowSize = Math.min(values.length, limit + 1);
5635
+ const idealStart = focusIndex - Math.floor(limit / 2);
5636
+ const start = Math.min(Math.max(0, idealStart), values.length - windowSize);
5637
+ return values.slice(start, start + windowSize).filter((_, index) => start + index !== focusIndex);
5467
5638
  };
5468
- const auditSurvey = (context, options = {}) => {
5469
- const findings = [];
5470
- const add = (finding) => findings.push(finding);
5471
- const items = context.survey.surveyItems;
5472
- const itemsById = /* @__PURE__ */ new Map();
5473
- const parentById = /* @__PURE__ */ new Map();
5474
- const itemTypes = {};
5475
- for (const item of items) {
5476
- itemTypes[item.itemType] = (itemTypes[item.itemType] ?? 0) + 1;
5477
- if (itemsById.has(item.id)) add({
5478
- code: "duplicate-item-id",
5479
- severity: "error",
5480
- category: "structure",
5481
- itemId: item.id,
5482
- message: `Item id "${item.id}" is duplicated.`
5483
- });
5484
- itemsById.set(item.id, item);
5639
+ const buildSurveyAssistantTaskContext = (snapshot, options) => {
5640
+ const { inspection } = createSurveyAssistantEngineContext(snapshot, void 0, { focusItemId: options.focusItemId });
5641
+ const itemsById = new Map(snapshot.survey.surveyItems.map((item) => [item.id, item]));
5642
+ const parentById = new Map(inspection.outline.flatMap((item) => item.parentId ? [[item.itemId, item.parentId]] : []));
5643
+ const childIdsByParent = /* @__PURE__ */ new Map();
5644
+ for (const item of inspection.outline) {
5645
+ if (!item.parentId) continue;
5646
+ const childIds = childIdsByParent.get(item.parentId) ?? [];
5647
+ childIds.push(item.itemId);
5648
+ childIdsByParent.set(item.parentId, childIds);
5485
5649
  }
5486
- for (const parent of items) {
5487
- const seenChildren = /* @__PURE__ */ new Set();
5488
- for (const childId of strings(isRecord(parent.config) ? parent.config.items : void 0)) {
5489
- if (seenChildren.has(childId)) add({
5490
- code: "duplicate-child-reference",
5491
- severity: "error",
5492
- category: "structure",
5493
- itemId: parent.id,
5494
- message: `Child "${childId}" is listed more than once.`
5495
- });
5496
- seenChildren.add(childId);
5497
- if (!itemsById.has(childId)) add({
5498
- code: "missing-child",
5499
- severity: "error",
5500
- category: "structure",
5501
- itemId: parent.id,
5502
- message: `Referenced child "${childId}" does not exist.`
5503
- });
5504
- else if (parentById.has(childId)) add({
5505
- code: "multiple-parents",
5506
- severity: "error",
5507
- category: "structure",
5508
- itemId: childId,
5509
- message: `Item is referenced by multiple parents.`
5510
- });
5511
- else parentById.set(childId, parent.id);
5650
+ const fullKeyById = new Map(inspection.keyIndex.map((item) => [item.itemId, item.fullKey]));
5651
+ const summarizeItem = (item) => {
5652
+ const allVisibleText = [];
5653
+ let visibleTextValueTruncated = false;
5654
+ for (const [locale, translations] of Object.entries(snapshot.survey.translations ?? {})) {
5655
+ const itemTranslations = translations.itemTranslations?.[item.id];
5656
+ if (!itemTranslations) continue;
5657
+ for (const [contentKey, content] of Object.entries(itemTranslations)) {
5658
+ const text = getContentPlainText(content).trim();
5659
+ if (text) {
5660
+ if (text.length > MAX_VISIBLE_TEXT_CHARS) visibleTextValueTruncated = true;
5661
+ allVisibleText.push({
5662
+ locale,
5663
+ contentKey,
5664
+ text: text.length > MAX_VISIBLE_TEXT_CHARS ? `${text.slice(0, MAX_VISIBLE_TEXT_CHARS)}...` : text
5665
+ });
5666
+ }
5667
+ }
5512
5668
  }
5513
- }
5514
- const roots = items.filter((item) => !parentById.has(item.id));
5515
- if (roots.length !== 1) add({
5516
- code: "root-count",
5517
- severity: "error",
5518
- category: "structure",
5519
- message: `Expected one survey root but found ${roots.length}.`
5520
- });
5521
- for (const parent of items) {
5522
- const siblingKeys = /* @__PURE__ */ new Map();
5523
- for (const childId of strings(isRecord(parent.config) ? parent.config.items : void 0)) {
5524
- const child = itemsById.get(childId);
5525
- if (!child) continue;
5526
- const existing = siblingKeys.get(child.key);
5527
- if (existing) add({
5528
- code: "duplicate-sibling-key",
5529
- severity: "error",
5530
- category: "structure",
5531
- itemId: child.id,
5532
- message: `Key "${child.key}" duplicates sibling "${existing}".`
5533
- });
5534
- siblingKeys.set(child.key, child.id);
5669
+ return {
5670
+ id: item.id,
5671
+ key: item.key,
5672
+ fullKey: fullKeyById.get(item.id) ?? item.key,
5673
+ itemType: item.itemType,
5674
+ itemLabel: getItemLabel(item),
5675
+ visibleText: allVisibleText.slice(0, MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM),
5676
+ visibleTextTruncated: allVisibleText.length > MAX_VISIBLE_TEXT_ENTRIES_PER_ITEM || visibleTextValueTruncated
5677
+ };
5678
+ };
5679
+ const rootItemId = inspection.outline.find((item) => item.depth === 0)?.itemId;
5680
+ const rootItem = rootItemId ? itemsById.get(rootItemId) : void 0;
5681
+ const result = {
5682
+ document: {
5683
+ revision: snapshot.version.revision,
5684
+ contentHash: snapshot.version.contentHash,
5685
+ capabilityDigest: snapshot.version.capabilityDigest
5686
+ },
5687
+ survey: {
5688
+ key: rootItem?.key,
5689
+ locales: inspection.locales,
5690
+ itemCount: inspection.itemCount,
5691
+ selectedLocale: snapshot.ui.selectedLocale
5692
+ }
5693
+ };
5694
+ const focusItem = options.focusItemId ? itemsById.get(options.focusItemId) : void 0;
5695
+ if (focusItem) {
5696
+ const parentId = parentById.get(focusItem.id);
5697
+ const parentItem = parentId ? itemsById.get(parentId) : void 0;
5698
+ const ancestorItems = [];
5699
+ const seenAncestorIds = /* @__PURE__ */ new Set();
5700
+ let ancestorId = parentId;
5701
+ while (ancestorId && !seenAncestorIds.has(ancestorId)) {
5702
+ seenAncestorIds.add(ancestorId);
5703
+ const ancestor = itemsById.get(ancestorId);
5704
+ if (!ancestor) break;
5705
+ ancestorItems.unshift(ancestor);
5706
+ ancestorId = parentById.get(ancestorId);
5707
+ }
5708
+ const siblingIds = parentItem ? childIdsByParent.get(parentItem.id) ?? [] : [];
5709
+ const siblingItemCount = Math.max(0, siblingIds.length - 1);
5710
+ const siblingItems = getCenteredNeighbors(siblingIds, siblingIds.indexOf(focusItem.id), MAX_SIBLING_ITEMS).map((itemId) => itemsById.get(itemId)).filter((item) => Boolean(item));
5711
+ const allRelatedItems = [];
5712
+ const seenRelatedIds = /* @__PURE__ */ new Set();
5713
+ const pending = [...childIdsByParent.get(focusItem.id) ?? []];
5714
+ while (pending.length > 0) {
5715
+ const itemId = pending.shift();
5716
+ const item = itemId ? itemsById.get(itemId) : void 0;
5717
+ if (!item || seenRelatedIds.has(item.id)) continue;
5718
+ seenRelatedIds.add(item.id);
5719
+ allRelatedItems.push(item);
5720
+ pending.push(...childIdsByParent.get(item.id) ?? []);
5535
5721
  }
5722
+ const relatedItems = allRelatedItems.slice(0, MAX_RELATED_ITEMS);
5723
+ result.focus = {
5724
+ item: summarizeItem(focusItem),
5725
+ parent: parentItem ? summarizeItem(parentItem) : void 0,
5726
+ ancestorPath: ancestorItems.map(summarizeItem),
5727
+ siblingKeys: siblingItems.map((item) => item.key),
5728
+ siblingItems: siblingItems.map(summarizeItem),
5729
+ siblingItemCount,
5730
+ siblingItemsTruncated: siblingItemCount > siblingItems.length,
5731
+ relatedItems: relatedItems.map(summarizeItem),
5732
+ relatedItemCount: allRelatedItems.length,
5733
+ relatedItemsTruncated: allRelatedItems.length > relatedItems.length
5734
+ };
5536
5735
  }
5537
- const locales = context.inspection.locales;
5538
- const sourceLocale = options.sourceLocale ?? context.ui.selectedLocale ?? locales[0];
5539
- const targetLocales = options.targetLocales ?? locales.filter((locale) => locale !== sourceLocale);
5540
- const manifests = new Map(context.capabilities.itemTypes.map((entry) => [entry.itemType, entry]));
5541
- for (const item of items) {
5542
- const manifest = manifests.get(item.itemType);
5543
- if (!manifest) {
5544
- add({
5545
- code: "unsupported-item-type",
5546
- severity: "warning",
5547
- category: "structure",
5548
- itemId: item.id,
5549
- message: `Item type "${item.itemType}" is not advertised by the active editor.`
5550
- });
5551
- continue;
5552
- }
5553
- for (const surface of manifest.contentSurfaces.filter((entry) => entry.requiredOnCreate && !entry.pattern)) if (!translationText(context, sourceLocale, item.id, surface.key)) add({
5554
- code: "missing-primary-content",
5555
- severity: "warning",
5556
- category: "content",
5557
- itemId: item.id,
5558
- locale: sourceLocale,
5559
- contentKey: surface.key,
5560
- message: `Required respondent content "${surface.key}" is empty in ${sourceLocale}.`
5736
+ if ((options.purpose === "suggest-item-key" || options.purpose === "suggest-item-label") && focusItem) {
5737
+ const parentId = parentById.get(focusItem.id);
5738
+ const siblingIds = parentId ? childIdsByParent.get(parentId) ?? [] : [];
5739
+ const siblingPositionById = new Map(siblingIds.map((itemId, index) => [itemId, index]));
5740
+ const focusSiblingPosition = siblingPositionById.get(focusItem.id);
5741
+ const rankedItems = snapshot.survey.surveyItems.filter((item) => item.id !== focusItem.id).map((item, index) => ({
5742
+ item,
5743
+ index,
5744
+ siblingDistance: focusSiblingPosition !== void 0 && siblingPositionById.has(item.id) ? Math.abs(siblingPositionById.get(item.id) - focusSiblingPosition) : Number.POSITIVE_INFINITY,
5745
+ relationship: parentById.get(item.id) === parentId ? "sibling" : item.itemType === focusItem.itemType ? "same-type" : "survey"
5746
+ })).sort((left, right) => {
5747
+ const rank = {
5748
+ sibling: 0,
5749
+ "same-type": 1,
5750
+ survey: 2
5751
+ };
5752
+ return rank[left.relationship] - rank[right.relationship] || left.siblingDistance - right.siblingDistance || left.index - right.index;
5561
5753
  });
5562
- const sourceContent = isRecord(context.survey.translations?.[sourceLocale]?.itemTranslations?.[item.id]) ? context.survey.translations?.[sourceLocale]?.itemTranslations?.[item.id] : {};
5563
- for (const [contentKey, content] of Object.entries(sourceContent ?? {})) {
5564
- if (!getContentPlainText(content).trim()) continue;
5565
- for (const locale of targetLocales) if (!translationText(context, locale, item.id, contentKey)) add({
5566
- code: "missing-translation",
5567
- severity: "warning",
5568
- category: "localization",
5569
- itemId: item.id,
5570
- locale,
5571
- contentKey,
5572
- message: `Content "${contentKey}" has source text but no ${locale} translation.`
5573
- });
5574
- }
5575
- const config = isRecord(item.config) ? item.config : {};
5576
- if (item.itemType === "choiceItem") {
5577
- const optionsList = records(config.options);
5578
- if (optionsList.length === 0) add({
5579
- code: "choice-without-options",
5580
- severity: "error",
5581
- category: "content",
5754
+ const allKeyExamples = rankedItems.map(({ item, relationship }) => ({
5755
+ itemId: item.id,
5756
+ itemType: item.itemType,
5757
+ key: item.key,
5758
+ relationship
5759
+ }));
5760
+ const allLabelExamples = rankedItems.flatMap(({ item, relationship }) => {
5761
+ const label = getItemLabel(item);
5762
+ return label ? [{
5582
5763
  itemId: item.id,
5583
- message: "Choice item has no options."
5584
- });
5585
- for (const option of optionsList) {
5586
- if (typeof option.id === "string" && !translationText(context, sourceLocale, item.id, `options.${option.id}.label`)) add({
5587
- code: "missing-option-label",
5588
- severity: "warning",
5589
- category: "content",
5764
+ itemType: item.itemType,
5765
+ key: item.key,
5766
+ label,
5767
+ relationship
5768
+ }] : [];
5769
+ });
5770
+ result.naming = {
5771
+ keyExamples: allKeyExamples.slice(0, MAX_NAMING_EXAMPLES),
5772
+ keyExampleCount: allKeyExamples.length,
5773
+ keyExamplesTruncated: allKeyExamples.length > MAX_NAMING_EXAMPLES,
5774
+ labelExamples: allLabelExamples.slice(0, MAX_NAMING_EXAMPLES),
5775
+ labelExampleCount: allLabelExamples.length,
5776
+ labelExamplesTruncated: allLabelExamples.length > MAX_NAMING_EXAMPLES
5777
+ };
5778
+ }
5779
+ const sourceLocale = options.sourceLocale;
5780
+ if (options.purpose === "translate-content" && sourceLocale) {
5781
+ const relevantLocales = [sourceLocale, ...options.targetLocales ?? []];
5782
+ const sourceTokens = new Set((options.sourceTexts ?? []).flatMap((text) => text.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u)).filter((token) => token.length >= 3));
5783
+ const allExamples = [];
5784
+ snapshot.survey.surveyItems.forEach((item, itemIndex) => {
5785
+ const contentKeys = /* @__PURE__ */ new Set();
5786
+ for (const locale of relevantLocales) {
5787
+ const itemTranslations = snapshot.survey.translations?.[locale]?.itemTranslations?.[item.id];
5788
+ for (const contentKey of Object.keys(itemTranslations ?? {})) contentKeys.add(contentKey);
5789
+ }
5790
+ for (const contentKey of contentKeys) {
5791
+ const values = {};
5792
+ for (const locale of relevantLocales) {
5793
+ const content = snapshot.survey.translations?.[locale]?.itemTranslations?.[item.id]?.[contentKey];
5794
+ if (!content) continue;
5795
+ const text = getContentPlainText(content).trim();
5796
+ if (text) values[locale] = text.slice(0, MAX_VISIBLE_TEXT_CHARS);
5797
+ }
5798
+ const sourceText = values[sourceLocale];
5799
+ if (!sourceText || Object.keys(values).length < 2) continue;
5800
+ const normalizedSource = sourceText.toLocaleLowerCase();
5801
+ const score = [...sourceTokens].reduce((total, token) => total + (normalizedSource.includes(token) ? 1 : 0), 0);
5802
+ allExamples.push({
5590
5803
  itemId: item.id,
5591
- locale: sourceLocale,
5592
- contentKey: `options.${option.id}.label`,
5593
- message: `Choice option "${option.id}" has no visible label.`
5804
+ contentKey,
5805
+ values,
5806
+ score,
5807
+ order: itemIndex
5594
5808
  });
5595
- if (typeof option.id === "string" && option.embeddedForm) {
5596
- for (const group of getGroups(item, option.id)) for (const field of records(group.fields)) if (typeof field.id === "string" && !translationText(context, sourceLocale, item.id, `embeddedForms.${option.id}.fields.${field.id}.label`)) add({
5597
- code: "missing-field-label",
5598
- severity: "warning",
5599
- category: "content",
5600
- itemId: item.id,
5601
- locale: sourceLocale,
5602
- contentKey: `embeddedForms.${option.id}.fields.${field.id}.label`,
5603
- message: `Embedded field "${field.id}" has no visible label.`
5604
- });
5605
- }
5606
5809
  }
5810
+ });
5811
+ allExamples.sort((left, right) => right.score - left.score || left.order - right.order);
5812
+ result.translation = {
5813
+ examples: allExamples.slice(0, MAX_TRANSLATION_EXAMPLES).map(({ itemId, contentKey, values }) => ({
5814
+ itemId,
5815
+ contentKey,
5816
+ values
5817
+ })),
5818
+ exampleCount: allExamples.length,
5819
+ examplesTruncated: allExamples.length > MAX_TRANSLATION_EXAMPLES
5820
+ };
5821
+ }
5822
+ return result;
5823
+ };
5824
+ //#endregion
5825
+ //#region src/engine/task-result-cache.ts
5826
+ const DEFAULT_TTL_MS = 900 * 1e3;
5827
+ const DEFAULT_MAX_ENTRIES = 64;
5828
+ /** Small in-memory LRU cache for deterministic, snapshot-scoped one-shot results. */
5829
+ var SurveyAssistantTaskResultCache = class {
5830
+ entries = /* @__PURE__ */ new Map();
5831
+ ttlMs;
5832
+ maxEntries;
5833
+ now;
5834
+ constructor(options = {}) {
5835
+ this.ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_TTL_MS);
5836
+ this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
5837
+ this.now = options.now ?? Date.now;
5838
+ }
5839
+ get(key) {
5840
+ const entry = this.entries.get(key);
5841
+ if (!entry) return void 0;
5842
+ if (entry.expiresAt <= this.now()) {
5843
+ this.entries.delete(key);
5844
+ return;
5607
5845
  }
5608
- if (item.itemType === "formItem") {
5609
- const groups = getGroups(item);
5610
- if (groups.length === 0) add({
5611
- code: "form-without-groups",
5612
- severity: "error",
5613
- category: "content",
5614
- itemId: item.id,
5615
- message: "Form item has no field groups."
5616
- });
5617
- for (const group of groups) for (const field of records(group.fields)) if (typeof field.id === "string" && !translationText(context, sourceLocale, item.id, `fields.${field.id}.label`)) add({
5618
- code: "missing-field-label",
5619
- severity: "warning",
5620
- category: "content",
5621
- itemId: item.id,
5622
- locale: sourceLocale,
5623
- contentKey: `fields.${field.id}.label`,
5624
- message: `Form field "${field.id}" has no visible label.`
5625
- });
5626
- }
5846
+ this.entries.delete(key);
5847
+ this.entries.set(key, entry);
5848
+ return entry.value;
5627
5849
  }
5628
- const assets = context.survey.assets ?? {};
5629
- for (const [assetId, asset] of Object.entries(assets)) {
5630
- const metadata = isRecord(asset.metadata) ? asset.metadata : {};
5631
- if (asset.kind === "image" && typeof metadata.alt !== "string") add({
5632
- code: "missing-image-alt",
5633
- severity: "warning",
5634
- category: "assets",
5635
- message: `Image asset "${assetId}" has no alternative text.`,
5636
- suggestion: "Add concise alt text, or an empty alt value when the image is purely decorative."
5850
+ set(key, value) {
5851
+ this.entries.delete(key);
5852
+ this.entries.set(key, {
5853
+ expiresAt: this.now() + this.ttlMs,
5854
+ value
5637
5855
  });
5856
+ while (this.entries.size > this.maxEntries) {
5857
+ const oldestKey = this.entries.keys().next().value;
5858
+ if (oldestKey === void 0) break;
5859
+ this.entries.delete(oldestKey);
5860
+ }
5861
+ }
5862
+ clear() {
5863
+ this.entries.clear();
5638
5864
  }
5639
- const limited = findings.slice(0, options.maxFindings ?? 200);
5640
- return {
5641
- summary: limited.reduce((acc, entry) => {
5642
- acc[entry.severity] += 1;
5643
- acc.total += 1;
5644
- return acc;
5645
- }, {
5646
- error: 0,
5647
- warning: 0,
5648
- info: 0,
5649
- total: 0
5650
- }),
5651
- metrics: {
5652
- itemCount: context.inspection.itemCount,
5653
- localeCount: locales.length,
5654
- responseSlotCount: context.inspection.responseSlots.length,
5655
- assetCount: Object.keys(assets).length,
5656
- itemTypes
5657
- },
5658
- findings: limited,
5659
- qualityReviewPrinciples: QUALITY_PRINCIPLES,
5660
- note: "Technical findings are deterministic. Quality principles are a structured review rubric; apply them with human/model judgment and domain context, and do not present them as psychometric validation."
5661
- };
5662
5865
  };
5663
5866
  //#endregion
5664
- export { deriveResponseSlotTypes as $, getRawSurvey$1 as A, resolveItemSummary as B, clampLimit as C, formFieldGroupLayoutSchema as D, formFieldGroupDetailsSchema as E, mapKeyIndexById as F, summarizeKeyIndexEntry as G, summarizeFocusItemNode as H, mapOutlineById as I, translationEntrySchema as J, summarizeOutlineNode as K, mapRawItemsById as L, itemConfigDetailsSchema as M, itemMatchesQuery as N, formSelectOptionDetailsSchema as O, itemSummarySchema as P, createSurveyAssistantEngineContext as Q, normalizeText as R, choiceOptionDetailsSchema as S, formFieldDetailsSchema as T, summarizeItemById as U, sliceWithCursor as V, summarizeKeyIndexEntries as W, inferExpressionReturnType as X, unique as Y, validateExpression as Z, buildConfigSearchText as _, MAX_SURVEY_ASSISTANT_OPERATIONS_PER_PROPOSAL as a, buildItemTranslationEntries as b, buildSurveyDocumentVersion as c, normalizeSurveyChangeOperations as d, buildExpressionMutationPatches as f, MAX_LIMIT as g, DEFAULT_LIMIT as h, TurnSurveyDraft as i, getRawSurveyItems as j, getKeyIndexEntries as k, createSurveyChangeSet as l, expressionSchema as m, SurveyAssistantTaskResultCache as n, MAX_SURVEY_ASSISTANT_OPERATIONS_PER_TURN_DRAFT as o, expressionMutationSchema as p, summarizeRawItem as q, buildSurveyAssistantTaskContext as r, applyValidatedSurveyOperationsToDraft as s, auditSurvey as t, validateSurveyOperations as u, buildEmbeddedFormDetails as v, findDirectChildrenByFullKey as w, buildSurveyTranslations as x, buildItemConfigDetails as y, parseCursor as z };
5867
+ export { deriveResponseSlotTypes as $, getRawSurvey$1 as A, resolveItemSummary as B, clampLimit as C, formFieldGroupLayoutSchema as D, formFieldGroupDetailsSchema as E, mapKeyIndexById as F, summarizeKeyIndexEntry as G, summarizeFocusItemNode as H, mapOutlineById as I, translationEntrySchema as J, summarizeOutlineNode as K, mapRawItemsById as L, itemConfigDetailsSchema as M, itemMatchesQuery as N, formSelectOptionDetailsSchema as O, itemSummarySchema as P, createSurveyAssistantEngineContext as Q, normalizeText as R, choiceOptionDetailsSchema as S, formFieldDetailsSchema as T, summarizeItemById as U, sliceWithCursor as V, summarizeKeyIndexEntries as W, inferExpressionReturnType as X, unique as Y, validateExpression as Z, buildConfigSearchText as _, MAX_SURVEY_ASSISTANT_OPERATIONS_PER_PROPOSAL as a, buildItemTranslationEntries as b, buildSurveyDocumentVersion as c, normalizeSurveyChangeOperations as d, buildExpressionMutationPatches as f, MAX_LIMIT as g, DEFAULT_LIMIT as h, auditSurvey as i, getRawSurveyItems as j, getKeyIndexEntries as k, createSurveyChangeSet as l, expressionSchema as m, buildSurveyAssistantTaskContext as n, MAX_SURVEY_ASSISTANT_OPERATIONS_PER_TURN_DRAFT as o, expressionMutationSchema as p, summarizeRawItem as q, TurnSurveyDraft as r, applyValidatedSurveyOperationsToDraft as s, SurveyAssistantTaskResultCache as t, validateSurveyOperations as u, buildEmbeddedFormDetails as v, findDirectChildrenByFullKey as w, buildSurveyTranslations as x, buildItemConfigDetails as y, parseCursor as z };
5665
5868
 
5666
- //# sourceMappingURL=engine-0AJrv0hI.mjs.map
5869
+ //# sourceMappingURL=engine-CWTuN3o2.mjs.map