diff --git a/apps/azure/src/components/ProcessHubEvidencePanel.tsx b/apps/azure/src/components/ProcessHubEvidencePanel.tsx index 49bb4a64b..d3f2766a9 100644 --- a/apps/azure/src/components/ProcessHubEvidencePanel.tsx +++ b/apps/azure/src/components/ProcessHubEvidencePanel.tsx @@ -1,12 +1,19 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Database, Upload, ShieldCheck } from 'lucide-react'; import { AGENT_REVIEW_LOG_PROFILE, + DATA_PROFILE_REGISTRY, + detectDataProfiles, parseText, + type DataProfileDefinition, + type DataProfileDetection, + type DataRow, + type EvidenceCadence, type EvidenceSnapshot, type EvidenceSource, } from '@variscout/core'; import { useStorage } from '../services/storage'; +import { safeTrackEvent } from '../lib/appInsights'; interface ProcessHubEvidencePanelProps { hubId: string; @@ -34,6 +41,51 @@ function falseGreenSeverity(unsafeGreens: number, totalRows: number): 'green' | return unsafeGreens / totalRows <= FALSE_GREEN_AMBER_RATE ? 'amber' : 'red'; } +// --------------------------------------------------------------------------- +// Wizard state machine +// --------------------------------------------------------------------------- + +type WizardState = + | { step: 'idle' } + | { + step: 'picking-profile'; + rows: DataRow[]; + rawText: string; + sourceName: string; + matches: DataProfileDetection[]; + } + | { + step: 'confirming-mapping'; + rows: DataRow[]; + rawText: string; + sourceName: string; + profile: DataProfileDefinition; + mapping: Record; + } + | { + step: 'choosing-cadence'; + rows: DataRow[]; + rawText: string; + sourceName: string; + profile: DataProfileDefinition; + mapping: Record; + } + | { + step: 'saving'; + rows: DataRow[]; + rawText: string; + sourceName: string; + profile: DataProfileDefinition; + mapping: Record; + cadence: EvidenceCadence; + } + | { step: 'success' } + | { step: 'error'; message: string }; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + const ProcessHubEvidencePanel: React.FC = ({ hubId, onEvidenceChanged, @@ -45,6 +97,11 @@ const ProcessHubEvidencePanel: React.FC = ({ const [selectedSourceId, setSelectedSourceId] = useState(''); const [status, setStatus] = useState(''); + // Wizard state + const [wizard, setWizard] = useState({ step: 'idle' }); + const [chosenCadence, setChosenCadence] = useState('manual'); + const newSourceFileInputRef = useRef(null); + const selectedSource = useMemo( () => sources.find(source => source.id === selectedSourceId), [selectedSourceId, sources] @@ -75,6 +132,10 @@ const ProcessHubEvidencePanel: React.FC = ({ .catch(() => setStatus('Snapshot history could not be loaded.')); }, [hubId, listEvidenceSnapshots, selectedSourceId]); + // --------------------------------------------------------------------------- + // Existing quick-path: Agent Review Log + // --------------------------------------------------------------------------- + const handleCreateAgentReviewSource = async (): Promise => { const timestamp = nowIso(); const source: EvidenceSource = { @@ -141,6 +202,159 @@ const ProcessHubEvidencePanel: React.FC = ({ } }; + // --------------------------------------------------------------------------- + // New generic wizard path + // --------------------------------------------------------------------------- + + const handleNewSourceFile = async (file: File): Promise => { + try { + const text = await file.text(); + const rows = await parseText(text); + const sourceName = file.name.replace(/\.[^.]+$/, '') || 'Evidence source'; + if (rows.length === 0) { + setWizard({ step: 'error', message: 'File contains no rows.' }); + return; + } + const matches = detectDataProfiles(rows); + if (matches.length === 0) { + setWizard({ step: 'error', message: 'No profile matched this file.' }); + return; + } + if (matches.length === 1) { + const profile = DATA_PROFILE_REGISTRY.find(p => p.id === matches[0]!.profileId); + if (!profile) { + setWizard({ step: 'error', message: 'Detected profile is not registered.' }); + return; + } + setWizard({ + step: 'confirming-mapping', + rows, + rawText: text, + sourceName, + profile, + mapping: { ...matches[0]!.recommendedMapping }, + }); + } else { + setWizard({ + step: 'picking-profile', + rows, + rawText: text, + sourceName, + matches, + }); + } + } catch (err) { + setWizard({ + step: 'error', + message: err instanceof Error ? err.message : 'File upload failed.', + }); + } + }; + + const handlePickProfile = (profileId: string): void => { + setWizard(prev => { + if (prev.step !== 'picking-profile') return prev; + const profile = DATA_PROFILE_REGISTRY.find(p => p.id === profileId); + if (!profile) return prev; + const detection = prev.matches.find(m => m.profileId === profileId); + return { + step: 'confirming-mapping', + rows: prev.rows, + rawText: prev.rawText, + sourceName: prev.sourceName, + profile, + mapping: { ...(detection?.recommendedMapping ?? {}) }, + }; + }); + }; + + const handleMappingChange = (key: string, value: string): void => { + setWizard(prev => { + if (prev.step !== 'confirming-mapping') return prev; + return { ...prev, mapping: { ...prev.mapping, [key]: value } }; + }); + }; + + const handleConfirmMapping = (): void => { + setWizard(prev => { + if (prev.step !== 'confirming-mapping') return prev; + return { + step: 'choosing-cadence', + rows: prev.rows, + rawText: prev.rawText, + sourceName: prev.sourceName, + profile: prev.profile, + mapping: prev.mapping, + }; + }); + }; + + const handleSaveNewSource = async (): Promise => { + if (wizard.step !== 'choosing-cadence') return; + const { rows, rawText, sourceName, profile, mapping } = wizard; + setWizard({ + step: 'saving', + rows, + rawText, + sourceName, + profile, + mapping, + cadence: chosenCadence, + }); + try { + const timestamp = nowIso(); + const sourceId = `evidence-source-${Date.now()}`; + const source: EvidenceSource = { + id: sourceId, + hubId, + name: sourceName, + cadence: chosenCadence, + profileId: profile.id, + createdAt: timestamp, + updatedAt: timestamp, + }; + await saveEvidenceSource(source); + + const application = profile.apply(rows, mapping); + const snapshot: EvidenceSnapshot = { + id: `snapshot-${Date.now()}`, + hubId, + sourceId, + capturedAt: timestamp, + rowCount: rows.length, + profileApplication: application, + }; + await saveEvidenceSnapshot(snapshot, rawText); + + safeTrackEvent('process_hub.evidence_source_created', { + hubId, + profileId: profile.id, + columnCount: Object.keys(rows[0] ?? {}).length, + rowCount: rows.length, + cadence: chosenCadence, + }); + + setWizard({ step: 'success' }); + await refresh(); + onEvidenceChanged?.(); + setTimeout(() => setWizard({ step: 'idle' }), 800); + } catch (err) { + setWizard({ + step: 'error', + message: err instanceof Error ? err.message : 'Save failed.', + }); + } + }; + + const handleCancelWizard = (): void => { + setWizard({ step: 'idle' }); + setChosenCadence('manual'); + }; + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return (
@@ -153,16 +367,197 @@ const ProcessHubEvidencePanel: React.FC = ({ Customer-owned snapshot contracts for recurring hub evidence.

- +
+ {wizard.step === 'idle' && ( + + )} + + {/* New Source wizard trigger */} + {wizard.step === 'idle' && ( + <> + + { + const file = event.target.files?.[0]; + if (file) void handleNewSourceFile(file); + event.currentTarget.value = ''; + }} + /> + + )} +
+ {/* ------------------------------------------------------------------ */} + {/* Wizard steps */} + {/* ------------------------------------------------------------------ */} + + {wizard.step === 'picking-profile' && ( +
+

Pick a profile

+ {wizard.matches.map(m => { + const profile = DATA_PROFILE_REGISTRY.find(p => p.id === m.profileId); + if (!profile) return null; + return ( + + ); + })} + +
+ )} + + {wizard.step === 'confirming-mapping' && ( +
{ + e.preventDefault(); + handleConfirmMapping(); + }} + > +

Confirm column mapping

+

Profile: {wizard.profile.label}

+ {Object.keys(wizard.mapping).length === 0 && ( +

+ No mappings to confirm — defaults will be used. +

+ )} + {Object.entries(wizard.mapping).map(([key, value]) => ( +
+ + +
+ ))} +
+ + +
+
+ )} + + {wizard.step === 'choosing-cadence' && ( +
+

Choose cadence

+ {(['manual', 'hourly', 'shiftly', 'daily', 'weekly'] as const).map(c => ( + + ))} +
+ + +
+
+ )} + + {wizard.step === 'saving' &&

Saving…

} + + {wizard.step === 'success' && ( +

Evidence Source created.

+ )} + + {wizard.step === 'error' && ( +
+

{wizard.message}

+ +
+ )} + + {/* ------------------------------------------------------------------ */} + {/* Existing source selector + snapshot upload */} + {/* ------------------------------------------------------------------ */} + {sources.length > 0 && (
diff --git a/apps/azure/src/components/__tests__/ProcessHubEvidencePanel.generic.test.tsx b/apps/azure/src/components/__tests__/ProcessHubEvidencePanel.generic.test.tsx new file mode 100644 index 000000000..bd4069b3b --- /dev/null +++ b/apps/azure/src/components/__tests__/ProcessHubEvidencePanel.generic.test.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// vi.mock BEFORE component imports per writing-tests rule +vi.mock('../../services/storage', () => ({ + useStorage: vi.fn(() => ({ + listEvidenceSources: vi.fn(async () => []), + saveEvidenceSource: vi.fn(async () => undefined), + listEvidenceSnapshots: vi.fn(async () => []), + saveEvidenceSnapshot: vi.fn(async () => undefined), + })), +})); +vi.mock('../../lib/appInsights', () => ({ + safeTrackEvent: vi.fn(), +})); + +import ProcessHubEvidencePanel from '../ProcessHubEvidencePanel'; +import { useStorage } from '../../services/storage'; +import { safeTrackEvent } from '../../lib/appInsights'; + +// Helper: build a CSV file with given content +function makeCsvFile(content: string, name = 'data.csv'): File { + return new File([content], name, { type: 'text/csv' }); +} + +const PURE_NUMERIC_CSV = `A,B,C +1,2,3 +4,5,6 +7,8,9`; + +const REVIEW_LOG_CSV = `id,flagColor,decision,confidence +abc,green,correct,0.95 +def,green,incorrect,0.6 +ghi,red,correct,0.8`; + +describe('ProcessHubEvidencePanel — Generic Tabular flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows a "New Source" button to start the generic flow', () => { + render(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); + }); + + it('uploading a non-review-log CSV advances to mapping confirmation (single profile match → no picker)', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(PURE_NUMERIC_CSV)] } }); + await waitFor(() => { + expect(screen.getByTestId('mapping-confirmation-form')).toBeInTheDocument(); + }); + // Generic Tabular suggests outcome from first numeric column + expect(screen.getByLabelText(/outcome/i)).toHaveValue('A'); + }); + + it('uploading a review-log CSV with both profiles matching renders a picker', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(REVIEW_LOG_CSV)] } }); + await waitFor(() => { + expect(screen.getByTestId('profile-picker')).toBeInTheDocument(); + }); + // Both profile labels should be selectable options + expect(screen.getByText(/agent review log/i)).toBeInTheDocument(); + expect(screen.getByText(/generic tabular/i)).toBeInTheDocument(); + }); + + it('user can edit the outcome mapping field', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(PURE_NUMERIC_CSV)] } }); + await waitFor(() => screen.getByTestId('mapping-confirmation-form')); + const outcomeSelect = screen.getByLabelText(/outcome/i); + fireEvent.change(outcomeSelect, { target: { value: 'B' } }); + expect(outcomeSelect).toHaveValue('B'); + }); + + it('cadence selector has 5 options matching EvidenceCadence enum (no monthly)', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(PURE_NUMERIC_CSV)] } }); + await waitFor(() => screen.getByTestId('mapping-confirmation-form')); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + await waitFor(() => screen.getByTestId('cadence-selector')); + expect(screen.getByLabelText(/manual/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/hourly/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/shiftly/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/daily/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/weekly/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/monthly/i)).not.toBeInTheDocument(); + }); + + it('cadence defaults to manual', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(PURE_NUMERIC_CSV)] } }); + await waitFor(() => screen.getByTestId('mapping-confirmation-form')); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + await waitFor(() => screen.getByTestId('cadence-selector')); + expect((screen.getByLabelText(/manual/i) as HTMLInputElement).checked).toBe(true); + }); + + it('Save calls saveEvidenceSource and saveEvidenceSnapshot in order', async () => { + const mockSaveSource = vi.fn(async () => undefined); + const mockSaveSnapshot = vi.fn(async () => undefined); + vi.mocked(useStorage).mockReturnValue({ + listEvidenceSources: vi.fn(async () => []), + saveEvidenceSource: mockSaveSource, + listEvidenceSnapshots: vi.fn(async () => []), + saveEvidenceSnapshot: mockSaveSnapshot, + } as unknown as ReturnType); + + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(PURE_NUMERIC_CSV)] } }); + await waitFor(() => screen.getByTestId('mapping-confirmation-form')); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + await waitFor(() => screen.getByTestId('cadence-selector')); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(mockSaveSource).toHaveBeenCalled(); + expect(mockSaveSnapshot).toHaveBeenCalled(); + }); + // Order check: source before snapshot + const sourceOrder = mockSaveSource.mock.invocationCallOrder[0]; + const snapshotOrder = mockSaveSnapshot.mock.invocationCallOrder[0]; + expect(sourceOrder).toBeLessThan(snapshotOrder); + }); + + it('emits process_hub.evidence_source_created telemetry on successful save', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /new source/i })); + const fileInput = screen.getByTestId('evidence-source-file-input') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [makeCsvFile(PURE_NUMERIC_CSV)] } }); + await waitFor(() => screen.getByTestId('mapping-confirmation-form')); + fireEvent.click(screen.getByRole('button', { name: /next/i })); + await waitFor(() => screen.getByTestId('cadence-selector')); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(safeTrackEvent).toHaveBeenCalledWith( + 'process_hub.evidence_source_created', + expect.objectContaining({ + hubId: 'h-1', + profileId: 'generic-tabular', + rowCount: expect.any(Number), + cadence: 'manual', + }) + ); + }); + // Verify NO PII fields in payload + const call = (safeTrackEvent as unknown as ReturnType).mock.calls.find( + (c: unknown[]) => c[0] === 'process_hub.evidence_source_created' + ); + if (call) { + const payload = call[1] as Record; + expect(payload).not.toHaveProperty('label'); + expect(payload).not.toHaveProperty('name'); + expect(payload).not.toHaveProperty('text'); + } + }); +}); diff --git a/packages/core/src/__tests__/evidenceSources.generic.test.ts b/packages/core/src/__tests__/evidenceSources.generic.test.ts new file mode 100644 index 000000000..7c25af37c --- /dev/null +++ b/packages/core/src/__tests__/evidenceSources.generic.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { DataRow } from '../types'; +import { + DATA_PROFILE_REGISTRY, + detectDataProfiles, + GENERIC_TABULAR_PROFILE, +} from '../evidenceSources'; + +const numericRow = (overrides: DataRow = {}): DataRow => ({ + Timestamp: '2026-04-27T00:00:00Z', + Value: 12.5, + Channel: 'A', + ...overrides, +}); + +describe('GENERIC_TABULAR_PROFILE', () => { + it('has id, version, label set', () => { + expect(GENERIC_TABULAR_PROFILE.id).toBe('generic-tabular'); + expect(GENERIC_TABULAR_PROFILE.version).toBe(1); + expect(GENERIC_TABULAR_PROFILE.label).toMatch(/generic/i); + }); + + it('detect returns null on empty rows', () => { + expect(GENERIC_TABULAR_PROFILE.detect([])).toBeNull(); + }); + + it('detect returns a result for tabular data with at least one numeric column', () => { + const rows = [numericRow(), numericRow({ Value: 13 }), numericRow({ Value: 14 })]; + const result = GENERIC_TABULAR_PROFILE.detect(rows); + expect(result).not.toBeNull(); + expect(result?.profileId).toBe('generic-tabular'); + expect(result?.profileVersion).toBe(1); + }); + + it('detect returns medium or high confidence when most columns are numeric', () => { + const rows = [ + { A: 1, B: 2, C: 3 }, + { A: 4, B: 5, C: 6 }, + ]; + const result = GENERIC_TABULAR_PROFILE.detect(rows); + expect(result?.confidence === 'medium' || result?.confidence === 'high').toBe(true); + }); + + it('detect returns low confidence when only a small fraction of columns are numeric', () => { + const rows = [ + { A: 1, B: 'text', C: 'text', D: 'text', E: 'text' }, + { A: 2, B: 'text', C: 'text', D: 'text', E: 'text' }, + ]; + const result = GENERIC_TABULAR_PROFILE.detect(rows); + expect(result?.confidence).toBe('low'); + }); + + it('validate rejects empty rows', () => { + const result = GENERIC_TABULAR_PROFILE.validate([], {}); + expect(result.ok).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('validate accepts non-empty rows', () => { + const rows = [numericRow()]; + const result = GENERIC_TABULAR_PROFILE.validate(rows, {}); + expect(result.ok).toBe(true); + }); + + it('apply returns identity ProfileApplication', () => { + const rows = [numericRow(), numericRow({ Value: 99 })]; + const mapping = { outcome: 'Value' }; + const application = GENERIC_TABULAR_PROFILE.apply(rows, mapping); + expect(application.profileId).toBe('generic-tabular'); + expect(application.profileVersion).toBe(1); + expect(application.derivedRows).toEqual(rows); + expect(application.validation.ok).toBe(true); + expect(application.mapping).toEqual(mapping); + }); +}); + +describe('DATA_PROFILE_REGISTRY (Phase 3)', () => { + it('contains both AGENT_REVIEW_LOG_PROFILE and GENERIC_TABULAR_PROFILE', () => { + const ids = DATA_PROFILE_REGISTRY.map(p => p.id); + expect(ids).toContain('agent-review-log'); + expect(ids).toContain('generic-tabular'); + }); + + it('detectDataProfiles returns generic-tabular for purely numeric data', () => { + const rows = [ + { Value: 1, Other: 2 }, + { Value: 3, Other: 4 }, + ]; + const matches = detectDataProfiles(rows); + expect(matches.some(m => m.profileId === 'generic-tabular')).toBe(true); + }); +}); diff --git a/packages/core/src/evidenceSources.ts b/packages/core/src/evidenceSources.ts index 17423dc8e..364a8aeb1 100644 --- a/packages/core/src/evidenceSources.ts +++ b/packages/core/src/evidenceSources.ts @@ -197,7 +197,91 @@ export const AGENT_REVIEW_LOG_PROFILE: DataProfileDefinition = { }, }; -export const DATA_PROFILE_REGISTRY: DataProfileDefinition[] = [AGENT_REVIEW_LOG_PROFILE]; +function isNumericValueLoose(value: unknown): boolean { + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value === 'string' && value.trim() !== '') { + return Number.isFinite(Number(value)); + } + return false; +} + +function classifyColumnNumeric(rows: DataRow[], col: string): boolean { + // A column is "numeric" if at least 70% of non-empty values parse as finite numbers. + let total = 0; + let numeric = 0; + for (const row of rows) { + const value = row[col]; + if (value === undefined || value === null || value === '') continue; + total += 1; + if (isNumericValueLoose(value)) numeric += 1; + } + if (total === 0) return false; + return numeric / total >= 0.7; +} + +function genericTabularConfidence(numericRatio: number): DataProfileConfidence { + if (numericRatio >= 0.6) return 'high'; + if (numericRatio >= 0.3) return 'medium'; + return 'low'; +} + +export const GENERIC_TABULAR_PROFILE: DataProfileDefinition = { + id: 'generic-tabular', + version: 1, + label: 'Generic tabular', + + detect(rows: DataRow[]): DataProfileDetection | null { + if (rows.length === 0) return null; + const cols = columns(rows); + if (cols.length === 0) return null; + + const numericCols = cols.filter(col => classifyColumnNumeric(rows, col)); + if (numericCols.length === 0) return null; + + const ratio = numericCols.length / cols.length; + const confidence = genericTabularConfidence(ratio); + + // Recommended mapping: pick the first numeric column as the outcome candidate. + // The user is expected to confirm/correct in the mapping form (PR #3 UI). + const recommendedMapping: Record = { + outcome: numericCols[0], + }; + + return { + profileId: 'generic-tabular', + profileVersion: 1, + confidence, + recommendedMapping, + reasons: [ + `Detected ${numericCols.length} numeric column${numericCols.length === 1 ? '' : 's'} of ${cols.length} total.`, + ], + }; + }, + + validate(rows: DataRow[], _mapping: Record): EvidenceValidationResult { + if (rows.length === 0) { + return validation(false, ['Snapshot has zero rows.']); + } + return validation(true); + }, + + apply(rows: DataRow[], mapping: Record): ProfileApplication { + // Identity transform — no derived signals for generic tabular. + return { + profileId: 'generic-tabular', + profileVersion: 1, + mapping, + validation: validation(true), + derivedColumns: [], + derivedRows: rows, + }; + }, +}; + +export const DATA_PROFILE_REGISTRY: DataProfileDefinition[] = [ + AGENT_REVIEW_LOG_PROFILE, + GENERIC_TABULAR_PROFILE, +]; export function detectDataProfiles(rows: DataRow[]): DataProfileDetection[] { return DATA_PROFILE_REGISTRY.map(profile => profile.detect(rows)).filter( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cdc9f6795..b4fde1dae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -251,6 +251,7 @@ export type { // Evidence Sources / Data Profiles / Snapshots export { AGENT_REVIEW_LOG_PROFILE, + GENERIC_TABULAR_PROFILE, DATA_PROFILE_REGISTRY, detectDataProfiles, processHubEvidenceBlobPath,