diff --git a/packages/core/src/research.ts b/packages/core/src/research.ts index b9e54f2..eaf9bc3 100644 --- a/packages/core/src/research.ts +++ b/packages/core/src/research.ts @@ -27,11 +27,25 @@ export type ResearchVerdict = 'positive' | 'negative' | 'neutral' | 'unavailable * underlying fact. LLM prose is never the source of truth — evidence is. */ export interface EvidenceRef { + /** + * Stable identity inside the report. Derived from the producing run and + * capability rather than from array position, so it survives report assembly, + * persistence, reload and export unchanged. Optional because reports written + * before claim linking existed omit it — readers derive the same value from + * `runId` + `capabilityId` for those. + */ + id?: string; capabilityId: string; /** CapabilityRunRecord.id of the run this evidence comes from. */ runId: string; /** The claim this evidence supports, e.g. "NVDA valuation is expensive". */ claim: string; + /** + * Id of the `ResearchClaim` this evidence backs: the evidence → claim + * direction of the mapping. The reverse direction lives in + * `ResearchClaim.evidenceRefs`, so the relation can be walked both ways. + */ + claimId?: string; fetchedAt: number; /** Short factual summary of the data point (from CapabilityResult.summary). */ summary?: string; @@ -39,6 +53,25 @@ export interface EvidenceRef { instrumentId?: string; } +/** + * A verifiable statement lifted out of a report section, carrying a stable + * identity so its evidence links survive persistence, reload and export. + * + * One claim may need several pieces of evidence, and one piece of evidence may + * back several claims — deliberately many-to-many rather than a 1:1 relational + * schema. + */ +export interface ResearchClaim { + /** Stable inside the report; derived from the section key and position. */ + id: string; + /** `ResearchSection.key` of the section this claim was lifted from. */ + sectionKey: string; + /** The statement itself. */ + text: string; + /** `EvidenceRef.id` values backing this claim (claim → evidence direction). */ + evidenceRefs: string[]; +} + /** Condensed outcome of one capability run, embedded in the report. */ export interface CapabilityRunSummary { runId: string; @@ -83,6 +116,13 @@ export interface ResearchReport { catalysts: string[]; risks: string[]; capabilityRuns: CapabilityRunSummary[]; + /** + * Claim-level identities for this report, including claims that ended up with + * no evidence at all — those are exactly the ones a reader must not mistake + * for verified conclusions. Optional: reports persisted before claim linking + * existed omit it, and readers rebuild the same ids from `sections[].evidence`. + */ + claims?: ResearchClaim[]; /** * `completed` when every planned capability succeeded; `partial` when some * failed or were unavailable — the report still stands, gaps are explicit. diff --git a/packages/shared/src/export/markdown.test.ts b/packages/shared/src/export/markdown.test.ts index a7c656d..c09b7d7 100644 --- a/packages/shared/src/export/markdown.test.ts +++ b/packages/shared/src/export/markdown.test.ts @@ -40,6 +40,37 @@ describe('reportToMarkdown', () => { expect(md).toContain('- Valuation: P/E at the 95th percentile. — company.valuation (run run_v1)') }) + it('carries the claim id on each evidence line so exported text stays traceable', () => { + const md = reportToMarkdown(reportFixture()) + // The fixture predates claim linking; the id is derived from section + position. + expect(md).toContain('· claim claim:growth:0') + expect(md).toContain('· claim claim:valuation:0') + }) + + it('names unbacked claims instead of letting them read as verified', () => { + const report = reportFixture({ + claims: [ + { + id: 'claim:fundamentals:0', + sectionKey: 'fundamentals', + text: 'Balance sheet carries net cash.', + evidenceRefs: [], + }, + ], + }) + const md = reportToMarkdown(report) + expect(md).toContain('### Unbacked Claims') + expect(md).toContain('- claim:fundamentals:0: Balance sheet carries net cash. — no evidence') + }) + + it('appends the machine-readable claim ↔ evidence index only when asked', () => { + const md = reportToMarkdown(reportFixture(), { includeClaimEvidenceIndex: true }) + expect(md).toContain('### Claim-Evidence Index') + expect(md).toContain('"claim:growth:0"') + expect(md).toContain('"evidence:run_g1:company.financials"') + expect(reportToMarkdown(reportFixture())).not.toContain('### Claim-Evidence Index') + }) + it('omits the evidence list when includeEvidence is false', () => { const md = reportToMarkdown(reportFixture(), { includeEvidence: false }) expect(md).not.toContain('## Evidence') diff --git a/packages/shared/src/export/markdown.ts b/packages/shared/src/export/markdown.ts index 976db6d..887a709 100644 --- a/packages/shared/src/export/markdown.ts +++ b/packages/shared/src/export/markdown.ts @@ -1,4 +1,9 @@ import type { ResearchReport, ResearchStance, ResearchVerdict } from '@finagent/core' +import { + buildClaimEvidenceIndex, + claimIdOf, + findUnbackedClaims, +} from '../research/claim-evidence.ts' import { isStrategyId, RESEARCH_STRATEGIES } from '../strategies/index.ts' /** @@ -7,7 +12,9 @@ import { isStrategyId, RESEARCH_STRATEGIES } from '../strategies/index.ts' * Pure function — no I/O, no `Date.now()`, no locale-dependent formatting — * so main-process IPC handlers and tests get byte-identical output for the * same report. The evidence list is the source-of-truth layer: every claim - * stays linked to the capability run that produced it. + * stays linked to the capability run that produced it, and each entry carries + * the claim id so the exported text can be walked back to the report's + * claim ↔ evidence index. */ export interface MarkdownOptions { @@ -15,6 +22,12 @@ export interface MarkdownOptions { includeEvidence?: boolean /** Print the research strategy badge line. Default true. */ includeStrategy?: boolean + /** + * Append the machine-readable claim ↔ evidence index (both directions) as a + * JSON block. Default false: the human-readable list above already carries + * the claim ids, this is for consumers that need the full mapping. + */ + includeClaimEvidenceIndex?: boolean } export const STANCE_LABEL: Record = { @@ -46,7 +59,11 @@ function pushList(lines: string[], points: string[]): void { } export function reportToMarkdown(report: ResearchReport, options: MarkdownOptions = {}): string { - const { includeEvidence = true, includeStrategy = true } = options + const { + includeEvidence = true, + includeStrategy = true, + includeClaimEvidenceIndex = false, + } = options const lines: string[] = [] lines.push(`# ${report.symbol} — Research Report`) @@ -85,11 +102,14 @@ export function reportToMarkdown(report: ResearchReport, options: MarkdownOption lines.push('') lines.push('## Evidence') const refs = report.sections.flatMap((section) => - section.evidence.map((ref) => ({ + section.evidence.map((ref, index) => ({ sectionTitle: section.title, claim: ref.claim, capabilityId: ref.capabilityId, runId: ref.runId, + // Reports written before claim linking have no stored id; derive the + // same one the report's index derives for them. + claimId: ref.claimId ?? claimIdOf(section.key, index), })) ) if (refs.length === 0) { @@ -98,9 +118,30 @@ export function reportToMarkdown(report: ResearchReport, options: MarkdownOption } else { for (const ref of refs) { lines.push('') - lines.push(`- ${ref.sectionTitle}: ${ref.claim || '(claim not recorded)'} — ${ref.capabilityId} (run ${ref.runId})`) + lines.push(`- ${ref.sectionTitle}: ${ref.claim || '(claim not recorded)'} — ${ref.capabilityId} (run ${ref.runId}) · claim ${ref.claimId}`) } } + + // A claim nothing backs must not read as a verified conclusion, so it is + // named here instead of only appearing as prose in the section above. + const unbacked = findUnbackedClaims(report) + if (unbacked.length > 0) { + lines.push('') + lines.push('### Unbacked Claims') + for (const claim of unbacked) { + lines.push('') + lines.push(`- ${claim.id}: ${claim.text || '(claim text not recorded)'} — no evidence`) + } + } + + if (includeClaimEvidenceIndex) { + lines.push('') + lines.push('### Claim-Evidence Index') + lines.push('') + lines.push('```json') + lines.push(JSON.stringify(buildClaimEvidenceIndex(report), null, 2)) + lines.push('```') + } } return `${lines.join('\n').trim()}\n` diff --git a/packages/shared/src/research/claim-evidence.test.ts b/packages/shared/src/research/claim-evidence.test.ts new file mode 100644 index 0000000..56362b5 --- /dev/null +++ b/packages/shared/src/research/claim-evidence.test.ts @@ -0,0 +1,228 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { ResearchReport } from '@finagent/core'; +import { JsonFileStore } from '../storage/json-file-store.ts'; +import { ResearchReportRepository } from './repository.ts'; +import { + buildClaimEvidenceIndex, + claimIdOf, + claimsForEvidence, + collectReportClaims, + evidenceForClaim, + evidenceIdOf, + findUnbackedClaims, +} from './claim-evidence.ts'; + +const VALUATION_EVIDENCE = evidenceIdOf('run-v', 'company.valuation'); + +/** Report shaped the way the runner assembles it today: ids already linked. */ +function linkedReport(): ResearchReport { + return { + id: 'report-run-1', + symbol: 'NVDA.US', + generatedAt: 1_700_000_000_000, + summary: 'summary', + stance: 'bullish', + confidence: 0.6, + sections: [ + { + key: 'valuation', + title: 'Valuation', + verdict: 'negative', + summary: 'NVDA trades at a premium multiple.', + evidence: [ + { + id: VALUATION_EVIDENCE, + capabilityId: 'company.valuation', + runId: 'run-v', + claim: 'NVDA trades at a premium multiple.', + claimId: claimIdOf('valuation'), + fetchedAt: 1_700_000_000_000, + summary: 'PE 45', + }, + ], + }, + { + key: 'momentum', + title: 'Momentum', + verdict: 'unavailable', + summary: 'Momentum data unavailable.', + evidence: [], + }, + ], + bullCase: [], + bearCase: [], + catalysts: [], + risks: [], + capabilityRuns: [ + { runId: 'run-v', capabilityId: 'company.valuation', status: 'success' }, + { runId: 'missing:market.momentum', capabilityId: 'market.momentum', status: 'unavailable' }, + ], + claims: [ + { + id: claimIdOf('valuation'), + sectionKey: 'valuation', + text: 'NVDA trades at a premium multiple.', + evidenceRefs: [VALUATION_EVIDENCE], + }, + { + id: claimIdOf('momentum'), + sectionKey: 'momentum', + text: 'Momentum data unavailable.', + evidenceRefs: [], + }, + ], + runStatus: 'partial', + }; +} + +/** Report persisted before claim linking existed: no `claims`, no ids. */ +function legacyReport(): ResearchReport { + return { + id: 'report-legacy', + symbol: 'AAPL.US', + generatedAt: 1_700_000_000_000, + summary: 'summary', + stance: 'neutral', + confidence: 0.4, + sections: [ + { + key: 'growth', + title: 'Growth', + verdict: 'positive', + summary: 'Revenue compounding.', + evidence: [ + { + capabilityId: 'company.financials', + runId: 'run-g', + claim: 'Revenue grew 18% YoY.', + fetchedAt: 1_700_000_000_000, + }, + ], + }, + ], + bullCase: [], + bearCase: [], + catalysts: [], + risks: [], + capabilityRuns: [{ runId: 'run-g', capabilityId: 'company.financials', status: 'success' }], + runStatus: 'completed', + }; +} + +describe('claim and evidence ids', () => { + it('derives ids from data the report already carries', () => { + expect(evidenceIdOf('run-1', 'market.quote')).toBe('evidence:run-1:market.quote'); + expect(claimIdOf('valuation')).toBe('claim:valuation:0'); + expect(claimIdOf('valuation', 2)).toBe('claim:valuation:2'); + }); +}); + +describe('buildClaimEvidenceIndex', () => { + it('walks claim → evidence and evidence → claim', () => { + const index = buildClaimEvidenceIndex(linkedReport()); + expect(index.claimToEvidence['claim:valuation:0']).toEqual([VALUATION_EVIDENCE]); + expect(index.evidenceToClaims[VALUATION_EVIDENCE]).toEqual(['claim:valuation:0']); + }); + + it('keeps a claim nothing backs visible as an empty edge', () => { + expect(buildClaimEvidenceIndex(linkedReport()).claimToEvidence['claim:momentum:0']).toEqual([]); + }); + + it('supports several claims backed by the same evidence (many-to-many)', () => { + const report = linkedReport(); + report.claims = [ + { + id: claimIdOf('valuation'), + sectionKey: 'valuation', + text: 'Premium multiple.', + evidenceRefs: [VALUATION_EVIDENCE], + }, + { + id: claimIdOf('growth'), + sectionKey: 'growth', + text: 'Growth still compounding.', + evidenceRefs: [VALUATION_EVIDENCE], + }, + ]; + + const index = buildClaimEvidenceIndex(report); + expect(index.evidenceToClaims[VALUATION_EVIDENCE]).toEqual([ + 'claim:valuation:0', + 'claim:growth:0', + ]); + expect(index.claimToEvidence['claim:growth:0']).toEqual([VALUATION_EVIDENCE]); + }); + + it('rebuilds the same links for a report written before claim linking', () => { + const report = legacyReport(); + const index = buildClaimEvidenceIndex(report); + + expect(index.claimToEvidence['claim:growth:0']).toEqual([ + evidenceIdOf('run-g', 'company.financials'), + ]); + expect(index.evidenceToClaims[evidenceIdOf('run-g', 'company.financials')]).toEqual([ + 'claim:growth:0', + ]); + expect(collectReportClaims(report)).toEqual([ + { + id: 'claim:growth:0', + sectionKey: 'growth', + text: 'Revenue grew 18% YoY.', + evidenceRefs: [evidenceIdOf('run-g', 'company.financials')], + }, + ]); + }); +}); + +describe('claim ↔ evidence lookups', () => { + it('resolves evidence for a claim and claims for a piece of evidence', () => { + const report = linkedReport(); + expect(evidenceForClaim(report, 'claim:valuation:0').map((ref) => ref.runId)).toEqual(['run-v']); + expect(claimsForEvidence(report, VALUATION_EVIDENCE).map((claim) => claim.id)).toEqual([ + 'claim:valuation:0', + ]); + }); + + it('returns nothing for unknown ids', () => { + const report = linkedReport(); + expect(evidenceForClaim(report, 'claim:nope:0')).toEqual([]); + expect(claimsForEvidence(report, 'evidence:nope')).toEqual([]); + }); + + it('names the claims nothing backs', () => { + expect(findUnbackedClaims(linkedReport()).map((claim) => claim.id)).toEqual([ + 'claim:momentum:0', + ]); + expect(findUnbackedClaims(legacyReport())).toEqual([]); + }); +}); + +describe('claim links across persistence', () => { + let dir = ''; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'finagent-claim-evidence-')); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('resolves the same links after save + reload', async () => { + const report = linkedReport(); + await new ResearchReportRepository(new JsonFileStore(dir)).saveReport(report); + + const reloaded = await new ResearchReportRepository(new JsonFileStore(dir)).getReport( + report.id + ); + expect(reloaded).toBeDefined(); + expect(buildClaimEvidenceIndex(reloaded!)).toEqual(buildClaimEvidenceIndex(report)); + expect(claimsForEvidence(reloaded!, VALUATION_EVIDENCE).map((claim) => claim.id)).toEqual([ + 'claim:valuation:0', + ]); + expect(findUnbackedClaims(reloaded!).map((claim) => claim.id)).toEqual(['claim:momentum:0']); + }); +}); diff --git a/packages/shared/src/research/claim-evidence.ts b/packages/shared/src/research/claim-evidence.ts new file mode 100644 index 0000000..d8879ae --- /dev/null +++ b/packages/shared/src/research/claim-evidence.ts @@ -0,0 +1,129 @@ +import type { EvidenceRef, ResearchClaim, ResearchReport } from '@finagent/core'; + +/** + * Claim ↔ evidence identity for Deep Research reports (issue #13, step 1). + * + * The report itself stays the single source of truth: every id here is derived + * deterministically from data the report already carries (section key, run id, + * capability id, position), so rebuilding the mapping after a reload yields + * exactly the links that were written at assembly time — no side table to keep + * in sync, no clock, no filesystem. + * + * The relation is deliberately many-to-many: a claim may need several pieces of + * evidence, and one piece of evidence may back several claims. + */ + +/** Stable evidence id, derived from the producing run rather than array order. */ +export function evidenceIdOf(runId: string, capabilityId: string): string { + return `evidence:${runId}:${capabilityId}`; +} + +/** Stable claim id for the n-th claim of a section. */ +export function claimIdOf(sectionKey: string, index = 0): string { + return `claim:${sectionKey}:${index}`; +} + +/** + * Both directions of the claim ↔ evidence relation, as plain records so they + * survive JSON persistence and stay deterministic across reloads. + */ +export interface ClaimEvidenceIndex { + /** claim id → ids of the evidence backing it. Empty array = nothing backs it. */ + claimToEvidence: Record; + /** evidence id → ids of the claims that reference it. */ + evidenceToClaims: Record; +} + +/** + * Claims of a report, including reports written before `claims[]` existed. + * Those fall back to one claim per evidence ref, keyed by section + position — + * the same ids the current assembler would have written for them. + */ +export function collectReportClaims(report: ResearchReport): ResearchClaim[] { + if (report.claims && report.claims.length > 0) return report.claims; + + const claims: ResearchClaim[] = []; + for (const section of report.sections) { + section.evidence.forEach((ref, index) => { + claims.push({ + id: ref.claimId ?? claimIdOf(section.key, index), + sectionKey: section.key, + text: ref.claim, + evidenceRefs: [evidenceIdOf(ref.runId, ref.capabilityId)], + }); + }); + } + return claims; +} + +/** + * Rebuilds the bidirectional claim ↔ evidence mapping from a report. + * + * Declared claims are registered first so that a claim nothing backs stays + * visible as an empty edge instead of disappearing; section evidence is then + * folded in, which is also the only source for legacy reports. + */ +export function buildClaimEvidenceIndex(report: ResearchReport): ClaimEvidenceIndex { + const claimToEvidence: Record = {}; + const evidenceToClaims: Record = {}; + + const link = (claimId: string, evidenceId: string) => { + const forward = claimToEvidence[claimId] ?? (claimToEvidence[claimId] = []); + if (!forward.includes(evidenceId)) forward.push(evidenceId); + + const backward = evidenceToClaims[evidenceId] ?? (evidenceToClaims[evidenceId] = []); + if (!backward.includes(claimId)) backward.push(claimId); + }; + + for (const claim of report.claims ?? []) { + if (!claimToEvidence[claim.id]) claimToEvidence[claim.id] = []; + for (const evidenceId of claim.evidenceRefs) link(claim.id, evidenceId); + } + + for (const section of report.sections) { + section.evidence.forEach((ref, index) => { + const evidenceId = ref.id ?? evidenceIdOf(ref.runId, ref.capabilityId); + const claimId = ref.claimId ?? claimIdOf(section.key, index); + link(claimId, evidenceId); + }); + } + + return { claimToEvidence, evidenceToClaims }; +} + +/** Evidence refs backing a claim, in report order. */ +export function evidenceForClaim(report: ResearchReport, claimId: string): EvidenceRef[] { + const wanted = new Set(buildClaimEvidenceIndex(report).claimToEvidence[claimId] ?? []); + if (wanted.size === 0) return []; + + const refs: EvidenceRef[] = []; + for (const section of report.sections) { + for (const ref of section.evidence) { + const evidenceId = ref.id ?? evidenceIdOf(ref.runId, ref.capabilityId); + if (wanted.has(evidenceId)) refs.push(ref); + } + } + return refs; +} + +/** Claims that reference a piece of evidence — the reverse lookup. */ +export function claimsForEvidence(report: ResearchReport, evidenceId: string): ResearchClaim[] { + const ids = buildClaimEvidenceIndex(report).evidenceToClaims[evidenceId] ?? []; + if (ids.length === 0) return []; + + const byId = new Map(collectReportClaims(report).map((claim) => [claim.id, claim])); + return ids + .map((id) => byId.get(id)) + .filter((claim): claim is ResearchClaim => claim !== undefined); +} + +/** + * Claims with no evidence behind them. These are the ones that must not be + * presented as verified conclusions without an explicit marker. + */ +export function findUnbackedClaims(report: ResearchReport): ResearchClaim[] { + const { claimToEvidence } = buildClaimEvidenceIndex(report); + return collectReportClaims(report).filter( + (claim) => (claimToEvidence[claim.id] ?? []).length === 0 + ); +} diff --git a/packages/shared/src/research/index.ts b/packages/shared/src/research/index.ts index 8573011..98df7d3 100644 --- a/packages/shared/src/research/index.ts +++ b/packages/shared/src/research/index.ts @@ -6,6 +6,16 @@ export { RESEARCH_CAPABILITY_PLAN, type PlannedCapability, } from './planner.ts'; +export { + buildClaimEvidenceIndex, + claimIdOf, + claimsForEvidence, + collectReportClaims, + evidenceForClaim, + evidenceIdOf, + findUnbackedClaims, + type ClaimEvidenceIndex, +} from './claim-evidence.ts'; export { ResearchRunner, type ResearchRunnerOptions, type ResearchRunRequest, type ResearchRunResult } from './runner.ts'; export { LocalResearchSynthesizer } from './synthesizer-local.ts'; export { createAgentSynthesizer, parseSynthesisJson, type ResearchAgentRunner } from './agent-synth.ts'; diff --git a/packages/shared/src/research/runner.test.ts b/packages/shared/src/research/runner.test.ts index 97ad64f..1dd62dc 100644 --- a/packages/shared/src/research/runner.test.ts +++ b/packages/shared/src/research/runner.test.ts @@ -5,6 +5,7 @@ import { LocalResearchSynthesizer } from './synthesizer-local.ts'; import { ResearchRunner } from './runner.ts'; import { fakeCap } from './test-helpers.ts'; import { RESEARCH_CAPABILITY_PLAN } from './planner.ts'; +import { claimsForEvidence, evidenceForClaim, findUnbackedClaims } from './claim-evidence.ts'; function makeRunner(capabilities: Array<[string, Parameters[1]?]>) { const registry = createCapabilityRegistry( @@ -64,6 +65,26 @@ describe('ResearchRunner', () => { } }); + it('gives every claim an id and a link to the evidence it rests on', async () => { + const runner = makeRunner(RESEARCH_CAPABILITY_PLAN.map((id) => [id, 'success' as const])); + const result = await runner.run({ symbol: 'NVDA.US', runId: 'run-1' }); + const report = result.report!; + + // One claim per section today; the link must be walkable both ways. + expect(report.claims).toHaveLength(report.sections.length); + for (const claim of report.claims!) { + const refs = evidenceForClaim(report, claim.id); + expect(refs).toHaveLength(1); + expect(refs[0]!.id).toBeDefined(); + expect(refs[0]!.capabilityId).toBe(claim.sectionKey); + expect(refs[0]!.claimId).toBe(claim.id); + expect(claimsForEvidence(report, refs[0]!.id!).map((c) => c.id)).toEqual([claim.id]); + } + + // A clean run leaves nothing unbacked. + expect(findUnbackedClaims(report)).toEqual([]); + }); + it('produces a partial report with explicit unavailable + failed entries', async () => { // company.financials registered but fails; company.earnings/company.ratings absent. const caps: Array<[string, 'success' | 'fail']> = [ @@ -95,6 +116,25 @@ describe('ResearchRunner', () => { expect(result.report!.capabilityRuns).toHaveLength(RESEARCH_CAPABILITY_PLAN.length); }); + it('keeps the claim of a failed capability, marked as unbacked', async () => { + const runner = makeRunner([ + ['company.profile', 'success'], + ['market.quote', 'fail'], + ]); + const result = await runner.run({ symbol: 'NVDA.US', runId: 'run-5' }); + const report = result.report!; + + const unbacked = findUnbackedClaims(report); + expect(unbacked.map((claim) => claim.sectionKey)).toContain('market.quote'); + expect(evidenceForClaim(report, 'claim:market.quote:0')).toEqual([]); + expect(claimsForEvidence(report, 'evidence:missing')).toEqual([]); + + // The section still exists, so the gap is visible rather than dropped. + const quote = report.sections.find((section) => section.key === 'market.quote')!; + expect(quote.evidence).toEqual([]); + expect(quote.summary).not.toBe(''); + }); + it('fails when no capability succeeds', async () => { const runner = makeRunner([ ['company.profile', 'fail'], diff --git a/packages/shared/src/research/runner.ts b/packages/shared/src/research/runner.ts index 3ee0fee..14bd46a 100644 --- a/packages/shared/src/research/runner.ts +++ b/packages/shared/src/research/runner.ts @@ -3,6 +3,7 @@ import { type CapabilityRunStatus, type CapabilityRunSummary, type EvidenceRef, + type ResearchClaim, type ResearchReport, type ResearchRunStatus, type ResearchRunSummary, @@ -19,6 +20,7 @@ import type { ResearchCheckpoint } from './checkpoint.ts'; import type { SupportedLocale } from '@finagent/core'; import type { CapabilityRegistry } from '@finagent/core'; import { CapabilityExecutor, type RunOutcome } from '../capabilities/index.ts'; +import { claimIdOf, evidenceIdOf } from './claim-evidence.ts'; import { buildCapabilityInput, planForStrategy, @@ -314,22 +316,43 @@ function assembleReport(args: { const { runId, symbol, strategyId, generatedAt, plan, outcomes, synthesis, locale } = args; const outcomeByCapability = new Map(outcomes.map((o) => [o.record.capabilityId, o])); + const claims: ResearchClaim[] = []; const sections: ResearchSection[] = synthesis.sections.map((section) => { const outcome = outcomeByCapability.get(section.key); + const usable = outcome && outcome.record.status === 'success' ? outcome : undefined; + const claimId = claimIdOf(section.key, 0); + const evidenceId = usable + ? evidenceIdOf(usable.record.id, usable.record.capabilityId) + : undefined; + const evidence: EvidenceRef[] = []; - if (outcome && outcome.record.status === 'success') { + if (usable) { const instrumentId = - outcome.result?.provenance?.instrumentId ?? readInstrumentId(outcome.result?.data); + usable.result?.provenance?.instrumentId ?? readInstrumentId(usable.result?.data); evidence.push({ - capabilityId: outcome.record.capabilityId, - runId: outcome.record.id, + id: evidenceId, + capabilityId: usable.record.capabilityId, + runId: usable.record.id, claim: section.summary, - fetchedAt: outcome.record.provenance?.fetchedAt ?? generatedAt, - summary: outcome.result?.summary, + claimId, + fetchedAt: usable.record.provenance?.fetchedAt ?? generatedAt, + summary: usable.result?.summary, ...(instrumentId ? { instrumentId } : {}), }); } + + // Every section contributes one claim today; the id plus list shape already + // supports several per section. A section whose capability failed keeps its + // claim with no evidence behind it — visible as an unbacked edge rather + // than silently dropped. + claims.push({ + id: claimId, + sectionKey: section.key, + text: section.summary, + evidenceRefs: evidenceId ? [evidenceId] : [], + }); + return { ...section, evidence }; }); @@ -382,6 +405,9 @@ function assembleReport(args: { catalysts: synthesis.catalysts, risks: synthesis.risks, capabilityRuns, + // Claim ↔ evidence identity travels with the report, so the links are the + // same after persistence, reload and export (issue #13). + claims, runStatus: computeRunStatus(plan, successIds), }; }