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