fodda-mcp 1.32.1 → 1.46.31
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 +14 -16
- 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 +1 -1
- package/dist/brandTemplate.d.ts.map +1 -1
- package/dist/brandTemplate.js +179 -11
- package/dist/brandTemplate.js.map +1 -1
- package/dist/catalogCache.d.ts +14 -13
- package/dist/catalogCache.d.ts.map +1 -1
- package/dist/catalogCache.js +266 -67
- package/dist/catalogCache.js.map +1 -1
- package/dist/coverageRelevance.d.ts +156 -0
- package/dist/coverageRelevance.d.ts.map +1 -0
- package/dist/coverageRelevance.js +1062 -0
- package/dist/coverageRelevance.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/enrichment.d.ts.map +1 -1
- package/dist/enrichment.js +51 -1
- package/dist/enrichment.js.map +1 -1
- package/dist/errorHandling.d.ts +1 -1
- package/dist/errorHandling.d.ts.map +1 -1
- package/dist/errorHandling.js +26 -6
- 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 +548 -38
- package/dist/index.js.map +1 -1
- 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/oauthRegisterShim.d.ts +13 -0
- package/dist/oauthRegisterShim.d.ts.map +1 -0
- package/dist/oauthRegisterShim.js +83 -0
- package/dist/oauthRegisterShim.js.map +1 -0
- package/dist/pricingCache.d.ts +6 -8
- package/dist/pricingCache.d.ts.map +1 -1
- package/dist/pricingCache.js +69 -13
- 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/sessionTracker.d.ts +24 -2
- package/dist/sessionTracker.d.ts.map +1 -1
- package/dist/sessionTracker.js +198 -4
- 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 +81 -61
- 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_book_a_call_live.d.ts +2 -0
- package/dist/test_book_a_call_live.d.ts.map +1 -0
- package/dist/test_book_a_call_live.js +148 -0
- package/dist/test_book_a_call_live.js.map +1 -0
- 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_relevance.d.ts +12 -0
- package/dist/test_coverage_relevance.d.ts.map +1 -0
- package/dist/test_coverage_relevance.js +121 -0
- package/dist/test_coverage_relevance.js.map +1 -0
- package/dist/test_dcr_and_legacy_deprecation.d.ts +2 -0
- package/dist/test_dcr_and_legacy_deprecation.d.ts.map +1 -0
- package/dist/test_dcr_and_legacy_deprecation.js +148 -0
- package/dist/test_dcr_and_legacy_deprecation.js.map +1 -0
- package/dist/test_financial_snapshot_render.d.ts +2 -0
- package/dist/test_financial_snapshot_render.d.ts.map +1 -0
- package/dist/test_financial_snapshot_render.js +167 -0
- package/dist/test_financial_snapshot_render.js.map +1 -0
- package/dist/test_gap_alert.d.ts +12 -0
- package/dist/test_gap_alert.d.ts.map +1 -0
- package/dist/test_gap_alert.js +43 -0
- package/dist/test_gap_alert.js.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_next_moves.d.ts +2 -0
- package/dist/test_next_moves.d.ts.map +1 -0
- package/dist/test_next_moves.js +349 -0
- package/dist/test_next_moves.js.map +1 -0
- package/dist/test_next_moves_transcripts.d.ts +2 -0
- package/dist/test_next_moves_transcripts.d.ts.map +1 -0
- package/dist/test_next_moves_transcripts.js +413 -0
- package/dist/test_next_moves_transcripts.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_session_next_moves_telemetry.d.ts +2 -0
- package/dist/test_session_next_moves_telemetry.d.ts.map +1 -0
- package/dist/test_session_next_moves_telemetry.js +100 -0
- package/dist/test_session_next_moves_telemetry.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 +1678 -364
- package/dist/toolHandlers.js.map +1 -1
- package/dist/tools.d.ts +7 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +12 -3
- 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_tools_endpoint.js +4 -3
- package/dist/verify_tools_endpoint.js.map +1 -1
- package/package.json +5 -2
- package/server.json +16 -3
- package/dist/scripts/simulate_gemini.d.ts +0 -2
- package/dist/scripts/simulate_gemini.d.ts.map +0 -1
- package/dist/scripts/simulate_gemini.js +0 -100
- package/dist/scripts/simulate_gemini.js.map +0 -1
- package/dist/scripts/verify_registry.d.ts +0 -2
- package/dist/scripts/verify_registry.d.ts.map +0 -1
- package/dist/scripts/verify_registry.js +0 -77
- package/dist/scripts/verify_registry.js.map +0 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -332
- package/dist/src/index.js.map +0 -1
- package/dist/src/tools.d.ts +0 -12
- package/dist/src/tools.d.ts.map +0 -1
- package/dist/src/tools.js +0 -104
- package/dist/src/tools.js.map +0 -1
- package/dist/src/types.d.ts +0 -44
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -2
- package/dist/src/types.js.map +0 -1
- package/dist/src/verify.d.ts +0 -2
- package/dist/src/verify.d.ts.map +0 -1
- package/dist/src/verify.js +0 -47
- package/dist/src/verify.js.map +0 -1
- package/dist/src/verify_hmac.d.ts +0 -2
- package/dist/src/verify_hmac.d.ts.map +0 -1
- package/dist/src/verify_hmac.js +0 -106
- package/dist/src/verify_hmac.js.map +0 -1
- package/dist/src/verify_tools_endpoint.d.ts +0 -2
- package/dist/src/verify_tools_endpoint.d.ts.map +0 -1
- package/dist/src/verify_tools_endpoint.js +0 -40
- package/dist/src/verify_tools_endpoint.js.map +0 -1
- package/dist/test-waverunner-response.d.ts +0 -2
- package/dist/test-waverunner-response.d.ts.map +0 -1
- package/dist/test-waverunner-response.js +0 -20
- package/dist/test-waverunner-response.js.map +0 -1
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage relevance heuristic — counts how many result rows are actually
|
|
3
|
+
* on-topic for the query, so the coverage annotation can flag "thin" when a
|
|
4
|
+
* cross-graph fan-out pads the result set with off-topic rows.
|
|
5
|
+
*
|
|
6
|
+
* Why raw count isn't enough: an all-graph search fans out to every accessible
|
|
7
|
+
* graph and each graph returns its own best matches. For a narrow query
|
|
8
|
+
* ("Chinese automotive trends EV brands"), unrelated graphs (women's health,
|
|
9
|
+
* beauty, social media) still contribute their locally-top rows, so the merged
|
|
10
|
+
* set can hit 10 rows while only 2–3 are on-topic — and the old count-only
|
|
11
|
+
* check reported coverage "ok" and never nudged the client toward
|
|
12
|
+
* get_supplemental_context.
|
|
13
|
+
*
|
|
14
|
+
* How a row is judged on-topic (positive-evidence-of-off-topic model — rows
|
|
15
|
+
* are only marked off-topic when we can actually tell):
|
|
16
|
+
* 1. Lexical: the row's text (title/summary/brands/places + its source
|
|
17
|
+
* graph's name/domain/topics) contains at least one SPECIFIC query token
|
|
18
|
+
* (generic research vocabulary like "trends", "market", "brands" is
|
|
19
|
+
* stopworded out). Match → on-topic.
|
|
20
|
+
* 2. Score rescue: no lexical match, but the row's relevance score clears
|
|
21
|
+
* ON_TOPIC_RESCUE_RATIO of its tier's scale. Scores are NOT comparable
|
|
22
|
+
* across tiers (domain-graph composite scores cluster near ~2.0 for
|
|
23
|
+
* on-topic matches; report-graph vector scores near ~0.8), so each tier
|
|
24
|
+
* normalizes against max(nominal tier scale, best observed score in that
|
|
25
|
+
* tier within this result set). Using the nominal as a floor prevents a
|
|
26
|
+
* tier whose rows are ALL off-topic from normalizing itself to look good.
|
|
27
|
+
* 3. No score at all → counted on-topic (score-less rows from statistics /
|
|
28
|
+
* insights endpoints must never regress coverage).
|
|
29
|
+
*
|
|
30
|
+
* If the query has no specific tokens left after stopwording (e.g. "top
|
|
31
|
+
* emerging trends"), relevance can't be judged and `evaluated: false` is
|
|
32
|
+
* returned — callers keep the legacy count-only behavior.
|
|
33
|
+
*/
|
|
34
|
+
import { getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getAnalysts } from './catalogCache.js';
|
|
35
|
+
/** Nominal on-topic relevance-score scale per graph tier (observed in QA:
|
|
36
|
+
* domain composites ~2.0, report vector scores ~0.8; expert sits between). */
|
|
37
|
+
export const TIER_NOMINAL_SCORE = {
|
|
38
|
+
domain: 2.0,
|
|
39
|
+
expert: 1.0,
|
|
40
|
+
report: 0.8,
|
|
41
|
+
};
|
|
42
|
+
/** Fallback tier when a row's graph can't be resolved — the most permissive
|
|
43
|
+
* scale, so unresolved rows are rarely marked off-topic. */
|
|
44
|
+
const FALLBACK_TIER = 'report';
|
|
45
|
+
/** A row with no lexical overlap must score at least this fraction of its
|
|
46
|
+
* tier scale to still count as on-topic. */
|
|
47
|
+
export const ON_TOPIC_RESCUE_RATIO = 0.75;
|
|
48
|
+
/** English function words + research vocabulary present in almost every graph
|
|
49
|
+
* row — these carry no topical signal and are excluded from query tokens. */
|
|
50
|
+
const GENERIC_QUERY_TOKENS = new Set([
|
|
51
|
+
// function words
|
|
52
|
+
'a', 'an', 'the', 'and', 'or', 'of', 'in', 'on', 'for', 'to', 'with', 'from', 'by', 'at',
|
|
53
|
+
'as', 'is', 'are', 'was', 'be', 'been', 'how', 'what', 'which', 'who', 'why', 'when',
|
|
54
|
+
'where', 'will', 'can', 'do', 'does', 'about', 'into', 'over', 'under', 'between', 'vs',
|
|
55
|
+
'versus', 'their', 'its', 'this', 'that', 'these', 'those', 'it', 'not', 'no', 'more',
|
|
56
|
+
'most', 'less', 'least', 'than', 'also', 'other', 'across', 'around', 'within', 'per',
|
|
57
|
+
'via', 'both', 'all', 'any', 'some', 'each', 'such', 'we', 'us', 'our', 'you', 'your',
|
|
58
|
+
// domain-generic research vocabulary
|
|
59
|
+
'trend', 'trends', 'market', 'markets', 'marketing', 'brand', 'brands', 'branding',
|
|
60
|
+
'consumer', 'consumers', 'industry', 'industries', 'growth', 'data', 'insight',
|
|
61
|
+
'insights', 'analysis', 'global', 'new', 'top', 'key', 'major', 'latest', 'future',
|
|
62
|
+
'emerging', 'current', 'recent', 'report', 'reports', 'research', 'sector', 'sectors',
|
|
63
|
+
'landscape', 'opportunity', 'opportunities', 'innovation', 'innovations', 'strategy',
|
|
64
|
+
'strategies', 'signal', 'signals', 'impact', 'update', 'updates', 'overview',
|
|
65
|
+
]);
|
|
66
|
+
const TIER_ALIASES = {
|
|
67
|
+
'industry report': 'report',
|
|
68
|
+
'analyst': 'expert',
|
|
69
|
+
};
|
|
70
|
+
/** Tokenize a query down to its topically-specific terms. */
|
|
71
|
+
export function specificQueryTokens(query) {
|
|
72
|
+
return [...new Set(query.toLowerCase().split(/[^a-z0-9]+/)
|
|
73
|
+
.filter(t => t.length >= 2 && !GENERIC_QUERY_TOKENS.has(t)))];
|
|
74
|
+
}
|
|
75
|
+
function rowScore(row) {
|
|
76
|
+
return row.relevance_score || row.semantic_score || row._score || row.score || 0;
|
|
77
|
+
}
|
|
78
|
+
function rowGraphId(row) {
|
|
79
|
+
return row._use_this_graphId || row.graphId || row.graph_id || row.psfk_graph_slug || undefined;
|
|
80
|
+
}
|
|
81
|
+
function resolveRowTier(row, searchedGraphs, catalog) {
|
|
82
|
+
const gid = rowGraphId(row);
|
|
83
|
+
let type;
|
|
84
|
+
if (gid) {
|
|
85
|
+
const g = catalog.find(x => x.graph_id === gid) || searchedGraphs.find(x => x?.graph_id === gid);
|
|
86
|
+
type = g?.graph_type;
|
|
87
|
+
}
|
|
88
|
+
if (!type && searchedGraphs.length === 1)
|
|
89
|
+
type = searchedGraphs[0]?.graph_type;
|
|
90
|
+
type = TIER_ALIASES[type || ''] || type;
|
|
91
|
+
return type && TIER_NOMINAL_SCORE[type] !== undefined ? type : FALLBACK_TIER;
|
|
92
|
+
}
|
|
93
|
+
/** Does the row's text (or its source graph's metadata) contain any specific query token? */
|
|
94
|
+
export function rowMatchesQueryTokens(row, tokens, catalog) {
|
|
95
|
+
if (tokens.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
const parts = [
|
|
98
|
+
row.title, row.trendName, row.name, row.display, row.label,
|
|
99
|
+
row.summary, row.description, row.trendDescription, row.whyNow,
|
|
100
|
+
row.graphName, row.statistic, row.claim, row.insight, row.text,
|
|
101
|
+
];
|
|
102
|
+
const gid = rowGraphId(row);
|
|
103
|
+
if (gid) {
|
|
104
|
+
parts.push(String(gid).replace(/[-_/,]/g, ' '));
|
|
105
|
+
const g = catalog.find(x => x.graph_id === gid);
|
|
106
|
+
if (g)
|
|
107
|
+
parts.push(g.name, g.domain, ...(Array.isArray(g.topics) ? g.topics : []));
|
|
108
|
+
}
|
|
109
|
+
for (const arr of [row.brandNames, row.place, row.sectors, row.topics]) {
|
|
110
|
+
if (Array.isArray(arr))
|
|
111
|
+
parts.push(...arr);
|
|
112
|
+
}
|
|
113
|
+
const words = new Set(parts.filter((x) => typeof x === 'string').join(' ').toLowerCase().split(/[^a-z0-9]+/));
|
|
114
|
+
// Exact word match always counts; prefix match only for tokens ≥4 chars
|
|
115
|
+
// (so "china" matches "chinas" but "car" never matches "carbon").
|
|
116
|
+
for (const t of tokens) {
|
|
117
|
+
if (words.has(t))
|
|
118
|
+
return true;
|
|
119
|
+
if (t.length >= 4) {
|
|
120
|
+
for (const w of words) {
|
|
121
|
+
if (w.startsWith(t))
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Count rows that are on-topic for the query. `catalog` is injectable for
|
|
130
|
+
* tests; defaults to the live graph catalog.
|
|
131
|
+
*/
|
|
132
|
+
export function countOnTopicRows(rows, query, searchedGraphs, catalog = getGraphs()) {
|
|
133
|
+
const tokens = specificQueryTokens(query);
|
|
134
|
+
if (tokens.length === 0 || rows.length === 0) {
|
|
135
|
+
return { onTopic: rows.length, evaluated: false };
|
|
136
|
+
}
|
|
137
|
+
const scored = rows.map(r => {
|
|
138
|
+
const score = rowScore(r);
|
|
139
|
+
const tier = resolveRowTier(r, searchedGraphs, catalog);
|
|
140
|
+
return { r, score, tier };
|
|
141
|
+
});
|
|
142
|
+
let onTopic = 0;
|
|
143
|
+
for (const { r, score, tier } of scored) {
|
|
144
|
+
if (rowMatchesQueryTokens(r, tokens, catalog)) {
|
|
145
|
+
onTopic++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// No token match and no score: nothing supports the row being on-topic.
|
|
149
|
+
if (score <= 0)
|
|
150
|
+
continue;
|
|
151
|
+
// Rescue by score alone must be judged against the tier's NOMINAL scale,
|
|
152
|
+
// never the result set's own max — otherwise the top rows of any set
|
|
153
|
+
// (including a fully off-topic one) self-certify as on-topic.
|
|
154
|
+
const scale = TIER_NOMINAL_SCORE[tier] ?? 0.8;
|
|
155
|
+
if (score >= scale * ON_TOPIC_RESCUE_RATIO)
|
|
156
|
+
onTopic++;
|
|
157
|
+
}
|
|
158
|
+
return { onTopic, evaluated: true };
|
|
159
|
+
}
|
|
160
|
+
export function isDemandShaped(query) {
|
|
161
|
+
const q = query.toLowerCase();
|
|
162
|
+
const demandKeywords = [
|
|
163
|
+
/\binterest\b/,
|
|
164
|
+
/\bgrowing\b/,
|
|
165
|
+
/\bdemand\b/,
|
|
166
|
+
/\bsearch\b/,
|
|
167
|
+
/\battention\b/,
|
|
168
|
+
/\bvolume\b/,
|
|
169
|
+
/\bforecasts?\b/,
|
|
170
|
+
/\bpopularity\b/,
|
|
171
|
+
/\bpopular\b/,
|
|
172
|
+
/\bgrowth\b/,
|
|
173
|
+
/\bhistorical\b/,
|
|
174
|
+
/\bdata\s+series\b/,
|
|
175
|
+
/\bstatistics\b/,
|
|
176
|
+
/\bcharts?\b/
|
|
177
|
+
];
|
|
178
|
+
return demandKeywords.some(pat => pat.test(q));
|
|
179
|
+
}
|
|
180
|
+
export function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEvidenceCheck = false, catalog = getGraphs(), options) {
|
|
181
|
+
if (!data || typeof data !== 'object')
|
|
182
|
+
return data;
|
|
183
|
+
let normalizedData = data;
|
|
184
|
+
if (Array.isArray(data)) {
|
|
185
|
+
normalizedData = { rows: data, dataStatus: 'ok' };
|
|
186
|
+
}
|
|
187
|
+
const layerMap = {
|
|
188
|
+
'industry report': 'report'
|
|
189
|
+
};
|
|
190
|
+
const uniqueTypes = new Set(searchedGraphs.map(g => layerMap[g.graph_type] || g.graph_type).filter(Boolean));
|
|
191
|
+
const layersSearched = Array.from(uniqueTypes);
|
|
192
|
+
// ── Check for explicit backend errors (e.g. NEO4J_AUTH_MISSING, auth failures, 5xx) ──
|
|
193
|
+
const errDetail = normalizedData.error || normalizedData.error_code || normalizedData.code;
|
|
194
|
+
const isErrStatus = normalizedData.dataStatus === 'error' || normalizedData.status === 'error';
|
|
195
|
+
if (errDetail || isErrStatus) {
|
|
196
|
+
normalizedData.coverage = {
|
|
197
|
+
status: 'error',
|
|
198
|
+
error: (typeof errDetail === 'string' ? errDetail : JSON.stringify(errDetail)) || 'Backend service error',
|
|
199
|
+
results_returned: 0,
|
|
200
|
+
layers_searched: layersSearched,
|
|
201
|
+
};
|
|
202
|
+
console.error(`[coverage] status: error (${normalizedData.coverage.error}), query: "${query}"`);
|
|
203
|
+
return normalizedData;
|
|
204
|
+
}
|
|
205
|
+
// Explicitly target rows/results/trends/matches fields, mirroring normalizeTrends
|
|
206
|
+
let rows = normalizedData.rows;
|
|
207
|
+
if (!Array.isArray(rows)) {
|
|
208
|
+
if (Array.isArray(normalizedData.results)) {
|
|
209
|
+
rows = normalizedData.results;
|
|
210
|
+
}
|
|
211
|
+
else if (Array.isArray(normalizedData.trends)) {
|
|
212
|
+
rows = normalizedData.trends;
|
|
213
|
+
}
|
|
214
|
+
else if (Array.isArray(normalizedData.matches)) {
|
|
215
|
+
rows = normalizedData.matches;
|
|
216
|
+
}
|
|
217
|
+
else if (Array.isArray(normalizedData.statistics)) {
|
|
218
|
+
rows = normalizedData.statistics;
|
|
219
|
+
}
|
|
220
|
+
else if (Array.isArray(normalizedData.items)) {
|
|
221
|
+
rows = normalizedData.items;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
// Find any array property
|
|
225
|
+
for (const key of Object.keys(normalizedData)) {
|
|
226
|
+
if (Array.isArray(normalizedData[key])) {
|
|
227
|
+
rows = normalizedData[key];
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(rows)) {
|
|
234
|
+
rows = [];
|
|
235
|
+
}
|
|
236
|
+
const resultCount = rows.length;
|
|
237
|
+
let status = 'ok';
|
|
238
|
+
let onTopicCount;
|
|
239
|
+
let isThinRelevance = false;
|
|
240
|
+
if (resultCount === 0) {
|
|
241
|
+
status = 'empty';
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
let isThinCount = resultCount < 3;
|
|
245
|
+
if (limit !== undefined && limit < 3 && resultCount === limit) {
|
|
246
|
+
isThinCount = false;
|
|
247
|
+
}
|
|
248
|
+
let isThinEvidence = false;
|
|
249
|
+
if (!skipEvidenceCheck) {
|
|
250
|
+
isThinEvidence = rows.every((r) => {
|
|
251
|
+
const count = Array.isArray(r.evidence)
|
|
252
|
+
? r.evidence.length
|
|
253
|
+
: Array.isArray(r.evidence_items)
|
|
254
|
+
? r.evidence_items.length
|
|
255
|
+
: Array.isArray(r.evidenceItems)
|
|
256
|
+
? r.evidenceItems.length
|
|
257
|
+
: Array.isArray(r.trendEvidence)
|
|
258
|
+
? r.trendEvidence.length
|
|
259
|
+
: (r.evidence_count || r.evidenceCount || 0);
|
|
260
|
+
return count < 3;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// ── Relevance-aware thinness ──
|
|
264
|
+
// Raw count can look healthy while cross-graph fan-out pads the set with
|
|
265
|
+
// off-topic rows (each graph contributes its locally-best matches even
|
|
266
|
+
// for unrelated queries). Count only on-topic rows; requiring
|
|
267
|
+
// onTopic < resultCount means this never fires when every returned row
|
|
268
|
+
// is on-topic (that case is already governed by the count/limit rules).
|
|
269
|
+
const relevance = countOnTopicRows(rows, query, searchedGraphs, catalog);
|
|
270
|
+
if (relevance.evaluated) {
|
|
271
|
+
onTopicCount = relevance.onTopic;
|
|
272
|
+
if (relevance.onTopic < 3 && relevance.onTopic < resultCount) {
|
|
273
|
+
isThinRelevance = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (isThinCount || isThinEvidence || isThinRelevance) {
|
|
277
|
+
status = 'thin';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const isDemand = isDemandShaped(query);
|
|
281
|
+
let suggestedAction = undefined;
|
|
282
|
+
if (status === 'empty' || status === 'thin' || isDemand) {
|
|
283
|
+
let reason = '';
|
|
284
|
+
if (status === 'empty') {
|
|
285
|
+
reason = 'curated coverage empty; supplemental provides external trends and data series';
|
|
286
|
+
}
|
|
287
|
+
else if (status === 'thin') {
|
|
288
|
+
reason = isThinRelevance
|
|
289
|
+
? `curated coverage thin: only ${onTopicCount} of ${resultCount} results are on-topic for this query; supplemental covers demand and participation signals for this domain`
|
|
290
|
+
: 'curated coverage thin; supplemental covers demand and participation signals for this domain';
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
reason = 'curated coverage is OK, but query is explicitly demand- or attention-shaped; supplemental adds value via live signals';
|
|
294
|
+
}
|
|
295
|
+
const suggestedArgs = { query };
|
|
296
|
+
// Sourced from graph ids present in the result rows, fall back to searchedGraphs
|
|
297
|
+
const rowGraphIds = [...new Set(rows.map((r) => r.graphId || r._use_this_graphId || r.psfk_graph_slug || r.graph_id).filter(Boolean))];
|
|
298
|
+
if (rowGraphIds.length > 0) {
|
|
299
|
+
suggestedArgs.graph_ids = rowGraphIds;
|
|
300
|
+
}
|
|
301
|
+
else if (searchedGraphs.length > 0) {
|
|
302
|
+
suggestedArgs.graph_ids = searchedGraphs.map(g => g.graph_id);
|
|
303
|
+
}
|
|
304
|
+
suggestedAction = {
|
|
305
|
+
tool: 'get_supplemental_context',
|
|
306
|
+
arguments: suggestedArgs,
|
|
307
|
+
reason
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
normalizedData.coverage = {
|
|
311
|
+
status,
|
|
312
|
+
results_returned: resultCount,
|
|
313
|
+
layers_searched: layersSearched,
|
|
314
|
+
};
|
|
315
|
+
if (onTopicCount !== undefined) {
|
|
316
|
+
normalizedData.coverage.results_on_topic = onTopicCount;
|
|
317
|
+
}
|
|
318
|
+
if (suggestedAction) {
|
|
319
|
+
normalizedData.coverage.suggested_action = suggestedAction;
|
|
320
|
+
}
|
|
321
|
+
// Machine-only recovery ladder: thin/empty coverage is never announced to
|
|
322
|
+
// the user. The client recovers via suggested_action first; escalation
|
|
323
|
+
// exists only for when that recovery also comes back dry.
|
|
324
|
+
if (status === 'thin' || status === 'empty') {
|
|
325
|
+
normalizedData.coverage.presentation = 'internal';
|
|
326
|
+
normalizedData.coverage.escalation = {
|
|
327
|
+
when: 'Only if suggested_action also returns nothing usable for this query',
|
|
328
|
+
say: 'This is what we have on this right now.',
|
|
329
|
+
options: [
|
|
330
|
+
{
|
|
331
|
+
tool: 'deep_research_topic',
|
|
332
|
+
arguments: { query },
|
|
333
|
+
reason: 'commission a Deep Dive report combining expert graph intelligence with live web research',
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
action: 'web_llm_research',
|
|
337
|
+
reason: 'run your own web/LLM research pass, clearly attributing non-Fodda findings',
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
normalizedData.next_moves = generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog, options?.analysts || getAnalysts(), options);
|
|
343
|
+
console.error(`[coverage] status: ${status}, results: ${resultCount}/${limit || 'default'}${onTopicCount !== undefined ? `, on-topic: ${onTopicCount}` : ''}, query: "${query}"`);
|
|
344
|
+
return normalizedData;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Clean and truncate a single theme candidate to <= 3 words, lowercase.
|
|
348
|
+
*/
|
|
349
|
+
export function cleanThemeCandidate(raw) {
|
|
350
|
+
if (!raw || typeof raw !== 'string')
|
|
351
|
+
return null;
|
|
352
|
+
let cleaned = raw
|
|
353
|
+
.replace(/^\[(?:REVIW|REVIEW|DRAFT|WIP)\]\s*/i, '')
|
|
354
|
+
.replace(/[&/+,]+/g, ' ')
|
|
355
|
+
.replace(/[^a-zA-Z0-9\s-]/g, '')
|
|
356
|
+
.toLowerCase()
|
|
357
|
+
.trim();
|
|
358
|
+
if (!cleaned)
|
|
359
|
+
return null;
|
|
360
|
+
const stopWords = new Set(['and', 'or', 'the', 'in', 'of', 'for', 'with', 'to', 'a', 'an']);
|
|
361
|
+
let words = cleaned.split(/\s+/).filter(Boolean);
|
|
362
|
+
while (words.length > 0 && words[0] && stopWords.has(words[0]))
|
|
363
|
+
words.shift();
|
|
364
|
+
while (words.length > 0 && words[words.length - 1] && stopWords.has(words[words.length - 1]))
|
|
365
|
+
words.pop();
|
|
366
|
+
if (words.length === 0)
|
|
367
|
+
return null;
|
|
368
|
+
if (words.length > 3) {
|
|
369
|
+
words = words.slice(0, 3);
|
|
370
|
+
while (words.length > 0 && words[words.length - 1] && stopWords.has(words[words.length - 1]))
|
|
371
|
+
words.pop();
|
|
372
|
+
}
|
|
373
|
+
return words.length > 0 ? words.join(' ') : null;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Format raw theme candidates (topics, sectors, trend names, titles)
|
|
377
|
+
* into concise, lowercase theme phrases (<= 3 words each).
|
|
378
|
+
*/
|
|
379
|
+
export function formatThemePhrase(rawCandidates, fallback = 'emerging signals') {
|
|
380
|
+
const cleanedThemes = [];
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
for (const raw of rawCandidates) {
|
|
383
|
+
const cleaned = cleanThemeCandidate(raw);
|
|
384
|
+
if (cleaned && !seen.has(cleaned)) {
|
|
385
|
+
seen.add(cleaned);
|
|
386
|
+
cleanedThemes.push(cleaned);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (cleanedThemes.length === 0) {
|
|
390
|
+
return cleanThemeCandidate(fallback) || fallback.toLowerCase();
|
|
391
|
+
}
|
|
392
|
+
if (cleanedThemes.length === 1 && cleanedThemes[0]) {
|
|
393
|
+
return cleanedThemes[0];
|
|
394
|
+
}
|
|
395
|
+
return `${cleanedThemes[0]} and ${cleanedThemes[1]}`;
|
|
396
|
+
}
|
|
397
|
+
export function generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog = getGraphs(), analysts = getAnalysts(), options) {
|
|
398
|
+
const nextMoves = {
|
|
399
|
+
scope_prompt: true,
|
|
400
|
+
presentation: 'internal',
|
|
401
|
+
};
|
|
402
|
+
if (options?.knownBrand) {
|
|
403
|
+
nextMoves.known_brand = options.knownBrand;
|
|
404
|
+
}
|
|
405
|
+
const searchedGraphIds = new Set();
|
|
406
|
+
for (const g of searchedGraphs) {
|
|
407
|
+
const gid = typeof g === 'string' ? g : (g.graph_id || g.id || g.slug);
|
|
408
|
+
if (gid)
|
|
409
|
+
searchedGraphIds.add(gid);
|
|
410
|
+
}
|
|
411
|
+
for (const r of rows) {
|
|
412
|
+
const gid = rowGraphId(r);
|
|
413
|
+
if (gid)
|
|
414
|
+
searchedGraphIds.add(gid);
|
|
415
|
+
}
|
|
416
|
+
// ── 1. Thread (Pull the thread) ──
|
|
417
|
+
const tokens = specificQueryTokens(query);
|
|
418
|
+
const rowsByGraph = new Map();
|
|
419
|
+
for (const r of rows) {
|
|
420
|
+
const gid = rowGraphId(r) || (searchedGraphs[0]?.graph_id) || 'unknown';
|
|
421
|
+
if (!rowsByGraph.has(gid))
|
|
422
|
+
rowsByGraph.set(gid, []);
|
|
423
|
+
rowsByGraph.get(gid).push(r);
|
|
424
|
+
}
|
|
425
|
+
let maxRemainder = 0;
|
|
426
|
+
let bestGraphId;
|
|
427
|
+
let bestGraphTheme;
|
|
428
|
+
// First pass: check per-graph on_topic_total / total attached to gRows[0]
|
|
429
|
+
for (const [gid, gRows] of rowsByGraph.entries()) {
|
|
430
|
+
const onTopicRows = gRows.filter(r => rowMatchesQueryTokens(r, tokens, catalog) || (rowScore(r) >= 0.75 * (TIER_NOMINAL_SCORE[resolveRowTier(r, searchedGraphs, catalog)] ?? 0.8)));
|
|
431
|
+
const renderedCount = gRows.length;
|
|
432
|
+
const gTotal = gRows[0]?.on_topic_total ?? gRows[0]?.total_count ?? gRows[0]?.total;
|
|
433
|
+
let remainder = 0;
|
|
434
|
+
if (typeof gTotal === 'number' && gTotal > renderedCount) {
|
|
435
|
+
remainder = gTotal - renderedCount;
|
|
436
|
+
}
|
|
437
|
+
if (remainder > maxRemainder) {
|
|
438
|
+
maxRemainder = remainder;
|
|
439
|
+
bestGraphId = gid;
|
|
440
|
+
const themes = [];
|
|
441
|
+
for (const r of onTopicRows) {
|
|
442
|
+
if (Array.isArray(r.topics))
|
|
443
|
+
themes.push(...r.topics);
|
|
444
|
+
if (Array.isArray(r.sectors))
|
|
445
|
+
themes.push(...r.sectors);
|
|
446
|
+
}
|
|
447
|
+
const graphMeta = catalog.find(g => g.graph_id === gid);
|
|
448
|
+
if (graphMeta?.topics?.length)
|
|
449
|
+
themes.push(...graphMeta.topics);
|
|
450
|
+
for (const r of onTopicRows) {
|
|
451
|
+
if (r.title)
|
|
452
|
+
themes.push(r.title);
|
|
453
|
+
if (r.trend_name)
|
|
454
|
+
themes.push(r.trend_name);
|
|
455
|
+
}
|
|
456
|
+
bestGraphTheme = formatThemePhrase(themes, graphMeta?.domain || 'emerging signals');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Second pass: if per-graph totals were not present on rows, check aggregate multi-graph totals in options
|
|
460
|
+
if (maxRemainder === 0) {
|
|
461
|
+
const totalMulti = options?.onTopicTotal ?? options?.total;
|
|
462
|
+
if (typeof totalMulti === 'number' && totalMulti > rows.length) {
|
|
463
|
+
maxRemainder = totalMulti - rows.length;
|
|
464
|
+
let maxCount = 0;
|
|
465
|
+
for (const [gid, gRows] of rowsByGraph.entries()) {
|
|
466
|
+
if (gRows.length > maxCount) {
|
|
467
|
+
maxCount = gRows.length;
|
|
468
|
+
bestGraphId = gid;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (bestGraphId) {
|
|
472
|
+
const gRows = rowsByGraph.get(bestGraphId) || [];
|
|
473
|
+
const themes = [];
|
|
474
|
+
for (const r of gRows) {
|
|
475
|
+
if (Array.isArray(r.topics))
|
|
476
|
+
themes.push(...r.topics);
|
|
477
|
+
if (Array.isArray(r.sectors))
|
|
478
|
+
themes.push(...r.sectors);
|
|
479
|
+
}
|
|
480
|
+
const graphMeta = catalog.find(g => g.graph_id === bestGraphId);
|
|
481
|
+
if (graphMeta?.topics?.length)
|
|
482
|
+
themes.push(...graphMeta.topics);
|
|
483
|
+
for (const r of gRows) {
|
|
484
|
+
if (r.title)
|
|
485
|
+
themes.push(r.title);
|
|
486
|
+
if (r.trend_name)
|
|
487
|
+
themes.push(r.trend_name);
|
|
488
|
+
}
|
|
489
|
+
bestGraphTheme = formatThemePhrase(themes, graphMeta?.domain || 'emerging signals');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (options?.isBrandTracker) {
|
|
494
|
+
if (rows.length > 0) {
|
|
495
|
+
let maxCount = 0;
|
|
496
|
+
let topGraphId;
|
|
497
|
+
for (const [gid, gRows] of rowsByGraph.entries()) {
|
|
498
|
+
if (gRows.length > maxCount) {
|
|
499
|
+
maxCount = gRows.length;
|
|
500
|
+
topGraphId = gid;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (topGraphId) {
|
|
504
|
+
const gRows = rowsByGraph.get(topGraphId) || [];
|
|
505
|
+
const themes = [];
|
|
506
|
+
for (const r of gRows) {
|
|
507
|
+
if (Array.isArray(r.topics))
|
|
508
|
+
themes.push(...r.topics);
|
|
509
|
+
if (Array.isArray(r.sectors))
|
|
510
|
+
themes.push(...r.sectors);
|
|
511
|
+
}
|
|
512
|
+
const gMeta = catalog.find(g => g.graph_id === topGraphId);
|
|
513
|
+
if (gMeta?.topics?.length)
|
|
514
|
+
themes.push(...gMeta.topics);
|
|
515
|
+
for (const r of gRows) {
|
|
516
|
+
if (r.trend_name)
|
|
517
|
+
themes.push(r.trend_name);
|
|
518
|
+
if (r.title)
|
|
519
|
+
themes.push(r.title);
|
|
520
|
+
}
|
|
521
|
+
const bestTheme = formatThemePhrase(themes, gMeta?.domain || 'emerging signals');
|
|
522
|
+
const display = gMeta ? buildDisplayName(gMeta) : (gRows[0]?.graphName || topGraphId);
|
|
523
|
+
nextMoves.thread = {
|
|
524
|
+
kind: 'more_in_graph',
|
|
525
|
+
graph_id: topGraphId,
|
|
526
|
+
graph_display: display,
|
|
527
|
+
remaining_count: maxCount,
|
|
528
|
+
theme: bestTheme,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
// Empty brand footprint: pick top domain graph from catalog without keyword-matching on bare brand name
|
|
534
|
+
const domainGraphs = catalog.filter(g => g.graph_type === 'domain' || !g.curator);
|
|
535
|
+
const topDomain = domainGraphs[0] || catalog[0];
|
|
536
|
+
if (topDomain) {
|
|
537
|
+
const topDisplay = buildDisplayName(topDomain);
|
|
538
|
+
nextMoves.thread = {
|
|
539
|
+
kind: 'honest_thin',
|
|
540
|
+
graph_id: topDomain.graph_id,
|
|
541
|
+
graph_display: topDisplay,
|
|
542
|
+
adjacent: {
|
|
543
|
+
graph_id: topDomain.graph_id,
|
|
544
|
+
graph_display: topDisplay,
|
|
545
|
+
reason: topDomain.headline || topDomain.domain || topDomain.name,
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else if (maxRemainder > 0 && bestGraphId) {
|
|
552
|
+
const gMeta = catalog.find(g => g.graph_id === bestGraphId);
|
|
553
|
+
const display = gMeta ? buildDisplayName(gMeta) : bestGraphId;
|
|
554
|
+
nextMoves.thread = {
|
|
555
|
+
kind: 'more_in_graph',
|
|
556
|
+
graph_id: bestGraphId,
|
|
557
|
+
graph_display: display,
|
|
558
|
+
remaining_count: maxRemainder,
|
|
559
|
+
theme: bestGraphTheme,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
else if (status === 'thin' || status === 'empty') {
|
|
563
|
+
// honest_thin ONLY when status is thin or empty
|
|
564
|
+
let relevantCandidates = [];
|
|
565
|
+
try {
|
|
566
|
+
relevantCandidates = getRelevantGraphs(query);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
relevantCandidates = [];
|
|
570
|
+
}
|
|
571
|
+
const candidateGraphs = relevantCandidates.map(c => c.graph).filter(Boolean);
|
|
572
|
+
if (candidateGraphs.length === 0 && catalog?.length > 0) {
|
|
573
|
+
candidateGraphs.push(...catalog);
|
|
574
|
+
}
|
|
575
|
+
const closest = candidateGraphs.find(g => !searchedGraphIds.has(g.graph_id)) || candidateGraphs[0];
|
|
576
|
+
if (closest) {
|
|
577
|
+
const closestDisplay = buildDisplayName(closest);
|
|
578
|
+
const primarySearched = searchedGraphs[0];
|
|
579
|
+
const primaryGid = typeof primarySearched === 'string' ? primarySearched : primarySearched?.graph_id;
|
|
580
|
+
const primaryMeta = catalog.find(g => g.graph_id === primaryGid);
|
|
581
|
+
const primaryDisplay = primaryMeta ? buildDisplayName(primaryMeta) : (primaryGid || closestDisplay);
|
|
582
|
+
nextMoves.thread = {
|
|
583
|
+
kind: 'honest_thin',
|
|
584
|
+
graph_id: primaryGid || closest.graph_id,
|
|
585
|
+
graph_display: primaryDisplay,
|
|
586
|
+
adjacent: {
|
|
587
|
+
graph_id: closest.graph_id,
|
|
588
|
+
graph_display: closestDisplay,
|
|
589
|
+
reason: closest.headline || closest.domain || closest.name,
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
// If no candidate exists, thread is undefined (no fabrication)
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// status is 'ok' with 0 remainder everywhere: check for unsearched adjacent room
|
|
597
|
+
let relevantCandidates = [];
|
|
598
|
+
try {
|
|
599
|
+
relevantCandidates = getRelevantGraphs(query);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
relevantCandidates = [];
|
|
603
|
+
}
|
|
604
|
+
const unsearched = relevantCandidates.find(cand => cand.graph && !searchedGraphIds.has(cand.graph.graph_id) && (cand.score ?? 0) >= 0.10);
|
|
605
|
+
if (unsearched) {
|
|
606
|
+
const adjG = unsearched.graph;
|
|
607
|
+
const adjDisplay = buildDisplayName(adjG);
|
|
608
|
+
const primarySearched = searchedGraphs[0];
|
|
609
|
+
const primaryGid = typeof primarySearched === 'string' ? primarySearched : primarySearched?.graph_id;
|
|
610
|
+
const primaryMeta = catalog.find(g => g.graph_id === primaryGid);
|
|
611
|
+
const primaryDisplay = primaryMeta ? buildDisplayName(primaryMeta) : (primaryGid || adjDisplay);
|
|
612
|
+
nextMoves.thread = {
|
|
613
|
+
kind: 'adjacent_room',
|
|
614
|
+
graph_id: primaryGid || adjG.graph_id,
|
|
615
|
+
graph_display: primaryDisplay,
|
|
616
|
+
adjacent: {
|
|
617
|
+
graph_id: adjG.graph_id,
|
|
618
|
+
graph_display: adjDisplay,
|
|
619
|
+
reason: adjG.headline || adjG.domain || adjG.description || adjG.name,
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
// If no unsearched relevant room, thread is undefined (drop the line, no fabrication)
|
|
624
|
+
}
|
|
625
|
+
// ── 2. Specific (Go specific: brands, statistics_source, expert) ──
|
|
626
|
+
const specific = {};
|
|
627
|
+
// Brands: extract competitive landscape or top 2 brand entities present in returned rows
|
|
628
|
+
if (options?.competitiveLandscape && options.competitiveLandscape.length > 0) {
|
|
629
|
+
specific.brands = options.competitiveLandscape.slice(0, 2);
|
|
630
|
+
}
|
|
631
|
+
else if (options?.isBrandTracker) {
|
|
632
|
+
// For brand reports, if competitiveLandscape is empty or omitted, leave specific.brands undefined — never pad!
|
|
633
|
+
specific.brands = undefined;
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const tokens = specificQueryTokens(query);
|
|
637
|
+
const onTopicRows = rows.filter(r => rowMatchesQueryTokens(r, tokens, catalog) || (rowScore(r) >= 0.75 * (TIER_NOMINAL_SCORE[resolveRowTier(r, searchedGraphs, catalog)] ?? 0.8)));
|
|
638
|
+
const brandSourceRows = onTopicRows.length > 0 ? onTopicRows : rows;
|
|
639
|
+
const brandCounts = new Map();
|
|
640
|
+
for (const r of brandSourceRows) {
|
|
641
|
+
const brandList = [];
|
|
642
|
+
if (Array.isArray(r.brandNames))
|
|
643
|
+
brandList.push(...r.brandNames);
|
|
644
|
+
if (Array.isArray(r.brands))
|
|
645
|
+
brandList.push(...r.brands);
|
|
646
|
+
if (typeof r.brand === 'string' && r.brand)
|
|
647
|
+
brandList.push(r.brand);
|
|
648
|
+
if (typeof r.company === 'string' && r.company)
|
|
649
|
+
brandList.push(r.company);
|
|
650
|
+
if (Array.isArray(r.entities?.brands))
|
|
651
|
+
brandList.push(...r.entities.brands);
|
|
652
|
+
for (const b of brandList) {
|
|
653
|
+
if (typeof b === 'string' && b.trim().length > 1) {
|
|
654
|
+
const cleaned = b.trim();
|
|
655
|
+
brandCounts.set(cleaned, (brandCounts.get(cleaned) || 0) + 1);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (brandCounts.size > 0) {
|
|
660
|
+
const sortedBrands = [...brandCounts.entries()]
|
|
661
|
+
.filter(([b]) => {
|
|
662
|
+
const bLow = b.toLowerCase();
|
|
663
|
+
const qLow = query.toLowerCase();
|
|
664
|
+
return bLow !== qLow && !qLow.includes(bLow) && !bLow.includes(qLow);
|
|
665
|
+
})
|
|
666
|
+
.sort((a, b) => b[1] - a[1])
|
|
667
|
+
.map(([brand]) => brand)
|
|
668
|
+
.slice(0, 2);
|
|
669
|
+
if (sortedBrands.length > 0) {
|
|
670
|
+
specific.brands = sortedBrands;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// Statistics source: supplemental source or scored stat dataset
|
|
675
|
+
if (options?.earningsStatsSource) {
|
|
676
|
+
specific.statistics_source = options.earningsStatsSource;
|
|
677
|
+
}
|
|
678
|
+
else if (options?.earningsTicker || options?.isBrandTracker) {
|
|
679
|
+
// Format with brand display name: "Lululemon's latest earnings and financial results"
|
|
680
|
+
const brandName = options?.brandDisplayName || options?.brandName || query || 'the company';
|
|
681
|
+
const cleanName = brandName.trim();
|
|
682
|
+
let possessiveName = `${cleanName}'s`;
|
|
683
|
+
if (cleanName.endsWith("'s") || cleanName.endsWith("’s")) {
|
|
684
|
+
possessiveName = cleanName;
|
|
685
|
+
}
|
|
686
|
+
else if (cleanName.endsWith("s") || cleanName.endsWith("S")) {
|
|
687
|
+
possessiveName = `${cleanName}'`;
|
|
688
|
+
}
|
|
689
|
+
if (options?.earningsTicker) {
|
|
690
|
+
specific.statistics_source = `${possessiveName} latest earnings and financial results`;
|
|
691
|
+
}
|
|
692
|
+
else if (options?.isBrandTracker) {
|
|
693
|
+
// If no competitor brands were found, fall back to market demand signals so line 2 is never empty
|
|
694
|
+
if (!specific.brands || specific.brands.length === 0) {
|
|
695
|
+
specific.statistics_source = 'Google Trends and market demand signals';
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
const qLower = query.toLowerCase();
|
|
701
|
+
const isStatOrMarketShaped = suggestedAction !== undefined ||
|
|
702
|
+
isDemandShaped(query) ||
|
|
703
|
+
options?.knownBrand !== undefined ||
|
|
704
|
+
(specific.brands && specific.brands.length > 0) ||
|
|
705
|
+
/(?:market|spend|sales|growth|size|volume|rate|adoption|share|stats?|numbers?|forecast|demographics?|economic|inflation|pricing|consumer|retail|cpg|beauty|fashion|auto|tech|work|travel|food|drink|beverage|culture|trends?|brand|performance|activity)/i.test(qLower);
|
|
706
|
+
if (isStatOrMarketShaped) {
|
|
707
|
+
if (/(?:retail|spend|sales|commerce|shopping|store|grocery|cpg|consumer)/i.test(qLower)) {
|
|
708
|
+
specific.statistics_source = 'Census retail trade and spending data';
|
|
709
|
+
}
|
|
710
|
+
else if (/(?:search|demand|interest|popular|google|volume|buzz|social)/i.test(qLower)) {
|
|
711
|
+
specific.statistics_source = 'Google Trends search volume and breakout queries';
|
|
712
|
+
}
|
|
713
|
+
else if (/(?:employment|labor|job|wage|worker|workplace|talent|hiring)/i.test(qLower)) {
|
|
714
|
+
specific.statistics_source = 'BLS labor and employment metrics';
|
|
715
|
+
}
|
|
716
|
+
else if (/(?:economic|inflation|cpi|interest\s+rate|gdp|macro|fed|recession)/i.test(qLower)) {
|
|
717
|
+
specific.statistics_source = 'FRED macroeconomic series';
|
|
718
|
+
}
|
|
719
|
+
else if (/(?:beauty|fashion|apparel|luxury|sport|wellness|food|beverage)/i.test(qLower)) {
|
|
720
|
+
specific.statistics_source = 'Census and Google Trends market demand data';
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
specific.statistics_source = 'Census and FRED market statistics';
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Expert: first Active analyst whose graph was searched or scored >= threshold in routing
|
|
728
|
+
const activeAnalysts = analysts.filter(a => {
|
|
729
|
+
const st = (a.status || a.Status || '').toLowerCase().trim();
|
|
730
|
+
return !st || st === 'active';
|
|
731
|
+
});
|
|
732
|
+
const isCurrentAnalyst = (a) => {
|
|
733
|
+
if (!options?.currentAnalystId)
|
|
734
|
+
return false;
|
|
735
|
+
const cur = options.currentAnalystId.toLowerCase().trim();
|
|
736
|
+
return (a.analyst_id && a.analyst_id.toLowerCase().trim() === cur) ||
|
|
737
|
+
(a.name && a.name.toLowerCase().trim() === cur);
|
|
738
|
+
};
|
|
739
|
+
const matchedAnalyst = activeAnalysts.find(a => {
|
|
740
|
+
if (isCurrentAnalyst(a))
|
|
741
|
+
return false;
|
|
742
|
+
const aSlug = (a.analyst_id || '').toLowerCase().trim();
|
|
743
|
+
if (searchedGraphIds.has(aSlug))
|
|
744
|
+
return true;
|
|
745
|
+
const aName = (a.name || '').toLowerCase();
|
|
746
|
+
const aDesc = (a.description || '').toLowerCase();
|
|
747
|
+
if (tokens.some(t => aName.includes(t) || aDesc.includes(t)))
|
|
748
|
+
return true;
|
|
749
|
+
return false;
|
|
750
|
+
});
|
|
751
|
+
if (matchedAnalyst) {
|
|
752
|
+
specific.expert = {
|
|
753
|
+
analyst_id: matchedAnalyst.analyst_id,
|
|
754
|
+
display_name: matchedAnalyst.name,
|
|
755
|
+
reason: matchedAnalyst.description ? `covers ${matchedAnalyst.description.slice(0, 50).trim()} directly` : 'covers this domain directly',
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
if (Object.keys(specific).length > 0) {
|
|
759
|
+
nextMoves.specific = specific;
|
|
760
|
+
}
|
|
761
|
+
return nextMoves;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Generate consult-specific next moves envelope adhering to Render Spec 1.3.
|
|
765
|
+
*
|
|
766
|
+
* Sentence 1 (Thread): Expert's 1st-person next move (using expert_thread.next_angle,
|
|
767
|
+
* with fallback to uncited themes / remaining count; out-of-lane declination uses top referral).
|
|
768
|
+
* Sentence 2 (Shelf): Fodda platform merchandising via catalogCache.getRelevantGraphs()
|
|
769
|
+
* (strictly excluding the expert's own graph).
|
|
770
|
+
* Sentence 3 (Scope): Deliverable / project scope line.
|
|
771
|
+
*/
|
|
772
|
+
export function generateConsultNextMoves(result, query, analystId, options, catalog = getGraphs(), analysts = getAnalysts()) {
|
|
773
|
+
const nextMoves = {
|
|
774
|
+
scope_prompt: true,
|
|
775
|
+
presentation: 'internal',
|
|
776
|
+
};
|
|
777
|
+
if (options?.knownBrand) {
|
|
778
|
+
nextMoves.known_brand = options.knownBrand;
|
|
779
|
+
}
|
|
780
|
+
const cleanAnalystId = (analystId || '').toLowerCase().trim();
|
|
781
|
+
const matchedAnalyst = analysts.find(a => {
|
|
782
|
+
const aId = (a.analyst_id || a.id || a.slug || '').toLowerCase().trim();
|
|
783
|
+
const aName = (a.name || '').toLowerCase().trim();
|
|
784
|
+
return aId === cleanAnalystId || aName === cleanAnalystId;
|
|
785
|
+
});
|
|
786
|
+
const expertDisplayName = matchedAnalyst?.name || analystId;
|
|
787
|
+
const expertGraphId = matchedAnalyst?.analyst_id || analystId;
|
|
788
|
+
const expertThread = result?.expert_thread || {};
|
|
789
|
+
// Read next_angle strictly from expert_thread.next_angle per §2.A.5
|
|
790
|
+
const nextAngleRaw = expertThread?.next_angle;
|
|
791
|
+
const uncitedThemes = Array.isArray(expertThread?.uncited_themes) ? expertThread.uncited_themes : [];
|
|
792
|
+
const coverage = (result?.coverage || 'ok').toLowerCase().trim();
|
|
793
|
+
const isOutOfLane = coverage === 'out' || expertThread?.on_topic_total === 0;
|
|
794
|
+
// ── Sentence 1: Thread (Expert's 1st Person / Referral on decline) ──
|
|
795
|
+
let threadSentence = '';
|
|
796
|
+
if (isOutOfLane) {
|
|
797
|
+
const activeReferrals = Array.isArray(result?.referrals)
|
|
798
|
+
? result.referrals.filter((r) => {
|
|
799
|
+
const rStatus = (r.status || r.Status || '').toLowerCase().trim();
|
|
800
|
+
return !rStatus || rStatus === 'active';
|
|
801
|
+
})
|
|
802
|
+
: [];
|
|
803
|
+
if (activeReferrals.length > 0) {
|
|
804
|
+
const topRef = activeReferrals[0];
|
|
805
|
+
const refName = topRef.name || topRef.curator || 'another expert';
|
|
806
|
+
const refReason = topRef.reason ? `who covers ${topRef.reason}` : 'who covers this topic directly';
|
|
807
|
+
threadSentence = `For inquiries on this topic, I'd recommend connecting with ${refName} ${refReason}.`;
|
|
808
|
+
nextMoves.thread = {
|
|
809
|
+
kind: 'adjacent_room',
|
|
810
|
+
graph_id: topRef.id || topRef.analyst_id || topRef.slug,
|
|
811
|
+
graph_display: refName,
|
|
812
|
+
adjacent: {
|
|
813
|
+
graph_id: topRef.id || topRef.analyst_id || topRef.slug,
|
|
814
|
+
graph_display: refName,
|
|
815
|
+
reason: topRef.reason || 'related expertise',
|
|
816
|
+
},
|
|
817
|
+
text: threadSentence,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
threadSentence = `That's what Fodda holds on this right now; if you have a related topic in my graph, let me know.`;
|
|
822
|
+
nextMoves.thread = {
|
|
823
|
+
kind: 'honest_thin',
|
|
824
|
+
graph_id: expertGraphId,
|
|
825
|
+
graph_display: expertDisplayName,
|
|
826
|
+
text: threadSentence,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
// Next angle token check per §2.A.5: must share >=1 content token (>=3 chars) with sources_used or uncited_themes
|
|
832
|
+
let nextAngleValid = false;
|
|
833
|
+
if (typeof nextAngleRaw === 'string' && nextAngleRaw.trim().length > 0) {
|
|
834
|
+
const angleTokens = specificQueryTokens(nextAngleRaw);
|
|
835
|
+
const sourceTitles = (result?.sources_used || []).map((s) => (s.title || s.name || '').toLowerCase());
|
|
836
|
+
const themeTexts = uncitedThemes.map((t) => t.toLowerCase());
|
|
837
|
+
const groundingCorpus = [...sourceTitles, ...themeTexts].join(' ');
|
|
838
|
+
const hasSharedToken = angleTokens.some(tok => tok.length >= 3 && groundingCorpus.includes(tok));
|
|
839
|
+
if (hasSharedToken || (sourceTitles.length === 0 && themeTexts.length === 0)) {
|
|
840
|
+
nextAngleValid = true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (nextAngleValid && typeof nextAngleRaw === 'string') {
|
|
844
|
+
let cleanNextAngle = nextAngleRaw.trim();
|
|
845
|
+
if (!/[.!?]$/.test(cleanNextAngle)) {
|
|
846
|
+
cleanNextAngle += '.';
|
|
847
|
+
}
|
|
848
|
+
threadSentence = cleanNextAngle;
|
|
849
|
+
nextMoves.thread = {
|
|
850
|
+
kind: 'expert_thread',
|
|
851
|
+
graph_id: expertGraphId,
|
|
852
|
+
graph_display: expertDisplayName,
|
|
853
|
+
next_angle: cleanNextAngle,
|
|
854
|
+
text: threadSentence,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
else if (uncitedThemes.length > 0) {
|
|
858
|
+
const topTheme = uncitedThemes[0];
|
|
859
|
+
threadSentence = `If you want to stay on this, we can look into ${topTheme} in my graph.`;
|
|
860
|
+
nextMoves.thread = {
|
|
861
|
+
kind: 'expert_thread',
|
|
862
|
+
graph_id: expertGraphId,
|
|
863
|
+
graph_display: expertDisplayName,
|
|
864
|
+
theme: topTheme,
|
|
865
|
+
uncited_themes: uncitedThemes,
|
|
866
|
+
text: threadSentence,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
const onTopicTotal = typeof expertThread?.on_topic_total === 'number' ? expertThread.on_topic_total : undefined;
|
|
871
|
+
const citedCount = typeof expertThread?.cited_count === 'number' ? expertThread.cited_count : (Array.isArray(result?.sources_used) ? result.sources_used.length : 0);
|
|
872
|
+
let remainder = (onTopicTotal !== undefined && onTopicTotal > citedCount) ? (onTopicTotal - citedCount) : 0;
|
|
873
|
+
if (remainder >= 10) {
|
|
874
|
+
threadSentence = `There are many more trends in my graph exploring this topic — want me to pull those?`;
|
|
875
|
+
}
|
|
876
|
+
else if (remainder > 0) {
|
|
877
|
+
threadSentence = `There are several more trends in my graph exploring this topic — want me to pull those?`;
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
threadSentence = `If you want to stay on this, we can explore deeper signals in my graph.`;
|
|
881
|
+
}
|
|
882
|
+
nextMoves.thread = {
|
|
883
|
+
kind: 'expert_thread',
|
|
884
|
+
graph_id: expertGraphId,
|
|
885
|
+
graph_display: expertDisplayName,
|
|
886
|
+
remaining_count: remainder > 0 ? remainder : undefined,
|
|
887
|
+
text: threadSentence,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// ── Sentence 2: Shelf (Platform Voice Merchandising) ──
|
|
892
|
+
let relevantCandidates = [];
|
|
893
|
+
try {
|
|
894
|
+
relevantCandidates = getRelevantGraphs(query);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
relevantCandidates = [];
|
|
898
|
+
}
|
|
899
|
+
const expertOwnIds = new Set([cleanAnalystId, expertGraphId.toLowerCase()]);
|
|
900
|
+
if (matchedAnalyst) {
|
|
901
|
+
if (matchedAnalyst.analyst_id)
|
|
902
|
+
expertOwnIds.add(matchedAnalyst.analyst_id.toLowerCase());
|
|
903
|
+
if (matchedAnalyst.id)
|
|
904
|
+
expertOwnIds.add(String(matchedAnalyst.id).toLowerCase());
|
|
905
|
+
if (matchedAnalyst.slug)
|
|
906
|
+
expertOwnIds.add(String(matchedAnalyst.slug).toLowerCase());
|
|
907
|
+
}
|
|
908
|
+
const SHELF_RELEVANCE_FLOOR = 0.10;
|
|
909
|
+
const shelfCandidateGraphs = [];
|
|
910
|
+
for (const cand of relevantCandidates) {
|
|
911
|
+
if ((cand.score ?? 0) < SHELF_RELEVANCE_FLOOR)
|
|
912
|
+
continue;
|
|
913
|
+
const g = cand.graph;
|
|
914
|
+
if (!g)
|
|
915
|
+
continue;
|
|
916
|
+
const gid = (g.graph_id || g.id || '').toLowerCase();
|
|
917
|
+
if (expertOwnIds.has(gid))
|
|
918
|
+
continue;
|
|
919
|
+
if (g.curator && g.curator.toLowerCase() === expertDisplayName.toLowerCase())
|
|
920
|
+
continue;
|
|
921
|
+
if (g.name && g.name.toLowerCase() === expertDisplayName.toLowerCase())
|
|
922
|
+
continue;
|
|
923
|
+
if (shelfCandidateGraphs.length < 2 && !shelfCandidateGraphs.some(sg => sg.graph_id === g.graph_id)) {
|
|
924
|
+
shelfCandidateGraphs.push(g);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
let shelfSentence = '';
|
|
928
|
+
const hasDeliverableOffering = Array.isArray(matchedAnalyst?.offerings) && matchedAnalyst.offerings.length > 0;
|
|
929
|
+
if (shelfCandidateGraphs.length > 0) {
|
|
930
|
+
const shelfGraphs = shelfCandidateGraphs.map(g => ({
|
|
931
|
+
graph_id: g.graph_id,
|
|
932
|
+
graph_display: buildDisplayName(g),
|
|
933
|
+
domain: g.domain,
|
|
934
|
+
headline: g.headline,
|
|
935
|
+
reason: g.headline || g.domain || g.name,
|
|
936
|
+
}));
|
|
937
|
+
nextMoves.shelf = shelfGraphs;
|
|
938
|
+
if (shelfGraphs.length >= 2 && shelfGraphs[0]?.graph_display && shelfGraphs[1]?.graph_display) {
|
|
939
|
+
shelfSentence = `Fodda also holds trend signals on this in ${shelfGraphs[0].graph_display} and ${shelfGraphs[1].graph_display} if you want the wider picture`;
|
|
940
|
+
}
|
|
941
|
+
else if (shelfGraphs.length >= 1 && shelfGraphs[0]?.graph_display) {
|
|
942
|
+
shelfSentence = `Fodda also holds trend signals on this in ${shelfGraphs[0].graph_display} if you want the wider picture`;
|
|
943
|
+
}
|
|
944
|
+
if (hasDeliverableOffering) {
|
|
945
|
+
shelfSentence += `, and ${expertDisplayName} takes scoped briefs on this if you need a deliverable.`;
|
|
946
|
+
}
|
|
947
|
+
else {
|
|
948
|
+
shelfSentence += `.`;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
// Empty candidate list — omit sentence 2 per Brief 1.46.29
|
|
953
|
+
shelfSentence = '';
|
|
954
|
+
nextMoves.shelf = undefined;
|
|
955
|
+
}
|
|
956
|
+
const specific = {};
|
|
957
|
+
if (nextMoves.shelf && nextMoves.shelf.length > 0) {
|
|
958
|
+
specific.shelf_graphs = nextMoves.shelf;
|
|
959
|
+
}
|
|
960
|
+
if (Array.isArray(expertThread?.brands) && expertThread.brands.length > 0) {
|
|
961
|
+
specific.brands = expertThread.brands.slice(0, 2);
|
|
962
|
+
}
|
|
963
|
+
if (Array.isArray(result?.referrals) && result.referrals.length > 0) {
|
|
964
|
+
const topRef = result.referrals[0];
|
|
965
|
+
specific.expert = {
|
|
966
|
+
analyst_id: topRef.id || topRef.analyst_id || topRef.slug,
|
|
967
|
+
display_name: topRef.name || topRef.curator,
|
|
968
|
+
reason: topRef.reason || 'related expertise',
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
if (Object.keys(specific).length > 0) {
|
|
972
|
+
nextMoves.specific = specific;
|
|
973
|
+
}
|
|
974
|
+
// ── Sentence 3: Scope (Platform Voice, Render Spec 1.2 Copy) ──
|
|
975
|
+
let scopeSentence = '';
|
|
976
|
+
if (options?.knownBrand) {
|
|
977
|
+
scopeSentence = `Want this cut to ${options.knownBrand} specifically?`;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
scopeSentence = `If you tell me the brand or brief you're working on, I'll cut this to that.`;
|
|
981
|
+
}
|
|
982
|
+
nextMoves.consult_envelope = {
|
|
983
|
+
thread_line: threadSentence,
|
|
984
|
+
shelf_line: shelfSentence || undefined,
|
|
985
|
+
scope_line: scopeSentence,
|
|
986
|
+
};
|
|
987
|
+
return nextMoves;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Render the consult 3-sentence closing envelope complying with Render Spec 1.3.
|
|
991
|
+
*/
|
|
992
|
+
export function renderConsultClosingEnvelope(nextMoves) {
|
|
993
|
+
if (!nextMoves?.consult_envelope) {
|
|
994
|
+
return renderClosingBlock(nextMoves);
|
|
995
|
+
}
|
|
996
|
+
const { thread_line, shelf_line, scope_line } = nextMoves.consult_envelope;
|
|
997
|
+
const lines = [thread_line, shelf_line, scope_line].filter((l) => Boolean(l));
|
|
998
|
+
return {
|
|
999
|
+
lines,
|
|
1000
|
+
text: lines.join(' '),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Universal closing block renderer supporting both general research (Render Spec 1.2/1.3)
|
|
1005
|
+
* and consult-specific responses (Render Spec 1.3).
|
|
1006
|
+
*/
|
|
1007
|
+
export function renderClosingBlock(nextMoves) {
|
|
1008
|
+
if (!nextMoves)
|
|
1009
|
+
return { lines: [], text: '' };
|
|
1010
|
+
if (nextMoves.consult_envelope) {
|
|
1011
|
+
const { thread_line, shelf_line, scope_line } = nextMoves.consult_envelope;
|
|
1012
|
+
const lines = [thread_line, shelf_line, scope_line].filter((l) => Boolean(l));
|
|
1013
|
+
return {
|
|
1014
|
+
lines,
|
|
1015
|
+
text: lines.join(' '),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const lines = [];
|
|
1019
|
+
// Line 1: Pull the thread
|
|
1020
|
+
if (nextMoves.thread) {
|
|
1021
|
+
const t = nextMoves.thread;
|
|
1022
|
+
if (t.text) {
|
|
1023
|
+
lines.push(t.text);
|
|
1024
|
+
}
|
|
1025
|
+
else if (t.kind === 'more_in_graph' && t.remaining_count && t.remaining_count > 0) {
|
|
1026
|
+
const countPhrase = t.remaining_count >= 10 ? 'many more trends' : 'several more trends';
|
|
1027
|
+
lines.push(`There are ${countPhrase} in ${t.graph_display || 'the graph'} exploring ${t.theme || 'this topic'} — want me to pull those?`);
|
|
1028
|
+
}
|
|
1029
|
+
else if (t.kind === 'adjacent_room' && t.adjacent) {
|
|
1030
|
+
lines.push(`We also have related coverage in ${t.adjacent.graph_display} — want me to pull that?`);
|
|
1031
|
+
}
|
|
1032
|
+
else if (t.kind === 'honest_thin' && t.adjacent) {
|
|
1033
|
+
lines.push(`That's what Fodda holds on this right now; the closest adjacent hit is ${t.adjacent.reason || 'related research'} in ${t.adjacent.graph_display} — want it?`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// Line 2: Go specific
|
|
1037
|
+
if (nextMoves.specific) {
|
|
1038
|
+
const s = nextMoves.specific;
|
|
1039
|
+
const options = [];
|
|
1040
|
+
if (s.brands && s.brands.length > 0) {
|
|
1041
|
+
options.push(`look into ${s.brands.join(' or ')}`);
|
|
1042
|
+
}
|
|
1043
|
+
if (s.statistics_source) {
|
|
1044
|
+
options.push(`pull quantitative data from ${s.statistics_source}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (s.expert && options.length < 2) {
|
|
1047
|
+
options.push(`consult ${s.expert.display_name}`);
|
|
1048
|
+
}
|
|
1049
|
+
if (options.length > 0) {
|
|
1050
|
+
lines.push(`Or we can ${options.slice(0, 2).join(' or ')}.`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// Line 3: Scope to the job
|
|
1054
|
+
if (nextMoves.known_brand) {
|
|
1055
|
+
lines.push(`Want this cut to ${nextMoves.known_brand} specifically?`);
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
lines.push(`If you tell me the brand or brief you're working on, I'll cut this to that.`);
|
|
1059
|
+
}
|
|
1060
|
+
return { lines, text: lines.join(' ') };
|
|
1061
|
+
}
|
|
1062
|
+
//# sourceMappingURL=coverageRelevance.js.map
|