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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/core/src/research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,51 @@ 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;
/** Canonical instrument id linking this evidence to one listing. */
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;
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/src/export/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
49 changes: 45 additions & 4 deletions packages/shared/src/export/markdown.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -7,14 +12,22 @@ 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 {
/** Append the evidence list (section title + claim + capability + run id). Default true. */
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<ResearchStance, string> = {
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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) {
Expand All @@ -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`
Expand Down
Loading