fodda-mcp 1.32.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -9
- package/dist/a2aHandler.d.ts +33 -3
- package/dist/a2aHandler.d.ts.map +1 -1
- package/dist/a2aHandler.js +189 -36
- package/dist/a2aHandler.js.map +1 -1
- package/dist/agentFacts.d.ts +21 -0
- package/dist/agentFacts.d.ts.map +1 -0
- package/dist/agentFacts.js +72 -0
- package/dist/agentFacts.js.map +1 -0
- package/dist/agents/fodda-researcher/index.d.ts +6 -0
- package/dist/agents/fodda-researcher/index.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/index.js +18 -0
- package/dist/agents/fodda-researcher/index.js.map +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/skills.js +8 -3
- package/dist/agents/fodda-researcher/skills.js.map +1 -1
- package/dist/brandTemplate.d.ts.map +1 -1
- package/dist/brandTemplate.js +65 -4
- package/dist/brandTemplate.js.map +1 -1
- package/dist/catalogCache.d.ts +3 -13
- package/dist/catalogCache.d.ts.map +1 -1
- package/dist/catalogCache.js +238 -59
- package/dist/catalogCache.js.map +1 -1
- package/dist/debug_tools.d.ts +2 -0
- package/dist/debug_tools.d.ts.map +1 -0
- package/dist/debug_tools.js +19 -0
- package/dist/debug_tools.js.map +1 -0
- package/dist/deepResearch.d.ts +43 -0
- package/dist/deepResearch.d.ts.map +1 -0
- package/dist/deepResearch.js +498 -0
- package/dist/deepResearch.js.map +1 -0
- package/dist/errorHandling.d.ts +1 -1
- package/dist/errorHandling.d.ts.map +1 -1
- package/dist/errorHandling.js +24 -4
- package/dist/errorHandling.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +449 -36
- package/dist/index.js.map +1 -1
- package/dist/inspect_list_graphs.d.ts +2 -0
- package/dist/inspect_list_graphs.d.ts.map +1 -0
- package/dist/inspect_list_graphs.js +25 -0
- package/dist/inspect_list_graphs.js.map +1 -0
- package/dist/jobs/jlens_quarterly_sweep.d.ts +62 -0
- package/dist/jobs/jlens_quarterly_sweep.d.ts.map +1 -0
- package/dist/jobs/jlens_quarterly_sweep.js +160 -0
- package/dist/jobs/jlens_quarterly_sweep.js.map +1 -0
- package/dist/linkedinEngine.d.ts +150 -0
- package/dist/linkedinEngine.d.ts.map +1 -0
- package/dist/linkedinEngine.js +696 -0
- package/dist/linkedinEngine.js.map +1 -0
- package/dist/pricingCache.d.ts +6 -8
- package/dist/pricingCache.d.ts.map +1 -1
- package/dist/pricingCache.js +55 -11
- package/dist/pricingCache.js.map +1 -1
- package/dist/queryCache.d.ts +2 -0
- package/dist/queryCache.d.ts.map +1 -1
- package/dist/queryCache.js +11 -2
- package/dist/queryCache.js.map +1 -1
- package/dist/resources.d.ts +27 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +126 -0
- package/dist/resources.js.map +1 -0
- package/dist/scratch_test.d.ts.map +1 -0
- package/dist/services/notifications.d.ts +49 -0
- package/dist/services/notifications.d.ts.map +1 -0
- package/dist/services/notifications.js +206 -0
- package/dist/services/notifications.js.map +1 -0
- package/dist/sessionTracker.js +1 -1
- package/dist/sessionTracker.js.map +1 -1
- package/dist/systemPrompt.d.ts +3 -3
- package/dist/systemPrompt.d.ts.map +1 -1
- package/dist/systemPrompt.js +44 -54
- package/dist/systemPrompt.js.map +1 -1
- package/dist/telemetry.d.ts +43 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +101 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/test_analyst.js +2 -2
- package/dist/test_analyst.js.map +1 -1
- package/dist/test_brand_intelligence_endpoint.d.ts +2 -0
- package/dist/test_brand_intelligence_endpoint.d.ts.map +1 -0
- package/dist/test_brand_intelligence_endpoint.js +20 -0
- package/dist/test_brand_intelligence_endpoint.js.map +1 -0
- package/dist/test_coverage_r2.d.ts +2 -0
- package/dist/test_coverage_r2.d.ts.map +1 -0
- package/dist/test_coverage_scratch.d.ts +2 -0
- package/dist/test_coverage_scratch.d.ts.map +1 -0
- package/dist/test_list_graphs_topics.d.ts +2 -0
- package/dist/test_list_graphs_topics.d.ts.map +1 -0
- package/dist/test_list_graphs_topics.js +26 -0
- package/dist/test_list_graphs_topics.js.map +1 -0
- package/dist/test_live_endpoint_runner.d.ts +2 -0
- package/dist/test_live_endpoint_runner.d.ts.map +1 -0
- package/dist/test_live_endpoint_runner.js +65 -0
- package/dist/test_live_endpoint_runner.js.map +1 -0
- package/dist/test_local_list_graphs.d.ts +2 -0
- package/dist/test_local_list_graphs.d.ts.map +1 -0
- package/dist/test_local_list_graphs.js +53 -0
- package/dist/test_local_list_graphs.js.map +1 -0
- package/dist/test_nike_cmo_alias.d.ts +2 -0
- package/dist/test_nike_cmo_alias.d.ts.map +1 -0
- package/dist/test_nike_cmo_alias.js +76 -0
- package/dist/test_nike_cmo_alias.js.map +1 -0
- package/dist/test_token_resolution.d.ts +2 -0
- package/dist/test_token_resolution.d.ts.map +1 -0
- package/dist/test_token_resolution.js +82 -0
- package/dist/test_token_resolution.js.map +1 -0
- package/dist/toolHandlers.d.ts +7 -3
- package/dist/toolHandlers.d.ts.map +1 -1
- package/dist/toolHandlers.js +963 -310
- package/dist/toolHandlers.js.map +1 -1
- package/dist/tools.d.ts +6 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +7 -2
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +9 -46
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/dist/verify_discoverability.d.ts +2 -0
- package/dist/verify_discoverability.d.ts.map +1 -0
- package/dist/verify_discoverability.js +53 -0
- package/dist/verify_discoverability.js.map +1 -0
- package/package.json +5 -2
- package/server.json +17 -4
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn Evidence Engine — shared retrieval + curation core for the two
|
|
3
|
+
* LinkedIn content tools (`draft_linkedin_post`, `draft_linkedin_article`).
|
|
4
|
+
*
|
|
5
|
+
* Architecture (per the build brief): the SERVER does retrieval and curation;
|
|
6
|
+
* the CALLING model does the writing. This module runs the retrieval recipe
|
|
7
|
+
* against the existing graph endpoints, clusters results into themes, enforces
|
|
8
|
+
* the evidence hard-gates, and classifies sources. The tool heads in
|
|
9
|
+
* toolHandlers.ts return the curated evidence pack + a composition contract —
|
|
10
|
+
* never finished post text. No server-side composition in v1.
|
|
11
|
+
*
|
|
12
|
+
* Hard gates (acceptance tests, not aspirations):
|
|
13
|
+
* 1. A theme ships only with ≥1 NAMED company/product/person tied to a
|
|
14
|
+
* source. No named example → theme dropped and logged in `dropped`.
|
|
15
|
+
* 2. Every source is typed: public_url (linkable) vs earnings_call/report
|
|
16
|
+
* (verbal attribution only — url is ALWAYS null for these).
|
|
17
|
+
* 3. URLs come only from source metadata, passed through verbatim.
|
|
18
|
+
* Zero constructed URLs, ever.
|
|
19
|
+
* 4. Entity names must appear in the cited source's own wording. Graph-side
|
|
20
|
+
* labels that don't appear in the source text are flagged
|
|
21
|
+
* `unverified_name` and excluded.
|
|
22
|
+
* 5. Explicit quota exhaustion mid-retrieval aborts the whole pack
|
|
23
|
+
* (QuotaExhaustedError) — the engine never returns a quietly-starved pack.
|
|
24
|
+
* 6. Thin coverage → coverage_note + recommend_against, never padding.
|
|
25
|
+
*/
|
|
26
|
+
import crypto from 'crypto';
|
|
27
|
+
import { classifyAccessError } from './errorHandling.js';
|
|
28
|
+
import { getDomainGraphIds, getAnalysts } from './catalogCache.js';
|
|
29
|
+
/**
|
|
30
|
+
* Thrown when retrieval hits an EXPLICIT quota/credit state
|
|
31
|
+
* (CREDITS_EXHAUSTED / PLAN_LIMIT_EXCEEDED / 402). The tool head converts this
|
|
32
|
+
* into the platform's standard structured quota error — the engine never
|
|
33
|
+
* degrades a starved run into a thin evidence pack.
|
|
34
|
+
*/
|
|
35
|
+
export class QuotaExhaustedError extends Error {
|
|
36
|
+
causeErr;
|
|
37
|
+
constructor(causeErr) {
|
|
38
|
+
super('Quota exhausted during evidence retrieval');
|
|
39
|
+
this.causeErr = causeErr;
|
|
40
|
+
this.name = 'QuotaExhaustedError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Entity extraction & verification
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** Words that look like proper nouns but are not named companies/products. */
|
|
47
|
+
const ENTITY_STOPWORDS = new Set([
|
|
48
|
+
'The', 'A', 'An', 'And', 'But', 'Or', 'Nor', 'For', 'So', 'Yet', 'In', 'On', 'At', 'Of', 'To',
|
|
49
|
+
'From', 'With', 'By', 'As', 'Is', 'Are', 'Was', 'Were', 'Be', 'Been', 'It', 'Its', 'This',
|
|
50
|
+
'That', 'These', 'Those', 'There', 'Here', 'How', 'Why', 'What', 'When', 'Where', 'Who',
|
|
51
|
+
'Whose', 'Which', 'While', 'New', 'Now', 'More', 'Most', 'Less', 'Best', 'Top', 'First',
|
|
52
|
+
'Last', 'Next', 'Inside', 'Beyond', 'After', 'Before', 'During', 'Their', 'Our', 'Your',
|
|
53
|
+
'My', 'His', 'Her', 'We', 'You', 'They', 'He', 'She', 'I', 'Not', 'No', 'Yes', 'All', 'Some',
|
|
54
|
+
'Many', 'Every', 'Each', 'One', 'Two', 'Three', 'If', 'Then', 'Than', 'Will', 'Would',
|
|
55
|
+
'Could', 'Should', 'Can', 'May', 'Might', 'Do', 'Does', 'Did', 'Get', 'Got', 'Meet', 'Meets',
|
|
56
|
+
'Say', 'Says', 'Report', 'Reports', 'Study', 'Survey', 'Trend', 'Trends', 'Consumer',
|
|
57
|
+
'Consumers', 'Brand', 'Brands', 'Retail', 'Retailers', 'Market', 'Markets', 'Global',
|
|
58
|
+
'Digital', 'Future', 'Growth', 'Gen', 'GenZ', 'Millennials', 'AI', 'US', 'USA', 'UK', 'EU',
|
|
59
|
+
'Q1', 'Q2', 'Q3', 'Q4', 'CEO', 'CFO', 'CMO', 'COO', 'January', 'February', 'March', 'April',
|
|
60
|
+
'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', 'Monday',
|
|
61
|
+
'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Americans', 'American',
|
|
62
|
+
'People', 'Everyone', 'Nobody', 'Something', 'Everything',
|
|
63
|
+
]);
|
|
64
|
+
/** Connector words allowed INSIDE a multi-word entity ("Bank of America"). */
|
|
65
|
+
const ENTITY_CONNECTORS = new Set(['of', 'the', 'and', '&', 'for', 'de', 'la']);
|
|
66
|
+
const CAP_TOKEN = /^[A-Z][A-Za-z0-9&'’.\-]*$/;
|
|
67
|
+
const HAS_INNER_CAP = /^[a-z]+[A-Z]/; // eBay, iRobot
|
|
68
|
+
/**
|
|
69
|
+
* Extract named-entity candidates (companies, products, people) from source
|
|
70
|
+
* text via a conservative proper-noun scan. Errs toward false negatives —
|
|
71
|
+
* a missed entity drops a theme (safe); an invented one would ship a bad claim.
|
|
72
|
+
*/
|
|
73
|
+
export function extractEntities(text) {
|
|
74
|
+
if (!text)
|
|
75
|
+
return [];
|
|
76
|
+
const tokens = text.split(/\s+/);
|
|
77
|
+
const found = [];
|
|
78
|
+
let run = [];
|
|
79
|
+
let runStartIdx = 0;
|
|
80
|
+
const flush = (endIdx) => {
|
|
81
|
+
// Trim connector words from both ends
|
|
82
|
+
while (run.length && ENTITY_CONNECTORS.has(run[0].toLowerCase())) {
|
|
83
|
+
run.shift();
|
|
84
|
+
runStartIdx++;
|
|
85
|
+
}
|
|
86
|
+
while (run.length && ENTITY_CONNECTORS.has(run[run.length - 1].toLowerCase()))
|
|
87
|
+
run.pop();
|
|
88
|
+
if (run.length === 0)
|
|
89
|
+
return;
|
|
90
|
+
const cleaned = run.map(t => t.replace(/[,.;:!?'’]+$/g, '').replace(/^["'‘’“”(]+/g, ''));
|
|
91
|
+
const allStop = cleaned.every(t => ENTITY_STOPWORDS.has(t) || ENTITY_STOPWORDS.has(t.replace(/'s$/, '')));
|
|
92
|
+
if (allStop) {
|
|
93
|
+
run = [];
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (cleaned.length === 1) {
|
|
97
|
+
const tok = cleaned[0];
|
|
98
|
+
const base = tok.replace(/[’']s$/, '');
|
|
99
|
+
if (ENTITY_STOPWORDS.has(base) || base.length < 3) {
|
|
100
|
+
run = [];
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Single capitalized token at index 0 is likely just sentence case —
|
|
104
|
+
// accept only when it looks brand-shaped: inner capital (eBay),
|
|
105
|
+
// ALL-CAPS 3+ (IKEA), digits/&/- (H&M, Coca-Cola), or possessive
|
|
106
|
+
// ("Walmart's move…"). Otherwise require corroboration later in the
|
|
107
|
+
// text — conservative by design: a missed entity drops a theme
|
|
108
|
+
// (safe); a generic word as "named example" ships a hollow claim.
|
|
109
|
+
const brandShaped = HAS_INNER_CAP.test(base) || /^[A-Z]{3,}$/.test(base)
|
|
110
|
+
|| /[\d&-]/.test(base) || /['’]s$/.test(tok);
|
|
111
|
+
if (runStartIdx === 0 && endIdx === 1 && !brandShaped) {
|
|
112
|
+
run = [];
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
found.push(base);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const phrase = cleaned.map(t => t.replace(/[’']s$/, '')).join(' ');
|
|
119
|
+
// Drop phrases where leading token is a stopword (e.g. "Why Nike" → keep Nike only)
|
|
120
|
+
const nonStop = cleaned.filter(t => !ENTITY_STOPWORDS.has(t.replace(/[’']s$/, '')));
|
|
121
|
+
if (nonStop.length === 0) {
|
|
122
|
+
run = [];
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (nonStop.length < cleaned.length && nonStop.length === 1) {
|
|
126
|
+
const base = nonStop[0].replace(/[’']s$/, '');
|
|
127
|
+
if (base.length >= 3)
|
|
128
|
+
found.push(base);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
found.push(phrase);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
run = [];
|
|
135
|
+
};
|
|
136
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
137
|
+
const raw = tokens[i];
|
|
138
|
+
const tok = raw.replace(/^["'‘’“”(]+/, '').replace(/[",;:!?)]+$/g, '');
|
|
139
|
+
const endsSentence = /[.!?]$/.test(tok);
|
|
140
|
+
const core = tok.replace(/[.!?]+$/g, '');
|
|
141
|
+
if (CAP_TOKEN.test(core) || (run.length > 0 && ENTITY_CONNECTORS.has(core.toLowerCase()))) {
|
|
142
|
+
if (run.length === 0)
|
|
143
|
+
runStartIdx = i;
|
|
144
|
+
run.push(core);
|
|
145
|
+
if (endsSentence)
|
|
146
|
+
flush(i + 1);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
flush(i);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
flush(tokens.length);
|
|
153
|
+
// Dedupe, preserve order
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
return found.filter(e => {
|
|
156
|
+
const k = e.toLowerCase();
|
|
157
|
+
if (seen.has(k))
|
|
158
|
+
return false;
|
|
159
|
+
seen.add(k);
|
|
160
|
+
return true;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Source typing — gate #2 and #3
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/** Return the item's verbatim metadata URL if it is a real http(s) link, else null. */
|
|
167
|
+
export function verbatimUrl(item) {
|
|
168
|
+
const raw = (item?.sourceUrl || item?.source_url || item?.url || '').toString().trim();
|
|
169
|
+
if (/^https?:\/\/\S+$/i.test(raw))
|
|
170
|
+
return raw;
|
|
171
|
+
return null; // anything else (empty, fragments, non-http schemes) → NOT linkable
|
|
172
|
+
}
|
|
173
|
+
/** Classify a source. earnings_call/report NEVER carry a URL. */
|
|
174
|
+
export function typeSource(item, ctx = {}) {
|
|
175
|
+
if (ctx.isEarnings)
|
|
176
|
+
return { source_type: 'earnings_call', url: null };
|
|
177
|
+
const url = verbatimUrl(item);
|
|
178
|
+
if (url)
|
|
179
|
+
return { source_type: 'public_url', url };
|
|
180
|
+
const pub = ((item?.publication || item?.source || '') + ' ' + (item?.contentType || '')).toLowerCase();
|
|
181
|
+
if (pub.includes('earnings'))
|
|
182
|
+
return { source_type: 'earnings_call', url: null };
|
|
183
|
+
return { source_type: 'report', url: null };
|
|
184
|
+
}
|
|
185
|
+
function sourceText(item) {
|
|
186
|
+
return [item?.title, item?.excerpt, item?.summary, item?.text, item?.quote, item?.description]
|
|
187
|
+
.filter(Boolean).join(' ');
|
|
188
|
+
}
|
|
189
|
+
function buildRow(item, theme) {
|
|
190
|
+
const text = sourceText(item);
|
|
191
|
+
const typed = typeSource(item, { isEarnings: theme.isEarnings ?? false });
|
|
192
|
+
// ── Entity resolution ──
|
|
193
|
+
// 1) Graph-side labels must be VERIFIED against the source's own wording.
|
|
194
|
+
const graphLabels = [item?.brand, item?.company, item?.entity, ...(Array.isArray(item?.brands) ? item.brands : [])]
|
|
195
|
+
.filter((v) => typeof v === 'string' && v.trim().length > 1);
|
|
196
|
+
let entity = null;
|
|
197
|
+
let hadUnverifiedLabel = false;
|
|
198
|
+
for (const label of graphLabels) {
|
|
199
|
+
if (text.toLowerCase().includes(label.trim().toLowerCase())) {
|
|
200
|
+
entity = label.trim();
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
hadUnverifiedLabel = true;
|
|
204
|
+
}
|
|
205
|
+
// 2) Extract directly from the source text (inherently source-verbatim).
|
|
206
|
+
if (!entity) {
|
|
207
|
+
const candidates = extractEntities(text);
|
|
208
|
+
entity = candidates[0] ?? null;
|
|
209
|
+
}
|
|
210
|
+
// 3) Quote items: the named speaker IS the named example ("Om Malik argues…").
|
|
211
|
+
const speaker = typeof item?.speakerName === 'string' ? item.speakerName.trim() : '';
|
|
212
|
+
const isQuote = ((item?.contentType || '') + '').toLowerCase().includes('quote') || !!speaker;
|
|
213
|
+
if (!entity && speaker && isQuote)
|
|
214
|
+
entity = speaker;
|
|
215
|
+
if (!entity)
|
|
216
|
+
return { row: null, unverified: hadUnverifiedLabel };
|
|
217
|
+
const claim = (item?.title || item?.quote || item?.excerpt || '').toString().trim().slice(0, 280);
|
|
218
|
+
if (!claim)
|
|
219
|
+
return { row: null, unverified: false };
|
|
220
|
+
const row = {
|
|
221
|
+
claim,
|
|
222
|
+
entity,
|
|
223
|
+
source_title: (item?.publication || item?.source || item?.title || 'Source').toString().trim(),
|
|
224
|
+
source_type: typed.source_type,
|
|
225
|
+
url: typed.url,
|
|
226
|
+
graph: theme.graph,
|
|
227
|
+
theme: theme.name,
|
|
228
|
+
};
|
|
229
|
+
if (speaker) {
|
|
230
|
+
const spTitle = item?.speakerTitle ? `, ${item.speakerTitle}` : '';
|
|
231
|
+
row.attribution_hint = `${speaker}${spTitle}${item?.publication ? ` (${item.publication})` : ''}`;
|
|
232
|
+
}
|
|
233
|
+
else if (typed.source_type !== 'public_url') {
|
|
234
|
+
row.attribution_hint = typed.source_type === 'earnings_call'
|
|
235
|
+
? `verbal attribution only — cite the earnings call, never a link`
|
|
236
|
+
: `verbal attribution only — cite ${row.source_title}, never a link`;
|
|
237
|
+
}
|
|
238
|
+
return { row, unverified: false };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Enforce the theme gates and select the strongest evidence.
|
|
242
|
+
* Exported for self-testing.
|
|
243
|
+
*/
|
|
244
|
+
export function curateThemes(rawThemes, maxThemes) {
|
|
245
|
+
const dropped = [];
|
|
246
|
+
const kept = [];
|
|
247
|
+
for (const theme of rawThemes) {
|
|
248
|
+
const rows = [];
|
|
249
|
+
let sawUnverified = false;
|
|
250
|
+
let sawEvidence = false;
|
|
251
|
+
for (const item of theme.evidence || []) {
|
|
252
|
+
sawEvidence = true;
|
|
253
|
+
const { row, unverified } = buildRow(item, theme);
|
|
254
|
+
if (unverified)
|
|
255
|
+
sawUnverified = true;
|
|
256
|
+
if (row)
|
|
257
|
+
rows.push(row);
|
|
258
|
+
}
|
|
259
|
+
if (rows.length === 0) {
|
|
260
|
+
dropped.push({
|
|
261
|
+
theme: theme.name,
|
|
262
|
+
reason: !sawEvidence ? 'no_usable_evidence' : (sawUnverified ? 'unverified_name' : 'no_named_example'),
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// One strong example beats one strong plus one weak — keep at most 2,
|
|
267
|
+
// and only keep the 2nd if it is also strong (linkable or spoken).
|
|
268
|
+
rows.sort((a, b) => strength(b) - strength(a));
|
|
269
|
+
const selected = [rows[0]];
|
|
270
|
+
if (rows[1] && strength(rows[1]) >= 2)
|
|
271
|
+
selected.push(rows[1]);
|
|
272
|
+
const score = selected.reduce((s, r) => s + strength(r), 0) + Math.min(rows.length, 4) * 0.1;
|
|
273
|
+
kept.push({ theme, rows: selected, score });
|
|
274
|
+
}
|
|
275
|
+
kept.sort((a, b) => b.score - a.score);
|
|
276
|
+
const shipped = kept.slice(0, maxThemes);
|
|
277
|
+
for (const cut of kept.slice(maxThemes)) {
|
|
278
|
+
dropped.push({ theme: cut.theme.name, reason: 'below_selection_cut' });
|
|
279
|
+
}
|
|
280
|
+
const ledger = [];
|
|
281
|
+
const themes = shipped.map(k => {
|
|
282
|
+
ledger.push(...k.rows);
|
|
283
|
+
return {
|
|
284
|
+
name: k.theme.name,
|
|
285
|
+
graph: k.theme.graph,
|
|
286
|
+
named_entities: [...new Set(k.rows.map(r => r.entity))],
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
return { themes, ledger, dropped };
|
|
290
|
+
}
|
|
291
|
+
function strength(row) {
|
|
292
|
+
if (row.source_type === 'public_url')
|
|
293
|
+
return 3;
|
|
294
|
+
if (row.attribution_hint && !row.attribution_hint.startsWith('verbal attribution'))
|
|
295
|
+
return 2; // named speaker
|
|
296
|
+
if (row.source_type === 'earnings_call')
|
|
297
|
+
return 2;
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Composition contracts — the locked format rules, embedded in every result
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
const BRIDGE = {
|
|
304
|
+
fodda_first_party: 'We found these using Fodda — our service that helps anyone\'s AI become a domain expert.',
|
|
305
|
+
practitioner: 'I pulled these from Fodda — a service that helps anyone\'s AI become a domain expert.',
|
|
306
|
+
};
|
|
307
|
+
const BANNED = 'BANNED: validation filler ("nailed it", "great point"), hype adjectives ("game-changing", "revolutionary"), unverifiable superlatives, engagement bait ("thoughts?", CTA stacks), and ANY claim that does not appear in evidence_ledger.';
|
|
308
|
+
const SOURCE_RULES = `SOURCE RULES (non-negotiable):
|
|
309
|
+
- Use ONLY claims from evidence_ledger. Every named entity in your draft must appear in a ledger row.
|
|
310
|
+
- source_type "public_url": you may link, using the row's url EXACTLY as given.
|
|
311
|
+
- source_type "earnings_call" or "report": VERBAL ATTRIBUTION ONLY ("...'s CFO told analysts", "per the report") — these rows have url null and must NEVER carry a link. Do not look up, guess, or construct a URL for them. Zero constructed URLs, ever.
|
|
312
|
+
- Attribute directly and personally ("Om Malik argues…", "Coca-Cola's CFO told analysts…") — never "one commentator notes".
|
|
313
|
+
- Graph-sourced brand momentum is NARRATIVE momentum, not financial performance — never present it as financials. Only earnings_call rows are financially sourced.`;
|
|
314
|
+
export function buildPostContract(voice, recommendAgainst) {
|
|
315
|
+
return `COMPOSITION CONTRACT — LinkedIn POST (v1). You (the client model) write the draft from the evidence pack above. The server curates; you compose. Follow this contract exactly.
|
|
316
|
+
${recommendAgainst ? '\n⚠️ COVERAGE IS TOO THIN — DO NOT DRAFT. Tell the user the graphs lack credible named-example coverage for this topic (see coverage_note) and recommend skipping a Fodda-backed post. A skipped post costs nothing; a hollow one costs credibility.\n' : ''}
|
|
317
|
+
STRUCTURE (fixed, in order):
|
|
318
|
+
1. INFORMATIVE OPEN — 2–3 sentences on the key themes, leading with a specific fact or named example from the ledger. The specific fact MUST land within the first 200 characters (before LinkedIn's "see more" fold). No Fodda mention yet.
|
|
319
|
+
2. THE BRIDGE — one sentence of locked copy: "${BRIDGE[voice]}" An alt opener like "When ${voice === 'practitioner' ? 'I' : 'we'} ran this through the Fodda graphs…" is allowed, but the value-prop phrase ("helps anyone's AI become a domain expert") is fixed and must appear verbatim.
|
|
320
|
+
3. THE THEMES — 2–3 themes, each 1–3 sentences with its named example and direct personal attribution.
|
|
321
|
+
4. CLOSE — one punchy, grounded observation. No CTA stack, no "thoughts?", no engagement bait.
|
|
322
|
+
|
|
323
|
+
FORMAT (plain text — LinkedIn does NOT render markdown):
|
|
324
|
+
- No markdown of any kind: no ##, no **, no markdown bullet glyphs, no [text](url) syntax.
|
|
325
|
+
- Short paragraphs of 1–2 sentences. 150–300 words total.
|
|
326
|
+
- Hashtags: three or fewer, or none.
|
|
327
|
+
|
|
328
|
+
${SOURCE_RULES}
|
|
329
|
+
|
|
330
|
+
${BANNED}
|
|
331
|
+
|
|
332
|
+
OUTPUT — produce exactly these fields for the human editor:
|
|
333
|
+
- post_text: the plain-text post.
|
|
334
|
+
- first_comment_text: source links (public_url rows only) + fodda.ai. Links live here because in-body links are widely believed to be feed-deprioritized; the human decides final placement.
|
|
335
|
+
Then show the evidence_ledger (the receipts) and the dropped list so the human review pass is fast. Never silently omit an available URL — every public_url row's link appears in first_comment_text.`;
|
|
336
|
+
}
|
|
337
|
+
export function buildArticleContract(voice, targetWords, recommendAgainst) {
|
|
338
|
+
const lo = Math.max(600, Math.round(targetWords * 0.8));
|
|
339
|
+
const hi = Math.round(targetWords * 1.2);
|
|
340
|
+
return `COMPOSITION CONTRACT — LinkedIn ARTICLE (v1). You (the client model) write the article from the evidence pack above. The server curates; you compose. Follow this contract exactly.
|
|
341
|
+
${recommendAgainst ? '\n⚠️ COVERAGE IS TOO THIN — DO NOT DRAFT. Tell the user the graphs lack credible named-example coverage for this topic (see coverage_note) and recommend against a Fodda-backed article. A skipped article costs nothing; a hollow one costs credibility.\n' : ''}
|
|
342
|
+
STRUCTURE (fixed, in order):
|
|
343
|
+
1. HEADLINE — makes a claim, not a topic label.
|
|
344
|
+
2. STANDFIRST — the informative intro: key themes, led by a specific fact or named example. No Fodda mention yet.
|
|
345
|
+
3. THE BRIDGE — locked copy, may breathe to two sentences here: "${BRIDGE[voice]}" The value-prop phrase ("helps anyone's AI become a domain expert") is fixed and must appear verbatim.
|
|
346
|
+
4. THEMED SECTIONS — 3–5 sections with plain-language subheads. Each carries its named example(s); inline links ONLY for public_url ledger rows (use the row's url exactly); verbal attribution for earnings_call/report rows. Weave in the hard numbers from the statistics list where they support a section. If counterpoints are present, engage the strongest one honestly — articles need an argument that survives contact.
|
|
347
|
+
5. "HOW WE FOUND THIS" BOX — three or four lines naming the graphs and query types used (see how_we_found_this). This methodology box is the product demo — NEVER cut it.
|
|
348
|
+
6. CLOSE — a short, grounded closing observation.
|
|
349
|
+
|
|
350
|
+
FORMAT: ${lo}–${hi} words. Articles render rich text — markdown IS fine here (headers, links, bold).
|
|
351
|
+
|
|
352
|
+
${SOURCE_RULES}
|
|
353
|
+
|
|
354
|
+
${BANNED}
|
|
355
|
+
|
|
356
|
+
OUTPUT — produce exactly these fields for the human editor:
|
|
357
|
+
- title: the headline.
|
|
358
|
+
- body_markdown: the full article in markdown.
|
|
359
|
+
Then show the evidence_ledger (the receipts) and the dropped list so the human review pass is fast.`;
|
|
360
|
+
}
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// Evidence-pack cache — topic-hash keyed, 24h TTL (draft-iterate loop saver)
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
const PACK_CACHE = new Map();
|
|
365
|
+
const PACK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
366
|
+
const PACK_CACHE_MAX = 200;
|
|
367
|
+
function packCacheKey(apiKey, opts) {
|
|
368
|
+
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 8);
|
|
369
|
+
const sig = JSON.stringify([
|
|
370
|
+
opts.mode,
|
|
371
|
+
opts.topic.trim().toLowerCase(),
|
|
372
|
+
(opts.angle || '').trim().toLowerCase(),
|
|
373
|
+
opts.voice || 'fodda_first_party',
|
|
374
|
+
(opts.brand || '').trim().toLowerCase(),
|
|
375
|
+
(opts.subThemes || []).map(s => s.trim().toLowerCase()),
|
|
376
|
+
opts.targetLengthWords || 0,
|
|
377
|
+
keyHash,
|
|
378
|
+
]);
|
|
379
|
+
return crypto.createHash('sha256').update(sig).digest('hex');
|
|
380
|
+
}
|
|
381
|
+
function prunePackCache() {
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
for (const [k, v] of PACK_CACHE) {
|
|
384
|
+
if (now - v.createdAt > PACK_TTL_MS)
|
|
385
|
+
PACK_CACHE.delete(k);
|
|
386
|
+
}
|
|
387
|
+
while (PACK_CACHE.size >= PACK_CACHE_MAX) {
|
|
388
|
+
const oldest = PACK_CACHE.keys().next().value;
|
|
389
|
+
if (oldest === undefined)
|
|
390
|
+
break;
|
|
391
|
+
PACK_CACHE.delete(oldest);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Exposed for tests. */
|
|
395
|
+
export function clearPackCache() { PACK_CACHE.clear(); }
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Retrieval recipe
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
/** Deterministic fallback decomposition when the client didn't supply sub-themes. */
|
|
400
|
+
export function fallbackSubThemes(topic, mode) {
|
|
401
|
+
const t = topic.trim();
|
|
402
|
+
const facets = [
|
|
403
|
+
t,
|
|
404
|
+
`${t} named brand and retailer examples`,
|
|
405
|
+
`${t} consumer behavior shifts`,
|
|
406
|
+
];
|
|
407
|
+
if (mode === 'article') {
|
|
408
|
+
facets.push(`${t} technology and platform moves`, `${t} market data`);
|
|
409
|
+
}
|
|
410
|
+
return facets.slice(0, mode === 'post' ? 4 : 5);
|
|
411
|
+
}
|
|
412
|
+
function normalizeTrends(data) {
|
|
413
|
+
if (!data)
|
|
414
|
+
return [];
|
|
415
|
+
if (Array.isArray(data))
|
|
416
|
+
return data;
|
|
417
|
+
return data.results || data.trends || data.matches || [];
|
|
418
|
+
}
|
|
419
|
+
function trendName(t) {
|
|
420
|
+
return (t?.trend_name || t?.trendName || t?.name || t?.title || '').toString().trim();
|
|
421
|
+
}
|
|
422
|
+
function trendGraph(t) {
|
|
423
|
+
return (t?.graph_id || t?.graphId || t?.graphName || t?.graph || 'unknown').toString();
|
|
424
|
+
}
|
|
425
|
+
function trendEvidence(t) {
|
|
426
|
+
const ev = t?.evidence || t?.evidence_items || t?.evidenceItems || [];
|
|
427
|
+
return Array.isArray(ev) ? ev : [];
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Run one retrieval call; classify failures. Explicit quota state → throw
|
|
431
|
+
* QuotaExhaustedError (aborts the whole pack). Plan-forbidden/disabled →
|
|
432
|
+
* treat as an unavailable source (skip). Other errors → skip with log.
|
|
433
|
+
*/
|
|
434
|
+
async function safeCall(deps, label, skipped, fn) {
|
|
435
|
+
try {
|
|
436
|
+
return await fn();
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
const access = classifyAccessError(err);
|
|
440
|
+
if (access === 'credits' || access === 'legacy_retired') {
|
|
441
|
+
throw new QuotaExhaustedError(err);
|
|
442
|
+
}
|
|
443
|
+
skipped.push(label);
|
|
444
|
+
console.error(`[linkedinEngine] ${label} unavailable (${access || err?.message || 'error'}) — skipping`);
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// The engine
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
export async function buildEvidencePack(deps, opts) {
|
|
452
|
+
const voice = opts.voice || 'fodda_first_party';
|
|
453
|
+
const targetWords = opts.targetLengthWords || 1000;
|
|
454
|
+
// ── Cache (24h TTL, keyed on topic-hash + account) ──
|
|
455
|
+
prunePackCache();
|
|
456
|
+
const cacheKey = packCacheKey(deps.apiKey, opts);
|
|
457
|
+
const cached = PACK_CACHE.get(cacheKey);
|
|
458
|
+
if (cached && Date.now() - cached.createdAt < PACK_TTL_MS) {
|
|
459
|
+
const clone = JSON.parse(JSON.stringify(cached.pack));
|
|
460
|
+
clone._cache = { hit: true, age_minutes: Math.round((Date.now() - cached.createdAt) / 60000) };
|
|
461
|
+
console.error(`[linkedinEngine] pack cache HIT for "${opts.topic}" (age ${clone._cache.age_minutes}m)`);
|
|
462
|
+
return clone;
|
|
463
|
+
}
|
|
464
|
+
const subThemes = (opts.subThemes && opts.subThemes.length > 0)
|
|
465
|
+
? opts.subThemes.slice(0, opts.mode === 'post' ? 4 : 5)
|
|
466
|
+
: fallbackSubThemes(opts.angle ? `${opts.topic} — ${opts.angle}` : opts.topic, opts.mode);
|
|
467
|
+
const skipped = [];
|
|
468
|
+
const queryTypes = new Set(['domain_intelligence', 'expert_intelligence']);
|
|
469
|
+
// ── Parallel retrieval: domain + expert per sub-theme ──
|
|
470
|
+
const searchBody = (q) => ({
|
|
471
|
+
query: q,
|
|
472
|
+
limit: 7,
|
|
473
|
+
include_evidence: true,
|
|
474
|
+
max_evidence_per_trend: 5,
|
|
475
|
+
min_score: 0.6,
|
|
476
|
+
});
|
|
477
|
+
const searchCalls = [];
|
|
478
|
+
for (const st of subThemes) {
|
|
479
|
+
searchCalls.push(safeCall(deps, `domain:"${st}"`, skipped, () => deps.foddaRequest('POST', '/v1/search/domain', deps.apiKey, deps.userId, searchBody(st))).then(data => data ? { kind: 'domain', data } : null), safeCall(deps, `expert:"${st}"`, skipped, () => deps.foddaRequest('POST', '/v1/search/expert', deps.apiKey, deps.userId, searchBody(st))).then(data => data ? { kind: 'expert', data } : null));
|
|
480
|
+
}
|
|
481
|
+
// ── Brand truth-layer path (only when a named brand is the topic) ──
|
|
482
|
+
let earningsPromise = Promise.resolve(null);
|
|
483
|
+
if (opts.brand) {
|
|
484
|
+
queryTypes.add('earnings_snapshot');
|
|
485
|
+
earningsPromise = safeCall(deps, `earnings:"${opts.brand}"`, skipped, () => deps.foddaRequest('GET', `/v1/supplemental/earnings/snapshot?brand=${encodeURIComponent(opts.brand)}&limit=5`, deps.apiKey, deps.userId));
|
|
486
|
+
}
|
|
487
|
+
// ── Article extras: statistics pass ──
|
|
488
|
+
let statsPromises = [];
|
|
489
|
+
let statsGraphIds = [];
|
|
490
|
+
if (opts.mode === 'article') {
|
|
491
|
+
queryTypes.add('standalone_statistics');
|
|
492
|
+
const domainIds = [...getDomainGraphIds()];
|
|
493
|
+
statsGraphIds = (domainIds.length > 0 ? domainIds : ['retail', 'sic']).slice(0, 3);
|
|
494
|
+
statsPromises = statsGraphIds.map(gid => safeCall(deps, `statistics:${gid}`, skipped, () => deps.foddaRequest('GET', `/v1/graphs/${gid}/statistics?query=${encodeURIComponent(opts.topic)}&limit=8&min_score=0.6`, deps.apiKey, deps.userId)));
|
|
495
|
+
}
|
|
496
|
+
const [searchResults, earningsRaw, ...statsResults] = await Promise.all([
|
|
497
|
+
Promise.all(searchCalls),
|
|
498
|
+
earningsPromise,
|
|
499
|
+
...statsPromises,
|
|
500
|
+
]);
|
|
501
|
+
// ── Article extras: analyst pressure-test (thesis framing, 25s budget) ──
|
|
502
|
+
let counterpoints = null;
|
|
503
|
+
if (opts.mode === 'article') {
|
|
504
|
+
const analysts = getAnalysts();
|
|
505
|
+
const analyst = analysts[0];
|
|
506
|
+
if (analyst) {
|
|
507
|
+
queryTypes.add('expert_agent');
|
|
508
|
+
const thesis = opts.angle || `${opts.topic} is a durable shift, not a passing narrative`;
|
|
509
|
+
const consult = await Promise.race([
|
|
510
|
+
safeCall(deps, `consult:${analyst.analyst_id}`, skipped, () => deps.foddaRequest('POST', '/v1/analysts/consult', deps.apiKey, deps.userId, {
|
|
511
|
+
analyst_id: analyst.analyst_id,
|
|
512
|
+
query: `Thesis: ${thesis}. What breaks it?`,
|
|
513
|
+
})),
|
|
514
|
+
new Promise(resolve => setTimeout(() => resolve(null), 25000)),
|
|
515
|
+
]);
|
|
516
|
+
const text = typeof consult?.result === 'string' ? consult.result
|
|
517
|
+
: (typeof consult?.report === 'string' ? consult.report : null);
|
|
518
|
+
if (text) {
|
|
519
|
+
counterpoints = { analyst: analyst.name, text: text.slice(0, 2500) };
|
|
520
|
+
}
|
|
521
|
+
else if (consult === null) {
|
|
522
|
+
skipped.push(`consult:${analyst.analyst_id} (timeout)`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
skipped.push('consult (no analysts in catalog)');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// ── Cluster search results into themes (dedupe by trend name) ──
|
|
530
|
+
const rawThemes = [];
|
|
531
|
+
const seenThemes = new Set();
|
|
532
|
+
const emptySources = [];
|
|
533
|
+
let domainReturned = false;
|
|
534
|
+
let expertReturned = false;
|
|
535
|
+
for (const res of searchResults) {
|
|
536
|
+
if (!res)
|
|
537
|
+
continue;
|
|
538
|
+
const trends = normalizeTrends(res.data);
|
|
539
|
+
if (trends.length > 0) {
|
|
540
|
+
if (res.kind === 'domain')
|
|
541
|
+
domainReturned = true;
|
|
542
|
+
if (res.kind === 'expert')
|
|
543
|
+
expertReturned = true;
|
|
544
|
+
}
|
|
545
|
+
for (const t of trends) {
|
|
546
|
+
const name = trendName(t);
|
|
547
|
+
if (!name)
|
|
548
|
+
continue;
|
|
549
|
+
const key = name.toLowerCase();
|
|
550
|
+
if (seenThemes.has(key))
|
|
551
|
+
continue;
|
|
552
|
+
seenThemes.add(key);
|
|
553
|
+
rawThemes.push({ name, graph: trendGraph(t), evidence: trendEvidence(t) });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Empty expert results are signal, not failure — record, never retry-loop.
|
|
557
|
+
if (!expertReturned)
|
|
558
|
+
emptySources.push('expert_graphs');
|
|
559
|
+
if (!domainReturned)
|
|
560
|
+
emptySources.push('domain_graphs');
|
|
561
|
+
// ── Curate: hard gates + selection ──
|
|
562
|
+
const maxThemes = opts.mode === 'post' ? 3 : 5;
|
|
563
|
+
const { themes, ledger, dropped } = curateThemes(rawThemes, maxThemes);
|
|
564
|
+
// ── Brand truth layer → ledger rows (earnings_call, verbal-only, ideal ledger material) ──
|
|
565
|
+
const graphsSearched = new Set(ledger.map(r => r.graph));
|
|
566
|
+
if (earningsRaw) {
|
|
567
|
+
const snapshot = earningsRaw?.snapshot || earningsRaw;
|
|
568
|
+
const truthLayer = earningsRaw?.earningsTruthLayer || snapshot?.truth_layer || null;
|
|
569
|
+
const validatedTrends = earningsRaw?.validatedTrends || snapshot?.validated_trends || [];
|
|
570
|
+
const brand = opts.brand;
|
|
571
|
+
if (truthLayer) {
|
|
572
|
+
graphsSearched.add('earnings-truth-layer');
|
|
573
|
+
if (truthLayer.headline) {
|
|
574
|
+
ledger.push({
|
|
575
|
+
claim: String(truthLayer.headline).slice(0, 280),
|
|
576
|
+
entity: brand,
|
|
577
|
+
source_title: `${brand} earnings call`,
|
|
578
|
+
source_type: 'earnings_call',
|
|
579
|
+
url: null,
|
|
580
|
+
graph: 'earnings-truth-layer',
|
|
581
|
+
theme: 'Earnings truth layer',
|
|
582
|
+
attribution_hint: `verbal attribution only — "${brand}'s latest earnings call" — never a link`,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const concerns = Array.isArray(truthLayer.analyst_concerns)
|
|
586
|
+
? truthLayer.analyst_concerns
|
|
587
|
+
: (truthLayer.analyst_concerns ? [truthLayer.analyst_concerns] : []);
|
|
588
|
+
for (const c of concerns.slice(0, 2)) {
|
|
589
|
+
const concernText = typeof c === 'string' ? c : (c?.concern || c?.text || JSON.stringify(c));
|
|
590
|
+
ledger.push({
|
|
591
|
+
claim: String(concernText).slice(0, 280),
|
|
592
|
+
entity: brand,
|
|
593
|
+
source_title: `${brand} earnings call — analyst Q&A`,
|
|
594
|
+
source_type: 'earnings_call',
|
|
595
|
+
url: null,
|
|
596
|
+
graph: 'earnings-truth-layer',
|
|
597
|
+
theme: 'Earnings truth layer',
|
|
598
|
+
attribution_hint: `verbal attribution only — "analysts pressed ${brand} on…" — never a link`,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (truthLayer.quote_from_ceo) {
|
|
602
|
+
ledger.push({
|
|
603
|
+
claim: String(truthLayer.quote_from_ceo).slice(0, 280),
|
|
604
|
+
entity: brand,
|
|
605
|
+
source_title: `${brand} earnings call — CEO`,
|
|
606
|
+
source_type: 'earnings_call',
|
|
607
|
+
url: null,
|
|
608
|
+
graph: 'earnings-truth-layer',
|
|
609
|
+
theme: 'Earnings truth layer',
|
|
610
|
+
attribution_hint: `${brand}'s CEO on the earnings call (verbal attribution only — never a link)`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
if (ledger.some(r => r.theme === 'Earnings truth layer')) {
|
|
614
|
+
themes.push({ name: 'Earnings truth layer', graph: 'earnings-truth-layer', named_entities: [brand] });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// validated_trends: ideal ledger material — "NKE's earnings validate [trend] — [graph]"
|
|
618
|
+
const vtRows = [];
|
|
619
|
+
for (const vt of (validatedTrends || []).slice(0, 3)) {
|
|
620
|
+
const vtName = (vt?.trend || vt?.trend_name || vt?.name || '').toString().trim();
|
|
621
|
+
if (!vtName)
|
|
622
|
+
continue;
|
|
623
|
+
const vtGraph = (vt?.graph || vt?.graph_id || vt?.graphName || 'earnings-truth-layer').toString();
|
|
624
|
+
graphsSearched.add(vtGraph);
|
|
625
|
+
vtRows.push({
|
|
626
|
+
claim: `${brand}'s earnings validate "${vtName}"`,
|
|
627
|
+
entity: brand,
|
|
628
|
+
source_title: `${brand} earnings call × ${vtGraph}`,
|
|
629
|
+
source_type: 'earnings_call',
|
|
630
|
+
url: null,
|
|
631
|
+
graph: vtGraph,
|
|
632
|
+
theme: 'Market-validated trends',
|
|
633
|
+
attribution_hint: `verbal attribution only — "${brand}'s earnings validate ${vtName}" — never a link`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (vtRows.length > 0) {
|
|
637
|
+
ledger.push(...vtRows);
|
|
638
|
+
themes.push({ name: 'Market-validated trends', graph: 'earnings-truth-layer', named_entities: [brand] });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// ── Article statistics → hard-numbers list (same gates) ──
|
|
642
|
+
let statistics;
|
|
643
|
+
if (opts.mode === 'article') {
|
|
644
|
+
statistics = [];
|
|
645
|
+
statsResults.forEach((res, i) => {
|
|
646
|
+
if (!res)
|
|
647
|
+
return;
|
|
648
|
+
const gid = statsGraphIds[i] || 'unknown';
|
|
649
|
+
const items = res?.results || res?.statistics || res?.items || (Array.isArray(res) ? res : []);
|
|
650
|
+
for (const item of items) {
|
|
651
|
+
const { row } = buildRow(item, { name: 'Hard numbers', graph: gid, evidence: [] });
|
|
652
|
+
if (row)
|
|
653
|
+
statistics.push(row);
|
|
654
|
+
if (statistics.length >= 3)
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
if (statistics.length === 0)
|
|
659
|
+
emptySources.push('statistics');
|
|
660
|
+
for (const s of statistics)
|
|
661
|
+
graphsSearched.add(s.graph);
|
|
662
|
+
}
|
|
663
|
+
// ── Coverage honesty — gate #6 ──
|
|
664
|
+
const minThemes = opts.mode === 'post' ? 2 : 3;
|
|
665
|
+
const thin = themes.length < minThemes;
|
|
666
|
+
const coverageNote = thin
|
|
667
|
+
? `Coverage is thin: only ${themes.length} theme(s) survived the named-example gate for "${opts.topic}" (minimum for a credible ${opts.mode} is ${minThemes}). ${dropped.length > 0 ? `${dropped.length} theme(s) were dropped — see \`dropped\` for reasons. ` : ''}The graphs may be genuinely thin here. Recommend AGAINST a Fodda-backed ${opts.mode} on this topic rather than padding — a skipped ${opts.mode} costs nothing; a hollow one costs credibility.`
|
|
668
|
+
: null;
|
|
669
|
+
const pack = {
|
|
670
|
+
tool: opts.mode === 'post' ? 'draft_linkedin_post' : 'draft_linkedin_article',
|
|
671
|
+
topic: opts.topic,
|
|
672
|
+
voice,
|
|
673
|
+
themes,
|
|
674
|
+
evidence_ledger: ledger,
|
|
675
|
+
dropped,
|
|
676
|
+
coverage_note: coverageNote,
|
|
677
|
+
recommend_against: thin,
|
|
678
|
+
how_we_found_this: {
|
|
679
|
+
graphs_searched: [...graphsSearched].sort(),
|
|
680
|
+
query_types: [...queryTypes],
|
|
681
|
+
sub_themes: subThemes,
|
|
682
|
+
empty_sources: emptySources,
|
|
683
|
+
note: 'Name these graphs and query types in the "How we found this" box (articles) or when the user asks about methodology. Empty expert results are signal (emerging/culture-adjacent topic), not failure.',
|
|
684
|
+
},
|
|
685
|
+
composition_contract: opts.mode === 'post'
|
|
686
|
+
? buildPostContract(voice, thin)
|
|
687
|
+
: buildArticleContract(voice, targetWords, thin),
|
|
688
|
+
};
|
|
689
|
+
if (opts.mode === 'article') {
|
|
690
|
+
pack.statistics = statistics ?? [];
|
|
691
|
+
pack.counterpoints = counterpoints;
|
|
692
|
+
}
|
|
693
|
+
PACK_CACHE.set(cacheKey, { pack: JSON.parse(JSON.stringify(pack)), createdAt: Date.now() });
|
|
694
|
+
return pack;
|
|
695
|
+
}
|
|
696
|
+
//# sourceMappingURL=linkedinEngine.js.map
|