From f82894de61c135e149ecf94c928b284ee48ddf0d Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:06:06 +0300 Subject: [PATCH 1/4] fix(insights): keep evidence claims with their sources --- SPEC.md | 2 +- apps/insights/src/agent.ts | 60 ++-- apps/insights/src/evals/README.md | 2 +- apps/insights/src/evals/quality.test.ts | 10 +- apps/insights/src/evals/quality.ts | 16 +- apps/insights/src/investigation-flow.test.ts | 311 ++++++++++++++++++- packages/shared/src/insights.ts | 4 +- 7 files changed, 365 insertions(+), 40 deletions(-) diff --git a/SPEC.md b/SPEC.md index 7ad26e540..766102f93 100644 --- a/SPEC.md +++ b/SPEC.md @@ -99,7 +99,7 @@ locks. Legacy replies without an original scope remain history rather than being relabeled as current business facts. Scope changes during execution reject the old outcome before persistence. -Tools are discoverable. There is no fixed first query, query family, receipt choreography, or two-read limit. Each investigation uses one tool loop with at most eight model turns, including a reserved final turn. It ends through `finish_investigation`, which validates the outcome and returns any repair error in the same conversation; at most three finish attempts are allowed. Successful reads include exact citation references. The agent does not restart the conversation to repair output. +Tools are discoverable. There is no fixed first query, query family, receipt choreography, or two-read limit. Each investigation uses one tool loop with at most eight model turns, including a reserved final turn. It ends through `finish_investigation`, which validates the outcome and returns any repair error in the same conversation; at most three finish attempts are allowed. Successful reads include exact citation references. The finish tool keeps each claim together with its contributing sources; code renders structured revenue claims and validates every claim against those sources before storing the existing text outcome. The agent does not restart the conversation to repair output. Native `revenue_overview` evidence selects a currency and metric fields from exact successful result references. Code renders labels, values, units, dates and differences for complete equal-duration comparison windows with the same website, timezone and filters, including fresh windows on a later recheck. The stored evidence remains text. This binds those numeric comparisons; other sources retain numeric grounding checks and every finding still needs semantic quality review. diff --git a/apps/insights/src/agent.ts b/apps/insights/src/agent.ts index 439c975f7..bad717fb6 100644 --- a/apps/insights/src/agent.ts +++ b/apps/insights/src/agent.ts @@ -14,6 +14,7 @@ import { getAILogger } from "@databuddy/ai/lib/ai-logger"; import { QueryBuilders } from "@databuddy/ai/query/builders"; import { insightRepairError } from "@databuddy/rpc/insight-repairs"; import { + agentEvidenceReferenceSchema, agentInvestigationOutcomeSchema, describeInsightDefinitionAction, insightDefinitionEditChangesSchema, @@ -64,22 +65,28 @@ const revenueEvidenceSchema = z .max(4), }) .describe( - "For revenue_overview, select complementary fields: gross revenue, settled transactions, refunds, and attributed revenue when it differs from gross. Select only fields with a non-null value in every cited period. Omit redundant subtotals and diagnostic availability flags. One entry per measured population; a payment-description comparison uses a second entry for the whole-currency control. Cite both complete comparison windows in each evidenceRefs entry. Code supplies labels, values, periods and deltas; preserve supported comparisons when correcting format." + "For revenue_overview, select complementary fields: gross revenue, settled transactions, refunds, and attributed revenue when it differs from gross. Select only fields with a non-null value in every cited period. Omit redundant subtotals and diagnostic availability flags. One entry per measured population; a payment-description comparison uses a second entry for the whole-currency control. Cite both complete comparison windows in this claim's sources. Code supplies labels, values, periods and deltas; preserve supported comparisons when correcting format." ); -const finishSchema = z.object(agentInvestigationOutcomeSchema.shape).extend({ - evidence: z - .array( - z.union([ - agentInvestigationOutcomeSchema.shape.evidence.element, - revenueEvidenceSchema, - ]) - ) - .min(1) - .max(2) - .describe( - "Every revenue_overview entry, including unchanged controls, must be {currency, fields}. Receipt-description and whole-currency entries cite separate result pairs. Use text only for other sources. Keep only comparisons that change the interpretation." - ), -}); +const finishSchema = z + .object(agentInvestigationOutcomeSchema.shape) + .omit({ evidenceRefs: true }) + .extend({ + evidence: z + .array( + z.strictObject({ + claim: z.union([ + agentInvestigationOutcomeSchema.shape.evidence.element, + revenueEvidenceSchema, + ]), + sources: z.array(agentEvidenceReferenceSchema).min(1).max(8), + }) + ) + .min(1) + .max(2) + .describe( + "Keep each concise claim with all its contributing sources. Every revenue_overview claim, including unchanged controls, must be {currency, fields}; use text for other claims. Receipt-description and whole-currency claims cite separate result pairs. Retain comparisons that change the interpretation." + ), + }); const revenueReadingSchema = z.object({ type: z.literal("revenue_overview"), @@ -374,7 +381,7 @@ Subject Evidence - The optional investigationObjective is a machine-selected question, not a human request or citable measurement. Use it to choose useful diagnostic work; verify its premise with source data. -- Cite each evidence sentence to its actual source: source signal for the supplied signal; source provided with a valid zero-based evidence index; source history with the index of a prior action for its saved verification condition only (not historical or current measurements); source customer_impact for supplied customerImpact; source related_signal with its array index; or source tool with its exact name, toolCallId, and get_data resultKey (null for other tools). Use an array of source references per evidence entry, including every contributing period, population, and inspected mechanism. One concise comparison can cite several sources without repeating its facts. An exact verification read also supports the saved condition and code verdict returned with it. Correct a mismatched citation without discarding a supported discovery. Never cite a failed read as evidence. An empty evidence array does not invalidate the supplied signal. +- Cite each evidence sentence to its actual source: source signal for the supplied signal; source provided with a valid zero-based evidence index; source history with the index of a prior action for its saved verification condition only (not historical or current measurements); source customer_impact for supplied customerImpact; source related_signal with its array index; or source tool with its exact name, toolCallId, and get_data resultKey (null for other tools). Keep each evidence claim with its sources, including every contributing period, population, and inspected mechanism. One concise comparison can cite several sources without repeating its facts. An exact verification read also supports the saved condition and code verdict returned with it. Correct a mismatched citation without discarding a supported discovery. Never cite a failed read as evidence. An empty evidence array does not invalidate the supplied signal. - Tool availability is not proof of a connected integration. If a connector reports missing access, stop trying that connector. Preserve an independently verified product or reliability finding, with an unknown cause when necessary. Missing diagnostic access is not evidence that tracking failed, and does not itself deserve a coverage notice or a connection request. - get_data can return a partial table. returnedRows is what you saw; rowCount is query rows, not visitors or all matching entities. A path missing from a top-N table is not absent. Use an exact filtered lookup or a dedicated aggregate before making absence, total, or exhaustive claims. Omit orderBy unless discovery documents the field and use only declared row filters. - Use reads to resolve a specific distinction that could change the finding or next move. Batch independent reads and never repeat an identical call. Stop gathering when further reads cannot change the decision; retain already-established changes and controls that change its interpretation. An overview of this subject can reveal several independent facts even when its headline metric is stable. For settled payments, distinguish gross revenue, refunds and attribution: stable sales with falling attribution limits acquisition decisions; rising refunds are a separate deterioration. Preserve both when measured, without treating one as the cause of the other. Select independent changes and interpretation-changing controls before redundant counts. @@ -627,7 +634,7 @@ export function validateNumericGrounding( throw new Error( evidenceIndex === undefined ? `Insights outcome cites the number ${value}, which does not appear in the supplied signal, evidence, or inspected tool results. Only report numbers you were given or measured.` - : `Insights evidence[${evidenceIndex}] cites the number ${value}, which does not appear in its cited source. Correct evidenceRefs[${evidenceIndex}] to the successful source containing this fact. If a claim combines reads, cite all contributing sources in an array for that evidence item. Preserve facts supported by inspected results; remove only unsupported claims.` + : `Insights evidence[${evidenceIndex}] cites the number ${value}, which does not appear in its cited source. Correct evidence[${evidenceIndex}].sources to include the successful source containing this fact. If a claim combines reads, cite all contributing sources for that claim. Preserve facts supported by inspected results; remove only unsupported claims.` ); } } @@ -1474,18 +1481,18 @@ export async function runInsightAgent( } const results = steps.flatMap((step) => step.toolResults); const verification = verificationFor(input, results); + const evidenceRefs = candidate.evidence.map((item) => item.sources); const citedEvidence = resolveEvidenceReferences( - candidate, + { evidenceRefs }, input, results ); const nativeRevenue: ReturnType[] = []; const evidence = candidate.evidence.map((item, index) => { - if (typeof item !== "string") { - const references = candidate.evidenceRefs[index]; + if (typeof item.claim !== "string") { if ( - (Array.isArray(references) ? references : [references]).some( - (ref) => ref?.source !== "tool" || ref.name !== "get_data" + item.sources.some( + (ref) => ref.source !== "tool" || ref.name !== "get_data" ) ) { throw new Error( @@ -1493,7 +1500,7 @@ export async function runInsightAgent( ); } const native = renderRevenueEvidence( - item, + item.claim, citedEvidence[index], input ); @@ -1512,11 +1519,12 @@ export async function runInsightAgent( "For revenue_overview evidence, submit {currency, fields} instead of prose, preserving this comparison; code binds every value to its field. Cite both periods." ); } - return item; + return item.claim; }); const proposed = agentInvestigationOutcomeSchema.parse({ ...candidate, evidence, + evidenceRefs, ...(verification ? { summary: @@ -1541,7 +1549,7 @@ export async function runInsightAgent( steps.flatMap((step) => step.toolCalls.map((call) => call.toolName)) ); if ( - candidate.evidence.some((item) => typeof item !== "string") && + candidate.evidence.some((item) => typeof item.claim !== "string") && [ proposed.title.replace(input.signal.entity.label, ""), verification ? "" : proposed.summary, @@ -1608,7 +1616,7 @@ export async function runInsightAgent( }) ); for (const [index, source] of citedEvidence.entries()) { - if (typeof candidate.evidence[index] !== "string") { + if (typeof candidate.evidence[index].claim !== "string") { continue; } validateNumericGrounding( diff --git a/apps/insights/src/evals/README.md b/apps/insights/src/evals/README.md index 3f43204a0..c27e6cef5 100644 --- a/apps/insights/src/evals/README.md +++ b/apps/insights/src/evals/README.md @@ -24,7 +24,7 @@ Usefulness checks require missing-access-only notices to remain private, retain Source interpretation requires manual review against the tool results. The automatic checks detect missing period reads and an omitted source cohort; a source-name or number match cannot establish a correct comparison. The runner records `reviewRequired` and prints `REVIEW REQUIRED` for a mechanically valid source case. A zero exit status means no automatic check failed; it does not complete that review. Verify direction, cohort, and period attribution, accepting equivalent measured rates. The 60-word budget is a quality target, not a runtime publication gate. -The agent uses one native tool loop, ending with `finish_investigation` in a separate turn after receiving its reads. It cannot cite a read sent in the same batch, because the model has not seen that result; the validation error asks it to use the completed result next turn without repeating the read. Schema and evidence validation failures return as tool errors in the same conversation. The total budget is eight model turns, with the last reserved for finishing, and at most three finish attempts. An empty or text-only provider response fails the run without starting another conversation. Read results supply exact citation references to copy; failed reads supply none. The reported read-call count excludes the finish tool; JSONL model requests and step events record all turns and finish attempts. Compare both counts when evaluating efficiency. +The agent uses one native tool loop, ending with `finish_investigation` in a separate turn after receiving its reads. It cannot cite a read sent in the same batch, because the model has not seen that result; the validation error asks it to use the completed result next turn without repeating the read. The finish tool accepts one or two evidence objects, each containing a `claim` and one to eight `sources`; it no longer asks the model to align separate claim and citation arrays. Revenue claims retain their native `{currency, fields}` selection. Stored evidence remains text. Schema and evidence validation failures return as tool errors in the same conversation. The total budget is eight model turns, with the last reserved for finishing, and at most three finish attempts. An empty or text-only provider response fails the run without starting another conversation. Read results supply exact citation references to copy; failed reads supply none. The reported read-call count excludes the finish tool; JSONL model requests and step events record all turns and finish attempts. Compare both counts when evaluating efficiency. New model outputs omit the separate impact paragraph; the finding summary states the consequence. Stored legacy impact remains readable and new stored outcomes default it to null. Review each executed JSONL, including failed/intermediate drafts, before claiming an improvement. Keep the exact source revisions and fixture versions with the comparison; changing a fixture is not an agent improvement. diff --git a/apps/insights/src/evals/quality.test.ts b/apps/insights/src/evals/quality.test.ts index fabc39382..e05a41cbb 100644 --- a/apps/insights/src/evals/quality.test.ts +++ b/apps/insights/src/evals/quality.test.ts @@ -345,6 +345,12 @@ it.each([ }) .parse(await read.execute(query, { toolCallId: "holdout", messages: [] })); const readings = Object.values(output.results); + const sources = Object.keys(output.results).map((resultKey) => ({ + source: "tool", + name: "get_data", + toolCallId: "holdout", + resultKey, + })); expect(Object.keys(readings[0].data[0])[0]).toBe( reordered ? "attributed_revenue" : "currency" ); @@ -360,7 +366,7 @@ it.each([ const failures = fixture.check( { ...holdoutOutcome, outcome: { ...holdoutOutcome.outcome, evidence } }, [], - { evidence: [selection] } + { evidence: [{ claim: selection, sources }] } ); expect(failures).toHaveLength(omitted ? 2 : 0); if (omitted) @@ -379,7 +385,7 @@ it.each([ outcome: { ...holdoutOutcome.outcome, evidence: [swapped] }, }, [], - { evidence: [selection] } + { evidence: [{ claim: selection, sources }] } ) ).toEqual(["Rendered evidence omitted Gross Revenue: 12,000 → 12,000"]); }); diff --git a/apps/insights/src/evals/quality.ts b/apps/insights/src/evals/quality.ts index fef567d4f..f88e70c10 100644 --- a/apps/insights/src/evals/quality.ts +++ b/apps/insights/src/evals/quality.ts @@ -1821,17 +1821,19 @@ for (const reordered of [false, true]) { const selection = z .object({ evidence: z.array( - z.union([ - z.string(), - z.object({ currency: z.string(), fields: z.array(z.string()) }), - ]) + z.object({ + claim: z.union([ + z.string(), + z.object({ currency: z.string(), fields: z.array(z.string()) }), + ]), + }) ), }) .safeParse(acceptedFinish); const fields = selection.success - ? selection.data.evidence.flatMap((entry) => - typeof entry !== "string" && entry.currency === "USD" - ? entry.fields + ? selection.data.evidence.flatMap(({ claim }) => + typeof claim !== "string" && claim.currency === "USD" + ? claim.fields : [] ) : []; diff --git a/apps/insights/src/investigation-flow.test.ts b/apps/insights/src/investigation-flow.test.ts index 3b183a155..e0d8e6de1 100644 --- a/apps/insights/src/investigation-flow.test.ts +++ b/apps/insights/src/investigation-flow.test.ts @@ -188,6 +188,35 @@ function appContext() { } function outputResponse(value: unknown) { + // Keep legacy outcome fixtures readable; raw/malformed model input stays invalid. + if ( + typeof value === "object" && + value !== null && + "evidence" in value && + "evidenceRefs" in value && + Array.isArray(value.evidence) && + Array.isArray(value.evidenceRefs) && + value.evidence.length === value.evidenceRefs.length && + value.evidence.every( + (claim) => + typeof claim === "string" || + (typeof claim === "object" && + claim !== null && + "currency" in claim && + "fields" in claim) + ) + ) { + const { evidence, evidenceRefs, ...outcome } = value; + value = { + ...outcome, + evidence: evidence.map((claim, index) => ({ + claim, + sources: Array.isArray(evidenceRefs[index]) + ? evidenceRefs[index] + : [evidenceRefs[index]], + })), + }; + } return toolCallResponse("finish_investigation", JSON.stringify(value)); } @@ -231,6 +260,286 @@ function outputModel(value: unknown = agentOutcome) { }); } +describe("claim-bound finish input", () => { + const finish = { + ...outcome, + next: agentOutcome.next, + evidence: outcome.evidence.map((claim, index) => ({ + claim, + sources: [{ source: "provided", index }], + })), + }; + const input = { + appContext: appContext(), + evidence, + githubRepository: null, + history: [], + otherOpenWork: [], + signal, + }; + const comparison = { + ...finish, + title: "Measured cohort comparison", + summary: "The measured populations have distinct counts.", + rootCause: null, + next: { type: "resolve", reason: "The comparison is measured." }, + }; + + it("requires sources on each model claim and stores the existing text outcome", async () => { + const model = outputModel(finish); + const result = await runInsightAgent(input, { model, tools: {} }); + const schema = model.doGenerateCalls[0]?.tools?.find( + (item) => item.name === "finish_investigation" + )?.inputSchema; + expect(schema).toMatchObject({ + properties: { + evidence: { + minItems: 1, + maxItems: 2, + items: { + required: expect.arrayContaining(["claim", "sources"]), + properties: { + claim: { + anyOf: expect.arrayContaining([ + expect.objectContaining({ type: "string" }), + expect.objectContaining({ + type: "object", + required: expect.arrayContaining(["currency", "fields"]), + }), + ]), + }, + sources: { type: "array", minItems: 1, maxItems: 8 }, + }, + }, + }, + }, + }); + expect(schema).not.toHaveProperty("properties.evidenceRefs"); + expect(result.outcome).toEqual(outcome); + expect(result.outcome).not.toHaveProperty("evidenceRefs"); + expect(model.doGenerateCalls).toHaveLength(1); + }); + + it.each([ + { name: "missing sources", item: { claim: outcome.evidence[0] } }, + { + name: "empty sources", + item: { claim: outcome.evidence[0], sources: [] }, + }, + { + name: "null sources", + item: { claim: outcome.evidence[0], sources: null }, + }, + { + name: "a reference instead of an array", + item: { + claim: outcome.evidence[0], + sources: { source: "provided", index: 0 }, + }, + }, + { + name: "an unknown source kind", + item: { + claim: outcome.evidence[0], + sources: [{ source: "invented", index: 0 }], + }, + }, + { + name: "a missing provided index", + item: { claim: outcome.evidence[0], sources: [{ source: "provided" }] }, + }, + { + name: "a negative provided index", + item: { + claim: outcome.evidence[0], + sources: [{ source: "provided", index: -1 }], + }, + }, + { + name: "a null reference", + item: { claim: outcome.evidence[0], sources: [null] }, + }, + { + name: "nested references", + item: { + claim: outcome.evidence[0], + sources: [[{ source: "provided", index: 0 }]], + }, + }, + { + name: "too many sources", + item: { + claim: outcome.evidence[0], + sources: Array.from({ length: 9 }, () => ({ + source: "provided", + index: 0, + })), + }, + }, + { + name: "a missing claim", + item: { sources: [{ source: "provided", index: 0 }] }, + }, + { + name: "a blank claim", + item: { claim: " ", sources: [{ source: "provided", index: 0 }] }, + }, + ])("rejects $name without repairing the raw model input", async ({ + item, + }) => { + const candidate = { ...finish, evidence: [item, finish.evidence[1]] }; + const response = toolCallResponse( + "finish_investigation", + JSON.stringify(candidate) + ); + const model = new MockLanguageModelV3({ + doGenerate: mockValues(response, response, response), + }); + await expect( + runInsightAgent(input, { model, tools: {} }) + ).rejects.toBeInstanceOf(InsightAgentGenerationError); + expect(model.doGenerateCalls).toHaveLength(3); + const error = model.doGenerateCalls[1]?.prompt + .flatMap((message) => (message.role === "tool" ? message.content : [])) + .find((part) => part.type === "tool-result"); + if (error?.output.type !== "error-text") + throw new Error("Missing schema validation error"); + const feedback = error.output.value; + expect(feedback).toContain("evidence"); + expect(feedback).toContain( + "claim" in item && item.claim?.trim() ? "sources" : "claim" + ); + }); + + it.each([ + { name: "no evidence", candidate: { ...finish, evidence: [] } }, + { + name: "three claims", + candidate: { + ...finish, + evidence: [...finish.evidence, finish.evidence[0]], + }, + }, + { name: "legacy prose and separate refs", candidate: agentOutcome }, + ])("rejects $name at the model boundary", async ({ candidate }) => { + const response = toolCallResponse( + "finish_investigation", + JSON.stringify(candidate) + ); + const model = new MockLanguageModelV3({ + doGenerate: mockValues(response, response, response), + }); + await expect( + runInsightAgent(input, { model, tools: {} }) + ).rejects.toBeInstanceOf(InsightAgentGenerationError); + expect(model.doGenerateCalls).toHaveLength(3); + }); + + it.each([ + "valid", + "wrong-source", + "wrong-number", + ] as const)("validates each claim against its own sources: %s", async (scenario) => { + const candidate = { + ...comparison, + evidence: [ + { + claim: "Checkout and report counts were 41 and 52.", + sources: [ + { source: "provided", index: 0 }, + { source: "provided", index: 1 }, + ], + }, + { + claim: `${scenario === "wrong-number" ? 53 : 52} reports were shared.`, + sources: [ + { source: "provided", index: scenario === "wrong-source" ? 0 : 1 }, + ], + }, + ], + }; + const model = outputModel(candidate); + const run = runInsightAgent( + { + ...input, + evidence: ["41 sessions used checkout.", "52 reports were shared."], + }, + { model, tools: {} } + ); + if (scenario === "valid") { + expect((await run).outcome.evidence).toEqual( + candidate.evidence.map((item) => item.claim) + ); + expect(model.doGenerateCalls).toHaveLength(1); + return; + } + await expect(run).rejects.toThrow( + `evidence[1] cites the number ${scenario === "wrong-number" ? 53 : 52}` + ); + expect(JSON.stringify(model.doGenerateCalls[1]?.prompt)).toContain( + "Correct evidence[1].sources" + ); + }); + + it.each([ + "valid", + "omitted-source", + "wrong-source", + "missing-source", + ] as const)("retains all five provided sources in a bound comparison: %s", async (scenario) => { + const counts = [41, 52, 63, 74, 85]; + const sources = counts.map((_, index) => ({ source: "provided", index })); + if (scenario === "omitted-source") sources.pop(); + if (scenario === "wrong-source") + sources[4] = { source: "provided", index: 0 }; + if (scenario === "missing-source") + sources[4] = { source: "provided", index: 5 }; + const claim = "The cohort counts were 41, 52, 63, 74, and 85."; + const model = outputModel({ + ...comparison, + evidence: [{ claim, sources }], + }); + const run = runInsightAgent( + { + ...input, + evidence: counts.map( + (count) => `The measured cohort contained ${count} profiles.` + ), + }, + { model, tools: {} } + ); + if (scenario === "valid") { + expect((await run).outcome.evidence).toEqual([claim]); + expect(model.doGenerateCalls).toHaveLength(1); + return; + } + await expect(run).rejects.toThrow( + scenario === "missing-source" ? "evidence index 5" : "number 85" + ); + }); + + it("accepts all eight sources at the normalization limit", async () => { + const counts = [41, 52, 63, 74, 85, 96, 107, 118]; + const claim = `The measured cohort counts were ${counts.join(", ")}.`; + const sources = counts.map((_, index) => ({ source: "provided", index })); + const model = outputModel({ + ...comparison, + evidence: [{ claim, sources }], + }); + const result = await runInsightAgent( + { + ...input, + evidence: counts.map( + (count) => `The cohort contained ${count} profiles.` + ), + }, + { model, tools: {} } + ); + expect(result.outcome.evidence).toEqual([claim]); + expect(model.doGenerateCalls).toHaveLength(1); + }); +}); + describe("intelligence agent", () => { it("does not resupply prior context snapshots or offer model-authored provenance", async () => { const model = outputModel(); @@ -2719,7 +3028,7 @@ describe("intelligence agent", () => { ); } else { expect(feedback).toContain("evidence[0] cites the number 88"); - expect(feedback).toContain("Correct evidenceRefs[0]"); + expect(feedback).toContain("Correct evidence[0].sources"); expect(feedback).toContain( "Preserve facts supported by inspected results" ); diff --git a/packages/shared/src/insights.ts b/packages/shared/src/insights.ts index e2d8e344b..e3c9b0be5 100644 --- a/packages/shared/src/insights.ts +++ b/packages/shared/src/insights.ts @@ -294,7 +294,7 @@ const insightDefinitionExecutionSchema = z.discriminatedUnion("operation", [ legacyDefinitionExecutionSchema, ]); -const agentEvidenceReferenceSchema = z.discriminatedUnion("source", [ +export const agentEvidenceReferenceSchema = z.discriminatedUnion("source", [ z .strictObject({ source: z.literal("history"), @@ -783,7 +783,7 @@ export const agentInvestigationOutcomeSchema = z .array( z.union([ agentEvidenceReferenceSchema, - z.array(agentEvidenceReferenceSchema).min(1).max(4), + z.array(agentEvidenceReferenceSchema).min(1).max(8), ]) ) .min(1) From 98834919368fe93ee357aadedf647317effec09b Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:11:56 +0300 Subject: [PATCH 2/4] fix(insights): decide publication from cited evidence --- SPEC.md | 2 +- apps/insights/src/agent.ts | 64 +++++++++------- apps/insights/src/evals/README.md | 4 +- apps/insights/src/evals/quality.test.ts | 44 +++++++++++ apps/insights/src/evals/quality.ts | 19 +++-- apps/insights/src/investigation-flow.test.ts | 79 ++++++-------------- apps/insights/src/measurement-plan.test.ts | 3 + apps/insights/src/measurement-plan.ts | 2 +- packages/shared/src/insights.ts | 10 +-- 9 files changed, 123 insertions(+), 104 deletions(-) diff --git a/SPEC.md b/SPEC.md index 766102f93..f13faa20a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -99,7 +99,7 @@ locks. Legacy replies without an original scope remain history rather than being relabeled as current business facts. Scope changes during execution reject the old outcome before persistence. -Tools are discoverable. There is no fixed first query, query family, receipt choreography, or two-read limit. Each investigation uses one tool loop with at most eight model turns, including a reserved final turn. It ends through `finish_investigation`, which validates the outcome and returns any repair error in the same conversation; at most three finish attempts are allowed. Successful reads include exact citation references. The finish tool keeps each claim together with its contributing sources; code renders structured revenue claims and validates every claim against those sources before storing the existing text outcome. The agent does not restart the conversation to repair output. +Tools are discoverable. There is no fixed first query, query family, receipt choreography, or two-read limit. Each investigation uses one tool loop with at most eight model turns, including a reserved final turn. It ends through `finish_investigation`, which validates the outcome and returns any repair error in the same conversation; at most three finish attempts are allowed. Supplied evidence and successful reads include exact citation references. Sufficient supplied evidence can finish immediately; requested reads must complete before finishing. The finish tool asks for sources and claims before the publication decision; code renders structured revenue claims and validates every claim against those sources before storing the existing text outcome. The agent does not restart the conversation to repair output. Native `revenue_overview` evidence selects a currency and metric fields from exact successful result references. Code renders labels, values, units, dates and differences for complete equal-duration comparison windows with the same website, timezone and filters, including fresh windows on a later recheck. The stored evidence remains text. This binds those numeric comparisons; other sources retain numeric grounding checks and every finding still needs semantic quality review. diff --git a/apps/insights/src/agent.ts b/apps/insights/src/agent.ts index bad717fb6..a88a25654 100644 --- a/apps/insights/src/agent.ts +++ b/apps/insights/src/agent.ts @@ -65,28 +65,33 @@ const revenueEvidenceSchema = z .max(4), }) .describe( - "For revenue_overview, select complementary fields: gross revenue, settled transactions, refunds, and attributed revenue when it differs from gross. Select only fields with a non-null value in every cited period. Omit redundant subtotals and diagnostic availability flags. One entry per measured population; a payment-description comparison uses a second entry for the whole-currency control. Cite both complete comparison windows in this claim's sources. Code supplies labels, values, periods and deltas; preserve supported comparisons when correcting format." + "For revenue_overview, select complementary fields: gross revenue, refunds, and attributed revenue when it differs from gross. Select only non-null fields in every cited period; omit redundant counts and subtotals. Refund totals/counts do not establish net revenue or distinct refunded receipts. One entry per population; payment-description comparisons need a second whole-currency control. Cite both complete windows using only get_data references. Code supplies labels, values, periods and deltas." ); -const finishSchema = z - .object(agentInvestigationOutcomeSchema.shape) - .omit({ evidenceRefs: true }) - .extend({ - evidence: z - .array( - z.strictObject({ - claim: z.union([ - agentInvestigationOutcomeSchema.shape.evidence.element, - revenueEvidenceSchema, - ]), - sources: z.array(agentEvidenceReferenceSchema).min(1).max(8), - }) - ) - .min(1) - .max(2) - .describe( - "Keep each concise claim with all its contributing sources. Every revenue_overview claim, including unchanged controls, must be {currency, fields}; use text for other claims. Receipt-description and whole-currency claims cite separate result pairs. Retain comparisons that change the interpretation." - ), - }); +const finishSchema = z.object({ + evidence: z + .array( + z.strictObject({ + sources: z.array(agentEvidenceReferenceSchema).min(1).max(8), + claim: z.union([ + agentInvestigationOutcomeSchema.shape.evidence.element.describe( + "One compact comparison: behavior, before → after, dates and denominator, plus any interpretation-changing control. Use about 30 words across all prose claims. Do not repeat event definitions or describe source provenance." + ), + revenueEvidenceSchema, + ]), + }) + ) + .min(1) + .max(2) + .describe( + "Select the evidence before deciding whether it merits publication. Keep each claim beside all contributing references. Revenue claims use {currency, fields} with only their contributing get_data references; other claims use concise text." + ), + publish: agentInvestigationOutcomeSchema.shape.publish, + ...agentInvestigationOutcomeSchema.omit({ + evidence: true, + evidenceRefs: true, + publish: true, + }).shape, +}); const revenueReadingSchema = z.object({ type: z.literal("revenue_overview"), @@ -373,7 +378,7 @@ export class InsightAgentGenerationError extends InsightAgentExecutionError { } const commonInstructions = (isDefinition: boolean) => - `Investigate one exact Databuddy signal until a teammate has a clear next move or a useful new fact. Finish by calling finish_investigation in a separate turn after receiving the needed read results. Its validation errors identify what to correct within this same investigation. Do not finish with ordinary text. + `Return one useful finding or next move for this exact Databuddy signal. Call finish_investigation as soon as supplied or inspected evidence is sufficient. If a read is needed, wait for its result before finishing. Repair validation errors using existing evidence; read again only to fill a missing fact. Do not finish with ordinary text. Subject - Name the exact subject: signal.entity.label for named goals, funnels, pages, events, and campaigns; otherwise the most specific inspected path, segment, or fingerprint. A fingerprint cohort can span routes, so never narrow the headline or repair request to one representative path. @@ -381,7 +386,7 @@ Subject Evidence - The optional investigationObjective is a machine-selected question, not a human request or citable measurement. Use it to choose useful diagnostic work; verify its premise with source data. -- Cite each evidence sentence to its actual source: source signal for the supplied signal; source provided with a valid zero-based evidence index; source history with the index of a prior action for its saved verification condition only (not historical or current measurements); source customer_impact for supplied customerImpact; source related_signal with its array index; or source tool with its exact name, toolCallId, and get_data resultKey (null for other tools). Keep each evidence claim with its sources, including every contributing period, population, and inspected mechanism. One concise comparison can cite several sources without repeating its facts. An exact verification read also supports the saved condition and code verdict returned with it. Correct a mismatched citation without discarding a supported discovery. Never cite a failed read as evidence. An empty evidence array does not invalidate the supplied signal. +- Keep each evidence claim with its actual sources. Copy the supplied evidence item's reference or a completed read's exact reference; include every contributing period, population, and inspected mechanism. Other references are source signal for the supplied signal, source related_signal with its array index, source customer_impact for customerImpact, or source history with its action index for a saved verification condition only. History cannot supply measurements. An exact verification read also supports its returned condition and code verdict. A concise comparison may cite several sources. Correct citations without discarding supported facts; never cite a failed read. Empty supplied evidence does not invalidate the signal. - Tool availability is not proof of a connected integration. If a connector reports missing access, stop trying that connector. Preserve an independently verified product or reliability finding, with an unknown cause when necessary. Missing diagnostic access is not evidence that tracking failed, and does not itself deserve a coverage notice or a connection request. - get_data can return a partial table. returnedRows is what you saw; rowCount is query rows, not visitors or all matching entities. A path missing from a top-N table is not absent. Use an exact filtered lookup or a dedicated aggregate before making absence, total, or exhaustive claims. Omit orderBy unless discovery documents the field and use only declared row filters. - Use reads to resolve a specific distinction that could change the finding or next move. Batch independent reads and never repeat an identical call. Stop gathering when further reads cannot change the decision; retain already-established changes and controls that change its interpretation. An overview of this subject can reveal several independent facts even when its headline metric is stable. For settled payments, distinguish gross revenue, refunds and attribution: stable sales with falling attribution limits acquisition decisions; rising refunds are a separate deterioration. Preserve both when measured, without treating one as the cause of the other. Select independent changes and interpretation-changing controls before redundant counts. @@ -399,12 +404,12 @@ Outcome Publishing - A raw website traffic change is not a verified product outcome. It may publish only as measurement_coverage with cited collection or implementation evidence. Uncited context, analytics counts, goal/funnel listings, and sibling metrics do not establish visitor loss. An unrelated sibling product result belongs to its own signal; comparisons returned for this subject belong in its finding when they change the interpretation. For a measurement-definition headline, name the mismatch and put period-specific counts in the evidence instead of estimating affected visits. -- Publish a distinct decision, action or durable finding; publication is independent of opening work. A material product result can publish with next.resolve and rootCause null. Name the changed outcome and measured scope. Keep unchanged, duplicate, routine, low-volume and unproven-impact work private. -- Distinguish an observed collection gap from an inability to explain a metric. Publish measurement_coverage only for a measured missing population or inspected tracking defect that makes a specific decision unsafe. An unavailable connector, absent diagnostic data, an unmeasured cohort, or an untested explanation is an investigation limit; resolve privately when that is the only new finding. A successful unrelated read does not turn that limit into a discovery. Still publish an independently verified outage or material product result. +- Publish a new measured finding that changes a product decision, or an inspected issue with a concrete remedy. A material product result can publish with next.resolve and rootCause null. Keep unchanged, explained, superseded, routine, low-volume and unproven-impact work private. A request for an explanation does not lower this threshold. An outdated business brief is context to correct, not an inspected measurement defect. +- Publish measurement_coverage only for a measured missing population or inspected tracking defect that makes a specific decision unsafe. An unavailable connector, absent diagnostic data, unmeasured or immature cohort, or untested explanation is an investigation limit; resolve privately when that is all you found. Waiting for a normal observation window is not a product or tracking problem. A successful unrelated read does not change this. Preserve an independently verified outage or material product result. - When a reported action is complete, remeasure its saved verification window and report whether the condition passed, failed, or remains inconclusive. Use the reported deployment time, not the reply timestamp, to select that window. An improvement that remains unhealthy is not recovery. When verification.read is supplied, use its exact query. Classify a measured goal or funnel recovery result as product_outcome; reserve measurement_definition for a newly inspected mismatch that needs a repair. Code computes the verdict and writes the summary, so omit that field when the finish schema omits it; keep the rest of the finding consistent. Missing, incomplete or undersampled measurements are inconclusive. A passed condition does not establish that a deployment preceded it or caused the improvement. Writing -- Keep title, summary, rootCause and evidence under 60 words combined; aim for 40–50. Title names the finding; summary adds a distinct consequence; rootCause names only the inspected failing operation; evidence supplies the before/after comparison and measured scope. State each fact once. Cite inspected code alongside the comparison without repeating its mechanism in the evidence text. Use one evidence entry, or two for a distinct comparison or contradiction. Preserve the affected cohort, denominator, period and stable control when they change the interpretation. Describe recorded behavior; eligible website visitors are not goal attempts, and missing telemetry or error exposure cannot prove failed tasks. Prefer the matched cohort and unchanged control over restating the definition. For repairs, say which behavior cannot be measured instead of calling reporting or decisions "unsafe". Omit investigation narration and repeated descriptions of the same change. +- Aim for 40–50 words across title, summary, rootCause and evidence; stay under 60. Title names the finding; summary adds its decision-relevant consequence; evidence supplies the before/after comparison and measured scope. State each fact once. Preserve the cohort, denominator, period, limiting identity coverage and interpretation-changing control; omit redundant counts and routine caveats. Use one evidence entry, or two for a distinct comparison. Put an inspected failing operation only in rootCause and cite its source alongside the comparison. Describe recorded behavior: visitors are not goal attempts, and missing telemetry or error exposure cannot prove failed tasks. Omit investigation narration and generic advice to investigate, monitor or prioritize further. - Never call occurrences, sessions, entrants, or samples "people"; distinguish visitors, identified profiles, and customers with attributed payment history. Translate raw event names into behavior; if behavior is unknown, say "this event." Never expose raw user, session, order, payment, or request identifiers. - For revenue_overview evidence, select {currency, fields} and cite only the contributing get_data result keys; code writes the quantitative comparison and deltas. Use a separate prose entry only when additional context is needed. Prefer independent changes and their stable control over redundant transaction or refund counts. Keep the headline, summary and cause qualitative when using this evidence. For other sources, report only supplied or measured numbers, using metricDelta for a change in native units. Write whole counts as integers and other numbers with at most one decimal. Never turn row counts into customer counts. @@ -1423,7 +1428,10 @@ export async function runInsightAgent( : {}), repository: input.githubRepository, investigationObjective: input.investigationObjective, - evidence: input.evidence, + evidence: input.evidence.map((value, index) => ({ + value, + reference: { source: "provided", index }, + })), history: input.history.map((item) => { if (item.kind !== "investigation") { return item; @@ -1464,7 +1472,7 @@ export async function runInsightAgent( ...investigationTools, finish_investigation: tool({ description: - "Submit the evidence-backed outcome and finish. Call after the necessary reads. If validation fails, correct the cited error using existing results.", + "Finish when supplied or inspected evidence supports the decision. Wait for any requested reads first. Correct validation errors using existing evidence.", inputSchema: pendingVerification ? finishInputSchema.omit({ summary: true }) : finishInputSchema, diff --git a/apps/insights/src/evals/README.md b/apps/insights/src/evals/README.md index c27e6cef5..7e2dd79f8 100644 --- a/apps/insights/src/evals/README.md +++ b/apps/insights/src/evals/README.md @@ -22,9 +22,9 @@ The checks cover signal-only evidence, a verified collection gap, a useful produ Usefulness checks require missing-access-only notices to remain private, retain a verified decline despite unavailable diagnostics, and retain its steady-arrival comparison. The source-comparison case reuses the production funnel tool input contracts with synthetic per-period and combined responses; the agent must inspect the two periods separately to locate the decline. Published briefs are measured against a 60-word budget across title, summary, cause, and evidence (plus impact for baseline/legacy outcomes); action details are excluded. Review brevity alongside retained information, not as a substitute for usefulness. -Source interpretation requires manual review against the tool results. The automatic checks detect missing period reads and an omitted source cohort; a source-name or number match cannot establish a correct comparison. The runner records `reviewRequired` and prints `REVIEW REQUIRED` for a mechanically valid source case. A zero exit status means no automatic check failed; it does not complete that review. Verify direction, cohort, and period attribution, accepting equivalent measured rates. The 60-word budget is a quality target, not a runtime publication gate. +Source interpretation requires manual review against the tool results. The automatic checks detect missing period reads and an omitted source cohort; a source-name or number match cannot establish a correct comparison. The runner records `reviewRequired` and prints `REVIEW REQUIRED` for a mechanically valid source case. Exact goal-read checks accept omitted or null cohort selectors while rejecting changed populations, websites and windows. A zero exit status means no automatic check failed; it does not complete that review. Verify direction, cohort, and period attribution, accepting equivalent measured rates. The 60-word budget is a quality target, not a runtime publication gate. -The agent uses one native tool loop, ending with `finish_investigation` in a separate turn after receiving its reads. It cannot cite a read sent in the same batch, because the model has not seen that result; the validation error asks it to use the completed result next turn without repeating the read. The finish tool accepts one or two evidence objects, each containing a `claim` and one to eight `sources`; it no longer asks the model to align separate claim and citation arrays. Revenue claims retain their native `{currency, fields}` selection. Stored evidence remains text. Schema and evidence validation failures return as tool errors in the same conversation. The total budget is eight model turns, with the last reserved for finishing, and at most three finish attempts. An empty or text-only provider response fails the run without starting another conversation. Read results supply exact citation references to copy; failed reads supply none. The reported read-call count excludes the finish tool; JSONL model requests and step events record all turns and finish attempts. Compare both counts when evaluating efficiency. +The agent uses one native tool loop and can finish immediately when supplied evidence is sufficient. Supplied evidence includes exact citation references. When a read is needed, the agent waits for its result before calling `finish_investigation`. It cannot cite a read sent in the same batch, because the model has not seen that result; the validation error asks it to use the completed result next turn without repeating the read. The finish tool accepts one or two evidence objects, each containing a `claim` and one to eight `sources`; sources precede their claim, and evidence precedes the publication decision. The model no longer aligns separate claim and citation arrays. Revenue claims retain their native `{currency, fields}` selection. Stored evidence remains text. Schema and evidence validation failures return as tool errors in the same conversation. The total budget is eight model turns, with the last reserved for finishing, and at most three finish attempts. An empty or text-only provider response fails the run without starting another conversation. Read results supply exact citation references to copy; failed reads supply none. The reported read-call count excludes the finish tool; JSONL model requests and step events record all turns and finish attempts. Compare both counts when evaluating efficiency. New model outputs omit the separate impact paragraph; the finding summary states the consequence. Stored legacy impact remains readable and new stored outcomes default it to null. Review each executed JSONL, including failed/intermediate drafts, before claiming an improvement. Keep the exact source revisions and fixture versions with the comparison; changing a fixture is not an agent improvement. diff --git a/apps/insights/src/evals/quality.test.ts b/apps/insights/src/evals/quality.test.ts index e05a41cbb..d55953641 100644 --- a/apps/insights/src/evals/quality.test.ts +++ b/apps/insights/src/evals/quality.test.ts @@ -311,6 +311,50 @@ const holdoutOutcome: InsightAgentResult = { }, }; +it.each([undefined, null])( + "accepts an unscoped native goal read with cohort %s while rejecting scope drift", + async (cohort) => { + const fixture = qualityCases.find( + (entry) => entry.id === "current-goal-unchanged" + ); + const read = fixture?.tools.get_goal_analytics; + if (!(fixture && read?.execute && read.inputSchema instanceof z.ZodType)) + throw new Error("Missing native goal evaluation"); + const query = read.inputSchema.parse({ + goalId: fixture.input.signal.entity.id, + startDate: fixture.input.signal.period.current.from, + endDate: fixture.input.signal.period.current.to, + ...(cohort === null ? { cohort } : {}), + }); + const call = { + name: "get_goal_analytics", + input: query, + output: await read.execute(query, { toolCallId: "goal", messages: [] }), + }; + const result = { + ...holdoutOutcome, + outcome: { + ...holdoutOutcome.outcome, + publish: false, + publicationBasis: null, + }, + }; + expect(fixture.check(result, [call])).toEqual([]); + for (const change of [ + { goalId: "other-goal" }, + { websiteId: "other-site" }, + { startDate: "2026-09-01" }, + { cohort: { country: "US" } }, + ]) { + expect( + fixture.check(result, [{ ...call, input: { ...query, ...change } }]) + ).toEqual([ + `Did not remeasure the exact goal for ${fixture.input.signal.period.current.from}–${fixture.input.signal.period.current.to}`, + ]); + } + } +); + it.each([ false, true, diff --git a/apps/insights/src/evals/quality.ts b/apps/insights/src/evals/quality.ts index f88e70c10..f7267f8dd 100644 --- a/apps/insights/src/evals/quality.ts +++ b/apps/insights/src/evals/quality.ts @@ -1251,16 +1251,15 @@ for (const scenario of [ calls.some( (call) => call.name === "get_goal_analytics" && - isDeepStrictEqual(call.input, { - startDate: window.from, - endDate: window.to, - goalId: goal.id, - ...(call.input && - typeof call.input === "object" && - "websiteId" in call.input - ? { websiteId: appContext.websiteId } - : {}), - }) + z + .strictObject({ + startDate: z.literal(window.from), + endDate: z.literal(window.to), + goalId: z.literal(goal.id), + websiteId: z.literal(appContext.websiteId).optional(), + cohort: z.null().optional(), + }) + .safeParse(call.input).success ) ? [] : [`Did not remeasure the exact goal for ${window.from}–${window.to}`] diff --git a/apps/insights/src/investigation-flow.test.ts b/apps/insights/src/investigation-flow.test.ts index e0d8e6de1..affb0360e 100644 --- a/apps/insights/src/investigation-flow.test.ts +++ b/apps/insights/src/investigation-flow.test.ts @@ -326,39 +326,6 @@ describe("claim-bound finish input", () => { name: "empty sources", item: { claim: outcome.evidence[0], sources: [] }, }, - { - name: "null sources", - item: { claim: outcome.evidence[0], sources: null }, - }, - { - name: "a reference instead of an array", - item: { - claim: outcome.evidence[0], - sources: { source: "provided", index: 0 }, - }, - }, - { - name: "an unknown source kind", - item: { - claim: outcome.evidence[0], - sources: [{ source: "invented", index: 0 }], - }, - }, - { - name: "a missing provided index", - item: { claim: outcome.evidence[0], sources: [{ source: "provided" }] }, - }, - { - name: "a negative provided index", - item: { - claim: outcome.evidence[0], - sources: [{ source: "provided", index: -1 }], - }, - }, - { - name: "a null reference", - item: { claim: outcome.evidence[0], sources: [null] }, - }, { name: "nested references", item: { @@ -376,14 +343,6 @@ describe("claim-bound finish input", () => { })), }, }, - { - name: "a missing claim", - item: { sources: [{ source: "provided", index: 0 }] }, - }, - { - name: "a blank claim", - item: { claim: " ", sources: [{ source: "provided", index: 0 }] }, - }, ])("rejects $name without repairing the raw model input", async ({ item, }) => { @@ -406,25 +365,13 @@ describe("claim-bound finish input", () => { throw new Error("Missing schema validation error"); const feedback = error.output.value; expect(feedback).toContain("evidence"); - expect(feedback).toContain( - "claim" in item && item.claim?.trim() ? "sources" : "claim" - ); + expect(feedback).toContain("sources"); }); - it.each([ - { name: "no evidence", candidate: { ...finish, evidence: [] } }, - { - name: "three claims", - candidate: { - ...finish, - evidence: [...finish.evidence, finish.evidence[0]], - }, - }, - { name: "legacy prose and separate refs", candidate: agentOutcome }, - ])("rejects $name at the model boundary", async ({ candidate }) => { + it("rejects legacy prose and separate references at the model boundary", async () => { const response = toolCallResponse( "finish_investigation", - JSON.stringify(candidate) + JSON.stringify(agentOutcome) ); const model = new MockLanguageModelV3({ doGenerate: mockValues(response, response, response), @@ -2793,6 +2740,7 @@ describe("intelligence agent", () => { reason: "Coverage is uncertain; the cause has not been established.", }, }; + const model = outputModel(coverage); const run = runInsightAgent( { appContext: appContext(), @@ -2819,7 +2767,7 @@ describe("intelligence agent", () => { history: [], otherOpenWork: [], }, - { model: outputModel(coverage), tools: {} } + { model, tools: {} } ); if (citeBusiness && publish) { await expect(run).rejects.toThrow( @@ -2833,6 +2781,23 @@ describe("intelligence agent", () => { next: { type: "resolve" }, rootCause: null, }); + const message = model.doGenerateCalls[0]?.prompt + .find((item) => item.role === "user") + ?.content.find((item) => item.type === "text"); + if (message?.type !== "text") throw new Error("Missing evidence prompt"); + expect(JSON.parse(message.text)).toMatchObject({ + businessContext: { sourceEvidenceIndexes: [providedCount] }, + evidence: [ + ...Array.from({ length: providedCount }, (_, index) => ({ + value: collection, + reference: { source: "provided", index }, + })), + { + value: expect.stringContaining(background), + reference: { source: "provided", index: providedCount }, + }, + ], + }); }); it.each([ diff --git a/apps/insights/src/measurement-plan.test.ts b/apps/insights/src/measurement-plan.test.ts index 7680df069..391cc4138 100644 --- a/apps/insights/src/measurement-plan.test.ts +++ b/apps/insights/src/measurement-plan.test.ts @@ -108,6 +108,9 @@ describe("saved activation and return measurement", () => { }); expect(signal.direction).toBe("up"); expect(signal.evidence?.join("\n")).toContain("200/2000"); + expect(signal.evidence?.join("\n")).toContain( + "Activation identity coverage: 10% (200/2000 activation events)" + ); expect(signal.evidence?.join("\n")).toContain( "Anonymous events are outside the profile denominator" ); diff --git a/apps/insights/src/measurement-plan.ts b/apps/insights/src/measurement-plan.ts index 3f2e2b28c..cf314e24b 100644 --- a/apps/insights/src/measurement-plan.ts +++ b/apps/insights/src/measurement-plan.ts @@ -272,7 +272,7 @@ export async function detectRetentionSignals( evidence: [ ...(["previous", "current"] as const).map((key) => { const counts = measured[key]; - return `Native identified_profile_retention, ${period[key].from}–${period[key].to}: ${counts.retained}/${counts.eligible} eligible identified profiles returned (${Math.round((counts.retained / counts.eligible) * 1000) / 10}%). Activation events with direct identity: ${counts.identifiedEvents}/${counts.events}. Both counts refer to this week's activation window.`; + return `Native identified_profile_retention, ${period[key].from}–${period[key].to}: ${counts.retained}/${counts.eligible} eligible identified profiles returned (${Math.round((counts.retained / counts.eligible) * 1000) / 10}%). Activation identity coverage: ${Math.round((counts.identifiedEvents / counts.events) * 1000) / 10}% (${counts.identifiedEvents}/${counts.events} activation events). Both counts refer to this week's activation window.`; }), `Team-defined activation event: ${plan.activationEvent}`, `Team-defined return event: ${plan.returnEvent}`, diff --git a/packages/shared/src/insights.ts b/packages/shared/src/insights.ts index e3c9b0be5..91546ab2e 100644 --- a/packages/shared/src/insights.ts +++ b/packages/shared/src/insights.ts @@ -611,7 +611,7 @@ export const investigationOutcomeSchema = z .trim() .min(1) .describe( - "In roughly twelve words, state the consequence for the affected journey or decision. Keep measured comparisons in evidence and the inspected mechanism in rootCause; do not repeat them here." + "In 8–10 words, add a concrete implication or material limit. No restatement of the headline, generic advice, or unmeasured customer/revenue harm. Keep comparisons in evidence and the inspected mechanism in rootCause." ), // Retain stored briefs; new investigations include the consequence in summary. impact: z.string().trim().min(1).nullable().default(null), @@ -621,7 +621,7 @@ export const investigationOutcomeSchema = z .min(1) .nullable() .describe( - "One short, inspected causal mechanism describing the actual failing operation. Use null for unknown, suspected, or merely correlated explanations. Error text, a runtime stack, bundle location, route, browser document line, timing, or annotation is not a source-code mechanism." + "One short, inspected mechanism naming the actual failing operation; otherwise null. A business brief or team reply alone cannot verify an implementation or measurement defect. Error text, a runtime stack, route, timing or annotation is not an inspected mechanism." ), // Supplied background only; does not establish which facts influenced a claim. contextSnapshot: businessContextSchema.optional(), @@ -771,7 +771,7 @@ const agentTitleSchema = z "Titles must use natural product language, never raw identifiers, event names, or URLs", }) .describe( - "A short headline stating the verified finding in natural product language. For directly measured reliability or user impact, an affected count can lead. With structured revenue evidence, use a qualitative headline and keep all quantities in the generated evidence. For measurement_definition, name the incorrect target or purpose mismatch without a numeric count; keep counts with their periods in evidence. For measurement_coverage, name the observed blind spot, never a presumed product loss. Never use raw identifiers, snake_case event names, or URLs." + "A natural 4–8 word headline stating the finding. Directly measured reliability or user impact may lead with an affected count. Structured revenue headlines stay qualitative. Measurement findings name the inspected mismatch or measured blind spot without implying product harm; counts belong with dates in evidence. Never use raw identifiers, event names or URLs." ); export const agentInvestigationOutcomeSchema = z @@ -795,10 +795,10 @@ export const agentInvestigationOutcomeSchema = z publish: z .boolean() .describe( - "True only when this turn adds a new customer-relevant fact worth showing in Insights." + "True for a new material measured change or coverage gap, inspected defect, or verification verdict. False for a baseline alone, normal maturation, explained/excluded changes, stale business context, or missing diagnostic access. Answering a question does not itself merit a feed incident." ), findingKind: insightFindingKindSchema.describe( - "Classify this as user_experience only for a directly measured downstream user experience; product_outcome for a measured business or journey result, or a material measured usage change of a behavior whose purpose is established by inspected code or explicit owner context, even when its cause is unknown (event names and raw traffic alone do not establish purpose); reliability_exposure for directly measured error or performance exposure without a measured downstream outcome; measurement_definition for a named definition that measures something other than its stated purpose; or measurement_coverage for missing telemetry/setup. Published user experience and product outcomes require measured impact, reliability exposure requires measured reliability, and published measurement findings require decision safety." + "Classify the cited evidence: user_experience needs a measured downstream consequence; product_outcome needs a measured result of known-purpose behavior; reliability_exposure reports measured errors or performance. measurement_definition needs an inspected current definition or emitter mismatch, not a stale brief or reply alone. measurement_coverage needs a measured missing population or inspected collection defect, not immature cohorts or unavailable diagnostics. Event names alone establish no business purpose." ), publicationBasis: insightPublicationBasisSchema .nullable() From 9235c19c7c83cc343fe44f7e67ac761ca1c3a146 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:11:57 +0300 Subject: [PATCH 3/4] fix(dashboard): label investigation resolutions as conclusions --- apps/dashboard/app/(main)/insights/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/app/(main)/insights/[id]/page.tsx b/apps/dashboard/app/(main)/insights/[id]/page.tsx index 5ee5b393b..5c008dfd1 100644 --- a/apps/dashboard/app/(main)/insights/[id]/page.tsx +++ b/apps/dashboard/app/(main)/insights/[id]/page.tsx @@ -774,7 +774,7 @@ function nextCopy( label: "Measuring", }; case "resolve": - return { body: next.reason, label: "Verified" }; + return { body: next.reason, label: "Conclusion" }; default: throw new Error("Unknown investigation outcome"); } From 065a527d27c8f8bbca539fa6e7599bb830507cb8 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:59:20 +0300 Subject: [PATCH 4/4] fix(insights): verify saved recovery checks without model turns --- SPEC.md | 2 +- apps/insights/src/agent.ts | 348 ++++++++-- apps/insights/src/evals/README.md | 2 + apps/insights/src/evals/quality.test.ts | 7 + apps/insights/src/evals/quality.ts | 8 +- apps/insights/src/investigation-flow.test.ts | 159 +++-- apps/insights/src/native-verification.test.ts | 597 ++++++++++++++++++ apps/insights/src/resume.ts | 6 + packages/rpc/src/routers/insights.ts | 3 +- packages/shared/src/insights.ts | 4 + 10 files changed, 994 insertions(+), 142 deletions(-) create mode 100644 apps/insights/src/native-verification.test.ts diff --git a/SPEC.md b/SPEC.md index f13faa20a..31872cfdf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -99,7 +99,7 @@ locks. Legacy replies without an original scope remain history rather than being relabeled as current business facts. Scope changes during execution reject the old outcome before persistence. -Tools are discoverable. There is no fixed first query, query family, receipt choreography, or two-read limit. Each investigation uses one tool loop with at most eight model turns, including a reserved final turn. It ends through `finish_investigation`, which validates the outcome and returns any repair error in the same conversation; at most three finish attempts are allowed. Supplied evidence and successful reads include exact citation references. Sufficient supplied evidence can finish immediately; requested reads must complete before finishing. The finish tool asks for sources and claims before the publication decision; code renders structured revenue claims and validates every claim against those sources before storing the existing text outcome. The agent does not restart the conversation to repair output. +Scheduled goal/funnel recovery checks and explicit Apply verification replies are deterministic: one exact native read verifies the saved subject, population, definition, full window, minimum sample and threshold without a model call. Code renders the result and keeps inconclusive checks private. An unfinished window preserves the case and saved check until midnight UTC after its inclusive end date; a completed check resolves without inventing another repair. Free-form human replies retain the investigation agent and their supplied context. Unsupported legacy population checks remain inconclusive without an aggregate read. Read inputs, results and failures remain observable. Other investigations use discoverable tools. There is no fixed first query, query family, receipt choreography, or two-read limit. Each investigation uses one tool loop with at most eight model turns, including a reserved final turn. It ends through `finish_investigation`, which validates the outcome and returns any repair error in the same conversation; at most three finish attempts are allowed. Supplied evidence and successful reads include exact citation references. Sufficient supplied evidence can finish immediately; requested reads must complete before finishing. The finish tool asks for sources and claims before the publication decision; code renders structured revenue claims and validates every claim against those sources before storing the existing text outcome. The agent does not restart the conversation to repair output. Native `revenue_overview` evidence selects a currency and metric fields from exact successful result references. Code renders labels, values, units, dates and differences for complete equal-duration comparison windows with the same website, timezone and filters, including fresh windows on a later recheck. The stored evidence remains text. This binds those numeric comparisons; other sources retain numeric grounding checks and every finding still needs semantic quality review. diff --git a/apps/insights/src/agent.ts b/apps/insights/src/agent.ts index a88a25654..24c45cf88 100644 --- a/apps/insights/src/agent.ts +++ b/apps/insights/src/agent.ts @@ -37,7 +37,9 @@ import { ToolLoopAgent, } from "ai"; import type { ErrorCustomerImpact } from "./error-customer-impact"; +import { raceWithAbort } from "./funnel-detection"; import { signalKeyForDetectedSignal } from "./investigation"; +import { emitInsightsEvent } from "./lib/evlog-insights"; const MAX_STEPS = 8; const TIMEOUT_MS = 2 * 60_000; @@ -333,17 +335,28 @@ export interface InsightAgentInput { }[]; relatedSignals?: InvestigationSignal[]; request?: { + kind?: "verification"; body: string; createdAt: string; }; signal: InvestigationSignal; } +type VerificationRead = Pick< + StepResult["toolResults"][number], + "toolName" | "toolCallId" | "input" | "output" +>; + +type SavedVerification = NonNullable & { + reason: string; +}; + export interface InsightAgentResult { modelId?: string; outcome: InvestigationOutcome; toolCallCount: number; usage?: LanguageModelUsage; + verificationRead?: VerificationRead; } export class InsightAgentExecutionError extends Error { readonly modelId: string; @@ -930,13 +943,7 @@ function resolveEvidenceReferences( ); } -function verificationFor( - input: InsightAgentInput, - results: Pick< - StepResult["toolResults"][number], - "toolName" | "toolCallId" | "input" | "output" - >[] -): InvestigationOutcome["verification"] { +function savedVerificationCheck(input: InsightAgentInput) { const prior = [...input.history] .reverse() .find( @@ -948,15 +955,26 @@ function verificationFor( ); if ( prior?.kind !== "investigation" || - prior.outcome.next.type !== "act" || - !prior.outcome.next.check || !["goal", "funnel"].includes(input.signal.entity.type) ) { return; } - const check = prior.outcome.next.check; - // A source case cannot recover on whole-funnel counts, including legacy checks. + return prior.outcome.next.type === "act" + ? (prior.outcome.next.check ?? undefined) + : prior.outcome.next.type === "watch" && + prior.outcome.verification?.status === "inconclusive" + ? prior.outcome.verification.check + : undefined; +} + +function verifySavedMeasurement( + input: InsightAgentInput, + check: NonNullable>, + result?: VerificationRead +): SavedVerification { + // Legacy source checks cannot recover on aggregate counts or an unbound definition. if ( + !check.definition || input.signal.signalKey.startsWith( `funnel:${input.signal.entity.id}:referrer:` ) @@ -967,27 +985,11 @@ function verificationFor( measured: null, entrants: null, source: null, + reason: check.definition + ? "This saved population cannot be verified with aggregate analytics." + : "The saved condition has no bound measurement definition.", }; } - const result = [...results].reverse().find( - (item) => - item.toolName === `get_${input.signal.entity.type}_analytics` && - item.input && - typeof item.input === "object" && - isDeepStrictEqual( - Object.fromEntries( - Object.entries(item.input).filter( - ([key, value]) => - key !== "websiteId" && !(key === "cohort" && value == null) - ) - ), - { - [`${input.signal.entity.type}Id`]: input.signal.entity.id, - startDate: check.startDate, - endDate: check.endDate, - } - ) - ); const measurement = z .object({ measurement: insightMeasurementSchema, @@ -996,22 +998,46 @@ function verificationFor( overall_conversion_rate: z.number().finite().min(0).max(100), }) .safeParse(result?.output); - const verification: NonNullable = { + const verification: SavedVerification = { + reason: "The exact saved measurement is unavailable.", check, status: "inconclusive", measured: null, entrants: null, source: null, }; + if (!(result && isSuccessfulRead(result.output) && measurement.success)) { + return verification; + } if ( - !(result && isSuccessfulRead(result.output) && measurement.success) || measurement.data.total_users_completed > - measurement.data.total_users_entered || + measurement.data.total_users_entered + ) { + return { + ...verification, + reason: "The returned visitor counts are inconsistent.", + }; + } + if ( measurement.data.measurement.websiteId !== (input.appContext.websiteId ?? input.appContext.defaultWebsiteId) || - measurement.data.measurement.definitionId !== input.signal.entity.id || + measurement.data.measurement.definitionId !== input.signal.entity.id + ) { + return { + ...verification, + reason: "The returned measurement concerns a different subject.", + }; + } + if ( measurement.data.measurement.startDate !== check.startDate || - measurement.data.measurement.endDate !== check.endDate || + measurement.data.measurement.endDate !== check.endDate + ) { + return { + ...verification, + reason: `Returned window ${measurement.data.measurement.startDate}–${measurement.data.measurement.endDate} differs from the saved window.`, + }; + } + if ( !isDeepStrictEqual( check.definition, insightVerificationDefinitionSchema.parse( @@ -1019,7 +1045,11 @@ function verificationFor( ) ) ) { - return verification; + return { + ...verification, + reason: + "The returned population or definition differs from the saved condition.", + }; } verification.measured = measurement.data[check.metric]; verification.entrants = measurement.data.total_users_entered; @@ -1030,11 +1060,19 @@ function verificationFor( resultKey: null, }; if ( - new Date(input.appContext.currentDateTime).getTime() < - Date.parse(check.endDate) + 86_400_000 || - verification.entrants < check.minimumEntrants + Date.parse(input.appContext.currentDateTime) < + Date.parse(check.endDate) + 86_400_000 ) { - return verification; + return { + ...verification, + reason: `The saved window remains open through ${check.endDate} UTC.`, + }; + } + if (verification.entrants < check.minimumEntrants) { + return { + ...verification, + reason: `Only ${verification.entrants} eligible visitors; ${check.minimumEntrants} required.`, + }; } const { comparison, value } = check.threshold; const passed = @@ -1045,7 +1083,48 @@ function verificationFor( : comparison === "below" ? verification.measured < value : verification.measured <= value; - return { ...verification, status: passed ? "passed" : "failed" }; + return { + ...verification, + status: passed ? "passed" : "failed", + reason: passed + ? "The saved recovery condition passed." + : "The saved recovery condition failed.", + }; +} + +function verificationFor( + input: InsightAgentInput, + results: VerificationRead[] +): InvestigationOutcome["verification"] { + const check = savedVerificationCheck(input); + if (!check) { + return; + } + const result = [...results].reverse().find( + (item) => + item.toolName === `get_${input.signal.entity.type}_analytics` && + item.input && + typeof item.input === "object" && + isDeepStrictEqual( + Object.fromEntries( + Object.entries(item.input).filter( + ([key, value]) => + key !== "websiteId" && !(key === "cohort" && value == null) + ) + ), + { + [`${input.signal.entity.type}Id`]: input.signal.entity.id, + startDate: check.startDate, + endDate: check.endDate, + } + ) + ); + const { reason: _reason, ...verification } = verifySavedMeasurement( + input, + check, + result + ); + return verification; } function validateAgentOutcome( @@ -1252,6 +1331,136 @@ function validateAgentOutcome( return investigationOutcomeSchema.parse({ ...outcome, next }); } +async function runSavedVerification( + input: InsightAgentInput, + check: NonNullable>, + tools: ToolSet, + abortSignal?: AbortSignal +): Promise { + const toolName = `get_${input.signal.entity.type}_analytics`; + const toolCallId = crypto.randomUUID(); + const query = { + [`${input.signal.entity.type}Id`]: input.signal.entity.id, + websiteId: input.appContext.websiteId ?? input.appContext.defaultWebsiteId, + startDate: check.startDate, + endDate: check.endDate, + cohort: null, + }; + const deadline = AbortSignal.any([ + ...(abortSignal ? [abortSignal] : []), + AbortSignal.timeout(TIMEOUT_MS), + ]); + let verificationRead: VerificationRead | undefined; + let toolCallCount = 0; + if ( + check.definition && + !input.signal.signalKey.startsWith( + `funnel:${input.signal.entity.id}:referrer:` + ) + ) { + const trace = { + organization_id: input.appContext.organizationId, + website_id: query.websiteId, + signal_key: input.signal.signalKey, + tool_name: toolName, + tool_call_id: toolCallId, + input: JSON.stringify(query), + }; + emitInsightsEvent("info", "verification.read.started", trace); + let output: unknown; + try { + const execute = tools[toolName]?.execute; + if (!execute) { + throw new Error("The saved measurement tool is unavailable."); + } + output = await raceWithAbort(async () => { + toolCallCount++; + return await execute(query, { + toolCallId, + messages: [], + abortSignal: deadline, + experimental_context: input.appContext, + }); + }, deadline); + } catch (error) { + if (deadline.aborted) { + emitInsightsEvent("warn", "verification.read.aborted", { + ...trace, + error_message: + error instanceof Error ? error.message : "Verification aborted", + }); + deadline.throwIfAborted(); + } + // Retain failed-read diagnostics without turning them into measurements or repairs. + output = { + error: + error instanceof Error + ? error.message + : "The saved measurement failed.", + }; + } + verificationRead = { toolName, toolCallId, input: query, output }; + emitInsightsEvent("info", "verification.read.completed", { + ...trace, + output: JSON.stringify(output, (_key, value) => + typeof value === "bigint" ? value.toString() : value + ), + tool_call_count: toolCallCount, + }); + } + const { reason, ...verification } = verifySavedMeasurement( + input, + check, + verificationRead + ); + const { status } = verification; + const windowClosesAt = Date.parse(check.endDate) + 86_400_000; + const waitingForWindow = + Date.parse(input.appContext.currentDateTime) < windowClosesAt; + const unit = + check.metric === "overall_conversion_rate" + ? "% conversion" + : " completed visitors"; + const threshold = `${{ above: "more than", at_or_above: "at least", below: "less than", at_or_below: "at most" }[check.threshold.comparison]} ${check.threshold.value}${unit}`; + const population = + input.signal.entity.type === "goal" + ? "eligible website visitors" + : "funnel entrants"; + const evidence = [ + `${check.startDate}–${check.endDate} UTC. ${verification.source ? `${verification.measured}${unit}; ${verification.entrants} ${population}. ` : ""}Required: ${threshold}; minimum ${check.minimumEntrants} eligible visitors.`, + ]; + const outcome = investigationOutcomeSchema.parse({ + title: `${input.signal.entity.label}: check ${status}`, + summary: + status === "inconclusive" ? `Recovery is unverified: ${reason}` : reason, + rootCause: null, + evidence, + findingKind: "product_outcome", + publish: status !== "inconclusive", + publicationBasis: status === "inconclusive" ? null : "measured_impact", + next: waitingForWindow + ? { + type: "watch", + escalation: `Verify the saved condition after ${check.endDate} UTC.`, + recheckAt: new Date(windowClosesAt).toISOString(), + } + : { + type: "resolve", + reason: + status === "passed" + ? "The condition passed; this does not establish that the reported change caused it." + : "No new repair is established by this verification result.", + }, + verification, + }); + return { + outcome, + toolCallCount, + usage: aggregateUsage([]), + ...(verificationRead ? { verificationRead } : {}), + }; +} + export async function runInsightAgent( originalInput: InsightAgentInput, options: { @@ -1261,6 +1470,34 @@ export async function runInsightAgent( tools?: ToolSet; } = {} ): Promise { + options.abortSignal?.throwIfAborted(); + const organizationId = originalInput.appContext.organizationId; + if (!organizationId) { + throw new Error("An organization is required for investigation tools"); + } + + const availableTools = + options.tools ?? + (await import("@databuddy/ai/tools/toolkit")).createToolkit({ + capabilities: ["analytics", "investigation"], + domain: originalInput.appContext.websiteDomain, + githubRepository: originalInput.githubRepository, + organizationId, + userId: originalInput.appContext.userId, + }); + const savedCheck = savedVerificationCheck(originalInput); + if ( + savedCheck && + (!originalInput.request || originalInput.request.kind === "verification") + ) { + return runSavedVerification( + originalInput, + savedCheck, + availableTools, + options.abortSignal + ); + } + const businessContext = originalInput.businessContext ? businessContextSchema.parse(originalInput.businessContext) : undefined; @@ -1278,10 +1515,6 @@ export async function runInsightAgent( if (!(options.model || isAiGatewayConfigured)) { throw new Error("AI_GATEWAY_API_KEY is required"); } - const organizationId = input.appContext.organizationId; - if (!organizationId) { - throw new Error("An organization is required for investigation tools"); - } const isDefinition = ["goal", "funnel"].includes(input.signal.entity.type); const finishInputSchema = isDefinition ? finishSchema @@ -1306,15 +1539,6 @@ export async function runInsightAgent( .filter(Boolean) .join("\n\n"); const pendingVerification = verificationFor(input, []); - const availableTools = - options.tools ?? - (await import("@databuddy/ai/tools/toolkit")).createToolkit({ - capabilities: ["analytics", "investigation"], - domain: input.appContext.websiteDomain, - githubRepository: input.githubRepository, - organizationId, - userId: input.appContext.userId, - }); const { configure_investigations: _configureInvestigations, describe_schema: _describeSchema, @@ -1547,6 +1771,22 @@ export async function runInsightAgent( } : {}), }); + if ( + verification?.status === "inconclusive" && + proposed.next.type === "act" && + !proposed.next.execution && + !proposed.evidenceRefs + .flat() + .some( + (ref) => + ref.source === "tool" && + DEFINITION_PURPOSE_TOOLS.includes(ref.name) + ) + ) { + throw new Error( + "An inconclusive saved check does not establish a new repair. A manual action needs independently inspected implementation evidence; otherwise report the check's limitation." + ); + } const successfulResults = results.filter( (result) => successfulReadOutputs(result).length > 0 ); diff --git a/apps/insights/src/evals/README.md b/apps/insights/src/evals/README.md index 7e2dd79f8..284fa23c1 100644 --- a/apps/insights/src/evals/README.md +++ b/apps/insights/src/evals/README.md @@ -47,3 +47,5 @@ Cohort review must distinguish a goal's website page-view denominator from its i Prompt compression needs fresh repair and verification controls, not only the targeted case. Retain rejected variants: shortened instructions have produced extra definition lookups, pooled period reads, longer briefs, and dropped attribution facts. Evaluate these separately from rubric success. A useful refund finding still omits depth when an independently returned attribution decline is discarded; an empty category-filtered retention search does not establish catalog-wide absence. Native revenue cases (`revenue-native-decline`, `revenue-native-stale`, `revenue-native-unavailable`) run the real detector and signal preparation before the agent, recording detector requests, synthetic responses, and prepared inputs in `case.setup`. This catches publication failures hidden by hand-built event subjects. Compare the same fixture source in separate checkouts. Revenue detection and rechecks match canonical currency rows across both windows; an absent row is inconclusive, not zero, and legacy unscoped revenue signals cannot be remeasured safely. The standalone runner builds native detector cases once; importing qualityCases builds only static fixtures and does not execute detection. Publication requires a product_outcome with successful, field-bound evidence for the exact scoped signal currency; legacy unscoped keys are rejected. These cases do not exercise real database latency or establish refund/attribution detection when gross revenue is unchanged. + +Scheduled structured recovery checks and explicitly marked verification requests use one native analytics read and no model call. Free-form human replies retain the model, including new corrections; the resume boundary marks only the exact shared Apply reply template as verification intent. An identical human-authored template requests the same read-only operation; it does not establish trusted provenance. Audit `verificationRead` in the result, including failed or mismatched reads; zero model steps is expected, not missing telemetry. Assert exact requested and returned subject/population/definition/window, sample and threshold, zero token usage, and no new repair. Compare deterministic output against the original observed model outcomes. A population mismatch is an inconclusive verification, not permission to invent a repair or discard a rejected structured check. An unfinished window returns a quiet watch at midnight UTC after its inclusive end, preserving the check for the next scheduled run. Production events retain the exact read inputs and outputs or failures, correlated by call ID. Legacy reported repairs without a structured check still use the investigation agent. diff --git a/apps/insights/src/evals/quality.test.ts b/apps/insights/src/evals/quality.test.ts index d55953641..5954cc371 100644 --- a/apps/insights/src/evals/quality.test.ts +++ b/apps/insights/src/evals/quality.test.ts @@ -311,6 +311,13 @@ const holdoutOutcome: InsightAgentResult = { }, }; +it("does not excuse a new repair when a saved verification has population drift", () => { + const fixture = qualityCases.find((entry) => entry.id === "check-population-drift"); + const previous = fixture?.input.history.find((entry) => entry.kind === "investigation"); + if (!fixture || previous?.kind !== "investigation") throw new Error("Missing population drift evaluation"); + expect(fixture.check({...holdoutOutcome, outcome: previous.outcome}, [])).toContain("Repeated the already-applied definition repair"); +}); + it.each([undefined, null])( "accepts an unscoped native goal read with cohort %s while rejecting scope drift", async (cohort) => { diff --git a/apps/insights/src/evals/quality.ts b/apps/insights/src/evals/quality.ts index f7267f8dd..e2f0a300e 100644 --- a/apps/insights/src/evals/quality.ts +++ b/apps/insights/src/evals/quality.ts @@ -1071,6 +1071,7 @@ for (const scenario of [ request: original.input.request ? { ...original.input.request, + kind: "verification", createdAt: scenario === "unfinished-window" ? "2026-09-04T12:00:00Z" @@ -1139,12 +1140,7 @@ for (const scenario of [ }, reviewRequired: `Expected ${status}. Check that the customer copy agrees with the code verdict and preserves the reason, exact dates, measured count and threshold. A small sample or unfinished window cannot prove recovery.`, check: (result, calls) => [ - ...original.check(result, calls).filter( - (failure) => - // A new population mismatch can justify a different repair. - scenario !== "population-drift" || - failure !== "Repeated the already-applied definition repair" - ), + ...original.check(result, calls), ...(result.outcome.verification?.status === status ? [] : [`Expected persisted verification status ${status}`]), diff --git a/apps/insights/src/investigation-flow.test.ts b/apps/insights/src/investigation-flow.test.ts index affb0360e..7f1a8bbba 100644 --- a/apps/insights/src/investigation-flow.test.ts +++ b/apps/insights/src/investigation-flow.test.ts @@ -2257,22 +2257,13 @@ describe("intelligence agent", () => { "wrong-measured-id", "passed", "passed-explicit", - "passed-null-cohort", - "passed-domain", "passed-cosmetic", "failed-rate", "failed", - "wrong-subject", - "wrong-website", - "wrong-start", - "wrong-end", - "extra-filter", - "extra-cohort", "small-sample", "unfinished-window", "failed-read", "invalid-count", - "no-read", "newer-resolution", "referrer-rate", "referrer-count", @@ -2317,23 +2308,9 @@ describe("intelligence agent", () => { : "inconclusive"; const completed = scenario === "failed" ? 40 : 120; const query = { - funnelId: scenario === "wrong-subject" ? "another-funnel" : "checkout", - startDate: scenario === "wrong-start" ? "2026-07-04" : check.startDate, - endDate: scenario === "wrong-end" ? "2026-07-12" : check.endDate, - ...(scenario === "passed-explicit" ? { websiteId: "site-1" } : {}), - ...(scenario === "wrong-website" ? { websiteId: "another-site" } : {}), - ...(scenario === "passed-domain" ? { websiteId: "example.com" } : {}), - ...(scenario === "extra-filter" ? { filter: "paid-only" } : {}), - ...(scenario === "passed-null-cohort" ? { cohort: null } : {}), - ...(scenario === "extra-cohort" - ? { - cohort: { - filters: [ - { field: "browser_name", operator: "equals", value: "Chrome" }, - ], - }, - } - : {}), + funnelId: "checkout", + startDate: check.startDate, + endDate: check.endDate, }; const candidate = { ...agentOutcome, @@ -2352,9 +2329,7 @@ describe("intelligence agent", () => { }; const model = new MockLanguageModelV3({ doGenerate: mockValues( - ...(scenario === "no-read" - ? [] - : [toolCallResponse("get_funnel_analytics", JSON.stringify(query))]), + toolCallResponse("get_funnel_analytics", JSON.stringify(query)), outputResponse(candidate) ), }); @@ -2407,57 +2382,68 @@ describe("intelligence agent", () => { tools: { get_funnel_analytics: tool({ inputSchema: z.object({}).passthrough(), - execute: () => ({ - ...(scenario === "missing-metadata" - ? {} - : { - measurement: { - websiteId: - scenario === "wrong-measured-site" || - scenario === "wrong-website" - ? "another-site" - : "site-1", - definitionId: - scenario === "wrong-measured-id" - ? "another-funnel" - : "checkout", - startDate: - scenario === "effective-window" - ? "2026-07-07" - : check.startDate, - endDate: check.endDate, - definition: { - ...check.definition, - steps: inspectedFunnel.steps.map((step) => ({ - ...step, - ...(scenario === "passed-cosmetic" - ? { name: "Renamed" } + execute: (readInput, options) => { + if (scenario !== "newer-resolution") { + expect(readInput).toEqual({ + ...query, + websiteId: "site-1", + cohort: null, + }); + expect(options.experimental_context).toMatchObject({ + organizationId: appContext().organizationId, + }); + } + return { + ...(scenario === "missing-metadata" + ? {} + : { + measurement: { + websiteId: + scenario === "wrong-measured-site" + ? "another-site" + : "site-1", + definitionId: + scenario === "wrong-measured-id" + ? "another-funnel" + : "checkout", + startDate: + scenario === "effective-window" + ? "2026-07-07" + : check.startDate, + endDate: check.endDate, + definition: { + ...check.definition, + steps: inspectedFunnel.steps.map((step) => ({ + ...step, + ...(scenario === "passed-cosmetic" + ? { name: "Renamed" } + : {}), + })), + ...(scenario === "changed-definition" + ? { + filters: [ + { + field: "country", + operator: "equals", + value: "US", + }, + ], + } : {}), - })), - ...(scenario === "changed-definition" - ? { - filters: [ - { - field: "country", - operator: "equals", - value: "US", - }, - ], - } - : {}), + }, }, - }, - }), - total_users_entered: scenario === "small-sample" ? 80 : 200, - total_users_completed: - scenario === "small-sample" - ? 60 - : scenario === "invalid-count" - ? 250 - : completed, - overall_conversion_rate: 60, - ...(scenario === "failed-read" ? { error: "Unavailable" } : {}), - }), + }), + total_users_entered: scenario === "small-sample" ? 80 : 200, + total_users_completed: + scenario === "small-sample" + ? 60 + : scenario === "invalid-count" + ? 250 + : completed, + overall_conversion_rate: 60, + ...(scenario === "failed-read" ? { error: "Unavailable" } : {}), + }; + }, }), }, } @@ -2467,8 +2453,21 @@ describe("intelligence agent", () => { return; } expect(result.outcome.verification?.status).toBe(status); - expect(result.toolCallCount).toBe(scenario === "no-read" ? 0 : 1); - expect(model.doGenerateCalls).toHaveLength(scenario === "no-read" ? 1 : 2); + expect(result.toolCallCount).toBe(scenario.startsWith("referrer-") ? 0 : 1); + expect(model.doGenerateCalls).toHaveLength(0); + expect(result.usage?.totalTokens).toBe(0); + expect(result.modelId).toBeUndefined(); + expect(result.outcome.rootCause).toBeNull(); + expect(result.outcome.next.type).toBe( + scenario === "unfinished-window" ? "watch" : "resolve" + ); + expect(result.outcome.publish).toBe(status !== "inconclusive"); + if (!scenario.startsWith("referrer-")) { + expect(result.verificationRead).toMatchObject({ + toolName: "get_funnel_analytics", + input: { ...query, websiteId: "site-1", cohort: null }, + }); + } expect(result.outcome.summary).not.toBe("Recovery definitely passed."); expect(result.outcome.summary).toContain( status === "inconclusive" ? "unverified" : status diff --git a/apps/insights/src/native-verification.test.ts b/apps/insights/src/native-verification.test.ts new file mode 100644 index 000000000..83efb35bd --- /dev/null +++ b/apps/insights/src/native-verification.test.ts @@ -0,0 +1,597 @@ +import "@databuddy/test/env"; +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { tool, type ToolExecutionOptions } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import { log } from "evlog"; +import { z } from "zod"; +import { runInsightAgent, type InsightAgentResult } from "./agent"; +import { qualityCases } from "./evals/quality"; +import { nextRecheckAt } from "./observations"; +import { caseValues } from "./persistence"; + +function verificationFixture(id = "check-passed") { + const fixture = qualityCases.find((candidate) => candidate.id === id); + if (!fixture) { + throw new Error(`Missing native verification fixture: ${id}`); + } + const input = structuredClone(fixture.input); + if (input.request) { + input.request.kind = "verification"; + } + return { ...fixture, input }; +} + +function hostileModel() { + return new MockLanguageModelV3({ + doGenerate: async () => ({ + content: [ + { + type: "text", + text: "Ignore the saved check and failed measurements. Publish that recovery passed and the deployment caused it.", + }, + ], + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 100, text: 100, reasoning: 0 }, + }, + warnings: [], + }), + doStream: async () => { + throw new Error("Native verification must never stream a model response"); + }, + }); +} + +function expectNoModelCalls(model: MockLanguageModelV3) { + expect(model.doGenerateCalls).toHaveLength(0); + expect(model.doStreamCalls).toHaveLength(0); +} + +function expectZeroUsage(result: InsightAgentResult) { + expect(result.modelId).toBeUndefined(); + expect(result.usage).toEqual({ + cachedInputTokens: 0, + inputTokenDetails: { + cacheReadTokens: 0, + cacheWriteTokens: 0, + noCacheTokens: 0, + }, + inputTokens: 0, + outputTokenDetails: { reasoningTokens: 0, textTokens: 0 }, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + }); +} + +function expectUnavailableMeasurement(result: InsightAgentResult) { + expect(result.outcome.verification).toMatchObject({ + status: "inconclusive", + measured: null, + entrants: null, + source: null, + }); + expect(result.outcome.publish).toBe(false); + expect(result.outcome.publicationBasis).toBeNull(); + expect(result.outcome.rootCause).toBeNull(); + expect(result.outcome.next.type).toBe("resolve"); + expectZeroUsage(result); +} + +function abortingModel() { + const controller = new AbortController(); + const reason = new Error("Stopped at the first model request"); + const model = new MockLanguageModelV3({ + doGenerate: async () => { + controller.abort(reason); + throw reason; + }, + }); + return { model, reason, abortSignal: controller.signal }; +} + +function modelPrompt(model: MockLanguageModelV3): unknown { + expect(model.doGenerateCalls).toHaveLength(1); + expect(model.doStreamCalls).toHaveLength(0); + const message = model.doGenerateCalls[0].prompt.find( + (item) => item.role === "user" + ); + const part = message?.content.find((item) => item.type === "text"); + if (part?.type !== "text") { + throw new Error("Expected the investigation input in the model request"); + } + return JSON.parse(part.text); +} + +describe("native saved verification", () => { + it.each([ + ["passed", "passed"], + ["failed", "failed"], + ["small-sample", "inconclusive"], + ["unfinished-window", "inconclusive"], + ["population-drift", "inconclusive"], + ["truncated-window", "inconclusive"], + ] as const)("computes %s without calling a hostile model", async (scenario, status) => { + const fixture = verificationFixture(`check-${scenario}`); + const read = fixture.tools.get_goal_analytics; + if (!read.execute) { + throw new Error("Verification fixture requires a native read executor"); + } + const execute = mock(read.execute); + const model = hostileModel(); + const onStepFinish = mock(() => undefined); + const result = await runInsightAgent(fixture.input, { + model, + onStepFinish, + tools: { get_goal_analytics: { ...read, execute } }, + }); + + expectNoModelCalls(model); + expectZeroUsage(result); + expect(onStepFinish).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(1); + expect(result.toolCallCount).toBe(1); + expect(result.outcome.verification?.status).toBe(status); + expect(result.outcome.publish).toBe(status !== "inconclusive"); + expect(result.outcome.rootCause).toBeNull(); + expect(result.outcome.next.type).toBe( + scenario === "unfinished-window" ? "watch" : "resolve" + ); + if (status !== "inconclusive") { + expect(result.outcome.verification).toMatchObject({ + measured: status === "passed" ? 120 : 40, + entrants: 200, + source: { + source: "tool", + name: "get_goal_analytics", + toolCallId: result.verificationRead?.toolCallId, + resultKey: null, + }, + }); + } + }); + + it("keeps an early check open and resumes the latest watch at the exact UTC boundary", async () => { + const { input, tools } = verificationFixture("check-unfinished-window"); + // A scheduled recheck has no human request and still uses the native path. + input.request = undefined; + input.appContext.timezone = "Asia/Hebron"; + const model = hostileModel(); + const early = await runInsightAgent(input, { tools, model }); + const prior = input.history[0]; + if (prior.kind !== "investigation" || prior.outcome.next.type !== "act") { + throw new Error("The early verification fixture requires a saved action"); + } + expect(early.outcome.verification?.check).toEqual(prior.outcome.next.check); + const at = new Date(input.appContext.currentDateTime); + const closesAt = "2026-09-05T00:00:00.000Z"; + expect(early.outcome.next).toMatchObject({ + type: "watch", + recheckAt: closesAt, + }); + expect(early.outcome.verification?.status).toBe("inconclusive"); + expect(early.outcome.publish).toBe(false); + expect( + caseValues( + { signal: input.signal, outcome: early.outcome }, + "Asia/Hebron", + at + ) + ).toMatchObject({ + status: "open", + resolvedAt: null, + resolvedReason: null, + }); + const recheckAt = nextRecheckAt(at, early.outcome.next); + expect(recheckAt.toISOString()).toBe(closesAt); + // Remove the original act: the persisted watch must carry the saved check. + input.history = [ + { + kind: "investigation", + asOf: at.toISOString(), + evidence: early.outcome.evidence, + signal: input.signal, + outcome: early.outcome, + }, + ]; + input.appContext.currentDateTime = recheckAt.toISOString(); + const completed = await runInsightAgent(input, { tools, model }); + expect(completed.outcome.verification).toMatchObject({ + check: early.outcome.verification?.check, + status: "passed", + measured: 120, + entrants: 200, + }); + expect(completed.verificationRead).toMatchObject({ + toolName: early.verificationRead?.toolName, + input: early.verificationRead?.input, + }); + expect(completed.verificationRead?.toolCallId).not.toBe( + early.verificationRead?.toolCallId + ); + expect(completed.outcome.next.type).toBe("resolve"); + expect(completed.outcome.publish).toBe(true); + expect( + caseValues( + { signal: input.signal, outcome: completed.outcome }, + "Asia/Hebron", + recheckAt + ) + ).toMatchObject({ + status: "resolved", + resolvedAt: recheckAt, + resolvedReason: "recovered", + }); + for (const result of [early, completed]) { + expect(result.toolCallCount).toBe(1); + expectZeroUsage(result); + } + expectNoModelCalls(model); + }); + + it("lets a later resolution supersede an earlier watch and saved act", async () => { + const { input, tools } = verificationFixture("check-unfinished-window"); + const nativeModel = hostileModel(); + const early = await runInsightAgent(input, { tools, model: nativeModel }); + const watch = { + kind: "investigation" as const, + asOf: input.appContext.currentDateTime, + evidence: early.outcome.evidence, + signal: input.signal, + outcome: early.outcome, + }; + input.history.push(watch, { + ...watch, + asOf: "2026-09-04T13:00:00.000Z", + outcome: { + ...early.outcome, + next: { type: "resolve", reason: "This condition no longer applies." }, + }, + }); + input.appContext.currentDateTime = "2026-09-05T00:00:00.000Z"; + input.request = undefined; + const execute = mock(() => { + throw new Error("Superseded checks must not run"); + }); + const stopped = abortingModel(); + await expect( + runInsightAgent(input, { + ...stopped, + tools: { get_goal_analytics: { ...tools.get_goal_analytics, execute } }, + }) + ).rejects.toBe(stopped.reason); + expect(modelPrompt(stopped.model)).not.toHaveProperty("verification"); + expect(execute).not.toHaveBeenCalled(); + expectNoModelCalls(nativeModel); + }); + + it("passes an unclassified human correction to the model despite a saved check", async () => { + const { input, tools } = verificationFixture(); + input.request = { + body: "Correction: this goal should count /settings, not /workspace. Reconsider the saved condition.", + createdAt: "2026-09-05T01:00:00.000Z", + }; + const execute = mock(() => { + throw new Error("Do not preempt the human correction with a native read"); + }); + const stopped = abortingModel(); + await expect( + runInsightAgent(input, { + ...stopped, + tools: { get_goal_analytics: { ...tools.get_goal_analytics, execute } }, + }) + ).rejects.toBe(stopped.reason); + expect(modelPrompt(stopped.model)).toHaveProperty("request", input.request); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + "completed", + "aborted", + ] as const)("emits correlated native read started and %s events", async (status) => { + const info = spyOn(log, "info").mockImplementation(() => undefined); + const warn = spyOn(log, "warn").mockImplementation(() => undefined); + try { + const { input, tools } = verificationFixture(); + const model = hostileModel(); + const controller = new AbortController(); + const reason = new Error("Cancelled observed native read"); + const output = { error: "Synthetic measurement unavailable" }; + const execute = mock( + (_query: unknown, _context: ToolExecutionOptions) => { + if (status === "aborted") { + queueMicrotask(() => controller.abort(reason)); + return new Promise(() => undefined); + } + return output; + } + ); + const pending = runInsightAgent(input, { + model, + abortSignal: controller.signal, + tools: { get_goal_analytics: { ...tools.get_goal_analytics, execute } }, + }); + if (status === "aborted") { + await expect(pending).rejects.toBe(reason); + } else { + expectUnavailableMeasurement(await pending); + } + expect(execute).toHaveBeenCalledTimes(1); + const [query, context] = execute.mock.calls[0]; + const trace = { + service: "insights", + organization_id: input.appContext.organizationId, + website_id: input.appContext.websiteId, + signal_key: input.signal.signalKey, + tool_name: "get_goal_analytics", + tool_call_id: context.toolCallId, + input: JSON.stringify(query), + }; + expect(info).toHaveBeenNthCalledWith(1, { + ...trace, + insights_event: "verification.read.started", + }); + if (status === "aborted") { + expect(info).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith({ + ...trace, + insights_event: "verification.read.aborted", + error_message: reason.message, + }); + } else { + expect(info).toHaveBeenCalledTimes(2); + expect(info).toHaveBeenNthCalledWith(2, { + ...trace, + insights_event: "verification.read.completed", + output: JSON.stringify(output), + tool_call_count: 1, + }); + expect(warn).not.toHaveBeenCalled(); + } + expectNoModelCalls(model); + } finally { + info.mockRestore(); + warn.mockRestore(); + } + }); + + it.each([ + ["goal", "explicit"], + ["goal", "default"], + ["funnel", "explicit"], + ["funnel", "default"], + ] as const)("passes the exact %s query and context with the %s website", async (entityType, websiteSource) => { + const useDefaultWebsite = websiteSource === "default"; + const { input } = verificationFixture(); + input.signal.entity.type = entityType; + input.signal.signalKey = `${entityType}:${input.signal.entity.id}`; + for (const item of input.history) { + if (item.kind === "investigation") { + item.signal.entity.type = entityType; + item.signal.signalKey = input.signal.signalKey; + } + } + input.appContext.defaultWebsiteId = "synthetic-default-site"; + if (useDefaultWebsite) { + input.appContext.websiteId = undefined; + } + const output = { error: "Synthetic unavailable measurement" }; + const execute = mock( + (_query: unknown, _options: ToolExecutionOptions) => output + ); + const controller = new AbortController(); + const model = hostileModel(); + const toolName = `get_${entityType}_analytics`; + const result = await runInsightAgent(input, { + abortSignal: controller.signal, + model, + tools: { [toolName]: tool({ inputSchema: z.object({}), execute }) }, + }); + + expect(execute).toHaveBeenCalledTimes(1); + const [query, context] = execute.mock.calls[0]; + expect(query).toEqual({ + [`${entityType}Id`]: "workspace-goal", + websiteId: useDefaultWebsite + ? "synthetic-default-site" + : "synthetic-site", + startDate: "2026-08-29", + endDate: "2026-09-04", + cohort: null, + }); + expect(context.messages).toEqual([]); + expect(context.experimental_context).toBe(input.appContext); + expect(context.toolCallId).toEqual(expect.any(String)); + expect(context.toolCallId.length).toBeGreaterThan(0); + expect(context.abortSignal).toBeInstanceOf(AbortSignal); + expect(context.abortSignal?.aborted).toBe(false); + expect(result.verificationRead).toEqual({ + toolName, + toolCallId: context.toolCallId, + input: query, + output, + }); + expect(result.toolCallCount).toBe(1); + expectUnavailableMeasurement(result); + expectNoModelCalls(model); + }); + + it.each([ + [ + "Error", + new Error("Synthetic native read failed"), + "Synthetic native read failed", + ], + ["non-Error", "non-Error failure", "The saved measurement failed."], + ] as const)("records thrown read %s without inventing a measurement", async (_kind, error, message) => { + const { input, tools } = verificationFixture(); + const execute = mock(() => { + throw error; + }); + const model = hostileModel(); + const result = await runInsightAgent(input, { + model, + tools: { get_goal_analytics: { ...tools.get_goal_analytics, execute } }, + }); + + expect(execute).toHaveBeenCalledTimes(1); + expect(result.toolCallCount).toBe(1); + expect(result.verificationRead?.output).toEqual({ error: message }); + expectUnavailableMeasurement(result); + expectNoModelCalls(model); + }); + + it.each([ + "missing tool", + "missing executor", + ])("handles %s without model fallback", async (scenario) => { + const { input, tools } = verificationFixture(); + const model = hostileModel(); + const result = await runInsightAgent(input, { + model, + tools: + scenario === "missing tool" + ? {} + : { + get_goal_analytics: { + ...tools.get_goal_analytics, + execute: undefined, + }, + }, + }); + + expect(result.toolCallCount).toBe(0); + expect(result.verificationRead?.output).toEqual({ + error: "The saved measurement tool is unavailable.", + }); + expectUnavailableMeasurement(result); + expectNoModelCalls(model); + }); + + it("propagates an abort before the read without executing tools or models", async () => { + const { input, tools } = verificationFixture(); + const reason = new Error("Cancelled before native read"); + const execute = mock(() => ({ error: "Must not be read" })); + const model = hostileModel(); + await expect( + runInsightAgent(input, { + abortSignal: AbortSignal.abort(reason), + model, + tools: { get_goal_analytics: { ...tools.get_goal_analytics, execute } }, + }) + ).rejects.toBe(reason); + + expect(execute).not.toHaveBeenCalled(); + expectNoModelCalls(model); + }); + + it("propagates an abort during an uncooperative native read", async () => { + const { input, tools } = verificationFixture(); + const controller = new AbortController(); + const reason = new Error("Cancelled during native read"); + const execute = mock((_query: unknown, _context: ToolExecutionOptions) => { + queueMicrotask(() => controller.abort(reason)); + // A stalled executor never settles; cancellation must not wait for it. + return new Promise(() => undefined); + }); + const model = hostileModel(); + await expect( + runInsightAgent(input, { + abortSignal: controller.signal, + model, + tools: { get_goal_analytics: { ...tools.get_goal_analytics, execute } }, + }) + ).rejects.toBe(reason); + + expect(execute).toHaveBeenCalledTimes(1); + const context = execute.mock.calls[0][1]; + expect(context.abortSignal?.aborted).toBe(true); + expect(context.abortSignal?.reason).toBe(reason); + expectNoModelCalls(model); + }); +}); + +it("rejects the observed repair workaround even after its structured check is dropped", async () => { + const fixture = verificationFixture("check-population-drift"); + if (!fixture.input.request) throw new Error("Missing human reply fixture"); + delete fixture.input.request.kind; + const errors: string[] = []; + let calls = 0; + const model = new MockLanguageModelV3({ + doGenerate: async () => { + const reading = calls++ === 0; + return { + content: [ + { + type: "tool-call", + toolCallId: reading ? "read-verification" : `finish-${calls}`, + toolName: reading ? "get_goal_analytics" : "finish_investigation", + input: JSON.stringify( + reading + ? { + goalId: "workspace-goal", + websiteId: "synthetic-site", + startDate: "2026-08-29", + endDate: "2026-09-04", + cohort: null, + } + : { + evidence: [ + { + sources: [ + { + source: "tool", + name: "get_goal_analytics", + toolCallId: "read-verification", + resultKey: null, + }, + ], + claim: + "The returned measurement uses a referrer filter; the saved goal has no filters.", + }, + ], + publish: true, + findingKind: "measurement_coverage", + title: "Workspace verification has a population mismatch", + rootCause: "Analytics adds a referrer filter at read time.", + publicationBasis: "decision_safety", + next: { + type: "act", + action: "Remove the read-time referrer filter.", + target: "Workspace analytics measurement", + verification: "The saved unfiltered condition passes.", + recheckAt: "2026-09-06T00:00:00Z", + execution: null, + }, + } + ), + }, + ], + finishReason: { unified: "tool-calls", raw: "tool_calls" }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + warnings: [], + }; + }, + }); + await expect( + runInsightAgent(fixture.input, { + model, + tools: fixture.tools, + onStepFinish: (step) => { + for (const part of step.content) + if (part.type === "tool-error") errors.push(String(part.error)); + }, + }) + ).rejects.toThrow(); + expect(errors).toHaveLength(3); + expect( + errors.every((error) => + error.includes("independently inspected implementation evidence") + ) + ).toBe(true); +}); diff --git a/apps/insights/src/resume.ts b/apps/insights/src/resume.ts index 19bee11b4..1cb4c268e 100644 --- a/apps/insights/src/resume.ts +++ b/apps/insights/src/resume.ts @@ -26,6 +26,7 @@ import { } from "@databuddy/redis"; import { createServiceAuth } from "@databuddy/rpc"; import { + appliedInsightActionReply, insightReplySlackDeliverySchema, parseInvestigationOutcome, parseInvestigationSignal, @@ -275,6 +276,11 @@ export async function resumeInsightReply( history, otherOpenWork, request: { + kind: (["goal", "funnel"] as const).some( + (type) => trigger.body === appliedInsightActionReply(type) + ) + ? "verification" + : undefined, body: trigger.body, createdAt: trigger.createdAt.toISOString(), }, diff --git a/packages/rpc/src/routers/insights.ts b/packages/rpc/src/routers/insights.ts index 64ce4135b..79dadb4b8 100644 --- a/packages/rpc/src/routers/insights.ts +++ b/packages/rpc/src/routers/insights.ts @@ -1,3 +1,4 @@ +import { appliedInsightActionReply } from "@databuddy/shared/insights"; import { and, db, @@ -1020,7 +1021,7 @@ async function applyInsightAction(input: { } const replyId = randomUUIDv7(); - const body = `Databuddy applied the ${entityType} action. Recheck its verification condition against current data.`; + const body = appliedInsightActionReply(entityType); await tx.insert(insightReplies).values({ ...author, body, diff --git a/packages/shared/src/insights.ts b/packages/shared/src/insights.ts index 91546ab2e..00636b421 100644 --- a/packages/shared/src/insights.ts +++ b/packages/shared/src/insights.ts @@ -824,6 +824,10 @@ export const agentInvestigationOutcomeSchema = z const insightStatusSchema = z.enum(["open", "resolved"]); const insightResolvedReasonSchema = z.enum(["recovered", "stale"]); +export function appliedInsightActionReply(type: "goal" | "funnel"): string { + return `Databuddy applied the ${type} action. Recheck its verification condition against current data.`; +} + export const insightReplyStatusSchema = z.enum([ "queued", "running",