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