@ainyc/canonry 4.1.1 → 4.2.2

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.
@@ -1,8 +1,13 @@
1
1
  import {
2
+ CitationStates,
2
3
  ContentActions,
3
4
  RunKinds,
4
- __export
5
- } from "./chunk-O5JZQUPX.js";
5
+ __export,
6
+ brandLabelFromDomain,
7
+ categorizeSource,
8
+ determineAnswerMentioned,
9
+ normalizeProjectDomain
10
+ } from "./chunk-T2I6AO7D.js";
6
11
 
7
12
  // src/intelligence-service.ts
8
13
  import { eq, desc, asc, and, or, inArray } from "drizzle-orm";
@@ -1650,13 +1655,14 @@ function computeHealthTrend(runs2) {
1650
1655
  // ../intelligence/src/causes.ts
1651
1656
  function analyzeCause(regression, currentSnapshots) {
1652
1657
  const currentSnap = currentSnapshots.find(
1653
- (s) => s.query === regression.query && s.provider === regression.provider && !s.cited && s.competitorDomain
1658
+ (s) => s.query === regression.query && s.provider === regression.provider && !s.cited && s.competitorDomains && s.competitorDomains.length > 0
1654
1659
  );
1655
1660
  if (currentSnap) {
1661
+ const competitor = currentSnap.competitorDomains[0];
1656
1662
  return {
1657
1663
  cause: "competitor_gain",
1658
- competitorDomain: currentSnap.competitorDomain,
1659
- details: `Competitor ${currentSnap.competitorDomain} now cited for "${regression.query}" on ${regression.provider}`
1664
+ competitorDomain: competitor,
1665
+ details: `Competitor ${competitor} now cited for "${regression.query}" on ${regression.provider}`
1660
1666
  };
1661
1667
  }
1662
1668
  return {
@@ -1667,14 +1673,15 @@ function analyzeCause(regression, currentSnapshots) {
1667
1673
 
1668
1674
  // ../intelligence/src/insights.ts
1669
1675
  import { randomUUID } from "crypto";
1670
- function generateInsights(regressions, gains, health, causes) {
1676
+ var QUERY_LEVEL_PROVIDER = "all";
1677
+ function generateInsights(input) {
1671
1678
  const insights2 = [];
1672
1679
  const now = (/* @__PURE__ */ new Date()).toISOString();
1673
- for (const reg of regressions) {
1674
- const key = `${reg.query}:${reg.provider}`;
1675
- const cause = causes.get(key);
1680
+ const id = () => `ins_${randomUUID().slice(0, 8)}`;
1681
+ for (const reg of input.regressions) {
1682
+ const cause = input.causes.get(`${reg.query}:${reg.provider}`);
1676
1683
  insights2.push({
1677
- id: `ins_${randomUUID().slice(0, 8)}`,
1684
+ id: id(),
1678
1685
  type: "regression",
1679
1686
  severity: "high",
1680
1687
  title: `Lost ${reg.provider} citation for "${reg.query}"`,
@@ -1689,9 +1696,9 @@ function generateInsights(regressions, gains, health, causes) {
1689
1696
  createdAt: now
1690
1697
  });
1691
1698
  }
1692
- for (const gain of gains) {
1699
+ for (const gain of input.gains) {
1693
1700
  insights2.push({
1694
- id: `ins_${randomUUID().slice(0, 8)}`,
1701
+ id: id(),
1695
1702
  type: "gain",
1696
1703
  severity: "low",
1697
1704
  title: `New ${gain.provider} citation for "${gain.query}"`,
@@ -1705,24 +1712,263 @@ function generateInsights(regressions, gains, health, causes) {
1705
1712
  createdAt: now
1706
1713
  });
1707
1714
  }
1715
+ for (const fc of input.firstCitations) {
1716
+ insights2.push({
1717
+ id: id(),
1718
+ type: "first-citation",
1719
+ severity: "medium",
1720
+ title: `First citation for "${fc.query}" on ${fc.provider}`,
1721
+ query: fc.query,
1722
+ provider: fc.provider,
1723
+ recommendation: {
1724
+ action: "monitor",
1725
+ target: fc.citationUrl,
1726
+ reason: `"${fc.query}" had not been cited by any provider before this run. Monitor to confirm the citation persists.`
1727
+ },
1728
+ createdAt: now
1729
+ });
1730
+ }
1731
+ for (const pp of input.providerPickups) {
1732
+ insights2.push({
1733
+ id: id(),
1734
+ type: "provider-pickup",
1735
+ severity: "low",
1736
+ title: `${pp.provider} picked up "${pp.query}"`,
1737
+ query: pp.query,
1738
+ provider: pp.provider,
1739
+ recommendation: {
1740
+ action: "monitor",
1741
+ target: pp.citationUrl,
1742
+ reason: `${pp.provider} started citing "${pp.query}" alongside other providers. Monitor to confirm the citation persists.`
1743
+ },
1744
+ createdAt: now
1745
+ });
1746
+ }
1747
+ for (const gap of input.persistentGaps) {
1748
+ insights2.push({
1749
+ id: id(),
1750
+ type: "persistent-gap",
1751
+ severity: "medium",
1752
+ title: `"${gap.query}" uncited for ${gap.streak} runs`,
1753
+ query: gap.query,
1754
+ provider: QUERY_LEVEL_PROVIDER,
1755
+ recommendation: {
1756
+ action: "audit",
1757
+ reason: `No provider has cited "${gap.query}" for ${gap.streak} consecutive runs. Audit content and schema for this topic.`
1758
+ },
1759
+ createdAt: now
1760
+ });
1761
+ }
1762
+ for (const cg of input.competitorGains) {
1763
+ insights2.push({
1764
+ id: id(),
1765
+ type: "competitor-gained",
1766
+ severity: "medium",
1767
+ title: `${cg.competitorDomain} appeared on "${cg.query}"`,
1768
+ query: cg.query,
1769
+ provider: QUERY_LEVEL_PROVIDER,
1770
+ cause: {
1771
+ cause: "competitor_gain",
1772
+ competitorDomain: cg.competitorDomain,
1773
+ details: `Tracked competitor ${cg.competitorDomain} just got cited on "${cg.query}".`
1774
+ },
1775
+ recommendation: {
1776
+ action: "audit",
1777
+ reason: `Investigate ${cg.competitorDomain}'s content for "${cg.query}" \u2014 they just earned a citation here.`
1778
+ },
1779
+ createdAt: now
1780
+ });
1781
+ }
1782
+ for (const cl of input.competitorLosses) {
1783
+ insights2.push({
1784
+ id: id(),
1785
+ type: "competitor-lost",
1786
+ severity: "low",
1787
+ title: `${cl.competitorDomain} dropped from "${cl.query}"`,
1788
+ query: cl.query,
1789
+ provider: QUERY_LEVEL_PROVIDER,
1790
+ cause: {
1791
+ cause: "competitor_loss",
1792
+ competitorDomain: cl.competitorDomain,
1793
+ details: `Tracked competitor ${cl.competitorDomain} lost their citation on "${cl.query}".`
1794
+ },
1795
+ recommendation: {
1796
+ action: "monitor",
1797
+ reason: `Opportunity: ${cl.competitorDomain} just lost "${cl.query}". Tighten your own coverage to fill the gap.`
1798
+ },
1799
+ createdAt: now
1800
+ });
1801
+ }
1708
1802
  return insights2;
1709
1803
  }
1710
1804
 
1805
+ // ../intelligence/src/first-citations.ts
1806
+ function detectFirstCitations(currentRun, previousRun) {
1807
+ const previousCitedQueries = /* @__PURE__ */ new Set();
1808
+ for (const snap of previousRun.snapshots) {
1809
+ if (snap.cited) previousCitedQueries.add(snap.query);
1810
+ }
1811
+ const result = [];
1812
+ const seen = /* @__PURE__ */ new Set();
1813
+ for (const snap of currentRun.snapshots) {
1814
+ if (!snap.cited) continue;
1815
+ if (previousCitedQueries.has(snap.query)) continue;
1816
+ const key = `${snap.query}:${snap.provider}`;
1817
+ if (seen.has(key)) continue;
1818
+ seen.add(key);
1819
+ result.push({
1820
+ query: snap.query,
1821
+ provider: snap.provider,
1822
+ citationUrl: snap.citationUrl,
1823
+ position: snap.position,
1824
+ runId: currentRun.runId
1825
+ });
1826
+ }
1827
+ return result;
1828
+ }
1829
+
1830
+ // ../intelligence/src/provider-pickups.ts
1831
+ function detectProviderPickups(currentRun, previousRun) {
1832
+ const previousCitedQueries = /* @__PURE__ */ new Set();
1833
+ const previousCitedPairs = /* @__PURE__ */ new Set();
1834
+ for (const snap of previousRun.snapshots) {
1835
+ if (!snap.cited) continue;
1836
+ previousCitedQueries.add(snap.query);
1837
+ previousCitedPairs.add(`${snap.query}:${snap.provider}`);
1838
+ }
1839
+ const result = [];
1840
+ const seen = /* @__PURE__ */ new Set();
1841
+ for (const snap of currentRun.snapshots) {
1842
+ if (!snap.cited) continue;
1843
+ if (!previousCitedQueries.has(snap.query)) continue;
1844
+ const key = `${snap.query}:${snap.provider}`;
1845
+ if (previousCitedPairs.has(key)) continue;
1846
+ if (seen.has(key)) continue;
1847
+ seen.add(key);
1848
+ result.push({
1849
+ query: snap.query,
1850
+ provider: snap.provider,
1851
+ citationUrl: snap.citationUrl,
1852
+ position: snap.position,
1853
+ runId: currentRun.runId
1854
+ });
1855
+ }
1856
+ return result;
1857
+ }
1858
+
1859
+ // ../intelligence/src/persistent-gaps.ts
1860
+ var PERSISTENT_GAP_THRESHOLD = 3;
1861
+ function detectPersistentGaps(runs2, threshold = PERSISTENT_GAP_THRESHOLD) {
1862
+ if (runs2.length < threshold) return [];
1863
+ const queries2 = /* @__PURE__ */ new Set();
1864
+ for (const run of runs2) {
1865
+ for (const snap of run.snapshots) {
1866
+ if (snap.query) queries2.add(snap.query);
1867
+ }
1868
+ }
1869
+ const result = [];
1870
+ for (const query of queries2) {
1871
+ let streak = 0;
1872
+ for (let i = runs2.length - 1; i >= 0; i--) {
1873
+ const run = runs2[i];
1874
+ const snaps = run.snapshots.filter((s) => s.query === query);
1875
+ if (snaps.length === 0) break;
1876
+ const anyCited = snaps.some((s) => s.cited);
1877
+ if (anyCited) break;
1878
+ streak++;
1879
+ }
1880
+ if (streak >= threshold) {
1881
+ result.push({ query, streak, threshold });
1882
+ }
1883
+ }
1884
+ return result;
1885
+ }
1886
+
1887
+ // ../intelligence/src/competitor-changes.ts
1888
+ function buildCompetitorQueryMap(run, tracked) {
1889
+ const result = /* @__PURE__ */ new Map();
1890
+ for (const snap of run.snapshots) {
1891
+ if (!snap.query || !snap.competitorDomains || snap.competitorDomains.length === 0) continue;
1892
+ for (const domain of snap.competitorDomains) {
1893
+ if (!tracked.has(domain)) continue;
1894
+ const existing = result.get(domain) ?? /* @__PURE__ */ new Set();
1895
+ existing.add(snap.query);
1896
+ result.set(domain, existing);
1897
+ }
1898
+ }
1899
+ return result;
1900
+ }
1901
+ function detectCompetitorGains(currentRun, previousRun, opts) {
1902
+ const tracked = new Set(opts.trackedCompetitors);
1903
+ if (tracked.size === 0) return [];
1904
+ const currentMap = buildCompetitorQueryMap(currentRun, tracked);
1905
+ const previousMap = buildCompetitorQueryMap(previousRun, tracked);
1906
+ const result = [];
1907
+ for (const competitorDomain of tracked) {
1908
+ const currentQs = currentMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
1909
+ const previousQs = previousMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
1910
+ for (const query of currentQs) {
1911
+ if (previousQs.has(query)) continue;
1912
+ result.push({ query, competitorDomain });
1913
+ }
1914
+ }
1915
+ return result;
1916
+ }
1917
+ function detectCompetitorLosses(currentRun, previousRun, opts) {
1918
+ const tracked = new Set(opts.trackedCompetitors);
1919
+ if (tracked.size === 0) return [];
1920
+ const currentMap = buildCompetitorQueryMap(currentRun, tracked);
1921
+ const previousMap = buildCompetitorQueryMap(previousRun, tracked);
1922
+ const result = [];
1923
+ for (const competitorDomain of tracked) {
1924
+ const currentQs = currentMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
1925
+ const previousQs = previousMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
1926
+ for (const query of previousQs) {
1927
+ if (currentQs.has(query)) continue;
1928
+ result.push({ query, competitorDomain });
1929
+ }
1930
+ }
1931
+ return result;
1932
+ }
1933
+
1711
1934
  // ../intelligence/src/analyzer.ts
1712
- function analyzeRuns(currentRun, previousRun, allRuns) {
1935
+ function analyzeRuns(currentRun, previousRun, opts = {}) {
1936
+ const trackedCompetitors = opts.trackedCompetitors ?? [];
1937
+ const history = opts.history ?? [];
1938
+ const persistentGapThreshold = opts.persistentGapThreshold ?? PERSISTENT_GAP_THRESHOLD;
1713
1939
  const regressions = detectRegressions(currentRun, previousRun);
1714
1940
  const gains = detectGains(currentRun, previousRun);
1941
+ const firstCitations = detectFirstCitations(currentRun, previousRun);
1942
+ const providerPickups = detectProviderPickups(currentRun, previousRun);
1943
+ const competitorGains = detectCompetitorGains(currentRun, previousRun, { trackedCompetitors });
1944
+ const competitorLosses = detectCompetitorLosses(currentRun, previousRun, { trackedCompetitors });
1945
+ const persistentGaps = history.length >= persistentGapThreshold ? detectPersistentGaps(history, persistentGapThreshold) : [];
1715
1946
  const health = computeHealth(currentRun);
1716
- const trend = allRuns ? computeHealthTrend(allRuns) : void 0;
1947
+ const trend = history.length > 0 ? computeHealthTrend(history) : void 0;
1717
1948
  const causes = /* @__PURE__ */ new Map();
1718
1949
  for (const reg of regressions) {
1719
1950
  const cause = analyzeCause(reg, currentRun.snapshots);
1720
1951
  causes.set(`${reg.query}:${reg.provider}`, cause);
1721
1952
  }
1722
- const insights2 = generateInsights(regressions, gains, health, causes);
1953
+ const insights2 = generateInsights({
1954
+ regressions,
1955
+ gains,
1956
+ firstCitations,
1957
+ providerPickups,
1958
+ persistentGaps,
1959
+ competitorGains,
1960
+ competitorLosses,
1961
+ health,
1962
+ causes
1963
+ });
1723
1964
  return {
1724
1965
  regressions,
1725
1966
  gains,
1967
+ firstCitations,
1968
+ providerPickups,
1969
+ persistentGaps,
1970
+ competitorGains,
1971
+ competitorLosses,
1726
1972
  health,
1727
1973
  trend,
1728
1974
  insights: insights2
@@ -2148,6 +2394,447 @@ function isTrendBaseline(points) {
2148
2394
  return points.length < MIN_TREND_POINTS;
2149
2395
  }
2150
2396
 
2397
+ // ../intelligence/src/citation-scorecard.ts
2398
+ function buildCitationScorecard(snapshots, queryLookup) {
2399
+ if (snapshots.length === 0) {
2400
+ return { queries: [], providers: [], matrix: [], providerRates: [] };
2401
+ }
2402
+ const querySet = /* @__PURE__ */ new Set();
2403
+ const providerSet = /* @__PURE__ */ new Set();
2404
+ for (const snap of snapshots) {
2405
+ const q = queryLookup.byId.get(snap.queryId);
2406
+ if (!q) continue;
2407
+ querySet.add(q);
2408
+ providerSet.add(snap.provider);
2409
+ }
2410
+ const queryList = [...querySet].sort();
2411
+ const providerList = [...providerSet].sort();
2412
+ const matrix = queryList.map(
2413
+ () => providerList.map(() => null)
2414
+ );
2415
+ const providerCounts = /* @__PURE__ */ new Map();
2416
+ for (const snap of snapshots) {
2417
+ const q = queryLookup.byId.get(snap.queryId);
2418
+ if (!q) continue;
2419
+ const qi = queryList.indexOf(q);
2420
+ const pi = providerList.indexOf(snap.provider);
2421
+ if (qi < 0 || pi < 0) continue;
2422
+ matrix[qi][pi] = {
2423
+ citationState: snap.citationState === CitationStates.cited ? "cited" : "not-cited",
2424
+ answerMentioned: snap.answerMentioned ?? null,
2425
+ model: snap.model
2426
+ };
2427
+ const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
2428
+ counts.total++;
2429
+ if (snap.citationState === CitationStates.cited) counts.cited++;
2430
+ providerCounts.set(snap.provider, counts);
2431
+ }
2432
+ const providerRates = providerList.map((provider) => {
2433
+ const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
2434
+ const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
2435
+ return {
2436
+ provider,
2437
+ citedCount: counts.cited,
2438
+ totalCount: counts.total,
2439
+ citationRate
2440
+ };
2441
+ });
2442
+ return { queries: queryList, providers: providerList, matrix, providerRates };
2443
+ }
2444
+
2445
+ // ../intelligence/src/domain-matching.ts
2446
+ function citedDomainBelongsToProject(citedDomain, projectDomains) {
2447
+ const candidate = normalizeProjectDomain(citedDomain);
2448
+ for (const domain of projectDomains) {
2449
+ const normalized = normalizeProjectDomain(domain);
2450
+ if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
2451
+ }
2452
+ return false;
2453
+ }
2454
+
2455
+ // ../intelligence/src/competitor-landscape.ts
2456
+ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
2457
+ let projectCitationCount = 0;
2458
+ const competitorMap = /* @__PURE__ */ new Map();
2459
+ for (const c of competitorDomains) {
2460
+ competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
2461
+ }
2462
+ for (const snap of snapshots) {
2463
+ const q = queryLookup.byId.get(snap.queryId);
2464
+ const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
2465
+ if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
2466
+ projectCitationCount++;
2467
+ }
2468
+ for (const competitor of competitorDomains) {
2469
+ if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
2470
+ const entry = competitorMap.get(competitor);
2471
+ entry.count++;
2472
+ if (q) entry.queries.add(q);
2473
+ }
2474
+ const competitorNorm = normalizeUrlDomain(competitor);
2475
+ for (const gs of snap.groundingSources) {
2476
+ const host = normalizeUrlDomain(extractHostFromUri(gs.uri));
2477
+ if (!host) continue;
2478
+ if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
2479
+ const entry = competitorMap.get(competitor);
2480
+ const pageQueries = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
2481
+ if (q) pageQueries.add(q);
2482
+ entry.pages.set(gs.uri, pageQueries);
2483
+ }
2484
+ }
2485
+ }
2486
+ }
2487
+ const totalCitedSlots = projectCitationCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
2488
+ const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
2489
+ const total = snapshots.length;
2490
+ const ratio = total > 0 ? data.count / total : 0;
2491
+ let pressureLabel = "None";
2492
+ if (data.count > 0) {
2493
+ if (ratio >= 0.5) pressureLabel = "High";
2494
+ else if (ratio >= 0.2) pressureLabel = "Moderate";
2495
+ else pressureLabel = "Low";
2496
+ }
2497
+ const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
2498
+ const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
2499
+ return {
2500
+ domain,
2501
+ citationCount: data.count,
2502
+ totalCount: total,
2503
+ pressureLabel,
2504
+ citedQueries: [...data.queries].sort(),
2505
+ sharePct,
2506
+ theirCitedPages
2507
+ };
2508
+ });
2509
+ competitorRows.sort((a, b) => b.citationCount - a.citationCount);
2510
+ return { projectCitationCount, competitors: competitorRows };
2511
+ }
2512
+ function normalizeUrlDomain(domain) {
2513
+ return domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
2514
+ }
2515
+ function extractHostFromUri(uri) {
2516
+ try {
2517
+ return new URL(uri).hostname;
2518
+ } catch {
2519
+ return "";
2520
+ }
2521
+ }
2522
+
2523
+ // ../intelligence/src/mention-landscape.ts
2524
+ function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, queryLookup) {
2525
+ let projectMentionCount = 0;
2526
+ let totalAnswerSnapshots = 0;
2527
+ const competitorMap = /* @__PURE__ */ new Map();
2528
+ for (const c of competitorDomains) {
2529
+ competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
2530
+ }
2531
+ for (const snap of snapshots) {
2532
+ const text2 = snap.answerText;
2533
+ if (!text2) continue;
2534
+ totalAnswerSnapshots++;
2535
+ const q = queryLookup.byId.get(snap.queryId);
2536
+ const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
2537
+ text2,
2538
+ projectDisplayName,
2539
+ [...projectDomains]
2540
+ );
2541
+ if (projectMentioned) projectMentionCount++;
2542
+ for (const competitor of competitorDomains) {
2543
+ const brand = brandLabelFromDomain(competitor);
2544
+ const mentioned = determineAnswerMentioned(text2, brand, [competitor]);
2545
+ if (mentioned) {
2546
+ const entry = competitorMap.get(competitor);
2547
+ entry.count++;
2548
+ if (q) entry.queries.add(q);
2549
+ }
2550
+ }
2551
+ }
2552
+ const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
2553
+ const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
2554
+ const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
2555
+ let pressureLabel = "None";
2556
+ if (data.count > 0) {
2557
+ if (ratio >= 0.5) pressureLabel = "High";
2558
+ else if (ratio >= 0.2) pressureLabel = "Moderate";
2559
+ else pressureLabel = "Low";
2560
+ }
2561
+ const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
2562
+ return {
2563
+ domain,
2564
+ mentionCount: data.count,
2565
+ totalCount: totalAnswerSnapshots,
2566
+ pressureLabel,
2567
+ mentionedQueries: [...data.queries].sort(),
2568
+ sharePct
2569
+ };
2570
+ });
2571
+ competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
2572
+ return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
2573
+ }
2574
+
2575
+ // ../intelligence/src/ai-source-origin.ts
2576
+ var DEFAULT_TOP_SOURCE_DOMAINS_LIMIT = 20;
2577
+ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains, topDomainsLimit = DEFAULT_TOP_SOURCE_DOMAINS_LIMIT) {
2578
+ const categoryCounts = /* @__PURE__ */ new Map();
2579
+ const domainCounts = /* @__PURE__ */ new Map();
2580
+ let totalCitations = 0;
2581
+ for (const snap of snapshots) {
2582
+ for (const raw of snap.citedDomains) {
2583
+ if (citedDomainBelongsToProject(raw, projectDomains)) continue;
2584
+ const { category, label, domain } = categorizeSource(raw);
2585
+ const cat = categoryCounts.get(category) ?? { label, count: 0 };
2586
+ cat.count++;
2587
+ categoryCounts.set(category, cat);
2588
+ domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
2589
+ totalCitations++;
2590
+ }
2591
+ }
2592
+ const categories = [...categoryCounts.entries()].map(([category, { label, count }]) => ({
2593
+ category,
2594
+ label,
2595
+ count,
2596
+ sharePct: totalCitations > 0 ? Math.round(count / totalCitations * 100) : 0
2597
+ })).sort((a, b) => b.count - a.count);
2598
+ const topDomains = [...domainCounts.entries()].map(([domain, count]) => ({
2599
+ domain,
2600
+ count,
2601
+ isCompetitor: citedDomainBelongsToProject(domain, competitorDomains)
2602
+ })).sort((a, b) => b.count - a.count).slice(0, topDomainsLimit);
2603
+ return { categories, topDomains };
2604
+ }
2605
+
2606
+ // ../intelligence/src/movement-summary.ts
2607
+ function buildMovementSummary(currentSnapshots, previousSnapshots) {
2608
+ if (previousSnapshots.length === 0) {
2609
+ const citedCount = collectCitedQueryIds(currentSnapshots).size;
2610
+ const tone2 = citedCount > 0 ? "positive" : "neutral";
2611
+ return { gained: citedCount, lost: 0, tone: tone2, hasPreviousRun: false };
2612
+ }
2613
+ const latestCited = collectCitedQueryIds(currentSnapshots);
2614
+ const previousCited = collectCitedQueryIds(previousSnapshots);
2615
+ let gained = 0;
2616
+ let lost = 0;
2617
+ for (const id of latestCited) {
2618
+ if (!previousCited.has(id)) gained++;
2619
+ }
2620
+ for (const id of previousCited) {
2621
+ if (!latestCited.has(id)) lost++;
2622
+ }
2623
+ const tone = lost > gained ? "negative" : gained > lost ? "positive" : "neutral";
2624
+ return { gained, lost, tone, hasPreviousRun: true };
2625
+ }
2626
+ function collectCitedQueryIds(snapshots) {
2627
+ const cited = /* @__PURE__ */ new Set();
2628
+ for (const s of snapshots) {
2629
+ if (s.citationState === CitationStates.cited && s.queryId) cited.add(s.queryId);
2630
+ }
2631
+ return cited;
2632
+ }
2633
+
2634
+ // ../intelligence/src/score-tones.ts
2635
+ function scoreTone(score) {
2636
+ if (score >= 70) return "positive";
2637
+ if (score >= 40) return "caution";
2638
+ return "negative";
2639
+ }
2640
+ function pressureTone(label) {
2641
+ if (label === "High") return "negative";
2642
+ if (label === "Moderate") return "caution";
2643
+ return "neutral";
2644
+ }
2645
+ function gapTone(gapCount, totalCount) {
2646
+ if (gapCount === 0) return "positive";
2647
+ const ratio = totalCount > 0 ? gapCount / totalCount : 0;
2648
+ if (ratio >= 0.3) return "negative";
2649
+ return "caution";
2650
+ }
2651
+
2652
+ // ../intelligence/src/visibility-score.ts
2653
+ function buildVisibilityScore(snapshots, options) {
2654
+ const tooltip = 'Percentage of tracked queries where your domain is cited by at least one AI answer engine. A query is "visible" if any configured provider includes your site in its response.';
2655
+ if (snapshots.length === 0) {
2656
+ return {
2657
+ label: "Answer Visibility",
2658
+ value: "No data",
2659
+ delta: "Run a sweep first",
2660
+ tone: "neutral",
2661
+ description: "No visibility data yet. Trigger a run to start tracking.",
2662
+ tooltip,
2663
+ trend: []
2664
+ };
2665
+ }
2666
+ const queryCited = /* @__PURE__ */ new Map();
2667
+ for (const snap of snapshots) {
2668
+ if (!queryCited.has(snap.queryId)) queryCited.set(snap.queryId, false);
2669
+ if (snap.citationState === CitationStates.cited) queryCited.set(snap.queryId, true);
2670
+ }
2671
+ const totalCount = queryCited.size;
2672
+ const citedCount = [...queryCited.values()].filter(Boolean).length;
2673
+ const score = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
2674
+ const runProviders = new Set(snapshots.map((s) => s.provider));
2675
+ const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
2676
+ const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
2677
+ return {
2678
+ label: "Answer Visibility",
2679
+ value: `${score}`,
2680
+ delta: `${citedCount} of ${totalCount} queries visible`,
2681
+ tone: isPartialProviderRun ? "caution" : scoreTone(score),
2682
+ description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
2683
+ tooltip,
2684
+ trend: [],
2685
+ progress: score,
2686
+ providerCoverage: isPartialProviderRun ? `${runApiProviderCount} of ${options.configuredApiProviders.length} providers` : void 0
2687
+ };
2688
+ }
2689
+
2690
+ // ../intelligence/src/gap-query-score.ts
2691
+ function buildGapQueryScore(snapshots) {
2692
+ const tooltip = "Tracked queries where a competitor is cited in the latest run but your domain is not.";
2693
+ if (snapshots.length === 0) {
2694
+ return {
2695
+ label: "Gap Queries",
2696
+ value: "No data",
2697
+ delta: "Run a sweep first",
2698
+ tone: "neutral",
2699
+ description: "Run a visibility sweep to identify queries where competitors are cited and your domain is not.",
2700
+ tooltip,
2701
+ trend: []
2702
+ };
2703
+ }
2704
+ const byQuery = /* @__PURE__ */ new Map();
2705
+ for (const snap of snapshots) {
2706
+ const key = snap.queryId;
2707
+ const current = byQuery.get(key) ?? { cited: false, competitorOverlap: /* @__PURE__ */ new Set() };
2708
+ if (snap.citationState === CitationStates.cited) current.cited = true;
2709
+ for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
2710
+ byQuery.set(key, current);
2711
+ }
2712
+ const totalCount = byQuery.size;
2713
+ const gapCount = [...byQuery.values()].filter(
2714
+ (entry) => !entry.cited && entry.competitorOverlap.size > 0
2715
+ ).length;
2716
+ const gapQueryLabel = gapCount === 1 ? "query" : "queries";
2717
+ return {
2718
+ label: "Gap Queries",
2719
+ value: `${gapCount}`,
2720
+ delta: `${gapCount} of ${totalCount} queries at risk`,
2721
+ tone: gapTone(gapCount, totalCount),
2722
+ description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive query gaps detected in the latest visibility run.",
2723
+ tooltip,
2724
+ trend: [],
2725
+ progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
2726
+ };
2727
+ }
2728
+
2729
+ // ../intelligence/src/competitor-pressure-score.ts
2730
+ function buildCompetitorPressureScore(snapshots, competitorDomains, totalTrackedCompetitors) {
2731
+ const tooltip = "How often competitor domains appear alongside yours in AI answers. High pressure means competitors are frequently cited for the same queries.";
2732
+ const description = totalTrackedCompetitors > 0 ? `${totalTrackedCompetitors} competitor${totalTrackedCompetitors > 1 ? "s" : ""} tracked.` : "No competitors configured.";
2733
+ if (snapshots.length === 0 || competitorDomains.length === 0) {
2734
+ return {
2735
+ label: "Competitor Pressure",
2736
+ value: "None",
2737
+ delta: "No overlap detected",
2738
+ tone: pressureTone("None"),
2739
+ description,
2740
+ tooltip,
2741
+ trend: []
2742
+ };
2743
+ }
2744
+ const competitorSet = new Set(competitorDomains);
2745
+ let overlapCount = 0;
2746
+ for (const snap of snapshots) {
2747
+ if (snap.competitorOverlap.some((d) => competitorSet.has(d))) {
2748
+ overlapCount++;
2749
+ }
2750
+ }
2751
+ const ratio = overlapCount / snapshots.length;
2752
+ const label = ratio >= 0.5 ? "High" : ratio >= 0.2 ? "Moderate" : overlapCount > 0 ? "Low" : "None";
2753
+ return {
2754
+ label: "Competitor Pressure",
2755
+ value: label,
2756
+ delta: overlapCount > 0 ? `${overlapCount} overlapping citations` : "No overlap detected",
2757
+ tone: pressureTone(label),
2758
+ description,
2759
+ tooltip,
2760
+ trend: []
2761
+ };
2762
+ }
2763
+ function buildOverviewCompetitors(snapshots, competitors2, queryLookup) {
2764
+ const uniqueQueries = /* @__PURE__ */ new Set();
2765
+ for (const snap of snapshots) {
2766
+ if (snap.queryId) uniqueQueries.add(snap.queryId);
2767
+ }
2768
+ const renderQuery = (queryId) => queryLookup?.byId.get(queryId) ?? queryId;
2769
+ return competitors2.map((competitor, index2) => {
2770
+ const citedQuerySet = /* @__PURE__ */ new Set();
2771
+ for (const snap of snapshots) {
2772
+ if (snap.competitorOverlap.includes(competitor.domain) || snap.citedDomains.includes(competitor.domain)) {
2773
+ if (snap.queryId) citedQuerySet.add(snap.queryId);
2774
+ }
2775
+ }
2776
+ const citedQueries = [...citedQuerySet].map(renderQuery).sort();
2777
+ const ratio = uniqueQueries.size > 0 ? citedQuerySet.size / uniqueQueries.size : 0;
2778
+ const pressureLabel = ratio >= 0.5 ? "High" : ratio >= 0.2 ? "Moderate" : citedQuerySet.size > 0 ? "Low" : "None";
2779
+ return {
2780
+ id: competitor.id || `comp_${index2}`,
2781
+ domain: competitor.domain,
2782
+ citationCount: citedQuerySet.size,
2783
+ totalQueries: uniqueQueries.size,
2784
+ pressureLabel,
2785
+ citedQueries
2786
+ };
2787
+ });
2788
+ }
2789
+
2790
+ // ../intelligence/src/provider-scores.ts
2791
+ function buildProviderScores(snapshots) {
2792
+ const modelGroups = /* @__PURE__ */ new Map();
2793
+ for (const snap of snapshots) {
2794
+ const provider = snap.provider;
2795
+ const model = snap.model ?? null;
2796
+ const key = `${provider}::${model ?? "unknown"}`;
2797
+ const group = modelGroups.get(key) ?? { provider, model, cited: 0, total: 0 };
2798
+ group.total++;
2799
+ if (snap.citationState === CitationStates.cited) group.cited++;
2800
+ modelGroups.set(key, group);
2801
+ }
2802
+ return [...modelGroups.values()].sort(
2803
+ (a, b) => a.provider.localeCompare(b.provider) || (a.model ?? "").localeCompare(b.model ?? "")
2804
+ ).map(({ provider, model, cited, total }) => ({
2805
+ provider,
2806
+ model,
2807
+ score: total > 0 ? Math.round(cited / total * 100) : 0,
2808
+ cited,
2809
+ total
2810
+ }));
2811
+ }
2812
+
2813
+ // ../intelligence/src/run-history.ts
2814
+ var DEFAULT_RUN_HISTORY_LIMIT = 12;
2815
+ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LIMIT) {
2816
+ const recent = [...runs2].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2817
+ return recent.map((run) => {
2818
+ const snapshots = snapshotsByRunId.get(run.id) ?? [];
2819
+ const queryCited = /* @__PURE__ */ new Map();
2820
+ for (const snap of snapshots) {
2821
+ if (!queryCited.has(snap.queryId)) queryCited.set(snap.queryId, false);
2822
+ if (snap.citationState === CitationStates.cited) queryCited.set(snap.queryId, true);
2823
+ }
2824
+ const totalCount = queryCited.size;
2825
+ const citedCount = [...queryCited.values()].filter(Boolean).length;
2826
+ const citationRate = totalCount > 0 ? Math.round(citedCount / totalCount * 100) : 0;
2827
+ return {
2828
+ runId: run.id,
2829
+ createdAt: run.createdAt,
2830
+ citedCount,
2831
+ totalCount,
2832
+ citationRate,
2833
+ status: run.status
2834
+ };
2835
+ });
2836
+ }
2837
+
2151
2838
  // src/intelligence-service.ts
2152
2839
  import crypto from "crypto";
2153
2840
 
@@ -2190,6 +2877,7 @@ function createLogger(module) {
2190
2877
 
2191
2878
  // src/intelligence-service.ts
2192
2879
  var RECURRENCE_LOOKBACK_RUNS = 5;
2880
+ var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
2193
2881
  var log = createLogger("IntelligenceService");
2194
2882
  var IntelligenceService = class {
2195
2883
  constructor(db) {
@@ -2206,7 +2894,7 @@ var IntelligenceService = class {
2206
2894
  eq(runs.projectId, projectId),
2207
2895
  or(eq(runs.status, "completed"), eq(runs.status, "partial"))
2208
2896
  )
2209
- ).orderBy(desc(runs.createdAt)).limit(2).all();
2897
+ ).orderBy(desc(runs.finishedAt), desc(runs.createdAt)).limit(HISTORY_WINDOW_RUNS).all();
2210
2898
  if (recentRuns.length === 0) {
2211
2899
  log.info("intelligence.skip", { runId, reason: "no completed runs" });
2212
2900
  return null;
@@ -2221,10 +2909,14 @@ var IntelligenceService = class {
2221
2909
  log.info("intelligence.skip", { runId, reason: "no snapshots" });
2222
2910
  return null;
2223
2911
  }
2224
- const previousRunRecord = recentRuns.find((r) => r.id !== runId);
2912
+ const orderedRecent = [...recentRuns].reverse();
2913
+ const currentIdx = orderedRecent.findIndex((r) => r.id === runId);
2914
+ const previousRunRecord = currentIdx > 0 ? orderedRecent[currentIdx - 1] : null;
2225
2915
  const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
2916
+ const trackedCompetitors = this.loadTrackedCompetitors(projectId);
2917
+ const history = orderedRecent.slice(0, currentIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(r.id, projectId, r.finishedAt ?? r.createdAt));
2226
2918
  if (!previousRun) {
2227
- const result2 = analyzeRuns(currentRun, currentRun);
2919
+ const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
2228
2920
  log.info("intelligence.analyzed", {
2229
2921
  runId,
2230
2922
  regressions: 0,
@@ -2232,14 +2924,19 @@ var IntelligenceService = class {
2232
2924
  citedRate: result2.health.overallCitedRate,
2233
2925
  insights: 0
2234
2926
  });
2235
- this.persistResult({ ...result2, insights: [], regressions: [], gains: [] }, runId, projectId);
2927
+ this.persistResult(this.emptyAnalysisResult(result2), runId, projectId);
2236
2928
  return result2;
2237
2929
  }
2238
- const result = analyzeRuns(currentRun, previousRun);
2930
+ const result = analyzeRuns(currentRun, previousRun, { trackedCompetitors, history });
2239
2931
  log.info("intelligence.analyzed", {
2240
2932
  runId,
2241
2933
  regressions: result.regressions.length,
2242
2934
  gains: result.gains.length,
2935
+ firstCitations: result.firstCitations.length,
2936
+ providerPickups: result.providerPickups.length,
2937
+ persistentGaps: result.persistentGaps.length,
2938
+ competitorGains: result.competitorGains.length,
2939
+ competitorLosses: result.competitorLosses.length,
2243
2940
  citedRate: result.health.overallCitedRate,
2244
2941
  insights: result.insights.length
2245
2942
  });
@@ -2251,18 +2948,20 @@ var IntelligenceService = class {
2251
2948
  * Analyze a single run given an explicit previous run (or null for first run).
2252
2949
  * Used by backfill where we control the run ordering.
2253
2950
  */
2254
- analyzeRunWithPrevious(runRecord, previousRunRecord) {
2951
+ analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords) {
2255
2952
  const currentRun = this.buildRunData(runRecord.id, runRecord.projectId, runRecord.finishedAt ?? runRecord.createdAt);
2256
2953
  if (currentRun.snapshots.length === 0) {
2257
2954
  return null;
2258
2955
  }
2259
2956
  const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, previousRunRecord.projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
2957
+ const trackedCompetitors = this.loadTrackedCompetitors(runRecord.projectId);
2958
+ const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt));
2260
2959
  if (!previousRun) {
2261
- const result2 = analyzeRuns(currentRun, currentRun);
2262
- this.persistResult({ ...result2, insights: [], regressions: [], gains: [] }, runRecord.id, runRecord.projectId);
2960
+ const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
2961
+ this.persistResult(this.emptyAnalysisResult(result2), runRecord.id, runRecord.projectId);
2263
2962
  return result2;
2264
2963
  }
2265
- const result = analyzeRuns(currentRun, previousRun);
2964
+ const result = analyzeRuns(currentRun, previousRun, { trackedCompetitors, history });
2266
2965
  const tieredResult = this.tierResult(result, runRecord.id, runRecord.projectId);
2267
2966
  this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
2268
2967
  return tieredResult;
@@ -2302,7 +3001,9 @@ var IntelligenceService = class {
2302
3001
  const run = targetRuns[i];
2303
3002
  const globalIdx = allRuns.indexOf(run);
2304
3003
  const previousRun = globalIdx > 0 ? allRuns[globalIdx - 1] : null;
2305
- const result = this.analyzeRunWithPrevious(run, previousRun);
3004
+ const historyStart = Math.max(0, globalIdx - (HISTORY_WINDOW_RUNS - 1));
3005
+ const historyRecords = allRuns.slice(historyStart, globalIdx + 1);
3006
+ const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords);
2306
3007
  if (result) {
2307
3008
  processed++;
2308
3009
  totalInsights += result.insights.length;
@@ -2314,6 +3015,26 @@ var IntelligenceService = class {
2314
3015
  }
2315
3016
  return { processed, skipped, totalInsights };
2316
3017
  }
3018
+ loadTrackedCompetitors(projectId) {
3019
+ return this.db.select({ domain: competitors.domain }).from(competitors).where(eq(competitors.projectId, projectId)).all().map((r) => r.domain);
3020
+ }
3021
+ /**
3022
+ * Wipe transition signals from an analysis result while keeping health.
3023
+ * Used when there's no baseline (first run) to avoid emitting false transitions.
3024
+ */
3025
+ emptyAnalysisResult(result) {
3026
+ return {
3027
+ ...result,
3028
+ insights: [],
3029
+ regressions: [],
3030
+ gains: [],
3031
+ firstCitations: [],
3032
+ providerPickups: [],
3033
+ persistentGaps: [],
3034
+ competitorGains: [],
3035
+ competitorLosses: []
3036
+ };
3037
+ }
2317
3038
  persistResult(result, runId, projectId) {
2318
3039
  const previouslyDismissed = /* @__PURE__ */ new Set();
2319
3040
  const existingInsights = this.db.select({ query: insights.query, provider: insights.provider, type: insights.type, dismissed: insights.dismissed }).from(insights).where(eq(insights.runId, runId)).all();
@@ -2423,9 +3144,9 @@ var IntelligenceService = class {
2423
3144
  return {
2424
3145
  query: r.query ?? "",
2425
3146
  provider: r.provider,
2426
- cited: r.citationState === "cited",
3147
+ cited: r.citationState === CitationStates.cited,
2427
3148
  citationUrl: domains[0] ?? void 0,
2428
- competitorDomain: competitors2[0] ?? void 0
3149
+ competitorDomains: competitors2
2429
3150
  };
2430
3151
  });
2431
3152
  return { runId, projectId, completedAt, snapshots };
@@ -2476,6 +3197,18 @@ export {
2476
3197
  categorizeQueryByIntent,
2477
3198
  MIN_TREND_POINTS,
2478
3199
  isTrendBaseline,
3200
+ buildCitationScorecard,
3201
+ buildCompetitorLandscape,
3202
+ buildMentionLandscape,
3203
+ buildAiSourceOrigin,
3204
+ buildMovementSummary,
3205
+ buildVisibilityScore,
3206
+ buildGapQueryScore,
3207
+ buildCompetitorPressureScore,
3208
+ buildOverviewCompetitors,
3209
+ buildProviderScores,
3210
+ DEFAULT_RUN_HISTORY_LIMIT,
3211
+ buildRunHistory,
2479
3212
  createLogger,
2480
3213
  IntelligenceService
2481
3214
  };