@wix/web5-core 1.63.36 → 1.63.37

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.
@@ -22,9 +22,55 @@ exports.composeSemantic = composeSemantic;
22
22
  * CDN build externalises core, so a core change could not be seen through
23
23
  * `?clientBundleUrl=` until it had been published. 0223 always assigned it
24
24
  * here, and this is that move.
25
+ *
26
+ * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)
27
+ * ------------------------------------------------------
28
+ * It buys **recall, and only recall**. In media-manager an editorial slot
29
+ * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates
30
+ * (`CandidateGather.planFor`), scoped by a filter that is just "this site's
31
+ * images" (`buildFilter`) — so the phrase ranks the corpus rather than gating
32
+ * it. Which picture then WINS is decided by `SlotScorer`, whose every term is
33
+ * geometric (crop fit, background, subject, text safety, resolution). Vespa's
34
+ * relevance score is not one of them, and the pool's order is discarded by the
35
+ * assignment solve. The phrase's entire job is choosing which 30 images reach
36
+ * the scorer.
37
+ *
38
+ * That is why it is budgeted in WORDS. The documents it is matched against are
39
+ * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,
40
+ * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with
41
+ * vector closeness. A headline plus a clause of marketing prose is the wrong
42
+ * shape against that on both halves: the vector drifts toward the mean of
43
+ * several topics instead of sitting on the subject, and BM25 spends term weight
44
+ * on filler that pulls in whatever else on the site happens to carry those
45
+ * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)
46
+ * happen on the document side only.
47
+ *
48
+ * A phrase that is too specific costs a worse 30, never a blank section: the
49
+ * site-wide widening in `CandidateGather.poolFor` is entity-only because an
50
+ * editorial pool empties only when the site has no images at all.
51
+ *
52
+ * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text
53
+ * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —
54
+ * every text-derived slot in the product gets a new id. Not a place for
55
+ * cosmetic edits.
56
+ */
57
+
58
+ /**
59
+ * The phrase's real budget, in whole words — the unit that matters, because
60
+ * what it is matched against is a short bag of nouns and not a paragraph.
61
+ */
62
+ const WORD_BUDGET = 10;
63
+
64
+ /**
65
+ * A title of this many words already names its own subject, so the lead adds
66
+ * dilution rather than signal and is dropped. Below it the title may be as thin
67
+ * as "Our picks" or "Why it matters", which names nothing retrievable on its
68
+ * own — that is the case the lead exists for.
25
69
  */
70
+ const TITLE_ENOUGH = 5;
26
71
 
27
- /** Longer than this and the tail stops helping retrieval and starts diluting it. */
72
+ /** A character backstop under the word budget: ten words is normally well
73
+ * inside it, but one pathological unbroken "word" is not. */
28
74
  const MAX_LENGTH = 120;
29
75
 
30
76
  /** Below this there is no query worth sending — better to render no picture
@@ -39,9 +85,18 @@ const stripMarkdown = raw => raw
39
85
  .replace(/^\s*#{1,6}\s*/gm, '').replace(/^\s*>\s?/gm, '')
40
86
  // html tags an author or the parser may have left behind
41
87
  .replace(/<[^>]+>/g, ' ');
42
- const tidy = raw => stripMarkdown(raw).replace(/\s+/g, ' ').trim()
43
- // trailing punctuation is noise in a retrieval phrase
44
- .replace(/[\s.,;:!?—–-]+$/g, '').trim();
88
+ const trimTail = raw => raw.replace(/[\s.,;:!?—–-]+$/g, '').trim();
89
+ const tidy = raw => trimTail(stripMarkdown(raw).replace(/\s+/g, ' ').trim());
90
+ const wordsOf = raw => raw.split(' ').filter(word => word.length > 0);
91
+
92
+ /** Keep at most `max` whole words. The unit the budget is actually stated in. */
93
+ const clampToWords = (raw, max) => {
94
+ const words = wordsOf(raw);
95
+ if (words.length <= max) {
96
+ return raw;
97
+ }
98
+ return trimTail(words.slice(0, max).join(' '));
99
+ };
45
100
 
46
101
  /**
47
102
  * Clamp without cutting a word in half. Slicing at a fixed length leaves
@@ -58,9 +113,12 @@ const clampToWord = (raw, max) => {
58
113
  const cut = raw.slice(0, max + 1);
59
114
  const lastBreak = cut.lastIndexOf(' ');
60
115
  const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);
61
- return out.replace(/[\s.,;:!?—–-]+$/g, '').trim();
116
+ return trimTail(out);
62
117
  };
63
118
 
119
+ /** The word budget first, then the character backstop under it. */
120
+ const budgeted = raw => clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);
121
+
64
122
  /** The first clause carries the subject; what follows is usually qualification. */
65
123
  const firstClause = raw => {
66
124
  const m = raw.match(/^[^.!?;]+/);
@@ -78,17 +136,23 @@ function composeSemantic({
78
136
  // A mission, when there is one, is the whole answer.
79
137
  const mission = tidy(sectionSemantic ?? '');
80
138
  if (mission.length >= MIN_LENGTH) {
81
- return clampToWord(mission, MAX_LENGTH);
139
+ return budgeted(mission);
82
140
  }
83
141
  const head = tidy(title ?? '');
84
- const body = firstClause(tidy(lead ?? ''));
142
+ const headWords = wordsOf(head).length;
143
+
144
+ // The title is the stronger signal, so it is spent first and never truncated
145
+ // to make room for the lead. Once it names a subject on its own, it IS the
146
+ // phrase.
147
+ if (headWords >= TITLE_ENOUGH) {
148
+ const out = budgeted(head);
149
+ return out.length < MIN_LENGTH ? '' : out;
150
+ }
151
+
152
+ // A thin title spends what it did not use on the lead's first clause.
153
+ const body = clampToWords(firstClause(tidy(lead ?? '')), WORD_BUDGET - headWords);
85
154
  const joined = [head, body].filter(Boolean).join('. ');
86
155
  if (joined.length < MIN_LENGTH) return '';
87
- if (joined.length <= MAX_LENGTH) return joined;
88
-
89
- // Over budget: keep the title whole if it fits, since it is the stronger
90
- // signal, and spend whatever is left on the lead.
91
- if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);
92
156
  return clampToWord(joined, MAX_LENGTH);
93
157
  }
94
158
  //# sourceMappingURL=composeSemantic.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","tidy","trim","clampToWord","max","length","cut","slice","lastBreak","lastIndexOf","out","firstClause","m","match","composeSemantic","title","lead","sectionSemantic","mission","head","body","joined","filter","Boolean","join"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n */\n\n/** Longer than this and the tail stops helping retrieval and starts diluting it. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst tidy = (raw: string): string =>\n stripMarkdown(raw)\n .replace(/\\s+/g, ' ')\n .trim()\n // trailing punctuation is noise in a retrieval phrase\n .replace(/[\\s.,;:!?—–-]+$/g, '')\n .trim();\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return out.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n};\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return clampToWord(mission, MAX_LENGTH);\n }\n\n const head = tidy(title ?? '');\n const body = firstClause(tidy(lead ?? ''));\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n if (joined.length <= MAX_LENGTH) return joined;\n\n // Over budget: keep the title whole if it fits, since it is the stronger\n // signal, and spend whatever is left on the lead.\n if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["WORD_BUDGET","TITLE_ENOUGH","MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","trimTail","trim","tidy","wordsOf","split","filter","word","length","clampToWords","max","words","slice","join","clampToWord","cut","lastBreak","lastIndexOf","out","budgeted","firstClause","m","match","composeSemantic","title","lead","sectionSemantic","mission","head","headWords","body","joined","Boolean"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n *\n * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)\n * ------------------------------------------------------\n * It buys **recall, and only recall**. In media-manager an editorial slot\n * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates\n * (`CandidateGather.planFor`), scoped by a filter that is just \"this site's\n * images\" (`buildFilter`) — so the phrase ranks the corpus rather than gating\n * it. Which picture then WINS is decided by `SlotScorer`, whose every term is\n * geometric (crop fit, background, subject, text safety, resolution). Vespa's\n * relevance score is not one of them, and the pool's order is discarded by the\n * assignment solve. The phrase's entire job is choosing which 30 images reach\n * the scorer.\n *\n * That is why it is budgeted in WORDS. The documents it is matched against are\n * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,\n * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with\n * vector closeness. A headline plus a clause of marketing prose is the wrong\n * shape against that on both halves: the vector drifts toward the mean of\n * several topics instead of sitting on the subject, and BM25 spends term weight\n * on filler that pulls in whatever else on the site happens to carry those\n * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)\n * happen on the document side only.\n *\n * A phrase that is too specific costs a worse 30, never a blank section: the\n * site-wide widening in `CandidateGather.poolFor` is entity-only because an\n * editorial pool empties only when the site has no images at all.\n *\n * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text\n * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —\n * every text-derived slot in the product gets a new id. Not a place for\n * cosmetic edits.\n */\n\n/**\n * The phrase's real budget, in whole words — the unit that matters, because\n * what it is matched against is a short bag of nouns and not a paragraph.\n */\nconst WORD_BUDGET = 10;\n\n/**\n * A title of this many words already names its own subject, so the lead adds\n * dilution rather than signal and is dropped. Below it the title may be as thin\n * as \"Our picks\" or \"Why it matters\", which names nothing retrievable on its\n * own — that is the case the lead exists for.\n */\nconst TITLE_ENOUGH = 5;\n\n/** A character backstop under the word budget: ten words is normally well\n * inside it, but one pathological unbroken \"word\" is not. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst trimTail = (raw: string): string =>\n raw.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n\nconst tidy = (raw: string): string =>\n trimTail(stripMarkdown(raw).replace(/\\s+/g, ' ').trim());\n\nconst wordsOf = (raw: string): string[] =>\n raw.split(' ').filter((word) => word.length > 0);\n\n/** Keep at most `max` whole words. The unit the budget is actually stated in. */\nconst clampToWords = (raw: string, max: number): string => {\n const words = wordsOf(raw);\n if (words.length <= max) {\n return raw;\n }\n return trimTail(words.slice(0, max).join(' '));\n};\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return trimTail(out);\n};\n\n/** The word budget first, then the character backstop under it. */\nconst budgeted = (raw: string): string =>\n clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return budgeted(mission);\n }\n\n const head = tidy(title ?? '');\n const headWords = wordsOf(head).length;\n\n // The title is the stronger signal, so it is spent first and never truncated\n // to make room for the lead. Once it names a subject on its own, it IS the\n // phrase.\n if (headWords >= TITLE_ENOUGH) {\n const out = budgeted(head);\n return out.length < MIN_LENGTH ? '' : out;\n }\n\n // A thin title spends what it did not use on the lead's first clause.\n const body = clampToWords(\n firstClause(tidy(lead ?? '')),\n WORD_BUDGET - headWords,\n );\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
@@ -18,9 +18,55 @@
18
18
  * CDN build externalises core, so a core change could not be seen through
19
19
  * `?clientBundleUrl=` until it had been published. 0223 always assigned it
20
20
  * here, and this is that move.
21
+ *
22
+ * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)
23
+ * ------------------------------------------------------
24
+ * It buys **recall, and only recall**. In media-manager an editorial slot
25
+ * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates
26
+ * (`CandidateGather.planFor`), scoped by a filter that is just "this site's
27
+ * images" (`buildFilter`) — so the phrase ranks the corpus rather than gating
28
+ * it. Which picture then WINS is decided by `SlotScorer`, whose every term is
29
+ * geometric (crop fit, background, subject, text safety, resolution). Vespa's
30
+ * relevance score is not one of them, and the pool's order is discarded by the
31
+ * assignment solve. The phrase's entire job is choosing which 30 images reach
32
+ * the scorer.
33
+ *
34
+ * That is why it is budgeted in WORDS. The documents it is matched against are
35
+ * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,
36
+ * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with
37
+ * vector closeness. A headline plus a clause of marketing prose is the wrong
38
+ * shape against that on both halves: the vector drifts toward the mean of
39
+ * several topics instead of sitting on the subject, and BM25 spends term weight
40
+ * on filler that pulls in whatever else on the site happens to carry those
41
+ * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)
42
+ * happen on the document side only.
43
+ *
44
+ * A phrase that is too specific costs a worse 30, never a blank section: the
45
+ * site-wide widening in `CandidateGather.poolFor` is entity-only because an
46
+ * editorial pool empties only when the site has no images at all.
47
+ *
48
+ * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text
49
+ * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —
50
+ * every text-derived slot in the product gets a new id. Not a place for
51
+ * cosmetic edits.
52
+ */
53
+
54
+ /**
55
+ * The phrase's real budget, in whole words — the unit that matters, because
56
+ * what it is matched against is a short bag of nouns and not a paragraph.
57
+ */
58
+ const WORD_BUDGET = 10;
59
+
60
+ /**
61
+ * A title of this many words already names its own subject, so the lead adds
62
+ * dilution rather than signal and is dropped. Below it the title may be as thin
63
+ * as "Our picks" or "Why it matters", which names nothing retrievable on its
64
+ * own — that is the case the lead exists for.
21
65
  */
66
+ const TITLE_ENOUGH = 5;
22
67
 
23
- /** Longer than this and the tail stops helping retrieval and starts diluting it. */
68
+ /** A character backstop under the word budget: ten words is normally well
69
+ * inside it, but one pathological unbroken "word" is not. */
24
70
  const MAX_LENGTH = 120;
25
71
 
26
72
  /** Below this there is no query worth sending — better to render no picture
@@ -35,9 +81,18 @@ const stripMarkdown = raw => raw
35
81
  .replace(/^\s*#{1,6}\s*/gm, '').replace(/^\s*>\s?/gm, '')
36
82
  // html tags an author or the parser may have left behind
37
83
  .replace(/<[^>]+>/g, ' ');
38
- const tidy = raw => stripMarkdown(raw).replace(/\s+/g, ' ').trim()
39
- // trailing punctuation is noise in a retrieval phrase
40
- .replace(/[\s.,;:!?—–-]+$/g, '').trim();
84
+ const trimTail = raw => raw.replace(/[\s.,;:!?—–-]+$/g, '').trim();
85
+ const tidy = raw => trimTail(stripMarkdown(raw).replace(/\s+/g, ' ').trim());
86
+ const wordsOf = raw => raw.split(' ').filter(word => word.length > 0);
87
+
88
+ /** Keep at most `max` whole words. The unit the budget is actually stated in. */
89
+ const clampToWords = (raw, max) => {
90
+ const words = wordsOf(raw);
91
+ if (words.length <= max) {
92
+ return raw;
93
+ }
94
+ return trimTail(words.slice(0, max).join(' '));
95
+ };
41
96
 
42
97
  /**
43
98
  * Clamp without cutting a word in half. Slicing at a fixed length leaves
@@ -54,9 +109,12 @@ const clampToWord = (raw, max) => {
54
109
  const cut = raw.slice(0, max + 1);
55
110
  const lastBreak = cut.lastIndexOf(' ');
56
111
  const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);
57
- return out.replace(/[\s.,;:!?—–-]+$/g, '').trim();
112
+ return trimTail(out);
58
113
  };
59
114
 
115
+ /** The word budget first, then the character backstop under it. */
116
+ const budgeted = raw => clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);
117
+
60
118
  /** The first clause carries the subject; what follows is usually qualification. */
61
119
  const firstClause = raw => {
62
120
  const m = raw.match(/^[^.!?;]+/);
@@ -75,17 +133,23 @@ export function composeSemantic(_ref) {
75
133
  // A mission, when there is one, is the whole answer.
76
134
  const mission = tidy(sectionSemantic ?? '');
77
135
  if (mission.length >= MIN_LENGTH) {
78
- return clampToWord(mission, MAX_LENGTH);
136
+ return budgeted(mission);
79
137
  }
80
138
  const head = tidy(title ?? '');
81
- const body = firstClause(tidy(lead ?? ''));
139
+ const headWords = wordsOf(head).length;
140
+
141
+ // The title is the stronger signal, so it is spent first and never truncated
142
+ // to make room for the lead. Once it names a subject on its own, it IS the
143
+ // phrase.
144
+ if (headWords >= TITLE_ENOUGH) {
145
+ const out = budgeted(head);
146
+ return out.length < MIN_LENGTH ? '' : out;
147
+ }
148
+
149
+ // A thin title spends what it did not use on the lead's first clause.
150
+ const body = clampToWords(firstClause(tidy(lead ?? '')), WORD_BUDGET - headWords);
82
151
  const joined = [head, body].filter(Boolean).join('. ');
83
152
  if (joined.length < MIN_LENGTH) return '';
84
- if (joined.length <= MAX_LENGTH) return joined;
85
-
86
- // Over budget: keep the title whole if it fits, since it is the stronger
87
- // signal, and spend whatever is left on the lead.
88
- if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);
89
153
  return clampToWord(joined, MAX_LENGTH);
90
154
  }
91
155
  //# sourceMappingURL=composeSemantic.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","tidy","trim","clampToWord","max","length","cut","slice","lastBreak","lastIndexOf","out","firstClause","m","match","composeSemantic","_ref","title","lead","sectionSemantic","mission","head","body","joined","filter","Boolean","join"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n */\n\n/** Longer than this and the tail stops helping retrieval and starts diluting it. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst tidy = (raw: string): string =>\n stripMarkdown(raw)\n .replace(/\\s+/g, ' ')\n .trim()\n // trailing punctuation is noise in a retrieval phrase\n .replace(/[\\s.,;:!?—–-]+$/g, '')\n .trim();\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return out.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n};\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return clampToWord(mission, MAX_LENGTH);\n }\n\n const head = tidy(title ?? '');\n const body = firstClause(tidy(lead ?? ''));\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n if (joined.length <= MAX_LENGTH) return joined;\n\n // Over budget: keep the title whole if it fits, since it is the stronger\n // signal, and spend whatever is left on the lead.\n if (head.length >= MAX_LENGTH) return clampToWord(head, MAX_LENGTH);\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["WORD_BUDGET","TITLE_ENOUGH","MAX_LENGTH","MIN_LENGTH","stripMarkdown","raw","replace","trimTail","trim","tidy","wordsOf","split","filter","word","length","clampToWords","max","words","slice","join","clampToWord","cut","lastBreak","lastIndexOf","out","budgeted","firstClause","m","match","composeSemantic","_ref","title","lead","sectionSemantic","mission","head","headWords","body","joined","Boolean"],"sources":["../../../src/image/composeSemantic.ts"],"sourcesContent":["/**\n * Turn a section's own words into a retrieval phrase.\n *\n * The section hands over its RAW props — a title, a lead paragraph — not a\n * query string. Composing is this module's job, in one place, because the two\n * call sites that do it today invent their own phrase, do not agree with each\n * other, and neither is tested:\n *\n * w5-client-circana/src/components/sections/GenericEntitySection.tsx:144\n * `web5://image/${title}` — raw, so `**Nike** collections`\n * is searched WITH its asterisks\n * w5-client-circana/src/components/sections/ResearchSection.tsx:68\n * `web5://image/${encodeURIComponent(title)}` — same idea, escaped\n *\n * Centralising it is most of what the text-derived case is worth.\n *\n * Lifted from the seed client package (ADR 0225): it started there because the\n * CDN build externalises core, so a core change could not be seen through\n * `?clientBundleUrl=` until it had been published. 0223 always assigned it\n * here, and this is that move.\n *\n * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)\n * ------------------------------------------------------\n * It buys **recall, and only recall**. In media-manager an editorial slot\n * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates\n * (`CandidateGather.planFor`), scoped by a filter that is just \"this site's\n * images\" (`buildFilter`) — so the phrase ranks the corpus rather than gating\n * it. Which picture then WINS is decided by `SlotScorer`, whose every term is\n * geometric (crop fit, background, subject, text safety, resolution). Vespa's\n * relevance score is not one of them, and the pool's order is discarded by the\n * assignment solve. The phrase's entire job is choosing which 30 images reach\n * the scorer.\n *\n * That is why it is budgeted in WORDS. The documents it is matched against are\n * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,\n * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with\n * vector closeness. A headline plus a clause of marketing prose is the wrong\n * shape against that on both halves: the vector drifts toward the mean of\n * several topics instead of sitting on the subject, and BM25 spends term weight\n * on filler that pulls in whatever else on the site happens to carry those\n * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)\n * happen on the document side only.\n *\n * A phrase that is too specific costs a worse 30, never a blank section: the\n * site-wide widening in `CandidateGather.poolFor` is entity-only because an\n * editorial pool empties only when the site has no images at all.\n *\n * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text\n * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —\n * every text-derived slot in the product gets a new id. Not a place for\n * cosmetic edits.\n */\n\n/**\n * The phrase's real budget, in whole words — the unit that matters, because\n * what it is matched against is a short bag of nouns and not a paragraph.\n */\nconst WORD_BUDGET = 10;\n\n/**\n * A title of this many words already names its own subject, so the lead adds\n * dilution rather than signal and is dropped. Below it the title may be as thin\n * as \"Our picks\" or \"Why it matters\", which names nothing retrievable on its\n * own — that is the case the lead exists for.\n */\nconst TITLE_ENOUGH = 5;\n\n/** A character backstop under the word budget: ten words is normally well\n * inside it, but one pathological unbroken \"word\" is not. */\nconst MAX_LENGTH = 120;\n\n/** Below this there is no query worth sending — better to render no picture\n * than to retrieve on a word like \"More\". */\nconst MIN_LENGTH = 3;\n\nconst stripMarkdown = (raw: string): string =>\n raw\n // images before links: ![alt](src) would otherwise leave a stray `!`\n .replace(/!\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1')\n // emphasis / strong / strike / inline code — keep the words, drop the marks\n .replace(/(\\*\\*\\*|\\*\\*|\\*|___|__|_|~~|`)/g, '')\n // leading heading hashes and blockquote marks\n .replace(/^\\s*#{1,6}\\s*/gm, '')\n .replace(/^\\s*>\\s?/gm, '')\n // html tags an author or the parser may have left behind\n .replace(/<[^>]+>/g, ' ');\n\nconst trimTail = (raw: string): string =>\n raw.replace(/[\\s.,;:!?—–-]+$/g, '').trim();\n\nconst tidy = (raw: string): string =>\n trimTail(stripMarkdown(raw).replace(/\\s+/g, ' ').trim());\n\nconst wordsOf = (raw: string): string[] =>\n raw.split(' ').filter((word) => word.length > 0);\n\n/** Keep at most `max` whole words. The unit the budget is actually stated in. */\nconst clampToWords = (raw: string, max: number): string => {\n const words = wordsOf(raw);\n if (words.length <= max) {\n return raw;\n }\n return trimTail(words.slice(0, max).join(' '));\n};\n\n/**\n * Clamp without cutting a word in half. Slicing at a fixed length leaves\n * fragments like \"the kinds of product\" — observed in the first real run — and\n * a dangling partial word is noise in a retrieval phrase, not a shorter\n * version of it. Falls back to a hard slice only when the budget cannot fit\n * even one word.\n */\nconst clampToWord = (raw: string, max: number): string => {\n if (raw.length <= max) {\n return raw;\n }\n // +1 so a boundary landing exactly on the budget still counts as a break.\n const cut = raw.slice(0, max + 1);\n const lastBreak = cut.lastIndexOf(' ');\n const out = lastBreak > 0 ? cut.slice(0, lastBreak) : raw.slice(0, max);\n return trimTail(out);\n};\n\n/** The word budget first, then the character backstop under it. */\nconst budgeted = (raw: string): string =>\n clampToWord(clampToWords(raw, WORD_BUDGET), MAX_LENGTH);\n\n/** The first clause carries the subject; what follows is usually qualification. */\nconst firstClause = (raw: string): string => {\n const m = raw.match(/^[^.!?;]+/);\n return (m ? m[0] : raw).trim();\n};\n\nexport interface ComposeSemanticInput {\n /** The section's headline. */\n title?: string;\n /** Its lead paragraph or body copy. */\n lead?: string;\n /** The descriptor's own `semantic` / mission, when the host supplies one.\n * Outranks the prose: it is what the orchestrator meant the section to be\n * about, which is a better subject than what it happened to say. */\n sectionSemantic?: string;\n}\n\n/**\n * Returns the phrase, or `''` when what survives is too thin to retrieve on —\n * the caller then declares no slot at all rather than sending a bad query.\n */\nexport function composeSemantic({\n title,\n lead,\n sectionSemantic,\n}: ComposeSemanticInput): string {\n // A mission, when there is one, is the whole answer.\n const mission = tidy(sectionSemantic ?? '');\n if (mission.length >= MIN_LENGTH) {\n return budgeted(mission);\n }\n\n const head = tidy(title ?? '');\n const headWords = wordsOf(head).length;\n\n // The title is the stronger signal, so it is spent first and never truncated\n // to make room for the lead. Once it names a subject on its own, it IS the\n // phrase.\n if (headWords >= TITLE_ENOUGH) {\n const out = budgeted(head);\n return out.length < MIN_LENGTH ? '' : out;\n }\n\n // A thin title spends what it did not use on the lead's first clause.\n const body = clampToWords(\n firstClause(tidy(lead ?? '')),\n WORD_BUDGET - headWords,\n );\n\n const joined = [head, body].filter(Boolean).join('. ');\n if (joined.length < MIN_LENGTH) return '';\n\n return clampToWord(joined, MAX_LENGTH);\n}\n"],"mappings":"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","ignoreList":[]}
@@ -18,6 +18,37 @@
18
18
  * CDN build externalises core, so a core change could not be seen through
19
19
  * `?clientBundleUrl=` until it had been published. 0223 always assigned it
20
20
  * here, and this is that move.
21
+ *
22
+ * WHAT THIS PHRASE IS FOR, AND WHY IT IS SHORT (ADR 0238)
23
+ * ------------------------------------------------------
24
+ * It buys **recall, and only recall**. In media-manager an editorial slot
25
+ * retrieves `SEMANTIC` over `semantic_text` capped at 30 candidates
26
+ * (`CandidateGather.planFor`), scoped by a filter that is just "this site's
27
+ * images" (`buildFilter`) — so the phrase ranks the corpus rather than gating
28
+ * it. Which picture then WINS is decided by `SlotScorer`, whose every term is
29
+ * geometric (crop fit, background, subject, text safety, resolution). Vespa's
30
+ * relevance score is not one of them, and the pool's order is discarded by the
31
+ * assignment solve. The phrase's entire job is choosing which 30 images reach
32
+ * the scorer.
33
+ *
34
+ * That is why it is budgeted in WORDS. The documents it is matched against are
35
+ * short — `ImageDocumentWriter.semanticText` embeds `alt, caption, label,
36
+ * label…`, usually under ten words — and retrieval is hybrid, BM25 blended with
37
+ * vector closeness. A headline plus a clause of marketing prose is the wrong
38
+ * shape against that on both halves: the vector drifts toward the mean of
39
+ * several topics instead of sitting on the subject, and BM25 spends term weight
40
+ * on filler that pulls in whatever else on the site happens to carry those
41
+ * words. Nothing normalises the query — case folding and dedupe (`LabelMerge`)
42
+ * happen on the document side only.
43
+ *
44
+ * A phrase that is too specific costs a worse 30, never a blank section: the
45
+ * site-wide widening in `CandidateGather.poolFor` is entity-only because an
46
+ * editorial pool empties only when the site has no images at all.
47
+ *
48
+ * CHANGING ANY OF THIS INVALIDATES SLOT IDS. `useImageSlot` derives a text
49
+ * slot's id as `s:${semantic}` (ADR 0223), so the phrase IS the cache key —
50
+ * every text-derived slot in the product gets a new id. Not a place for
51
+ * cosmetic edits.
21
52
  */
22
53
  export interface ComposeSemanticInput {
23
54
  /** The section's headline. */
@@ -1 +1 @@
1
- {"version":3,"file":"composeSemantic.d.ts","sourceRoot":"","sources":["../../../src/image/composeSemantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAsDH,MAAM,WAAW,oBAAoB;IACnC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;yEAEqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,IAAI,EACJ,eAAe,GAChB,EAAE,oBAAoB,GAAG,MAAM,CAmB/B"}
1
+ {"version":3,"file":"composeSemantic.d.ts","sourceRoot":"","sources":["../../../src/image/composeSemantic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AAmFH,MAAM,WAAW,oBAAoB;IACnC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;yEAEqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,IAAI,EACJ,eAAe,GAChB,EAAE,oBAAoB,GAAG,MAAM,CA4B/B"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wix/web5-core",
3
3
  "license": "MIT",
4
- "version": "1.63.36",
4
+ "version": "1.63.37",
5
5
  "author": {
6
6
  "name": "tsachis",
7
7
  "email": "tsachis@wix.com"
@@ -100,5 +100,5 @@
100
100
  "wallaby": {
101
101
  "autoDetect": true
102
102
  },
103
- "falconPackageHash": "fd995f5e05dc3354be22d05cd45791b67be39f102c79617aa4ab65c7"
103
+ "falconPackageHash": "13189ccec55e96d863674e8bdad119b67199e59b89283f33fe077e91"
104
104
  }