- 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>
215 lines
8.1 KiB
TypeScript
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}>
|
|
“{valueStr.length > 50 ? valueStr.slice(0, 50) + "..." : valueStr}”
|
|
</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">·</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 →
|
|
</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>
|
|
);
|
|
}
|