@witchpot/gmc 0.1.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/CHANGELOG.md +19 -0
- package/README.md +118 -0
- package/bin/gmc.mjs +50 -0
- package/package.json +42 -0
- package/scripts/check-release-readiness.mjs +99 -0
- package/scripts/smoke-beta.mjs +220 -0
- package/scripts/smoke-pack.mjs +75 -0
- package/src/args.js +99 -0
- package/src/browser-login.js +255 -0
- package/src/config.js +320 -0
- package/src/constants.js +66 -0
- package/src/help.js +60 -0
- package/src/main.js +141 -0
- package/src/output.js +3 -0
- package/src/projection.js +163 -0
- package/src/requests.js +600 -0
- package/src/workflows.js +1862 -0
package/src/workflows.js
ADDED
|
@@ -0,0 +1,1862 @@
|
|
|
1
|
+
import { COMPARE_FOCUS_SECTIONS, EXIT_API } from "./constants.js";
|
|
2
|
+
import { buildRequestHeaders, createRequestUrl, filterShowcasesByDeadline } from "./requests.js";
|
|
3
|
+
import { writeJsonError } from "./output.js";
|
|
4
|
+
|
|
5
|
+
export async function executeRequestPlan(baseUrl, request, { apiKey }) {
|
|
6
|
+
if (request.kind === "resolve") {
|
|
7
|
+
return executeResolveRequest(baseUrl, request, { apiKey });
|
|
8
|
+
}
|
|
9
|
+
if (request.kind === "benchmark") {
|
|
10
|
+
return executeBenchmarkRequest(baseUrl, request, { apiKey });
|
|
11
|
+
}
|
|
12
|
+
if (request.kind === "compare") {
|
|
13
|
+
return executeCompareRequest(baseUrl, request, { apiKey });
|
|
14
|
+
}
|
|
15
|
+
if (request.kind === "reviewPatterns") {
|
|
16
|
+
return executeReviewPatternsRequest(baseUrl, request, { apiKey });
|
|
17
|
+
}
|
|
18
|
+
if (request.kind === "campaignSignals") {
|
|
19
|
+
return executeCampaignSignalsRequest(baseUrl, request, { apiKey });
|
|
20
|
+
}
|
|
21
|
+
if (request.kind === "cohortEvidence") {
|
|
22
|
+
return executeCohortEvidenceRequest(baseUrl, request, { apiKey });
|
|
23
|
+
}
|
|
24
|
+
if (request.kind === "showcaseFit") {
|
|
25
|
+
return executeShowcaseFitRequest(baseUrl, request, { apiKey });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = await fetchJson(createRequestUrl(baseUrl, request), { apiKey });
|
|
29
|
+
return request.transform ? request.transform(response) : response;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function executeCohortEvidenceRequest(baseUrl, request, { apiKey }) {
|
|
33
|
+
const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
|
|
34
|
+
const search = mergeSearchPages(sampled.searches, request.sampling);
|
|
35
|
+
const games = Array.isArray(search?.data) ? search.data : [];
|
|
36
|
+
const analysisResults = request.analysisQuery
|
|
37
|
+
? await Promise.all(games.map(async (game) => {
|
|
38
|
+
const externalId = asString(game?.externalId ?? game?.appid);
|
|
39
|
+
if (!externalId) {
|
|
40
|
+
return {
|
|
41
|
+
game,
|
|
42
|
+
skippedReason: "missing_external_id"
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const analysisResult = await fetchJsonResult(createRequestUrl(baseUrl, {
|
|
47
|
+
path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/analysis`,
|
|
48
|
+
query: request.analysisQuery
|
|
49
|
+
}), { apiKey });
|
|
50
|
+
if (!analysisResult.ok) {
|
|
51
|
+
return {
|
|
52
|
+
game,
|
|
53
|
+
externalId,
|
|
54
|
+
skippedReason: "analysis_request_failed",
|
|
55
|
+
error: analysisResult.error
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
game,
|
|
61
|
+
externalId,
|
|
62
|
+
analysis: analysisResult.payload
|
|
63
|
+
};
|
|
64
|
+
}))
|
|
65
|
+
: games.map((game) => ({
|
|
66
|
+
game,
|
|
67
|
+
externalId: asString(game?.externalId ?? game?.appid)
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
return composeCohortEvidencePayload(request, {
|
|
71
|
+
search,
|
|
72
|
+
searches: sampled.searches,
|
|
73
|
+
searchPageErrors: sampled.pageErrors,
|
|
74
|
+
analysisResults
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function executeResolveRequest(baseUrl, request, { apiKey }) {
|
|
79
|
+
const search = await fetchJson(createRequestUrl(baseUrl, request.searchRequest), { apiKey });
|
|
80
|
+
return composeResolvePayload(request, search);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function executeBenchmarkRequest(baseUrl, request, { apiKey }) {
|
|
84
|
+
const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
|
|
85
|
+
return composeBenchmarkPayload(request, sampled);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function executeCompareRequest(baseUrl, request, { apiKey }) {
|
|
89
|
+
const games = await Promise.all(
|
|
90
|
+
request.externalIds.map(async (externalId) => {
|
|
91
|
+
const detailRequest = {
|
|
92
|
+
path: `/api/v1/steam/games/${encodeURIComponent(externalId)}`,
|
|
93
|
+
query: request.query
|
|
94
|
+
};
|
|
95
|
+
const detail = await fetchJson(createRequestUrl(baseUrl, detailRequest), { apiKey });
|
|
96
|
+
const sections = COMPARE_FOCUS_SECTIONS[request.focus] ?? [];
|
|
97
|
+
const analysis = sections.length
|
|
98
|
+
? await fetchJson(createRequestUrl(baseUrl, {
|
|
99
|
+
path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/analysis`,
|
|
100
|
+
query: {
|
|
101
|
+
...request.query,
|
|
102
|
+
sections: sections.join(",")
|
|
103
|
+
}
|
|
104
|
+
}), { apiKey })
|
|
105
|
+
: undefined;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
externalId,
|
|
109
|
+
detail,
|
|
110
|
+
analysis
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return composeComparePayload(request, games);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function executeReviewPatternsRequest(baseUrl, request, { apiKey }) {
|
|
119
|
+
const sampled = await executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey });
|
|
120
|
+
const search = mergeSearchPages(sampled.searches, request.sampling);
|
|
121
|
+
const games = Array.isArray(search?.data) ? search.data : [];
|
|
122
|
+
const topicResults = await Promise.all(
|
|
123
|
+
games.map(async (game) => {
|
|
124
|
+
const externalId = asString(game?.externalId ?? game?.appid);
|
|
125
|
+
if (!externalId) {
|
|
126
|
+
return {
|
|
127
|
+
game,
|
|
128
|
+
skippedReason: "missing_external_id"
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const topicsUrl = createRequestUrl(baseUrl, {
|
|
133
|
+
path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/review-topics`,
|
|
134
|
+
query: request.topicQuery
|
|
135
|
+
});
|
|
136
|
+
const topicsResult = await fetchJsonResult(topicsUrl, { apiKey });
|
|
137
|
+
if (!topicsResult.ok) {
|
|
138
|
+
return {
|
|
139
|
+
game,
|
|
140
|
+
externalId,
|
|
141
|
+
skippedReason: "review_topics_request_failed",
|
|
142
|
+
error: topicsResult.error
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
game,
|
|
148
|
+
externalId,
|
|
149
|
+
topics: topicsResult.payload
|
|
150
|
+
};
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return composeReviewPatternsPayload(request, {
|
|
155
|
+
search,
|
|
156
|
+
searches: sampled.searches,
|
|
157
|
+
searchPageErrors: sampled.pageErrors,
|
|
158
|
+
topicResults
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function executeCampaignSignalsRequest(baseUrl, request, { apiKey }) {
|
|
163
|
+
const detailRequest = {
|
|
164
|
+
path: `/api/v1/steam/games/${encodeURIComponent(request.externalId)}`,
|
|
165
|
+
query: request.detailQuery
|
|
166
|
+
};
|
|
167
|
+
const detail = await fetchJson(createRequestUrl(baseUrl, detailRequest), { apiKey });
|
|
168
|
+
return composeCampaignSignalsPayload(request, { detail });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function executeShowcaseFitRequest(baseUrl, request, { apiKey }) {
|
|
172
|
+
const detailRequest = {
|
|
173
|
+
path: `/api/v1/steam/games/${encodeURIComponent(request.externalId)}`,
|
|
174
|
+
query: request.detailQuery
|
|
175
|
+
};
|
|
176
|
+
const detail = await fetchJson(createRequestUrl(baseUrl, detailRequest), { apiKey });
|
|
177
|
+
const tags = request.tagsOverride?.length
|
|
178
|
+
? request.tagsOverride
|
|
179
|
+
: getPreferredShowcaseTags(detail.data);
|
|
180
|
+
const showcaseQuery = {
|
|
181
|
+
...request.showcaseQuery,
|
|
182
|
+
...(tags.length
|
|
183
|
+
? {
|
|
184
|
+
tags: tags.join(","),
|
|
185
|
+
...(request.showcaseQuery.tagMode ? {} : { tagMode: "or" })
|
|
186
|
+
}
|
|
187
|
+
: {})
|
|
188
|
+
};
|
|
189
|
+
const showcasesResponse = await fetchJson(createRequestUrl(baseUrl, {
|
|
190
|
+
path: "/api/v1/showcases",
|
|
191
|
+
query: showcaseQuery
|
|
192
|
+
}), { apiKey });
|
|
193
|
+
const showcases = request.deadlineBefore
|
|
194
|
+
? filterShowcasesByDeadline(showcasesResponse, request.deadlineBefore)
|
|
195
|
+
: showcasesResponse;
|
|
196
|
+
|
|
197
|
+
return composeShowcaseFitPayload(request, {
|
|
198
|
+
detail,
|
|
199
|
+
showcases,
|
|
200
|
+
showcaseQuery,
|
|
201
|
+
tags
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey }) {
|
|
206
|
+
if (!sampling) {
|
|
207
|
+
return {
|
|
208
|
+
searches: [await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey })],
|
|
209
|
+
pageErrors: []
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const firstSearch = await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey });
|
|
214
|
+
const searches = [firstSearch];
|
|
215
|
+
const pageErrors = [];
|
|
216
|
+
let nextOffset = firstSearch?.pagination?.nextOffset;
|
|
217
|
+
|
|
218
|
+
while (nextOffset != null && shouldFetchNextSamplePage(sampling, searches)) {
|
|
219
|
+
const pageRequest = {
|
|
220
|
+
...searchRequest,
|
|
221
|
+
query: {
|
|
222
|
+
...searchRequest.query,
|
|
223
|
+
limit: sampling.pageLimit,
|
|
224
|
+
offset: nextOffset
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const pageResult = await fetchJsonResult(createRequestUrl(baseUrl, pageRequest), { apiKey });
|
|
228
|
+
if (!pageResult.ok) {
|
|
229
|
+
pageErrors.push({
|
|
230
|
+
offset: nextOffset,
|
|
231
|
+
error: pageResult.error
|
|
232
|
+
});
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
searches.push(pageResult.payload);
|
|
237
|
+
nextOffset = pageResult.payload?.pagination?.nextOffset;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
searches,
|
|
242
|
+
pageErrors
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function shouldFetchNextSamplePage(sampling, searches) {
|
|
247
|
+
if (sampling.mode === "sample-pages") return searches.length < sampling.requestedPages;
|
|
248
|
+
const fetchedRows = searches.reduce((sum, search) => sum + asArray(search?.data).length, 0);
|
|
249
|
+
return fetchedRows < sampling.requestedSize;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function composeComparePayload(request, games, options = {}) {
|
|
253
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
254
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
255
|
+
const warnings = uniqueStrings(games.flatMap((game) => [
|
|
256
|
+
...safeWarnings(game.detail),
|
|
257
|
+
...safeWarnings(game.analysis)
|
|
258
|
+
]));
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
data: {
|
|
262
|
+
kind: "evidence_pack",
|
|
263
|
+
source: request.source,
|
|
264
|
+
workflow: "gamesCompare",
|
|
265
|
+
inputs: {
|
|
266
|
+
externalIds: request.externalIds,
|
|
267
|
+
focus: request.focus,
|
|
268
|
+
sections: COMPARE_FOCUS_SECTIONS[request.focus] ?? []
|
|
269
|
+
},
|
|
270
|
+
focus: request.focus,
|
|
271
|
+
externalIds: request.externalIds,
|
|
272
|
+
cohort: {
|
|
273
|
+
included: games.map((game) => ({
|
|
274
|
+
source: request.source,
|
|
275
|
+
externalId: game.externalId,
|
|
276
|
+
title: game.detail?.data?.title,
|
|
277
|
+
evidence: {
|
|
278
|
+
detail: game.detail?.data,
|
|
279
|
+
...(game.analysis ? { analysis: game.analysis.data } : {})
|
|
280
|
+
},
|
|
281
|
+
warnings: uniqueStrings([
|
|
282
|
+
...safeWarnings(game.detail),
|
|
283
|
+
...safeWarnings(game.analysis)
|
|
284
|
+
])
|
|
285
|
+
})),
|
|
286
|
+
skipped: []
|
|
287
|
+
},
|
|
288
|
+
summary: withSummaryBasis({
|
|
289
|
+
targetCount: games.length,
|
|
290
|
+
analysisSections: COMPARE_FOCUS_SECTIONS[request.focus] ?? [],
|
|
291
|
+
comparedExternalIds: request.externalIds,
|
|
292
|
+
warningCount: warnings.length,
|
|
293
|
+
caveats: [
|
|
294
|
+
"Compare output is normalized evidence for selected titles, not a generated verdict.",
|
|
295
|
+
"Use locked metadata and warnings before making comparative claims.",
|
|
296
|
+
"Resolve ambiguous titles before calling compare so each target is source-backed."
|
|
297
|
+
]
|
|
298
|
+
}, "returned_page"),
|
|
299
|
+
games: games.map((game) => ({
|
|
300
|
+
source: request.source,
|
|
301
|
+
externalId: game.externalId,
|
|
302
|
+
detail: game.detail?.data,
|
|
303
|
+
detailMeta: game.detail?.meta,
|
|
304
|
+
...(game.analysis
|
|
305
|
+
? {
|
|
306
|
+
analysis: game.analysis.data,
|
|
307
|
+
analysisMeta: game.analysis.meta
|
|
308
|
+
}
|
|
309
|
+
: {}),
|
|
310
|
+
warnings: uniqueStrings([
|
|
311
|
+
...safeWarnings(game.detail),
|
|
312
|
+
...safeWarnings(game.analysis)
|
|
313
|
+
])
|
|
314
|
+
}))
|
|
315
|
+
},
|
|
316
|
+
meta: {
|
|
317
|
+
generatedAt,
|
|
318
|
+
requestId,
|
|
319
|
+
composedBy: "gmc-cli",
|
|
320
|
+
upstreamRequests: games.flatMap((game) => [
|
|
321
|
+
{
|
|
322
|
+
externalId: game.externalId,
|
|
323
|
+
kind: "detail",
|
|
324
|
+
requestId: game.detail?.meta?.requestId,
|
|
325
|
+
source: game.detail?.meta?.source,
|
|
326
|
+
entitlements: game.detail?.meta?.entitlements
|
|
327
|
+
},
|
|
328
|
+
...(game.analysis
|
|
329
|
+
? [{
|
|
330
|
+
externalId: game.externalId,
|
|
331
|
+
kind: "analysis",
|
|
332
|
+
requestId: game.analysis?.meta?.requestId,
|
|
333
|
+
source: game.analysis?.meta?.source,
|
|
334
|
+
entitlements: game.analysis?.meta?.entitlements
|
|
335
|
+
}]
|
|
336
|
+
: [])
|
|
337
|
+
])
|
|
338
|
+
},
|
|
339
|
+
...(warnings.length ? { warnings } : {})
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function composeResolvePayload(request, search, options = {}) {
|
|
344
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
345
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
346
|
+
const candidates = Array.isArray(search?.data)
|
|
347
|
+
? search.data.map((game, index) => buildResolveCandidate(request, game, index))
|
|
348
|
+
: [];
|
|
349
|
+
const rankedCandidates = candidates.sort((a, b) => b.score - a.score);
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
data: {
|
|
353
|
+
source: request.source,
|
|
354
|
+
query: request.query,
|
|
355
|
+
resolution: summarizeResolution(rankedCandidates),
|
|
356
|
+
candidates: rankedCandidates
|
|
357
|
+
},
|
|
358
|
+
meta: {
|
|
359
|
+
generatedAt,
|
|
360
|
+
requestId,
|
|
361
|
+
composedBy: "gmc-cli",
|
|
362
|
+
upstreamRequests: [
|
|
363
|
+
{
|
|
364
|
+
kind: "search",
|
|
365
|
+
requestId: search?.meta?.requestId,
|
|
366
|
+
source: search?.meta?.source,
|
|
367
|
+
entitlements: search?.meta?.entitlements,
|
|
368
|
+
pagination: search?.pagination
|
|
369
|
+
}
|
|
370
|
+
]
|
|
371
|
+
},
|
|
372
|
+
...(Array.isArray(search?.warnings) && search.warnings.length ? { warnings: search.warnings } : {})
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function composeBenchmarkPayload(request, search, options = {}) {
|
|
377
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
378
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
379
|
+
const searches = Array.isArray(search?.searches) ? search.searches : [search];
|
|
380
|
+
const pageErrors = Array.isArray(search?.pageErrors) ? search.pageErrors : [];
|
|
381
|
+
const mergedSearch = Array.isArray(search?.searches)
|
|
382
|
+
? mergeSearchPages(search.searches, request.sampling)
|
|
383
|
+
: search;
|
|
384
|
+
const games = Array.isArray(mergedSearch?.data) ? mergedSearch.data : [];
|
|
385
|
+
const sample = buildSampleMetadata(request, searches, games, pageErrors);
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
data: {
|
|
389
|
+
kind: "evidence_pack",
|
|
390
|
+
source: request.source,
|
|
391
|
+
workflow: "gamesBenchmark",
|
|
392
|
+
inputs: {
|
|
393
|
+
filters: request.query,
|
|
394
|
+
...(sample ? { sample: buildEvidencePackSample(sample, games.length) } : {
|
|
395
|
+
sample: buildReturnedPageSample(mergedSearch?.pagination, games.length)
|
|
396
|
+
})
|
|
397
|
+
},
|
|
398
|
+
query: request.query,
|
|
399
|
+
summary: withSummaryBasis(
|
|
400
|
+
buildBenchmarkSummary(games, mergedSearch?.pagination, request.source, { sampled: Boolean(sample) }),
|
|
401
|
+
sample ? "bounded_sample" : "returned_page"
|
|
402
|
+
),
|
|
403
|
+
...(sample
|
|
404
|
+
? {
|
|
405
|
+
sampledSummary: withSummaryBasis(
|
|
406
|
+
buildBenchmarkSummary(games, mergedSearch?.pagination, request.source, { sampled: true }),
|
|
407
|
+
"bounded_sample"
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
: {}),
|
|
411
|
+
cohort: {
|
|
412
|
+
included: games.map((game) => ({
|
|
413
|
+
source: request.source,
|
|
414
|
+
externalId: asString(game?.externalId ?? game?.appid),
|
|
415
|
+
title: game?.title ?? game?.name,
|
|
416
|
+
evidence: game
|
|
417
|
+
})),
|
|
418
|
+
skipped: []
|
|
419
|
+
},
|
|
420
|
+
games
|
|
421
|
+
},
|
|
422
|
+
meta: {
|
|
423
|
+
generatedAt,
|
|
424
|
+
requestId,
|
|
425
|
+
composedBy: "gmc-cli",
|
|
426
|
+
...(sample ? { sample } : {}),
|
|
427
|
+
upstreamRequests: buildSearchUpstreamRequests(searches, pageErrors)
|
|
428
|
+
},
|
|
429
|
+
...(mergedSearch?.pagination ? { pagination: mergedSearch.pagination } : {}),
|
|
430
|
+
warnings: uniqueStrings([
|
|
431
|
+
...searches.flatMap((entry) => safeWarnings(entry)),
|
|
432
|
+
...pageErrors.map((entry) => `benchmark sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
|
|
433
|
+
sample
|
|
434
|
+
? "sampledSummary is computed from bounded sampled pages; it is not a full market-size estimate"
|
|
435
|
+
: "benchmark summary is computed from the returned page only; it is not a full market-size estimate"
|
|
436
|
+
])
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
441
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
442
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
443
|
+
const games = Array.isArray(result.search?.data) ? result.search.data : [];
|
|
444
|
+
const searches = Array.isArray(result.searches) ? result.searches : [result.search];
|
|
445
|
+
const searchPageErrors = Array.isArray(result.searchPageErrors) ? result.searchPageErrors : [];
|
|
446
|
+
const topicResults = Array.isArray(result.topicResults) ? result.topicResults : [];
|
|
447
|
+
const normalizedGames = topicResults.map((entry) => buildReviewPatternGame(request, entry));
|
|
448
|
+
const includedGames = normalizedGames.filter((game) => !game.skippedReason);
|
|
449
|
+
const usableGames = includedGames.filter((game) => game.topics.length > 0);
|
|
450
|
+
const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
|
|
451
|
+
const skipped = normalizedGames
|
|
452
|
+
.filter((game) => game.skippedReason)
|
|
453
|
+
.map((game) => ({
|
|
454
|
+
source: request.source,
|
|
455
|
+
externalId: game.externalId,
|
|
456
|
+
appid: game.appid,
|
|
457
|
+
title: game.title,
|
|
458
|
+
reason: game.skippedReason,
|
|
459
|
+
...(game.error ? { error: game.error } : {})
|
|
460
|
+
}));
|
|
461
|
+
const warnings = uniqueStrings([
|
|
462
|
+
...safeWarnings(result.search),
|
|
463
|
+
...searches.flatMap((entry) => safeWarnings(entry)),
|
|
464
|
+
...searchPageErrors.map((entry) => `review pattern sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
|
|
465
|
+
...topicResults.flatMap((entry) => safeWarnings(entry.topics)),
|
|
466
|
+
...topicResults.flatMap((entry) => reviewPatternErrorWarning(entry)),
|
|
467
|
+
"review patterns are computed from the returned cohort page only; they are not full-market review clusters",
|
|
468
|
+
...(usableGames.length < request.minGames
|
|
469
|
+
? [`usable game count ${usableGames.length} is below --min-games ${request.minGames}`]
|
|
470
|
+
: [])
|
|
471
|
+
]);
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
data: {
|
|
475
|
+
kind: "evidence_pack",
|
|
476
|
+
source: request.source,
|
|
477
|
+
workflow: request.compareBy ? "reviewPatternsCompare" : "reviewPatterns",
|
|
478
|
+
inputs: {
|
|
479
|
+
filters: request.query,
|
|
480
|
+
sections: ["review-topics"],
|
|
481
|
+
...(request.sentiment ? { sentiment: request.sentiment } : {}),
|
|
482
|
+
...(request.topic ? { topic: request.topic } : {}),
|
|
483
|
+
...(request.compareBy ? { compareBy: request.compareBy } : {}),
|
|
484
|
+
...(sample
|
|
485
|
+
? { sample: buildEvidencePackSample(sample, usableGames.length) }
|
|
486
|
+
: { sample: buildReturnedPageSample(result.search?.pagination, usableGames.length) })
|
|
487
|
+
},
|
|
488
|
+
cohort: {
|
|
489
|
+
query: request.query,
|
|
490
|
+
minGames: request.minGames,
|
|
491
|
+
maxTopicsPerGame: request.maxTopicsPerGame,
|
|
492
|
+
sentiment: request.sentiment,
|
|
493
|
+
topic: request.topic,
|
|
494
|
+
...(request.compareBy ? { compareBy: request.compareBy } : {}),
|
|
495
|
+
returnedGames: games.length,
|
|
496
|
+
usableGames: usableGames.length,
|
|
497
|
+
skippedGames: skipped.length,
|
|
498
|
+
totalMatches: result.search?.pagination?.total,
|
|
499
|
+
included: usableGames.map((game) => ({
|
|
500
|
+
source: request.source,
|
|
501
|
+
externalId: game.externalId,
|
|
502
|
+
title: game.title,
|
|
503
|
+
evidence: {
|
|
504
|
+
reviews: game.reviews,
|
|
505
|
+
rating: game.rating,
|
|
506
|
+
releaseDate: game.releaseDate,
|
|
507
|
+
coverage: game.coverage,
|
|
508
|
+
topics: game.topics
|
|
509
|
+
}
|
|
510
|
+
})),
|
|
511
|
+
skipped
|
|
512
|
+
},
|
|
513
|
+
summary: withSummaryBasis(
|
|
514
|
+
buildReviewPatternsSummary(request, usableGames, skipped, result.search?.pagination),
|
|
515
|
+
sample ? "bounded_sample" : "returned_page"
|
|
516
|
+
),
|
|
517
|
+
patterns: buildReviewPatterns(usableGames),
|
|
518
|
+
...(request.compareBy ? { segments: buildReviewPatternSegments(usableGames, request.compareBy) } : {}),
|
|
519
|
+
games: includedGames.map((game) => ({
|
|
520
|
+
source: request.source,
|
|
521
|
+
externalId: game.externalId,
|
|
522
|
+
appid: game.appid,
|
|
523
|
+
title: game.title,
|
|
524
|
+
reviews: game.reviews,
|
|
525
|
+
rating: game.rating,
|
|
526
|
+
releaseDate: game.releaseDate,
|
|
527
|
+
coverage: game.coverage,
|
|
528
|
+
topicCount: game.topics.length,
|
|
529
|
+
topics: game.topics
|
|
530
|
+
})),
|
|
531
|
+
skipped
|
|
532
|
+
},
|
|
533
|
+
meta: {
|
|
534
|
+
generatedAt,
|
|
535
|
+
requestId,
|
|
536
|
+
composedBy: "gmc-cli",
|
|
537
|
+
...(sample ? { sample } : {}),
|
|
538
|
+
upstreamRequests: [
|
|
539
|
+
...buildSearchUpstreamRequests(searches, searchPageErrors),
|
|
540
|
+
...topicResults
|
|
541
|
+
.filter((entry) => entry.topics || entry.error)
|
|
542
|
+
.map((entry) => ({
|
|
543
|
+
externalId: entry.externalId,
|
|
544
|
+
kind: "review-topics",
|
|
545
|
+
requestId: entry.topics?.meta?.requestId,
|
|
546
|
+
source: entry.topics?.meta?.source,
|
|
547
|
+
entitlements: entry.topics?.meta?.entitlements,
|
|
548
|
+
...(entry.error ? { error: entry.error } : {})
|
|
549
|
+
}))
|
|
550
|
+
]
|
|
551
|
+
},
|
|
552
|
+
...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
|
|
553
|
+
...(warnings.length ? { warnings } : {})
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function composeCampaignSignalsPayload(request, result, options = {}) {
|
|
558
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
559
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
560
|
+
const detail = result.detail ?? {};
|
|
561
|
+
const game = detail.data ?? {};
|
|
562
|
+
const allSignals = asArray(game?.insights?.marketing?.signals);
|
|
563
|
+
const signals = allSignals.slice(0, request.limit);
|
|
564
|
+
const locked = asArray(detail?.meta?.entitlements?.locked);
|
|
565
|
+
const warnings = uniqueStrings([
|
|
566
|
+
...safeWarnings(detail),
|
|
567
|
+
...(locked.some((key) => String(key).startsWith("marketing."))
|
|
568
|
+
? ["Marketing campaign evidence is partially locked by the caller entitlement."]
|
|
569
|
+
: []),
|
|
570
|
+
"Campaign signals are observed source-backed evidence, not causal attribution or lift measurement."
|
|
571
|
+
]);
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
data: {
|
|
575
|
+
kind: "evidence_pack",
|
|
576
|
+
source: request.source,
|
|
577
|
+
workflow: "campaignSignals",
|
|
578
|
+
inputs: {
|
|
579
|
+
externalIds: [request.externalId],
|
|
580
|
+
sections: ["marketing"],
|
|
581
|
+
limit: request.limit
|
|
582
|
+
},
|
|
583
|
+
game: {
|
|
584
|
+
source: game.source,
|
|
585
|
+
externalId: asString(game.externalId ?? game.appid),
|
|
586
|
+
appid: game.appid,
|
|
587
|
+
title: game.title,
|
|
588
|
+
releaseDate: game.releaseDate,
|
|
589
|
+
coverage: game.coverage
|
|
590
|
+
},
|
|
591
|
+
summary: withSummaryBasis({
|
|
592
|
+
signalCount: signals.length,
|
|
593
|
+
totalSignalsReturnedByDetail: allSignals.length,
|
|
594
|
+
topSignalSources: topValues(signals.map((signal) => signal?.source)),
|
|
595
|
+
topCampaignTypes: topValues(signals.map((signal) => signal?.campaignType)),
|
|
596
|
+
locked: locked.filter((key) => String(key).startsWith("marketing.")),
|
|
597
|
+
caveats: [
|
|
598
|
+
"This pack uses visible fields from the Steam detail API only.",
|
|
599
|
+
"sourceUrl may be absent when the caller only has preview access.",
|
|
600
|
+
"Use future lift workflows before making before/after movement claims."
|
|
601
|
+
]
|
|
602
|
+
}, "returned_detail"),
|
|
603
|
+
signals,
|
|
604
|
+
cohort: {
|
|
605
|
+
included: signals.length
|
|
606
|
+
? [{
|
|
607
|
+
source: request.source,
|
|
608
|
+
externalId: request.externalId,
|
|
609
|
+
title: game.title,
|
|
610
|
+
evidence: {
|
|
611
|
+
signals
|
|
612
|
+
}
|
|
613
|
+
}]
|
|
614
|
+
: [],
|
|
615
|
+
skipped: signals.length
|
|
616
|
+
? []
|
|
617
|
+
: [{
|
|
618
|
+
source: request.source,
|
|
619
|
+
externalId: request.externalId,
|
|
620
|
+
title: game.title,
|
|
621
|
+
reason: locked.some((key) => String(key).startsWith("marketing."))
|
|
622
|
+
? "campaign_signals_locked_or_redacted"
|
|
623
|
+
: "no_campaign_signals"
|
|
624
|
+
}]
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
meta: {
|
|
628
|
+
generatedAt,
|
|
629
|
+
requestId,
|
|
630
|
+
composedBy: "gmc-cli",
|
|
631
|
+
upstreamRequests: [{
|
|
632
|
+
externalId: request.externalId,
|
|
633
|
+
kind: "detail",
|
|
634
|
+
requestId: detail?.meta?.requestId,
|
|
635
|
+
source: detail?.meta?.source,
|
|
636
|
+
entitlements: detail?.meta?.entitlements
|
|
637
|
+
}]
|
|
638
|
+
},
|
|
639
|
+
...(warnings.length ? { warnings } : {})
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
644
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
645
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
646
|
+
const games = Array.isArray(result.search?.data) ? result.search.data : [];
|
|
647
|
+
const searches = Array.isArray(result.searches) ? result.searches : [result.search];
|
|
648
|
+
const searchPageErrors = Array.isArray(result.searchPageErrors) ? result.searchPageErrors : [];
|
|
649
|
+
const analysisResults = Array.isArray(result.analysisResults) ? result.analysisResults : [];
|
|
650
|
+
const entries = analysisResults.map((entry) => buildCohortEvidenceGame(request, entry));
|
|
651
|
+
const included = entries.filter((entry) => !entry.skippedReason);
|
|
652
|
+
const skipped = entries
|
|
653
|
+
.filter((entry) => entry.skippedReason)
|
|
654
|
+
.map((entry) => ({
|
|
655
|
+
source: request.source,
|
|
656
|
+
externalId: entry.externalId,
|
|
657
|
+
appid: entry.appid,
|
|
658
|
+
title: entry.title,
|
|
659
|
+
reason: entry.skippedReason,
|
|
660
|
+
...(entry.error ? { error: entry.error } : {})
|
|
661
|
+
}));
|
|
662
|
+
const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
|
|
663
|
+
const warnings = uniqueStrings([
|
|
664
|
+
...safeWarnings(result.search),
|
|
665
|
+
...searches.flatMap((entry) => safeWarnings(entry)),
|
|
666
|
+
...searchPageErrors.map((entry) => `${request.workflow} sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
|
|
667
|
+
...analysisResults.flatMap((entry) => safeWarnings(entry.analysis)),
|
|
668
|
+
...analysisResults.flatMap((entry) => cohortEvidenceErrorWarning(entry, request.workflow)),
|
|
669
|
+
`${request.workflow} is a CLI-composed evidence pack over bounded visible API data; treat it as sampled evidence, not causal proof or full-market measurement`
|
|
670
|
+
]);
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
data: {
|
|
674
|
+
kind: "evidence_pack",
|
|
675
|
+
source: request.source,
|
|
676
|
+
workflow: request.workflow,
|
|
677
|
+
evidenceType: request.evidenceType,
|
|
678
|
+
inputs: {
|
|
679
|
+
filters: request.query,
|
|
680
|
+
sections: request.sections,
|
|
681
|
+
...(sample ? { sample: buildEvidencePackSample(sample, included.length) } : {
|
|
682
|
+
sample: buildReturnedPageSample(result.search?.pagination, included.length)
|
|
683
|
+
})
|
|
684
|
+
},
|
|
685
|
+
cohort: {
|
|
686
|
+
query: request.query,
|
|
687
|
+
returnedGames: games.length,
|
|
688
|
+
usableGames: included.length,
|
|
689
|
+
skippedGames: skipped.length,
|
|
690
|
+
totalMatches: result.search?.pagination?.total,
|
|
691
|
+
included: included.map((entry) => ({
|
|
692
|
+
source: request.source,
|
|
693
|
+
externalId: entry.externalId,
|
|
694
|
+
title: entry.title,
|
|
695
|
+
evidence: {
|
|
696
|
+
reviews: entry.reviews,
|
|
697
|
+
rating: entry.rating,
|
|
698
|
+
price: entry.price,
|
|
699
|
+
releaseDate: entry.releaseDate,
|
|
700
|
+
coverage: entry.coverage,
|
|
701
|
+
tags: entry.tags,
|
|
702
|
+
patternEvidence: entry.patternEvidence
|
|
703
|
+
}
|
|
704
|
+
})),
|
|
705
|
+
skipped
|
|
706
|
+
},
|
|
707
|
+
summary: withSummaryBasis(
|
|
708
|
+
buildCohortEvidenceSummary(request, included, result.search?.pagination),
|
|
709
|
+
sample ? "bounded_sample" : "returned_page"
|
|
710
|
+
),
|
|
711
|
+
patterns: buildCohortEvidencePatterns(request, included),
|
|
712
|
+
...(request.evidenceType === "pricing" ? { segments: buildPricingSegments(included) } : {}),
|
|
713
|
+
...(request.evidenceType === "trends" || request.evidenceType === "brief" ? { trends: buildTrendSummary(included, result.search?.pagination, request.source) } : {}),
|
|
714
|
+
games: included.map((entry) => ({
|
|
715
|
+
source: request.source,
|
|
716
|
+
externalId: entry.externalId,
|
|
717
|
+
appid: entry.appid,
|
|
718
|
+
title: entry.title,
|
|
719
|
+
reviews: entry.reviews,
|
|
720
|
+
rating: entry.rating,
|
|
721
|
+
price: entry.price,
|
|
722
|
+
releaseDate: entry.releaseDate,
|
|
723
|
+
coverage: entry.coverage,
|
|
724
|
+
tags: entry.tags,
|
|
725
|
+
patternEvidence: entry.patternEvidence
|
|
726
|
+
})),
|
|
727
|
+
skipped
|
|
728
|
+
},
|
|
729
|
+
meta: {
|
|
730
|
+
generatedAt,
|
|
731
|
+
requestId,
|
|
732
|
+
composedBy: "gmc-cli",
|
|
733
|
+
...(sample ? { sample } : {}),
|
|
734
|
+
upstreamRequests: [
|
|
735
|
+
...buildSearchUpstreamRequests(searches, searchPageErrors),
|
|
736
|
+
...analysisResults
|
|
737
|
+
.filter((entry) => entry.analysis || entry.error)
|
|
738
|
+
.map((entry) => ({
|
|
739
|
+
externalId: entry.externalId,
|
|
740
|
+
kind: "analysis",
|
|
741
|
+
requestId: entry.analysis?.meta?.requestId,
|
|
742
|
+
source: entry.analysis?.meta?.source,
|
|
743
|
+
entitlements: entry.analysis?.meta?.entitlements,
|
|
744
|
+
...(entry.error ? { error: entry.error } : {})
|
|
745
|
+
}))
|
|
746
|
+
]
|
|
747
|
+
},
|
|
748
|
+
...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
|
|
749
|
+
...(warnings.length ? { warnings } : {})
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function composeShowcaseFitPayload(request, result, options = {}) {
|
|
754
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
755
|
+
const requestId = options.requestId ?? createCliRequestId();
|
|
756
|
+
const warnings = uniqueStrings([
|
|
757
|
+
...safeWarnings(result.detail),
|
|
758
|
+
...safeWarnings(result.showcases)
|
|
759
|
+
]);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
data: {
|
|
763
|
+
kind: "evidence_pack",
|
|
764
|
+
source: request.source,
|
|
765
|
+
workflow: "showcaseFit",
|
|
766
|
+
inputs: {
|
|
767
|
+
externalIds: [request.externalId],
|
|
768
|
+
filters: result.showcaseQuery,
|
|
769
|
+
sample: buildReturnedPageSample(result.showcases?.pagination, asArray(result.showcases?.data).length)
|
|
770
|
+
},
|
|
771
|
+
externalId: request.externalId,
|
|
772
|
+
game: result.detail?.data,
|
|
773
|
+
showcaseQuery: result.showcaseQuery,
|
|
774
|
+
matchBasis: {
|
|
775
|
+
tags: result.tags,
|
|
776
|
+
status: result.showcaseQuery.status,
|
|
777
|
+
format: result.showcaseQuery.format,
|
|
778
|
+
country: result.showcaseQuery.country,
|
|
779
|
+
deadlineBefore: request.deadlineBefore
|
|
780
|
+
},
|
|
781
|
+
summary: withSummaryBasis(buildShowcaseFitSummary(request, result), "returned_page"),
|
|
782
|
+
matches: result.showcases?.data ?? [],
|
|
783
|
+
showcases: result.showcases?.data ?? [],
|
|
784
|
+
cohort: {
|
|
785
|
+
included: asArray(result.showcases?.data).map((showcase) => ({
|
|
786
|
+
source: "showcase_catalog",
|
|
787
|
+
externalId: asString(showcase?.id),
|
|
788
|
+
title: showcase?.name,
|
|
789
|
+
evidence: showcase
|
|
790
|
+
})),
|
|
791
|
+
skipped: []
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
meta: {
|
|
795
|
+
generatedAt,
|
|
796
|
+
requestId,
|
|
797
|
+
composedBy: "gmc-cli",
|
|
798
|
+
upstreamRequests: [
|
|
799
|
+
{
|
|
800
|
+
externalId: request.externalId,
|
|
801
|
+
kind: "detail",
|
|
802
|
+
requestId: result.detail?.meta?.requestId,
|
|
803
|
+
source: result.detail?.meta?.source,
|
|
804
|
+
entitlements: result.detail?.meta?.entitlements
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
kind: "showcases",
|
|
808
|
+
requestId: result.showcases?.meta?.requestId,
|
|
809
|
+
source: result.showcases?.meta?.source,
|
|
810
|
+
pagination: result.showcases?.pagination
|
|
811
|
+
}
|
|
812
|
+
]
|
|
813
|
+
},
|
|
814
|
+
...(warnings.length ? { warnings } : {})
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function fetchJson(url, { apiKey }) {
|
|
819
|
+
let response;
|
|
820
|
+
try {
|
|
821
|
+
response = await fetch(url, {
|
|
822
|
+
headers: buildRequestHeaders(apiKey)
|
|
823
|
+
});
|
|
824
|
+
} catch (error) {
|
|
825
|
+
writeJsonError({
|
|
826
|
+
code: "CLI_API_ERROR",
|
|
827
|
+
message: `Failed to reach GMC API at ${url.origin}`,
|
|
828
|
+
cause: error instanceof Error ? error.message : "Unknown network failure"
|
|
829
|
+
});
|
|
830
|
+
process.exit(EXIT_API);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const payload = await response.json().catch(() => null);
|
|
834
|
+
|
|
835
|
+
if (!response.ok) {
|
|
836
|
+
process.stdout.write(`${JSON.stringify(payload ?? {
|
|
837
|
+
error: buildHttpApiError(url, response)
|
|
838
|
+
}, null, 2)}\n`);
|
|
839
|
+
process.exit(EXIT_API);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (payload == null) {
|
|
843
|
+
writeJsonError(buildNonJsonApiError(url, response));
|
|
844
|
+
process.exit(EXIT_API);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return payload;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function getPreferredShowcaseTags(game) {
|
|
851
|
+
const rawTags = [
|
|
852
|
+
...getRankedTagNames(game),
|
|
853
|
+
...(Array.isArray(game?.tags) ? game.tags : []),
|
|
854
|
+
...(Array.isArray(game?.genres) ? game.genres : [])
|
|
855
|
+
];
|
|
856
|
+
return uniqueStrings(rawTags.map((tag) => normalizeTagSlug(tag)).filter(Boolean)).slice(0, 6);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function buildResolveCandidate(request, game, index) {
|
|
860
|
+
const title = asString(game?.title ?? game?.name);
|
|
861
|
+
const appid = game?.appid;
|
|
862
|
+
const externalId = asString(game?.externalId ?? appid);
|
|
863
|
+
const normalizedQuery = normalizeResolveText(request.query);
|
|
864
|
+
const normalizedTitle = normalizeResolveText(title);
|
|
865
|
+
const queryTokens = tokenizeResolveText(request.query);
|
|
866
|
+
const titleTokens = tokenizeResolveText(title);
|
|
867
|
+
const tokenOverlap = calculateTokenOverlap(queryTokens, titleTokens);
|
|
868
|
+
const scoreInputs = {
|
|
869
|
+
titleExact: title === request.query,
|
|
870
|
+
normalizedTitleExact: normalizedTitle.length > 0 && normalizedTitle === normalizedQuery,
|
|
871
|
+
titleContainsQuery: normalizedQuery.length > 0 && normalizedTitle.includes(normalizedQuery),
|
|
872
|
+
queryContainsTitle: normalizedTitle.length > 0 && normalizedQuery.includes(normalizedTitle),
|
|
873
|
+
tokenOverlap,
|
|
874
|
+
reviewCount: typeof game?.reviews === "number" ? game.reviews : undefined,
|
|
875
|
+
rating: typeof game?.rating === "number" ? game.rating : undefined,
|
|
876
|
+
coverage: game?.coverage
|
|
877
|
+
};
|
|
878
|
+
const score = calculateResolveScore(scoreInputs, index);
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
source: request.source,
|
|
882
|
+
externalId,
|
|
883
|
+
appid,
|
|
884
|
+
title,
|
|
885
|
+
developer: game?.developer,
|
|
886
|
+
releaseDate: game?.releaseDate,
|
|
887
|
+
reviews: game?.reviews,
|
|
888
|
+
rating: game?.rating,
|
|
889
|
+
price: game?.price,
|
|
890
|
+
tags: game?.tags,
|
|
891
|
+
tagRanks: game?.tagRanks,
|
|
892
|
+
genres: game?.genres,
|
|
893
|
+
categories: game?.categories,
|
|
894
|
+
facets: game?.facets,
|
|
895
|
+
coverage: game?.coverage,
|
|
896
|
+
score,
|
|
897
|
+
confidence: classifyResolveConfidence(score),
|
|
898
|
+
scoreInputs
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function buildBenchmarkSummary(games, pagination, source, options = {}) {
|
|
903
|
+
return {
|
|
904
|
+
sampleSize: games.length,
|
|
905
|
+
totalMatches: pagination?.total,
|
|
906
|
+
reviews: numericSummary(games.map((game) => game?.reviews)),
|
|
907
|
+
rating: numericSummary(games.map((game) => game?.rating)),
|
|
908
|
+
price: numericSummary(games.map((game) => game?.price)),
|
|
909
|
+
releaseYears: countReleaseYears(games),
|
|
910
|
+
priceBuckets: countPriceBuckets(games),
|
|
911
|
+
topTags: topRankedTags(games),
|
|
912
|
+
topGenres: topValues(games.flatMap((game) => asArray(game?.genres))),
|
|
913
|
+
topPlayModes: topValues(games.flatMap((game) => asArray(game?.facets?.playModes))),
|
|
914
|
+
topFeatures: topValues(games.flatMap((game) => asArray(game?.facets?.features))),
|
|
915
|
+
coverage: countCoverage(games),
|
|
916
|
+
topGames: games
|
|
917
|
+
.slice()
|
|
918
|
+
.sort((a, b) => sortableNumber(b?.reviews) - sortableNumber(a?.reviews))
|
|
919
|
+
.slice(0, 5)
|
|
920
|
+
.map((game) => ({
|
|
921
|
+
source,
|
|
922
|
+
externalId: asString(game?.externalId ?? game?.appid),
|
|
923
|
+
appid: game?.appid,
|
|
924
|
+
title: game?.title ?? game?.name,
|
|
925
|
+
reviews: game?.reviews,
|
|
926
|
+
rating: game?.rating,
|
|
927
|
+
releaseDate: game?.releaseDate
|
|
928
|
+
})),
|
|
929
|
+
caveats: options.sampled
|
|
930
|
+
? [
|
|
931
|
+
"Summary uses only the fetched sample rows, not every matching Steam title.",
|
|
932
|
+
"Use larger bounded samples or a future server-side aggregate endpoint before making broad cohort claims.",
|
|
933
|
+
"Visible review volume, rating, tags, price, and coverage are evidence inputs, not causal explanations."
|
|
934
|
+
]
|
|
935
|
+
: [
|
|
936
|
+
"Summary uses only the returned page, not every matching Steam title.",
|
|
937
|
+
"Use pagination.nextOffset for additional pages before making broad cohort claims.",
|
|
938
|
+
"Visible review volume, rating, tags, price, and coverage are evidence inputs, not causal explanations."
|
|
939
|
+
]
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function getRankedTagNames(game) {
|
|
944
|
+
const ranked = asArray(game?.tagRanks)
|
|
945
|
+
.slice()
|
|
946
|
+
.sort((a, b) => sortableNumber(a?.rank) - sortableNumber(b?.rank))
|
|
947
|
+
.map((tag) => asString(tag?.name))
|
|
948
|
+
.filter(Boolean);
|
|
949
|
+
return ranked.length ? ranked : asArray(game?.tags);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function topRankedTags(games, limit = 10) {
|
|
953
|
+
const counts = new Map();
|
|
954
|
+
for (const game of games) {
|
|
955
|
+
const rankedTags = asArray(game?.tagRanks)
|
|
956
|
+
.map((tag, index) => {
|
|
957
|
+
const rank = numericValue(tag?.rank);
|
|
958
|
+
return {
|
|
959
|
+
value: asString(tag?.name),
|
|
960
|
+
rank: Number.isFinite(rank) ? rank : index + 1
|
|
961
|
+
};
|
|
962
|
+
})
|
|
963
|
+
.filter((tag) => tag.value);
|
|
964
|
+
const tags = rankedTags.length
|
|
965
|
+
? rankedTags
|
|
966
|
+
: asArray(game?.tags).map((tag, index) => ({
|
|
967
|
+
value: asString(tag),
|
|
968
|
+
rank: index + 1
|
|
969
|
+
}));
|
|
970
|
+
|
|
971
|
+
const seen = new Set();
|
|
972
|
+
for (const tag of tags) {
|
|
973
|
+
if (seen.has(tag.value)) continue;
|
|
974
|
+
seen.add(tag.value);
|
|
975
|
+
const current = counts.get(tag.value) ?? {
|
|
976
|
+
value: tag.value,
|
|
977
|
+
count: 0,
|
|
978
|
+
rankTotal: 0,
|
|
979
|
+
minRank: tag.rank
|
|
980
|
+
};
|
|
981
|
+
current.count += 1;
|
|
982
|
+
current.rankTotal += tag.rank;
|
|
983
|
+
current.minRank = Math.min(current.minRank, tag.rank);
|
|
984
|
+
counts.set(tag.value, current);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return Array.from(counts.values())
|
|
989
|
+
.map((item) => ({
|
|
990
|
+
value: item.value,
|
|
991
|
+
count: item.count,
|
|
992
|
+
averageRank: roundNumber(item.rankTotal / item.count),
|
|
993
|
+
minRank: item.minRank
|
|
994
|
+
}))
|
|
995
|
+
.sort((a, b) => b.count - a.count || a.averageRank - b.averageRank || a.value.localeCompare(b.value))
|
|
996
|
+
.slice(0, limit);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function mergeSearchPages(searches, sampling) {
|
|
1000
|
+
const data = searches.flatMap((search) => asArray(search?.data));
|
|
1001
|
+
const sampledData = sampling?.mode === "sample-size"
|
|
1002
|
+
? data.slice(0, sampling.requestedSize)
|
|
1003
|
+
: data;
|
|
1004
|
+
const first = searches[0] ?? {};
|
|
1005
|
+
const last = searches[searches.length - 1] ?? {};
|
|
1006
|
+
return {
|
|
1007
|
+
...first,
|
|
1008
|
+
data: sampledData,
|
|
1009
|
+
pagination: {
|
|
1010
|
+
...(first.pagination ?? {}),
|
|
1011
|
+
limit: first.pagination?.limit,
|
|
1012
|
+
offset: first.pagination?.offset,
|
|
1013
|
+
total: first.pagination?.total,
|
|
1014
|
+
nextOffset: last.pagination?.nextOffset
|
|
1015
|
+
},
|
|
1016
|
+
warnings: uniqueStrings(searches.flatMap((search) => safeWarnings(search)))
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function buildSampleMetadata(request, searches, games, pageErrors) {
|
|
1021
|
+
if (!request.sampling) return undefined;
|
|
1022
|
+
return {
|
|
1023
|
+
mode: request.sampling.mode,
|
|
1024
|
+
requestedSize: request.sampling.requestedSize,
|
|
1025
|
+
requestedPages: request.sampling.requestedPages,
|
|
1026
|
+
pageLimit: request.sampling.pageLimit,
|
|
1027
|
+
fetchedPages: searches.length,
|
|
1028
|
+
fetchedGames: games.length,
|
|
1029
|
+
skippedPages: pageErrors.length,
|
|
1030
|
+
totalMatches: searches[0]?.pagination?.total,
|
|
1031
|
+
quotaCaveat: "Sampled workflows issue one search request per fetched page, plus downstream per-game requests when the command needs detail evidence."
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function buildEvidencePackSample(sample, returnedRows) {
|
|
1036
|
+
return {
|
|
1037
|
+
mode: normalizeEvidencePackSampleMode(sample.mode),
|
|
1038
|
+
requestedRows: sample.requestedSize,
|
|
1039
|
+
requestedPages: sample.requestedPages,
|
|
1040
|
+
returnedRows,
|
|
1041
|
+
fetchedPages: sample.fetchedPages,
|
|
1042
|
+
skippedPages: sample.skippedPages,
|
|
1043
|
+
totalMatches: sample.totalMatches
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function buildReturnedPageSample(pagination, returnedRows) {
|
|
1048
|
+
return {
|
|
1049
|
+
mode: "returned_page",
|
|
1050
|
+
requestedRows: pagination?.limit,
|
|
1051
|
+
returnedRows,
|
|
1052
|
+
totalMatches: pagination?.total
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function normalizeEvidencePackSampleMode(mode) {
|
|
1057
|
+
if (mode === "sample-size") return "sample_size";
|
|
1058
|
+
if (mode === "sample-pages") return "sample_pages";
|
|
1059
|
+
return mode;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function withSummaryBasis(summary, basis) {
|
|
1063
|
+
return {
|
|
1064
|
+
basis,
|
|
1065
|
+
...summary
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function buildSearchUpstreamRequests(searches, pageErrors = []) {
|
|
1070
|
+
return [
|
|
1071
|
+
...searches.map((search, index) => ({
|
|
1072
|
+
kind: "search",
|
|
1073
|
+
page: index + 1,
|
|
1074
|
+
requestId: search?.meta?.requestId,
|
|
1075
|
+
source: search?.meta?.source,
|
|
1076
|
+
entitlements: search?.meta?.entitlements,
|
|
1077
|
+
pagination: search?.pagination
|
|
1078
|
+
})),
|
|
1079
|
+
...pageErrors.map((entry, index) => ({
|
|
1080
|
+
kind: "search",
|
|
1081
|
+
page: searches.length + index + 1,
|
|
1082
|
+
offset: entry.offset,
|
|
1083
|
+
error: entry.error
|
|
1084
|
+
}))
|
|
1085
|
+
];
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function buildReviewPatternGame(request, entry) {
|
|
1089
|
+
const game = entry.game ?? {};
|
|
1090
|
+
const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
|
|
1091
|
+
const topics = extractReviewTopics(entry.topics)
|
|
1092
|
+
.slice(0, request.maxTopicsPerGame)
|
|
1093
|
+
.map(normalizeReviewTopic)
|
|
1094
|
+
.filter((topic) => matchesReviewPatternFilters(topic, request));
|
|
1095
|
+
const skippedReason = entry.skippedReason ?? (topics.length === 0 ? "no_matching_review_topics" : undefined);
|
|
1096
|
+
|
|
1097
|
+
return {
|
|
1098
|
+
source: request.source,
|
|
1099
|
+
externalId,
|
|
1100
|
+
appid: game?.appid,
|
|
1101
|
+
title: game?.title ?? game?.name,
|
|
1102
|
+
reviews: game?.reviews,
|
|
1103
|
+
rating: game?.rating,
|
|
1104
|
+
releaseDate: game?.releaseDate,
|
|
1105
|
+
coverage: game?.coverage,
|
|
1106
|
+
topics,
|
|
1107
|
+
skippedReason,
|
|
1108
|
+
error: entry.error
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function buildReviewPatterns(games) {
|
|
1113
|
+
const totalGames = games.length;
|
|
1114
|
+
const totalReviewVolume = games.reduce((sum, game) => sum + Math.max(0, numericValue(game.reviews) || 0), 0);
|
|
1115
|
+
const patternMap = new Map();
|
|
1116
|
+
|
|
1117
|
+
for (const game of games) {
|
|
1118
|
+
const seenInGame = new Set();
|
|
1119
|
+
for (const topic of game.topics) {
|
|
1120
|
+
const key = normalizeReviewPatternKey(topic.topic);
|
|
1121
|
+
if (!key || seenInGame.has(key)) continue;
|
|
1122
|
+
seenInGame.add(key);
|
|
1123
|
+
|
|
1124
|
+
const existing = patternMap.get(key) ?? {
|
|
1125
|
+
key,
|
|
1126
|
+
topic: topic.topic,
|
|
1127
|
+
appearsInGames: 0,
|
|
1128
|
+
matchedReviewVolume: 0,
|
|
1129
|
+
sentiments: {},
|
|
1130
|
+
prevalenceValues: [],
|
|
1131
|
+
exampleGames: []
|
|
1132
|
+
};
|
|
1133
|
+
existing.appearsInGames += 1;
|
|
1134
|
+
existing.matchedReviewVolume += Math.max(0, numericValue(game.reviews) || 0);
|
|
1135
|
+
if (topic.sentiment) {
|
|
1136
|
+
existing.sentiments[topic.sentiment] = (existing.sentiments[topic.sentiment] ?? 0) + 1;
|
|
1137
|
+
}
|
|
1138
|
+
const prevalence = numericValue(topic.prevalence);
|
|
1139
|
+
if (Number.isFinite(prevalence)) existing.prevalenceValues.push(prevalence);
|
|
1140
|
+
if (existing.exampleGames.length < 5) {
|
|
1141
|
+
existing.exampleGames.push({
|
|
1142
|
+
source: game.source,
|
|
1143
|
+
externalId: game.externalId,
|
|
1144
|
+
appid: game.appid,
|
|
1145
|
+
title: game.title,
|
|
1146
|
+
reviews: game.reviews,
|
|
1147
|
+
rating: game.rating,
|
|
1148
|
+
topic: topic.topic,
|
|
1149
|
+
clusterId: topic.clusterId,
|
|
1150
|
+
displayGroup: topic.displayGroup,
|
|
1151
|
+
sentiment: topic.sentiment,
|
|
1152
|
+
summary: topic.summary,
|
|
1153
|
+
prevalence: topic.prevalence,
|
|
1154
|
+
impactScore: topic.impactScore,
|
|
1155
|
+
representativeQuotes: topic.representativeQuotes
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
patternMap.set(key, existing);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return Array.from(patternMap.values())
|
|
1163
|
+
.map((pattern) => ({
|
|
1164
|
+
key: pattern.key,
|
|
1165
|
+
topic: pattern.topic,
|
|
1166
|
+
appearsInGames: pattern.appearsInGames,
|
|
1167
|
+
shareOfGames: totalGames > 0 ? roundNumber(pattern.appearsInGames / totalGames) : 0,
|
|
1168
|
+
reviewVolumeWeightedShare: totalReviewVolume > 0
|
|
1169
|
+
? roundNumber(pattern.matchedReviewVolume / totalReviewVolume)
|
|
1170
|
+
: undefined,
|
|
1171
|
+
matchedReviewVolume: pattern.matchedReviewVolume,
|
|
1172
|
+
sentiments: sortCountObject(pattern.sentiments),
|
|
1173
|
+
averageTopicPrevalence: pattern.prevalenceValues.length > 0
|
|
1174
|
+
? roundNumber(pattern.prevalenceValues.reduce((sum, value) => sum + value, 0) / pattern.prevalenceValues.length)
|
|
1175
|
+
: undefined,
|
|
1176
|
+
exampleGames: pattern.exampleGames
|
|
1177
|
+
}))
|
|
1178
|
+
.sort((a, b) => (
|
|
1179
|
+
b.appearsInGames - a.appearsInGames
|
|
1180
|
+
|| sortableNumber(b.matchedReviewVolume) - sortableNumber(a.matchedReviewVolume)
|
|
1181
|
+
|| a.key.localeCompare(b.key)
|
|
1182
|
+
));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function buildReviewPatternsSummary(request, usableGames, skipped, pagination) {
|
|
1186
|
+
const patterns = buildReviewPatterns(usableGames);
|
|
1187
|
+
return {
|
|
1188
|
+
sampleSize: usableGames.length,
|
|
1189
|
+
totalMatches: pagination?.total,
|
|
1190
|
+
skippedGames: skipped.length,
|
|
1191
|
+
topicEvidenceItems: usableGames.reduce((sum, game) => sum + game.topics.length, 0),
|
|
1192
|
+
patternCount: patterns.length,
|
|
1193
|
+
topSignals: patterns.slice(0, 10).map((pattern) => ({
|
|
1194
|
+
type: "reviewTopic",
|
|
1195
|
+
value: pattern.topic,
|
|
1196
|
+
count: pattern.appearsInGames,
|
|
1197
|
+
evidenceRefs: pattern.exampleGames.map((game) => `${game.source}:${game.externalId}`)
|
|
1198
|
+
})),
|
|
1199
|
+
caveats: [
|
|
1200
|
+
"Review patterns are repeated visible topics across the bounded cohort, not full-market clusters.",
|
|
1201
|
+
"Representative quotes are evidence; locked severity or fixability fields must remain unavailable when locked.",
|
|
1202
|
+
...(usableGames.length < request.minGames
|
|
1203
|
+
? [`Usable game count ${usableGames.length} is below --min-games ${request.minGames}.`]
|
|
1204
|
+
: [])
|
|
1205
|
+
]
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function extractReviewTopics(payload) {
|
|
1210
|
+
if (Array.isArray(payload?.data)) return payload.data;
|
|
1211
|
+
if (Array.isArray(payload?.data?.topics)) return payload.data.topics;
|
|
1212
|
+
if (Array.isArray(payload?.data?.sections?.reviews?.topics)) return payload.data.sections.reviews.topics;
|
|
1213
|
+
return [];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function normalizeReviewTopic(topic) {
|
|
1217
|
+
const displayGroup = asString(topic?.displayGroup);
|
|
1218
|
+
const quote = asString(topic?.quote);
|
|
1219
|
+
const representativeQuotes = asArray(topic?.representativeQuotes);
|
|
1220
|
+
if (representativeQuotes.length === 0 && quote) {
|
|
1221
|
+
representativeQuotes.push({ text: quote });
|
|
1222
|
+
}
|
|
1223
|
+
const clusterLabel = topic?.clusterId == null
|
|
1224
|
+
? undefined
|
|
1225
|
+
: `${displayGroup ? `${displayGroup} ` : ""}cluster ${topic.clusterId}`;
|
|
1226
|
+
|
|
1227
|
+
return {
|
|
1228
|
+
topic: asString(topic?.topic ?? topic?.label ?? clusterLabel ?? displayGroup),
|
|
1229
|
+
clusterId: topic?.clusterId,
|
|
1230
|
+
displayGroup: displayGroup || undefined,
|
|
1231
|
+
sentiment: topic?.sentiment ?? sentimentFromReviewDisplayGroup(displayGroup),
|
|
1232
|
+
summary: topic?.summary ?? (quote || undefined),
|
|
1233
|
+
prevalence: topic?.prevalence,
|
|
1234
|
+
impactScore: topic?.impactScore,
|
|
1235
|
+
severity: topic?.severity,
|
|
1236
|
+
fixability: topic?.fixability,
|
|
1237
|
+
representativeQuotes: representativeQuotes.slice(0, 2)
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function matchesReviewPatternFilters(topic, request) {
|
|
1242
|
+
if (!topic.topic) return false;
|
|
1243
|
+
if (request.sentiment && topic.sentiment !== request.sentiment) return false;
|
|
1244
|
+
if (request.topic) {
|
|
1245
|
+
const needle = asString(request.topic).trim().toLowerCase();
|
|
1246
|
+
const haystack = `${topic.topic} ${topic.summary ?? ""}`.toLowerCase();
|
|
1247
|
+
if (needle && !haystack.includes(needle)) return false;
|
|
1248
|
+
}
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function normalizeReviewPatternKey(value) {
|
|
1253
|
+
return asString(value)
|
|
1254
|
+
.trim()
|
|
1255
|
+
.toLowerCase()
|
|
1256
|
+
.replace(/&/g, " and ")
|
|
1257
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
1258
|
+
.replace(/\s+/g, " ")
|
|
1259
|
+
.trim();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function sentimentFromReviewDisplayGroup(value) {
|
|
1263
|
+
if (value === "love") return "positive";
|
|
1264
|
+
if (value === "actionable") return "negative";
|
|
1265
|
+
if (value === "mixed") return "mixed";
|
|
1266
|
+
return undefined;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function reviewPatternErrorWarning(entry) {
|
|
1270
|
+
if (!entry?.error) return [];
|
|
1271
|
+
const code = entry.error.code ?? "CLI_API_ERROR";
|
|
1272
|
+
const suffix = entry.externalId ? ` for ${entry.externalId}` : "";
|
|
1273
|
+
return [`review topics request failed${suffix}: ${code}`];
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function buildReviewPatternSegments(games, segment) {
|
|
1277
|
+
const groups = new Map();
|
|
1278
|
+
for (const game of games) {
|
|
1279
|
+
const key = classifyGameSegment(game, segment);
|
|
1280
|
+
const current = groups.get(key) ?? [];
|
|
1281
|
+
current.push(game);
|
|
1282
|
+
groups.set(key, current);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return Array.from(groups.entries())
|
|
1286
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1287
|
+
.map(([segmentValue, segmentGames]) => ({
|
|
1288
|
+
segment,
|
|
1289
|
+
value: segmentValue,
|
|
1290
|
+
gameCount: segmentGames.length,
|
|
1291
|
+
patterns: buildReviewPatterns(segmentGames).slice(0, 10)
|
|
1292
|
+
}));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function buildCohortEvidenceGame(request, entry) {
|
|
1296
|
+
const game = entry.game ?? {};
|
|
1297
|
+
const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
|
|
1298
|
+
const sections = getAnalysisSections(entry.analysis);
|
|
1299
|
+
const patternEvidence = extractCohortPatternEvidence(request.evidenceType, game, sections);
|
|
1300
|
+
const skippedReason = entry.skippedReason
|
|
1301
|
+
?? (request.analysisQuery && patternEvidence.length === 0 ? "no_matching_analysis_evidence" : undefined);
|
|
1302
|
+
|
|
1303
|
+
return {
|
|
1304
|
+
source: request.source,
|
|
1305
|
+
externalId,
|
|
1306
|
+
appid: game?.appid,
|
|
1307
|
+
title: game?.title ?? game?.name,
|
|
1308
|
+
reviews: game?.reviews,
|
|
1309
|
+
rating: game?.rating,
|
|
1310
|
+
price: game?.price,
|
|
1311
|
+
discount: game?.discount,
|
|
1312
|
+
releaseDate: game?.releaseDate,
|
|
1313
|
+
coverage: game?.coverage,
|
|
1314
|
+
tags: getRankedTagNames(game).slice(0, 8),
|
|
1315
|
+
genres: game?.genres,
|
|
1316
|
+
analysis: entry.analysis,
|
|
1317
|
+
sections,
|
|
1318
|
+
patternEvidence,
|
|
1319
|
+
skippedReason,
|
|
1320
|
+
error: entry.error
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function buildCohortEvidenceSummary(request, entries, pagination) {
|
|
1325
|
+
return {
|
|
1326
|
+
sampleSize: entries.length,
|
|
1327
|
+
totalMatches: pagination?.total,
|
|
1328
|
+
reviews: numericSummary(entries.map((entry) => entry.reviews)),
|
|
1329
|
+
rating: numericSummary(entries.map((entry) => entry.rating)),
|
|
1330
|
+
price: numericSummary(entries.map((entry) => entry.price)),
|
|
1331
|
+
releaseYears: countReleaseYears(entries),
|
|
1332
|
+
priceBuckets: countPriceBuckets(entries),
|
|
1333
|
+
topTags: topRankedTags(entries),
|
|
1334
|
+
topGenres: topValues(entries.flatMap((entry) => asArray(entry.genres))),
|
|
1335
|
+
coverage: countCoverage(entries),
|
|
1336
|
+
evidenceItems: entries.reduce((sum, entry) => sum + entry.patternEvidence.length, 0),
|
|
1337
|
+
caveats: [
|
|
1338
|
+
"This summary uses bounded visible API rows and optional per-game analysis sections.",
|
|
1339
|
+
"Observed patterns are evidence inputs, not recommendations, causal claims, or full-market estimates.",
|
|
1340
|
+
"Locked, missing, partial, and failed downstream analysis requests must remain visible when interpreting the output."
|
|
1341
|
+
]
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function buildShowcaseFitSummary(request, result) {
|
|
1346
|
+
const showcases = asArray(result.showcases?.data);
|
|
1347
|
+
const tags = asArray(result.tags);
|
|
1348
|
+
return {
|
|
1349
|
+
sampleSize: showcases.length,
|
|
1350
|
+
totalMatches: result.showcases?.pagination?.total,
|
|
1351
|
+
target: {
|
|
1352
|
+
source: request.source,
|
|
1353
|
+
externalId: request.externalId,
|
|
1354
|
+
title: result.detail?.data?.title
|
|
1355
|
+
},
|
|
1356
|
+
topSignals: [
|
|
1357
|
+
...tags.slice(0, 8).map((tag) => ({
|
|
1358
|
+
type: "gameTag",
|
|
1359
|
+
value: tag,
|
|
1360
|
+
count: showcases.filter((showcase) => asArray(showcase?.tags).includes(tag)).length || undefined
|
|
1361
|
+
})),
|
|
1362
|
+
...(request.deadlineBefore
|
|
1363
|
+
? [{
|
|
1364
|
+
type: "deadlineBefore",
|
|
1365
|
+
value: request.deadlineBefore,
|
|
1366
|
+
count: showcases.length
|
|
1367
|
+
}]
|
|
1368
|
+
: [])
|
|
1369
|
+
],
|
|
1370
|
+
caveats: [
|
|
1371
|
+
"Showcase fit is evidence for submission planning, not a final recommendation or guaranteed eligibility.",
|
|
1372
|
+
"Unknown demo, Steam page, fee, or format fields require manual follow-up.",
|
|
1373
|
+
"Current matching is based on visible catalog filters and game tags unless a future scoring method is documented."
|
|
1374
|
+
]
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function buildCohortEvidencePatterns(request, entries) {
|
|
1379
|
+
if (request.evidenceType === "trends") {
|
|
1380
|
+
return [
|
|
1381
|
+
...topRankedTags(entries).map((item) => ({ type: "tag", ...item })),
|
|
1382
|
+
...topValues(entries.flatMap((entry) => asArray(entry.genres))).map((item) => ({ type: "genre", ...item }))
|
|
1383
|
+
];
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (request.evidenceType === "pricing") {
|
|
1387
|
+
return Object.entries(countPriceBuckets(entries)).map(([value, count]) => ({
|
|
1388
|
+
type: "priceBucket",
|
|
1389
|
+
value,
|
|
1390
|
+
count
|
|
1391
|
+
}));
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const patternMap = new Map();
|
|
1395
|
+
for (const entry of entries) {
|
|
1396
|
+
const seen = new Set();
|
|
1397
|
+
for (const evidence of entry.patternEvidence) {
|
|
1398
|
+
const key = `${evidence.type}:${normalizeReviewPatternKey(evidence.value)}`;
|
|
1399
|
+
if (!evidence.value || seen.has(key)) continue;
|
|
1400
|
+
seen.add(key);
|
|
1401
|
+
const current = patternMap.get(key) ?? {
|
|
1402
|
+
type: evidence.type,
|
|
1403
|
+
value: evidence.value,
|
|
1404
|
+
count: 0,
|
|
1405
|
+
reviewVolume: 0,
|
|
1406
|
+
exampleGames: []
|
|
1407
|
+
};
|
|
1408
|
+
current.count += 1;
|
|
1409
|
+
current.reviewVolume += Math.max(0, numericValue(entry.reviews) || 0);
|
|
1410
|
+
if (current.exampleGames.length < 5) {
|
|
1411
|
+
current.exampleGames.push({
|
|
1412
|
+
source: entry.source,
|
|
1413
|
+
externalId: entry.externalId,
|
|
1414
|
+
appid: entry.appid,
|
|
1415
|
+
title: entry.title,
|
|
1416
|
+
reviews: entry.reviews,
|
|
1417
|
+
rating: entry.rating,
|
|
1418
|
+
evidence: evidence.raw
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
patternMap.set(key, current);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
return Array.from(patternMap.values())
|
|
1426
|
+
.map((pattern) => ({
|
|
1427
|
+
...pattern,
|
|
1428
|
+
shareOfGames: entries.length > 0 ? roundNumber(pattern.count / entries.length) : 0
|
|
1429
|
+
}))
|
|
1430
|
+
.sort((a, b) => b.count - a.count || b.reviewVolume - a.reviewVolume || a.value.localeCompare(b.value))
|
|
1431
|
+
.slice(0, 30);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function buildPricingSegments(entries) {
|
|
1435
|
+
const buckets = new Map();
|
|
1436
|
+
for (const entry of entries) {
|
|
1437
|
+
const bucket = classifyPriceBucket(entry.price);
|
|
1438
|
+
const current = buckets.get(bucket) ?? [];
|
|
1439
|
+
current.push(entry);
|
|
1440
|
+
buckets.set(bucket, current);
|
|
1441
|
+
}
|
|
1442
|
+
return Array.from(buckets.entries())
|
|
1443
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1444
|
+
.map(([value, games]) => ({
|
|
1445
|
+
segment: "price",
|
|
1446
|
+
value,
|
|
1447
|
+
gameCount: games.length,
|
|
1448
|
+
reviews: numericSummary(games.map((game) => game.reviews)),
|
|
1449
|
+
rating: numericSummary(games.map((game) => game.rating)),
|
|
1450
|
+
topTags: topRankedTags(games, 5)
|
|
1451
|
+
}));
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function buildTrendSummary(entries, pagination, source) {
|
|
1455
|
+
return buildBenchmarkSummary(entries, pagination, source, { sampled: true });
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function extractCohortPatternEvidence(evidenceType, game, sections) {
|
|
1459
|
+
if (evidenceType === "trends" || evidenceType === "pricing") return [];
|
|
1460
|
+
if (evidenceType === "brief") {
|
|
1461
|
+
return [
|
|
1462
|
+
...extractCohortPatternEvidence("marketing", game, sections),
|
|
1463
|
+
...extractCohortPatternEvidence("creators", game, sections),
|
|
1464
|
+
...extractCohortPatternEvidence("showcases", game, sections)
|
|
1465
|
+
];
|
|
1466
|
+
}
|
|
1467
|
+
if (evidenceType === "marketing" || evidenceType === "marketingTiming") {
|
|
1468
|
+
const marketing = sections.marketing ?? {};
|
|
1469
|
+
const media = sections.media ?? {};
|
|
1470
|
+
const creators = sections.creators ?? {};
|
|
1471
|
+
const events = asArray(marketing.events).map((event) => ({
|
|
1472
|
+
type: evidenceType === "marketingTiming" ? "marketingEventTiming" : "marketingEvent",
|
|
1473
|
+
value: evidenceType === "marketingTiming"
|
|
1474
|
+
? classifyRelativeTiming(game?.releaseDate, event?.date)
|
|
1475
|
+
: asString(event?.category ?? event?.event),
|
|
1476
|
+
raw: pickCompactEvidence(event, ["date", "event", "category", "description", "marketScope", "previewUrl"])
|
|
1477
|
+
}));
|
|
1478
|
+
if (evidenceType === "marketingTiming") return events.filter((item) => item.value);
|
|
1479
|
+
return [
|
|
1480
|
+
...events,
|
|
1481
|
+
...asArray(media.articles).map((article) => ({
|
|
1482
|
+
type: "mediaAngle",
|
|
1483
|
+
value: asString(article?.articleType ?? article?.outlet),
|
|
1484
|
+
raw: pickCompactEvidence(article, ["title", "outlet", "articleType", "date", "summary", "previewUrl"])
|
|
1485
|
+
})),
|
|
1486
|
+
...asArray(creators.creators ?? creators).map((creator) => ({
|
|
1487
|
+
type: "creatorPlatform",
|
|
1488
|
+
value: asString(creator?.platform),
|
|
1489
|
+
raw: pickCompactEvidence(creator, ["platform", "channelTitle", "subscriberCount", "totalViewsOnGame"])
|
|
1490
|
+
}))
|
|
1491
|
+
].filter((item) => item.value);
|
|
1492
|
+
}
|
|
1493
|
+
if (evidenceType === "creators") {
|
|
1494
|
+
const creators = sections.creators ?? {};
|
|
1495
|
+
return asArray(creators.creators ?? creators).flatMap((creator) => [
|
|
1496
|
+
{
|
|
1497
|
+
type: "creatorPlatform",
|
|
1498
|
+
value: asString(creator?.platform),
|
|
1499
|
+
raw: pickCompactEvidence(creator, ["platform", "channelTitle", "subscriberCount", "totalViewsOnGame", "totalVideosOnGame", "firstCoverageDate"])
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
type: "creatorScale",
|
|
1503
|
+
value: classifyCreatorScale(creator?.subscriberCount, creator?.totalViewsOnGame),
|
|
1504
|
+
raw: pickCompactEvidence(creator, ["platform", "channelTitle", "subscriberCount", "totalViewsOnGame"])
|
|
1505
|
+
}
|
|
1506
|
+
]).filter((item) => item.value);
|
|
1507
|
+
}
|
|
1508
|
+
if (evidenceType === "showcases") {
|
|
1509
|
+
const showcases = sections.showcases ?? [];
|
|
1510
|
+
return asArray(showcases).flatMap((showcase) => [
|
|
1511
|
+
{
|
|
1512
|
+
type: "showcaseEvent",
|
|
1513
|
+
value: asString(showcase?.eventName),
|
|
1514
|
+
raw: pickCompactEvidence(showcase, ["eventName", "eventDate", "eventType", "marketScope", "description", "previewUrl"])
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
type: "showcaseType",
|
|
1518
|
+
value: asString(showcase?.eventType),
|
|
1519
|
+
raw: pickCompactEvidence(showcase, ["eventName", "eventDate", "eventType", "marketScope"])
|
|
1520
|
+
}
|
|
1521
|
+
]).filter((item) => item.value);
|
|
1522
|
+
}
|
|
1523
|
+
return [];
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function getAnalysisSections(payload) {
|
|
1527
|
+
return payload?.data?.sections && typeof payload.data.sections === "object"
|
|
1528
|
+
? payload.data.sections
|
|
1529
|
+
: {};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function cohortEvidenceErrorWarning(entry, workflow) {
|
|
1533
|
+
if (!entry?.error) return [];
|
|
1534
|
+
const code = entry.error.code ?? "CLI_API_ERROR";
|
|
1535
|
+
const suffix = entry.externalId ? ` for ${entry.externalId}` : "";
|
|
1536
|
+
return [`${workflow} analysis request failed${suffix}: ${code}`];
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function classifyGameSegment(game, segment) {
|
|
1540
|
+
if (segment === "coverage") return asString(game.coverage) || "unknown";
|
|
1541
|
+
if (segment === "releaseYear") {
|
|
1542
|
+
const year = typeof game.releaseDate === "string" ? game.releaseDate.slice(0, 4) : "";
|
|
1543
|
+
return /^\d{4}$/.test(year) ? year : "unknown";
|
|
1544
|
+
}
|
|
1545
|
+
return classifyPriceBucket(game.price);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function classifyPriceBucket(priceValue) {
|
|
1549
|
+
const price = numericValue(priceValue);
|
|
1550
|
+
if (!Number.isFinite(price)) return "unknown";
|
|
1551
|
+
if (price <= 0) return "free";
|
|
1552
|
+
if (price < 10) return "under10";
|
|
1553
|
+
if (price < 20) return "under20";
|
|
1554
|
+
if (price < 40) return "under40";
|
|
1555
|
+
return "fortyPlus";
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function classifyRelativeTiming(releaseDate, eventDate) {
|
|
1559
|
+
if (!isIsoDate(releaseDate) || !isIsoDate(eventDate)) return "unknown";
|
|
1560
|
+
const release = Date.parse(`${releaseDate}T00:00:00.000Z`);
|
|
1561
|
+
const event = Date.parse(`${eventDate}T00:00:00.000Z`);
|
|
1562
|
+
const days = Math.round((event - release) / 86400000);
|
|
1563
|
+
if (days < -180) return "prelaunch_6m_plus";
|
|
1564
|
+
if (days < -90) return "prelaunch_3_6m";
|
|
1565
|
+
if (days < -30) return "prelaunch_1_3m";
|
|
1566
|
+
if (days < 0) return "prelaunch_0_30d";
|
|
1567
|
+
if (days <= 30) return "launch_window_0_30d";
|
|
1568
|
+
if (days <= 90) return "postlaunch_1_3m";
|
|
1569
|
+
if (days <= 180) return "postlaunch_3_6m";
|
|
1570
|
+
return "postlaunch_6m_plus";
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function isIsoDate(value) {
|
|
1574
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(asString(value));
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function classifyCreatorScale(subscriberCount, totalViewsOnGame) {
|
|
1578
|
+
const subscribers = numericValue(subscriberCount);
|
|
1579
|
+
const views = numericValue(totalViewsOnGame);
|
|
1580
|
+
const size = Number.isFinite(subscribers) ? subscribers : views;
|
|
1581
|
+
if (!Number.isFinite(size)) return "unknown";
|
|
1582
|
+
if (size >= 1000000) return "mega";
|
|
1583
|
+
if (size >= 100000) return "large";
|
|
1584
|
+
if (size >= 10000) return "mid";
|
|
1585
|
+
return "small";
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function pickCompactEvidence(value, keys) {
|
|
1589
|
+
if (!value || typeof value !== "object") return value;
|
|
1590
|
+
const picked = {};
|
|
1591
|
+
for (const key of keys) {
|
|
1592
|
+
if (value[key] != null && value[key] !== "") picked[key] = value[key];
|
|
1593
|
+
}
|
|
1594
|
+
return picked;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function fetchJsonResult(url, { apiKey }) {
|
|
1598
|
+
let response;
|
|
1599
|
+
try {
|
|
1600
|
+
response = await fetch(url, {
|
|
1601
|
+
headers: buildRequestHeaders(apiKey)
|
|
1602
|
+
});
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
return {
|
|
1605
|
+
ok: false,
|
|
1606
|
+
error: {
|
|
1607
|
+
code: "CLI_API_ERROR",
|
|
1608
|
+
message: `Failed to reach GMC API at ${url.origin}`,
|
|
1609
|
+
cause: error instanceof Error ? error.message : "Unknown network failure"
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const payload = await response.json().catch(() => null);
|
|
1615
|
+
if (!response.ok) {
|
|
1616
|
+
return {
|
|
1617
|
+
ok: false,
|
|
1618
|
+
payload,
|
|
1619
|
+
error: payload?.error ?? buildHttpApiError(url, response)
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (payload == null) {
|
|
1624
|
+
return {
|
|
1625
|
+
ok: false,
|
|
1626
|
+
error: buildNonJsonApiError(url, response)
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return {
|
|
1631
|
+
ok: true,
|
|
1632
|
+
payload
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
export function buildHttpApiError(url, response) {
|
|
1637
|
+
return {
|
|
1638
|
+
code: "CLI_API_ERROR",
|
|
1639
|
+
message: `API request failed with HTTP ${response.status}`,
|
|
1640
|
+
status: response.status,
|
|
1641
|
+
statusText: response.statusText || undefined,
|
|
1642
|
+
path: `${url.pathname}${url.search}`,
|
|
1643
|
+
contentType: response.headers.get("content-type") || undefined
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
export function buildNonJsonApiError(url, response) {
|
|
1648
|
+
return {
|
|
1649
|
+
code: "CLI_API_ERROR",
|
|
1650
|
+
message: `GMC API returned a non-JSON response from ${url.origin}`,
|
|
1651
|
+
status: response.status,
|
|
1652
|
+
statusText: response.statusText || undefined,
|
|
1653
|
+
path: `${url.pathname}${url.search}`,
|
|
1654
|
+
contentType: response.headers.get("content-type") || undefined
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function numericSummary(values) {
|
|
1659
|
+
const numbers = values
|
|
1660
|
+
.map(numericValue)
|
|
1661
|
+
.filter((value) => Number.isFinite(value))
|
|
1662
|
+
.sort((a, b) => a - b);
|
|
1663
|
+
if (numbers.length === 0) {
|
|
1664
|
+
return {
|
|
1665
|
+
count: 0
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
return {
|
|
1670
|
+
count: numbers.length,
|
|
1671
|
+
min: numbers[0],
|
|
1672
|
+
median: median(numbers),
|
|
1673
|
+
max: numbers[numbers.length - 1],
|
|
1674
|
+
average: roundNumber(numbers.reduce((sum, value) => sum + value, 0) / numbers.length)
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function median(sortedNumbers) {
|
|
1679
|
+
const middle = Math.floor(sortedNumbers.length / 2);
|
|
1680
|
+
if (sortedNumbers.length % 2 === 1) return sortedNumbers[middle];
|
|
1681
|
+
return roundNumber((sortedNumbers[middle - 1] + sortedNumbers[middle]) / 2);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function countReleaseYears(games) {
|
|
1685
|
+
const counts = {};
|
|
1686
|
+
for (const game of games) {
|
|
1687
|
+
const year = typeof game?.releaseDate === "string" ? game.releaseDate.slice(0, 4) : undefined;
|
|
1688
|
+
const key = /^\d{4}$/.test(year ?? "") ? year : "unknown";
|
|
1689
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
1690
|
+
}
|
|
1691
|
+
return sortCountObject(counts);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function countPriceBuckets(games) {
|
|
1695
|
+
const counts = {
|
|
1696
|
+
free: 0,
|
|
1697
|
+
under10: 0,
|
|
1698
|
+
under20: 0,
|
|
1699
|
+
under40: 0,
|
|
1700
|
+
fortyPlus: 0,
|
|
1701
|
+
unknown: 0
|
|
1702
|
+
};
|
|
1703
|
+
for (const game of games) {
|
|
1704
|
+
const price = numericValue(game?.price);
|
|
1705
|
+
if (!Number.isFinite(price)) counts.unknown += 1;
|
|
1706
|
+
else if (price <= 0) counts.free += 1;
|
|
1707
|
+
else if (price < 10) counts.under10 += 1;
|
|
1708
|
+
else if (price < 20) counts.under20 += 1;
|
|
1709
|
+
else if (price < 40) counts.under40 += 1;
|
|
1710
|
+
else counts.fortyPlus += 1;
|
|
1711
|
+
}
|
|
1712
|
+
return counts;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function countCoverage(games) {
|
|
1716
|
+
const counts = {
|
|
1717
|
+
basic: 0,
|
|
1718
|
+
partial: 0,
|
|
1719
|
+
full: 0,
|
|
1720
|
+
unknown: 0
|
|
1721
|
+
};
|
|
1722
|
+
for (const game of games) {
|
|
1723
|
+
const coverage = game?.coverage;
|
|
1724
|
+
if (coverage === "basic" || coverage === "partial" || coverage === "full") counts[coverage] += 1;
|
|
1725
|
+
else counts.unknown += 1;
|
|
1726
|
+
}
|
|
1727
|
+
return counts;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function topValues(values, limit = 10) {
|
|
1731
|
+
const counts = {};
|
|
1732
|
+
for (const rawValue of values) {
|
|
1733
|
+
const value = asString(rawValue).trim();
|
|
1734
|
+
if (!value) continue;
|
|
1735
|
+
counts[value] = (counts[value] ?? 0) + 1;
|
|
1736
|
+
}
|
|
1737
|
+
return Object.entries(counts)
|
|
1738
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
1739
|
+
.slice(0, limit)
|
|
1740
|
+
.map(([value, count]) => ({ value, count }));
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function sortCountObject(counts) {
|
|
1744
|
+
return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)));
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function asArray(value) {
|
|
1748
|
+
return Array.isArray(value) ? value : [];
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function numericValue(value) {
|
|
1752
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
1753
|
+
return Number.isFinite(number) ? number : Number.NaN;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function sortableNumber(value) {
|
|
1757
|
+
const number = numericValue(value);
|
|
1758
|
+
return Number.isFinite(number) ? number : Number.NEGATIVE_INFINITY;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function roundNumber(value) {
|
|
1762
|
+
return Math.round(value * 100) / 100;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function summarizeResolution(candidates) {
|
|
1766
|
+
if (candidates.length === 0) {
|
|
1767
|
+
return {
|
|
1768
|
+
status: "no_candidates",
|
|
1769
|
+
reason: "No candidates were returned by the game search API."
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const exactCandidates = candidates.filter((candidate) => candidate.scoreInputs.normalizedTitleExact);
|
|
1774
|
+
if (exactCandidates.length === 1) {
|
|
1775
|
+
return {
|
|
1776
|
+
status: "single_confident_candidate",
|
|
1777
|
+
recommendedExternalId: exactCandidates[0].externalId,
|
|
1778
|
+
reason: "One candidate exactly matches the normalized query."
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
const [first, second] = candidates;
|
|
1783
|
+
if (first && second && first.score >= 80 && first.score - second.score >= 20) {
|
|
1784
|
+
return {
|
|
1785
|
+
status: "single_likely_candidate",
|
|
1786
|
+
recommendedExternalId: first.externalId,
|
|
1787
|
+
reason: "The top candidate is clearly separated from the next candidate."
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
return {
|
|
1792
|
+
status: "ambiguous",
|
|
1793
|
+
reason: "Multiple candidates may match. Ask the user to choose an externalId before detail, compare, or fit commands."
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function calculateResolveScore(inputs, index) {
|
|
1798
|
+
let score = 0;
|
|
1799
|
+
if (inputs.normalizedTitleExact) score += 70;
|
|
1800
|
+
else if (inputs.titleContainsQuery || inputs.queryContainsTitle) score += 35;
|
|
1801
|
+
score += Math.round(inputs.tokenOverlap * 25);
|
|
1802
|
+
if (typeof inputs.reviewCount === "number") score += Math.min(10, Math.floor(Math.log10(Math.max(1, inputs.reviewCount))));
|
|
1803
|
+
if (typeof inputs.rating === "number" && inputs.rating >= 80) score += 5;
|
|
1804
|
+
if (inputs.coverage === "full") score += 5;
|
|
1805
|
+
else if (inputs.coverage === "partial") score += 3;
|
|
1806
|
+
score -= Math.min(index, 10);
|
|
1807
|
+
return Math.max(0, Math.min(100, score));
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function classifyResolveConfidence(score) {
|
|
1811
|
+
if (score >= 90) return "exact";
|
|
1812
|
+
if (score >= 75) return "strong";
|
|
1813
|
+
if (score >= 45) return "possible";
|
|
1814
|
+
return "weak";
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function calculateTokenOverlap(queryTokens, titleTokens) {
|
|
1818
|
+
if (queryTokens.length === 0 || titleTokens.length === 0) return 0;
|
|
1819
|
+
const titleSet = new Set(titleTokens);
|
|
1820
|
+
const matched = queryTokens.filter((token) => titleSet.has(token)).length;
|
|
1821
|
+
return matched / queryTokens.length;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function tokenizeResolveText(value) {
|
|
1825
|
+
return normalizeResolveText(value).split(" ").filter(Boolean);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function normalizeResolveText(value) {
|
|
1829
|
+
return asString(value)
|
|
1830
|
+
.trim()
|
|
1831
|
+
.toLowerCase()
|
|
1832
|
+
.replace(/&/g, " and ")
|
|
1833
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
1834
|
+
.replace(/\s+/g, " ")
|
|
1835
|
+
.trim();
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function asString(value) {
|
|
1839
|
+
return value == null ? "" : String(value);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function normalizeTagSlug(value) {
|
|
1843
|
+
return String(value)
|
|
1844
|
+
.trim()
|
|
1845
|
+
.toLowerCase()
|
|
1846
|
+
.replace(/&/g, "and")
|
|
1847
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1848
|
+
.replace(/^-+|-+$/g, "");
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function safeWarnings(payload) {
|
|
1852
|
+
return Array.isArray(payload?.warnings) ? payload.warnings : [];
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function uniqueStrings(values) {
|
|
1856
|
+
return Array.from(new Set(values.filter((value) => typeof value === "string" && value.length > 0)));
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function createCliRequestId() {
|
|
1860
|
+
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
|
1861
|
+
return `cli_${id.replaceAll("-", "")}`;
|
|
1862
|
+
}
|