stemedb/applications/stemedb-dashboard/src/components/skeptic/claim-row.tsx
jordan ad07a75d0a feat: add source content to source registry, signed assertions, feed endpoint, dashboard enhancements
- Add `content: Option<String>` to SourceRecord with rkyv schema evolution
  (LegacySourceRecord compat deserializer for backward compatibility)
- Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation
- Strip content from list responses, include in single-source GET
- Update Go SDK RegisterSourceRequest with Content field
- FCM pipeline extracts PDF text via pdftotext and passes to registration
- Dashboard impact panel fetches and displays source content with expand/collapse
- Add feed endpoint, dashboard feed panel, and signed assertion support
- Update data-structures.md, API docs, and storage docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:27 -07:00

215 lines
8.1 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import type { ClaimSummary, SourceRecordDto } from "@/lib/api/types";
import { StemeDBClient } from "@/lib/api";
import { SourceTierBadge } from "./source-tier-badge";
import { WeightBar } from "./weight-bar";
import { HashDisplay } from "./hash-display";
import { TrustBar } from "./trust-bar";
import { statusColors, statusIcons, type SourceStatus } from "./constants";
interface ClaimRowProps {
claim: ClaimSummary;
isLeading: boolean;
isExpanded: boolean;
onToggle: () => void;
}
function formatValue(value: ClaimSummary["value"]): string {
if (typeof value.value === "string") {
return value.value;
}
return String(value.value);
}
export function ClaimRow({ claim, isLeading, isExpanded, onToggle }: ClaimRowProps) {
const tier = claim.source.source_metadata?.tier ?? 5;
const sourceLabel = claim.source.source_metadata?.label ?? "Unknown Source";
const tierLabel = claim.source.source_metadata?.tier_label ?? "Unknown";
const sourceUrl = claim.source.source_metadata?.url;
const rawStatus = claim.source.source_metadata?.status ?? "active";
const status = (rawStatus === "active" || rawStatus === "deprecated" || rawStatus === "quarantined"
? rawStatus
: "active") as SourceStatus;
const valueStr = formatValue(claim.value);
// Fetch full source record when expanded
const [sourceRecord, setSourceRecord] = useState<SourceRecordDto | null>(null);
const [sourceLoading, setSourceLoading] = useState(false);
useEffect(() => {
if (!isExpanded || sourceRecord || sourceLoading) return;
setSourceLoading(true);
const client = new StemeDBClient();
client
.getSource(claim.source.source_hash)
.then(setSourceRecord)
.catch(() => {
// Source may not be in registry — that's fine
})
.finally(() => setSourceLoading(false));
}, [isExpanded, claim.source.source_hash, sourceRecord, sourceLoading]);
return (
<div
className={cn(
"border rounded-lg transition-all",
isExpanded
? "border-foreground/20 bg-muted/30"
: "border-border hover:border-foreground/10"
)}
>
{/* Collapsed row header */}
<button
onClick={onToggle}
className="w-full p-3 text-left"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<SourceTierBadge tier={tier} />
<span className="text-sm font-medium truncate max-w-xs" title={valueStr}>
&ldquo;{valueStr.length > 50 ? valueStr.slice(0, 50) + "..." : valueStr}&rdquo;
</span>
{isLeading && (
<span className="text-[10px] bg-foreground/10 px-1.5 py-0.5 rounded font-medium">
LEADING
</span>
)}
</div>
<WeightBar
share={claim.weight_share}
tier={tier}
isLeading={isLeading}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{claim.assertion_count} assertions</span>
<span>{claim.supporting_agents.length} agents</span>
{claim.supporting_agents.length > 0 && (
<span>
avg trust:{" "}
{(
claim.supporting_agents.reduce((a, b) => a + b.trust_score, 0) /
claim.supporting_agents.length
).toFixed(2)}
</span>
)}
</div>
</div>
<span className="text-muted-foreground text-xs mt-1">
{isExpanded ? "▼" : "▶"}
</span>
</div>
</button>
{/* Expanded details */}
{isExpanded && (
<div className="px-3 pb-3 space-y-4 border-t border-border pt-3">
{/* Full value */}
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Value
</div>
<p className="text-sm text-foreground whitespace-pre-wrap break-words leading-relaxed">
{valueStr}
</p>
<div className="text-xs text-muted-foreground">
Type: <code className="bg-muted px-1 py-0.5 rounded">{claim.value.type}</code>
</div>
</div>
{/* Source info */}
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Source
</div>
<div className="text-sm font-medium">{sourceLabel}</div>
<div className="flex items-center gap-2 text-xs">
<span className={statusColors[status]}>
{statusIcons[status]} {status}
</span>
<span className="text-muted-foreground">&middot;</span>
<span className="text-muted-foreground">
{tierLabel} (T{tier})
</span>
</div>
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline block truncate"
>
{sourceUrl}
</a>
)}
{/* Source registry details (fetched) */}
{sourceLoading && (
<div className="text-xs text-muted-foreground animate-pulse mt-1">
Loading source details...
</div>
)}
{sourceRecord && (
<div className="mt-2 rounded border border-border bg-muted/30 p-2 space-y-1">
{sourceRecord.notes && (
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
{sourceRecord.notes}
</p>
)}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<span>Created: {new Date(sourceRecord.created_at).toLocaleDateString()}</span>
{sourceRecord.updated_at !== sourceRecord.created_at && (
<span>Updated: {new Date(sourceRecord.updated_at).toLocaleDateString()}</span>
)}
</div>
<Link
href={`/sources`}
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
>
View in Source Registry &rarr;
</Link>
</div>
)}
</div>
{/* Supporting agents */}
{claim.supporting_agents.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Supporting Agents ({claim.supporting_agents.length})
</div>
<div className="space-y-2">
{claim.supporting_agents.map((agent, j) => (
<div key={j} className="flex items-center gap-2">
<code className="text-[10px] font-mono bg-muted px-1 py-0.5 rounded">
{agent.agent_id.slice(0, 8)}...
</code>
<div className="flex-1">
<TrustBar score={agent.trust_score} size="sm" />
</div>
</div>
))}
</div>
</div>
)}
{/* Provenance hashes */}
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Provenance
</div>
<HashDisplay hash={claim.representative_hash} label="Assertion" />
<HashDisplay hash={claim.source.source_hash} label="Source" />
{claim.source.visual_hash && (
<HashDisplay hash={claim.source.visual_hash} label="Visual" />
)}
</div>
</div>
)}
</div>
);
}