@indexnetwork/protocol 65.0.0-rc.606.1 → 66.0.0-rc.608.1
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/CHANGELOG.md +8 -0
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/internal/opportunities/opportunity.graph.modes.d.ts +0 -1
- package/dist/internal/opportunities/opportunity.graph.modes.js +0 -5
- package/dist/internal/opportunities/opportunity.labels.d.ts +0 -2
- package/dist/internal/opportunities/opportunity.labels.js +0 -2
- package/dist/internal/opportunities/opportunity.presentation.d.ts +19 -183
- package/dist/internal/opportunities/opportunity.presentation.js +32 -648
- package/dist/internal/opportunities/radar/radar.graph.d.ts +2 -2
- package/dist/internal/opportunities/radar/radar.graph.js +4 -35
- package/dist/internal/opportunities/radar/radar.state.d.ts +1 -3
- package/package.json +1 -1
- package/dist/internal/shared/utils/claim-safety.d.ts +0 -24
- package/dist/internal/shared/utils/claim-safety.js +0 -88
|
@@ -2,16 +2,12 @@
|
|
|
2
2
|
* The opportunity presentation cluster.
|
|
3
3
|
*
|
|
4
4
|
* One file for the whole path from a persisted opportunity to the copy a user
|
|
5
|
-
* reads: the
|
|
6
|
-
*
|
|
7
|
-
* called each other in one direction, and following a card's copy meant hopping
|
|
8
|
-
* between them.
|
|
5
|
+
* reads: the cache-key builders and the LLM presenter itself. All user-facing
|
|
6
|
+
* copy comes from the presenter; there is no deterministic fallback.
|
|
9
7
|
*
|
|
10
8
|
* Sections, in dependency order:
|
|
11
|
-
* 1.
|
|
12
|
-
* 2.
|
|
13
|
-
* 3. Safe-presentation pipeline (fallbacks that never leak raw reasoning)
|
|
14
|
-
* 4. OpportunityPresenter (LLM card and chat copy)
|
|
9
|
+
* 1. Presentation cache keys
|
|
10
|
+
* 2. OpportunityPresenter (LLM card and chat copy)
|
|
15
11
|
*/
|
|
16
12
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
17
13
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -22,433 +18,12 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
22
18
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
23
19
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
24
20
|
};
|
|
25
|
-
import { MINIMAL_MAIN_TEXT_MAX_CHARS } from "./opportunity.labels.js";
|
|
26
|
-
import { stripUnsupportedOpportunityClaims } from "../shared/utils/claim-safety.js";
|
|
27
21
|
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
|
|
28
22
|
import { z } from "zod";
|
|
29
23
|
import { Timed } from "../shared/observability/performance.js";
|
|
30
|
-
import { protocolLogger } from "../shared/observability/protocol.logger.js";
|
|
31
24
|
import { createStructuredModel } from "../shared/agent/model.config.js";
|
|
32
|
-
/**
|
|
33
|
-
* Generate presentation copy for an opportunity based on viewer context.
|
|
34
|
-
* Pure function — no side effects, no database access.
|
|
35
|
-
*/
|
|
36
|
-
export function presentOpportunity(opp, viewerId, otherPartyInfo, format) {
|
|
37
|
-
const myActor = opp.actors.find((a) => a.userId === viewerId);
|
|
38
|
-
if (!myActor) {
|
|
39
|
-
throw new Error('Viewer is not an actor in this opportunity');
|
|
40
|
-
}
|
|
41
|
-
const otherName = otherPartyInfo.name;
|
|
42
|
-
const safeReasoning = stripUnsupportedOpportunityClaims(stripUuids(opp.interpretation.reasoning)) ||
|
|
43
|
-
'A promising connection.';
|
|
44
|
-
let title;
|
|
45
|
-
let description;
|
|
46
|
-
let descriptionIsReasoning = false;
|
|
47
|
-
switch (myActor.role) {
|
|
48
|
-
case 'agent':
|
|
49
|
-
title = `You can help ${otherName}`;
|
|
50
|
-
description = `Based on your expertise, ${otherName} might benefit from connecting with you.`;
|
|
51
|
-
break;
|
|
52
|
-
case 'patient':
|
|
53
|
-
title = `${otherName} might be able to help you`;
|
|
54
|
-
description = `${otherName} has skills that align with what you're looking for.`;
|
|
55
|
-
break;
|
|
56
|
-
case 'peer':
|
|
57
|
-
title = `Potential collaboration with ${otherName}`;
|
|
58
|
-
description = `You and ${otherName} have complementary interests.`;
|
|
59
|
-
break;
|
|
60
|
-
case 'mentee':
|
|
61
|
-
title = `${otherName} could mentor you`;
|
|
62
|
-
description = `${otherName} has experience that could help guide your journey.`;
|
|
63
|
-
break;
|
|
64
|
-
case 'mentor':
|
|
65
|
-
title = `${otherName} is looking for guidance`;
|
|
66
|
-
description = `Your expertise could help ${otherName} on their path.`;
|
|
67
|
-
break;
|
|
68
|
-
case 'founder':
|
|
69
|
-
title = `${otherName} might be interested in your venture`;
|
|
70
|
-
description = `${otherName}'s investment focus aligns with what you're building.`;
|
|
71
|
-
break;
|
|
72
|
-
case 'investor':
|
|
73
|
-
title = `${otherName} is building something interesting`;
|
|
74
|
-
description = `${otherName}'s venture might fit your investment thesis.`;
|
|
75
|
-
break;
|
|
76
|
-
case 'party':
|
|
77
|
-
default:
|
|
78
|
-
title = `Opportunity with ${otherName}`;
|
|
79
|
-
description = safeReasoning;
|
|
80
|
-
descriptionIsReasoning = true;
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
if (!descriptionIsReasoning) {
|
|
84
|
-
description += `\n\n${safeReasoning}`;
|
|
85
|
-
}
|
|
86
|
-
if (format === 'notification') {
|
|
87
|
-
description =
|
|
88
|
-
description.length > 100 ? description.slice(0, 97) + '...' : description;
|
|
89
|
-
}
|
|
90
|
-
return {
|
|
91
|
-
title,
|
|
92
|
-
description,
|
|
93
|
-
callToAction: 'View Opportunity',
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Strips UUID patterns from user-facing text to prevent internal ID leaks.
|
|
98
|
-
*/
|
|
99
|
-
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
100
|
-
export function stripUuids(text) {
|
|
101
|
-
return text
|
|
102
|
-
.replace(/\(([^)]*)\)/g, (_match, inner) => {
|
|
103
|
-
if (!UUID_PATTERN.test(inner)) {
|
|
104
|
-
UUID_PATTERN.lastIndex = 0;
|
|
105
|
-
return _match;
|
|
106
|
-
}
|
|
107
|
-
UUID_PATTERN.lastIndex = 0;
|
|
108
|
-
const cleaned = inner
|
|
109
|
-
.replace(UUID_PATTERN, '')
|
|
110
|
-
.replace(/,\s*,/g, ',')
|
|
111
|
-
.replace(/\b(?:from|and)\b/gi, '')
|
|
112
|
-
.replace(/^[\s,]+|[\s,]+$/g, '');
|
|
113
|
-
return cleaned ? `(${cleaned})` : '';
|
|
114
|
-
})
|
|
115
|
-
.replace(UUID_PATTERN, '')
|
|
116
|
-
.replace(/\s{2,}/g, ' ')
|
|
117
|
-
.trim();
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Truncate user-facing text to at most `maxChars` without cutting mid-word.
|
|
121
|
-
*
|
|
122
|
-
* Prefers a sentence boundary, then a word boundary, and only falls back to a
|
|
123
|
-
* hard slice if no boundary exists within the limit. An ellipsis is appended
|
|
124
|
-
* when the text is actually shortened. Used by presenter fallbacks so a degraded
|
|
125
|
-
* card never shows a sentence chopped mid-word (e.g. "His focus on 'indiv").
|
|
126
|
-
*/
|
|
127
|
-
export function truncateAtBoundary(text, maxChars) {
|
|
128
|
-
const trimmed = text.trim();
|
|
129
|
-
if (trimmed.length <= maxChars)
|
|
130
|
-
return trimmed;
|
|
131
|
-
const slice = trimmed.slice(0, maxChars);
|
|
132
|
-
// Prefer ending on the last completed sentence within the limit.
|
|
133
|
-
const lastSentence = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "));
|
|
134
|
-
if (lastSentence >= maxChars * 0.5) {
|
|
135
|
-
return slice.slice(0, lastSentence + 1).trim();
|
|
136
|
-
}
|
|
137
|
-
// Otherwise back off to the last whole word and add an ellipsis.
|
|
138
|
-
const lastSpace = slice.lastIndexOf(" ");
|
|
139
|
-
const body = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
|
|
140
|
-
return body.replace(/[\s,;:.!?'"-]+$/, "").trim() + "\u2026";
|
|
141
|
-
}
|
|
142
|
-
// Helper function
|
|
143
|
-
function escapeRegex(s) {
|
|
144
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Viewer-centric text for opportunity cards.
|
|
148
|
-
* The card is shown to the viewer (logged-in user) and should introduce the
|
|
149
|
-
* counterpart, not describe the viewer to themselves.
|
|
150
|
-
*/
|
|
151
|
-
/**
|
|
152
|
-
* Splits text into sentences using (?<=[.!?])\s+ (period/exclamation/question followed by whitespace).
|
|
153
|
-
* Note: splits after any such punctuation, including abbreviations like "Dr." or "e.g.".
|
|
154
|
-
*/
|
|
155
|
-
function splitSentences(text) {
|
|
156
|
-
const trimmed = text.trim();
|
|
157
|
-
if (!trimmed)
|
|
158
|
-
return [];
|
|
159
|
-
return trimmed
|
|
160
|
-
.split(/(?<=[.!?])\s+/)
|
|
161
|
-
.map((s) => s.trim())
|
|
162
|
-
.filter(Boolean);
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Returns viewer-centric main text for an opportunity card.
|
|
166
|
-
* Prefers the part of the reasoning that describes the counterpart (the person
|
|
167
|
-
* on the card), so the viewer sees an introduction to the counterpart rather
|
|
168
|
-
* than a description of themselves.
|
|
169
|
-
*
|
|
170
|
-
* @param reasoning - Raw interpretation.reasoning (may describe both parties).
|
|
171
|
-
* @param counterpartName - Display name of the suggested connection (e.g. "Alex Chen").
|
|
172
|
-
* @param maxChars - Max length of returned string (default MINIMAL_MAIN_TEXT_MAX_CHARS).
|
|
173
|
-
* @param viewerName - Optional display name of the viewer (signed-in user). When provided, sentences or prefixes describing the viewer are skipped so the card introduces the counterpart, not the viewer.
|
|
174
|
-
* @returns Viewer-centric snippet mentioning the counterpart when possible; if counterpartName is empty, returns reasoning truncated to maxChars. Never null; may be "A suggested connection." when reasoning is empty.
|
|
175
|
-
*/
|
|
176
|
-
export function viewerCentricCardSummary(reasoning, counterpartName, maxChars = MINIMAL_MAIN_TEXT_MAX_CHARS, viewerName) {
|
|
177
|
-
const raw = stripUnsupportedOpportunityClaims(stripUuids(reasoning));
|
|
178
|
-
if (!raw)
|
|
179
|
-
return "A suggested connection.";
|
|
180
|
-
const name = counterpartName.trim();
|
|
181
|
-
if (!name) {
|
|
182
|
-
let out = raw.length <= maxChars ? raw : raw.slice(0, maxChars) + "...";
|
|
183
|
-
out = replaceViewerNameWithYou(out, viewerName);
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
186
|
-
const sentences = splitSentences(raw);
|
|
187
|
-
const nameLower = name.toLowerCase();
|
|
188
|
-
const firstWordOfName = name.split(/\s+/)[0]?.toLowerCase();
|
|
189
|
-
const hasCounterpartName = (s) => s.toLowerCase().includes(nameLower) ||
|
|
190
|
-
(firstWordOfName && firstWordOfName.length > 1 && s.toLowerCase().includes(firstWordOfName));
|
|
191
|
-
const viewer = viewerName?.trim().toLowerCase();
|
|
192
|
-
const viewerFirstWord = viewerName?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
193
|
-
const startsWithViewer = (s) => {
|
|
194
|
-
if (!viewer)
|
|
195
|
-
return false;
|
|
196
|
-
const sl = s.toLowerCase();
|
|
197
|
-
return sl.startsWith(viewer) ||
|
|
198
|
-
(viewerFirstWord && viewerFirstWord.length > 1 && sl.startsWith(viewerFirstWord));
|
|
199
|
-
};
|
|
200
|
-
// When viewerName is provided, prefer sentences that mention the counterpart
|
|
201
|
-
// but do NOT start with the viewer's name.
|
|
202
|
-
if (viewer) {
|
|
203
|
-
// First pass: find a sentence that mentions counterpart and doesn't start with viewer
|
|
204
|
-
const cleanIdx = sentences.findIndex((s) => hasCounterpartName(s) && !startsWithViewer(s));
|
|
205
|
-
if (cleanIdx !== -1) {
|
|
206
|
-
const result = sentences.slice(cleanIdx).join(" ").trim();
|
|
207
|
-
let out = result.length <= maxChars ? result : result.slice(0, maxChars) + "...";
|
|
208
|
-
out = replaceViewerNameWithYou(out, viewerName, [name]);
|
|
209
|
-
return out;
|
|
210
|
-
}
|
|
211
|
-
// Second pass: sentence mentions counterpart but starts with viewer (compound sentence).
|
|
212
|
-
// Try to extract the counterpart portion after the counterpart's name.
|
|
213
|
-
const compoundIdx = sentences.findIndex((s) => hasCounterpartName(s) && startsWithViewer(s));
|
|
214
|
-
if (compoundIdx !== -1) {
|
|
215
|
-
const sentence = sentences[compoundIdx];
|
|
216
|
-
// Find where the counterpart name appears and extract from there
|
|
217
|
-
// Use case-insensitive Unicode-aware regex so the index is correct
|
|
218
|
-
// even when toLowerCase() changes string length (e.g. Turkish İ→i, German ß→ss).
|
|
219
|
-
const cpMatch = sentence.match(new RegExp(escapeRegex(name), "iu"));
|
|
220
|
-
const cpIdx = cpMatch?.index ?? -1;
|
|
221
|
-
if (cpIdx > 0) {
|
|
222
|
-
const extracted = sentence.slice(cpIdx).trim();
|
|
223
|
-
const rest = sentences.slice(compoundIdx + 1).join(" ").trim();
|
|
224
|
-
const result = rest ? `${extracted} ${rest}` : extracted;
|
|
225
|
-
let out = result.length <= maxChars ? result : result.slice(0, maxChars) + "...";
|
|
226
|
-
out = replaceViewerNameWithYou(out, viewerName, [name]);
|
|
227
|
-
return out;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
// Fallback: original logic without viewer awareness
|
|
232
|
-
const idx = sentences.findIndex(hasCounterpartName);
|
|
233
|
-
if (idx === -1) {
|
|
234
|
-
let out = raw.length <= maxChars ? raw : raw.slice(0, maxChars) + "...";
|
|
235
|
-
out = replaceViewerNameWithYou(out, viewerName, [name]);
|
|
236
|
-
return out;
|
|
237
|
-
}
|
|
238
|
-
const fromCounterpart = sentences.slice(idx).join(" ").trim();
|
|
239
|
-
let out = fromCounterpart.length <= maxChars
|
|
240
|
-
? fromCounterpart
|
|
241
|
-
: fromCounterpart.slice(0, maxChars) + "...";
|
|
242
|
-
out = replaceViewerNameWithYou(out, viewerName, [name]);
|
|
243
|
-
return out;
|
|
244
|
-
}
|
|
245
|
-
/** Max length for narrator chip text (matches LLM presenter schema). */
|
|
246
|
-
const NARRATOR_MAX_CHARS = 80;
|
|
247
|
-
const FALLBACK_REMARK = "A potential connection worth exploring.";
|
|
248
|
-
/**
|
|
249
|
-
* Generates a short narrator remark from opportunity reasoning for the narrator chip.
|
|
250
|
-
* Used by the minimal (no-LLM) card path so each card gets a unique remark
|
|
251
|
-
* instead of the same static text.
|
|
252
|
-
*
|
|
253
|
-
* Extracts domain keywords (e.g. "AI", "design", "machine learning") from the
|
|
254
|
-
* reasoning and frames them in a short template like "Shared interest in AI and design."
|
|
255
|
-
*
|
|
256
|
-
* This is a regex-based heuristic — an alternative is OpportunityPresenter.presentCard()
|
|
257
|
-
* which generates narratorRemark via LLM with much higher quality.
|
|
258
|
-
*
|
|
259
|
-
* @param reasoning - Raw interpretation.reasoning text.
|
|
260
|
-
* @param counterpartName - Display name of the counterpart (stripped from output).
|
|
261
|
-
* @param viewerName - Optional display name of the viewer (stripped from output).
|
|
262
|
-
* @returns A short remark (max ~80 chars) suitable for the narrator chip. Never truncated with "...".
|
|
263
|
-
*/
|
|
264
|
-
export function narratorRemarkFromReasoning(reasoning, counterpartName, viewerName) {
|
|
265
|
-
const raw = stripUnsupportedOpportunityClaims(stripUuids(reasoning)).trim();
|
|
266
|
-
if (!raw)
|
|
267
|
-
return FALLBACK_REMARK;
|
|
268
|
-
// Strip all person names from the text so we work only with topics.
|
|
269
|
-
let cleaned = raw;
|
|
270
|
-
for (const name of [counterpartName, viewerName]) {
|
|
271
|
-
if (!name?.trim())
|
|
272
|
-
continue;
|
|
273
|
-
const full = name.trim();
|
|
274
|
-
cleaned = cleaned.replace(new RegExp(escapeRegex(full), "gi"), "").trim();
|
|
275
|
-
const first = full.split(/\s+/)[0];
|
|
276
|
-
if (first && first.length > 1) {
|
|
277
|
-
cleaned = cleaned.replace(new RegExp(`\\b${escapeRegex(first)}\\b`, "gi"), "").trim();
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// Extract domain/topic noun phrases from the cleaned text.
|
|
281
|
-
// Match multi-word capitalized phrases (e.g. "AI operations toolkit") and
|
|
282
|
-
// known domain terms.
|
|
283
|
-
const domainTerms = extractDomainTerms(cleaned);
|
|
284
|
-
if (domainTerms.length > 0) {
|
|
285
|
-
// Build "Shared interest in X and Y." or "Overlap in X, Y, and Z."
|
|
286
|
-
const prefixes = [
|
|
287
|
-
"Shared interest in",
|
|
288
|
-
"Overlap in",
|
|
289
|
-
"Common ground in",
|
|
290
|
-
"Aligned on",
|
|
291
|
-
"Mutual interest in",
|
|
292
|
-
];
|
|
293
|
-
// Pick prefix deterministically based on first term's char code
|
|
294
|
-
const prefixIdx = domainTerms[0].charCodeAt(0) % prefixes.length;
|
|
295
|
-
const prefix = prefixes[prefixIdx];
|
|
296
|
-
const joined = joinTerms(domainTerms, NARRATOR_MAX_CHARS - prefix.length - 2); // -2 for " " and "."
|
|
297
|
-
const remark = `${prefix} ${joined}.`;
|
|
298
|
-
if (remark.length <= NARRATOR_MAX_CHARS)
|
|
299
|
-
return remark;
|
|
300
|
-
}
|
|
301
|
-
// Fallback: try to extract a short relationship phrase
|
|
302
|
-
const relationshipMatch = cleaned.match(/\b(complementary skills|shared expertise|overlapping intents|similar interests|strong match|mutual fit|potential collaboration|looking for (?:a |an )?[\w\s]{3,20})\b/i);
|
|
303
|
-
if (relationshipMatch) {
|
|
304
|
-
const phrase = relationshipMatch[0];
|
|
305
|
-
const remark = `Spotted ${phrase.toLowerCase()}.`;
|
|
306
|
-
if (remark.length <= NARRATOR_MAX_CHARS)
|
|
307
|
-
return remark;
|
|
308
|
-
}
|
|
309
|
-
return FALLBACK_REMARK;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Extracts domain/topic terms from text by matching known patterns:
|
|
313
|
-
* - Acronyms (AI, ML, UX, API)
|
|
314
|
-
* - Multi-word domain phrases (machine learning, game development)
|
|
315
|
-
* - Capitalized proper nouns that look like topics
|
|
316
|
-
*/
|
|
317
|
-
function extractDomainTerms(text) {
|
|
318
|
-
const seen = new Set();
|
|
319
|
-
const terms = [];
|
|
320
|
-
// Known domain phrases (order matters — longer first)
|
|
321
|
-
const knownPhrases = [
|
|
322
|
-
/\b(machine learning|artificial intelligence|software development|game development|web development|data science|deep learning|natural language processing|computer vision|cloud computing|mobile development|product design|user experience|graphic design|character design|frontend development|backend development|full[- ]stack|smart contracts|visual art|creative writing|content creation|digital marketing|venture capital|angel invest(?:ing|ment)|open source|blockchain|cryptocurrency|decentralized finance|social impact|community building|music production|film(?:making| production)|photography|illustration|animation|3D modeling|startup|co-?founding|entrepreneurship|research|consulting|mentoring|freelanc(?:e|ing))\b/gi,
|
|
323
|
-
/\b(AI|ML|UX|UI|API|NLP|SaaS|DeFi|DevOps|DeSci|NFT|DAO|React|Node|Python|TypeScript|JavaScript|Rust|Solidity|Go|Swift|Kotlin|Figma|Blender|Unity|Unreal)\b/g,
|
|
324
|
-
];
|
|
325
|
-
for (const pattern of knownPhrases) {
|
|
326
|
-
for (const match of text.matchAll(pattern)) {
|
|
327
|
-
const term = match[1] ?? match[0];
|
|
328
|
-
const key = term.toLowerCase();
|
|
329
|
-
if (!seen.has(key)) {
|
|
330
|
-
seen.add(key);
|
|
331
|
-
// Preserve case for short acronyms/proper nouns; lowercase multi-word phrases
|
|
332
|
-
if (term.length <= 5 && /^[A-Z]/.test(term)) {
|
|
333
|
-
terms.push(term); // Keep React, AI, ML, etc. as-is
|
|
334
|
-
}
|
|
335
|
-
else {
|
|
336
|
-
terms.push(key);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
// If no known phrases found, look for capitalized multi-word phrases
|
|
342
|
-
// that look like explicit topic references (e.g. "Visual Art", "Smart Contracts").
|
|
343
|
-
// Only accept capitalized words to avoid grabbing meta-language from evaluator reasoning
|
|
344
|
-
// (e.g. "discoverer", "explicitly", "states" which are about the matching process, not topics).
|
|
345
|
-
if (terms.length === 0) {
|
|
346
|
-
// Multi-word capitalized phrases first (e.g. "Visual Art", "Creative Writing")
|
|
347
|
-
const multiWordPattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g;
|
|
348
|
-
for (const match of text.matchAll(multiWordPattern)) {
|
|
349
|
-
const term = match[1];
|
|
350
|
-
const key = term.toLowerCase();
|
|
351
|
-
if (!seen.has(key)) {
|
|
352
|
-
seen.add(key);
|
|
353
|
-
terms.push(key);
|
|
354
|
-
if (terms.length >= 3)
|
|
355
|
-
break;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
// Single capitalized words as last resort (skip common sentence-starters and meta-words)
|
|
359
|
-
if (terms.length === 0) {
|
|
360
|
-
const skipCapitalized = new Set([
|
|
361
|
-
// Articles / conjunctions / prepositions (capitalized at sentence start)
|
|
362
|
-
"the", "and", "but", "for", "from", "with", "without", "between",
|
|
363
|
-
"into", "about", "after", "before", "over", "under", "through",
|
|
364
|
-
// Common sentence starters / pronouns / determiners
|
|
365
|
-
"both", "their", "they", "this", "that", "these", "those",
|
|
366
|
-
"here", "there", "would", "could", "should", "also", "very",
|
|
367
|
-
"one", "another", "other", "each", "some", "many", "most",
|
|
368
|
-
"such", "clear", "high", "good", "well", "just", "even",
|
|
369
|
-
// Generic matching/relationship language
|
|
370
|
-
"strong", "match", "based", "making", "looking", "seeking",
|
|
371
|
-
"connection", "relationship", "opportunity", "overlap",
|
|
372
|
-
"complementary", "potential", "interested", "collaborate",
|
|
373
|
-
// Evaluator meta-language (about the matching process, not topics)
|
|
374
|
-
"intent", "intents", "profile", "user", "users", "person",
|
|
375
|
-
"discoverer", "explicitly", "states", "expressed", "mentioned",
|
|
376
|
-
"indicates", "suggests", "demonstrates", "describes", "involves",
|
|
377
|
-
"inference", "preparatory", "sincerity", "evaluator", "classifier",
|
|
378
|
-
"semantic", "pragmatic", "verification", "reconciliation",
|
|
379
|
-
"assertive", "commissive", "directive", "illocutionary",
|
|
380
|
-
"felicity", "utterance", "detected", "analysis", "confirmed",
|
|
381
|
-
"genuine", "conditions", "determined",
|
|
382
|
-
// Discourse markers
|
|
383
|
-
"particularly", "specifically", "especially", "primarily",
|
|
384
|
-
"overall", "furthermore", "however", "therefore", "moreover",
|
|
385
|
-
]);
|
|
386
|
-
const capWords = text.match(/\b[A-Z][a-z]{2,}\b/g) ?? [];
|
|
387
|
-
for (const w of capWords) {
|
|
388
|
-
const key = w.toLowerCase();
|
|
389
|
-
if (!skipCapitalized.has(key) && !seen.has(key)) {
|
|
390
|
-
seen.add(key);
|
|
391
|
-
terms.push(key);
|
|
392
|
-
if (terms.length >= 3)
|
|
393
|
-
break;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
return terms.slice(0, 3); // Max 3 terms
|
|
399
|
-
}
|
|
400
|
-
/** Joins terms into "X, Y, and Z" form, dropping terms if too long. */
|
|
401
|
-
function joinTerms(terms, maxLen) {
|
|
402
|
-
if (terms.length === 1)
|
|
403
|
-
return terms[0];
|
|
404
|
-
// Try all terms first
|
|
405
|
-
for (let count = terms.length; count >= 1; count--) {
|
|
406
|
-
const subset = terms.slice(0, count);
|
|
407
|
-
let joined;
|
|
408
|
-
if (subset.length === 1) {
|
|
409
|
-
joined = subset[0];
|
|
410
|
-
}
|
|
411
|
-
else if (subset.length === 2) {
|
|
412
|
-
joined = `${subset[0]} and ${subset[1]}`;
|
|
413
|
-
}
|
|
414
|
-
else {
|
|
415
|
-
joined = `${subset.slice(0, -1).join(", ")}, and ${subset[subset.length - 1]}`;
|
|
416
|
-
}
|
|
417
|
-
if (joined.length <= maxLen)
|
|
418
|
-
return joined;
|
|
419
|
-
}
|
|
420
|
-
return terms[0].slice(0, maxLen);
|
|
421
|
-
}
|
|
422
|
-
/**
|
|
423
|
-
* Replaces viewer's name with "you"/"your" so the card addresses the viewer in second person.
|
|
424
|
-
* Applied to mainText when viewerName is provided.
|
|
425
|
-
* @param otherNames - Other actor names in the card; first-name replacement is
|
|
426
|
-
* skipped when the viewer's first name matches any other actor's first name.
|
|
427
|
-
*/
|
|
428
|
-
function replaceViewerNameWithYou(text, viewerName, otherNames) {
|
|
429
|
-
if (!viewerName?.trim())
|
|
430
|
-
return text;
|
|
431
|
-
const full = viewerName.trim();
|
|
432
|
-
const first = full.split(/\s+/)[0];
|
|
433
|
-
let out = text;
|
|
434
|
-
// Possessive: "Yankı's" → "your", "Yankı Ekin Yüksel's" → "your"
|
|
435
|
-
out = out.replace(new RegExp(`\\b${escapeRegex(full)}'s\\b`, "gi"), "your");
|
|
436
|
-
const otherFirstNames = (otherNames ?? [])
|
|
437
|
-
.map(n => n.trim().split(/\s+/)[0]?.toLowerCase())
|
|
438
|
-
.filter(Boolean);
|
|
439
|
-
const firstNameCollides = first && otherFirstNames.includes(first.toLowerCase());
|
|
440
|
-
if (first && first.length > 1 && !firstNameCollides) {
|
|
441
|
-
out = out.replace(new RegExp(`\\b${escapeRegex(first)}'s\\b`, "gi"), "your");
|
|
442
|
-
}
|
|
443
|
-
// Standalone: full name then first name so we don't break "Yankı Ekin Yüksel"
|
|
444
|
-
out = out.replace(new RegExp(`\\b${escapeRegex(full)}\\b`, "gi"), "you");
|
|
445
|
-
if (first && first.length > 1 && !firstNameCollides) {
|
|
446
|
-
out = out.replace(new RegExp(`\\b${escapeRegex(first)}\\b`, "gi"), "you");
|
|
447
|
-
}
|
|
448
|
-
return out;
|
|
449
|
-
}
|
|
450
25
|
// ──────────────────────────────────────────────────────────────────────
|
|
451
|
-
// ──
|
|
26
|
+
// ── 1. Presentation cache keys ──
|
|
452
27
|
// ──────────────────────────────────────────────────────────────────────
|
|
453
28
|
/** Cache namespace for opportunity presentation copy. Bump to invalidate copy safety changes. */
|
|
454
29
|
export const OPPORTUNITY_PRESENTATION_CACHE_VERSION = "v2";
|
|
@@ -459,111 +34,6 @@ export function buildRadarCardPresentationCacheKey(opportunityId, status, viewer
|
|
|
459
34
|
export function buildApiChatCardPresentationCacheKey(opportunityId, viewerId) {
|
|
460
35
|
return `chat:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${viewerId}`;
|
|
461
36
|
}
|
|
462
|
-
// ──────────────────────────────────────────────────────────────────────
|
|
463
|
-
// ── 3. Safe-presentation pipeline ──
|
|
464
|
-
// ──────────────────────────────────────────────────────────────────────
|
|
465
|
-
/**
|
|
466
|
-
* Shared safe-presentation primitive for all user-facing opportunity surfaces.
|
|
467
|
-
*
|
|
468
|
-
* Historically every surface (radar, list/discover cards, minimal chat
|
|
469
|
-
* cards, notification emails/Telegram, chat context, delivery cards) invented
|
|
470
|
-
* its own fallback chain for the case where genuine LLM presenter output is
|
|
471
|
-
* unavailable — some sliced raw `interpretation.reasoning` with no
|
|
472
|
-
* sanitization at all. This module is the single standard:
|
|
473
|
-
*
|
|
474
|
-
* raw reasoning
|
|
475
|
-
* → whitespace-normalize
|
|
476
|
-
* → viewer-centric rewrite (incl. UUID stripping)
|
|
477
|
-
* → boundary-aware truncation
|
|
478
|
-
* → per-surface empty-text default
|
|
479
|
-
*
|
|
480
|
-
* Surfaces choose *policy* (send a sanitized fallback vs skip entirely) via
|
|
481
|
-
* `allowFallback`; they no longer choose (or forget) sanitization steps.
|
|
482
|
-
*
|
|
483
|
-
* See `packages/protocol/s./opportunity/AGENTS.md` for the review checklist this
|
|
484
|
-
* module exists to satisfy.
|
|
485
|
-
*/
|
|
486
|
-
/** Default max length for fallback summaries (matches presenter internal fallback). */
|
|
487
|
-
export const SAFE_FALLBACK_MAX_CHARS = 300;
|
|
488
|
-
/** Default copy when no reasoning text is available at all. */
|
|
489
|
-
export const DEFAULT_EMPTY_FALLBACK_TEXT = "A promising connection.";
|
|
490
|
-
/** Default headline for fallback presentations (matches presenter internal fallback). */
|
|
491
|
-
export const DEFAULT_FALLBACK_HEADLINE = "A promising connection";
|
|
492
|
-
/** Default CTA for fallback presentations (matches presenter internal fallback). */
|
|
493
|
-
export const DEFAULT_FALLBACK_ACTION = "Take a look and decide whether to reach out.";
|
|
494
|
-
/**
|
|
495
|
-
* Produce safe user-facing fallback copy from raw match reasoning.
|
|
496
|
-
*
|
|
497
|
-
* This is the ONE sanitization standard: UUID stripping,
|
|
498
|
-
* stripping, and viewer-centric rewrite (via {@link viewerCentricCardSummary}),
|
|
499
|
-
* followed by whitespace normalization and boundary-aware truncation (via
|
|
500
|
-
* {@link truncateAtBoundary}). Never returns raw reasoning verbatim beyond
|
|
501
|
-
* these guarantees, and never returns an empty string.
|
|
502
|
-
*
|
|
503
|
-
* @param rawReasoning - Raw `interpretation.reasoning` / `matchReason` text (may be null/undefined).
|
|
504
|
-
* @param opts - Per-surface knobs (names for rewrite, max length, empty-text copy).
|
|
505
|
-
*/
|
|
506
|
-
export function safeFallbackSummary(rawReasoning, opts = {}) {
|
|
507
|
-
const emptyText = opts.emptyText ?? DEFAULT_EMPTY_FALLBACK_TEXT;
|
|
508
|
-
const maxChars = opts.maxChars ?? SAFE_FALLBACK_MAX_CHARS;
|
|
509
|
-
const normalized = (rawReasoning ?? "").replace(/\s+/g, " ").trim();
|
|
510
|
-
if (!normalized)
|
|
511
|
-
return emptyText;
|
|
512
|
-
const claimSafeInput = stripUnsupportedOpportunityClaims(normalized);
|
|
513
|
-
if (!claimSafeInput)
|
|
514
|
-
return emptyText;
|
|
515
|
-
// viewerCentricCardSummary handles UUID stripping,
|
|
516
|
-
// stripping, and the viewer-centric rewrite. Pass Infinity so truncation is
|
|
517
|
-
// handled by boundary-aware logic below instead of a mid-word hard slice.
|
|
518
|
-
const rewritten = viewerCentricCardSummary(claimSafeInput, opts.counterpartName ?? "", Number.POSITIVE_INFINITY, opts.viewerName);
|
|
519
|
-
// Claim validation intentionally runs after viewer-centric rewriting: rewrite
|
|
520
|
-
// heuristics may select or join different source sentences, and the final
|
|
521
|
-
// user-facing sentence set is what must be safe.
|
|
522
|
-
const claimSafe = stripUnsupportedOpportunityClaims(rewritten);
|
|
523
|
-
const truncated = truncateAtBoundary(claimSafe, maxChars);
|
|
524
|
-
return truncated || emptyText;
|
|
525
|
-
}
|
|
526
|
-
/**
|
|
527
|
-
* Resolve the safe user-facing presentation for an opportunity, or signal skip.
|
|
528
|
-
*
|
|
529
|
-
* Resolution order:
|
|
530
|
-
* 1. Genuine presenter output (`homeCardPresentation` present, non-empty, and
|
|
531
|
-
* NOT tagged `isFallback` by the presenter) — claim-validated before return.
|
|
532
|
-
* 2. Otherwise, if `allowFallback` (default true): sanitized fallback copy
|
|
533
|
-
* built from `matchReason` / `interpretation.reasoning` via
|
|
534
|
-
* {@link safeFallbackSummary}.
|
|
535
|
-
* 3. Otherwise `null` — the surface must skip this opportunity.
|
|
536
|
-
*
|
|
537
|
-
* Raw `interpretation.reasoning` / `matchReason` never reaches the caller
|
|
538
|
-
* unsanitized through this function.
|
|
539
|
-
*/
|
|
540
|
-
export function getSafePresentationOrSkip(source, opts = {}) {
|
|
541
|
-
const candidate = source.homeCardPresentation;
|
|
542
|
-
if (candidate?.personalizedSummary?.trim() && !candidate.isFallback) {
|
|
543
|
-
const summary = stripUnsupportedOpportunityClaims(candidate.personalizedSummary);
|
|
544
|
-
if (summary) {
|
|
545
|
-
return {
|
|
546
|
-
headline: stripUnsupportedOpportunityClaims(candidate.headline) ||
|
|
547
|
-
DEFAULT_FALLBACK_HEADLINE,
|
|
548
|
-
summary,
|
|
549
|
-
suggestedAction: stripUnsupportedOpportunityClaims(candidate.suggestedAction) ||
|
|
550
|
-
DEFAULT_FALLBACK_ACTION,
|
|
551
|
-
isFallback: false,
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
if (opts.allowFallback === false)
|
|
556
|
-
return null;
|
|
557
|
-
const rawReasoning = source.matchReason ?? source.interpretation?.reasoning ?? "";
|
|
558
|
-
return {
|
|
559
|
-
headline: DEFAULT_FALLBACK_HEADLINE,
|
|
560
|
-
summary: safeFallbackSummary(rawReasoning, opts),
|
|
561
|
-
suggestedAction: DEFAULT_FALLBACK_ACTION,
|
|
562
|
-
isFallback: true,
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
const presentLog = protocolLogger("OpportunityPresenter:present");
|
|
566
|
-
const presentCardLog = protocolLogger("OpportunityPresenter:presentCard");
|
|
567
37
|
const LLM_TIMEOUT_MS = 20000;
|
|
568
38
|
const GREETING_DESCRIPTION = "A 2-4 sentence first-person message the viewer could send to the counterpart, in the viewer's voice, referencing what they have in common. Plain prose only — no markdown, no greeting prefix like 'Hey {Name},'. Example body: 'Saw we're both working on regenerative coordination tooling — your post on consent flows resonated. Would love to compare notes if you have time this week.'";
|
|
569
39
|
// ──────────────────────────────────────────────────────────────
|
|
@@ -622,6 +92,8 @@ Rules:
|
|
|
622
92
|
5. If possible, avoid repeating "opportunity" in both headline and summary. Prefer alternatives like "connection", "thought partner", "mutual fit", "valuable conversation", or "peer".
|
|
623
93
|
6. Prefer first names in user-facing copy. Do not repeatedly use full names unless needed to disambiguate.
|
|
624
94
|
7. Network assignment, network title/type, and network/event metadata are retrieval context only. They are NEVER proof that a person attended or will attend, belongs to a group, resides in a place, knows anyone from the network, or shared a session, time, place, or location with anyone. Do not make co-attendance, membership, residence, shared-session, or same-place/same-time claims from network co-membership.
|
|
95
|
+
8. Match reasoning may describe the viewer in third person; always write from the viewer's perspective.
|
|
96
|
+
9. Never output internal IDs.
|
|
625
97
|
|
|
626
98
|
|
|
627
99
|
**Role-Specific Presentation:**
|
|
@@ -667,6 +139,8 @@ Rules:
|
|
|
667
139
|
- Vary wording for the match itself. Do not repeat "opportunity" across headline, summary, and narratorRemark when alternatives fit.
|
|
668
140
|
- Prefer first names in user-facing copy. Avoid repeated full names unless disambiguation is necessary.
|
|
669
141
|
- Network assignment, network title/type, and network/event metadata are retrieval context only. They are NEVER proof that a person attended or will attend, belongs to a group, resides in a place, knows anyone from the network, or shared a session, time, place, or location with anyone. Do not make co-attendance, membership, residence, shared-session, or same-place/same-time claims from network co-membership.
|
|
142
|
+
- Match reasoning may describe the viewer in third person; always write from the viewer's perspective.
|
|
143
|
+
- Never output internal IDs.
|
|
670
144
|
- If you cannot fit every detail, choose one clear reason and stop. Do not rely on downstream truncation.
|
|
671
145
|
|
|
672
146
|
**Negotiation-grounded explanations (ONLY when NEGOTIATION CONTEXT is provided):**
|
|
@@ -678,16 +152,6 @@ When NEGOTIATION CONTEXT is provided, this opportunity passed through an agent-t
|
|
|
678
152
|
|
|
679
153
|
`;
|
|
680
154
|
// ──────────────────────────────────────────────────────────────
|
|
681
|
-
// DETERMINISTIC OUTPUT VALIDATION
|
|
682
|
-
// ──────────────────────────────────────────────────────────────
|
|
683
|
-
function sanitizePresenterField(value, fallback, allowEmpty = fallback === "") {
|
|
684
|
-
const cleaned = stripUnsupportedOpportunityClaims(stripUuids(value));
|
|
685
|
-
if (cleaned || allowEmpty) {
|
|
686
|
-
return { value: cleaned, usedFallback: false };
|
|
687
|
-
}
|
|
688
|
-
return { value: fallback, usedFallback: true };
|
|
689
|
-
}
|
|
690
|
-
// ──────────────────────────────────────────────────────────────
|
|
691
155
|
// CLASS
|
|
692
156
|
// ──────────────────────────────────────────────────────────────
|
|
693
157
|
export class OpportunityPresenter {
|
|
@@ -722,6 +186,11 @@ export class OpportunityPresenter {
|
|
|
722
186
|
}
|
|
723
187
|
/**
|
|
724
188
|
* Generate personalized presentation for a single opportunity.
|
|
189
|
+
*
|
|
190
|
+
* @param input - Pre-assembled presenter context.
|
|
191
|
+
* @param options - Optional abort signal.
|
|
192
|
+
* @returns The LLM-generated presentation.
|
|
193
|
+
* @throws When the LLM call fails, times out, or returns invalid output.
|
|
725
194
|
*/
|
|
726
195
|
async present(input, options = {}) {
|
|
727
196
|
const humanContent = `
|
|
@@ -741,47 +210,12 @@ Viewer's role in this opportunity: ${input.viewerRole}
|
|
|
741
210
|
|
|
742
211
|
Produce headline, personalizedSummary (2-3 sentences in "you" language), suggestedAction, and greeting.
|
|
743
212
|
`;
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const parsed = responseFormat.parse(result);
|
|
751
|
-
const headline = sanitizePresenterField(parsed.presentation.headline, DEFAULT_FALLBACK_HEADLINE);
|
|
752
|
-
const summary = sanitizePresenterField(parsed.presentation.personalizedSummary, DEFAULT_EMPTY_FALLBACK_TEXT);
|
|
753
|
-
const action = sanitizePresenterField(parsed.presentation.suggestedAction, DEFAULT_FALLBACK_ACTION);
|
|
754
|
-
const greeting = sanitizePresenterField(parsed.presentation.greeting, "");
|
|
755
|
-
const usedFallback = headline.usedFallback || summary.usedFallback || action.usedFallback || greeting.usedFallback;
|
|
756
|
-
return {
|
|
757
|
-
headline: headline.value,
|
|
758
|
-
personalizedSummary: summary.value,
|
|
759
|
-
suggestedAction: action.value,
|
|
760
|
-
greeting: greeting.value,
|
|
761
|
-
...(usedFallback ? { isFallback: true, fallbackReason: "sanitization" } : {}),
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
catch (e) {
|
|
765
|
-
if (options.signal?.aborted)
|
|
766
|
-
throw e;
|
|
767
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
768
|
-
const timeoutReason = message.includes("timed out") ? message : undefined;
|
|
769
|
-
presentLog.warn("LLM failed, returning fallback", {
|
|
770
|
-
event: "presenter_fallback",
|
|
771
|
-
presenter: "opportunity",
|
|
772
|
-
reason: timeoutReason ? "timeout" : "parse_error",
|
|
773
|
-
message,
|
|
774
|
-
timeoutReason,
|
|
775
|
-
});
|
|
776
|
-
return {
|
|
777
|
-
headline: DEFAULT_FALLBACK_HEADLINE,
|
|
778
|
-
personalizedSummary: safeFallbackSummary(input.matchReasoning),
|
|
779
|
-
suggestedAction: DEFAULT_FALLBACK_ACTION,
|
|
780
|
-
greeting: "",
|
|
781
|
-
isFallback: true,
|
|
782
|
-
fallbackReason: timeoutReason ? "timeout" : "error",
|
|
783
|
-
};
|
|
784
|
-
}
|
|
213
|
+
const messages = [
|
|
214
|
+
new SystemMessage(systemPrompt),
|
|
215
|
+
new HumanMessage(humanContent),
|
|
216
|
+
];
|
|
217
|
+
const result = await this.invokeWithTimeout(this.model, messages, options.signal);
|
|
218
|
+
return responseFormat.parse(result).presentation;
|
|
785
219
|
}
|
|
786
220
|
/**
|
|
787
221
|
* Generate LLM-powered card content (headline, body, narrator remark, mutual-intent label).
|
|
@@ -790,6 +224,10 @@ Produce headline, personalizedSummary (2-3 sentences in "you" language), suggest
|
|
|
790
224
|
* When `negotiationContext.status === 'negotiating'`, returns a templated
|
|
791
225
|
* chip synchronously without invoking the LLM — the card just reflects
|
|
792
226
|
* "negotiation in progress" at that point.
|
|
227
|
+
*
|
|
228
|
+
* @param input - Pre-assembled card presenter context.
|
|
229
|
+
* @returns The LLM-generated card copy.
|
|
230
|
+
* @throws When the LLM call fails, times out, or returns invalid output.
|
|
793
231
|
*/
|
|
794
232
|
async presentCard(input) {
|
|
795
233
|
if (input.negotiationContext?.status === 'negotiating') {
|
|
@@ -825,58 +263,12 @@ Opportunity status: ${input.opportunityStatus ?? "pending"}
|
|
|
825
263
|
|
|
826
264
|
Produce headline, personalizedSummary, suggestedAction, narratorRemark, greeting, and mutualIntentsLabel.
|
|
827
265
|
`;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const parsed = homeCardResponseFormat.parse(result);
|
|
835
|
-
if (/^0\s+(mutual|overlapping)\s+intent/i.test(parsed.presentation.mutualIntentsLabel)) {
|
|
836
|
-
parsed.presentation.mutualIntentsLabel = "Shared interests";
|
|
837
|
-
}
|
|
838
|
-
const fields = {
|
|
839
|
-
headline: sanitizePresenterField(parsed.presentation.headline, DEFAULT_FALLBACK_HEADLINE),
|
|
840
|
-
personalizedSummary: sanitizePresenterField(parsed.presentation.personalizedSummary, DEFAULT_EMPTY_FALLBACK_TEXT),
|
|
841
|
-
suggestedAction: sanitizePresenterField(parsed.presentation.suggestedAction, DEFAULT_FALLBACK_ACTION),
|
|
842
|
-
narratorRemark: sanitizePresenterField(parsed.presentation.narratorRemark, "Worth a look."),
|
|
843
|
-
mutualIntentsLabel: sanitizePresenterField(parsed.presentation.mutualIntentsLabel, "Shared interests"),
|
|
844
|
-
greeting: sanitizePresenterField(parsed.presentation.greeting, ""),
|
|
845
|
-
};
|
|
846
|
-
const usedFallback = Object.values(fields).some((field) => field.usedFallback);
|
|
847
|
-
return {
|
|
848
|
-
headline: fields.headline.value,
|
|
849
|
-
personalizedSummary: fields.personalizedSummary.value,
|
|
850
|
-
suggestedAction: fields.suggestedAction.value,
|
|
851
|
-
narratorRemark: fields.narratorRemark.value,
|
|
852
|
-
mutualIntentsLabel: fields.mutualIntentsLabel.value,
|
|
853
|
-
greeting: fields.greeting.value,
|
|
854
|
-
...(usedFallback ? { isFallback: true } : {}),
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
catch (e) {
|
|
858
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
859
|
-
const timeoutReason = message.includes("timed out") ? message : undefined;
|
|
860
|
-
presentCardLog.warn("LLM failed, returning fallback", {
|
|
861
|
-
event: "presenter_fallback",
|
|
862
|
-
presenter: "home_card",
|
|
863
|
-
reason: timeoutReason ? "timeout" : "parse_error",
|
|
864
|
-
message,
|
|
865
|
-
timeoutReason,
|
|
866
|
-
});
|
|
867
|
-
const fallbackSummary = safeFallbackSummary(input.matchReasoning);
|
|
868
|
-
return {
|
|
869
|
-
headline: "A promising connection",
|
|
870
|
-
personalizedSummary: fallbackSummary,
|
|
871
|
-
suggestedAction: "Take a look and decide whether to reach out.",
|
|
872
|
-
narratorRemark: "Worth a look.",
|
|
873
|
-
mutualIntentsLabel: input.mutualIntentCount != null && input.mutualIntentCount > 0
|
|
874
|
-
? `${input.mutualIntentCount} mutual intent${input.mutualIntentCount !== 1 ? "s" : ""}`
|
|
875
|
-
: "Shared interests",
|
|
876
|
-
greeting: "",
|
|
877
|
-
isFallback: true,
|
|
878
|
-
};
|
|
879
|
-
}
|
|
266
|
+
const messages = [
|
|
267
|
+
new SystemMessage(homeCardSystemPrompt),
|
|
268
|
+
new HumanMessage(humanContent),
|
|
269
|
+
];
|
|
270
|
+
const result = await this.invokeWithTimeout(this.homeCardModel, messages);
|
|
271
|
+
return homeCardResponseFormat.parse(result).presentation;
|
|
880
272
|
}
|
|
881
273
|
/**
|
|
882
274
|
* Process multiple opportunities in parallel with bounded concurrency.
|
|
@@ -1045,23 +437,15 @@ export async function gatherPresenterContext(database, opportunity, viewerId, di
|
|
|
1045
437
|
otherParts.join("\n\n") || "Other party (details not available).";
|
|
1046
438
|
}
|
|
1047
439
|
const interp = opportunity.interpretation;
|
|
1048
|
-
const signalsSummary = summarizeSignalsForPresenter(interp.signals);
|
|
1049
|
-
const counterpartName = otherPartyIds.length === 1 && otherProfiles[0]
|
|
1050
|
-
? otherProfiles[0]?.identity?.name?.trim()
|
|
1051
|
-
: undefined;
|
|
1052
|
-
const viewerNameForFilter = viewerProfile?.identity?.name?.trim();
|
|
1053
|
-
const matchReasoning = counterpartName && interp.reasoning
|
|
1054
|
-
? viewerCentricCardSummary(interp.reasoning, counterpartName, 400, viewerNameForFilter)
|
|
1055
|
-
: stripUuids(interp.reasoning);
|
|
1056
440
|
const result = {
|
|
1057
441
|
viewerContext,
|
|
1058
442
|
otherPartyContext,
|
|
1059
|
-
matchReasoning,
|
|
443
|
+
matchReasoning: interp.reasoning,
|
|
1060
444
|
category: interp.category ?? "connection",
|
|
1061
445
|
confidence: typeof interp.confidence === "number"
|
|
1062
446
|
? interp.confidence
|
|
1063
447
|
: parseFloat(String(interp.confidence ?? 0)) || 0,
|
|
1064
|
-
signalsSummary,
|
|
448
|
+
signalsSummary: summarizeSignalsForPresenter(interp.signals),
|
|
1065
449
|
networkName: networkRecord?.title ?? contextNetworkId ?? "",
|
|
1066
450
|
viewerRole: myActor.role ?? "party",
|
|
1067
451
|
};
|