diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 2f4fc35..bd6c2ea 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -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(null); @@ -157,6 +159,13 @@ export function Analysis() { Export PDF +
diff --git a/frontend/src/pages/HistoryDiff.tsx b/frontend/src/pages/HistoryDiff.tsx new file mode 100644 index 0000000..9019d1d --- /dev/null +++ b/frontend/src/pages/HistoryDiff.tsx @@ -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({ + 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 ( +
+ {/* Header */} +
+

+ Historical Analysis Diff +

+

+ Compare analysis runs for the same company to see what changed between them. +

+
+ + {/* Company Search */} +
+
+ + 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" + /> +
+ +
+ + {/* History list */} + {company && historyQuery.isLoading && ( +
Loading analysis history...
+ )} + + {company && historyQuery.isError && ( +
+ + Failed to load history. Check the company name and try again. +
+ )} + + {company && history.length === 0 && !historyQuery.isLoading && ( +
No analysis history found for "{company}".
+ )} + + {history.length >= 2 && ( +
+

+ Select Two Runs to Compare +

+
+ {history.map((item, idx) => { + const next = history[idx + 1]; + if (!next) return null; + const isSelected = + fromId === String(next.id) && toId === String(item.id); + return ( + + ); + })} +
+
+ )} + + {/* Diff Results */} + {diffQuery.isLoading && ( +
Computing diff...
+ )} + + {diffQuery.isError && ( +
+ + Failed to compute diff. One or both analysis IDs may not exist. +
+ )} + + {diffQuery.data && } +
+ ); +} + +function DiffView({ diff }: { diff: AnalysisDiff }) { + return ( +
+

+ Diff: #{diff.from_id} → #{diff.to_id} +

+ + {/* Summary */} +
+
{diff.summary}
+
+ {new Date(diff.from_timestamp).toLocaleString()} + + {new Date(diff.to_timestamp).toLocaleString()} +
+
+ + {/* Patent count delta */} +
+ Patent mention delta: + 0 + ? 'text-success' + : diff.patent_count_delta < 0 + ? 'text-error' + : 'text-text-secondary' + }`} + > + {diff.patent_count_delta > 0 ? '+' : ''} + {diff.patent_count_delta} + +
+ + {/* Added patents */} + {diff.added_patents.length > 0 && ( +
+

+ + New Patents ({diff.added_patents.length}) +

+
+ {diff.added_patents.map((p) => ( + + {p} + + ))} +
+
+ )} + + {/* Removed patents */} + {diff.removed_patents.length > 0 && ( +
+

+ + Removed Patents ({diff.removed_patents.length}) +

+
+ {diff.removed_patents.map((p) => ( + + {p} + + ))} +
+
+ )} + + {/* Changed fields */} + {Object.keys(diff.changed_fields).length > 0 && ( +
+

Changed Fields

+
+ {Object.entries(diff.changed_fields).map(([field, vals]) => ( +
+ {field}: + {vals.from || 'null'} + + {vals.to || 'null'} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/tests/test_analysis_diff.py b/tests/test_analysis_diff.py new file mode 100644 index 0000000..c867b55 --- /dev/null +++ b/tests/test_analysis_diff.py @@ -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() == []