diff --git a/backend/app/config.py b/backend/app/config.py index 01b9219..ed69882 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): minio_secure: bool = False # AI/ML - Provider Selection - llm_provider: str = "anthropic" # "anthropic" or "gemini" + llm_provider: str = "anthropic" # "anthropic", "gemini", or "deepseek" embedding_provider: str = "openai" # "openai" or "gemini" # Anthropic (Claude) - Using Claude Opus 4.5 (Latest as of Jan 2026) @@ -58,6 +58,11 @@ class Settings(BaseSettings): gemini_model: str = "gemini-3.0-flash" gemini_embedding_model: str = "text-embedding-005" + # DeepSeek - OpenAI-compatible API + deepseek_api_key: str | None = None + deepseek_model: str = "deepseek-chat" + deepseek_base_url: str = "https://api.deepseek.com" + # Document Processing azure_doc_intel_endpoint: str | None = None azure_doc_intel_key: str | None = None diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py index 3b31652..4c9eb36 100644 --- a/backend/app/services/llm.py +++ b/backend/app/services/llm.py @@ -725,11 +725,224 @@ async def answer_query( return self._parse_json_response(raw_text) -LLMService = ClaudeService | GeminiService +class DeepSeekService(BaseLLMService): + """Service for interacting with DeepSeek API for document analysis. + + DeepSeek exposes an OpenAI-compatible API, so this service uses the + openai Python package pointed at DeepSeek's base URL. + + Supported models: + - deepseek-chat (DeepSeek-V3, general purpose, default) + - deepseek-reasoner (DeepSeek-R1, chain-of-thought reasoning) + """ + + def __init__( + self, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + ): + """Initialize the DeepSeek service. + + Args: + api_key: DeepSeek API key (uses settings if not provided) + model: Model name (uses settings if not provided) + base_url: API base URL (uses settings if not provided) + """ + settings = get_settings() + self.api_key = api_key or settings.deepseek_api_key + self._model = model or settings.deepseek_model + self._base_url = base_url or settings.deepseek_base_url + self._client = None + + if self.api_key: + try: + from openai import AsyncOpenAI + + self._client = AsyncOpenAI( + api_key=self.api_key, + base_url=self._base_url, + ) + except ImportError: + pass + + @property + def is_configured(self) -> bool: + """Check if the service has valid API credentials.""" + return self._client is not None + + @property + def model(self) -> str: + """Return the model name being used.""" + return self._model + + async def _generate( + self, + prompt: str, + system: str | None = None, + max_tokens: int = 4096, + ) -> tuple[str, int, int]: + """Generate content using the DeepSeek API. + + Args: + prompt: The user prompt to send + system: Optional system prompt + max_tokens: Maximum tokens in response + + Returns: + Tuple of (response_text, input_tokens, output_tokens) + """ + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + response = await self._client.chat.completions.create( + model=self._model, + messages=messages, + max_tokens=max_tokens, + temperature=0.1, + ) + + content = response.choices[0].message.content or "" + input_tokens = response.usage.prompt_tokens if response.usage else 0 + output_tokens = response.usage.completion_tokens if response.usage else 0 + return content, input_tokens, output_tokens + + async def analyze_document( + self, + chunks: list[dict], + framework: str, + document_type: str, + max_tokens: int = 4096, + ) -> AnalysisResult: + """Analyze document chunks against a compliance framework.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + context = self._build_context(chunks) + prompt = self._build_analysis_prompt(context, framework, document_type) + raw_text, input_tokens, output_tokens = await self._generate( + prompt, self.ANALYSIS_SYSTEM_PROMPT, max_tokens + ) + findings = self._parse_findings(raw_text) + + return AnalysisResult( + findings=findings, + summary=self._extract_summary(findings), + raw_response=raw_text, + model=self._model, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + async def analyze_document_with_prompt( + self, + prompt: str, + framework: str, + document_type: str | None = None, + max_tokens: int = 8192, + ) -> AnalysisResult: + """Analyze document using a pre-built prompt.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + from app.prompts.compliance_analysis import COMPLIANCE_ANALYSIS_SYSTEM_PROMPT + + raw_text, input_tokens, output_tokens = await self._generate( + prompt, COMPLIANCE_ANALYSIS_SYSTEM_PROMPT, max_tokens + ) + parsed = self._parse_enhanced_response(raw_text) + findings = parsed.get("findings", []) + summary = parsed.get("overall_assessment", {}).get( + "summary", self._extract_summary(findings) + ) + + return AnalysisResult( + findings=findings, + summary=summary, + raw_response=raw_text, + model=self._model, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + async def generate_finding_details( + self, + chunk_content: str, + framework_control: str, + initial_concern: str, + max_tokens: int = 2048, + ) -> dict: + """Generate detailed finding information for a specific concern.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + prompt = f"""Analyze this document excerpt and provide a detailed finding assessment. + +Document Excerpt: +{chunk_content} + +Framework Control: {framework_control} +Initial Concern: {initial_concern} + +Provide a detailed assessment in JSON format: +{{ + "title": "Brief finding title", + "severity": "critical|high|medium|low|info", + "description": "Detailed description of the finding", + "evidence": "Specific quote from the document", + "impact": "Business impact of this finding", + "remediation": "Recommended remediation steps", + "confidence": 0.0-1.0 +}}""" + + raw_text, _, _ = await self._generate(prompt, max_tokens=max_tokens) + return self._parse_json_response(raw_text) + + async def answer_query( + self, + query: str, + context_chunks: list[dict], + max_tokens: int = 2048, + ) -> dict: + """Answer a natural language query about the documents.""" + if not self.is_configured: + raise ValueError("DeepSeek API key not configured") + + context = self._build_context(context_chunks) + + prompt = f"""Based on the following document excerpts, answer the user's question. + +Document Context: +{context} + +User Question: {query} + +Provide your answer in JSON format: +{{ + "answer": "Your detailed answer", + "confidence": 0.0-1.0, + "citations": [ + {{ + "chunk_index": 0, + "excerpt": "relevant quote", + "relevance": "why this is relevant" + }} + ], + "limitations": "Any limitations or caveats" +}}""" + + raw_text, _, _ = await self._generate(prompt, max_tokens=max_tokens) + return self._parse_json_response(raw_text) + + +LLMService = ClaudeService | GeminiService | DeepSeekService _claude_service: ClaudeService | None = None _gemini_service: GeminiService | None = None +_deepseek_service: DeepSeekService | None = None _llm_service: LLMService | None = None @@ -747,6 +960,13 @@ def get_gemini_service() -> GeminiService: return _gemini_service +def get_deepseek_service() -> DeepSeekService: + global _deepseek_service + if _deepseek_service is None: + _deepseek_service = DeepSeekService() + return _deepseek_service + + def create_llm_service(provider: str | None = None) -> LLMService: settings = get_settings() provider = provider or settings.llm_provider @@ -754,6 +974,8 @@ def create_llm_service(provider: str | None = None) -> LLMService: return ClaudeService() elif provider == "gemini": return GeminiService() + elif provider == "deepseek": + return DeepSeekService() raise ValueError(f"Unsupported LLM provider: {provider}") diff --git a/backend/app/services/risk_scoring.py b/backend/app/services/risk_scoring.py index f8fe23d..45f1534 100644 --- a/backend/app/services/risk_scoring.py +++ b/backend/app/services/risk_scoring.py @@ -239,11 +239,12 @@ def _calculate_document_freshness_score(documents: list[Document]) -> tuple[floa oldest_age_days = 0 for doc in processed_docs: - if doc.processed_at: - age = (now - doc.processed_at).days - oldest_age_days = max(oldest_age_days, age) - elif doc.created_at: - age = (now - doc.created_at).days + ts = doc.processed_at or doc.created_at + if ts: + # SQLite stores naive datetimes - treat as UTC for safe comparison + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + age = (now - ts).days oldest_age_days = max(oldest_age_days, age) # Score based on age thresholds diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1ee905..aca1790 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -174,7 +174,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -543,7 +542,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -587,7 +585,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2483,6 +2480,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2496,6 +2494,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2510,7 +2509,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2586,7 +2586,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2722,7 +2723,6 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2740,7 +2740,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2752,7 +2751,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2806,7 +2804,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3100,7 +3097,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3557,7 +3553,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4283,7 +4278,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4571,7 +4567,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6146,7 +6141,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6176,7 +6170,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -6409,6 +6402,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7072,7 +7066,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7306,7 +7299,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7319,7 +7311,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7333,7 +7324,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8311,7 +8301,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8432,7 +8421,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9109,7 +9097,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9278,7 +9265,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d28b327..1369c42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { MainLayout, AuthLayout } from '@/components/layout'; import { Landing, Login, Register, Dashboard, Vendors, VendorDetail, Documents, Query, Analysis, Remediation, Monitoring, Agents, Risk, Analytics, Competition, Playbooks, ApprovedVendors, BPO, Integrations } from '@/pages'; @@ -33,11 +33,22 @@ function PublicRoute({ children }: { children: React.ReactNode }) { function App() { const { init } = useAuthStore(); + const [ready, setReady] = useState(false); useEffect(() => { + // Run init synchronously then allow routes to render. + // This prevents a race where Zustand's persisted isAuthenticated: true + // is visible to ProtectedRoute/PublicRoute before init() has a chance + // to reset it when tokens are missing (which causes the redirect loop). init(); + setReady(true); }, [init]); + if (!ready) { + // Blank frame while auth state is being resolved - avoids redirect flicker + return null; + } + return (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d98e5f6..07c16d8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -48,6 +48,9 @@ apiClient.interceptors.response.use( if (error.response?.status === 401) { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); + // Clear the Zustand persist store so isAuthenticated resets to false, + // preventing an infinite redirect loop between /login and /dashboard + localStorage.removeItem('auth-storage'); // Only redirect if not already on login page if (window.location.pathname !== '/login') { diff --git a/frontend/src/lib/findings.ts b/frontend/src/lib/findings.ts new file mode 100644 index 0000000..e93d724 --- /dev/null +++ b/frontend/src/lib/findings.ts @@ -0,0 +1,58 @@ +/** + * Utility for normalizing backend finding responses. + * + * The backend returns snake_case fields (confidence_score, framework_control, etc.) + * while the frontend Finding type uses camelCase. This module provides a single + * normalization function used wherever findings are fetched from the API. + */ + +import type { Finding, FindingType, Severity } from '@/types/api'; + +/** + * Map a raw backend FindingResponse (snake_case) to the camelCase Finding shape + * expected by FindingCard, FindingsList, and FindingsSummary. + * + * Backend fields that differ from the frontend type: + * analysis_run_id -> runId + * document_id -> documentId + * framework_control -> controlId (first segment) + controlName (remainder) + * finding_type -> findingType (may be absent; defaults to 'gap') + * confidence_score -> confidenceScore + * remediation -> recommendation + * page_number -> pageReferences (single number -> single-element array) + * created_at -> createdAt + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function normalizeFinding(raw: any): Finding { + // framework_control may look like "CC6.1 - Logical Access Controls" + // Split on first " - " to get controlId and controlName separately. + const frameworkControl: string = raw.framework_control ?? raw.controlId ?? ''; + const separatorIdx = frameworkControl.indexOf(' - '); + const controlId = + separatorIdx !== -1 ? frameworkControl.slice(0, separatorIdx) : frameworkControl; + const controlName = + separatorIdx !== -1 ? frameworkControl.slice(separatorIdx + 3) : (raw.controlName ?? ''); + + return { + id: raw.id ?? '', + runId: raw.analysis_run_id ?? raw.runId ?? '', + documentId: raw.document_id ?? raw.documentId ?? '', + controlId, + controlName, + framework: raw.framework ?? '', + findingType: (raw.finding_type ?? raw.findingType ?? 'gap') as FindingType, + severity: (raw.severity ?? 'info') as Severity, + title: raw.title ?? '', + description: raw.description ?? '', + evidence: raw.evidence ?? undefined, + recommendation: raw.remediation ?? raw.recommendation ?? undefined, + pageReferences: + raw.page_number != null + ? [raw.page_number as number] + : (raw.pageReferences ?? []), + citations: raw.citations ?? [], + confidenceScore: raw.confidence_score ?? raw.confidenceScore ?? 0, + status: raw.status ?? 'open', + createdAt: raw.created_at ?? raw.createdAt ?? new Date().toISOString(), + }; +} diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 5fbd3cd..f506bb2 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useState, useEffect, Component, type ReactNode } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Play, FileText, AlertTriangle, Loader2, RefreshCw, Download, FileSpreadsheet, Shield } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; @@ -6,6 +7,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/u import { CardSkeleton, SkeletonLoader } from '@/components/ui/TypingIndicator'; import { FindingsSummary, FindingsList } from '@/components/findings'; import apiClient, { getApiErrorMessage } from '@/lib/api'; +import { normalizeFinding } from '@/lib/findings'; import type { Document, Finding, AnalysisRun, Severity } from '@/types/api'; // Animation variants @@ -51,14 +53,51 @@ const FRAMEWORKS = [ type FrameworkType = typeof FRAMEWORKS[number]['value']; +/** + * Error boundary to catch render crashes in findings components + * and show a readable error instead of a blank screen. + */ +class FindingsErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean; message: string } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false, message: '' }; + } + static getDerivedStateFromError(error: Error) { + return { hasError: true, message: error.message }; + } + render() { + if (this.state.hasError) { + return ( +
+

Failed to render findings

+

{this.state.message}

+
+ ); + } + return this.props.children; + } +} + /** * Analysis Page - Document analysis dashboard with findings display. * Allows users to select a document, run analysis, and view findings. */ export function Analysis() { const queryClient = useQueryClient(); - const [selectedDocumentId, setSelectedDocumentId] = useState(''); + const [searchParams] = useSearchParams(); + + // Allow vendor/document pages to pre-select a document via ?document_id=... + const documentIdParam = searchParams.get('document_id') ?? ''; + const [selectedDocumentId, setSelectedDocumentId] = useState(documentIdParam); const [selectedFramework, setSelectedFramework] = useState('soc2_tsc'); + + // Keep the selection in sync if the URL param changes (e.g. browser back/forward) + useEffect(() => { + if (documentIdParam) setSelectedDocumentId(documentIdParam); + }, [documentIdParam]); const [analysisError, setAnalysisError] = useState(null); const [isExporting, setIsExporting] = useState<'csv' | 'pdf' | null>(null); @@ -140,7 +179,8 @@ export function Analysis() { enabled: !!selectedDocumentId, }); - const findings: Finding[] = findingsResponse?.data || []; + // Normalize snake_case backend response to camelCase Finding shape + const findings: Finding[] = (findingsResponse?.data || []).map(normalizeFinding); const analysisRuns: AnalysisRun[] = runsResponse?.data || []; const latestRun = analysisRuns[0]; @@ -148,10 +188,11 @@ export function Analysis() { const runAnalysisMutation = useMutation({ mutationFn: async ({ documentId, framework }: { documentId: string; framework: FrameworkType }) => { // POST /analysis/documents/{document_id}/analyze with { framework, chunk_limit } + // LLM analysis can take 60-180s depending on document size and provider, so use a long timeout const response = await apiClient.post(`/analysis/documents/${documentId}/analyze`, { framework, chunk_limit: 50, - }); + }, { timeout: 180000 }); return response.data; }, onSuccess: () => { @@ -452,6 +493,7 @@ export function Analysis() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > + {findings.length > 0 ? (
{/* Summary Statistics */} @@ -528,6 +570,7 @@ export function Analysis() { )} + )} diff --git a/frontend/src/pages/Documents.tsx b/frontend/src/pages/Documents.tsx index 205a5a9..50b75f2 100644 --- a/frontend/src/pages/Documents.tsx +++ b/frontend/src/pages/Documents.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Upload, Search, FileText, MoreVertical, CheckCircle, Clock, AlertCircle, Loader2, Download, Trash2 } from 'lucide-react'; import { @@ -51,6 +52,8 @@ interface BackendDocument { export function Documents() { const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const vendorIdFromUrl = searchParams.get('vendor_id'); const [searchQuery, setSearchQuery] = useState(''); const [isDragging, setIsDragging] = useState(false); const [uploadError, setUploadError] = useState(null); @@ -87,11 +90,21 @@ export function Documents() { const formData = new FormData(); formData.append('file', file); - const response = await apiClient.post('/documents', formData); + // If arriving from a vendor detail page, associate the document with that vendor + const url = vendorIdFromUrl + ? `/documents?vendor_id=${encodeURIComponent(vendorIdFromUrl)}` + : '/documents'; + + // Document upload triggers text extraction and chunking which can be slow for large files + const response = await apiClient.post(url, formData, { timeout: 120000 }); return response.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['documents'] }); + // Also invalidate vendor-documents so the vendor detail page refreshes + if (vendorIdFromUrl) { + queryClient.invalidateQueries({ queryKey: ['vendor-documents', vendorIdFromUrl] }); + } setUploadError(null); }, onError: (error) => { @@ -233,6 +246,12 @@ export function Documents() {

DOCUMENTVAULT

+ {vendorIdFromUrl && ( +
+ + UPLOADING FOR VENDOR — documents will be linked automatically +
+ )}
Documents ({documents.length}) - @@ -568,7 +570,17 @@ export function VendorDetail() { diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 5a1482d..7541466 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -177,12 +177,21 @@ export const useAuthStore = create()( const refreshToken = localStorage.getItem('refresh_token'); if (accessToken && refreshToken) { - // Tokens exist, consider authenticated (will be validated on first API call) + // Tokens exist - consider authenticated (validated on first API call) set({ accessToken, refreshToken, isAuthenticated: true, }); + } else { + // No tokens - explicitly reset auth state to prevent stale persisted + // isAuthenticated: true from causing an infinite /login <-> /dashboard loop + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }); } }, }),