@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/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
- }