vn6295337 Claude Opus 4.5 commited on
Commit
3e0da39
·
1 Parent(s): 522e6f7

Rename financials to fundamentals for schema consistency

Browse files

- Update analyzer.py to use fundamentals_all instead of financials_all
- Align with Researcher-Agent schema normalization changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

data/cache/us_stocks.json CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/src/App.tsx CHANGED
@@ -18,6 +18,7 @@ import {
18
  MCPStatus,
19
  LLMStatus,
20
  MetricEntry,
 
21
  } from "@/lib/api"
22
  import { AnalysisResponse } from "@/lib/types"
23
  import {
@@ -39,6 +40,11 @@ import {
39
  Pause,
40
  X,
41
  Loader2,
 
 
 
 
 
42
  } from "lucide-react"
43
 
44
  // Import new components
@@ -107,6 +113,14 @@ const Index = () => {
107
  const [abortReason, setAbortReason] = useState<string>('')
108
  const [userEvents, setUserEvents] = useState<Array<{timestamp: string; message: string}>>([])
109
 
 
 
 
 
 
 
 
 
110
  const [copied, setCopied] = useState(false)
111
  const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
112
 
@@ -184,27 +198,14 @@ const Index = () => {
184
  clearInterval(pollingRef.current!)
185
  pollingRef.current = null
186
 
187
- // Check if this was a cache hit
188
  if (status.data_source === 'cache') {
189
- // Cache hit flow - only animate cache and output
190
  setCacheHit(true)
191
  setCurrentStep('cache')
192
- setCompletedSteps(['input'])
193
-
194
- setTimeout(() => {
195
- setCompletedSteps(['input', 'cache'])
196
- setCurrentStep('output')
197
- }, 800)
198
-
199
- setTimeout(async () => {
200
- setCompletedSteps(['input', 'cache', 'output'])
201
- setCurrentStep('completed')
202
- const result = await getWorkflowResult(workflowIdToUse)
203
- setAnalysisResult(result)
204
- setIsLoading(false)
205
- setShowResults(true)
206
- setMainTab("results")
207
- }, 1600)
208
  return
209
  }
210
 
@@ -284,6 +285,64 @@ const Index = () => {
284
  setLlmStatus(defaultLLMStatus)
285
  }
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  // Force dark mode
288
  useEffect(() => {
289
  document.documentElement.classList.add("dark")
@@ -367,7 +426,9 @@ Generated by Instant SWOT Agent`
367
  const { workflow_id } = await startAnalysis(
368
  selectedStock.name,
369
  selectedStock.symbol,
370
- 'Competitive Position'
 
 
371
  )
372
  setWorkflowId(workflow_id)
373
  setCompletedSteps(['input'])
@@ -509,6 +570,88 @@ Generated by Instant SWOT Agent`
509
  </div>
510
  </header>
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  <main className="container mx-auto px-4 sm:px-6 pt-4 pb-6 space-y-6 overflow-visible">
513
 
514
  {/* Process Flow + Metrics Panel */}
 
18
  MCPStatus,
19
  LLMStatus,
20
  MetricEntry,
21
+ UserApiKeys,
22
  } from "@/lib/api"
23
  import { AnalysisResponse } from "@/lib/types"
24
  import {
 
40
  Pause,
41
  X,
42
  Loader2,
43
+ Settings,
44
+ Key,
45
+ ChevronDown,
46
+ ChevronUp,
47
+ Database,
48
  } from "lucide-react"
49
 
50
  // Import new components
 
113
  const [abortReason, setAbortReason] = useState<string>('')
114
  const [userEvents, setUserEvents] = useState<Array<{timestamp: string; message: string}>>([])
115
 
116
+ // User API keys (optional - for when server keys hit rate limits)
117
+ const [userApiKeys, setUserApiKeys] = useState<UserApiKeys>({})
118
+ const [showApiKeySettings, setShowApiKeySettings] = useState(false)
119
+
120
+ // Cache dialog state
121
+ const [showCacheDialog, setShowCacheDialog] = useState(false)
122
+ const [pendingCacheWorkflowId, setPendingCacheWorkflowId] = useState<string | null>(null)
123
+
124
  const [copied, setCopied] = useState(false)
125
  const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
126
 
 
198
  clearInterval(pollingRef.current!)
199
  pollingRef.current = null
200
 
201
+ // Check if this was a cache hit - show dialog to let user choose
202
  if (status.data_source === 'cache') {
 
203
  setCacheHit(true)
204
  setCurrentStep('cache')
205
+ setCompletedSteps(['input', 'cache'])
206
+ setPendingCacheWorkflowId(workflowIdToUse)
207
+ setShowCacheDialog(true)
208
+ // Don't auto-proceed - wait for user choice
 
 
 
 
 
 
 
 
 
 
 
 
209
  return
210
  }
211
 
 
285
  setLlmStatus(defaultLLMStatus)
286
  }
287
 
288
+ // Cache dialog: Use cached data
289
+ const handleUseCached = async () => {
290
+ if (!pendingCacheWorkflowId) return
291
+ setShowCacheDialog(false)
292
+ addUserEvent('Using cached analysis')
293
+
294
+ // Animate cache → output transition
295
+ setCurrentStep('output')
296
+ setTimeout(async () => {
297
+ setCompletedSteps(['input', 'cache', 'output'])
298
+ setCurrentStep('completed')
299
+ const result = await getWorkflowResult(pendingCacheWorkflowId)
300
+ setAnalysisResult(result)
301
+ setIsLoading(false)
302
+ setShowResults(true)
303
+ setMainTab("results")
304
+ setPendingCacheWorkflowId(null)
305
+ }, 800)
306
+ }
307
+
308
+ // Cache dialog: Run fresh analysis
309
+ const handleRunFresh = async () => {
310
+ if (!selectedStock) return
311
+ setShowCacheDialog(false)
312
+ setPendingCacheWorkflowId(null)
313
+ addUserEvent('Running fresh analysis (cache bypassed)')
314
+
315
+ // Reset state for fresh run
316
+ setCurrentStep('input')
317
+ setCompletedSteps([])
318
+ setMcpStatus(defaultMCPStatus)
319
+ setLlmStatus(defaultLLMStatus)
320
+ setActivityLog([])
321
+ setMetrics([])
322
+ setRevisionCount(0)
323
+ setScore(0)
324
+ setCacheHit(false)
325
+ setAnalysisResult(null)
326
+
327
+ try {
328
+ const { workflow_id } = await startAnalysis(
329
+ selectedStock.name,
330
+ selectedStock.symbol,
331
+ 'Competitive Position',
332
+ true, // skipCache = true
333
+ userApiKeys
334
+ )
335
+ setWorkflowId(workflow_id)
336
+ setCompletedSteps(['input'])
337
+ setCurrentStep('cache')
338
+ startPolling(workflow_id)
339
+ } catch (error) {
340
+ console.error("Error starting fresh analysis:", error)
341
+ setIsLoading(false)
342
+ setHasError(true)
343
+ }
344
+ }
345
+
346
  // Force dark mode
347
  useEffect(() => {
348
  document.documentElement.classList.add("dark")
 
426
  const { workflow_id } = await startAnalysis(
427
  selectedStock.name,
428
  selectedStock.symbol,
429
+ 'Competitive Position',
430
+ false, // skipCache = false (check cache first)
431
+ userApiKeys
432
  )
433
  setWorkflowId(workflow_id)
434
  setCompletedSteps(['input'])
 
570
  </div>
571
  </header>
572
 
573
+ {/* Cache Hit Dialog */}
574
+ {showCacheDialog && (
575
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
576
+ <Card className="w-full max-w-md mx-4">
577
+ <CardHeader>
578
+ <CardTitle className="flex items-center gap-2">
579
+ <Database className="h-5 w-5 text-blue-500" />
580
+ Cached Analysis Found
581
+ </CardTitle>
582
+ </CardHeader>
583
+ <CardContent className="space-y-4">
584
+ <p className="text-sm text-muted-foreground">
585
+ A recent analysis for <span className="font-medium text-foreground">{selectedStock?.symbol}</span> was found in cache.
586
+ Would you like to use the cached result or run a fresh analysis?
587
+ </p>
588
+ <div className="flex gap-3">
589
+ <Button onClick={handleUseCached} className="flex-1 gap-2">
590
+ <Database className="h-4 w-4" />
591
+ Use Cached
592
+ </Button>
593
+ <Button onClick={handleRunFresh} variant="outline" className="flex-1 gap-2">
594
+ <RefreshCw className="h-4 w-4" />
595
+ Run Fresh
596
+ </Button>
597
+ </div>
598
+ </CardContent>
599
+ </Card>
600
+ </div>
601
+ )}
602
+
603
+ {/* API Key Settings (Expandable) */}
604
+ <div className="container mx-auto px-4 sm:px-6 pt-2">
605
+ <button
606
+ onClick={() => setShowApiKeySettings(!showApiKeySettings)}
607
+ className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
608
+ >
609
+ <Key className="h-3 w-3" />
610
+ <span>API Keys (Optional)</span>
611
+ {showApiKeySettings ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
612
+ </button>
613
+
614
+ {showApiKeySettings && (
615
+ <Card className="mt-2 p-3">
616
+ <p className="text-xs text-muted-foreground mb-3">
617
+ Provide your own API keys if server keys hit rate limits. Keys are not stored.
618
+ </p>
619
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
620
+ <div>
621
+ <label className="text-xs text-muted-foreground block mb-1">Groq</label>
622
+ <input
623
+ type="password"
624
+ placeholder="gsk_..."
625
+ value={userApiKeys.groq || ''}
626
+ onChange={(e) => setUserApiKeys(prev => ({ ...prev, groq: e.target.value || undefined }))}
627
+ className="w-full px-2 py-1 text-xs bg-background border rounded focus:outline-none focus:ring-1 focus:ring-ring"
628
+ />
629
+ </div>
630
+ <div>
631
+ <label className="text-xs text-muted-foreground block mb-1">Gemini</label>
632
+ <input
633
+ type="password"
634
+ placeholder="AI..."
635
+ value={userApiKeys.gemini || ''}
636
+ onChange={(e) => setUserApiKeys(prev => ({ ...prev, gemini: e.target.value || undefined }))}
637
+ className="w-full px-2 py-1 text-xs bg-background border rounded focus:outline-none focus:ring-1 focus:ring-ring"
638
+ />
639
+ </div>
640
+ <div>
641
+ <label className="text-xs text-muted-foreground block mb-1">OpenRouter</label>
642
+ <input
643
+ type="password"
644
+ placeholder="sk-or-..."
645
+ value={userApiKeys.openrouter || ''}
646
+ onChange={(e) => setUserApiKeys(prev => ({ ...prev, openrouter: e.target.value || undefined }))}
647
+ className="w-full px-2 py-1 text-xs bg-background border rounded focus:outline-none focus:ring-1 focus:ring-ring"
648
+ />
649
+ </div>
650
+ </div>
651
+ </Card>
652
+ )}
653
+ </div>
654
+
655
  <main className="container mx-auto px-4 sm:px-6 pt-4 pb-6 space-y-6 overflow-visible">
656
 
657
  {/* Process Flow + Metrics Panel */}
frontend/src/components/MCPDataPanel.tsx CHANGED
@@ -182,22 +182,24 @@ export function MCPDataPanel({ metrics, rawData, mcpStatus, companyName, ticker,
182
  const newsArticles = React.useMemo(() => {
183
  if (!rawData) return []
184
 
185
- // Try to get articles from metrics.news (field name is "results" from Tavily API)
186
- const newsData = rawData.metrics?.news || rawData.news
187
- if (newsData && typeof newsData === 'object') {
188
- // Check "results" first (Tavily API format), then "articles" as fallback
189
- const articles = (newsData as Record<string, unknown>).results || (newsData as Record<string, unknown>).articles
190
- if (Array.isArray(articles)) {
191
- return articles.slice(0, 4).map((a: Record<string, unknown>) => ({
192
- title: a.title as string || a.content as string || 'News article',
193
- url: a.url as string || a.link as string || '#'
 
 
194
  }))
195
  }
196
  }
197
 
198
- // Fallback: check if news is an array directly
199
- if (Array.isArray(rawData.news)) {
200
- return rawData.news.slice(0, 4)
201
  }
202
 
203
  return []
@@ -207,35 +209,40 @@ export function MCPDataPanel({ metrics, rawData, mcpStatus, companyName, ticker,
207
  const sentimentSources = React.useMemo(() => {
208
  if (!rawData) return []
209
 
210
- const sentimentData = rawData.metrics?.sentiment || rawData.sentiment
211
- if (!sentimentData || typeof sentimentData !== 'object') return []
 
 
 
212
 
213
  const sources: Array<{name: string, score: number | null, url?: string}> = []
214
- const metrics = (sentimentData as Record<string, unknown>).metrics as Record<string, unknown> | undefined
215
 
216
  // Finnhub sentiment
217
- if (metrics?.finnhub) {
218
- const finnhub = metrics.finnhub as Record<string, unknown>
 
219
  sources.push({
220
  name: 'Finnhub',
221
- score: finnhub.score as number || finnhub.sentiment_score as number || null,
222
  url: 'https://finnhub.io'
223
  })
224
  }
225
 
226
  // Reddit sentiment
227
- if (metrics?.reddit) {
228
- const reddit = metrics.reddit as Record<string, unknown>
 
229
  sources.push({
230
  name: 'Reddit',
231
- score: reddit.score as number || null,
232
  url: 'https://reddit.com'
233
  })
234
  }
235
 
236
- // Composite score
237
- const composite = (sentimentData as Record<string, unknown>).composite_score as number | undefined
238
- if (composite !== undefined && sources.length === 0) {
239
  sources.push({ name: 'Composite', score: composite })
240
  }
241
 
 
182
  const newsArticles = React.useMemo(() => {
183
  if (!rawData) return []
184
 
185
+ // Navigate to metrics.news - the actual structure from API
186
+ const metricsObj = rawData.metrics as Record<string, unknown> | undefined
187
+ const newsData = metricsObj?.news as Record<string, unknown> | undefined
188
+
189
+ if (newsData) {
190
+ // Get results array (from Tavily/NYT/NewsAPI)
191
+ const results = newsData.results as Array<Record<string, unknown>> | undefined
192
+ if (results && Array.isArray(results) && results.length > 0) {
193
+ return results.slice(0, 5).map((a) => ({
194
+ title: String(a.title || a.content || 'News article'),
195
+ url: String(a.url || a.link || '#')
196
  }))
197
  }
198
  }
199
 
200
+ // Fallback: check rawData.news directly
201
+ if (rawData.news && Array.isArray(rawData.news)) {
202
+ return rawData.news.slice(0, 5)
203
  }
204
 
205
  return []
 
209
  const sentimentSources = React.useMemo(() => {
210
  if (!rawData) return []
211
 
212
+ // Navigate to metrics.sentiment
213
+ const metricsObj = rawData.metrics as Record<string, unknown> | undefined
214
+ const sentimentData = metricsObj?.sentiment as Record<string, unknown> | undefined
215
+
216
+ if (!sentimentData) return []
217
 
218
  const sources: Array<{name: string, score: number | null, url?: string}> = []
219
+ const sentMetrics = sentimentData.metrics as Record<string, unknown> | undefined
220
 
221
  // Finnhub sentiment
222
+ if (sentMetrics?.finnhub) {
223
+ const finnhub = sentMetrics.finnhub as Record<string, unknown>
224
+ const score = finnhub.score ?? finnhub.sentiment_score
225
  sources.push({
226
  name: 'Finnhub',
227
+ score: typeof score === 'number' ? score : null,
228
  url: 'https://finnhub.io'
229
  })
230
  }
231
 
232
  // Reddit sentiment
233
+ if (sentMetrics?.reddit) {
234
+ const reddit = sentMetrics.reddit as Record<string, unknown>
235
+ const score = reddit.score
236
  sources.push({
237
  name: 'Reddit',
238
+ score: typeof score === 'number' ? score : null,
239
  url: 'https://reddit.com'
240
  })
241
  }
242
 
243
+ // Composite score as fallback
244
+ const composite = sentimentData.composite_score
245
+ if (typeof composite === 'number' && sources.length === 0) {
246
  sources.push({ name: 'Composite', score: composite })
247
  }
248
 
frontend/src/lib/api.ts CHANGED
@@ -1,8 +1,8 @@
1
  import { AnalysisResponse } from './types'
2
 
3
  // In production (HF Spaces), API is served from same origin - use empty string
4
- // In development, use localhost:8002
5
- const API_BASE_URL = import.meta.env.VITE_API_URL ?? (import.meta.env.PROD ? '' : 'http://localhost:8002')
6
 
7
  // Stock search types
8
  export interface StockResult {
@@ -83,11 +83,20 @@ export async function searchStocks(query: string): Promise<StockSearchResponse>
83
  return response.json()
84
  }
85
 
 
 
 
 
 
 
 
86
  // Start analysis with ticker support
87
  export async function startAnalysis(
88
  companyName: string,
89
  ticker: string = '',
90
- strategyFocus: string = 'Competitive Position'
 
 
91
  ): Promise<WorkflowStartResponse> {
92
  const response = await fetch(`${API_BASE_URL}/analyze`, {
93
  method: 'POST',
@@ -97,7 +106,9 @@ export async function startAnalysis(
97
  body: JSON.stringify({
98
  name: companyName,
99
  ticker: ticker,
100
- strategy_focus: strategyFocus
 
 
101
  }),
102
  })
103
 
 
1
  import { AnalysisResponse } from './types'
2
 
3
  // In production (HF Spaces), API is served from same origin - use empty string
4
+ // In development, use localhost:8000 (8002 is used by financials cluster)
5
+ const API_BASE_URL = import.meta.env.VITE_API_URL ?? (import.meta.env.PROD ? '' : 'http://localhost:8000')
6
 
7
  // Stock search types
8
  export interface StockResult {
 
83
  return response.json()
84
  }
85
 
86
+ // User API keys for LLM providers
87
+ export interface UserApiKeys {
88
+ groq?: string
89
+ gemini?: string
90
+ openrouter?: string
91
+ }
92
+
93
  // Start analysis with ticker support
94
  export async function startAnalysis(
95
  companyName: string,
96
  ticker: string = '',
97
+ strategyFocus: string = 'Competitive Position',
98
+ skipCache: boolean = false,
99
+ userApiKeys: UserApiKeys = {}
100
  ): Promise<WorkflowStartResponse> {
101
  const response = await fetch(`${API_BASE_URL}/analyze`, {
102
  method: 'POST',
 
106
  body: JSON.stringify({
107
  name: companyName,
108
  ticker: ticker,
109
+ strategy_focus: strategyFocus,
110
+ skip_cache: skipCache,
111
+ user_api_keys: userApiKeys
112
  }),
113
  })
114
 
src/api/app.py CHANGED
@@ -41,6 +41,7 @@ app.add_middleware(
41
  allow_origins=[
42
  "http://localhost:5173",
43
  "http://localhost:8080",
 
44
  "http://localhost:3000",
45
  "https://huggingface.co",
46
  "https://*.hf.space",
 
41
  allow_origins=[
42
  "http://localhost:5173",
43
  "http://localhost:8080",
44
+ "http://localhost:8081",
45
  "http://localhost:3000",
46
  "https://huggingface.co",
47
  "https://*.hf.space",
src/api/routes/analysis.py CHANGED
@@ -53,7 +53,8 @@ async def start_analysis(request: AnalysisRequest):
53
  # Start workflow in background thread
54
  thread = threading.Thread(
55
  target=run_workflow_background,
56
- args=(workflow_id, request.name, request.ticker, request.strategy_focus),
 
57
  daemon=True
58
  )
59
  thread.start()
 
53
  # Start workflow in background thread
54
  thread = threading.Thread(
55
  target=run_workflow_background,
56
+ args=(workflow_id, request.name, request.ticker, request.strategy_focus,
57
+ request.skip_cache, request.user_api_keys),
58
  daemon=True
59
  )
60
  thread.start()
src/api/schemas.py CHANGED
@@ -10,6 +10,8 @@ class AnalysisRequest(BaseModel):
10
  name: str
11
  ticker: str = ""
12
  strategy_focus: str = "Competitive Position"
 
 
13
 
14
 
15
  class StockSearchResult(BaseModel):
 
10
  name: str
11
  ticker: str = ""
12
  strategy_focus: str = "Competitive Position"
13
+ skip_cache: bool = False # If True, ignore cache and run fresh analysis
14
+ user_api_keys: dict = {} # Optional: {"groq": "key", "gemini": "key", "openrouter": "key"}
15
 
16
 
17
  class StockSearchResult(BaseModel):
src/llm_client.py CHANGED
@@ -11,30 +11,40 @@ from typing import Optional, Tuple
11
  class LLMClient:
12
  """LLM client with automatic provider fallback."""
13
 
14
- def __init__(self):
15
- """Initialize client with available providers based on API keys."""
 
 
 
 
 
16
  self.providers = []
 
 
 
 
 
 
17
 
18
- # Build providers list dynamically based on available API keys
19
- if os.getenv("GROQ_API_KEY"):
20
  self.providers.append({
21
  "name": "groq",
22
- "key": os.getenv("GROQ_API_KEY"),
23
  "model": os.getenv("GROQ_MODEL", "llama-3.1-8b-instant"),
24
  "url": "https://api.groq.com/openai/v1/chat/completions"
25
  })
26
 
27
- if os.getenv("GEMINI_API_KEY"):
28
  self.providers.append({
29
  "name": "gemini",
30
- "key": os.getenv("GEMINI_API_KEY"),
31
  "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp")
32
  })
33
 
34
- if os.getenv("OPENROUTER_API_KEY"):
35
  self.providers.append({
36
  "name": "openrouter",
37
- "key": os.getenv("OPENROUTER_API_KEY"),
38
  "model": os.getenv("OPENROUTER_MODEL", "google/gemini-2.0-flash-exp:free"),
39
  "url": "https://openrouter.ai/api/v1/chat/completions"
40
  })
@@ -142,11 +152,21 @@ class LLMClient:
142
  return None, f"Unknown provider: {provider['name']}"
143
 
144
 
145
- # Singleton instance
146
  _client = None
147
 
148
- def get_llm_client() -> LLMClient:
149
- """Get or create the singleton LLM client instance."""
 
 
 
 
 
 
 
 
 
 
150
  global _client
151
  if _client is None:
152
  _client = LLMClient()
 
11
  class LLMClient:
12
  """LLM client with automatic provider fallback."""
13
 
14
+ def __init__(self, override_keys: dict = None):
15
+ """Initialize client with available providers based on API keys.
16
+
17
+ Args:
18
+ override_keys: Optional dict with user-provided API keys.
19
+ Keys: "groq", "gemini", "openrouter"
20
+ """
21
  self.providers = []
22
+ override_keys = override_keys or {}
23
+
24
+ # Build providers list - use override keys if provided, else env vars
25
+ groq_key = override_keys.get("groq") or os.getenv("GROQ_API_KEY")
26
+ gemini_key = override_keys.get("gemini") or os.getenv("GEMINI_API_KEY")
27
+ openrouter_key = override_keys.get("openrouter") or os.getenv("OPENROUTER_API_KEY")
28
 
29
+ if groq_key:
 
30
  self.providers.append({
31
  "name": "groq",
32
+ "key": groq_key,
33
  "model": os.getenv("GROQ_MODEL", "llama-3.1-8b-instant"),
34
  "url": "https://api.groq.com/openai/v1/chat/completions"
35
  })
36
 
37
+ if gemini_key:
38
  self.providers.append({
39
  "name": "gemini",
40
+ "key": gemini_key,
41
  "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp")
42
  })
43
 
44
+ if openrouter_key:
45
  self.providers.append({
46
  "name": "openrouter",
47
+ "key": openrouter_key,
48
  "model": os.getenv("OPENROUTER_MODEL", "google/gemini-2.0-flash-exp:free"),
49
  "url": "https://openrouter.ai/api/v1/chat/completions"
50
  })
 
152
  return None, f"Unknown provider: {provider['name']}"
153
 
154
 
155
+ # Singleton instance for default (env-based) client
156
  _client = None
157
 
158
+ def get_llm_client(override_keys: dict = None) -> LLMClient:
159
+ """Get or create an LLM client instance.
160
+
161
+ Args:
162
+ override_keys: If provided, creates a new client with these keys.
163
+ If None/empty, returns the singleton instance.
164
+ """
165
+ # If user provided override keys, create a fresh client for this request
166
+ if override_keys:
167
+ return LLMClient(override_keys)
168
+
169
+ # Otherwise use singleton for default env-based keys
170
  global _client
171
  if _client is None:
172
  _client = LLMClient()
src/nodes/analyzer.py CHANGED
@@ -48,6 +48,336 @@ def _get_fiscal_period_label(metric: dict) -> str:
48
  return f"FY {fy}"
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def _extract_key_metrics(raw_data: str) -> dict:
52
  """Extract and format key metrics from raw JSON data, preserving temporal info."""
53
  try:
@@ -59,7 +389,7 @@ def _extract_key_metrics(raw_data: str) -> dict:
59
  extracted = {
60
  "company": data.get("company_name", "Unknown"),
61
  "ticker": data.get("ticker", "N/A"),
62
- "financials": {},
63
  "valuation": {},
64
  "volatility": {},
65
  "macro": {},
@@ -68,12 +398,12 @@ def _extract_key_metrics(raw_data: str) -> dict:
68
  "aggregated_swot": data.get("aggregated_swot", {})
69
  }
70
 
71
- # Extract financials with temporal data
72
- fin = metrics.get("financials", {})
73
  if fin and "error" not in fin:
74
- fin_data = fin.get("financials", {})
75
  debt_data = fin.get("debt", {})
76
- extracted["financials"] = {
77
  "revenue": _extract_temporal_metric(fin_data.get("revenue", {})),
78
  "revenue_cagr_3yr": fin_data.get("revenue_growth_3yr"),
79
  "net_margin": _extract_temporal_metric(fin_data.get("net_margin_pct", {})),
@@ -147,7 +477,7 @@ def _format_metrics_for_prompt(extracted: dict) -> str:
147
  lines.append("")
148
 
149
  # Financials (with temporal context)
150
- fin = extracted.get("financials", {})
151
  if fin:
152
  lines.append("=== FINANCIALS (from SEC EDGAR) ===")
153
  # Revenue with fiscal period
@@ -294,7 +624,9 @@ def analyzer_node(state, workflow_id=None, progress_store=None):
294
  "score": state.get("score", 0)
295
  })
296
 
297
- llm = get_llm_client()
 
 
298
  raw = state["raw_data"]
299
  strategy_name = state.get("strategy_focus", "Cost Leadership")
300
  strategy_context = get_strategy_context(strategy_name)
@@ -305,17 +637,25 @@ def analyzer_node(state, workflow_id=None, progress_store=None):
305
  extracted = _extract_key_metrics(raw)
306
  formatted_data = _format_metrics_for_prompt(extracted)
307
 
 
 
 
308
  # Log LLM call start
309
  _add_activity_log(workflow_id, progress_store, "analyzer", f"Calling LLM to generate SWOT analysis...")
310
 
311
- prompt = f"""You are a financial analyst creating a SWOT analysis for {company} ({ticker}).
312
 
313
  CRITICAL INSTRUCTIONS:
314
  1. ONLY use the data provided below. DO NOT invent or assume any information.
315
  2. Every point MUST cite specific numbers from the data (e.g., "P/E of 21.3", "Beta of 0.88").
316
  3. If data is missing for a category, say "Insufficient data" - do NOT make up information.
317
  4. Focus on what the numbers actually mean for this specific company.
318
- 5. This is a {company} - tailor your analysis to their industry (e.g., bank, tech, retail).
 
 
 
 
 
319
 
320
  Strategic Focus: {strategy_name}
321
  Context: {strategy_context}
@@ -326,18 +666,18 @@ Context: {strategy_context}
326
  Based ONLY on the data above, provide a SWOT analysis in this format:
327
 
328
  Strengths:
329
- - [Cite specific metrics that show strengths]
330
 
331
  Weaknesses:
332
- - [Cite specific metrics that show weaknesses]
333
 
334
  Opportunities:
335
- - [Cite macro/market conditions that create opportunities]
336
 
337
  Threats:
338
- - [Cite risks from volatility, macro conditions, or sentiment]
339
 
340
- Remember: Every bullet point must reference actual data provided above. Do not invent any figures or facts."""
341
  start_time = time.time()
342
  response, provider, error, providers_failed = llm.query(prompt, temperature=0)
343
  elapsed = time.time() - start_time
@@ -370,7 +710,11 @@ Remember: Every bullet point must reference actual data provided above. Do not i
370
  _add_activity_log(workflow_id, progress_store, "analyzer", f"LLM error: {error}")
371
  _add_activity_log(workflow_id, progress_store, "analyzer", "Workflow aborted - all LLM providers unavailable")
372
  else:
373
- state["draft_report"] = response
 
 
 
 
374
  state["provider_used"] = provider
375
  _add_activity_log(workflow_id, progress_store, "analyzer", f"SWOT generated via {provider} ({elapsed:.1f}s)")
376
 
 
48
  return f"FY {fy}"
49
 
50
 
51
+ def _format_currency(value):
52
+ """Format large numbers as currency (B/M)."""
53
+ if value is None:
54
+ return "N/A"
55
+ if isinstance(value, dict):
56
+ value = value.get("value")
57
+ if value is None:
58
+ return "N/A"
59
+ if isinstance(value, (int, float)):
60
+ if abs(value) >= 1e12:
61
+ return f"${value/1e12:.2f}T"
62
+ if abs(value) >= 1e9:
63
+ return f"${value/1e9:.2f}B"
64
+ if abs(value) >= 1e6:
65
+ return f"${value/1e6:.0f}M"
66
+ return f"${value:,.0f}"
67
+ return str(value)
68
+
69
+
70
+ def _format_number(value, suffix="", decimals=2):
71
+ """Format a number with optional suffix."""
72
+ if value is None:
73
+ return "N/A"
74
+ if isinstance(value, dict):
75
+ value = value.get("value")
76
+ if value is None:
77
+ return "N/A"
78
+ if isinstance(value, (int, float)):
79
+ return f"{value:.{decimals}f}{suffix}"
80
+ return str(value)
81
+
82
+
83
+ def _get_period_label(metric_data: dict) -> str:
84
+ """Get period label from metric data (e.g., 'FY 2024', 'Q3 2024', '2024-11')."""
85
+ if not isinstance(metric_data, dict):
86
+ return ""
87
+
88
+ # Check for fiscal year/form info
89
+ fy = metric_data.get("fiscal_year")
90
+ form = metric_data.get("form", "")
91
+ end_date = metric_data.get("end_date", "")
92
+ date = metric_data.get("date", "")
93
+
94
+ if fy:
95
+ if form == "10-K":
96
+ return f"FY {fy}"
97
+ elif form == "10-Q" and end_date:
98
+ try:
99
+ month = int(end_date.split("-")[1])
100
+ quarter = (month - 1) // 3 + 1
101
+ return f"Q{quarter} {fy}"
102
+ except:
103
+ return f"FY {fy}"
104
+ return f"FY {fy}"
105
+
106
+ # Fallback to date
107
+ if end_date:
108
+ return end_date[:10]
109
+ if date:
110
+ return str(date)[:10]
111
+ return ""
112
+
113
+
114
+ def _get_value(metric_data) -> any:
115
+ """Extract value from metric data (handles both dict and plain values)."""
116
+ if isinstance(metric_data, dict):
117
+ return metric_data.get("value")
118
+ return metric_data
119
+
120
+
121
+ def _generate_data_report(raw_data: str) -> str:
122
+ """Generate complete multi-source data report with simple tables."""
123
+ try:
124
+ data = json.loads(raw_data)
125
+ except json.JSONDecodeError:
126
+ return "Error: Could not parse data"
127
+
128
+ lines = []
129
+ company = data.get("company_name", "Unknown")
130
+ ticker = data.get("ticker", "N/A")
131
+ multi_source = data.get("multi_source", {})
132
+ metrics = data.get("metrics", {})
133
+
134
+ lines.append(f"# Data Report: {company} ({ticker})")
135
+ lines.append("")
136
+
137
+ # ========== FINANCIALS ==========
138
+ fin_all = multi_source.get("fundamentals_all", {})
139
+ sec_data = fin_all.get("sec_edgar", {}).get("data", {})
140
+ yf_data = fin_all.get("yahoo_finance", {}).get("data", {})
141
+
142
+ if sec_data or yf_data:
143
+ lines.append("## Financials")
144
+ lines.append("Primary: SEC EDGAR | Secondary: Yahoo Finance")
145
+ lines.append("")
146
+ lines.append("| Metric | Period | SEC EDGAR | Yahoo Finance |")
147
+ lines.append("|--------|--------|-----------|---------------|")
148
+
149
+ fin_metrics = [
150
+ ("Revenue", "revenue", _format_currency),
151
+ ("Net Income", "net_income", _format_currency),
152
+ ("Gross Profit", "gross_profit", _format_currency),
153
+ ("Operating Income", "operating_income", _format_currency),
154
+ ("Gross Margin %", "gross_margin_pct", lambda v: _format_number(v, "%")),
155
+ ("Operating Margin %", "operating_margin_pct", lambda v: _format_number(v, "%")),
156
+ ("Net Margin %", "net_margin_pct", lambda v: _format_number(v, "%")),
157
+ ("Free Cash Flow", "free_cash_flow", _format_currency),
158
+ ("Operating Cash Flow", "operating_cash_flow", _format_currency),
159
+ ("Total Assets", "total_assets", _format_currency),
160
+ ("Total Liabilities", "total_liabilities", _format_currency),
161
+ ("Stockholders Equity", "stockholders_equity", _format_currency),
162
+ ("Cash", "cash", _format_currency),
163
+ ("Long-term Debt", "long_term_debt", _format_currency),
164
+ ("Net Debt", "net_debt", _format_currency),
165
+ ("R&D Expense", "rd_expense", _format_currency),
166
+ ]
167
+
168
+ for name, key, fmt in fin_metrics:
169
+ sec_val = sec_data.get(key)
170
+ yf_val = yf_data.get(key)
171
+ period = _get_period_label(sec_val) or _get_period_label(yf_val)
172
+ sec_str = fmt(_get_value(sec_val)) if sec_val else "N/A"
173
+ yf_str = fmt(_get_value(yf_val)) if yf_val else "N/A"
174
+ if sec_str != "N/A" or yf_str != "N/A":
175
+ lines.append(f"| {name} | {period} | {sec_str} | {yf_str} |")
176
+
177
+ lines.append("")
178
+
179
+ # ========== VALUATION ==========
180
+ val_all = multi_source.get("valuation_all", {})
181
+ yf_val = val_all.get("yahoo_finance", {}).get("data", {})
182
+ av_val = val_all.get("alpha_vantage", {}).get("data", {})
183
+
184
+ if yf_val or av_val:
185
+ lines.append("## Valuation")
186
+ lines.append("Primary: Yahoo Finance | Secondary: Alpha Vantage")
187
+ lines.append("")
188
+ lines.append("| Metric | Yahoo Finance | Alpha Vantage |")
189
+ lines.append("|--------|---------------|---------------|")
190
+
191
+ val_metrics = [
192
+ ("Market Cap", "market_cap", _format_currency),
193
+ ("Enterprise Value", "enterprise_value", _format_currency),
194
+ ("P/E Trailing", "trailing_pe", lambda v: _format_number(v, "x")),
195
+ ("P/E Forward", "forward_pe", lambda v: _format_number(v, "x")),
196
+ ("P/B Ratio", "pb_ratio", lambda v: _format_number(v, "x")),
197
+ ("P/S Ratio", "ps_ratio", lambda v: _format_number(v, "x")),
198
+ ("EV/EBITDA", "ev_ebitda", lambda v: _format_number(v, "x")),
199
+ ("EV/Revenue", "ev_revenue", lambda v: _format_number(v, "x")),
200
+ ("PEG Ratio", "trailing_peg", lambda v: _format_number(v, "x")),
201
+ ("Price/FCF", "price_to_fcf", lambda v: _format_number(v, "x")),
202
+ ("Revenue Growth", "revenue_growth", lambda v: _format_number(v * 100 if v and abs(v) < 10 else v, "%") if v else "N/A"),
203
+ ("Earnings Growth", "earnings_growth", lambda v: _format_number(v * 100 if v and abs(v) < 10 else v, "%") if v else "N/A"),
204
+ ]
205
+
206
+ for name, key, fmt in val_metrics:
207
+ y = yf_val.get(key)
208
+ a = av_val.get(key)
209
+ ys = fmt(_get_value(y)) if y is not None else "N/A"
210
+ avs = fmt(_get_value(a)) if a is not None else "N/A"
211
+ if ys != "N/A" or avs != "N/A":
212
+ lines.append(f"| {name} | {ys} | {avs} |")
213
+
214
+ lines.append("")
215
+
216
+ # ========== VOLATILITY ==========
217
+ vol_all = multi_source.get("volatility_all", {})
218
+ if vol_all:
219
+ lines.append("## Volatility")
220
+ lines.append("Primary: FRED + Yahoo | Secondary: Alpha Vantage")
221
+ lines.append("")
222
+ lines.append("| Metric | Date | Primary | Secondary |")
223
+ lines.append("|--------|------|---------|-----------|")
224
+
225
+ ctx = vol_all.get("market_volatility_context", {})
226
+ vix = ctx.get("vix", {})
227
+ vxn = ctx.get("vxn", {})
228
+ yf_vol = vol_all.get("yahoo_finance", {}).get("data", {})
229
+ av_vol = vol_all.get("alpha_vantage", {}).get("data", {})
230
+
231
+ # VIX
232
+ if vix.get("value"):
233
+ lines.append(f"| VIX | {vix.get('date', '')} | {_format_number(vix.get('value'))} | - |")
234
+
235
+ # VXN
236
+ if vxn.get("value"):
237
+ lines.append(f"| VXN | {vxn.get('date', '')} | {_format_number(vxn.get('value'))} | - |")
238
+
239
+ # Beta
240
+ beta_yf = _get_value(yf_vol.get("beta"))
241
+ beta_av = _get_value(av_vol.get("beta")) if av_vol else None
242
+ if beta_yf or beta_av:
243
+ lines.append(f"| Beta | - | {_format_number(beta_yf, '', 3)} | {_format_number(beta_av, '', 3) if beta_av else 'N/A'} |")
244
+
245
+ # Historical Volatility
246
+ hv_yf = _get_value(yf_vol.get("historical_volatility"))
247
+ hv_av = _get_value(av_vol.get("historical_volatility")) if av_vol else None
248
+ if hv_yf or hv_av:
249
+ lines.append(f"| Historical Volatility | - | {_format_number(hv_yf, '%')} | {_format_number(hv_av, '%') if hv_av else 'N/A'} |")
250
+
251
+ # Implied Volatility
252
+ iv_yf = _get_value(yf_vol.get("implied_volatility"))
253
+ if iv_yf:
254
+ lines.append(f"| Implied Volatility | - | {_format_number(iv_yf, '%')} | N/A |")
255
+
256
+ lines.append("")
257
+
258
+ # ========== MACRO ==========
259
+ macro_all = multi_source.get("macro_all", {})
260
+ if macro_all:
261
+ lines.append("## Macro Indicators")
262
+ lines.append("Primary: BEA/BLS | Secondary: FRED")
263
+ lines.append("")
264
+ lines.append("| Metric | Period | BEA/BLS | FRED |")
265
+ lines.append("|--------|--------|---------|------|")
266
+
267
+ bea_bls = macro_all.get("bea_bls", {}).get("data", {})
268
+ fred = macro_all.get("fred", {}).get("data", {})
269
+
270
+ # GDP Growth
271
+ gdp_p = bea_bls.get("gdp_growth", {}) or {}
272
+ gdp_f = fred.get("gdp_growth", {}) or {}
273
+ gdp_date = gdp_p.get("date", "") or gdp_f.get("date", "")
274
+ lines.append(f"| GDP Growth | {gdp_date} | {_format_number(gdp_p.get('value'), '%')} | {_format_number(gdp_f.get('value'), '%')} |")
275
+
276
+ # CPI/Inflation
277
+ cpi_p = bea_bls.get("cpi_inflation", {}) or {}
278
+ cpi_f = fred.get("cpi_inflation", {}) or {}
279
+ cpi_date = cpi_p.get("date", "") or cpi_f.get("date", "")
280
+ lines.append(f"| Inflation (CPI YoY) | {cpi_date} | {_format_number(cpi_p.get('value'), '%')} | {_format_number(cpi_f.get('value'), '%')} |")
281
+
282
+ # Unemployment
283
+ unemp_p = bea_bls.get("unemployment", {}) or {}
284
+ unemp_f = fred.get("unemployment", {}) or {}
285
+ unemp_date = unemp_p.get("date", "") or unemp_f.get("date", "")
286
+ lines.append(f"| Unemployment | {unemp_date} | {_format_number(unemp_p.get('value'), '%')} | {_format_number(unemp_f.get('value'), '%')} |")
287
+
288
+ # Fed Funds Rate (FRED only)
289
+ rates = fred.get("interest_rate", {}) or {}
290
+ lines.append(f"| Fed Funds Rate | {rates.get('date', '')} | - | {_format_number(rates.get('value'), '%')} |")
291
+
292
+ lines.append("")
293
+
294
+ # ========== NEWS ==========
295
+ news = metrics.get("news", {})
296
+ # Tavily returns results in 'results', other sources use 'articles'
297
+ articles = news.get("results", []) or news.get("articles", []) if news else []
298
+
299
+ if articles:
300
+ lines.append("## News Articles")
301
+ lines.append(f"Source: {news.get('source', 'Tavily')}")
302
+ lines.append("")
303
+ lines.append("| # | Title | Source | URL |")
304
+ lines.append("|---|-------|--------|-----|")
305
+
306
+ for i, article in enumerate(articles[:10], 1):
307
+ title = article.get("title", "Untitled")
308
+ source = article.get("source", "Unknown")
309
+ url = article.get("url", article.get("link", ""))
310
+ lines.append(f"| {i} | {title} | {source} | {url} |")
311
+
312
+ lines.append("")
313
+
314
+ # ========== SENTIMENT ==========
315
+ sentiment = metrics.get("sentiment", {})
316
+ if sentiment:
317
+ composite_score = sentiment.get("composite_score", "N/A")
318
+ interpretation = sentiment.get("overall_interpretation", "")
319
+
320
+ # Try both old format (finnhub_sentiment) and new format (metrics.finnhub)
321
+ finnhub = sentiment.get("finnhub_sentiment", {}) or sentiment.get("metrics", {}).get("finnhub", {})
322
+ reddit = sentiment.get("reddit_sentiment", {}) or sentiment.get("metrics", {}).get("reddit", {})
323
+
324
+ finn_articles = finnhub.get("articles", [])
325
+ finn_score = finnhub.get("score", finnhub.get("composite_score", "N/A"))
326
+ finn_count = finnhub.get("articles_analyzed", len(finn_articles))
327
+
328
+ reddit_posts = reddit.get("posts", [])
329
+ reddit_score = reddit.get("score", reddit.get("composite_score", "N/A"))
330
+ reddit_count = reddit.get("posts_analyzed", len(reddit_posts))
331
+
332
+ lines.append("## Sentiment Analysis")
333
+ lines.append(f"Composite Score: {composite_score}/100 - {interpretation}")
334
+ lines.append("")
335
+ lines.append("| Source | Score | Items Analyzed |")
336
+ lines.append("|--------|-------|----------------|")
337
+ lines.append(f"| Finnhub | {finn_score}/100 | {finn_count} articles |")
338
+ lines.append(f"| Reddit | {reddit_score}/100 | {reddit_count} posts |")
339
+ lines.append("")
340
+
341
+ # Show individual articles if available
342
+ if finn_articles:
343
+ lines.append("### Finnhub Articles")
344
+ lines.append("")
345
+ lines.append("| # | Headline | Sentiment | URL |")
346
+ lines.append("|---|----------|-----------|-----|")
347
+ for i, article in enumerate(finn_articles[:10], 1):
348
+ headline = article.get("headline", article.get("title", "Untitled"))
349
+ sent = article.get("sentiment_score", article.get("sentiment", "N/A"))
350
+ if isinstance(sent, (int, float)):
351
+ sent = f"{sent:+.2f}"
352
+ url = article.get("url", article.get("link", ""))
353
+ lines.append(f"| {i} | {headline} | {sent} | {url} |")
354
+ lines.append("")
355
+
356
+ # Show Reddit posts if available
357
+ if reddit_posts:
358
+ lines.append("### Reddit Posts")
359
+ lines.append("")
360
+ lines.append("| # | Title | Subreddit | Upvotes | Sentiment | URL |")
361
+ lines.append("|---|-------|-----------|---------|-----------|-----|")
362
+ for i, post in enumerate(reddit_posts[:10], 1):
363
+ title = post.get("title", "Untitled")
364
+ subreddit = post.get("subreddit", "r/unknown")
365
+ upvotes = post.get("upvotes", post.get("score", 0))
366
+ sent = post.get("sentiment_score", post.get("sentiment", "N/A"))
367
+ if isinstance(sent, (int, float)):
368
+ sent = f"{sent:+.2f}"
369
+ url = post.get("url", post.get("permalink", ""))
370
+ if url and not url.startswith("http"):
371
+ url = f"https://reddit.com{url}"
372
+ lines.append(f"| {i} | {title} | {subreddit} | {upvotes} | {sent} | {url} |")
373
+ lines.append("")
374
+
375
+ lines.append("---")
376
+ lines.append("")
377
+
378
+ return "\n".join(lines)
379
+
380
+
381
  def _extract_key_metrics(raw_data: str) -> dict:
382
  """Extract and format key metrics from raw JSON data, preserving temporal info."""
383
  try:
 
389
  extracted = {
390
  "company": data.get("company_name", "Unknown"),
391
  "ticker": data.get("ticker", "N/A"),
392
+ "fundamentals": {},
393
  "valuation": {},
394
  "volatility": {},
395
  "macro": {},
 
398
  "aggregated_swot": data.get("aggregated_swot", {})
399
  }
400
 
401
+ # Extract fundamentals with temporal data
402
+ fin = metrics.get("fundamentals", {})
403
  if fin and "error" not in fin:
404
+ fin_data = fin.get("fundamentals", {})
405
  debt_data = fin.get("debt", {})
406
+ extracted["fundamentals"] = {
407
  "revenue": _extract_temporal_metric(fin_data.get("revenue", {})),
408
  "revenue_cagr_3yr": fin_data.get("revenue_growth_3yr"),
409
  "net_margin": _extract_temporal_metric(fin_data.get("net_margin_pct", {})),
 
477
  lines.append("")
478
 
479
  # Financials (with temporal context)
480
+ fin = extracted.get("fundamentals", {})
481
  if fin:
482
  lines.append("=== FINANCIALS (from SEC EDGAR) ===")
483
  # Revenue with fiscal period
 
624
  "score": state.get("score", 0)
625
  })
626
 
627
+ # Use user-provided API keys if available
628
+ user_keys = state.get("user_api_keys", {})
629
+ llm = get_llm_client(user_keys) if user_keys else get_llm_client()
630
  raw = state["raw_data"]
631
  strategy_name = state.get("strategy_focus", "Cost Leadership")
632
  strategy_context = get_strategy_context(strategy_name)
 
637
  extracted = _extract_key_metrics(raw)
638
  formatted_data = _format_metrics_for_prompt(extracted)
639
 
640
+ # Generate detailed data report (shown before SWOT)
641
+ data_report = _generate_data_report(raw)
642
+
643
  # Log LLM call start
644
  _add_activity_log(workflow_id, progress_store, "analyzer", f"Calling LLM to generate SWOT analysis...")
645
 
646
+ prompt = f"""You are a financial analyst creating a CONCISE SWOT analysis for {company} ({ticker}).
647
 
648
  CRITICAL INSTRUCTIONS:
649
  1. ONLY use the data provided below. DO NOT invent or assume any information.
650
  2. Every point MUST cite specific numbers from the data (e.g., "P/E of 21.3", "Beta of 0.88").
651
  3. If data is missing for a category, say "Insufficient data" - do NOT make up information.
652
  4. Focus on what the numbers actually mean for this specific company.
653
+
654
+ FORMAT REQUIREMENTS - BE CONCISE:
655
+ - Each bullet point: 1 sentence MAX (under 25 words)
656
+ - 3-5 bullet points per SWOT category
657
+ - Focus on the most impactful insights only
658
+ - NO lengthy explanations or context paragraphs
659
 
660
  Strategic Focus: {strategy_name}
661
  Context: {strategy_context}
 
666
  Based ONLY on the data above, provide a SWOT analysis in this format:
667
 
668
  Strengths:
669
+ - [Single sentence with metric, under 25 words]
670
 
671
  Weaknesses:
672
+ - [Single sentence with metric, under 25 words]
673
 
674
  Opportunities:
675
+ - [Single sentence citing macro/market data, under 25 words]
676
 
677
  Threats:
678
+ - [Single sentence citing risks, under 25 words]
679
 
680
+ Remember: Every bullet must cite actual data. Keep each point brief and impactful."""
681
  start_time = time.time()
682
  response, provider, error, providers_failed = llm.query(prompt, temperature=0)
683
  elapsed = time.time() - start_time
 
710
  _add_activity_log(workflow_id, progress_store, "analyzer", f"LLM error: {error}")
711
  _add_activity_log(workflow_id, progress_store, "analyzer", "Workflow aborted - all LLM providers unavailable")
712
  else:
713
+ # Combine data report (Part 1) with SWOT analysis (Part 2)
714
+ swot_section = f"## SWOT Analysis\n\n{response}"
715
+ full_report = f"{data_report}\n{swot_section}"
716
+ state["draft_report"] = full_report
717
+ state["data_report"] = data_report # Store separately for frontend flexibility
718
  state["provider_used"] = provider
719
  _add_activity_log(workflow_id, progress_store, "analyzer", f"SWOT generated via {provider} ({elapsed:.1f}s)")
720
 
src/nodes/editor.py CHANGED
@@ -42,7 +42,9 @@ def editor_node(state, workflow_id=None, progress_store=None):
42
  # Log revision start
43
  _add_activity_log(workflow_id, progress_store, "editor", f"Revision #{current_revision} in progress...")
44
 
45
- llm = get_llm_client()
 
 
46
  strategy_name = state.get("strategy_focus", "Cost Leadership")
47
 
48
  # Get source data for grounding - editor must use ONLY this data
@@ -53,7 +55,7 @@ def editor_node(state, workflow_id=None, progress_store=None):
53
 
54
  # Prepare the revision prompt with source data for grounding
55
  prompt = f"""
56
- You are revising a SWOT analysis based on critique feedback.
57
 
58
  CRITICAL GROUNDING RULES:
59
  1. You may ONLY use facts and numbers from the SOURCE DATA provided below.
@@ -79,8 +81,11 @@ REVISION INSTRUCTIONS:
79
  4. Make sure strengths/opportunities are positive, weaknesses/threats are negative
80
  5. Align analysis with {strategy_name} strategic focus
81
  6. If data is missing for a point, remove that point rather than inventing data
 
 
 
82
 
83
- Return only the improved SWOT analysis. Do NOT include any facts not found in the SOURCE DATA.
84
  """
85
 
86
  # Get the revised draft from LLM
 
42
  # Log revision start
43
  _add_activity_log(workflow_id, progress_store, "editor", f"Revision #{current_revision} in progress...")
44
 
45
+ # Use user-provided API keys if available
46
+ user_keys = state.get("user_api_keys", {})
47
+ llm = get_llm_client(user_keys) if user_keys else get_llm_client()
48
  strategy_name = state.get("strategy_focus", "Cost Leadership")
49
 
50
  # Get source data for grounding - editor must use ONLY this data
 
55
 
56
  # Prepare the revision prompt with source data for grounding
57
  prompt = f"""
58
+ You are revising a SWOT analysis based on critique feedback. Keep it CONCISE.
59
 
60
  CRITICAL GROUNDING RULES:
61
  1. You may ONLY use facts and numbers from the SOURCE DATA provided below.
 
81
  4. Make sure strengths/opportunities are positive, weaknesses/threats are negative
82
  5. Align analysis with {strategy_name} strategic focus
83
  6. If data is missing for a point, remove that point rather than inventing data
84
+ 7. Keep each bullet point under 25 words - single sentence only
85
+ 8. Maximum 5 bullet points per category
86
+ 9. Remove any verbose explanations or context paragraphs
87
 
88
+ Return only the improved SWOT analysis. Keep it brief and impactful.
89
  """
90
 
91
  # Get the revised draft from LLM
src/services/workflow_store.py CHANGED
@@ -97,14 +97,20 @@ def update_mcp_status(workflow_id: str, source: str, status: str):
97
  WORKFLOWS[workflow_id]["mcp_status"][source] = status
98
 
99
 
100
- def run_workflow_background(workflow_id: str, company_name: str, ticker: str, strategy_focus: str):
 
101
  """Execute workflow in background thread with progress tracking."""
102
  try:
103
- # Check cache first
104
  add_activity_log(workflow_id, "cache", f"Checking cache for {ticker}")
105
  WORKFLOWS[workflow_id]["current_step"] = "cache"
106
 
107
- cached = get_cached_analysis(ticker)
 
 
 
 
 
108
  if cached:
109
  # Cache hit - use cached result
110
  add_activity_log(workflow_id, "cache", f"Cache HIT - {ticker} analysis found in history")
@@ -166,7 +172,8 @@ def run_workflow_background(workflow_id: str, company_name: str, ticker: str, st
166
  "data_source": "live",
167
  "provider_used": None,
168
  "workflow_id": workflow_id,
169
- "progress_store": WORKFLOWS
 
170
  }
171
 
172
  # Execute workflow
 
97
  WORKFLOWS[workflow_id]["mcp_status"][source] = status
98
 
99
 
100
+ def run_workflow_background(workflow_id: str, company_name: str, ticker: str, strategy_focus: str,
101
+ skip_cache: bool = False, user_api_keys: dict = None):
102
  """Execute workflow in background thread with progress tracking."""
103
  try:
104
+ # Check cache first (unless skip_cache is True)
105
  add_activity_log(workflow_id, "cache", f"Checking cache for {ticker}")
106
  WORKFLOWS[workflow_id]["current_step"] = "cache"
107
 
108
+ if skip_cache:
109
+ add_activity_log(workflow_id, "cache", f"Cache skipped - running fresh analysis")
110
+ cached = None
111
+ else:
112
+ cached = get_cached_analysis(ticker)
113
+
114
  if cached:
115
  # Cache hit - use cached result
116
  add_activity_log(workflow_id, "cache", f"Cache HIT - {ticker} analysis found in history")
 
172
  "data_source": "live",
173
  "provider_used": None,
174
  "workflow_id": workflow_id,
175
+ "progress_store": WORKFLOWS,
176
+ "user_api_keys": user_api_keys or {} # Pass user API keys to nodes
177
  }
178
 
179
  # Execute workflow
static/assets/{index-DJPxD6T_.css → index-BcWqIwge.css} RENAMED
The diff for this file is too large to render. See raw diff
 
static/assets/{index-SoStcVBo.js → index-DCCdBPwi.js} RENAMED
The diff for this file is too large to render. See raw diff
 
static/index.html CHANGED
@@ -5,8 +5,8 @@
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
  <title>frontend</title>
8
- <script type="module" crossorigin src="/assets/index-SoStcVBo.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DJPxD6T_.css">
10
  </head>
11
  <body>
12
  <div id="root"></div>
 
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
  <title>frontend</title>
8
+ <script type="module" crossorigin src="/assets/index-DCCdBPwi.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BcWqIwge.css">
10
  </head>
11
  <body>
12
  <div id="root"></div>