forked from 0xWheatyz/SPARC
Add historical analysis diffing between runs for same company #1695
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { analysisApi, exportApi } from '../api/client';
|
||||
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown } from 'lucide-react';
|
||||
import { Search, CheckCircle, AlertCircle, Clock, FileText, Download, ChevronDown, History } from 'lucide-react';
|
||||
import type { CompanyAnalysis } from '../types';
|
||||
|
||||
export function Analysis() {
|
||||
const navigate = useNavigate();
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [result, setResult] = useState<CompanyAnalysis | null>(null);
|
||||
@@ -157,6 +159,13 @@ export function Analysis() {
|
||||
<FileText size={14} />
|
||||
Export PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/history-diff?company=${encodeURIComponent(result.company_name)}`)}
|
||||
className="flex items-center gap-2 text-sm bg-secondary/20 hover:bg-secondary/30 text-secondary font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<History size={14} />
|
||||
Compare with previous
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { analysisApi } from '../api/client';
|
||||
import type { AnalysisHistoryItem, AnalysisDiff } from '../api/client';
|
||||
import { History, ArrowRight, Plus, Minus, AlertCircle, Search } from 'lucide-react';
|
||||
|
||||
export function HistoryDiff() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [companyInput, setCompanyInput] = useState(searchParams.get('company') || '');
|
||||
|
||||
const company = searchParams.get('company') || '';
|
||||
const fromId = searchParams.get('from');
|
||||
const toId = searchParams.get('to');
|
||||
|
||||
// Fetch history when a company is selected
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['history', company],
|
||||
queryFn: () => analysisApi.getCompanyHistory(company),
|
||||
enabled: !!company,
|
||||
});
|
||||
|
||||
// Fetch diff when both IDs are selected
|
||||
const diffQuery = useQuery<AnalysisDiff>({
|
||||
queryKey: ['diff', company, fromId, toId],
|
||||
queryFn: () => analysisApi.diffAnalyses(company, Number(fromId), Number(toId)),
|
||||
enabled: !!company && !!fromId && !!toId,
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = companyInput.trim();
|
||||
if (name) {
|
||||
setSearchParams({ company: name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRuns = (from: number, to: number) => {
|
||||
setSearchParams({ company, from: String(from), to: String(to) });
|
||||
};
|
||||
|
||||
const history: AnalysisHistoryItem[] = historyQuery.data || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-2">
|
||||
Historical Analysis Diff
|
||||
</h2>
|
||||
<p className="text-text-secondary">
|
||||
Compare analysis runs for the same company to see what changed between them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Company Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={companyInput}
|
||||
onChange={(e) => setCompanyInput(e.target.value)}
|
||||
placeholder="Enter company name (e.g., nvidia)"
|
||||
className="w-full bg-bg-card/80 border border-primary/30 rounded-xl pl-12 pr-4 py-3 text-text-primary placeholder-text-secondary/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!companyInput.trim()}
|
||||
className="bg-gradient-to-r from-primary to-primary-dark text-white font-semibold py-3 px-6 rounded-xl hover:shadow-lg hover:shadow-primary/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<History size={18} />
|
||||
Load History
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* History list */}
|
||||
{company && historyQuery.isLoading && (
|
||||
<div className="text-text-secondary animate-pulse">Loading analysis history...</div>
|
||||
)}
|
||||
|
||||
{company && historyQuery.isError && (
|
||||
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||
<AlertCircle size={18} />
|
||||
<span>Failed to load history. Check the company name and try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{company && history.length === 0 && !historyQuery.isLoading && (
|
||||
<div className="text-text-secondary">No analysis history found for "{company}".</div>
|
||||
)}
|
||||
|
||||
{history.length >= 2 && (
|
||||
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2 mb-4">
|
||||
Select Two Runs to Compare
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{history.map((item, idx) => {
|
||||
const next = history[idx + 1];
|
||||
if (!next) return null;
|
||||
const isSelected =
|
||||
fromId === String(next.id) && toId === String(item.id);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectRuns(next.id, item.id)}
|
||||
className={`w-full text-left flex items-center gap-3 px-4 py-3 rounded-xl border transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-primary/15 hover:border-primary/40 hover:bg-primary/5'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-text-secondary font-mono">
|
||||
#{next.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{new Date(next.timestamp).toLocaleString()}
|
||||
</span>
|
||||
<ArrowRight size={14} className="text-primary" />
|
||||
<span className="text-sm text-text-secondary font-mono">
|
||||
#{item.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{new Date(item.timestamp).toLocaleString()}
|
||||
</span>
|
||||
{item.model && (
|
||||
<span className="ml-auto text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">
|
||||
{item.model}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff Results */}
|
||||
{diffQuery.isLoading && (
|
||||
<div className="text-text-secondary animate-pulse">Computing diff...</div>
|
||||
)}
|
||||
|
||||
{diffQuery.isError && (
|
||||
<div className="flex items-center gap-2 bg-error/10 border border-error/20 text-error rounded-xl px-4 py-3">
|
||||
<AlertCircle size={18} />
|
||||
<span>Failed to compute diff. One or both analysis IDs may not exist.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffQuery.data && <DiffView diff={diffQuery.data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffView({ diff }: { diff: AnalysisDiff }) {
|
||||
return (
|
||||
<div className="bg-bg-card/60 backdrop-blur-lg border border-primary/15 rounded-2xl p-6 space-y-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary border-b-2 border-primary/30 pb-2">
|
||||
Diff: #{diff.from_id} → #{diff.to_id}
|
||||
</h3>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-primary/5 border border-primary/20 rounded-xl p-4">
|
||||
<div className="text-sm font-medium text-text-primary">{diff.summary}</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
|
||||
<span>{new Date(diff.from_timestamp).toLocaleString()}</span>
|
||||
<ArrowRight size={12} />
|
||||
<span>{new Date(diff.to_timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Patent count delta */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-text-secondary">Patent mention delta:</span>
|
||||
<span
|
||||
className={`text-lg font-bold ${
|
||||
diff.patent_count_delta > 0
|
||||
? 'text-success'
|
||||
: diff.patent_count_delta < 0
|
||||
? 'text-error'
|
||||
: 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{diff.patent_count_delta > 0 ? '+' : ''}
|
||||
{diff.patent_count_delta}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Added patents */}
|
||||
{diff.added_patents.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-success flex items-center gap-1 mb-2">
|
||||
<Plus size={14} />
|
||||
New Patents ({diff.added_patents.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{diff.added_patents.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-xs bg-success/10 border border-success/20 text-success px-2 py-1 rounded font-mono"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Removed patents */}
|
||||
{diff.removed_patents.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-error flex items-center gap-1 mb-2">
|
||||
<Minus size={14} />
|
||||
Removed Patents ({diff.removed_patents.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{diff.removed_patents.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-xs bg-error/10 border border-error/20 text-error px-2 py-1 rounded font-mono"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changed fields */}
|
||||
{Object.keys(diff.changed_fields).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-text-primary mb-2">Changed Fields</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(diff.changed_fields).map(([field, vals]) => (
|
||||
<div key={field} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-text-secondary font-mono">{field}:</span>
|
||||
<span className="text-error line-through">{vals.from || 'null'}</span>
|
||||
<ArrowRight size={12} className="text-text-secondary" />
|
||||
<span className="text-success">{vals.to || 'null'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for historical analysis diff endpoint."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from SPARC.api import AnalysisDiffResponse, _compute_analysis_diff, _extract_patent_ids, app
|
||||
from SPARC.auth import UserResponse, get_current_user
|
||||
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
def _mock_user():
|
||||
"""Return a fake authenticated user for dependency override."""
|
||||
return UserResponse(
|
||||
id=1,
|
||||
email="test@example.com",
|
||||
role="user",
|
||||
created_at=datetime(2025, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client():
|
||||
"""TestClient with auth dependency overridden."""
|
||||
app.dependency_overrides[get_current_user] = _mock_user
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------- unit tests for helpers ----------
|
||||
|
||||
class TestExtractPatentIds:
|
||||
"""Test _extract_patent_ids utility."""
|
||||
|
||||
def test_extracts_standard_ids(self):
|
||||
text = "Patent US-12345678-B2 covers the device. Also see US-9876543-A1."
|
||||
ids = _extract_patent_ids(text)
|
||||
assert "US-12345678-B2" in ids
|
||||
assert "US-9876543-A1" in ids
|
||||
|
||||
def test_empty_text(self):
|
||||
assert _extract_patent_ids("") == set()
|
||||
assert _extract_patent_ids(None) == set() # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestComputeAnalysisDiff:
|
||||
"""Test _compute_analysis_diff logic."""
|
||||
|
||||
def test_identical_analyses(self):
|
||||
rec = {
|
||||
"id": 1,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "Patent US-12345678-B2 is notable.",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
}
|
||||
diff = _compute_analysis_diff(rec, dict(rec, id=2, timestamp=datetime(2025, 5, 2)))
|
||||
assert diff.patent_count_delta == 0
|
||||
assert diff.added_patents == []
|
||||
assert diff.removed_patents == []
|
||||
|
||||
def test_added_and_removed_patents(self):
|
||||
from_rec = {
|
||||
"id": 1,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "Patent US-12345678-B2 and US-11111111-A1.",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
}
|
||||
to_rec = {
|
||||
"id": 2,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "Patent US-12345678-B2 and US-99999999-B1.",
|
||||
"timestamp": datetime(2025, 5, 2),
|
||||
}
|
||||
diff = _compute_analysis_diff(from_rec, to_rec)
|
||||
assert "US-99999999-B1" in diff.added_patents
|
||||
assert "US-11111111-A1" in diff.removed_patents
|
||||
assert diff.patent_count_delta == 0 # one added, one removed
|
||||
|
||||
def test_model_change_detected(self):
|
||||
from_rec = {
|
||||
"id": 1,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
}
|
||||
to_rec = {
|
||||
"id": 2,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "anthropic/claude-3.5-sonnet",
|
||||
"response": "",
|
||||
"timestamp": datetime(2025, 5, 2),
|
||||
}
|
||||
diff = _compute_analysis_diff(from_rec, to_rec)
|
||||
assert "model" in diff.changed_fields
|
||||
assert diff.changed_fields["model"]["from"] == "openai/gpt-4o"
|
||||
assert diff.changed_fields["model"]["to"] == "anthropic/claude-3.5-sonnet"
|
||||
|
||||
|
||||
# ---------- API endpoint tests ----------
|
||||
|
||||
class TestDiffEndpoint:
|
||||
"""Test GET /analyze/{company_name}/diff."""
|
||||
|
||||
@patch("SPARC.api._get_job_db")
|
||||
def test_happy_path(self, mock_get_db, auth_client):
|
||||
"""Diff returns structured response when both IDs exist."""
|
||||
db = MagicMock()
|
||||
mock_get_db.return_value = db
|
||||
|
||||
from_rec = {
|
||||
"id": 10,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "Patent US-12345678-B2 found.",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
}
|
||||
to_rec = {
|
||||
"id": 20,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "Patent US-12345678-B2 and US-99999999-A1 found.",
|
||||
"timestamp": datetime(2025, 5, 10),
|
||||
}
|
||||
db.get_analysis_by_id.side_effect = lambda aid: from_rec if aid == 10 else to_rec
|
||||
|
||||
response = auth_client.get("/analyze/nvidia/diff?from=10&to=20")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["company_name"] == "nvidia"
|
||||
assert data["from_id"] == 10
|
||||
assert data["to_id"] == 20
|
||||
assert "US-99999999-A1" in data["added_patents"]
|
||||
assert data["patent_count_delta"] == 1
|
||||
|
||||
@patch("SPARC.api._get_job_db")
|
||||
def test_from_id_not_found(self, mock_get_db, auth_client):
|
||||
"""Returns 404 when 'from' analysis ID doesn't exist."""
|
||||
db = MagicMock()
|
||||
mock_get_db.return_value = db
|
||||
db.get_analysis_by_id.return_value = None
|
||||
|
||||
response = auth_client.get("/analyze/nvidia/diff?from=999&to=1000")
|
||||
assert response.status_code == 404
|
||||
assert "999" in response.json()["detail"]
|
||||
|
||||
@patch("SPARC.api._get_job_db")
|
||||
def test_to_id_not_found(self, mock_get_db, auth_client):
|
||||
"""Returns 404 when 'to' analysis ID doesn't exist."""
|
||||
db = MagicMock()
|
||||
mock_get_db.return_value = db
|
||||
|
||||
from_rec = {
|
||||
"id": 10,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
}
|
||||
db.get_analysis_by_id.side_effect = lambda aid: from_rec if aid == 10 else None
|
||||
|
||||
response = auth_client.get("/analyze/nvidia/diff?from=10&to=999")
|
||||
assert response.status_code == 404
|
||||
assert "999" in response.json()["detail"]
|
||||
|
||||
@patch("SPARC.api._get_job_db")
|
||||
def test_company_mismatch(self, mock_get_db, auth_client):
|
||||
"""Returns 404 when analysis belongs to a different company."""
|
||||
db = MagicMock()
|
||||
mock_get_db.return_value = db
|
||||
|
||||
rec = {
|
||||
"id": 10,
|
||||
"company_name": "intel",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
}
|
||||
db.get_analysis_by_id.return_value = rec
|
||||
|
||||
response = auth_client.get("/analyze/nvidia/diff?from=10&to=20")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestHistoryEndpoint:
|
||||
"""Test GET /analyze/{company_name}/history."""
|
||||
|
||||
@patch("SPARC.api._get_job_db")
|
||||
def test_returns_history_list(self, mock_get_db, auth_client):
|
||||
"""History endpoint returns list of past analysis runs."""
|
||||
db = MagicMock()
|
||||
mock_get_db.return_value = db
|
||||
db.list_company_analyses.return_value = [
|
||||
{
|
||||
"id": 20,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "...",
|
||||
"timestamp": datetime(2025, 5, 10),
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"company_name": "nvidia",
|
||||
"analysis_type": "portfolio",
|
||||
"model": "openai/gpt-4o",
|
||||
"response": "...",
|
||||
"timestamp": datetime(2025, 5, 1),
|
||||
},
|
||||
]
|
||||
|
||||
response = auth_client.get("/analyze/nvidia/history")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == 20
|
||||
assert data[1]["id"] == 10
|
||||
|
||||
@patch("SPARC.api._get_job_db")
|
||||
def test_empty_history(self, mock_get_db, auth_client):
|
||||
"""History endpoint returns empty list when no analyses exist."""
|
||||
db = MagicMock()
|
||||
mock_get_db.return_value = db
|
||||
db.list_company_analyses.return_value = []
|
||||
|
||||
response = auth_client.get("/analyze/nvidia/history")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
Reference in New Issue
Block a user