impact-compass 0.2.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.
Files changed (44) hide show
  1. package/DOCUMENTATION.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +98 -0
  4. package/SKILLS.md +52 -0
  5. package/assets/top-banner-readme.png +0 -0
  6. package/example-coffee.json +15 -0
  7. package/example-input.json +15 -0
  8. package/example-output.json +352 -0
  9. package/example-react.json +15 -0
  10. package/package.json +52 -0
  11. package/src/cli.ts +219 -0
  12. package/src/domain/evidence.test.ts +93 -0
  13. package/src/domain/evidence.ts +99 -0
  14. package/src/domain/queryBundle.test.ts +67 -0
  15. package/src/domain/queryBundle.ts +116 -0
  16. package/src/domain/scoring.test.ts +184 -0
  17. package/src/domain/scoring.ts +322 -0
  18. package/src/services/comparison.test.ts +89 -0
  19. package/src/services/comparison.ts +84 -0
  20. package/src/services/demoReport.test.ts +37 -0
  21. package/src/services/demoReport.ts +32 -0
  22. package/src/services/publicEvidenceReport.test.ts +66 -0
  23. package/src/services/publicEvidenceReport.ts +82 -0
  24. package/src/services/queryDerivedReport.test.ts +68 -0
  25. package/src/services/queryDerivedReport.ts +227 -0
  26. package/src/services/reportBuilder.test.ts +90 -0
  27. package/src/services/reportBuilder.ts +219 -0
  28. package/src/services/reportInsights.ts +23 -0
  29. package/src/services/reportStorage.test.ts +77 -0
  30. package/src/services/reportStorage.ts +113 -0
  31. package/src/services/reportTypes.ts +49 -0
  32. package/src/services/sources/extendedAdapters.ts +218 -0
  33. package/src/services/sources/githubSource.test.ts +48 -0
  34. package/src/services/sources/githubSource.ts +63 -0
  35. package/src/services/sources/hackerNewsSource.test.ts +80 -0
  36. package/src/services/sources/hackerNewsSource.ts +117 -0
  37. package/src/services/sources/itunesSource.ts +62 -0
  38. package/src/services/sources/npmSource.ts +65 -0
  39. package/src/services/sources/redditSource.ts +68 -0
  40. package/src/services/sources/sourceAdapter.ts +12 -0
  41. package/src/services/sources/stackExchangeSource.ts +62 -0
  42. package/src/services/sources/wikipediaSource.ts +62 -0
  43. package/src/services/therapySeed.ts +183 -0
  44. package/tsconfig.json +34 -0
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "impact-compass",
3
+ "version": "0.2.0",
4
+ "description": "Purely statistical, deterministic project idea validator and market research CLI.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/adityachauhan0/impact-compass.git"
9
+ },
10
+ "private": false,
11
+ "files": [
12
+ "assets/",
13
+ "src/",
14
+ "DOCUMENTATION.md",
15
+ "SKILLS.md",
16
+ "example-*.json",
17
+ "tsconfig.json"
18
+ ],
19
+ "scripts": {
20
+ "dev": "next dev",
21
+ "build": "next build",
22
+ "test": "vitest run",
23
+ "start": "next start",
24
+ "lint": "eslint",
25
+ "cli": "npx tsx src/cli.ts"
26
+ },
27
+ "dependencies": {
28
+ "katex": "^0.17.0",
29
+ "lucide-react": "^1.16.0",
30
+ "next": "16.2.6",
31
+ "react": "19.2.4",
32
+ "react-dom": "19.2.4"
33
+ },
34
+ "devDependencies": {
35
+ "@tailwindcss/postcss": "^4",
36
+ "@testing-library/jest-dom": "^6.9.1",
37
+ "@testing-library/react": "^16.3.2",
38
+ "@types/node": "^20",
39
+ "@types/react": "^19",
40
+ "@types/react-dom": "^19",
41
+ "eslint": "^9",
42
+ "eslint-config-next": "16.2.6",
43
+ "jsdom": "^29.1.1",
44
+ "playwright": "^1.60.0",
45
+ "tailwindcss": "^4",
46
+ "typescript": "^5",
47
+ "vitest": "^4.1.7"
48
+ },
49
+ "overrides": {
50
+ "postcss": "8.5.15"
51
+ }
52
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,219 @@
1
+ import { readFileSync, writeFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { createLockedQueryBundle, type QueryBundleForm } from "./domain/queryBundle";
4
+ import { loadPublicEvidenceReport } from "./services/publicEvidenceReport";
5
+ import type { IdeaBrief } from "./services/reportTypes";
6
+
7
+ // Simple ANSI color definitions
8
+ const ANSI_RESET = "\x1b[0m";
9
+ const ANSI_BOLD = "\x1b[1m";
10
+ const ANSI_GREEN = "\x1b[32m";
11
+ const ANSI_YELLOW = "\x1b[33m";
12
+ const ANSI_CYAN = "\x1b[36m";
13
+ const ANSI_GRAY = "\x1b[90m";
14
+
15
+ type CliInput = {
16
+ idea: IdeaBrief;
17
+ queryBundleForm: QueryBundleForm;
18
+ };
19
+
20
+ function drawBar(score: number, width: number = 30): string {
21
+ const filledLength = Math.round((score / 100) * width);
22
+ const emptyLength = width - filledLength;
23
+ const filled = "█".repeat(filledLength);
24
+ const empty = "░".repeat(emptyLength);
25
+
26
+ let color = ANSI_GREEN;
27
+ if (score < 50) color = ANSI_YELLOW;
28
+ if (score < 25) color = ANSI_GRAY;
29
+
30
+ return `${color}${filled}${ANSI_RESET}${ANSI_GRAY}${empty}${ANSI_RESET}`;
31
+ }
32
+
33
+ const banner = `
34
+ ${ANSI_CYAN}${ANSI_BOLD} ___ _ ____
35
+ |_ _|_ __ ___ _ __ __ _ ___| |_ / ___|___ _ __ ___ _ __ __ _ ___ ___
36
+ | || '_ \` _ \\| '_ \\ / _\` |/ __| __| | | / _ \\| '_ \` _ \\| '_ \\ / _\` / __/ __|
37
+ | || | | | | | |_) | (_| | (__| |_ | |__| (_) | | | | | | |_) | (_| \\__ \\__ \\
38
+ |___|_| |_| |_| .__/ \\__,_|\\___|\\__| \\____\\___/|_| |_| |_| .__/ \\__,_|___/___/
39
+ |_| |_| ${ANSI_RESET}
40
+ `;
41
+
42
+ function wrapText(text: string, width: number): string[] {
43
+ const words = text.split(" ");
44
+ const lines: string[] = [];
45
+ let currentLine = "";
46
+
47
+ for (const word of words) {
48
+ if (currentLine.length + word.length + 1 > width) {
49
+ lines.push(currentLine);
50
+ currentLine = word;
51
+ } else {
52
+ currentLine = currentLine ? `${currentLine} ${word}` : word;
53
+ }
54
+ }
55
+ if (currentLine) lines.push(currentLine);
56
+ return lines;
57
+ }
58
+
59
+ async function main() {
60
+ const args = process.argv.slice(2);
61
+ if (args.length < 2) {
62
+ console.error(`Usage: npm run cli <input.json> <output.json>`);
63
+ process.exit(1);
64
+ }
65
+
66
+ const inputPath = resolve(process.cwd(), args[0]);
67
+ const outputPath = resolve(process.cwd(), args[1]);
68
+
69
+ let inputData: CliInput;
70
+ try {
71
+ const fileContent = readFileSync(inputPath, "utf-8");
72
+ inputData = JSON.parse(fileContent);
73
+ } catch (error) {
74
+ console.error(`Error reading or parsing input file: ${(error as Error).message}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ // 1. Create Query Bundle
79
+ const queryBundle = createLockedQueryBundle(inputData.queryBundleForm);
80
+
81
+ // --- REAL EVIDENCE COLLECTION LOGS ---
82
+ console.log(`${ANSI_BOLD}${ANSI_GRAY}▶ INITIATING LIVE EVIDENCE COLLECTION PIPELINE...${ANSI_RESET}`);
83
+
84
+ const fetchJson = async (url: string) => {
85
+ let apiName = "Live API";
86
+ try {
87
+ const hostname = new URL(url).hostname;
88
+ if (hostname.includes('github')) apiName = 'GitHub';
89
+ else if (hostname.includes('algolia') || url.includes('hn.')) apiName = 'HackerNews';
90
+ else if (hostname.includes('reddit')) apiName = 'Reddit';
91
+ else if (hostname.includes('stackexchange')) apiName = 'StackExchange';
92
+ else if (hostname.includes('npmjs')) apiName = 'NPM';
93
+ else if (hostname.includes('wikipedia')) apiName = 'Wikipedia';
94
+ else if (hostname.includes('apple')) apiName = 'App Store';
95
+ else apiName = hostname;
96
+ } catch {}
97
+
98
+ console.log(` ${ANSI_CYAN}FETCH${ANSI_RESET} [${apiName} API] => ${url}`);
99
+ const res = await fetch(url, { headers: { "User-Agent": "Impact-Compass-CLI" } });
100
+ if (!res.ok) {
101
+ throw new Error(`Failed to fetch ${url}: ${res.statusText}`);
102
+ }
103
+ return res.json();
104
+ };
105
+
106
+ // 2. Load Public Evidence Report using the full, robust architecture
107
+ const report = await loadPublicEvidenceReport({
108
+ idea: inputData.idea,
109
+ queryBundle,
110
+ fetchJson,
111
+ minimumLoadMs: 0 // Run fast in CLI
112
+ });
113
+
114
+ console.log(`${ANSI_BOLD}${ANSI_GREEN}✔ API COLLECTION COMPLETE. (STRICT LIVE DATA ONLY)${ANSI_RESET}\n`);
115
+
116
+ const summary = new Map<string, { count: number, typeLabel: string, sourceType: string, query: string, source: string }>();
117
+
118
+ for (const item of report.evidence) {
119
+ if (!item.included) continue;
120
+ const typeLabel = `${ANSI_CYAN}LIVE${ANSI_RESET}`;
121
+ const key = `${typeLabel}-${item.source}-${item.query}`;
122
+
123
+ if (!summary.has(key)) {
124
+ summary.set(key, { count: 1, typeLabel, sourceType: item.sourceType, query: item.query, source: item.source });
125
+ } else {
126
+ summary.get(key)!.count++;
127
+ }
128
+ }
129
+
130
+ for (const info of summary.values()) {
131
+ console.log(` [${info.typeLabel}] ${info.source.padEnd(14, " ")} => Found ${info.count} ${info.sourceType}s for query: "${info.query}"`);
132
+ }
133
+
134
+ console.log("");
135
+
136
+ // Output Aesthetic ASCII report
137
+ console.log(banner);
138
+
139
+ const boxWidth = 74;
140
+ const drawTop = (title: string) => ` ┌─ ${ANSI_BOLD}${title.padEnd(boxWidth - 5, " ")}${ANSI_RESET}┐`;
141
+ const drawBot = () => ` └${"─".repeat(boxWidth - 2)}┘`;
142
+ const drawLine = (text: string) => {
143
+ // Correctly strip ANSI codes matching the exact escape sequence
144
+ const rawLen = text.replace(/\x1b\[[0-9;]*m/g, "").length;
145
+ return ` │ ${text}${" ".repeat(Math.max(0, boxWidth - 4 - rawLen))} │`;
146
+ };
147
+
148
+ // IDEA BRIEF BOX
149
+ console.log(drawTop("IDEA BRIEF"));
150
+ console.log(drawLine(`${ANSI_GRAY}Name :${ANSI_RESET} ${report.idea.name}`));
151
+ console.log(drawLine(`${ANSI_GRAY}Target User :${ANSI_RESET} ${report.idea.targetUser}`));
152
+ console.log(drawLine(`${ANSI_GRAY}Lens :${ANSI_RESET} ${report.idea.lens}`));
153
+ const wrappedProblem = wrapText(report.idea.problem, boxWidth - 18);
154
+ console.log(drawLine(`${ANSI_GRAY}Problem :${ANSI_RESET} ${wrappedProblem[0]}`));
155
+ for (let i = 1; i < wrappedProblem.length; i++) {
156
+ console.log(drawLine(` ${wrappedProblem[i]}`));
157
+ }
158
+ console.log(drawBot());
159
+ console.log("");
160
+
161
+ // OVERALL SCORE BOX
162
+ let scoreColor = ANSI_GREEN;
163
+ if (report.summary.score < 60) scoreColor = ANSI_YELLOW;
164
+ if (report.summary.score < 40) scoreColor = ANSI_GRAY;
165
+
166
+ console.log(drawTop("OVERALL COMPASS SCORE"));
167
+ console.log(drawLine(""));
168
+ const scoreStr = `[ ${scoreColor}${ANSI_BOLD}${report.summary.score} / 100${ANSI_RESET} ]`;
169
+ console.log(drawLine(` ${scoreStr}`));
170
+ console.log(drawLine(""));
171
+ console.log(drawLine(`${ANSI_GRAY}Confidence :${ANSI_RESET} ${report.summary.confidence} (±${report.summary.uncertainty})`));
172
+ const wrappedQ = wrapText(report.queryQuality.warning, boxWidth - 18);
173
+ console.log(drawLine(`${ANSI_GRAY}Query Qual. :${ANSI_RESET} ${ANSI_BOLD}${report.queryQuality.label}${ANSI_RESET} - ${wrappedQ[0]}`));
174
+ for (let i = 1; i < wrappedQ.length; i++) {
175
+ console.log(drawLine(` ${wrappedQ[i]}`));
176
+ }
177
+ if (report.integrity.warnings.length > 0) {
178
+ console.log(drawLine(""));
179
+ console.log(drawLine(`${ANSI_YELLOW}Warnings :${ANSI_RESET} ${report.integrity.warnings[0]}`));
180
+ }
181
+ console.log(drawBot());
182
+ console.log("");
183
+
184
+ // PILLARS BOX
185
+ console.log(drawTop("PILLAR BREAKDOWN"));
186
+ console.log(drawLine(""));
187
+ for (const pillar of report.pillars) {
188
+ const label = pillar.label.padEnd(17, " ");
189
+ const bar = drawBar(pillar.score, 35);
190
+ const scoreText = pillar.score.toString().padStart(3, " ");
191
+ console.log(drawLine(` ${ANSI_BOLD}${label}${ANSI_RESET} [${bar}] ${scoreText}`));
192
+ }
193
+ console.log(drawLine(""));
194
+ console.log(drawBot());
195
+ console.log("");
196
+
197
+ // INTERPRETATION
198
+ console.log(drawTop("INTERPRETATION"));
199
+ const wrappedInt = wrapText(report.interpretation, boxWidth - 4);
200
+ for (const line of wrappedInt) {
201
+ console.log(drawLine(line));
202
+ }
203
+ console.log(drawBot());
204
+ console.log("");
205
+
206
+ // Write the output to a JSON file
207
+ try {
208
+ writeFileSync(outputPath, JSON.stringify(report, null, 2), "utf-8");
209
+ console.log(` ${ANSI_GREEN}✔ Report securely saved to ${outputPath}${ANSI_RESET}\n`);
210
+ } catch (error) {
211
+ console.error(`\nError writing output file: ${(error as Error).message}`);
212
+ process.exit(1);
213
+ }
214
+ }
215
+
216
+ main().catch((err) => {
217
+ console.error("Fatal error:", err);
218
+ process.exit(1);
219
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ calculateEvidenceIntegrity,
4
+ filterEvidenceForBundle,
5
+ type EvidenceItem,
6
+ } from "./evidence";
7
+ import { createLockedQueryBundle } from "./queryBundle";
8
+
9
+ const evidence: EvidenceItem[] = [
10
+ {
11
+ id: "pain-1",
12
+ source: "Reddit",
13
+ sourceType: "post",
14
+ date: "2026-05-08",
15
+ query: "therapist paperwork",
16
+ snippet: "Therapists mention too much paperwork after sessions.",
17
+ link: "https://www.reddit.com/",
18
+ metricContribution: "Pain",
19
+ included: true,
20
+ reason: "Seed inclusion",
21
+ duplicateCluster: "paperwork-1",
22
+ signalStrength: 82,
23
+ },
24
+ {
25
+ id: "excluded-1",
26
+ source: "Reddit",
27
+ sourceType: "post",
28
+ date: "2026-05-02",
29
+ query: "therapy notes",
30
+ snippet: "Physical therapy treatment notes workflow.",
31
+ link: "https://www.reddit.com/",
32
+ metricContribution: "Demand",
33
+ included: true,
34
+ reason: "Seed inclusion",
35
+ duplicateCluster: "physical-1",
36
+ signalStrength: 40,
37
+ },
38
+ {
39
+ id: "demand-1",
40
+ source: "YouTube",
41
+ sourceType: "video",
42
+ date: "2026-04-18",
43
+ query: "SOAP notes",
44
+ snippet: "Tutorial demand around writing faster SOAP notes.",
45
+ link: "https://www.youtube.com/",
46
+ metricContribution: "Demand",
47
+ included: true,
48
+ reason: "Seed inclusion",
49
+ duplicateCluster: "soap-1",
50
+ signalStrength: 64,
51
+ },
52
+ ];
53
+
54
+ describe("evidence domain", () => {
55
+ it("marks evidence excluded when it hits bundle exclusions", () => {
56
+ const bundle = createLockedQueryBundle({
57
+ problemKeywords: "therapist paperwork, SOAP notes",
58
+ solutionKeywords: "session note automation",
59
+ audienceKeywords: "solo therapists",
60
+ competitorKeywords: "",
61
+ exclusions: "physical therapy",
62
+ });
63
+
64
+ const filtered = filterEvidenceForBundle(evidence, bundle);
65
+
66
+ expect(filtered.find((item) => item.id === "excluded-1")).toMatchObject({
67
+ included: false,
68
+ metricContribution: "Excluded",
69
+ reason: "Excluded by query bundle term: physical therapy.",
70
+ });
71
+ expect(filtered.find((item) => item.id === "pain-1")?.included).toBe(true);
72
+ });
73
+
74
+ it("calculates integrity inputs from included evidence only", () => {
75
+ const integrity = calculateEvidenceIntegrity(
76
+ filterEvidenceForBundle(
77
+ evidence,
78
+ createLockedQueryBundle({
79
+ problemKeywords: "therapist paperwork, SOAP notes",
80
+ solutionKeywords: "session note automation",
81
+ audienceKeywords: "solo therapists",
82
+ competitorKeywords: "",
83
+ exclusions: "physical therapy",
84
+ }),
85
+ ),
86
+ );
87
+
88
+ expect(integrity.sourceDiversity).toBe(2);
89
+ expect(integrity.relevantEvidenceCount).toBe(2);
90
+ expect(integrity.dominantSourceShare).toBe(0.5);
91
+ expect(integrity.relevancePrecision).toBe(67);
92
+ });
93
+ });
@@ -0,0 +1,99 @@
1
+ import type { QueryBundle } from "./queryBundle";
2
+
3
+ export type EvidenceSource =
4
+ | "Hacker News"
5
+ | "Reddit"
6
+ | "GitHub"
7
+ | "Product Hunt"
8
+ | "Stack Exchange"
9
+ | "YouTube"
10
+ | "npm"
11
+ | "PyPI";
12
+
13
+ export type SourceType =
14
+ | "post"
15
+ | "comment"
16
+ | "repo"
17
+ | "package"
18
+ | "launch"
19
+ | "question"
20
+ | "video";
21
+
22
+ export type MetricContribution =
23
+ | "Demand"
24
+ | "Pain"
25
+ | "Momentum"
26
+ | "Competition Fit"
27
+ | "Activity"
28
+ | "Channel Fit"
29
+ | "Evidence Quality"
30
+ | "Excluded";
31
+
32
+ export type EvidenceItem = {
33
+ id: string;
34
+ source: EvidenceSource;
35
+ sourceType: SourceType;
36
+ date: string;
37
+ query: string;
38
+ snippet: string;
39
+ link: string;
40
+ metricContribution: MetricContribution;
41
+ included: boolean;
42
+ reason: string;
43
+ duplicateCluster: string;
44
+ signalStrength: number;
45
+ };
46
+
47
+ export type EvidenceIntegrityInput = {
48
+ sourceDiversity: number;
49
+ relevancePrecision: number;
50
+ relevantEvidenceCount: number;
51
+ dominantSourceShare: number;
52
+ };
53
+
54
+ function includesTerm(value: string, term: string) {
55
+ return value.toLocaleLowerCase().includes(term.toLocaleLowerCase());
56
+ }
57
+
58
+ export function filterEvidenceForBundle(
59
+ evidence: EvidenceItem[],
60
+ bundle: QueryBundle,
61
+ ): EvidenceItem[] {
62
+ return evidence.map((item) => {
63
+ const searchableText = `${item.query} ${item.snippet}`;
64
+ const exclusion = bundle.exclusions.find((term) => includesTerm(searchableText, term));
65
+
66
+ if (!exclusion) {
67
+ return item;
68
+ }
69
+
70
+ return {
71
+ ...item,
72
+ included: false,
73
+ metricContribution: "Excluded",
74
+ reason: `Excluded by query bundle term: ${exclusion}.`,
75
+ };
76
+ });
77
+ }
78
+
79
+ export function calculateEvidenceIntegrity(
80
+ evidence: EvidenceItem[],
81
+ ): EvidenceIntegrityInput {
82
+ const included = evidence.filter((item) => item.included);
83
+ const sourceCounts = included.reduce<Record<string, number>>((counts, item) => {
84
+ counts[item.source] = (counts[item.source] ?? 0) + 1;
85
+ return counts;
86
+ }, {});
87
+ const dominantSourceCount = Math.max(0, ...Object.values(sourceCounts));
88
+
89
+ return {
90
+ sourceDiversity: Object.keys(sourceCounts).length,
91
+ relevancePrecision:
92
+ evidence.length === 0 ? 0 : Math.round((included.length / evidence.length) * 100),
93
+ relevantEvidenceCount: included.length,
94
+ dominantSourceShare:
95
+ included.length === 0
96
+ ? 0
97
+ : Number((dominantSourceCount / included.length).toFixed(2)),
98
+ };
99
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createLockedQueryBundle,
4
+ evaluateQueryQuality,
5
+ parseTermList,
6
+ } from "./queryBundle";
7
+
8
+ describe("query bundle domain", () => {
9
+ it("parses comma and newline separated terms without duplicates", () => {
10
+ expect(
11
+ parseTermList("therapist paperwork, SOAP notes\nsoap notes; therapy docs,, "),
12
+ ).toEqual(["therapist paperwork", "SOAP notes", "therapy docs"]);
13
+ });
14
+
15
+ it("creates an immutable locked query bundle from editable form fields", () => {
16
+ const bundle = createLockedQueryBundle({
17
+ problemKeywords: "invoice follow up, late payments",
18
+ solutionKeywords: "invoice reminder automation",
19
+ audienceKeywords: "freelancers, consultants",
20
+ competitorKeywords: "HoneyBook alternative",
21
+ exclusions: "medical billing",
22
+ });
23
+
24
+ expect(bundle).toMatchObject({
25
+ version: 1,
26
+ locked: true,
27
+ problemKeywords: ["invoice follow up", "late payments"],
28
+ solutionKeywords: ["invoice reminder automation"],
29
+ audienceKeywords: ["freelancers", "consultants"],
30
+ competitorKeywords: ["HoneyBook alternative"],
31
+ exclusions: ["medical billing"],
32
+ });
33
+ expect(bundle.painPhrases).toContain("manual process");
34
+ });
35
+
36
+ it("labels a focused bundle with exclusions as strong", () => {
37
+ const quality = evaluateQueryQuality(
38
+ createLockedQueryBundle({
39
+ problemKeywords: "therapist paperwork, SOAP notes",
40
+ solutionKeywords: "session note automation",
41
+ audienceKeywords: "solo therapists",
42
+ competitorKeywords: "EHR notes",
43
+ exclusions: "physical therapy",
44
+ }),
45
+ );
46
+
47
+ expect(quality.label).toBe("Strong");
48
+ expect(quality.warning).toBe(
49
+ "Ambiguity controlled with audience terms and exclusions.",
50
+ );
51
+ });
52
+
53
+ it("flags broad bundles with weak precision", () => {
54
+ const quality = evaluateQueryQuality(
55
+ createLockedQueryBundle({
56
+ problemKeywords: "AI",
57
+ solutionKeywords: "",
58
+ audienceKeywords: "",
59
+ competitorKeywords: "",
60
+ exclusions: "",
61
+ }),
62
+ );
63
+
64
+ expect(quality.label).toBe("Too broad");
65
+ expect(quality.warning).toBe("Add audience terms and exclusions before scoring.");
66
+ });
67
+ });
@@ -0,0 +1,116 @@
1
+ export type QueryBundleForm = {
2
+ problemKeywords: string;
3
+ solutionKeywords: string;
4
+ audienceKeywords: string;
5
+ competitorKeywords: string;
6
+ exclusions: string;
7
+ };
8
+
9
+ export type QueryBundle = {
10
+ version: number;
11
+ locked: boolean;
12
+ problemKeywords: string[];
13
+ solutionKeywords: string[];
14
+ audienceKeywords: string[];
15
+ competitorKeywords: string[];
16
+ painPhrases: string[];
17
+ exclusions: string[];
18
+ };
19
+
20
+ export type QueryQualityLabel =
21
+ | "Too broad"
22
+ | "Too narrow"
23
+ | "Ambiguous"
24
+ | "Good enough"
25
+ | "Strong";
26
+
27
+ export type QueryQuality = {
28
+ label: QueryQualityLabel;
29
+ warning: string;
30
+ };
31
+
32
+ const defaultPainPhrases = [
33
+ "manual process",
34
+ "too much paperwork",
35
+ "after-hours notes",
36
+ "how do I reduce",
37
+ "alternative to",
38
+ "too expensive",
39
+ "workaround",
40
+ ];
41
+
42
+ export function parseTermList(value: string) {
43
+ const seen = new Set<string>();
44
+
45
+ return value
46
+ .split(/[,\n;]/)
47
+ .map((term) => term.trim())
48
+ .filter(Boolean)
49
+ .filter((term) => {
50
+ const key = term.toLocaleLowerCase();
51
+
52
+ if (seen.has(key)) {
53
+ return false;
54
+ }
55
+
56
+ seen.add(key);
57
+ return true;
58
+ });
59
+ }
60
+
61
+ export function createLockedQueryBundle(
62
+ form: QueryBundleForm,
63
+ options: { version?: number; painPhrases?: string[] } = {},
64
+ ): QueryBundle {
65
+ return {
66
+ version: options.version ?? 1,
67
+ locked: true,
68
+ problemKeywords: parseTermList(form.problemKeywords),
69
+ solutionKeywords: parseTermList(form.solutionKeywords),
70
+ audienceKeywords: parseTermList(form.audienceKeywords),
71
+ competitorKeywords: parseTermList(form.competitorKeywords),
72
+ painPhrases: options.painPhrases ?? defaultPainPhrases,
73
+ exclusions: parseTermList(form.exclusions),
74
+ };
75
+ }
76
+
77
+ export function evaluateQueryQuality(bundle: QueryBundle): QueryQuality {
78
+ const totalTerms =
79
+ bundle.problemKeywords.length +
80
+ bundle.solutionKeywords.length +
81
+ bundle.audienceKeywords.length +
82
+ bundle.competitorKeywords.length;
83
+
84
+ if (bundle.problemKeywords.length <= 1 && bundle.audienceKeywords.length === 0) {
85
+ return {
86
+ label: "Too broad",
87
+ warning: "Add audience terms and exclusions before scoring.",
88
+ };
89
+ }
90
+
91
+ if (totalTerms <= 2) {
92
+ return {
93
+ label: "Too narrow",
94
+ warning: "Add solution, audience, or competitor terms to improve coverage.",
95
+ };
96
+ }
97
+
98
+ if (bundle.exclusions.length === 0) {
99
+ return {
100
+ label: "Ambiguous",
101
+ warning: "Add exclusions for wrong meanings before scoring.",
102
+ };
103
+ }
104
+
105
+ if (bundle.audienceKeywords.length > 0 && bundle.exclusions.length > 0) {
106
+ return {
107
+ label: "Strong",
108
+ warning: "Ambiguity controlled with audience terms and exclusions.",
109
+ };
110
+ }
111
+
112
+ return {
113
+ label: "Good enough",
114
+ warning: "Query bundle has enough terms for a preview-quality scan.",
115
+ };
116
+ }