Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ regressions retain priority even when the model selects none. Original measureme
constraints and the unverified planning rationale stay in the frozen objective.
Scheduled runs investigate at most two; a deliberate manual full scan investigates at
most five and covers a distinct eligible specialist family before taking extra work from
one family. The portfolio is diversified across correlated subjects and survives a
one family. This does not reintroduce optional general work excluded by business-aware
selection. Candidate input is bounded by serialized size rather than a count cutoff;
the complete saved brief and newest relevant correction survive source budgeting, or
selection retains the conservative fallback. The portfolio is diversified across correlated subjects and survives a
retry unchanged. Each selected signal still gets its own exact agent turn, durable
observation, and investigation history; a model does not manufacture a broad report
from ungrounded raw data.
Expand Down
67 changes: 45 additions & 22 deletions apps/insights/src/business-aware-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { BusinessContext } from "@databuddy/ai/lib/business-context";
import { MockLanguageModelV3 } from "ai/test";
import { chooseInvestigationSignals } from "./business-aware-selection";
import { planCoveragePortfolio } from "./coverage-planner";
import { organizationProfileContext } from "./business-context";
import type { DetectedSignal } from "./detection";
import {
investigateWebsitePortfolioWithSources,
Expand Down Expand Up @@ -317,11 +318,6 @@ describe("business-aware investigation selection", () => {
[],
[outcome],
[traffic, { ...outcome, definitionEvidence: "x".repeat(64_001) }],
Array.from({ length: 33 }, (_, index) => ({
...outcome,
metric: `goal:${index}`,
subjectKey: `goal:${index}`,
})),
]) {
await planInvestigationsWithBusinessContext(
input,
Expand All @@ -335,23 +331,50 @@ describe("business-aware investigation selection", () => {
scope
);
}
await chooseInvestigationSignals(
{
businessContext: context,
candidates: Array.from({ length: 9 }, (_, index) => ({
signal: prepareInvestigation(
{
...outcome,
metric: `goal:${index}`,
subjectKey: `goal:${index}`,
},
7
).signal,
})),
limit: 2,
},
model
);
expect(model.doGenerateCalls).toHaveLength(0);
});

it.each([9, 24, 33])("uses business context for %i bounded candidates", async (count) => {
const key = `goal:${count - 1}`;
const model = new MockLanguageModelV3({ doGenerate: async (request) => {
expect(JSON.stringify(request.prompt)).toContain(explanation);
return response({ selections: [{ ...choice, signalKey: key }] });
} });
const plan = await planInvestigationsWithBusinessContext(input,
Array.from({ length: count }, (_, index) => ({ ...outcome, metric: `goal:${index}`, subjectKey: `goal:${index}` })),
{ loadBusinessProfile: async () => context, selectCandidates: params => chooseInvestigationSignals(params, model) },
false, scope, { reason: "scheduled" });
expect(plan.map(candidate => candidate.signal.signalKey)).toEqual([key]);
expect(model.doGenerateCalls).toHaveLength(1);
});

it("keeps the complete maximum saved brief and a current correction before optional background", async () => {
const saved = organizationProfileContext({
content: "Business overview. ".padEnd(11_940, " Background.") + " Final exclusion: generic traffic is already explained.",
origin: "mixed", sources: [{ url: "https://example.com/", title: "Public overview" }],
revision: 4, updatedAt: "2026-07-11T00:00:00.000Z", updatedBy: "example-editor", sourceWebsiteId: null,
teamContext: { priority: "Prioritize delivery. ".padEnd(2000, " Priority."), successDefinition: "Delivery means accepted by the recipient. ".padEnd(2000, " Definition."), exclusions: "Exclude demos. ".padEnd(2000, " Exclusion.") },
}, input.organizationId, new Date(input.asOf));
const correction = { id: "current-correction", kind: "team_reply" as const, subjectKey: choice.signalKey,
content: "Current correction: use accepted delivery. ".padEnd(3950, " Team details.") + " Correction ends here.", observedAt: input.asOf };
saved.sources.push(correction);
const model = new MockLanguageModelV3({ doGenerate: async (request) => {
const sent = JSON.stringify(request.prompt);
expect(sent).toContain("Business overview.");
expect(sent).toContain("Final exclusion: generic traffic is already explained.");
expect(sent).toContain("Current correction: use accepted delivery.");
expect(sent).toContain("Correction ends here.");
expect(sent).toContain("Exclude demos.");
return response({ selections: [choice] });
} });
const result = await chooseInvestigationSignals({ businessContext: saved, candidates: [traffic, outcome].map(signal => ({ signal: prepareInvestigation(signal, 7).signal })), limit: 2 }, model);
expect(result?.output.selections).toEqual([choice]);
});

it("falls back instead of selecting from an incomplete over-budget saved document", async () => {
const model = new MockLanguageModelV3({ doGenerate: async () => { throw new Error("Selection should be skipped"); } });
const saved = organizationProfileContext({ content: "\u0000".repeat(12_000), sources: [], origin: "team", revision: 1, updatedAt: input.asOf, updatedBy: "example-editor", sourceWebsiteId: null }, input.organizationId, new Date(input.asOf));
expect(await chooseInvestigationSignals({ businessContext: saved, candidates: [traffic, outcome].map(signal => ({ signal: prepareInvestigation(signal, 7).signal })), limit: 2 }, model)).toBeNull();
expect(model.doGenerateCalls).toHaveLength(0);
});

Expand Down
60 changes: 26 additions & 34 deletions apps/insights/src/business-aware-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export async function chooseInvestigationSignals(
if (
!(model || isAiGatewayConfigured) ||
candidates.length <= 1 ||
candidates.length > input.limit * 4 ||
!businessContext.sources.length ||
!["ready", "partial"].includes(businessContext.status) ||
JSON.stringify(candidates).length > 48_000
Expand All @@ -69,54 +68,47 @@ export async function chooseInvestigationSignals(
),
...replies,
...businessContext.sources.filter((source) => source.kind === "website"),
];
const sources: Pick<
BusinessContext["sources"][number],
| "id"
| "kind"
| "content"
| "observedAt"
| "subjectKey"
| "author"
| "url"
| "references"
| "origin"
>[] = [];
let pageCharacters = 0;
let characters = 0;
for (const {
id,
kind,
content,
observedAt,
subjectKey,
author,
origin,
url,
references,
} of ordered) {
const source = {
].map(
({ id, kind, content, observedAt, subjectKey, author, origin, url }) => ({
id,
kind,
references,
content,
observedAt,
subjectKey,
author,
origin,
url,
};
})
);
// Keep the complete saved document and the newest relevant correction.
// Bibliography stays on the investigation snapshot; it is not needed to rank work.
const characterLimit = Math.max(
18_000,
ordered
.filter(
(source) =>
source.kind === "organization_profile" || source.id === replies[0]?.id
)
.reduce((total, source) => total + JSON.stringify(source).length, 0)
);
if (characterLimit > 32_000) {
return null;
}
const sources: typeof ordered = [];
let pageCharacters = 0;
let characters = 0;
for (const source of ordered) {
const size = JSON.stringify(source).length;
if (
sources.some((item) => item.id === id) ||
characters + size > 18_000 ||
(kind === "website" && pageCharacters + size > 8000)
sources.some((item) => item.id === source.id) ||
characters + size > characterLimit ||
(source.kind === "website" && pageCharacters + size > 8000)
) {
continue;
}
sources.push(source);
characters += size;
if (kind === "website") {
if (source.kind === "website") {
pageCharacters += size;
}
}
Expand Down
11 changes: 9 additions & 2 deletions apps/insights/src/coverage-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,19 @@ describe("business preference constraints", () => {
expect(plan).toHaveLength(5);
expect(plan).toContain(error);
expect(plan).toContain(funnel);
expect(plan).toContain(traffic);
expect(plan).not.toContain(traffic);
expect(plan.filter((item) => item.metric.startsWith("goal:"))).toHaveLength(
2
3
);
});

it.each(["manual", "scheduled"] as const)("preserves an explicit traffic exclusion in a %s scan", (reason) => {
const goal = signal({ metric: "goal:activation", subjectKey: "goal:activation" });
const traffic = signal({ metric: "visitors" });
expect(planCoveragePortfolio([traffic, goal], { reason, selectedSignalKeys: keys([goal]) })).toEqual([goal]);
expect(planCoveragePortfolio([traffic, goal], { reason, selectedSignalKeys: keys([traffic]) })).toContain(traffic);
});

it("keeps one correlated subject, the due case first, and the scheduled limit", () => {
const due = signal({ metric: "goal:due", subjectKey: "goal:due" });
const visitors = signal({ metric: "visitors" });
Expand Down
4 changes: 3 additions & 1 deletion apps/insights/src/coverage-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ export function planCoveragePortfolio(
(candidate) =>
selection.has(candidate.key) ||
isCriticalReliabilitySignal(candidate.signal) ||
(options.reason === "manual" && !usedFamilies.has(candidate.family))
(options.reason === "manual" &&
candidate.family !== "general" &&
!usedFamilies.has(candidate.family))
)
: available;
const preferred =
Expand Down
8 changes: 8 additions & 0 deletions apps/insights/src/evals/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Investigation quality evals

`context-selection.ts --out <fresh-directory> --runs 2` compares native absent/present
context paths before running selected investigations through this evaluator. It uses
synthetic 2-, 9- and 24-signal portfolios, a maximum-sized context correction case and
manual exclusion coverage. `--reverse` reverses candidate order for holdouts; `--cases`
selects scenario IDs. Alternate arms, preserve the copied source and fixtures, and
review the complete outputs as well as final selections. Its zero exit status means
the run completed; `results.json` retains quality failures for manual comparison.

Run from the repository root with `AI_GATEWAY_API_KEY` configured:

```sh
Expand Down
Loading