@cxpinsight/survey-spec 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -16,5 +16,7 @@ export { splitIntoPages } from './pageFlow';
16
16
  export { validateAnswer } from './validate';
17
17
  export type { ValidatableQuestion } from './validate';
18
18
  export type { Page, PageItem, PageFlowOptions, FlowQuestion, FlowSection } from './pageFlow';
19
+ export { normalizeTranscript, matchBoolean, matchOption, matchOptions, rankOptions, matchNumberInRange, matchDate, distinctiveLabels, voiceSupport, voiceCanHandle, auditVoiceQuestions, voiceDisclaimer, skipNoteText, scaffoldFor, buildPromptText, shortCode, optionLabelOf, optionValueOf, voiceOptionsOf, optionLabelsOf, } from './voice';
20
+ export type { VoiceSupport, VoiceQuestion, VoiceOption, VoiceScaffold } from './voice';
19
21
  export type { AnswersMap, VariablesMap, EvalContext } from './types';
20
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC7F,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC7F,OAAO,EACL,mBAAmB,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EACzE,kBAAkB,EAAE,SAAS,EAAE,iBAAiB,EAChD,YAAY,EAAE,cAAc,EAAE,mBAAmB,EACjD,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,SAAS,EACtE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,cAAc,GAC7D,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACvF,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.validateAnswer = exports.splitIntoPages = exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
3
+ exports.optionLabelsOf = exports.voiceOptionsOf = exports.optionValueOf = exports.optionLabelOf = exports.shortCode = exports.buildPromptText = exports.scaffoldFor = exports.skipNoteText = exports.voiceDisclaimer = exports.auditVoiceQuestions = exports.voiceCanHandle = exports.voiceSupport = exports.distinctiveLabels = exports.matchDate = exports.matchNumberInRange = exports.rankOptions = exports.matchOptions = exports.matchOption = exports.matchBoolean = exports.normalizeTranscript = exports.validateAnswer = exports.splitIntoPages = exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
4
4
  /**
5
5
  * @cxpinsight/survey-spec
6
6
  *
@@ -23,3 +23,24 @@ var pageFlow_1 = require("./pageFlow");
23
23
  Object.defineProperty(exports, "splitIntoPages", { enumerable: true, get: function () { return pageFlow_1.splitIntoPages; } });
24
24
  var validate_1 = require("./validate");
25
25
  Object.defineProperty(exports, "validateAnswer", { enumerable: true, get: function () { return validate_1.validateAnswer; } });
26
+ var voice_1 = require("./voice");
27
+ Object.defineProperty(exports, "normalizeTranscript", { enumerable: true, get: function () { return voice_1.normalizeTranscript; } });
28
+ Object.defineProperty(exports, "matchBoolean", { enumerable: true, get: function () { return voice_1.matchBoolean; } });
29
+ Object.defineProperty(exports, "matchOption", { enumerable: true, get: function () { return voice_1.matchOption; } });
30
+ Object.defineProperty(exports, "matchOptions", { enumerable: true, get: function () { return voice_1.matchOptions; } });
31
+ Object.defineProperty(exports, "rankOptions", { enumerable: true, get: function () { return voice_1.rankOptions; } });
32
+ Object.defineProperty(exports, "matchNumberInRange", { enumerable: true, get: function () { return voice_1.matchNumberInRange; } });
33
+ Object.defineProperty(exports, "matchDate", { enumerable: true, get: function () { return voice_1.matchDate; } });
34
+ Object.defineProperty(exports, "distinctiveLabels", { enumerable: true, get: function () { return voice_1.distinctiveLabels; } });
35
+ Object.defineProperty(exports, "voiceSupport", { enumerable: true, get: function () { return voice_1.voiceSupport; } });
36
+ Object.defineProperty(exports, "voiceCanHandle", { enumerable: true, get: function () { return voice_1.voiceCanHandle; } });
37
+ Object.defineProperty(exports, "auditVoiceQuestions", { enumerable: true, get: function () { return voice_1.auditVoiceQuestions; } });
38
+ Object.defineProperty(exports, "voiceDisclaimer", { enumerable: true, get: function () { return voice_1.voiceDisclaimer; } });
39
+ Object.defineProperty(exports, "skipNoteText", { enumerable: true, get: function () { return voice_1.skipNoteText; } });
40
+ Object.defineProperty(exports, "scaffoldFor", { enumerable: true, get: function () { return voice_1.scaffoldFor; } });
41
+ Object.defineProperty(exports, "buildPromptText", { enumerable: true, get: function () { return voice_1.buildPromptText; } });
42
+ Object.defineProperty(exports, "shortCode", { enumerable: true, get: function () { return voice_1.shortCode; } });
43
+ Object.defineProperty(exports, "optionLabelOf", { enumerable: true, get: function () { return voice_1.optionLabelOf; } });
44
+ Object.defineProperty(exports, "optionValueOf", { enumerable: true, get: function () { return voice_1.optionValueOf; } });
45
+ Object.defineProperty(exports, "voiceOptionsOf", { enumerable: true, get: function () { return voice_1.voiceOptionsOf; } });
46
+ Object.defineProperty(exports, "optionLabelsOf", { enumerable: true, get: function () { return voice_1.optionLabelsOf; } });
@@ -35,12 +35,6 @@ export interface PageFlowOptions {
35
35
  */
36
36
  flow?: string | null;
37
37
  presentationMode?: string;
38
- /**
39
- * Typeform / conversation only. See the header: this is an open product
40
- * decision, carried as a parameter so neither channel changes under the
41
- * other's rule before it is made.
42
- */
43
- typeformSections?: 'per-section' | 'per-question';
44
38
  }
45
39
  export declare function splitIntoPages<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection>(survey: {
46
40
  questions?: Q[];
@@ -1 +1 @@
1
- {"version":3,"file":"pageFlow.d.ts","sourceRoot":"","sources":["../src/pageFlow.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAK3C,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAMD,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW,IAC3F;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,IAAI,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW;IAC9F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,KAAK,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACxB,SAAS,EAAE,CAAC,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,aAAa,GAAG,cAAc,CAAC;CACnD;AAoBD,wBAAgB,cAAc,CAC5B,CAAC,SAAS,YAAY,GAAG,YAAY,EACrC,CAAC,SAAS,WAAW,GAAG,WAAW,EAEnC,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;CAAE,EAC3C,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,GAC5B,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAyEd"}
1
+ {"version":3,"file":"pageFlow.d.ts","sourceRoot":"","sources":["../src/pageFlow.ts"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAK3C,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAMD,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW,IAC3F;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,IAAI,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW;IAC9F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,KAAK,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACxB,SAAS,EAAE,CAAC,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAoBD,wBAAgB,cAAc,CAC5B,CAAC,SAAS,YAAY,GAAG,YAAY,EACrC,CAAC,SAAS,WAAW,GAAG,WAAW,EAEnC,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;CAAE,EAC3C,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,GAC5B,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CA+Dd"}
package/dist/pageFlow.js CHANGED
@@ -24,13 +24,21 @@ exports.splitIntoPages = splitIntoPages;
24
24
  * are honoured, not just `hidden`. Mobile ignored them, so a section
25
25
  * conditioned away still split pages there.
26
26
  *
27
- * The third is a product decision, not a defect, so it is a PARAMETER rather
28
- * than a silent choice: in typeform and conversation modes, does a section
29
- * become one page, or does each of its questions get its own? React has always
30
- * done per-section; mobile has always done per-question, and says so in its own
31
- * comments. Encoding either one here would change page counts on live surveys
32
- * for whichever channel lost, so `typeformSections` carries the difference in
33
- * the open until someone decides.
27
+ * The third was a product decision rather than a defect, and it has been made:
28
+ * in typeform and conversation modes each question gets its own page, and a
29
+ * section does not group them. That is mobile's long-standing behaviour and
30
+ * matches what the builder does it hides the add-page control in these modes
31
+ * and turns section headers into intro pages, so the author is already working
32
+ * one question at a time.
33
+ *
34
+ * It carried a `typeformSections` parameter while the question was open. The
35
+ * parameter is gone now the answer exists: a configurable behaviour nobody
36
+ * configures is a second code path that only gets exercised by accident.
37
+ *
38
+ * REACT CHANGES because of this. A typeform survey whose questions are grouped
39
+ * into sections previously showed one page per section there and now shows one
40
+ * per question, so its page count goes up and its progress bar moves in smaller
41
+ * steps. Mobile is unaffected.
34
42
  */
35
43
  const expressionEval_1 = require("./expressionEval");
36
44
  const isPageBreak = (q) => String(q.type || '').toLowerCase() === 'pagebreak';
@@ -54,35 +62,21 @@ function activeSections(sections, ctx) {
54
62
  return { claimed, active };
55
63
  }
56
64
  function splitIntoPages(survey, ctx, options = {}) {
57
- const { flow, presentationMode, typeformSections = 'per-section' } = options;
65
+ const { flow, presentationMode } = options;
58
66
  const raw = survey.questions || [];
59
67
  const all = flow === null
60
68
  ? raw
61
69
  : raw.filter((q) => (q.flow || undefined) === (flow || undefined));
62
70
  const sections = survey.sections || [];
63
71
  const { claimed, active } = activeSections(sections, ctx);
72
+ // One question per page. Sections do not group in these modes — see the
73
+ // header. `claimed` is still computed above because section conditions can
74
+ // hide a question's section, and a question whose section is skipped still
75
+ // renders; it just gets its own page like every other.
64
76
  if (presentationMode === 'typeform' || presentationMode === 'conversation') {
65
- const pages = [];
66
- const emitted = new Set();
67
- for (const q of all) {
68
- if (isPageBreak(q))
69
- continue;
70
- const sid = typeformSections === 'per-section' ? claimed.get(q.id) : undefined;
71
- if (sid) {
72
- if (emitted.has(sid))
73
- continue;
74
- const section = active.get(sid);
75
- if (!section)
76
- continue;
77
- const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
78
- pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
79
- emitted.add(sid);
80
- }
81
- else {
82
- pages.push({ items: [{ kind: 'question', question: q }], questions: [q] });
83
- }
84
- }
85
- return pages;
77
+ return all
78
+ .filter((q) => !isPageBreak(q))
79
+ .map((q) => ({ items: [{ kind: 'question', question: q }], questions: [q] }));
86
80
  }
87
81
  const pages = [];
88
82
  const emitted = new Set();
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Turning speech into an answer.
3
+ *
4
+ * This is the point where a respondent's VOICE becomes stored data, and a
5
+ * divergence between channels is invisible afterwards: the row looks fine, it
6
+ * is just a different value than the same sentence produced somewhere else.
7
+ * Three implementations existed — the React renderer's handsFreeVoice.tsx, the
8
+ * web SDK's voice.js (ported from it) and the mobile renderer's
9
+ * voiceParsers.ts (an older, stricter copy). Mobile had drifted: no fuzzy
10
+ * tolerance, no filler stripping, no letter shortcuts, no Arabic orthographic
11
+ * folding, English-only ordinals. "grean" for Green resolved on a link and on
12
+ * a website and failed on the phone, which is the channel where hands-free
13
+ * matters most.
14
+ *
15
+ * ── The seam ─────────────────────────────────────────────────────────────
16
+ *
17
+ * These functions return an option INDEX, never an answer. That is deliberate.
18
+ * The three renderers genuinely disagree about answer shape and are each right
19
+ * about their own: React's radio widget stores `{ choice: value }` and
20
+ * normalises it at submit, web and mobile store the bare value. A shared
21
+ * function returning "the answer" would have to pick one and break two.
22
+ * Matching a sentence to an option is the part that must be identical;
23
+ * wrapping an index in a shape is the part that must not be.
24
+ *
25
+ * tools/survey-conformance/voice.js runs the same spoken answers through all
26
+ * three and fails on any disagreement.
27
+ */
28
+ export declare function shortCode(lang?: string): string;
29
+ /** Lower-case, fold Arabic orthography, strip punctuation, normalise spelling. */
30
+ export declare function normalizeTranscript(s: unknown): string;
31
+ /**
32
+ * Strip the prefix every option shares, so "Option A / Option B / Option C"
33
+ * reduces to "a" / "b" / "c" and a respondent who just says "C" is understood.
34
+ */
35
+ export declare function distinctiveLabels(labels: string[]): string[];
36
+ export interface VoiceOption {
37
+ value?: unknown;
38
+ label?: string;
39
+ text?: string;
40
+ alt?: string;
41
+ }
42
+ export interface VoiceQuestion {
43
+ type?: string;
44
+ title?: string;
45
+ text?: string;
46
+ label?: string;
47
+ options?: unknown;
48
+ choices?: unknown;
49
+ yesLabel?: string;
50
+ noLabel?: string;
51
+ minValue?: number;
52
+ maxValue?: number;
53
+ minLabel?: string;
54
+ maxLabel?: string;
55
+ isRequired?: boolean;
56
+ required?: boolean;
57
+ }
58
+ export declare function optionLabelOf(o: VoiceOption): string;
59
+ export declare function optionValueOf(o: VoiceOption): unknown;
60
+ export declare function voiceOptionsOf(q: VoiceQuestion): VoiceOption[];
61
+ export declare function optionLabelsOf(q: VoiceQuestion): string[];
62
+ /** Yes/no, including the question's own yes/no labels. `null` = neither. */
63
+ export declare function matchBoolean(q: VoiceQuestion, transcript: string): boolean | null;
64
+ /** Single choice. Returns the option index, or -1 when nothing matched. */
65
+ export declare function matchOption(q: VoiceQuestion, transcript: string): number;
66
+ /** Multi-choice. Returns ascending option indices; empty when nothing matched. */
67
+ export declare function matchOptions(q: VoiceQuestion, transcript: string): number[];
68
+ /**
69
+ * Ranking. Returns option indices in the order they were spoken, or an empty
70
+ * array when fewer than two were heard — one name is not a ranking.
71
+ */
72
+ export declare function rankOptions(q: VoiceQuestion, transcript: string): number[];
73
+ /** A number inside [min, max], from digits, number words, or a homophone. */
74
+ export declare function matchNumberInRange(transcript: string, min: number, max: number): number | null;
75
+ /** A spoken date as ISO `YYYY-MM-DD`, or null. */
76
+ export declare function matchDate(transcript: string): string | null;
77
+ /**
78
+ * What a hands-free session can do with a question type.
79
+ *
80
+ * full — speak the scaffold, capture, parse into a value.
81
+ * matrix — walk it row by row; each row is a single choice.
82
+ * read — display-only. Read it and advance; there is nothing to capture.
83
+ * assist — read it aloud, then hand off to the visual UI. The respondent can
84
+ * answer, just not by speaking.
85
+ * none — voice cannot produce this answer at all (a signature, a file).
86
+ *
87
+ * `assist` and `none` are one bucket to web and mobile, which collapse both to
88
+ * 'partial'. They are separate here because React distinguishes them and the
89
+ * spec should not force a renderer to lose a distinction it makes.
90
+ */
91
+ export type VoiceSupport = 'full' | 'matrix' | 'read' | 'assist' | 'none';
92
+ export declare function voiceSupport(type: string): VoiceSupport;
93
+ /** Can a voice-only session get through this question unaided? */
94
+ export declare function voiceCanHandle(type: string): boolean;
95
+ /**
96
+ * blocking = required questions voice can't handle → the session must not start.
97
+ * skippable = optional ones → announced and skipped.
98
+ */
99
+ export declare function auditVoiceQuestions<Q extends VoiceQuestion>(questions: Q[]): {
100
+ blocking: Q[];
101
+ skippable: Q[];
102
+ };
103
+ export interface VoiceScaffold {
104
+ scale: (min: number, max: number, minL?: string, maxL?: string) => string;
105
+ binary: (y: string, n: string) => string;
106
+ options: (o: string[]) => string;
107
+ multi: (o: string[]) => string;
108
+ ranking: (o: string[]) => string;
109
+ date: () => string;
110
+ matrixStem: (stem: string) => string;
111
+ matrixRow: (row: string, cols: string[]) => string;
112
+ }
113
+ export declare function scaffoldFor(lang?: string): VoiceScaffold;
114
+ export declare function voiceDisclaimer(n: number, lang?: string): string;
115
+ export declare function skipNoteText(lang?: string): string;
116
+ /** What the session says out loud to ask this question. */
117
+ export declare function buildPromptText(q: VoiceQuestion, lang?: string): string;
118
+ //# sourceMappingURL=voice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../src/voice.ts"],"names":[],"mappings":"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"}
package/dist/voice.js ADDED
@@ -0,0 +1,632 @@
1
+ "use strict";
2
+ /**
3
+ * Turning speech into an answer.
4
+ *
5
+ * This is the point where a respondent's VOICE becomes stored data, and a
6
+ * divergence between channels is invisible afterwards: the row looks fine, it
7
+ * is just a different value than the same sentence produced somewhere else.
8
+ * Three implementations existed — the React renderer's handsFreeVoice.tsx, the
9
+ * web SDK's voice.js (ported from it) and the mobile renderer's
10
+ * voiceParsers.ts (an older, stricter copy). Mobile had drifted: no fuzzy
11
+ * tolerance, no filler stripping, no letter shortcuts, no Arabic orthographic
12
+ * folding, English-only ordinals. "grean" for Green resolved on a link and on
13
+ * a website and failed on the phone, which is the channel where hands-free
14
+ * matters most.
15
+ *
16
+ * ── The seam ─────────────────────────────────────────────────────────────
17
+ *
18
+ * These functions return an option INDEX, never an answer. That is deliberate.
19
+ * The three renderers genuinely disagree about answer shape and are each right
20
+ * about their own: React's radio widget stores `{ choice: value }` and
21
+ * normalises it at submit, web and mobile store the bare value. A shared
22
+ * function returning "the answer" would have to pick one and break two.
23
+ * Matching a sentence to an option is the part that must be identical;
24
+ * wrapping an index in a shape is the part that must not be.
25
+ *
26
+ * tools/survey-conformance/voice.js runs the same spoken answers through all
27
+ * three and fails on any disagreement.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.shortCode = shortCode;
31
+ exports.normalizeTranscript = normalizeTranscript;
32
+ exports.distinctiveLabels = distinctiveLabels;
33
+ exports.optionLabelOf = optionLabelOf;
34
+ exports.optionValueOf = optionValueOf;
35
+ exports.voiceOptionsOf = voiceOptionsOf;
36
+ exports.optionLabelsOf = optionLabelsOf;
37
+ exports.matchBoolean = matchBoolean;
38
+ exports.matchOption = matchOption;
39
+ exports.matchOptions = matchOptions;
40
+ exports.rankOptions = rankOptions;
41
+ exports.matchNumberInRange = matchNumberInRange;
42
+ exports.matchDate = matchDate;
43
+ exports.voiceSupport = voiceSupport;
44
+ exports.voiceCanHandle = voiceCanHandle;
45
+ exports.auditVoiceQuestions = auditVoiceQuestions;
46
+ exports.scaffoldFor = scaffoldFor;
47
+ exports.voiceDisclaimer = voiceDisclaimer;
48
+ exports.skipNoteText = skipNoteText;
49
+ exports.buildPromptText = buildPromptText;
50
+ /* ── Locale ─────────────────────────────────────────────────────────────── */
51
+ function shortCode(lang) {
52
+ return String(lang || 'en').split('-')[0].toLowerCase();
53
+ }
54
+ /* ── Transcript normalization ───────────────────────────────────────────── */
55
+ // Contractions expand BEFORE punctuation is stripped: otherwise "I'd say C"
56
+ // loses the apostrophe and leaves a bare "d" token, which the letter shortcut
57
+ // reads as option D — a silently wrong answer.
58
+ const CONTRACTIONS = [
59
+ [/\bcan['’]t\b/g, 'can not'], [/\bwon['’]t\b/g, 'will not'],
60
+ [/n['’]t\b/g, ' not'], [/['’]ll\b/g, ' will'], [/['’]re\b/g, ' are'],
61
+ [/['’]ve\b/g, ' have'], [/['’]m\b/g, ' am'], [/['’]d\b/g, ' would'],
62
+ [/['’]s\b/g, ''],
63
+ ];
64
+ // Whole-word pairs, never suffix regexes. A rule like -our→-or also rewrites
65
+ // "four"→"for" and corrupts the number table; -re→-er turns "more" into
66
+ // "moer". Boring beats clever here.
67
+ const EN_SPELLING = {
68
+ colour: 'color', colours: 'colors', coloured: 'colored',
69
+ favour: 'favor', favours: 'favors', favourite: 'favorite', favourites: 'favorites',
70
+ flavour: 'flavor', flavours: 'flavors',
71
+ honour: 'honor', humour: 'humor', labour: 'labor',
72
+ neighbour: 'neighbor', neighbours: 'neighbors',
73
+ behaviour: 'behavior', behaviours: 'behaviors',
74
+ harbour: 'harbor', rumour: 'rumor', odour: 'odor',
75
+ armour: 'armor', vapour: 'vapor', endeavour: 'endeavor',
76
+ centre: 'center', centres: 'centers', theatre: 'theater',
77
+ metre: 'meter', metres: 'meters', litre: 'liter', litres: 'liters',
78
+ fibre: 'fiber', calibre: 'caliber',
79
+ organisation: 'organization', organisations: 'organizations',
80
+ organise: 'organize', organised: 'organized',
81
+ realise: 'realize', realised: 'realized',
82
+ recognise: 'recognize', recognised: 'recognized',
83
+ apologise: 'apologize', apologised: 'apologized',
84
+ personalise: 'personalize', personalised: 'personalized',
85
+ customise: 'customize', customised: 'customized',
86
+ analyse: 'analyze', analysed: 'analyzed',
87
+ catalogue: 'catalog', dialogue: 'dialog',
88
+ grey: 'gray', licence: 'license', defence: 'defense',
89
+ practise: 'practice', programme: 'program',
90
+ travelling: 'traveling', travelled: 'traveled', traveller: 'traveler',
91
+ cancelled: 'canceled', cancelling: 'canceling',
92
+ enquire: 'inquire', enquiry: 'inquiry', enquiries: 'inquiries',
93
+ };
94
+ /** Lower-case, fold Arabic orthography, strip punctuation, normalise spelling. */
95
+ function normalizeTranscript(s) {
96
+ let out = String(s == null ? '' : s).toLowerCase();
97
+ for (const [re, to] of CONTRACTIONS)
98
+ out = out.replace(re, to);
99
+ // Hamza carriers, ta marbuta, alef maqsura, tashkeel and tatweel all fold,
100
+ // so "طلب مساعده" matches "طلب مساعدة".
101
+ out = out.replace(/[ً-ْٰـ]/g, '').replace(/[أإآٱ]/g, 'ا').replace(/ى/g, 'ي').replace(/ة/g, 'ه');
102
+ try {
103
+ out = out.replace(/[^\p{L}\p{N}\s]/gu, ' ');
104
+ }
105
+ catch {
106
+ out = out.replace(/[^a-z0-9؀-ۿ\s]/gi, ' ');
107
+ }
108
+ out = out.replace(/\s+/g, ' ').trim();
109
+ if (!out)
110
+ return out;
111
+ return out.split(' ').map((w) => (Object.prototype.hasOwnProperty.call(EN_SPELLING, w) ? EN_SPELLING[w] : w)).join(' ');
112
+ }
113
+ // Tables are keyed by NORMALIZED text. Writing 'أربعة' by hand while input
114
+ // folds to 'اربعه' is exactly the mismatch that silently killed Arabic number
115
+ // parsing in an earlier version.
116
+ const normList = (arr) => arr.map(normalizeTranscript).filter(Boolean);
117
+ const normPairs = (pairs) => pairs.map(([k, v]) => [normalizeTranscript(k), v]).filter(([k]) => !!k);
118
+ const normObj = (obj) => {
119
+ const out = {};
120
+ for (const k of Object.keys(obj)) {
121
+ const n = normalizeTranscript(k);
122
+ if (n)
123
+ out[n] = obj[k];
124
+ }
125
+ return out;
126
+ };
127
+ const YES_WORDS = normList(['yes', 'yeah', 'yep', 'yup', 'yah', 'sure', 'ok', 'okay', 'true', 'correct', 'right',
128
+ 'affirmative', 'absolutely', 'definitely', 'certainly', 'indeed', 'of course', 'i do', 'i would',
129
+ 'agreed', 'agree', 'positive', 'for sure', 'why not',
130
+ 'نعم', 'أجل', 'اجل', 'صحيح', 'أيوه', 'ايوه', 'اكيد', 'أكيد', 'طبعا', 'بالتأكيد', 'موافق',
131
+ 'sí', 'si', 'oui', 'ja', 'sì']);
132
+ const NO_WORDS = normList(['no', 'nope', 'nah', 'naw', 'false', 'incorrect', 'negative', 'never',
133
+ 'not really', 'no way', 'i do not', 'i would not', 'disagree', 'wrong', 'nay',
134
+ 'لا', 'كلا', 'مش', 'ابدا', 'أبدا', 'غير صحيح', 'لأ', 'non', 'nein']);
135
+ const phrasesOf = (list) => list.filter((w) => w.indexOf(' ') !== -1);
136
+ const YES_PHRASES = phrasesOf(YES_WORDS);
137
+ const NO_PHRASES = phrasesOf(NO_WORDS);
138
+ // Strong ordinals outrank weak cardinals: in "the third one", "one" is a
139
+ // pronoun, not a position, and scoring both equally resolved to option 1.
140
+ const ORDINALS_STRONG = normPairs([
141
+ ['first', 0], ['second', 1], ['third', 2], ['fourth', 3], ['fifth', 4], ['sixth', 5],
142
+ ['اول', 0], ['الاول', 0], ['الاولي', 0],
143
+ ['ثاني', 1], ['الثاني', 1], ['الثانيه', 1],
144
+ ['ثالث', 2], ['الثالث', 2], ['الثالثه', 2],
145
+ ['رابع', 3], ['الرابع', 3], ['الرابعه', 3],
146
+ ['خامس', 4], ['الخامس', 4], ['الخامسه', 4],
147
+ ['سادس', 5], ['السادس', 5], ['السادسه', 5],
148
+ ]);
149
+ const ORDINALS_WEAK = normPairs([
150
+ ['one', 0], ['1', 0], ['two', 1], ['2', 1], ['three', 2], ['3', 2],
151
+ ['four', 3], ['4', 3], ['five', 4], ['5', 4], ['six', 5], ['6', 5],
152
+ ['واحد', 0], ['اثنين', 1], ['اتنين', 1], ['ثلاثه', 2], ['تلاته', 2], ['اربعه', 3], ['خمسه', 4], ['سته', 5],
153
+ ]);
154
+ // Letter shortcuts — Latin by position, Arabic in abjad order.
155
+ const LETTER_INDEX = normObj({ a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, 'ا': 0, 'ب': 1, 'ج': 2, 'د': 3, 'ه': 4, 'و': 5 });
156
+ // Discourse markers stripped before letter/ordinal detection, so "um, I would
157
+ // say C please" reduces to "c". Articles are NOT included — they appear inside
158
+ // real option labels.
159
+ const FILLERS = normList(['um', 'uhm', 'uh', 'er', 'erm', 'hmm', 'mm', 'ah', 'oh', 'like', 'well', 'so',
160
+ 'actually', 'basically', 'literally', 'maybe', 'probably', 'definitely', 'honestly', 'personally',
161
+ 'please', 'thanks', 'thank you', 'ok', 'okay', 'right',
162
+ 'يعني', 'اه', 'ايه', 'طيب', 'اوك', 'من فضلك', 'لو سمحت']);
163
+ const FILLER_PHRASES = [/\bi would say\b/g, /\bi think\b/g, /\bi guess\b/g, /\bi mean\b/g,
164
+ /\blet me say\b/g, /\blets say\b/g, /\byou know\b/g, /\bkind of\b/g, /\bsort of\b/g,
165
+ /\bmy answer is\b/g, /\bthe answer is\b/g, /\bi choose\b/g, /\bi pick\b/g, /\bi select\b/g,
166
+ /\bgo with\b/g, /\bاختار\b/g, /\bاعتقد\b/g, /\bجوابي\b/g];
167
+ function stripFillers(said) {
168
+ let out = said;
169
+ for (const re of FILLER_PHRASES)
170
+ out = out.replace(re, ' ');
171
+ const kept = out.split(/\s+/).filter((t) => t && FILLERS.indexOf(t) === -1);
172
+ return kept.join(' ').trim() || said;
173
+ }
174
+ // Connectors between multiple picks — "the staff and the prices" is two
175
+ // answers, and matching the joined phrase as one string finds neither.
176
+ const CONNECTOR_RE = /\s+(?:and|also|plus|as well as|و|وكذلك|وايضا|ايضا)\s+|\s*[,،]\s*/g;
177
+ function editDistance(a, b) {
178
+ if (a === b)
179
+ return 0;
180
+ if (!a.length)
181
+ return b.length;
182
+ if (!b.length)
183
+ return a.length;
184
+ let prev = [];
185
+ for (let j = 0; j <= b.length; j++)
186
+ prev[j] = j;
187
+ for (let i = 1; i <= a.length; i++) {
188
+ const cur = [i];
189
+ for (let j = 1; j <= b.length; j++) {
190
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
191
+ }
192
+ prev = cur;
193
+ }
194
+ return prev[b.length];
195
+ }
196
+ function similarity(a, b) {
197
+ const longest = Math.max(a.length, b.length);
198
+ return longest === 0 ? 1 : 1 - editDistance(a, b) / longest;
199
+ }
200
+ /**
201
+ * Strip the prefix every option shares, so "Option A / Option B / Option C"
202
+ * reduces to "a" / "b" / "c" and a respondent who just says "C" is understood.
203
+ */
204
+ function distinctiveLabels(labels) {
205
+ if (labels.length < 2)
206
+ return labels.slice();
207
+ const words = labels.map((l) => l.split(' '));
208
+ const shortest = words.reduce((m, w) => Math.min(m, w.length), Infinity);
209
+ let shared = 0;
210
+ while (shared < shortest - 1) {
211
+ let same = true;
212
+ for (let i = 1; i < words.length; i++)
213
+ if (words[i][shared] !== words[0][shared]) {
214
+ same = false;
215
+ break;
216
+ }
217
+ if (!same)
218
+ break;
219
+ shared++;
220
+ }
221
+ if (shared === 0)
222
+ return labels.slice();
223
+ const stripped = [];
224
+ for (const w of words) {
225
+ const t = w.slice(shared).join(' ');
226
+ if (!t)
227
+ return labels.slice(); // stripping would empty one — keep them all
228
+ stripped.push(t);
229
+ }
230
+ return stripped;
231
+ }
232
+ const lookupPair = (pairs, token) => {
233
+ for (const [k, v] of pairs)
234
+ if (k === token)
235
+ return v;
236
+ return -1;
237
+ };
238
+ /**
239
+ * How strongly does this utterance point at this option? Higher wins.
240
+ * The bands matter more than the numbers: an exact label always beats a
241
+ * distinctive-suffix hit, which always beats a letter or ordinal, which always
242
+ * beats a fuzzy near-miss.
243
+ */
244
+ function scoreOption(said, label, distinctive, index) {
245
+ if (!said)
246
+ return 0;
247
+ if (said === label)
248
+ return 1000 + label.length;
249
+ if (said === distinctive)
250
+ return 900 + distinctive.length;
251
+ const tokens = stripFillers(said).split(' ');
252
+ if (label && said.indexOf(label) !== -1)
253
+ return 700 + label.length;
254
+ if (distinctive) {
255
+ const hit = distinctive.length >= 3 ? (said.indexOf(distinctive) !== -1) : (tokens.indexOf(distinctive) !== -1);
256
+ if (hit)
257
+ return 600 + distinctive.length;
258
+ }
259
+ if (said.length >= 3 && label.indexOf(said) !== -1)
260
+ return 500 + said.length;
261
+ for (const t of tokens) {
262
+ if (LETTER_INDEX[t] === index)
263
+ return 470;
264
+ if (lookupPair(ORDINALS_STRONG, t) === index)
265
+ return 460;
266
+ }
267
+ for (const t of tokens)
268
+ if (lookupPair(ORDINALS_WEAK, t) === index)
269
+ return 430;
270
+ const target = distinctive || label;
271
+ if (target.length >= 3) {
272
+ // Short labels need a tighter threshold: at 5 characters a single edit is
273
+ // already 20% of the word, and "blue"/"blur" are different answers.
274
+ const threshold = target.length <= 5 ? 0.8 : 0.72;
275
+ const sim = Math.max(similarity(said, target), similarity(said, label));
276
+ if (sim >= threshold)
277
+ return 100 + Math.round(sim * 100);
278
+ }
279
+ return 0;
280
+ }
281
+ function matchOptionIndex(said, labels, distinctives) {
282
+ let bestIdx = -1, bestScore = 0;
283
+ for (let i = 0; i < labels.length; i++) {
284
+ const sc = scoreOption(said, labels[i], distinctives[i], i);
285
+ if (sc > bestScore) {
286
+ bestScore = sc;
287
+ bestIdx = i;
288
+ }
289
+ }
290
+ return bestIdx;
291
+ }
292
+ const NUMBER_WORDS = normObj({
293
+ zero: 0, nought: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
294
+ first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8, ninth: 9, tenth: 10,
295
+ 'صفر': 0, 'واحد': 1, 'واحدة': 1, 'اثنان': 2, 'اثنين': 2, 'اتنين': 2, 'ثلاثة': 3, 'ثلاث': 3, 'تلاتة': 3, 'أربعة': 4, 'اربعة': 4,
296
+ 'خمسة': 5, 'خمس': 5, 'ستة': 6, 'سبعة': 7, 'سبع': 7, 'ثمانية': 8, 'ثماني': 8, 'تمانية': 8, 'تسعة': 9, 'عشرة': 10, 'عشر': 10,
297
+ });
298
+ // What speech recognition emits INSTEAD of a number. These are ordinary words,
299
+ // so they only apply when the utterance is essentially just the answer.
300
+ const NUMBER_HOMOPHONES = normObj({
301
+ oh: 0, won: 1, wun: 1, to: 2, too: 2, tu: 2, tree: 3, free: 3,
302
+ for: 4, fore: 4, forth: 4, fife: 5, sex: 6, sicks: 6, ate: 8, nein: 9,
303
+ });
304
+ function optionLabelOf(o) {
305
+ return String((o && (o.label != null ? o.label : (o.text != null ? o.text : (o.value != null ? o.value : o.alt)))) || '');
306
+ }
307
+ function optionValueOf(o) {
308
+ return (o && o.value != null) ? o.value : optionLabelOf(o);
309
+ }
310
+ function voiceOptionsOf(q) {
311
+ if (Array.isArray(q.options))
312
+ return q.options;
313
+ if (Array.isArray(q.choices))
314
+ return q.choices;
315
+ return [];
316
+ }
317
+ function optionLabelsOf(q) {
318
+ return voiceOptionsOf(q).map(optionLabelOf).filter(Boolean);
319
+ }
320
+ /* ── Matchers — every one returns an index or a primitive, never an answer ── */
321
+ /** Yes/no, including the question's own yes/no labels. `null` = neither. */
322
+ function matchBoolean(q, transcript) {
323
+ const norm = normalizeTranscript(transcript);
324
+ // Multi-word entries can never match token-by-token. Negatives are scanned
325
+ // first: "not really" must not be caught by the positive pass.
326
+ for (const p of NO_PHRASES)
327
+ if (norm.indexOf(p) !== -1)
328
+ return false;
329
+ for (const p of YES_PHRASES)
330
+ if (norm.indexOf(p) !== -1)
331
+ return true;
332
+ const yes = typeof q.yesLabel === 'string' ? normalizeTranscript(q.yesLabel) : '';
333
+ const no = typeof q.noLabel === 'string' ? normalizeTranscript(q.noLabel) : '';
334
+ for (const t of norm.split(' ').filter(Boolean)) {
335
+ if (YES_WORDS.indexOf(t) !== -1)
336
+ return true;
337
+ if (NO_WORDS.indexOf(t) !== -1)
338
+ return false;
339
+ if (yes && t === yes)
340
+ return true;
341
+ if (no && t === no)
342
+ return false;
343
+ }
344
+ if (yes && norm.indexOf(yes) !== -1)
345
+ return true;
346
+ if (no && norm.indexOf(no) !== -1)
347
+ return false;
348
+ return null;
349
+ }
350
+ /** Single choice. Returns the option index, or -1 when nothing matched. */
351
+ function matchOption(q, transcript) {
352
+ const options = voiceOptionsOf(q);
353
+ if (!options.length)
354
+ return -1;
355
+ const said = normalizeTranscript(transcript);
356
+ const labels = options.map((o) => normalizeTranscript(optionLabelOf(o)));
357
+ const dist = distinctiveLabels(labels);
358
+ let idx = matchOptionIndex(said, labels, dist);
359
+ if (idx < 0) {
360
+ // Retry per clause — "um, C I think" still scores, and a stray leading
361
+ // filler doesn't sink the match.
362
+ for (const clause of said.split(CONNECTOR_RE)) {
363
+ const c = (clause || '').trim();
364
+ if (!c)
365
+ continue;
366
+ idx = matchOptionIndex(c, labels, dist);
367
+ if (idx >= 0)
368
+ break;
369
+ }
370
+ }
371
+ return idx;
372
+ }
373
+ /** Multi-choice. Returns ascending option indices; empty when nothing matched. */
374
+ function matchOptions(q, transcript) {
375
+ const options = voiceOptionsOf(q);
376
+ if (!options.length)
377
+ return [];
378
+ const said = normalizeTranscript(transcript);
379
+ const labels = options.map((o) => normalizeTranscript(optionLabelOf(o)));
380
+ const dist = distinctiveLabels(labels);
381
+ const picked = [];
382
+ const add = (n) => { if (n >= 0 && picked.indexOf(n) === -1)
383
+ picked.push(n); };
384
+ // Clause-driven: "the staff and the prices" is two answers, and scoring the
385
+ // joined phrase as one string finds neither.
386
+ for (const clause of said.split(CONNECTOR_RE)) {
387
+ const c = (clause || '').trim();
388
+ if (c)
389
+ add(matchOptionIndex(c, labels, dist));
390
+ }
391
+ // Then sweep for labels mentioned outright, catching picks the connector
392
+ // split missed. Literal containment ONLY here — a fuzzy near-miss must not
393
+ // be able to over-select, because an unwanted extra pick is worse than a
394
+ // missed one when the respondent can't see what was recorded.
395
+ for (let i = 0; i < labels.length; i++) {
396
+ const target = dist[i] || labels[i];
397
+ if (target.length >= 3 && said.indexOf(target) !== -1)
398
+ add(i);
399
+ else if (labels[i].length >= 3 && said.indexOf(labels[i]) !== -1)
400
+ add(i);
401
+ }
402
+ return picked.sort((a, b) => a - b);
403
+ }
404
+ /**
405
+ * Ranking. Returns option indices in the order they were spoken, or an empty
406
+ * array when fewer than two were heard — one name is not a ranking.
407
+ */
408
+ function rankOptions(q, transcript) {
409
+ const options = voiceOptionsOf(q);
410
+ if (!options.length)
411
+ return [];
412
+ const norm = normalizeTranscript(transcript);
413
+ const found = [];
414
+ for (let i = 0; i < options.length; i++) {
415
+ const nl = normalizeTranscript(optionLabelOf(options[i]));
416
+ if (!nl)
417
+ continue;
418
+ const pos = norm.indexOf(nl);
419
+ if (pos >= 0)
420
+ found.push({ i, pos });
421
+ }
422
+ if (found.length < 2)
423
+ return [];
424
+ found.sort((a, b) => a.pos - b.pos);
425
+ return found.map((f) => f.i);
426
+ }
427
+ /** A number inside [min, max], from digits, number words, or a homophone. */
428
+ function matchNumberInRange(transcript, min, max) {
429
+ // Arabic-Indic and Extended Arabic-Indic digits fold to ASCII first —
430
+ // common when speech recognition transcribes spoken numbers in the native
431
+ // script.
432
+ const ascii = String(transcript)
433
+ .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
434
+ .replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
435
+ const norm = normalizeTranscript(ascii);
436
+ // Every digit run in order, so "5 out of 10" on a 0-10 scale lands on 5
437
+ // rather than falling through to the word table.
438
+ const digits = norm.match(/\d+(?:\.\d+)?/g);
439
+ if (digits)
440
+ for (const m of digits) {
441
+ const n = Math.round(Number(m));
442
+ if (n >= min && n <= max)
443
+ return n;
444
+ }
445
+ // Word forms matched as whole tokens, never substrings — otherwise
446
+ // "fourteen" matches "four".
447
+ for (const t of norm.split(/\s+/).filter(Boolean)) {
448
+ const n = NUMBER_WORDS[t];
449
+ if (n !== undefined && n >= min && n <= max)
450
+ return n;
451
+ }
452
+ // Homophone rescue, short utterances only: a bare "for" on a 0-10 scale was
453
+ // almost certainly "four"; "for" inside a sentence is a preposition.
454
+ const core = stripFillers(norm).split(/\s+/).filter(Boolean);
455
+ if (core.length <= 2) {
456
+ for (const t of core) {
457
+ const n = NUMBER_HOMOPHONES[t];
458
+ if (n !== undefined && n >= min && n <= max)
459
+ return n;
460
+ }
461
+ }
462
+ return null;
463
+ }
464
+ /** A spoken date as ISO `YYYY-MM-DD`, or null. */
465
+ function matchDate(transcript) {
466
+ const raw = String(transcript).trim();
467
+ // Strip filler words and ordinal suffixes ("January 5th" → "January 5") that
468
+ // trip the Date constructor; speech engines produce them constantly.
469
+ const cleaned = raw.replace(/\b(the|of|on|at)\b/gi, ' ').replace(/(\d+)(st|nd|rd|th)\b/gi, '$1').replace(/\s+/g, ' ').trim();
470
+ for (const c of [raw, cleaned, raw.replace(/(\d+)(st|nd|rd|th)\b/gi, '$1')]) {
471
+ if (!c)
472
+ continue;
473
+ const d = new Date(c);
474
+ if (!isNaN(d.getTime())) {
475
+ const m = String(d.getMonth() + 1).padStart(2, '0');
476
+ const day = String(d.getDate()).padStart(2, '0');
477
+ return `${d.getFullYear()}-${m}-${day}`;
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+ function voiceSupport(type) {
483
+ switch (String(type || '').toLowerCase()) {
484
+ case 'text':
485
+ case 'textbox':
486
+ case 'shorttext':
487
+ case 'comment':
488
+ case 'textarea':
489
+ case 'longtext':
490
+ case 'binary':
491
+ case 'boolean':
492
+ case 'yesno':
493
+ case 'yes_no':
494
+ case 'radio':
495
+ case 'radiogroup':
496
+ case 'dropdown':
497
+ case 'select':
498
+ case 'rating':
499
+ case 'nps':
500
+ case 'checkbox':
501
+ case 'imagepicker':
502
+ case 'image_picker':
503
+ case 'ranking':
504
+ case 'date':
505
+ return 'full';
506
+ case 'matrix':
507
+ return 'matrix';
508
+ case 'content':
509
+ case 'html':
510
+ case 'image':
511
+ case 'expression':
512
+ case 'pagebreak':
513
+ case 'page_break':
514
+ return 'read';
515
+ case 'multitext':
516
+ case 'multi_text':
517
+ case 'multipletext':
518
+ case 'matrixdropdown':
519
+ case 'matrix_dropdown':
520
+ case 'matrixdynamic':
521
+ case 'matrix_dynamic':
522
+ return 'assist';
523
+ case 'paneldynamic':
524
+ case 'panel_dynamic':
525
+ case 'signature':
526
+ case 'fileupload':
527
+ case 'file_upload':
528
+ case 'file':
529
+ return 'none';
530
+ default:
531
+ return 'none';
532
+ }
533
+ }
534
+ /** Can a voice-only session get through this question unaided? */
535
+ function voiceCanHandle(type) {
536
+ const s = voiceSupport(type);
537
+ return s === 'full' || s === 'matrix' || s === 'read';
538
+ }
539
+ /**
540
+ * blocking = required questions voice can't handle → the session must not start.
541
+ * skippable = optional ones → announced and skipped.
542
+ */
543
+ function auditVoiceQuestions(questions) {
544
+ const blocking = [], skippable = [];
545
+ for (const q of (Array.isArray(questions) ? questions : [])) {
546
+ if (!q)
547
+ continue;
548
+ const t = String(q.type || '').toLowerCase();
549
+ if (t === 'pagebreak' || t === 'page_break')
550
+ continue;
551
+ if (voiceCanHandle(String(q.type || '')))
552
+ continue;
553
+ if (q.isRequired || q.required)
554
+ blocking.push(q);
555
+ else
556
+ skippable.push(q);
557
+ }
558
+ return { blocking, skippable };
559
+ }
560
+ const SCAFFOLDS = {
561
+ en: {
562
+ scale: (min, max, minL, maxL) => {
563
+ const r = `Answer with a number from ${min} to ${max}`;
564
+ return (minL && maxL) ? `${r}. ${min} means ${minL}, ${max} means ${maxL}.` : `${r}.`;
565
+ },
566
+ binary: (y, n) => `Please answer ${y || 'yes'} or ${n || 'no'}.`,
567
+ options: (o) => `Options are: ${o.join(', ')}.`,
568
+ multi: (o) => `Multiple choices allowed. Options are: ${o.join(', ')}. Say the ones that apply.`,
569
+ ranking: (o) => `Rank in order of preference: ${o.join(', ')}.`,
570
+ date: () => 'Please say the date.',
571
+ matrixStem: (stem) => `${stem}. I’ll ask you row by row.`,
572
+ matrixRow: (row, cols) => `${row}. Options: ${cols.join(', ')}.`,
573
+ },
574
+ ar: {
575
+ scale: (min, max, minL, maxL) => {
576
+ const r = `أجب برقم من ${min} إلى ${max}`;
577
+ return (minL && maxL) ? `${r}. ${min} يعني ${minL}، ${max} يعني ${maxL}.` : `${r}.`;
578
+ },
579
+ binary: (y, n) => `يرجى الإجابة ${y || 'نعم'} أو ${n || 'لا'}.`,
580
+ options: (o) => `الخيارات: ${o.join('، ')}.`,
581
+ multi: (o) => `يمكن اختيار أكثر من إجابة. الخيارات: ${o.join('، ')}. قل الخيارات التي تنطبق.`,
582
+ ranking: (o) => `رتب حسب الأفضلية: ${o.join('، ')}.`,
583
+ date: () => 'يرجى قول التاريخ.',
584
+ matrixStem: (stem) => `${stem}. سأسألك عن كل صف على حدة.`,
585
+ matrixRow: (row, cols) => `${row}. الخيارات: ${cols.join('، ')}.`,
586
+ },
587
+ };
588
+ function scaffoldFor(lang) {
589
+ return SCAFFOLDS[shortCode(lang)] || SCAFFOLDS.en;
590
+ }
591
+ function voiceDisclaimer(n, lang) {
592
+ if (shortCode(lang) === 'ar')
593
+ return 'ملاحظة: ' + n + (n > 1 ? ' أسئلة' : ' سؤال') + ' لا يمكن الإجابة عليها صوتياً وسيتم تخطيها.';
594
+ return 'Heads up — ' + n + ' question' + (n > 1 ? 's' : '') + " can't be answered by voice and will be skipped.";
595
+ }
596
+ function skipNoteText(lang) {
597
+ if (shortCode(lang) === 'ar')
598
+ return 'هذا السؤال يحتاج إدخالاً يدوياً — سيتم تخطيه.';
599
+ return 'This one needs manual input — skipping it.';
600
+ }
601
+ /** What the session says out loud to ask this question. */
602
+ function buildPromptText(q, lang) {
603
+ const s = scaffoldFor(lang);
604
+ const base = String(q.title || q.text || q.label || '').replace(/<[^>]*>/g, '').trim();
605
+ const type = String(q.type || '').toLowerCase();
606
+ if (['binary', 'boolean', 'yesno', 'yes_no'].indexOf(type) !== -1) {
607
+ return `${base}. ${s.binary(typeof q.yesLabel === 'string' ? q.yesLabel : '', typeof q.noLabel === 'string' ? q.noLabel : '')}`;
608
+ }
609
+ if (type === 'rating') {
610
+ return `${base}. ${s.scale(typeof q.minValue === 'number' ? q.minValue : 1, typeof q.maxValue === 'number' ? q.maxValue : 5, typeof q.minLabel === 'string' ? q.minLabel : undefined, typeof q.maxLabel === 'string' ? q.maxLabel : undefined)}`;
611
+ }
612
+ if (type === 'nps') {
613
+ // Always 0-10 regardless of the question's own min/max — every NPS
614
+ // renderer hardcodes that scale.
615
+ return `${base}. ${s.scale(0, 10, typeof q.minLabel === 'string' ? q.minLabel : 'Not at all likely', typeof q.maxLabel === 'string' ? q.maxLabel : 'Extremely likely')}`;
616
+ }
617
+ if (['radio', 'radiogroup', 'dropdown', 'select', 'imagepicker', 'image_picker'].indexOf(type) !== -1) {
618
+ const o = optionLabelsOf(q);
619
+ return o.length ? `${base}. ${s.options(o)}` : base;
620
+ }
621
+ if (type === 'checkbox') {
622
+ const o = optionLabelsOf(q);
623
+ return o.length ? `${base}. ${s.multi(o)}` : base;
624
+ }
625
+ if (type === 'ranking') {
626
+ const o = optionLabelsOf(q);
627
+ return o.length ? `${base}. ${s.ranking(o)}` : base;
628
+ }
629
+ if (type === 'date')
630
+ return `${base}. ${s.date()}`;
631
+ return base;
632
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cxpinsight/survey-spec",
3
- "version": "0.2.0",
4
- "description": "The survey specification the renderers share expression evaluation first. Source of truth for behaviour that must be identical on web, links and mobile.",
3
+ "version": "0.3.0",
4
+ "description": "The survey specification the renderers share \u2014 expression evaluation first. Source of truth for behaviour that must be identical on web, links and mobile.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [