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.
- package/DOCUMENTATION.md +47 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/SKILLS.md +52 -0
- package/assets/top-banner-readme.png +0 -0
- package/example-coffee.json +15 -0
- package/example-input.json +15 -0
- package/example-output.json +352 -0
- package/example-react.json +15 -0
- package/package.json +52 -0
- package/src/cli.ts +219 -0
- package/src/domain/evidence.test.ts +93 -0
- package/src/domain/evidence.ts +99 -0
- package/src/domain/queryBundle.test.ts +67 -0
- package/src/domain/queryBundle.ts +116 -0
- package/src/domain/scoring.test.ts +184 -0
- package/src/domain/scoring.ts +322 -0
- package/src/services/comparison.test.ts +89 -0
- package/src/services/comparison.ts +84 -0
- package/src/services/demoReport.test.ts +37 -0
- package/src/services/demoReport.ts +32 -0
- package/src/services/publicEvidenceReport.test.ts +66 -0
- package/src/services/publicEvidenceReport.ts +82 -0
- package/src/services/queryDerivedReport.test.ts +68 -0
- package/src/services/queryDerivedReport.ts +227 -0
- package/src/services/reportBuilder.test.ts +90 -0
- package/src/services/reportBuilder.ts +219 -0
- package/src/services/reportInsights.ts +23 -0
- package/src/services/reportStorage.test.ts +77 -0
- package/src/services/reportStorage.ts +113 -0
- package/src/services/reportTypes.ts +49 -0
- package/src/services/sources/extendedAdapters.ts +218 -0
- package/src/services/sources/githubSource.test.ts +48 -0
- package/src/services/sources/githubSource.ts +63 -0
- package/src/services/sources/hackerNewsSource.test.ts +80 -0
- package/src/services/sources/hackerNewsSource.ts +117 -0
- package/src/services/sources/itunesSource.ts +62 -0
- package/src/services/sources/npmSource.ts +65 -0
- package/src/services/sources/redditSource.ts +68 -0
- package/src/services/sources/sourceAdapter.ts +12 -0
- package/src/services/sources/stackExchangeSource.ts +62 -0
- package/src/services/sources/wikipediaSource.ts +62 -0
- package/src/services/therapySeed.ts +183 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createLockedQueryBundle } from "../domain/queryBundle";
|
|
2
|
+
import { buildCompassReport } from "./reportBuilder";
|
|
3
|
+
import type { CompassReportModel, IdeaBrief } from "./reportTypes";
|
|
4
|
+
import { therapyEvidenceSeed, therapyPillarScores } from "./therapySeed";
|
|
5
|
+
|
|
6
|
+
export type DemoReport = CompassReportModel;
|
|
7
|
+
|
|
8
|
+
export const defaultIdea: IdeaBrief = {
|
|
9
|
+
name: "Privacy-safe session note drafts",
|
|
10
|
+
problem:
|
|
11
|
+
"Solo therapists lose unpaid time turning session context into structured notes.",
|
|
12
|
+
targetUser: "Solo therapists and small private clinics",
|
|
13
|
+
lens: "B2B Workflow / Vertical SaaS",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const defaultQueryForm = {
|
|
17
|
+
problemKeywords: "therapist paperwork, therapy documentation, SOAP notes",
|
|
18
|
+
solutionKeywords: "session note drafts, therapy note automation",
|
|
19
|
+
audienceKeywords: "solo therapists, private practice therapists",
|
|
20
|
+
competitorKeywords: "therapy notes app, EHR notes",
|
|
21
|
+
exclusions: "physical therapy, school therapy notes",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createDemoReport(): DemoReport {
|
|
25
|
+
return buildCompassReport({
|
|
26
|
+
idea: defaultIdea,
|
|
27
|
+
queryBundle: createLockedQueryBundle(defaultQueryForm),
|
|
28
|
+
evidence: therapyEvidenceSeed,
|
|
29
|
+
pillarScores: therapyPillarScores,
|
|
30
|
+
uncertainty: 11,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createLockedQueryBundle } from "../domain/queryBundle";
|
|
3
|
+
import { loadPublicEvidenceReport } from "./publicEvidenceReport";
|
|
4
|
+
|
|
5
|
+
const idea = {
|
|
6
|
+
name: "Invoice follow-up autopilot",
|
|
7
|
+
problem: "Freelancers lose time chasing late client payments.",
|
|
8
|
+
targetUser: "Freelancers and consultants",
|
|
9
|
+
lens: "Productivity / Prosumer SaaS",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("public evidence report loader", () => {
|
|
13
|
+
it("waits for free source scans before building the report", async () => {
|
|
14
|
+
const queryBundle = createLockedQueryBundle({
|
|
15
|
+
problemKeywords: "invoice reminders",
|
|
16
|
+
solutionKeywords: "invoice automation",
|
|
17
|
+
audienceKeywords: "freelancers",
|
|
18
|
+
competitorKeywords: "HoneyBook",
|
|
19
|
+
exclusions: "medical billing",
|
|
20
|
+
});
|
|
21
|
+
const requestedUrls: string[] = [];
|
|
22
|
+
const report = await loadPublicEvidenceReport({
|
|
23
|
+
idea,
|
|
24
|
+
queryBundle,
|
|
25
|
+
minimumLoadMs: 0,
|
|
26
|
+
fetchJson: async (url) => {
|
|
27
|
+
requestedUrls.push(url);
|
|
28
|
+
|
|
29
|
+
if (url.includes("api.github.com")) {
|
|
30
|
+
return {
|
|
31
|
+
items: [
|
|
32
|
+
{
|
|
33
|
+
id: 17,
|
|
34
|
+
html_url: "https://github.com/example/invoices",
|
|
35
|
+
full_name: "example/invoices",
|
|
36
|
+
description: "Invoice reminder automation",
|
|
37
|
+
updated_at: "2026-05-20T00:00:00Z",
|
|
38
|
+
stargazers_count: 25,
|
|
39
|
+
forks_count: 5,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
hits: [
|
|
47
|
+
{
|
|
48
|
+
objectID: "42",
|
|
49
|
+
title: "Ask HN: Invoice reminder workflows",
|
|
50
|
+
created_at: "2026-02-02T00:00:00Z",
|
|
51
|
+
created_at_i: 1770000000,
|
|
52
|
+
points: 20,
|
|
53
|
+
num_comments: 10,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(requestedUrls.some((url) => url.includes("api.github.com"))).toBe(true);
|
|
61
|
+
expect(requestedUrls.some((url) => url.includes("hn.algolia.com"))).toBe(true);
|
|
62
|
+
expect(report.evidence.some((item) => item.id === "gh-17")).toBe(true);
|
|
63
|
+
expect(report.evidence.some((item) => item.id === "hn-42")).toBe(true);
|
|
64
|
+
expect(report.idea.name).toBe("Invoice follow-up autopilot");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createAll35Adapters } from "./sources/extendedAdapters";
|
|
2
|
+
import type { FetchJson, SourceAdapter } from "./sources/sourceAdapter";
|
|
3
|
+
import type { QueryBundle } from "../domain/queryBundle";
|
|
4
|
+
import {
|
|
5
|
+
derivePillarScoresFromEvidence,
|
|
6
|
+
deriveUncertainty,
|
|
7
|
+
} from "./queryDerivedReport";
|
|
8
|
+
import { buildCompassReport } from "./reportBuilder";
|
|
9
|
+
import type { CompassReportModel, IdeaBrief } from "./reportTypes";
|
|
10
|
+
|
|
11
|
+
export type LoadPublicEvidenceReportInput = {
|
|
12
|
+
idea: IdeaBrief;
|
|
13
|
+
queryBundle: QueryBundle;
|
|
14
|
+
fetchJson?: FetchJson;
|
|
15
|
+
minimumLoadMs?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const defaultMinimumLoadMs = 650;
|
|
19
|
+
const requestTimeoutMs = 8000;
|
|
20
|
+
|
|
21
|
+
async function defaultFetchJson(url: string) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timeoutId = globalThis.setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Source request failed: ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return response.json() as Promise<unknown>;
|
|
33
|
+
} finally {
|
|
34
|
+
globalThis.clearTimeout(timeoutId);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function delay(ms: number) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
globalThis.setTimeout(resolve, ms);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function scanSource(adapter: SourceAdapter, queryBundle: QueryBundle) {
|
|
45
|
+
try {
|
|
46
|
+
return await adapter.scan(queryBundle);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadPublicEvidenceReport({
|
|
53
|
+
idea,
|
|
54
|
+
queryBundle,
|
|
55
|
+
fetchJson = defaultFetchJson,
|
|
56
|
+
minimumLoadMs = defaultMinimumLoadMs,
|
|
57
|
+
}: LoadPublicEvidenceReportInput): Promise<CompassReportModel> {
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
// Load all 35 specialized adapters that target 7 distinct pillars
|
|
60
|
+
const adapters = createAll35Adapters({ fetchJson });
|
|
61
|
+
|
|
62
|
+
const liveEvidence = (
|
|
63
|
+
await Promise.all(adapters.map((adapter) => scanSource(adapter, queryBundle)))
|
|
64
|
+
).flat();
|
|
65
|
+
|
|
66
|
+
const elapsed = Date.now() - startedAt;
|
|
67
|
+
|
|
68
|
+
if (elapsed < minimumLoadMs) {
|
|
69
|
+
await delay(minimumLoadMs - elapsed);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 100% Honest Data - No fallback mocks used
|
|
73
|
+
const evidence = liveEvidence;
|
|
74
|
+
|
|
75
|
+
return buildCompassReport({
|
|
76
|
+
idea,
|
|
77
|
+
queryBundle,
|
|
78
|
+
evidence,
|
|
79
|
+
pillarScores: derivePillarScoresFromEvidence(evidence),
|
|
80
|
+
uncertainty: deriveUncertainty(queryBundle),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createLockedQueryBundle } from "../domain/queryBundle";
|
|
3
|
+
import {
|
|
4
|
+
createQueryDerivedEvidence,
|
|
5
|
+
derivePillarScoresFromEvidence,
|
|
6
|
+
deriveUncertainty,
|
|
7
|
+
} from "./queryDerivedReport";
|
|
8
|
+
|
|
9
|
+
const idea = {
|
|
10
|
+
name: "Invoice follow-up autopilot",
|
|
11
|
+
problem: "Freelancers lose time chasing late client payments.",
|
|
12
|
+
targetUser: "Freelancers and consultants",
|
|
13
|
+
lens: "Productivity / Prosumer SaaS",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("query-derived report inputs", () => {
|
|
17
|
+
it("creates evidence ledger rows from the locked query bundle", () => {
|
|
18
|
+
const bundle = createLockedQueryBundle({
|
|
19
|
+
problemKeywords: "late client payments, unpaid invoices",
|
|
20
|
+
solutionKeywords: "invoice reminder automation",
|
|
21
|
+
audienceKeywords: "freelancers, consultants",
|
|
22
|
+
competitorKeywords: "HoneyBook alternative",
|
|
23
|
+
exclusions: "medical billing",
|
|
24
|
+
});
|
|
25
|
+
const evidence = createQueryDerivedEvidence(idea, bundle);
|
|
26
|
+
|
|
27
|
+
expect(evidence.map((item) => item.query)).toContain("late client payments");
|
|
28
|
+
expect(evidence.map((item) => item.query)).toContain("invoice reminder automation");
|
|
29
|
+
expect(evidence.map((item) => item.query)).toContain("freelancers");
|
|
30
|
+
expect(evidence.some((item) => item.snippet.includes("therapist"))).toBe(false);
|
|
31
|
+
expect(evidence.find((item) => item.metricContribution === "Excluded")).toMatchObject({
|
|
32
|
+
included: false,
|
|
33
|
+
query: "medical billing",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("derives all seven pillar scores from generated evidence strengths", () => {
|
|
38
|
+
const bundle = createLockedQueryBundle({
|
|
39
|
+
problemKeywords: "late client payments",
|
|
40
|
+
solutionKeywords: "invoice reminder automation",
|
|
41
|
+
audienceKeywords: "freelancers",
|
|
42
|
+
competitorKeywords: "HoneyBook alternative",
|
|
43
|
+
exclusions: "medical billing",
|
|
44
|
+
});
|
|
45
|
+
const scores = derivePillarScoresFromEvidence(createQueryDerivedEvidence(idea, bundle));
|
|
46
|
+
|
|
47
|
+
expect(Object.values(scores).every((score) => score > 0 && score <= 100)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("lowers uncertainty as query controls become more specific", () => {
|
|
51
|
+
const broad = createLockedQueryBundle({
|
|
52
|
+
problemKeywords: "payments",
|
|
53
|
+
solutionKeywords: "",
|
|
54
|
+
audienceKeywords: "",
|
|
55
|
+
competitorKeywords: "",
|
|
56
|
+
exclusions: "",
|
|
57
|
+
});
|
|
58
|
+
const specific = createLockedQueryBundle({
|
|
59
|
+
problemKeywords: "late client payments",
|
|
60
|
+
solutionKeywords: "invoice reminder automation",
|
|
61
|
+
audienceKeywords: "freelancers",
|
|
62
|
+
competitorKeywords: "HoneyBook alternative",
|
|
63
|
+
exclusions: "medical billing",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(deriveUncertainty(specific)).toBeLessThan(deriveUncertainty(broad));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { EvidenceItem, EvidenceSource, MetricContribution, SourceType } from "../domain/evidence";
|
|
2
|
+
import type { QueryBundle } from "../domain/queryBundle";
|
|
3
|
+
import type { PillarScores } from "../domain/scoring";
|
|
4
|
+
import type { IdeaBrief } from "./reportTypes";
|
|
5
|
+
|
|
6
|
+
type EvidenceDraft = {
|
|
7
|
+
source: EvidenceSource;
|
|
8
|
+
sourceType: SourceType;
|
|
9
|
+
query: string;
|
|
10
|
+
snippet: string;
|
|
11
|
+
link: string;
|
|
12
|
+
metricContribution: MetricContribution;
|
|
13
|
+
reason: string;
|
|
14
|
+
signalStrength: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const generatedDate = "2026-05-24";
|
|
18
|
+
|
|
19
|
+
function primary(terms: string[], fallback: string) {
|
|
20
|
+
return terms[0] ?? fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function searchUrl(base: string, query: string) {
|
|
24
|
+
return `${base}${encodeURIComponent(query)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clamp(value: number) {
|
|
28
|
+
return Math.min(100, Math.max(0, Math.round(value)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sourceTermScore(termCount: number, bonus: number) {
|
|
32
|
+
return clamp(38 + termCount * 11 + bonus);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function aggregateVolumeWeighted(values: number[]) {
|
|
36
|
+
if (values.length === 0) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sort descending to get the highest quality signals first
|
|
41
|
+
const sorted = [...values].sort((a, b) => b - a);
|
|
42
|
+
|
|
43
|
+
// Take average of top 3 (or fewer if we have less)
|
|
44
|
+
const topSignals = sorted.slice(0, 3);
|
|
45
|
+
const baseAverage = topSignals.reduce((sum, val) => sum + val, 0) / topSignals.length;
|
|
46
|
+
|
|
47
|
+
// Apply a volume bonus for every additional piece of evidence (+2 points per item)
|
|
48
|
+
const volumeBonus = (values.length > 3 ? (values.length - 3) * 2 : 0);
|
|
49
|
+
|
|
50
|
+
return clamp(baseAverage + volumeBonus);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scoreByContribution(
|
|
54
|
+
evidence: EvidenceItem[],
|
|
55
|
+
contribution: MetricContribution,
|
|
56
|
+
) {
|
|
57
|
+
return aggregateVolumeWeighted(
|
|
58
|
+
evidence
|
|
59
|
+
.filter((item) => item.included && item.metricContribution === contribution)
|
|
60
|
+
.map((item) => item.signalStrength),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createQueryDerivedEvidence(
|
|
65
|
+
idea: IdeaBrief,
|
|
66
|
+
bundle: QueryBundle,
|
|
67
|
+
): EvidenceItem[] {
|
|
68
|
+
const problem = primary(bundle.problemKeywords, idea.problem);
|
|
69
|
+
const solution = primary(bundle.solutionKeywords, idea.name);
|
|
70
|
+
const audience = primary(bundle.audienceKeywords, idea.targetUser);
|
|
71
|
+
const competitor = primary(bundle.competitorKeywords, `${idea.name} alternative`);
|
|
72
|
+
const exclusion = primary(bundle.exclusions, "wrong meaning");
|
|
73
|
+
const specificity =
|
|
74
|
+
bundle.problemKeywords.length +
|
|
75
|
+
bundle.solutionKeywords.length +
|
|
76
|
+
bundle.audienceKeywords.length +
|
|
77
|
+
bundle.competitorKeywords.length;
|
|
78
|
+
const audienceBonus = bundle.audienceKeywords.length > 0 ? 8 : 0;
|
|
79
|
+
const exclusionBonus = bundle.exclusions.length > 0 ? 6 : 0;
|
|
80
|
+
const drafts: EvidenceDraft[] = [
|
|
81
|
+
{
|
|
82
|
+
source: "Reddit",
|
|
83
|
+
sourceType: "post",
|
|
84
|
+
query: problem,
|
|
85
|
+
snippet: `Reddit public-search target for "${problem}" and "${audience}".`,
|
|
86
|
+
link: searchUrl("https://www.reddit.com/search/?q=", `${problem} ${audience}`),
|
|
87
|
+
metricContribution: "Demand",
|
|
88
|
+
reason: "Generated from the locked problem and audience terms.",
|
|
89
|
+
signalStrength: sourceTermScore(bundle.problemKeywords.length, audienceBonus),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
source: "YouTube",
|
|
93
|
+
sourceType: "video",
|
|
94
|
+
query: solution,
|
|
95
|
+
snippet: `YouTube public-search target for "${solution}" tutorials, reviews, and workflows.`,
|
|
96
|
+
link: searchUrl("https://www.youtube.com/results?search_query=", solution),
|
|
97
|
+
metricContribution: "Demand",
|
|
98
|
+
reason: "Generated from the locked solution terms.",
|
|
99
|
+
signalStrength: sourceTermScore(bundle.solutionKeywords.length, 4),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
source: "Hacker News",
|
|
103
|
+
sourceType: "comment",
|
|
104
|
+
query: `${problem} workaround`,
|
|
105
|
+
snippet: `Hacker News search target for workaround language around "${problem}".`,
|
|
106
|
+
link: searchUrl("https://hn.algolia.com/?q=", `${problem} workaround`),
|
|
107
|
+
metricContribution: "Pain",
|
|
108
|
+
reason: "Uses problem terms plus pain/workaround phrasing.",
|
|
109
|
+
signalStrength: sourceTermScore(bundle.problemKeywords.length, 14),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
source: "Product Hunt",
|
|
113
|
+
sourceType: "launch",
|
|
114
|
+
query: competitor,
|
|
115
|
+
snippet: `Product Hunt search target for comparable launches: "${competitor}".`,
|
|
116
|
+
link: searchUrl("https://www.producthunt.com/search?q=", competitor),
|
|
117
|
+
metricContribution: "Competition Fit",
|
|
118
|
+
reason: "Generated from locked competitor terms.",
|
|
119
|
+
signalStrength: sourceTermScore(bundle.competitorKeywords.length, 10),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
source: "GitHub",
|
|
123
|
+
sourceType: "repo",
|
|
124
|
+
query: solution,
|
|
125
|
+
snippet: `GitHub public-search target for repositories matching "${solution}".`,
|
|
126
|
+
link: searchUrl("https://github.com/search?q=", solution),
|
|
127
|
+
metricContribution: "Activity",
|
|
128
|
+
reason: "Generated from locked solution terms as a free-source activity target.",
|
|
129
|
+
signalStrength: sourceTermScore(bundle.solutionKeywords.length, 0),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
source: "Stack Exchange",
|
|
133
|
+
sourceType: "question",
|
|
134
|
+
query: audience,
|
|
135
|
+
snippet: `Stack Exchange search target for questions from or about "${audience}".`,
|
|
136
|
+
link: searchUrl("https://stackexchange.com/search?q=", audience),
|
|
137
|
+
metricContribution: "Channel Fit",
|
|
138
|
+
reason: "Generated from locked audience terms.",
|
|
139
|
+
signalStrength: sourceTermScore(bundle.audienceKeywords.length, 12),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
source: "npm",
|
|
143
|
+
sourceType: "package",
|
|
144
|
+
query: solution,
|
|
145
|
+
snippet: `npm package-search target for builder activity around "${solution}".`,
|
|
146
|
+
link: searchUrl("https://www.npmjs.com/search?q=", solution),
|
|
147
|
+
metricContribution: "Momentum",
|
|
148
|
+
reason: "Generated from locked solution terms and free package-search surface.",
|
|
149
|
+
signalStrength: sourceTermScore(specificity, 2),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
source: "Hacker News",
|
|
153
|
+
sourceType: "post",
|
|
154
|
+
query: `${problem} ${solution}`,
|
|
155
|
+
snippet: `Cross-source precision check for "${problem}" plus "${solution}".`,
|
|
156
|
+
link: searchUrl("https://hn.algolia.com/?q=", `${problem} ${solution}`),
|
|
157
|
+
metricContribution: "Evidence Quality",
|
|
158
|
+
reason: "Higher when the query bundle has audience and exclusion controls.",
|
|
159
|
+
signalStrength: sourceTermScore(specificity, audienceBonus + exclusionBonus),
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const evidence = drafts.map<EvidenceItem>((draft, index) => ({
|
|
164
|
+
id: `query-derived-${index + 1}`,
|
|
165
|
+
date: generatedDate,
|
|
166
|
+
included: true,
|
|
167
|
+
duplicateCluster: `query-derived-${draft.metricContribution.toLowerCase().replaceAll(" ", "-")}`,
|
|
168
|
+
...draft,
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
if (bundle.exclusions.length > 0) {
|
|
172
|
+
evidence.push({
|
|
173
|
+
id: "query-derived-exclusion-control",
|
|
174
|
+
source: "Reddit",
|
|
175
|
+
sourceType: "post",
|
|
176
|
+
date: generatedDate,
|
|
177
|
+
query: exclusion,
|
|
178
|
+
snippet: `Wrong-meaning control query for excluded term "${exclusion}".`,
|
|
179
|
+
link: searchUrl("https://www.reddit.com/search/?q=", exclusion),
|
|
180
|
+
metricContribution: "Excluded",
|
|
181
|
+
included: false,
|
|
182
|
+
reason: "Excluded by the locked query bundle before scoring.",
|
|
183
|
+
duplicateCluster: "query-derived-exclusion-control",
|
|
184
|
+
signalStrength: 0,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return evidence;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function derivePillarScoresFromEvidence(evidence: EvidenceItem[]): PillarScores {
|
|
192
|
+
return {
|
|
193
|
+
demand: scoreByContribution(evidence, "Demand"),
|
|
194
|
+
pain: scoreByContribution(evidence, "Pain"),
|
|
195
|
+
momentum: scoreByContribution(evidence, "Momentum"),
|
|
196
|
+
competitionFit: clamp(100 - scoreByContribution(evidence, "Competition Fit")),
|
|
197
|
+
activity: scoreByContribution(evidence, "Activity"),
|
|
198
|
+
channelFit: scoreByContribution(evidence, "Channel Fit"),
|
|
199
|
+
evidenceQuality: scoreByContribution(evidence, "Evidence Quality"),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function deriveUncertainty(bundle: QueryBundle) {
|
|
204
|
+
let uncertainty = 24;
|
|
205
|
+
|
|
206
|
+
if (bundle.problemKeywords.length > 0) {
|
|
207
|
+
uncertainty -= 3;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (bundle.solutionKeywords.length > 0) {
|
|
211
|
+
uncertainty -= 3;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (bundle.audienceKeywords.length > 0) {
|
|
215
|
+
uncertainty -= 4;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (bundle.competitorKeywords.length > 0) {
|
|
219
|
+
uncertainty -= 2;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (bundle.exclusions.length > 0) {
|
|
223
|
+
uncertainty -= 3;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return Math.max(7, uncertainty);
|
|
227
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createLockedQueryBundle } from "../domain/queryBundle";
|
|
3
|
+
import { buildCompassReport } from "./reportBuilder";
|
|
4
|
+
import { therapyEvidenceSeed, therapyPillarScores } from "./therapySeed";
|
|
5
|
+
|
|
6
|
+
describe("report builder service", () => {
|
|
7
|
+
it("builds a report from supplied idea and locked query bundle", () => {
|
|
8
|
+
const report = buildCompassReport({
|
|
9
|
+
idea: {
|
|
10
|
+
name: "Invoice follow-up autopilot",
|
|
11
|
+
problem: "Freelancers lose time chasing late client payments.",
|
|
12
|
+
targetUser: "Freelancers and consultants",
|
|
13
|
+
lens: "Productivity / Prosumer SaaS",
|
|
14
|
+
},
|
|
15
|
+
queryBundle: createLockedQueryBundle({
|
|
16
|
+
problemKeywords: "late client payments",
|
|
17
|
+
solutionKeywords: "invoice reminder automation",
|
|
18
|
+
audienceKeywords: "freelancers, consultants",
|
|
19
|
+
competitorKeywords: "HoneyBook alternative",
|
|
20
|
+
exclusions: "medical billing",
|
|
21
|
+
}),
|
|
22
|
+
evidence: therapyEvidenceSeed,
|
|
23
|
+
pillarScores: therapyPillarScores,
|
|
24
|
+
uncertainty: 11,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(report.idea.name).toBe("Invoice follow-up autopilot");
|
|
28
|
+
expect(report.queryBundle.problemKeywords).toEqual(["late client payments"]);
|
|
29
|
+
expect(report.summary.score).toBe(66);
|
|
30
|
+
expect(report.summary.range).toEqual({ lower: 55, upper: 77 });
|
|
31
|
+
expect(report.methodologyVersion).toBe("0.1");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("filters evidence through bundle exclusions and derives integrity", () => {
|
|
35
|
+
const report = buildCompassReport({
|
|
36
|
+
idea: {
|
|
37
|
+
name: "Therapy notes",
|
|
38
|
+
problem: "Therapists need note help.",
|
|
39
|
+
targetUser: "Solo therapists",
|
|
40
|
+
lens: "B2B Workflow / Vertical SaaS",
|
|
41
|
+
},
|
|
42
|
+
queryBundle: createLockedQueryBundle({
|
|
43
|
+
problemKeywords: "therapist paperwork, SOAP notes",
|
|
44
|
+
solutionKeywords: "session note automation",
|
|
45
|
+
audienceKeywords: "solo therapists",
|
|
46
|
+
competitorKeywords: "therapy notes app",
|
|
47
|
+
exclusions: "physical therapy",
|
|
48
|
+
}),
|
|
49
|
+
evidence: therapyEvidenceSeed,
|
|
50
|
+
pillarScores: therapyPillarScores,
|
|
51
|
+
uncertainty: 11,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(report.evidence).toHaveLength(12);
|
|
55
|
+
expect(report.evidence.find((item) => item.id === "physical-therapy-notes")).toMatchObject({
|
|
56
|
+
included: false,
|
|
57
|
+
metricContribution: "Excluded",
|
|
58
|
+
});
|
|
59
|
+
expect(report.integrity.finalScoreAvailable).toBe(true);
|
|
60
|
+
expect(report.integrity.warnings).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("includes formula readouts for all seven pillars", () => {
|
|
64
|
+
const report = buildCompassReport({
|
|
65
|
+
idea: {
|
|
66
|
+
name: "Therapy notes",
|
|
67
|
+
problem: "Therapists need note help.",
|
|
68
|
+
targetUser: "Solo therapists",
|
|
69
|
+
lens: "B2B Workflow / Vertical SaaS",
|
|
70
|
+
},
|
|
71
|
+
queryBundle: createLockedQueryBundle({
|
|
72
|
+
problemKeywords: "therapist paperwork, SOAP notes",
|
|
73
|
+
solutionKeywords: "session note automation",
|
|
74
|
+
audienceKeywords: "solo therapists",
|
|
75
|
+
competitorKeywords: "therapy notes app",
|
|
76
|
+
exclusions: "physical therapy",
|
|
77
|
+
}),
|
|
78
|
+
evidence: therapyEvidenceSeed,
|
|
79
|
+
pillarScores: therapyPillarScores,
|
|
80
|
+
uncertainty: 11,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(report.formulas).toHaveLength(7);
|
|
84
|
+
expect(report.formulas[0]).toMatchObject({
|
|
85
|
+
pillar: "Demand",
|
|
86
|
+
formula: "0.45 volume + 0.25 unique authors + 0.20 questions + 0.10 engagement",
|
|
87
|
+
score: 64,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|