@pie-players/pie-assessment-toolkit 0.3.73 → 0.3.74

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 (108) hide show
  1. package/README.md +171 -56
  2. package/dist/attempt/AssessmentSession.d.ts +1 -5
  3. package/dist/attempt/AssessmentSession.js +0 -29
  4. package/dist/components/ItemToolBar.custom-element.d.ts +1 -0
  5. package/dist/components/ItemToolBar.custom-element.js +1 -1
  6. package/dist/components/PieAssessmentToolkit.custom-element.d.ts +1 -0
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  8. package/dist/components/SectionToolBar.custom-element.d.ts +1 -0
  9. package/dist/components/SectionToolBar.custom-element.js +1 -1
  10. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +40 -0
  11. package/dist/components/chunks/ItemToolBar-gmrryv2v.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +4 -0
  13. package/dist/components/chunks/ItemToolBar-wr2myaah.js +2 -0
  14. package/dist/components/chunks/sre-engine-wf9s43gg.js +1 -0
  15. package/dist/context/assessment-toolkit-context.d.ts +5 -0
  16. package/dist/context/runtime-context-consumer.js +5 -38
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +8 -9
  19. package/dist/policy/core/ToolPolicyEngine.d.ts +8 -2
  20. package/dist/policy/core/ToolPolicyEngine.js +12 -0
  21. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +3 -3
  22. package/dist/runtime/adapter/SectionEngineAdapter.js +3 -3
  23. package/dist/runtime/registration-events.d.ts +3 -0
  24. package/dist/runtime/section-runtime-engine-host-context.d.ts +3 -9
  25. package/dist/runtime/section-runtime-engine-host-context.js +5 -43
  26. package/dist/runtime/tool-host-contract.d.ts +20 -0
  27. package/dist/runtime/tool-host-contract.js +21 -0
  28. package/dist/services/HighlightCoordinator.d.ts +1 -7
  29. package/dist/services/HighlightCoordinator.js +1 -3
  30. package/dist/services/TTSService.d.ts +15 -1
  31. package/dist/services/TTSService.js +88 -35
  32. package/dist/services/ToolCoordinator.d.ts +1 -7
  33. package/dist/services/ToolCoordinator.js +1 -3
  34. package/dist/services/ToolRegistry.d.ts +13 -6
  35. package/dist/services/ToolRegistry.js +19 -7
  36. package/dist/services/ToolkitCoordinator.d.ts +110 -18
  37. package/dist/services/ToolkitCoordinator.js +273 -85
  38. package/dist/services/catalog-owner.d.ts +11 -0
  39. package/dist/services/catalog-owner.js +25 -0
  40. package/dist/services/create-tools-config.d.ts +2 -2
  41. package/dist/services/framework-error.d.ts +8 -1
  42. package/dist/services/interfaces.d.ts +9 -9
  43. package/dist/services/pnp-standard-features.d.ts +1 -1
  44. package/dist/services/section-controller-types.d.ts +15 -0
  45. package/dist/services/spoken-audio-cards.js +1 -1
  46. package/dist/services/tool-config-validation.d.ts +13 -2
  47. package/dist/services/tool-config-validation.js +33 -16
  48. package/dist/services/tool-context.js +29 -13
  49. package/dist/services/tool-providers/TTSToolProvider.d.ts +27 -65
  50. package/dist/services/tool-providers/TTSToolProvider.js +47 -9
  51. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +4 -4
  52. package/dist/services/tool-providers/ToolProviderRegistry.js +4 -16
  53. package/dist/services/tool-providers/index.d.ts +0 -9
  54. package/dist/services/tool-providers/index.js +2 -4
  55. package/dist/services/tools-config-normalizer.d.ts +20 -1
  56. package/dist/services/tts/catalog-span-alignment.d.ts +2 -17
  57. package/dist/services/tts/catalog-span-alignment.js +17 -142
  58. package/dist/services/tts/generated-speech/types.d.ts +0 -12
  59. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +12 -62
  60. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +26 -1
  61. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +3 -6
  62. package/dist/services/tts/math-alignment/index.d.ts +0 -5
  63. package/dist/services/tts/math-alignment/index.js +5 -25
  64. package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +9 -1
  65. package/dist/services/tts/math-alignment/speech-tokenizer.js +38 -36
  66. package/dist/services/tts/math-alignment/types.d.ts +4 -2
  67. package/dist/services/tts/math-aware-text-processing.js +44 -2
  68. package/dist/services/tts/math-speech.d.ts +5 -1
  69. package/dist/services/tts/math-speech.js +9 -6
  70. package/dist/services/tts/sre-engine.d.ts +7 -0
  71. package/dist/services/tts/sre-engine.js +11 -0
  72. package/dist/services/tts/sre-locales.d.ts +14 -0
  73. package/dist/services/tts/sre-locales.js +57 -0
  74. package/dist/services/tts/text-processing.d.ts +0 -1
  75. package/dist/services/tts/text-processing.js +0 -1
  76. package/dist/services/tts-runtime-config.d.ts +21 -3
  77. package/dist/services/tts-runtime-config.js +11 -3
  78. package/dist/tools/client.d.ts +1 -3
  79. package/dist/tools/client.js +1 -5
  80. package/dist/tools/internal.d.ts +6 -5
  81. package/dist/tools/internal.js +6 -8
  82. package/package.json +17 -36
  83. package/dist/components/chunks/ItemToolBar-byq80z42.js +0 -40
  84. package/dist/components/chunks/ItemToolBar-d36g3aw4.js +0 -49
  85. package/dist/services/ToolConfigResolver.d.ts +0 -161
  86. package/dist/services/ToolConfigResolver.js +0 -229
  87. package/dist/services/catalog-media.d.ts +0 -56
  88. package/dist/services/catalog-media.js +0 -190
  89. package/dist/services/createDefaultToolRegistry.d.ts +0 -53
  90. package/dist/services/createDefaultToolRegistry.js +0 -45
  91. package/dist/services/tool-providers/CortexToolProvider.d.ts +0 -13
  92. package/dist/services/tool-providers/CortexToolProvider.js +0 -32
  93. package/dist/services/tool-providers/DesmosToolProvider.d.ts +0 -14
  94. package/dist/services/tool-providers/DesmosToolProvider.js +0 -32
  95. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +0 -16
  96. package/dist/services/tool-providers/GeoGebraToolProvider.js +0 -32
  97. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +0 -35
  98. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +0 -95
  99. package/dist/services/tts/visual-line-ranges.d.ts +0 -18
  100. package/dist/services/tts/visual-line-ranges.js +0 -226
  101. package/dist/tools/index.d.ts +0 -8
  102. package/dist/tools/index.js +0 -10
  103. package/dist/tools/response-discovery.d.ts +0 -61
  104. package/dist/tools/response-discovery.js +0 -113
  105. package/dist/tools/types.d.ts +0 -337
  106. package/dist/tools/types.js +0 -12
  107. package/dist/tools/variant-resolver.d.ts +0 -41
  108. package/dist/tools/variant-resolver.js +0 -207
@@ -1,3 +1,4 @@
1
+ import type { CalculatorProviderConfig } from "@pie-players/pie-calculator";
1
2
  import type { SREMathSpeechOptions } from "./tts/math-speech.js";
2
3
  export type ToolPlacementLevel = "section" | "item" | "passage";
3
4
  export interface ToolPolicyConfig {
@@ -58,8 +59,19 @@ export interface TextToSpeechToolProviderConfig extends Omit<ToolProviderConfig,
58
59
  mathSpeech?: SREMathSpeechOptions;
59
60
  settings?: TextToSpeechToolProviderSettings;
60
61
  }
62
+ /**
63
+ * `tools.providers.calculator`, closed to the keys the calculator reads, so a
64
+ * misplaced key such as a top-level `authFetcher` fails to compile instead of
65
+ * being ignored. `Pick` keeps it assignable to the providers index signature,
66
+ * which an interface would not be.
67
+ */
68
+ export type CalculatorToolProviderConfig = Pick<CalculatorProviderConfig, keyof CalculatorProviderConfig> & {
69
+ enabled?: boolean;
70
+ provider?: ToolRuntimeProviderConfig;
71
+ };
61
72
  export interface ToolProvidersConfig {
62
73
  textToSpeech?: TextToSpeechToolProviderConfig;
74
+ calculator?: CalculatorToolProviderConfig;
63
75
  [key: string]: ToolProviderConfig | TextToSpeechToolProviderConfig | undefined;
64
76
  }
65
77
  /**
@@ -89,7 +101,14 @@ export interface CanonicalToolsConfig {
89
101
  */
90
102
  pnpEnforcement?: ToolsPnpEnforcement;
91
103
  }
104
+ /**
105
+ * The tools config a host supplies. Every part is optional, placement levels
106
+ * included: {@link normalizeToolsConfig} fills a missing level with its default.
107
+ */
108
+ export type ToolsConfigInput = Omit<Partial<CanonicalToolsConfig>, "placement"> & {
109
+ placement?: ToolPlacementConfig;
110
+ };
92
111
  export declare function normalizeToolAlias(toolId: string): string;
93
112
  export declare function normalizeToolList(toolIds: string[] | undefined | null): string[];
94
113
  export declare function parseToolList(input: string | undefined | null): string[];
95
- export declare function normalizeToolsConfig(input?: Partial<CanonicalToolsConfig> | null): CanonicalToolsConfig;
114
+ export declare function normalizeToolsConfig(input?: ToolsConfigInput | null): CanonicalToolsConfig;
@@ -1,11 +1,5 @@
1
+ import { type SpeechSourceTokenization } from "./math-alignment/speech-tokenizer.js";
1
2
  export type CatalogChunkPlaybackMode = "exact-word" | "anchor-span" | "region-fallback";
2
- export type BoundaryOffsetMode = "plain-spoken-text" | "raw-ssml" | "unsupported";
3
- export interface CatalogTextToken {
4
- text: string;
5
- normalized: string;
6
- start: number;
7
- end: number;
8
- }
9
3
  export interface CatalogSpanAnchor {
10
4
  spokenStart: number;
11
5
  spokenEnd: number;
@@ -14,25 +8,16 @@ export interface CatalogSpanAnchor {
14
8
  score: number;
15
9
  }
16
10
  export interface CatalogSpanAlignment {
17
- speechText: string;
18
- spokenText: string;
11
+ speech: SpeechSourceTokenization;
19
12
  visibleText: string;
20
13
  playbackMode: CatalogChunkPlaybackMode;
21
- boundaryOffsetMode: BoundaryOffsetMode;
22
14
  anchors: CatalogSpanAnchor[];
23
15
  confidence: number;
24
- spokenTokens: CatalogTextToken[];
25
- visibleTokens: CatalogTextToken[];
26
- rawToSpokenOffsetMap: Map<number, number>;
27
16
  }
28
17
  export declare const createCatalogSpanAlignment: (args: {
29
18
  speechText: string;
30
19
  visibleText: string;
31
20
  }) => CatalogSpanAlignment;
32
- export declare const resolveSpokenBoundaryOffset: (alignment: CatalogSpanAlignment, position: number, length?: number, boundaryWord?: string) => {
33
- start: number;
34
- length: number;
35
- } | null;
36
21
  export declare const resolveVisibleSpanForBoundary: (alignment: CatalogSpanAlignment, spokenOffset: number) => {
37
22
  start: number;
38
23
  end: number;
@@ -1,25 +1,8 @@
1
- import { extractSpokenText } from "./ssml/spoken-text.js";
2
- import { createSpeechAlignmentTokenPattern, normalizeTextForSpeech, } from "./text-processing.js";
3
- // Shared Unicode-aware tokenizer (see `createSpeechAlignmentTokenPattern`), so
4
- // accented Latin and non-Latin words tokenize the same way here as in the math
5
- // speech tokenizer. LIMITATION (i18n): the phrase table below is still English,
6
- // so multi-word operator phrases ("plus or minus", "divided by") only align for
7
- // English speech; other locales degrade to coarse region highlighting. A fresh
8
- // pattern per call because of the global flag's mutable `lastIndex`.
9
- const TOKEN_PATTERN = createSpeechAlignmentTokenPattern();
10
- const NUMERIC_WORDS = new Map([
11
- ["zero", "0"],
12
- ["one", "1"],
13
- ["two", "2"],
14
- ["three", "3"],
15
- ["four", "4"],
16
- ["five", "5"],
17
- ["six", "6"],
18
- ["seven", "7"],
19
- ["eight", "8"],
20
- ["nine", "9"],
21
- ["ten", "10"],
22
- ]);
1
+ import { tokenizeAlignmentText, tokenizeSpeechSource, } from "./math-alignment/speech-tokenizer.js";
2
+ import { normalizeTextForSpeech } from "./text-processing.js";
3
+ // LIMITATION (i18n): the phrase table is English, so multi-word operator
4
+ // phrases ("plus or minus", "divided by") only align for English speech; other
5
+ // locales degrade to coarse region highlighting.
23
6
  const SPOKEN_PHRASE_TARGETS = [
24
7
  { phrase: ["plus", "or", "minus"], visible: "±", score: 8 },
25
8
  { phrase: ["square", "root"], visible: "√", score: 8 },
@@ -36,28 +19,12 @@ const SPOKEN_PHRASE_TARGETS = [
36
19
  { phrase: ["minus"], visible: "-", score: 5 },
37
20
  { phrase: ["plus"], visible: "+", score: 5 },
38
21
  ];
39
- const tokenize = (text) => {
40
- const tokens = [];
41
- for (const match of text.matchAll(TOKEN_PATTERN)) {
42
- const value = match[0];
43
- const start = match.index ?? 0;
44
- tokens.push({
45
- text: value,
46
- normalized: value.toLowerCase(),
47
- start,
48
- end: start + value.length,
49
- });
50
- }
51
- return tokens;
52
- };
53
- const normalizedTokenValue = (token) => NUMERIC_WORDS.get(token.normalized) || token.normalized;
54
22
  const createCandidates = (spokenTokens, visibleTokens) => {
55
23
  const candidates = [];
56
24
  for (let spokenIndex = 0; spokenIndex < spokenTokens.length; spokenIndex++) {
57
25
  for (let visibleIndex = 0; visibleIndex < visibleTokens.length; visibleIndex++) {
58
- const spokenValue = normalizedTokenValue(spokenTokens[spokenIndex]);
59
- const visibleValue = normalizedTokenValue(visibleTokens[visibleIndex]);
60
- if (spokenValue === visibleValue) {
26
+ if (spokenTokens[spokenIndex].normalized ===
27
+ visibleTokens[visibleIndex].normalized) {
61
28
  candidates.push({
62
29
  spokenStartToken: spokenIndex,
63
30
  spokenEndToken: spokenIndex + 1,
@@ -74,7 +41,9 @@ const createCandidates = (spokenTokens, visibleTokens) => {
74
41
  continue;
75
42
  }
76
43
  for (let visibleIndex = 0; visibleIndex < visibleTokens.length; visibleIndex++) {
77
- if (visibleTokens[visibleIndex].normalized !== phraseTarget.visible) {
44
+ // Glyph targets compare against the visible text as written, so a
45
+ // visible number word ("two") never stands in for the digit "2".
46
+ if (visibleTokens[visibleIndex].text !== phraseTarget.visible) {
78
47
  continue;
79
48
  }
80
49
  candidates.push({
@@ -151,21 +120,20 @@ const scoreConfidence = (anchors, spokenTokens, visibleTokens) => {
151
120
  return Math.min(1, tokenCoverage * averageScore);
152
121
  };
153
122
  export const createCatalogSpanAlignment = (args) => {
154
- const extracted = extractSpokenText(args.speechText);
123
+ const speech = tokenizeSpeechSource({ speechText: args.speechText });
155
124
  const visibleText = normalizeTextForSpeech(args.visibleText);
156
- const spokenText = extracted.spokenText;
157
- const spokenTokens = tokenize(spokenText);
158
- const visibleTokens = tokenize(visibleText);
159
- const anchors = extracted.unsupportedSemantic
125
+ const spokenTokens = speech.tokens;
126
+ const visibleTokens = tokenizeAlignmentText(visibleText);
127
+ const anchors = speech.unsupportedSemantic
160
128
  ? []
161
129
  : computeAnchors(spokenTokens, visibleTokens);
162
130
  const confidence = scoreConfidence(anchors, spokenTokens, visibleTokens);
163
- const isExact = spokenText === visibleText && spokenText.length > 0;
131
+ const isExact = speech.spokenText === visibleText && speech.spokenText.length > 0;
164
132
  const hasShortSingleExactAnchor = anchors.length === 1 &&
165
133
  anchors[0].score === 10 &&
166
134
  spokenTokens.length <= 2 &&
167
135
  visibleTokens.length <= 2;
168
- const playbackMode = extracted.unsupportedSemantic
136
+ const playbackMode = speech.unsupportedSemantic
169
137
  ? "region-fallback"
170
138
  : isExact
171
139
  ? "exact-word"
@@ -174,107 +142,14 @@ export const createCatalogSpanAlignment = (args) => {
174
142
  ? "anchor-span"
175
143
  : "region-fallback"
176
144
  : "region-fallback";
177
- const boundaryOffsetMode = extracted.unsupportedSemantic
178
- ? "unsupported"
179
- : extracted.hasMarkup
180
- ? "raw-ssml"
181
- : "plain-spoken-text";
182
145
  return {
183
- speechText: args.speechText,
184
- spokenText,
146
+ speech,
185
147
  visibleText,
186
148
  playbackMode,
187
- boundaryOffsetMode,
188
149
  anchors,
189
150
  confidence,
190
- spokenTokens,
191
- visibleTokens,
192
- rawToSpokenOffsetMap: extracted.rawToSpokenOffsetMap,
193
- };
194
- };
195
- const findTokenAtOffset = (tokens, offset) => tokens.find((token) => token.start <= offset && offset < token.end) || null;
196
- const normalizeBoundaryWord = (word) => {
197
- if (!word)
198
- return null;
199
- if (/^<[^>]+>$/.test(word.trim()))
200
- return null;
201
- const token = tokenize(word)[0];
202
- if (!token)
203
- return null;
204
- return NUMERIC_WORDS.get(token.normalized) || token.normalized;
205
- };
206
- const candidateMatchesBoundaryWord = (alignment, candidate, boundaryWord) => {
207
- const normalizedBoundary = normalizeBoundaryWord(boundaryWord);
208
- if (!candidate || !normalizedBoundary)
209
- return false;
210
- const token = findTokenAtOffset(alignment.spokenTokens, candidate.start);
211
- if (!token)
212
- return false;
213
- return normalizedTokenValue(token) === normalizedBoundary;
214
- };
215
- const mapRawOffsetToSpokenOffset = (alignment, position) => {
216
- const direct = alignment.rawToSpokenOffsetMap.get(position);
217
- if (direct !== undefined)
218
- return direct;
219
- for (let offset = position; offset < alignment.speechText.length; offset++) {
220
- const mapped = alignment.rawToSpokenOffsetMap.get(offset);
221
- if (mapped !== undefined)
222
- return mapped;
223
- if (alignment.speechText[offset] === ">")
224
- break;
225
- }
226
- return null;
227
- };
228
- const resolveRawBoundaryOffset = (alignment, position, safeLength) => {
229
- const mappedStart = mapRawOffsetToSpokenOffset(alignment, position);
230
- if (mappedStart === null)
231
- return null;
232
- const mappedEnd = mapRawOffsetToSpokenOffset(alignment, Math.max(position, position + safeLength - 1));
233
- const token = findTokenAtOffset(alignment.spokenTokens, mappedStart);
234
- const mappedLength = mappedEnd !== null && mappedEnd >= mappedStart
235
- ? mappedEnd - mappedStart + 1
236
- : token
237
- ? token.end - mappedStart
238
- : safeLength;
239
- return { start: mappedStart, length: Math.max(1, mappedLength) };
240
- };
241
- const resolvePlainBoundaryOffset = (alignment, position, safeLength) => {
242
- if (position < 0 || position >= alignment.spokenText.length)
243
- return null;
244
- const token = findTokenAtOffset(alignment.spokenTokens, position);
245
- if (!token)
246
- return null;
247
- return {
248
- start: position,
249
- length: Math.min(safeLength, token.end - position),
250
151
  };
251
152
  };
252
- export const resolveSpokenBoundaryOffset = (alignment, position, length = 1, boundaryWord) => {
253
- if (alignment.boundaryOffsetMode === "unsupported" ||
254
- !Number.isFinite(position)) {
255
- return null;
256
- }
257
- const safeLength = Math.max(1, Number.isFinite(length) ? length : 1);
258
- if (boundaryWord && /^<[^>]+>$/.test(boundaryWord.trim()))
259
- return null;
260
- if (alignment.boundaryOffsetMode === "raw-ssml") {
261
- const rawCandidate = resolveRawBoundaryOffset(alignment, position, safeLength);
262
- const plainCandidate = resolvePlainBoundaryOffset(alignment, position, safeLength);
263
- if (boundaryWord) {
264
- if (candidateMatchesBoundaryWord(alignment, rawCandidate, boundaryWord)) {
265
- return rawCandidate;
266
- }
267
- if (candidateMatchesBoundaryWord(alignment, plainCandidate, boundaryWord)) {
268
- return plainCandidate;
269
- }
270
- if (normalizeBoundaryWord(boundaryWord))
271
- return null;
272
- }
273
- return rawCandidate || plainCandidate;
274
- }
275
- return (resolvePlainBoundaryOffset(alignment, position, safeLength) ||
276
- resolveRawBoundaryOffset(alignment, position, safeLength));
277
- };
278
153
  export const resolveVisibleSpanForBoundary = (alignment, spokenOffset) => {
279
154
  if (alignment.playbackMode === "region-fallback")
280
155
  return null;
@@ -14,7 +14,6 @@
14
14
  * without rewriting the core. The runtime binds live DOM via `DomAnchor`
15
15
  * (see `./dom/types.ts`).
16
16
  */
17
- export type StructuralBreakStrength = "weak" | "medium" | "strong";
18
17
  export interface ProseSegment {
19
18
  kind: "prose";
20
19
  /** Spoken words for this prose run. Identical to `visibleText` today. */
@@ -53,18 +52,7 @@ export interface MathSegment {
53
52
  */
54
53
  ssml?: string;
55
54
  }
56
- /**
57
- * A structural pause. Breaks are never standalone speakable units: the DOM
58
- * adapter folds them into the SSML of an adjacent speakable segment (or relies
59
- * on the gap between sequential per-chunk utterances). Modeled here so the
60
- * plan can carry pause intent for the SSML serializer.
61
- */
62
- export interface StructuralBreak {
63
- kind: "break";
64
- strength: StructuralBreakStrength;
65
- }
66
55
  export type SpeechSegment = ProseSegment | MathSegment;
67
- export type PlanSegment = SpeechSegment | StructuralBreak;
68
56
  /** One assembled (still anchor-free) segment plus its source chunk index. */
69
57
  export interface AssembledSegment {
70
58
  segment: SpeechSegment;
@@ -1,43 +1,4 @@
1
- import { resolveSpokenBoundaryOffset } from "../catalog-span-alignment.js";
2
- import { resolveBoundaryToSpeechToken, tokenizeSpeechSource, } from "../math-alignment/speech-tokenizer.js";
3
- const normalizeBoundaryWord = (word) => {
4
- if (!word || /^<[^>]+>$/.test(word.trim()))
5
- return null;
6
- return (tokenizeSpeechSource({ speechText: word }).tokens[0]?.normalized || null);
7
- };
8
- // Resolve a provider boundary for a chunk that carries no catalog span
9
- // alignment — notably the generated-SSML math path, whose `speechText` is a
10
- // raw `<speak>` document. The provider's offset is an index into `speechText`
11
- // exactly as it was sent (which may include SSML tags), so we tokenize that
12
- // same string and let the shared speech tokenizer translate the offset into the
13
- // extracted spoken text. Returns the boundary in spoken-text space alongside
14
- // that spoken text, so the downstream word/offset checks share one coordinate
15
- // system. The previous "direct" path treated the raw-SSML offset as a
16
- // spoken-text offset and rejected every math boundary (PIE-623 regression).
17
- const speechSourceBoundary = (chunk, event) => {
18
- const tokenization = tokenizeSpeechSource({ speechText: chunk.speechText });
19
- if (!Number.isFinite(event.position)) {
20
- return { boundary: null, spokenText: tokenization.spokenText };
21
- }
22
- const resolved = resolveBoundaryToSpeechToken({
23
- tokenization,
24
- position: event.position,
25
- length: event.length,
26
- boundaryWord: event.word,
27
- });
28
- return {
29
- boundary: resolved
30
- ? { start: resolved.start, length: resolved.length }
31
- : null,
32
- spokenText: tokenization.spokenText,
33
- };
34
- };
35
- const tokenAtOffsetMatches = (speechText, start, normalizedWord) => {
36
- if (!normalizedWord)
37
- return true;
38
- const token = tokenizeSpeechSource({ speechText }).tokens.find((candidate) => candidate.start <= start && start < candidate.end);
39
- return token?.normalized === normalizedWord;
40
- };
1
+ import { normalizeBoundaryWord, resolveBoundaryToSpeechToken, tokenizeSpeechSource, } from "../math-alignment/speech-tokenizer.js";
41
2
  export const normalizeBoundaryEvent = (chunk, event) => {
42
3
  const normalizedWord = normalizeBoundaryWord(event.word);
43
4
  if (chunk.id !== event.chunkId) {
@@ -61,17 +22,16 @@ export const normalizeBoundaryEvent = (chunk, event) => {
61
22
  reason: "boundary offset space is unsupported",
62
23
  };
63
24
  }
64
- let boundary;
65
- let spokenText;
66
- if (chunk.catalogAlignment) {
67
- boundary = resolveSpokenBoundaryOffset(chunk.catalogAlignment, event.position, event.length, event.word);
68
- spokenText = chunk.catalogAlignment.spokenText;
69
- }
70
- else {
71
- const resolved = speechSourceBoundary(chunk, event);
72
- boundary = resolved.boundary;
73
- spokenText = resolved.spokenText;
74
- }
25
+ // The provider's offset indexes `speechText` exactly as sent, which may be a
26
+ // raw `<speak>` document; the shared tokenizer maps it into spoken text and
27
+ // checks it against the reported word, on the catalog path and without one.
28
+ const boundary = resolveBoundaryToSpeechToken({
29
+ tokenization: chunk.catalogAlignment?.speech ??
30
+ tokenizeSpeechSource({ speechText: chunk.speechText }),
31
+ position: event.position,
32
+ length: event.length,
33
+ boundaryWord: event.word,
34
+ });
75
35
  if (!boundary) {
76
36
  return {
77
37
  chunkId: chunk.id,
@@ -82,22 +42,12 @@ export const normalizeBoundaryEvent = (chunk, event) => {
82
42
  reason: "could not resolve provider boundary to chunk speech",
83
43
  };
84
44
  }
85
- if (!tokenAtOffsetMatches(spokenText, boundary.start, normalizedWord)) {
86
- return {
87
- chunkId: chunk.id,
88
- normalizedWord,
89
- chunkSpokenStart: null,
90
- chunkSpokenEnd: null,
91
- confidence: 0,
92
- reason: "could not resolve provider boundary to matching word",
93
- };
94
- }
95
45
  return {
96
46
  chunkId: chunk.id,
97
47
  normalizedWord,
98
48
  chunkSpokenStart: boundary.start,
99
49
  chunkSpokenEnd: boundary.start + boundary.length,
100
- confidence: normalizedWord ? 1 : 0.75,
50
+ confidence: boundary.confidence,
101
51
  reason: chunk.catalogAlignment
102
52
  ? "resolved through catalog alignment"
103
53
  : "resolved through speech tokenization",
@@ -3,6 +3,7 @@ import { createHighlightDecision } from "./highlight-policy.js";
3
3
  import { resolveMathBoundaryTarget } from "./math-semantic-resolver.js";
4
4
  import { resolveProseBoundaryTarget } from "./prose-boundary-resolver.js";
5
5
  import { classifyMathHighlightCapability, createRenderedMathTargetResolver, } from "./rendered-math-target-resolver.js";
6
+ import { isNodeHiddenForTTS } from "../text-processing.js";
6
7
  const regionTargetFor = (chunk) => chunk.regionRange
7
8
  ? {
8
9
  type: "range",
@@ -73,6 +74,25 @@ const wouldEscalateInsideTokenMath = (planned, target, candidates) => {
73
74
  element.contains(startContainer) &&
74
75
  element.contains(endContainer));
75
76
  };
77
+ // A prose range in hidden content paints nothing. Catalog words over MathJax
78
+ // math land in its clipped source MathML, so they select the equation, and a
79
+ // range over one source token resolves to that token's rendered glyph — the
80
+ // fill-in a single-text-node range gives native MathML.
81
+ const isHiddenRange = (target) => target?.type === "range" && isNodeHiddenForTTS(target.range.startContainer);
82
+ const renderHiddenProseTarget = (planned, target) => {
83
+ if (!isHiddenRange(target))
84
+ return null;
85
+ const { startContainer, endContainer, startOffset, endOffset } = target.range;
86
+ if (startContainer !== endContainer || startContainer.nodeType !== TEXT_NODE)
87
+ return null;
88
+ return planned.renderedMathTargetResolver({
89
+ type: "text-range",
90
+ quality: "exact-word",
91
+ node: startContainer,
92
+ startOffset,
93
+ endOffset,
94
+ });
95
+ };
76
96
  const createPlannedChunk = (chunk, mathTokenHighlighting) => ({
77
97
  chunk,
78
98
  regionTarget: regionTargetFor(chunk),
@@ -136,9 +156,13 @@ export const createTTSHighlightPlan = (args) => {
136
156
  const normalized = normalizeBoundaryEvent(chunk, event);
137
157
  const proseTarget = resolveProseBoundaryTarget(chunk, normalized);
138
158
  const mathCandidates = mathCandidatesForTarget(proseTarget, chunk);
139
- const mathTarget = (!proseTarget || mathCandidates.length > 0
159
+ const resolvedMathTarget = (!proseTarget || mathCandidates.length > 0
140
160
  ? resolveMathBoundaryTarget(chunk, normalized, event, mathCandidates, planned.renderedMathTargetResolver)
141
161
  : null) || null;
162
+ const mathTarget = resolvedMathTarget?.quality === "semantic-token"
163
+ ? resolvedMathTarget
164
+ : (renderHiddenProseTarget(planned, proseTarget) ??
165
+ resolvedMathTarget);
142
166
  const mathTokenTarget = mathTokenHighlighting &&
143
167
  mathTarget &&
144
168
  mathTarget.quality !== "expression"
@@ -169,6 +193,7 @@ export const createTTSHighlightPlan = (args) => {
169
193
  // highlighting is disabled, any prose range that lands inside a
170
194
  // formula is likewise rejected so the formula stays a single block.
171
195
  if (proseTarget &&
196
+ !isHiddenRange(proseTarget) &&
172
197
  (mathTokenHighlighting
173
198
  ? !wouldEscalateInsideTokenMath(planned, proseTarget, mathCandidates)
174
199
  : mathCandidates.length === 0)) {
@@ -1,10 +1,7 @@
1
1
  const chunkOffsetSpaceFor = (chunk) => {
2
- if (chunk.alignment?.boundaryOffsetMode)
3
- return chunk.alignment.boundaryOffsetMode;
4
- const mathOffsetSpace = chunk.mathAlignment?.speech.boundaryOffsetSpace;
5
- if (mathOffsetSpace)
6
- return mathOffsetSpace;
7
- return "plain-spoken-text";
2
+ return (chunk.alignment?.speech.boundaryOffsetSpace ??
3
+ chunk.mathAlignment?.speech.boundaryOffsetSpace ??
4
+ "plain-spoken-text");
8
5
  };
9
6
  const mathAlignmentsFor = (chunk) => {
10
7
  if (chunk.mathAlignments?.length)
@@ -14,11 +14,6 @@ export declare const resolveHighlightTargetForBoundary: (alignment: MathAwareAli
14
14
  length?: number;
15
15
  boundaryWord?: string;
16
16
  }) => HighlightTarget;
17
- export declare const resolveMatchedHighlightTargetForBoundary: (alignment: MathAwareAlignment, args: {
18
- position: number;
19
- length?: number;
20
- boundaryWord?: string;
21
- }) => HighlightTarget | null;
22
17
  export declare const resolveMatchedHighlightTargetForSpokenBoundary: (alignment: MathAwareAlignment, args: {
23
18
  position: number;
24
19
  length?: number;
@@ -1,7 +1,7 @@
1
1
  import { tokenizeMathML } from "./mathml-tokenizer.js";
2
2
  import { resolveMatchedHighlightTargetForBoundary as resolveMatchedTargetForBoundary, resolveHighlightTargetForBoundary as resolveTargetForBoundary, } from "./range-resolver.js";
3
3
  import { alignSpeechToMath } from "./sequence-aligner.js";
4
- import { resolveBoundaryToSpeechToken, resolveSpokenOffsetToSpeechToken, tokenizeSpeechSource, } from "./speech-tokenizer.js";
4
+ import { normalizeBoundaryWord, resolveBoundaryToSpeechToken, resolveSpokenOffsetToSpeechToken, tokenizeSpeechSource, } from "./speech-tokenizer.js";
5
5
  export const createMathAwareAlignment = (args) => {
6
6
  const speech = tokenizeSpeechSource({ speechText: args.speechText });
7
7
  const math = tokenizeMathML(args.mathElement);
@@ -23,18 +23,6 @@ export const resolveHighlightTargetForBoundary = (alignment, args) => {
23
23
  boundary,
24
24
  });
25
25
  };
26
- export const resolveMatchedHighlightTargetForBoundary = (alignment, args) => {
27
- const boundary = resolveBoundaryToSpeechToken({
28
- tokenization: alignment.speech,
29
- position: args.position,
30
- length: args.length,
31
- boundaryWord: args.boundaryWord,
32
- });
33
- return resolveMatchedTargetForBoundary({
34
- alignment: alignment.result,
35
- boundary,
36
- });
37
- };
38
26
  export const resolveMatchedHighlightTargetForSpokenBoundary = (alignment, args) => {
39
27
  const boundary = resolveSpokenOffsetToSpeechToken({
40
28
  tokenization: alignment.speech,
@@ -48,18 +36,10 @@ export const resolveMatchedHighlightTargetForSpokenBoundary = (alignment, args)
48
36
  });
49
37
  };
50
38
  export const resolveUniqueMathTargetForBoundaryWord = (alignment, args) => {
51
- if (!args.boundaryWord || /^<[^>]+>$/.test(args.boundaryWord.trim())) {
52
- return null;
53
- }
54
- const boundaryToken = tokenizeSpeechSource({
55
- speechText: args.boundaryWord,
56
- }).tokens[0];
57
- if (!boundaryToken)
39
+ const normalized = normalizeBoundaryWord(args.boundaryWord);
40
+ if (!normalized)
58
41
  return null;
59
- const matches = alignment.math.tokens.filter((token) => {
60
- const normalized = boundaryToken.normalized.toLowerCase();
61
- return (token.normalized.toLowerCase() === normalized ||
62
- token.spokenAliases.some((alias) => alias.toLowerCase() === normalized));
63
- });
42
+ const matches = alignment.math.tokens.filter((token) => token.normalized.toLowerCase() === normalized ||
43
+ token.spokenAliases.some((alias) => alias.toLowerCase() === normalized));
64
44
  return matches.length === 1 ? matches[0].target : null;
65
45
  };
@@ -1,4 +1,4 @@
1
- import type { BoundaryOffsetSpace, SpeechAlignmentToken } from "./types.js";
1
+ import type { AlignmentTextToken, BoundaryOffsetSpace, SpeechAlignmentToken } from "./types.js";
2
2
  export interface SpeechSourceTokenization {
3
3
  speechText: string;
4
4
  spokenText: string;
@@ -14,9 +14,17 @@ export interface ResolvedSpeechBoundary {
14
14
  length: number;
15
15
  confidence: number;
16
16
  }
17
+ export declare const tokenizeAlignmentText: (text: string) => AlignmentTextToken[];
17
18
  export declare const tokenizeSpeechSource: (args: {
18
19
  speechText: string;
19
20
  }) => SpeechSourceTokenization;
21
+ /**
22
+ * A provider boundary word in the form spoken tokens compare in, or null when
23
+ * the word carries no token (empty, a control tag, punctuation). The word goes
24
+ * through the same SSML extraction as the chunk, so an entity-encoded word
25
+ * normalizes to the text it encodes.
26
+ */
27
+ export declare const normalizeBoundaryWord: (word?: string) => string | null;
20
28
  export declare const resolveBoundaryToSpeechToken: (args: {
21
29
  tokenization: SpeechSourceTokenization;
22
30
  position: number;