Spaces:
Sleeping
Sleeping
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 +0 -0
- frontend/src/App.tsx +162 -19
- frontend/src/components/MCPDataPanel.tsx +31 -24
- frontend/src/lib/api.ts +15 -4
- src/api/app.py +1 -0
- src/api/routes/analysis.py +2 -1
- src/api/schemas.py +2 -0
- src/llm_client.py +32 -12
- src/nodes/analyzer.py +359 -15
- src/nodes/editor.py +8 -3
- src/services/workflow_store.py +11 -4
- static/assets/{index-DJPxD6T_.css → index-BcWqIwge.css} +0 -0
- static/assets/{index-SoStcVBo.js → index-DCCdBPwi.js} +0 -0
- static/index.html +2 -2
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 |
-
|
| 195 |
-
|
| 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 |
-
//
|
| 186 |
-
const
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
}))
|
| 195 |
}
|
| 196 |
}
|
| 197 |
|
| 198 |
-
// Fallback: check
|
| 199 |
-
if (Array.isArray(rawData.news)) {
|
| 200 |
-
return rawData.news.slice(0,
|
| 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 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
const sources: Array<{name: string, score: number | null, url?: string}> = []
|
| 214 |
-
const
|
| 215 |
|
| 216 |
// Finnhub sentiment
|
| 217 |
-
if (
|
| 218 |
-
const finnhub =
|
|
|
|
| 219 |
sources.push({
|
| 220 |
name: 'Finnhub',
|
| 221 |
-
score:
|
| 222 |
url: 'https://finnhub.io'
|
| 223 |
})
|
| 224 |
}
|
| 225 |
|
| 226 |
// Reddit sentiment
|
| 227 |
-
if (
|
| 228 |
-
const reddit =
|
|
|
|
| 229 |
sources.push({
|
| 230 |
name: 'Reddit',
|
| 231 |
-
score:
|
| 232 |
url: 'https://reddit.com'
|
| 233 |
})
|
| 234 |
}
|
| 235 |
|
| 236 |
-
// Composite score
|
| 237 |
-
const composite =
|
| 238 |
-
if (composite
|
| 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:
|
| 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 |
-
|
| 19 |
-
if os.getenv("GROQ_API_KEY"):
|
| 20 |
self.providers.append({
|
| 21 |
"name": "groq",
|
| 22 |
-
"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
|
| 28 |
self.providers.append({
|
| 29 |
"name": "gemini",
|
| 30 |
-
"key":
|
| 31 |
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp")
|
| 32 |
})
|
| 33 |
|
| 34 |
-
if
|
| 35 |
self.providers.append({
|
| 36 |
"name": "openrouter",
|
| 37 |
-
"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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 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
|
| 72 |
-
fin = metrics.get("
|
| 73 |
if fin and "error" not in fin:
|
| 74 |
-
fin_data = fin.get("
|
| 75 |
debt_data = fin.get("debt", {})
|
| 76 |
-
extracted["
|
| 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("
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- [
|
| 330 |
|
| 331 |
Weaknesses:
|
| 332 |
-
- [
|
| 333 |
|
| 334 |
Opportunities:
|
| 335 |
-
- [
|
| 336 |
|
| 337 |
Threats:
|
| 338 |
-
- [
|
| 339 |
|
| 340 |
-
Remember: Every bullet
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 9 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 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>
|