Commit 98138631 authored by Fares's avatar Fares

FinSim v1

parents
GROQ_API_KEY=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
SERPAPI_API_KEY=0b123e5cf375884f50e23cfae6de2afb76f4b0bac1c05abd700d8357c3ac2377
================================================================================
FinSim — HOW TO RUN (Complete Guide)
================================================================================
Last Updated: March 31, 2026
================================================================================
WHAT WAS INSTALLED ON YOUR MACHINE
================================================================================
1. UV Package Manager (v0.11.2)
- Location: C:\Users\fmfmf\.local\bin\uv.exe
- UV is a modern Python package manager (replaces pip/venv/virtualenv)
2. Python 3.12.13 (installed via UV)
- Managed by UV, no need to install separately
3. Virtual Environment + 67 Python packages
- Location: investment_engine\.venv\
- All dependencies (FastAPI, Polars, yfinance, LangChain, Groq, etc.)
4. LangGraph was added as a missing dependency to pyproject.toml
================================================================================
QUICK START — Run FinSim
================================================================================
Open PowerShell (or Windows Terminal) and run:
cd C:\Users\fmfmf\Desktop\FinSim\FinSim\investment_engine
$env:Path = "C:\Users\fmfmf\.local\bin;$env:Path"
uv run python main.py
Then open your browser to:
http://localhost:8000
That's it! The UI has two panels:
- LEFT: "Generate Historical Scenarios" — fetches stock data, runs AI, saves to DB
- RIGHT: "Interactive Advisor Bot" — chat with the AI investment advisor
================================================================================
STOPPING THE SERVER
================================================================================
Press Ctrl+C in the terminal where the server is running.
================================================================================
DETAILED STEP-BY-STEP (First Time Setup)
================================================================================
If you ever need to set this up on a fresh machine again:
Step 1: Install UV (one-time)
────────────────────────────
Open Powershell and run:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
irm https://astral.sh/uv/install.ps1 | iex
Step 2: Add UV to PATH (every new terminal session)
────────────────────────────────────────────────────
$env:Path = "C:\Users\fmfmf\.local\bin;$env:Path"
TIP: To make this permanent, add C:\Users\fmfmf\.local\bin to your
Windows System PATH via:
Settings > System > About > Advanced system settings >
Environment Variables > Path > Edit > New
Step 3: Install Python (one-time)
─────────────────────────────────
uv python install 3.12
Step 4: Navigate to project and sync dependencies
──────────────────────────────────────────────────
cd C:\Users\fmfmf\Desktop\FinSim\FinSim\investment_engine
uv sync
Step 5: Run the application
───────────────────────────
uv run python main.py
================================================================================
DATABASE INFO (Already Connected & Working)
================================================================================
Host: scenariodb.caprover.al-arcade.com
Port: 3306
User: root
Password: Alarcade123#
Database: mcq_app
Table: scenarios (141 scenarios currently in DB)
Other tables in the DB: quiz_attempts, quiz_scenarios, quizzes,
user_scenario_history, users
The database connection is configured in:
investment_engine\config.py (default values)
Connection is tested automatically on app startup (init_db).
================================================================================
API KEYS (Already Configured in .env)
================================================================================
File: investment_engine\.env
Contains:
GROQ_API_KEY — For the Llama-3.3-70b AI (scenario generation + chat)
SERPAPI_API_KEY — For real-time web search in the chat bot
If keys expire, replace them in the .env file. No code changes needed.
================================================================================
API ENDPOINTS
================================================================================
GET / → Serves the Web UI (index.html)
POST /generate → Generates scenarios (body: JSON with stock_symbol, etc.)
POST /chat → Chat with the advisor bot (body: JSON with message)
Example curl for generate:
curl -X POST http://localhost:8000/generate ^
-H "Content-Type: application/json" ^
-d "{\"stock_symbol\":\"AAPL\",\"zscore_window\":100,\"zscore_trigger_min\":-2.5,\"zscore_trigger_max\":2.5}"
Example curl for chat:
curl -X POST http://localhost:8000/chat ^
-H "Content-Type: application/json" ^
-d "{\"session_id\":\"my_session\",\"message\":\"Give me a scenario\"}"
================================================================================
PROJECT FILE STRUCTURE
================================================================================
investment_engine\
├── main.py Entry point (starts Uvicorn server on port 8000)
├── app.py FastAPI app with /generate and /chat routes
├── config.py Environment config (DB creds, API keys, defaults)
├── models.py Pydantic data models (request/response contracts)
├── .env Secret keys (GROQ_API_KEY, SERPAPI_API_KEY)
├── pyproject.toml Project definition + dependencies
├── uv.lock Locked dependency versions
├── services\
│ ├── zscore_engine.py Polars Z-Score calculation + yfinance data fetch
│ ├── scenario_gen.py Groq LLM prompt → structured MCQ generation
│ ├── database.py MySQL connection pool, insert, random fetch
│ └── chat_agent.py LangGraph ReAct agent (advisor bot)
└── static\
└── index.html Frontend UI (dark theme, two-panel layout)
================================================================================
TROUBLESHOOTING
================================================================================
Problem: "uv is not recognized"
Solution: Run this first in your terminal:
$env:Path = "C:\Users\fmfmf\.local\bin;$env:Path"
Problem: "Python was not found"
Solution: Don't run "python" directly. Always use "uv run python ..."
UV manages its own Python installation.
Problem: "GROQ_API_KEY is not set"
Solution: Make sure the .env file exists in the investment_engine folder
and contains your key: GROQ_API_KEY=gsk_...
Problem: "Could not initialize DB on startup"
Solution: Check your internet connection. The MySQL database is remote
(hosted on CapRover). If the host is down, the app will still start
but DB features won't work.
Problem: Port 8000 already in use
Solution: Either stop the other process using port 8000, or edit main.py
and change the port number in: uvicorn.run("app:app", port=8000)
Problem: "No events found for any requested symbols"
Solution: yfinance may have rate-limited you. Wait a minute and try again,
or try a different stock symbol.
Problem: Generation takes too long
Solution: The scenario generation pipeline does 3 things sequentially:
1. Downloads 5 years of stock data (yfinance) — ~5 seconds
2. Calculates Z-Scores with Polars — instant
3. Calls Groq LLM to generate scenarios — ~10-20 seconds
Total: ~15-30 seconds is normal.
================================================================================
USEFUL COMMANDS
================================================================================
Start the server:
uv run python main.py
Add a new dependency:
uv add <package-name>
Update all dependencies:
uv sync --upgrade
Run a one-off Python script:
uv run python <script.py>
Check installed packages:
uv pip list
================================================================================
================================================================================
LANGGRAPH TECHNICAL EXPLANATION — FINSIM AKINATOR 2.0
How the Multi-Agent Investment Prediction Engine Works
================================================================================
Written for: FinSim Project Technical Presentation
System: Akinator 2.0 — Multi-Agent Investment Prediction Engine
Framework: LangGraph (by LangChain) + Groq LLM + yfinance + SerpAPI
File: services/akinator.py
================================================================================
1. WHAT IS LANGGRAPH?
================================================================================
LangGraph is a framework built on top of LangChain for creating stateful,
multi-step AI workflows as directed graphs. Think of it as a flowchart where:
- Each BOX in the flowchart is a "Node" (a Python function)
- Each ARROW is an "Edge" (defines which node runs next)
- A shared "State" object carries data between all nodes
Unlike a simple chain (A -> B -> C), LangGraph supports:
- Conditional branching (if X, go to node A; else go to node B)
- Cycles/loops (a node can route back to a previous node)
- Shared persistent state across all nodes
- Human-in-the-loop interruptions
LangGraph uses a "StateGraph" — a graph where every node reads from and
writes to a shared state dictionary. This is what makes our multi-agent
system possible: each analyst node adds its findings to the state, and
later nodes can access ALL previous findings.
================================================================================
2. OUR GRAPH ARCHITECTURE — 9 NODES
================================================================================
Here is the complete flow of our Akinator 2.0 system:
+----------+
| START |
+----+-----+
|
v
+-------+--------+
| 1. ROUTER | (classifies the user query)
+-------+--------+
|
_________+_________
| |
[is_whatif?] [regular query]
| |
v |
+-------+--------+ |
| 2. WHAT-IF | |
| ENGINE | |
+-------+--------+ |
| |
+----->+<-----------+
|
v
+-------+--------+
| 3. ANALYST HUB | (ReAct agent with 5 tools)
+-------+--------+
|
v
+---------+-----------+
| 4. NEWS SENTIMENT | (headline fetch + scoring)
+---------+-----------+
|
v
+-----------+----------+
| 5. CONFIDENCE SCORER | (data-driven scoring)
+-----------+----------+
|
v
+------+-------+
| 6. CRITIQUE | (self-correction loop)
+------+-------+
|
v
+---------+-----------+
| 7. JIT EDUCATION | (jargon detection)
+---------+-----------+
|
v
+---------+-----------+
| 8. MEMO GENERATOR | (professional summary)
+---------+-----------+
|
v
+---------+-----------+
| 9. FORMAT RESPONSE | (assemble output)
+---------+-----------+
|
v
+---+---+
| END |
+-------+
================================================================================
3. THE STATE SCHEMA — Data That Flows Through the Graph
================================================================================
Every node in the graph reads from and writes to a shared "State" dictionary.
Our state is defined as a Python TypedDict:
class AkinatorState(TypedDict):
messages: list # Conversation history (HumanMessage/AIMessage)
user_query: str # The current user message
analyst_report: str # Main analysis from the ReAct agent
news_sentiment: dict # {headlines, score, label, topic}
confidence_score: float # 0-100 confidence percentage
critique_note: str # Self-correction note (if contradiction found)
whatif_result: str # What-if historical analysis result
jargon_definitions: dict # {term: plain_english_definition}
final_memo: str # Compiled investment memo
panel_mode: bool # Whether panel discussion mode is enabled
is_whatif: bool # Whether the query is a what-if scenario
final_response: str # The final combined response to the user
HOW STATE UPDATES WORK:
- When a node returns {"confidence_score": 85}, only that field is updated
- All other fields remain unchanged
- This allows each node to focus on its own responsibility
- By the end of the graph, the state contains data from ALL nodes
================================================================================
4. DETAILED NODE EXPLANATIONS
================================================================================
----- NODE 1: ROUTER -----
File: router_node()
Purpose: Classifies the user query to determine the graph path.
How it works:
1. Takes the user's message from state["user_query"]
2. Checks against a list of "what-if" patterns using regex:
- "what if I had invested..."
- "if I bought... 5 years ago"
- "what would have happened..."
3. Sets state["is_whatif"] = True or False
This node feeds into a CONDITIONAL EDGE — LangGraph's branching mechanism:
- If is_whatif is True -> next node is "whatif_engine"
- If is_whatif is False -> next node is "analyst_hub" (skip what-if)
Code for conditional routing:
graph.add_conditional_edges("router", route_after_router, {
"whatif_engine": "whatif_engine",
"analyst_hub": "analyst_hub",
})
----- NODE 2: WHAT-IF ENGINE -----
File: whatif_engine_node()
Purpose: Historical scenario analysis — "what if I had invested $X in Y, Z years ago?"
How it works:
1. Parses the query to extract: ticker, time period, investment amount
2. Uses yfinance to download historical price data
3. Calculates:
- Buy price at the start date
- Number of shares purchased
- Current value of those shares
- Total return (percentage)
- Maximum drawdown during the holding period
- Peak portfolio value
4. Writes a formatted analysis to state["whatif_result"]
This data is then passed to the Analyst Hub, which incorporates it into
the LLM's analysis. The analyst is instructed to reference the What-If
data in its prediction.
----- NODE 3: ANALYST HUB (Core Intelligence) -----
File: analyst_hub_node()
Purpose: The main AI analysis engine — runs a ReAct agent with 5 expert tools.
This is the most complex node. It uses LangGraph's built-in ReAct agent
pattern (create_react_agent), which is itself a mini-graph:
ReAct Loop (inside Analyst Hub):
1. LLM receives the user query + conversation history
2. LLM decides which tool(s) to call
3. Tool is executed, result returned to LLM
4. LLM decides: call another tool, or generate final answer?
5. Repeat until LLM produces a final answer
The 5 tools available to the agent:
a) market_data_analyst — Fetches live prices from yfinance (PE ratio,
market cap, 52-week range, recent returns)
b) news_sentiment_analyst — Searches SerpAPI for investment news
c) risk_assessment_analyst — Calculates volatility, Sharpe ratio, max
drawdown, risk level using 1 year of historical data
d) portfolio_strategy_search — Searches for expert portfolio advice
e) fetch_news_headlines — Gets dedicated news headlines for sentiment
The LLM (Groq's llama-3.3-70b-versatile) orchestrates which tools to call
and in what order. It typically calls ALL tools before synthesizing a report.
If Panel Mode is enabled, the system prompt includes instructions for the
LLM to simulate a 10-persona roundtable debate before making its prediction.
----- NODE 4: NEWS SENTIMENT -----
File: news_sentiment_node()
Purpose: Dedicated headline fetching and keyword-based sentiment scoring.
How it works:
1. Extracts the main topic from the user query
2. Fetches news headlines via SerpAPI's News tab
3. Scores sentiment using keyword matching:
- POSITIVE_WORDS: "surge", "rally", "growth", "bullish", etc.
- NEGATIVE_WORDS: "crash", "decline", "bearish", "recession", etc.
4. Calculates a score (0-100):
- score = (positive_count / total_count) * 100
- Label: Bullish (>=65), Bearish (<=35), Mixed (36-64), Neutral (no data)
5. Writes to state["news_sentiment"] with headlines, score, and label
This is independent from the analyst hub's news tool — it runs AFTER the
analyst to provide a separate, quantitative sentiment signal that feeds
into the confidence scorer and critique nodes.
----- NODE 5: CONFIDENCE SCORER -----
File: confidence_scorer_node()
Purpose: Calculates a data-driven confidence percentage (0-95%).
Scoring algorithm:
- Base score: 40%
- Report substance (length > 500 chars): +15%
- News data available: +15%
- Strong sentiment direction: +5%
- Specific numbers/prices in report: +3% each (up to +15%)
- What-if analysis included: +10%
- Each tool usage marker found: +5% each (up to +20%)
- Cap: minimum 10%, maximum 95% (never claim 100% confidence)
This is a PURELY COMPUTATIONAL node — no LLM call needed. It analyzes
the data already in the state to produce a confidence metric.
----- NODE 6: CRITIQUE (Self-Correction Loop) -----
File: critique_node()
Purpose: Reviews the prediction against news sentiment for contradictions.
How it works:
1. Detects prediction direction from the analyst report:
- Counts "buy/bullish/upside" keywords vs "sell/bearish/downside"
- Determines if prediction is bullish, bearish, or neutral
2. Compares against news sentiment label:
- If prediction is BULLISH but news is BEARISH -> issue correction
- If prediction is BEARISH but news is BULLISH -> issue correction
3. Scans headlines for high-impact events:
- "fed", "interest rate", "recession", "war", "crisis", etc.
- If found, issues an additional warning
4. Writes correction to state["critique_note"]
This implements the "self-correction loop" requirement — the system
reviews its own output and flags potential issues. In the UI, this
appears as a yellow warning box.
----- NODE 7: JIT (Just-In-Time) EDUCATION -----
File: jit_education_node()
Purpose: Identifies financial jargon and provides plain-English definitions.
How it works:
1. Maintains a dictionary of 30+ financial terms with simple definitions
(P/E Ratio, Volatility, Sharpe Ratio, ETF, Stop-Loss, etc.)
2. Scans the analyst report text for matches
3. Collects all found terms and their definitions
4. Writes to state["jargon_definitions"]
In the UI, these appear as an expandable "Financial Terms Explained" panel
at the bottom of the Akinator response. This helps students learn financial
terminology as they use the system.
----- NODE 8: MEMO GENERATOR -----
File: memo_generator_node()
Purpose: Compiles the entire session into a professional investment memo.
The memo includes:
- Timestamp and AI model used
- The original query
- Confidence score with label (High/Moderate/Low)
- News sentiment score and label
- Self-correction note (if any)
- Key headlines from news analysis
- Number of educational terms identified
- Data sources used
- Disclaimer
In the UI, this appears as a collapsible "Investment Memo" section that
users can expand to see a structured summary of the entire analysis.
----- NODE 9: FORMAT RESPONSE -----
File: format_response_node()
Purpose: Assembles the final user-facing response text.
Combines:
- The main analyst report
- What-if analysis results (if applicable)
The confidence score, sentiment data, critique, jargon, and memo are
sent as separate JSON fields alongside the reply, so the frontend can
render them as distinct UI components (badges, panels, etc.).
================================================================================
5. HOW THE GRAPH IS BUILT AND COMPILED
================================================================================
The graph is built using LangGraph's StateGraph API:
def build_akinator_graph():
graph = StateGraph(AkinatorState)
# Register all 9 nodes
graph.add_node("router", router_node)
graph.add_node("whatif_engine", whatif_engine_node)
graph.add_node("analyst_hub", analyst_hub_node)
graph.add_node("news_sentiment", news_sentiment_node)
graph.add_node("confidence_scorer", confidence_scorer_node)
graph.add_node("critique", critique_node)
graph.add_node("jit_education", jit_education_node)
graph.add_node("memo_generator", memo_generator_node)
graph.add_node("format_response", format_response_node)
# Wire the edges (the flow)
graph.add_edge(START, "router")
# Conditional branching after router
graph.add_conditional_edges("router", route_after_router, {
"whatif_engine": "whatif_engine",
"analyst_hub": "analyst_hub",
})
# Sequential pipeline
graph.add_edge("whatif_engine", "analyst_hub")
graph.add_edge("analyst_hub", "news_sentiment")
graph.add_edge("news_sentiment", "confidence_scorer")
graph.add_edge("confidence_scorer", "critique")
graph.add_edge("critique", "jit_education")
graph.add_edge("jit_education", "memo_generator")
graph.add_edge("memo_generator", "format_response")
graph.add_edge("format_response", END)
return graph.compile()
IMPORTANT: The graph is compiled ONCE at module load time:
akinator_graph = build_akinator_graph()
This means the graph structure is created when the server starts and
reused for every request. Only the STATE changes per request.
================================================================================
6. EXECUTION FLOW — WHAT HAPPENS WHEN A USER ASKS A QUESTION
================================================================================
Example: User asks "Should I invest in AAPL right now?"
Step 1 — API Call:
Frontend sends POST /api/akinator with the message.
Step 2 — State Initialization:
get_akinator_response() creates the initial state:
{
messages: [previous chat history],
user_query: "Should I invest in AAPL right now?",
analyst_report: "",
news_sentiment: {},
confidence_score: 0,
critique_note: "",
whatif_result: "",
jargon_definitions: {},
final_memo: "",
panel_mode: False,
is_whatif: False,
final_response: "",
}
Step 3 — Graph Execution:
akinator_graph.invoke(initial_state) runs all nodes in order:
3a. ROUTER: Scans for "what if" -> is_whatif = False
3b. Conditional: is_whatif is False -> skip to ANALYST HUB
3c. ANALYST HUB: ReAct agent calls all 5 tools:
- market_data_analyst("AAPL") -> price, PE, returns
- news_sentiment_analyst("AAPL") -> latest news
- risk_assessment_analyst("AAPL") -> volatility, Sharpe
- portfolio_strategy_search("AAPL invest") -> expert advice
- fetch_news_headlines("AAPL") -> headlines
-> Synthesizes full prediction report
3d. NEWS SENTIMENT: Fetches AAPL news, calculates score (72/100 = Bullish)
3e. CONFIDENCE SCORER: Evaluates data -> 78% confidence
3f. CRITIQUE: Prediction is bullish, news is bullish -> no contradiction
3g. JIT EDUCATION: Found terms: P/E Ratio, Volatility, Market Cap, etc.
3h. MEMO GENERATOR: Compiles full memo
3i. FORMAT RESPONSE: Assembles final text
Step 4 — Response:
Returns rich JSON: {reply, confidence_score, news_sentiment, jargon_definitions, memo, ...}
Step 5 — Frontend Rendering:
- Main report rendered with markdown-to-HTML (marked.js)
- Confidence badge (green/yellow/red pill)
- Sentiment badge (bullish/bearish indicator)
- Critique box (if contradiction found)
- Expandable jargon definitions panel
- Expandable investment memo
================================================================================
7. WHAT-IF SCENARIO FLOW
================================================================================
Example: "What if I had invested $10,000 in Tesla 3 years ago?"
The flow changes at the ROUTER node:
1. ROUTER: Detects "what if" + "3 years ago" -> is_whatif = True
2. WHAT-IF ENGINE: (runs BEFORE analyst hub)
- Extracts: ticker=TSLA, years_ago=3, amount=$10,000
- Downloads 3 years of TSLA price data
- Calculates: buy_price, current_price, shares, return, drawdown
- Writes formatted analysis to state["whatif_result"]
3. ANALYST HUB: Receives the what-if data appended to the user query
- Incorporates historical analysis into its prediction
- Still calls all 5 tools for current market context
4-9. Rest of pipeline runs normally, with the what-if data flowing through
================================================================================
8. THE REACT AGENT PATTERN (Inside Analyst Hub)
================================================================================
The Analyst Hub uses LangChain's ReAct (Reasoning + Acting) pattern:
agent = create_react_agent(llm, tools, prompt=system_prompt)
This creates a mini-graph inside the node:
+--------+ +----------+ +--------+
| LLM | --> | Tool | --> | LLM | --> (repeat or finish)
| Reason | | Execute | | Reason |
+--------+ +----------+ +--------+
The LLM "reasons" about what it needs, "acts" by calling a tool, then
"reasons" again based on the tool's output. This loop continues until
the LLM decides it has enough information to give a final answer.
In our system, the LLM typically:
1. Calls market_data_analyst first (get hard numbers)
2. Calls news_sentiment_analyst (get context)
3. Calls risk_assessment_analyst (evaluate risk)
4. Calls portfolio_strategy_search (get advice)
5. Produces final synthesis
The LLM autonomously decides the order and which tools to use.
================================================================================
9. SESSION MANAGEMENT
================================================================================
Sessions are stored in memory:
_akinator_sessions: dict[str, list] = {}
- Key: session_id (generated by frontend)
- Value: list of HumanMessage/AIMessage objects
After each graph execution:
1. User message is appended to history
2. AI response is appended to history
3. History is trimmed to last 10 messages (to prevent token overflow)
This allows multi-turn conversations where the AI remembers context.
================================================================================
10. ERROR HANDLING — RATE LIMITS
================================================================================
Groq's free API has rate limits (tokens per minute/day). When exceeded:
1. The exception is caught in get_akinator_response()
2. _format_rate_limit_error() parses the error message
3. Extracts the wait time from patterns like "try again in 45.5s"
4. Returns {"rate_limited": True, "wait_seconds": 46}
5. Frontend shows a countdown timer
6. ONLY real rate-limit errors trigger this (not generic "tokens" errors)
================================================================================
11. TECHNOLOGY STACK SUMMARY
================================================================================
Backend:
- FastAPI (Python web framework)
- LangGraph (graph-based AI orchestration)
- LangChain (LLM integration framework)
- Groq API (LLM provider — llama-3.3-70b-versatile)
- yfinance (real-time stock market data)
- SerpAPI (web search and news headlines)
- MySQL (scenario database)
Frontend:
- Vanilla HTML/CSS/JavaScript
- marked.js (markdown rendering)
- Responsive dark-mode design
Key Design Decisions:
- StateGraph over simple chains: enables conditional branching and
shared state across nodes
- ReAct agent for tool calling: LLM autonomously decides which
tools to use and in what order
- Computational nodes (no LLM): Confidence, JIT, Critique, and
Memo nodes use Python logic instead of LLM calls to minimize
API usage and avoid rate limits
- Rich JSON responses: Frontend receives structured data to render
confidence badges, sentiment indicators, jargon panels, etc.
================================================================================
12. FILES MODIFIED / CREATED
================================================================================
services/akinator.py — Complete rewrite with LangGraph StateGraph
app.py — Updated /api/akinator endpoint for rich responses
static/index.html — Markdown rendering + metadata UI panels
LangGraph_Explanation.txt — This file (technical documentation)
================================================================================
END OF DOCUMENT
================================================================================
================================================================================
FinSim
AI-Powered Investment Simulation & Education Platform
================================================================================
WHAT IS FINSIM?
───────────────────────────────────────────────────────────────────────
FinSim is a full-stack web application that teaches people how to make
smarter investment decisions — using real market data, artificial
intelligence, and interactive simulations.
Think of it as a personal investment training ground: you can chat with
an AI advisor, test your knowledge with scenario-based quizzes, predict
stock movements with a multi-agent AI engine, and even ask "what if I
had invested in Tesla 5 years ago?" and get the real math.
The platform is built for students, aspiring investors, and anyone who
wants to understand how financial markets work — without risking real
money.
CORE FEATURES
───────────────────────────────────────────────────────────────────────
1. USER AUTHENTICATION & DASHBOARD
- Secure login/registration system
- Personal dashboard showing quiz scores, accuracy stats, and
recent performance
- Tracks your learning progress over time
2. AI INVESTMENT ADVISOR (Chat)
- A conversational AI chatbot powered by Groq's LLaMA 3.3 (70B)
- Has access to live market data (real stock prices via yfinance)
and web search (SerpAPI) for current news
- Can answer questions about stocks, bonds, ETFs, portfolio
strategy, and financial concepts
- Can generate practice MCQ scenarios on demand
3. SCENARIO GENERATOR (Z-Score Pipeline)
- Fetches 5 years of real historical stock prices from Yahoo Finance
- Uses statistical analysis (Z-Scores via Polars) to detect
significant market events and anomalies
- Feeds those events into an AI model that generates realistic
investment scenario questions with 4 multiple-choice answers
- Each scenario includes a best answer with rationale and 3 wrong
answers with explanations for why they're wrong
- All scenarios are stored in a MySQL database
4. PRACTICE MCQ QUIZ SYSTEM
- Timed quizzes with real-world investment scenarios
- Configurable: choose number of questions and difficulty
- Instant feedback after each answer with detailed explanations
- Scores are tracked and contribute to your dashboard stats
5. LEADERBOARD
- Competitive ranking across all users
- Shows total score, quizzes taken, and average performance
- Encourages engagement through friendly competition
6. AKINATOR 2.0 — Multi-Agent Investment Prediction Engine
This is the flagship feature. A sophisticated AI system built with
LangGraph (a graph-based AI orchestration framework) that runs
9 interconnected processing nodes:
- ROUTER: Classifies user queries and determines the processing path
- WHAT-IF ENGINE: Analyzes hypothetical past investments using real
historical data ("What if I invested $10K in Apple 3 years ago?")
- ANALYST HUB: A ReAct agent that runs 5 expert tools in parallel:
* Market Data Analyst (live prices, PE ratios, returns)
* News & Sentiment Analyst (current headlines and market mood)
* Risk Assessment Analyst (volatility, Sharpe ratio, drawdown)
* Portfolio Strategy Advisor (allocation recommendations)
* NewsAPI Headlines (dedicated news fetching)
- NEWS SENTIMENT SCORER: Calculates a sentiment score (0-100) from
real headlines using keyword-based analysis
- CONFIDENCE SCORER: Rates the prediction's reliability (0-95%)
based on data completeness and source alignment
- SELF-CORRECTION (CRITIQUE): Reviews the prediction against the
news sentiment — if the AI says "buy" but the news is bearish,
it flags the contradiction with a correction note
- JIT EDUCATION: Scans the response for financial jargon (P/E Ratio,
Volatility, Sharpe Ratio, etc.) and provides plain-English
definitions so beginners can learn as they read
- INVESTMENT MEMO: Compiles the entire analysis into a professional
summary document
- FORMAT RESPONSE: Assembles everything into the final output
Additionally, Akinator 2.0 features a "Panel Discussion Mode" where
10 distinct investor personas (risk manager, quant, aggressive trader,
value investor, macro economist, technical analyst, institutional
banker, crypto enthusiast, behavioral psychologist, ESG advocate)
debate the investment using real data before making a consensus
recommendation.
TECHNOLOGY STACK
───────────────────────────────────────────────────────────────────────
Backend:
- Python 3.12
- FastAPI (web framework and REST API)
- LangGraph (graph-based AI workflow orchestration)
- LangChain (LLM integration and tool calling)
- Groq API with LLaMA 3.3 70B Versatile (large language model)
- yfinance (real-time and historical stock market data)
- SerpAPI (live web search and news)
- Polars (high-performance data processing, written in Rust)
- MySQL (remote database for scenario storage)
- bcrypt (password hashing)
Frontend:
- Vanilla HTML, CSS, JavaScript (no frameworks)
- marked.js (markdown rendering in chat)
- Responsive dark-mode UI with glassmorphism design
- Mobile-friendly with sidebar navigation
Infrastructure:
- UV package manager (reproducible Python environments)
- CapRover (remote MySQL hosting)
HOW IT ALL CONNECTS
───────────────────────────────────────────────────────────────────────
┌─────────────────────┐
│ User Browser │
│ (HTML/CSS/JS UI) │
└────────┬────────────┘
│ HTTP
┌─────────────────────┐
│ FastAPI Server │
│ (app.py) │
└────────┬────────────┘
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ AI Advisor │ │ Akinator │ │ Scenario │
│ (chat_agent) │ │ 2.0 Graph │ │ Generator │
│ ReAct Agent │ │ (9 nodes) │ │ (Z-Scores) │
└──────┬──────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ External Services │
│ Groq LLM | yfinance | SerpAPI | MySQL │
└─────────────────────────────────────────────────┘
WHAT MAKES THIS PROJECT SPECIAL
───────────────────────────────────────────────────────────────────────
1. Real Data, Not Simulations
Unlike most educational tools that use fake numbers, FinSim pulls
live stock prices, real news headlines, and actual historical data.
Every prediction and scenario is grounded in reality.
2. Multi-Agent AI Architecture
The Akinator 2.0 doesn't just call one AI model — it orchestrates
multiple specialized "agents" (market analyst, risk assessor, news
scanner, strategy advisor) that each gather different data, then
synthesizes their findings into a unified recommendation.
3. Self-Correcting AI
The system reviews its own predictions against current news
sentiment. If there's a contradiction, it flags it automatically.
This teaches users that even AI predictions need critical thinking.
4. Learning While Using
The JIT Education system detects financial jargon in AI responses
and explains terms in plain English. Users learn new financial
vocabulary naturally as they interact with the system.
5. Graph-Based AI Orchestration
Built with LangGraph, the Akinator uses a directed graph where
data flows through 9 processing nodes with conditional branching.
This is the same architecture used by production AI systems at
companies like Google and OpenAI.
6. Full-Stack, Production-Quality
User authentication, database persistence, responsive mobile UI,
leaderboards, error handling, rate limit management — this isn't
a prototype, it's a complete application.
================================================================================
Built with Python, FastAPI, LangGraph, LangChain, Groq AI, and love.
================================================================================
"""
FinSim — Full FastAPI Application
Routes: Auth, Scenarios, Generator, Chat, MCQ Quiz, Akinator 2.0
"""
import json
import uuid
import bcrypt
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request, Depends, UploadFile, File, Form
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
from typing import Optional
from dotenv import load_dotenv
import services.database as db
from services.zscore_engine import identify_events
from services.scenario_gen import generate_scenarios
from services.chat_agent import get_chat_response
from services.akinator import get_akinator_response
from models import GenerateRequest, ChatRequest
load_dotenv()
# Initialize DB tables
try:
db.init_db()
except Exception as e:
print(f"Warning: Could not initialize DB on startup. Error: {e}")
app = FastAPI(title="FinSim", version="2.0.0")
# ── In-memory session store ──────────────────────────────────────
_tokens: dict[str, dict] = {} # token -> {user_id, username, role, ...}
# ── Request Models ───────────────────────────────────────────────
class AkinatorRequest(BaseModel):
session_id: str = Field(default="default")
message: str
panel_mode: bool = Field(default=False)
knowledge_base_id: Optional[str] = None
class LoginRequest(BaseModel):
email: str
password: str
class RegisterRequest(BaseModel):
username: str
email: str
password: str
class AnswerRequest(BaseModel):
scenario_id: str
selected_answer: str
class StartQuizRequest(BaseModel):
num_questions: int = Field(default=5, ge=1, le=20)
difficulty: Optional[str] = None
category: Optional[str] = None
# ── Auth Helper ──────────────────────────────────────────────────
def get_current_user(request: Request) -> dict:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Not authenticated")
token = auth[7:]
user = _tokens.get(token)
if not user:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return user
# ── Serve UI ─────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def get_ui():
return FileResponse("static/index.html")
# ══════════════════════════════════════════════════════════════════
# AUTH ENDPOINTS
# ══════════════════════════════════════════════════════════════════
@app.post("/api/login")
def login(req: LoginRequest):
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE email = %s", (req.email,))
user = cursor.fetchone()
if not user:
raise HTTPException(status_code=401, detail="Invalid email or password")
stored_pw = user["password"]
# Support both bcrypt and plain-text legacy passwords
valid = False
if stored_pw.startswith("$2"):
try:
valid = bcrypt.checkpw(req.password.encode(), stored_pw.encode())
except Exception:
valid = False
else:
valid = (req.password == stored_pw)
if not valid:
raise HTTPException(status_code=401, detail="Invalid email or password")
token = uuid.uuid4().hex
_tokens[token] = {
"user_id": user["id"],
"username": user["username"],
"email": user["email"],
"role": user["role"],
"avatar": user["avatar"],
"total_score": user["total_score"],
"quizzes_taken": user["quizzes_taken"],
}
return {"token": token, "user": _tokens[token]}
@app.post("/api/register")
def register(req: RegisterRequest):
from services.database import get_connection
hashed = bcrypt.hashpw(req.password.encode(), bcrypt.gensalt()).decode()
try:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO users (username, email, password, role) VALUES (%s, %s, %s, 'user')",
(req.username, req.email, hashed)
)
user_id = cursor.lastrowid
except Exception as e:
if "Duplicate" in str(e):
raise HTTPException(status_code=409, detail="Username or email already exists")
raise HTTPException(status_code=500, detail=str(e))
token = uuid.uuid4().hex
_tokens[token] = {
"user_id": user_id,
"username": req.username,
"email": req.email,
"role": "user",
"avatar": None,
"total_score": 0,
"quizzes_taken": 0,
}
return {"token": token, "user": _tokens[token]}
@app.get("/api/me")
def get_me(user: dict = Depends(get_current_user)):
# Refresh from DB
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT total_score, quizzes_taken FROM users WHERE id = %s", (user["user_id"],))
fresh = cursor.fetchone()
if fresh:
user["total_score"] = fresh["total_score"]
user["quizzes_taken"] = fresh["quizzes_taken"]
return {"user": user}
@app.post("/api/logout")
def logout(request: Request):
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
_tokens.pop(token, None)
return {"ok": True}
# ══════════════════════════════════════════════════════════════════
# DATA INGESTION & KNOWLEDGE BASES (RAG)
# ══════════════════════════════════════════════════════════════════
@app.get("/api/rag/kbs")
def get_kbs():
from services.rag_engine import list_knowledge_bases
try:
kbs = list_knowledge_bases()
return {"status": "success", "knowledge_bases": [kb["name"] for kb in kbs]}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/rag/upload")
def upload_to_kb(
kb_name: str = Form(...),
urls: Optional[str] = Form(None),
files: Optional[list[UploadFile]] = File(None)
):
import os
import tempfile
from services.rag_engine import ingest_document
try:
total_chunks = 0
if urls:
url_list = [u.strip() for u in urls.split(",") if u.strip()]
for u in url_list:
total_chunks += ingest_document(kb_name, u, "url")
if files:
for file in files:
ext = file.filename.split(".")[-1].lower() if "." in file.filename else "txt"
if ext in ["doc", "docx"]:
ext = "docx"
elif ext not in ["pdf", "txt", "json", "docx"]:
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}. Allowed: PDF, TXT, JSON, DOCX.")
suffix = f".{ext}"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = file.file.read()
tmp.write(content)
source = tmp.name
total_chunks += ingest_document(kb_name, source, ext)
# Cleanup temp file if necessary
if os.path.exists(source):
os.remove(source)
if not urls and not files:
raise HTTPException(status_code=400, detail="Must provide at least one URL or one file.")
return {"status": "success", "message": f"Ingested {total_chunks} chunks into Knowledge Base '{kb_name}'"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ══════════════════════════════════════════════════════════════════
# SCENARIO GENERATOR
# ══════════════════════════════════════════════════════════════════
@app.post("/api/generate")
def api_generate_scenarios(req: GenerateRequest, user: dict = Depends(get_current_user)):
try:
zscore_result = identify_events(
symbol=req.stock_symbol,
window=req.zscore_window,
trigger_min=req.zscore_trigger_min,
trigger_max=req.zscore_trigger_max
)
if zscore_result.total_events == 0:
return {"status": "success", "message": f"No significant events found for {req.stock_symbol}."}
generation_result = generate_scenarios(req.stock_symbol, zscore_result, req.knowledge_base_id)
inserted_count = db.insert_all_scenarios(generation_result)
return {
"status": "success",
"stock_symbol": req.stock_symbol or "Mixed Basket",
"events_analyzed": zscore_result.total_events,
"scenarios_generated": len(generation_result.scenarios),
"scenarios_saved_to_db": inserted_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Keep old endpoint for backwards compat
@app.post("/generate")
def api_generate_scenarios_legacy(req: GenerateRequest):
try:
zscore_result = identify_events(
symbol=req.stock_symbol, window=req.zscore_window,
trigger_min=req.zscore_trigger_min, trigger_max=req.zscore_trigger_max
)
if zscore_result.total_events == 0:
return {"status": "success", "message": f"No significant events found for {req.stock_symbol}."}
generation_result = generate_scenarios(req.stock_symbol, zscore_result)
inserted_count = db.insert_all_scenarios(generation_result)
return {
"status": "success", "stock_symbol": req.stock_symbol or "Mixed Basket",
"events_analyzed": zscore_result.total_events,
"scenarios_generated": len(generation_result.scenarios),
"scenarios_saved_to_db": inserted_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ══════════════════════════════════════════════════════════════════
# CHAT
# ══════════════════════════════════════════════════════════════════
@app.post("/api/chat")
def api_chat(req: ChatRequest, user: dict = Depends(get_current_user)):
try:
response = get_chat_response(req.session_id, req.message, req.knowledge_base_id)
if isinstance(response, dict):
if response.get("rate_limited"):
return response
response["session_id"] = req.session_id
return response
return {"session_id": req.session_id, "reply": response}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/chat")
def api_chat_legacy(req: ChatRequest):
try:
response = get_chat_response(req.session_id, req.message)
if isinstance(response, dict):
if response.get("rate_limited"):
return response
response["session_id"] = req.session_id
return response
return {"session_id": req.session_id, "reply": response}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ══════════════════════════════════════════════════════════════════
# AKINATOR 2.0 — MULTI-AGENT PREDICTION ENGINE
# ══════════════════════════════════════════════════════════════════
@app.post("/api/akinator")
def api_akinator(req: AkinatorRequest, user: dict = Depends(get_current_user)):
try:
response = get_akinator_response(req.session_id, req.message, req.panel_mode, req.knowledge_base_id)
if isinstance(response, dict) and response.get("rate_limited"):
return response
# Rich response from LangGraph pipeline
if isinstance(response, dict):
return {
"session_id": req.session_id,
"reply": response.get("reply", ""),
"confidence_score": response.get("confidence_score", 0),
"news_sentiment": response.get("news_sentiment", {}),
"jargon_definitions": response.get("jargon_definitions", {}),
"memo": response.get("memo", ""),
"whatif_result": response.get("whatif_result", ""),
"critique_note": response.get("critique_note", ""),
}
return {"session_id": req.session_id, "reply": response}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ══════════════════════════════════════════════════════════════════
# SCENARIOS BROWSING
# ══════════════════════════════════════════════════════════════════
@app.get("/api/scenarios")
def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category: str = "", risk: str = ""):
from services.database import get_connection
offset = (page - 1) * limit
where_clauses = ["is_active = 1"]
params = []
if difficulty:
where_clauses.append("difficulty = %s")
params.append(difficulty)
if category:
where_clauses.append("category = %s")
params.append(category)
if risk:
where_clauses.append("risk_level = %s")
params.append(risk)
where_sql = " AND ".join(where_clauses)
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(f"SELECT COUNT(*) as total FROM scenarios WHERE {where_sql}", params)
total = cursor.fetchone()["total"]
cursor.execute(
f"""SELECT id, title, short_description, event_type, difficulty, category, risk_level, created_at
FROM scenarios WHERE {where_sql}
ORDER BY created_at DESC LIMIT %s OFFSET %s""",
params + [limit, offset]
)
scenarios = cursor.fetchall()
# Serialize datetimes
for s in scenarios:
if s.get("created_at"):
s["created_at"] = s["created_at"].isoformat()
return {"scenarios": scenarios, "total": total, "page": page, "pages": (total + limit - 1) // limit}
@app.get("/api/scenarios/{scenario_id}")
def get_scenario_detail(scenario_id: str):
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM scenarios WHERE id = %s", (scenario_id,))
scenario = cursor.fetchone()
if not scenario:
raise HTTPException(status_code=404, detail="Scenario not found")
if scenario.get("created_at"):
scenario["created_at"] = scenario["created_at"].isoformat()
if scenario.get("updated_at"):
scenario["updated_at"] = scenario["updated_at"].isoformat()
if scenario.get("givens_table") and isinstance(scenario["givens_table"], str):
try:
scenario["givens_table"] = json.loads(scenario["givens_table"])
except:
pass
return scenario
# ══════════════════════════════════════════════════════════════════
# MCQ QUIZ SYSTEM
# ══════════════════════════════════════════════════════════════════
@app.post("/api/quiz/start")
def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
from services.database import get_connection
where_clauses = ["is_active = 1"]
params = []
if req.difficulty:
where_clauses.append("difficulty = %s")
params.append(req.difficulty)
if req.category:
where_clauses.append("category = %s")
params.append(req.category)
where_sql = " AND ".join(where_clauses)
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
# Get random scenarios
cursor.execute(
f"SELECT id, title, short_description, scenario_paragraph, givens_table, "
f"best_answer, best_answer_rationale, "
f"other_option1, other_option1_exp, other_option2, other_option2_exp, "
f"other_option3, other_option3_exp, event_type, difficulty, category, risk_level "
f"FROM scenarios WHERE {where_sql} ORDER BY RAND() LIMIT %s",
params + [req.num_questions]
)
scenarios = cursor.fetchall()
if not scenarios:
raise HTTPException(status_code=404, detail="No scenarios found matching your criteria")
# Create quiz
cursor.execute(
"INSERT INTO quizzes (title, description, time_limit, passing_score, created_by) VALUES (%s, %s, %s, %s, %s)",
(f"Quick Quiz - {datetime.now().strftime('%b %d, %H:%M')}", f"{len(scenarios)} questions", 0, 60, user["user_id"])
)
quiz_id = cursor.lastrowid
# Link scenarios
for i, s in enumerate(scenarios):
cursor.execute(
"INSERT INTO quiz_scenarios (quiz_id, scenario_id, question_order) VALUES (%s, %s, %s)",
(quiz_id, s["id"], i + 1)
)
# Create attempt
cursor.execute(
"INSERT INTO quiz_attempts (user_id, quiz_id, total_questions, status) VALUES (%s, %s, %s, 'in_progress')",
(user["user_id"], quiz_id, len(scenarios))
)
attempt_id = cursor.lastrowid
# Process scenarios for frontend
questions = []
for s in scenarios:
givens = s.get("givens_table")
if givens and isinstance(givens, str):
try:
givens = json.loads(givens)
except:
givens = {}
# Build shuffled options
import random
options = [
{"text": s["best_answer"], "key": "correct"},
{"text": s["other_option1"], "key": "wrong1"},
{"text": s["other_option2"], "key": "wrong2"},
{"text": s["other_option3"], "key": "wrong3"},
]
random.shuffle(options)
labels = ["A", "B", "C", "D"]
labeled_options = []
correct_label = ""
for j, opt in enumerate(options):
labeled_options.append({"label": labels[j], "text": opt["text"]})
if opt["key"] == "correct":
correct_label = labels[j]
questions.append({
"scenario_id": s["id"],
"title": s["title"],
"short_description": s["short_description"],
"scenario_paragraph": s["scenario_paragraph"],
"givens_table": givens,
"risk_level": s["risk_level"],
"difficulty": s["difficulty"],
"event_type": s["event_type"],
"options": labeled_options,
"correct_label": correct_label,
"best_answer_rationale": s["best_answer_rationale"],
"other_explanations": {
s["other_option1"]: s["other_option1_exp"],
s["other_option2"]: s["other_option2_exp"],
s["other_option3"]: s["other_option3_exp"],
}
})
return {
"quiz_id": quiz_id,
"attempt_id": attempt_id,
"total_questions": len(questions),
"questions": questions,
}
@app.post("/api/quiz/{attempt_id}/submit")
def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depends(get_current_user)):
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
# Verify attempt belongs to user
cursor.execute("SELECT * FROM quiz_attempts WHERE id = %s AND user_id = %s", (attempt_id, user["user_id"]))
attempt = cursor.fetchone()
if not attempt:
raise HTTPException(status_code=404, detail="Quiz attempt not found")
correct_count = 0
results = []
for ans in answers:
# Get scenario
cursor.execute("SELECT best_answer, best_answer_rationale, other_option1, other_option1_exp, other_option2, other_option2_exp, other_option3, other_option3_exp FROM scenarios WHERE id = %s", (ans.scenario_id,))
scenario = cursor.fetchone()
if not scenario:
continue
is_correct = ans.selected_answer.strip() == scenario["best_answer"].strip()
if is_correct:
correct_count += 1
# Record history
cursor.execute(
"INSERT INTO user_scenario_history (user_id, scenario_id, selected_answer, is_correct, attempt_id) VALUES (%s, %s, %s, %s, %s)",
(user["user_id"], ans.scenario_id, ans.selected_answer, 1 if is_correct else 0, attempt_id)
)
# Build explanation
explanation = ""
if is_correct:
explanation = scenario["best_answer_rationale"]
else:
# Find what they picked
for opt_key in ["other_option1", "other_option2", "other_option3"]:
if ans.selected_answer.strip() == scenario[opt_key].strip():
explanation = scenario[opt_key + "_exp"]
break
results.append({
"scenario_id": ans.scenario_id,
"selected_answer": ans.selected_answer,
"correct_answer": scenario["best_answer"],
"is_correct": is_correct,
"explanation": explanation,
"correct_rationale": scenario["best_answer_rationale"],
})
# Update attempt
score = int((correct_count / len(answers)) * 100) if answers else 0
cursor.execute(
"UPDATE quiz_attempts SET score = %s, correct_answers = %s, status = 'completed', completed_at = NOW(), answers = %s WHERE id = %s",
(score, correct_count, json.dumps([r for r in results]), attempt_id)
)
# Update user stats
cursor.execute(
"UPDATE users SET total_score = total_score + %s, quizzes_taken = quizzes_taken + 1 WHERE id = %s",
(score, user["user_id"])
)
return {
"score": score,
"correct": correct_count,
"total": len(answers),
"percentage": score,
"results": results,
}
# ══════════════════════════════════════════════════════════════════
# USER STATS / HISTORY
# ══════════════════════════════════════════════════════════════════
@app.get("/api/stats")
def get_user_stats(user: dict = Depends(get_current_user)):
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
# Get overall stats
cursor.execute("SELECT total_score, quizzes_taken FROM users WHERE id = %s", (user["user_id"],))
stats = cursor.fetchone()
# Get recent attempts
cursor.execute(
"""SELECT qa.id, qa.score, qa.correct_answers, qa.total_questions, qa.status, qa.started_at, qa.completed_at
FROM quiz_attempts qa
WHERE qa.user_id = %s
ORDER BY qa.started_at DESC LIMIT 10""",
(user["user_id"],)
)
attempts = cursor.fetchall()
for a in attempts:
if a.get("started_at"):
a["started_at"] = a["started_at"].isoformat()
if a.get("completed_at"):
a["completed_at"] = a["completed_at"].isoformat()
# Get accuracy
cursor.execute(
"SELECT COUNT(*) as total, SUM(is_correct) as correct FROM user_scenario_history WHERE user_id = %s",
(user["user_id"],)
)
accuracy = cursor.fetchone()
# Get category breakdown
cursor.execute(
"""SELECT s.category, COUNT(*) as attempts, SUM(ush.is_correct) as correct
FROM user_scenario_history ush
JOIN scenarios s ON ush.scenario_id = s.id
WHERE ush.user_id = %s
GROUP BY s.category""",
(user["user_id"],)
)
categories = cursor.fetchall()
return {
"total_score": stats["total_score"] if stats else 0,
"quizzes_taken": stats["quizzes_taken"] if stats else 0,
"avg_score": int(stats["total_score"] / stats["quizzes_taken"]) if stats and stats["quizzes_taken"] > 0 else 0,
"total_questions_answered": accuracy["total"] if accuracy else 0,
"total_correct": int(accuracy["correct"]) if accuracy and accuracy["correct"] else 0,
"accuracy": round((int(accuracy["correct"]) / accuracy["total"]) * 100, 1) if accuracy and accuracy["total"] and accuracy["correct"] else 0,
"recent_attempts": attempts,
"categories": categories,
}
@app.get("/api/leaderboard")
def get_leaderboard():
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""SELECT username, total_score, quizzes_taken, avatar,
CASE WHEN quizzes_taken > 0 THEN ROUND(total_score / quizzes_taken) ELSE 0 END as avg_score
FROM users
WHERE quizzes_taken > 0
ORDER BY total_score DESC LIMIT 20"""
)
leaders = cursor.fetchall()
return {"leaderboard": leaders}
# ══════════════════════════════════════════════════════════════════
# FILTERS
# ══════════════════════════════════════════════════════════════════
@app.get("/api/filters")
def get_filters():
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT DISTINCT category FROM scenarios WHERE is_active = 1 AND category IS NOT NULL")
categories = [r["category"] for r in cursor.fetchall()]
cursor.execute("SELECT DISTINCT difficulty FROM scenarios WHERE is_active = 1 AND difficulty IS NOT NULL")
difficulties = [r["difficulty"] for r in cursor.fetchall()]
cursor.execute("SELECT DISTINCT risk_level FROM scenarios WHERE is_active = 1 AND risk_level IS NOT NULL")
risks = [r["risk_level"] for r in cursor.fetchall()]
return {"categories": categories, "difficulties": difficulties, "risk_levels": risks}
"""
Configuration — central environment variables definitions using pydantic-settings
"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# ── Groq API ────────────────────────────────────────────
GROQ_API_KEY: str = ""
# Model used for scenario generation and chat
GROQ_MODEL: str = "llama-3.3-70b-versatile"
# ── SerpAPI ─────────────────────────────────────────────
SERPAPI_API_KEY: str = ""
# ── MySQL (Al-Arcade Remote DB) ───────────────────────
MYSQL_HOST: str = "scenariodb.caprover.al-arcade.com"
MYSQL_PORT: int = 3306
MYSQL_USER: str = "root"
MYSQL_PASSWORD: str = "Alarcade123#"
MYSQL_DATABASE: str = "mcq_app"
# ── Defaults for Z-Score ──────────────────────────────
DEFAULT_ZSCORE_WINDOW: int = 100
DEFAULT_ZSCORE_TRIGGER_MIN: float = -2.5
DEFAULT_ZSCORE_TRIGGER_MAX: float = 2.5
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()
import os
import json
from datetime import datetime
from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
# Set the path so it can import from services
import sys
import os
sys.path.append(os.getcwd())
from config import get_settings
from services.chat_agent import SerpApi_Search, mcq_scenarios
def test_agent_scenario():
settings = get_settings()
llm = ChatGroq(
api_key=settings.GROQ_API_KEY,
model_name=settings.GROQ_MODEL,
temperature=0.7,
)
today = datetime.now().strftime("%Y-%m-%d")
system_prompt_str = f"""Role: Expert Investment Advisor AI for markets, strategy, and portfolio education.
1. INVESTMENT ADVICE & REAL-TIME DATA
Tool: Use SerpApi_Search for all news, current prices (e.g., Gold in Egypt), and market data.
Date: Today is {today}.
2. MCQ GENERATION (Practice)
**. SCENARIO PRESENTATION (UI/UX):**
When a scenario is retrieved, present it with high readability using this exact structure:
---
### 📊 Investment Case Study
> [Insert a concise, professional paragraph describing the situation.]
**Key Market Data:**
* 💵 **Initial Capital:** [Value]
* 📈 **Asset Class:** [Type]
* ⏱️ **Time Horizon:** [Duration]
* ⚠️ **Risk Level:** [Rating]
**Select the Best Course of Action:**
* **A)** [Option A text]
* **B)** [Option B text]
* **C)** [Option C text]
* **D)** [Option D text]
---
*Instruction: Wait for the user's letter (A-D) before providing the rationale.*
3. MCQ SCENARIO QUESTIONS (Database)
Tool: Use mcq_scenarios."""
tools = [SerpApi_Search, mcq_scenarios]
agent_executor = create_react_agent(llm, tools, prompt=system_prompt_str)
user_message = "provide a scenario"
print(f"User: {user_message}")
response = agent_executor.invoke({
"messages": [HumanMessage(content=user_message)]
})
messages = response["messages"]
for i, msg in enumerate(messages):
print(f"\n--- Message {i} ({type(msg).__name__}) ---")
try:
print(f"Content: {msg.content}")
except UnicodeEncodeError:
print(f"Content (UTF-8 bytes): {msg.content.encode('utf-8')}")
if hasattr(msg, 'tool_calls'):
print(f"Tool Calls: {msg.tool_calls}")
last_msg = messages[-1]
try:
print(f"\nFinal Answer: '{last_msg.content}'")
except UnicodeEncodeError:
print(f"\nFinal Answer (UTF-8 bytes): '{last_msg.content.encode('utf-8')}'")
if __name__ == "__main__":
test_agent_scenario()
import uvicorn
from app import app
from config import get_settings
def start():
"""Start the FastAPI application."""
print("Starting FinSim...")
settings = get_settings()
if not settings.GROQ_API_KEY:
print("WARNING: GROQ_API_KEY is not set in the .env file.")
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
if __name__ == "__main__":
start()
"""
Pydantic models — the contract between every layer of the app.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Optional, List
# ═══════════════════════════════════════════════════════════════
# REQUEST MODELS
# ═══════════════════════════════════════════════════════════════
class GenerateRequest(BaseModel):
stock_symbol: str = Field("", examples=["AAPL"])
zscore_window: int = Field(default=100, ge=5)
zscore_trigger_min: float = Field(default=-2.5)
zscore_trigger_max: float = Field(default=2.5)
knowledge_base_id: Optional[str] = None
class ChatRequest(BaseModel):
session_id: str = Field(default="default")
message: str
knowledge_base_id: Optional[str] = None
# ═══════════════════════════════════════════════════════════════
# Z-SCORE MODELS
# ═══════════════════════════════════════════════════════════════
class ZScoreEvent(BaseModel):
date: str
price: float
z_score: float
event_type: str # "major" | "normal"
context: str
direction: str # "decline" | "rally"
class ZScoreResult(BaseModel):
events: List[ZScoreEvent]
total_events: int
window_size: int
data_points: int
# ═══════════════════════════════════════════════════════════════
# SCENARIO MODELS
# ═══════════════════════════════════════════════════════════════
class AnswerOption(BaseModel):
answer: str
explanation: str
class BestAnswer(BaseModel):
answer: str
rationale: str
class GivensTable(BaseModel):
date: Optional[str] = None
stock_symbol: Optional[str] = None
price: Optional[float] = None
z_score: Optional[float] = None
event_type: Optional[str] = None
market_conditions: Optional[str] = None
context: Optional[str] = None
class Config:
extra = "allow" # AI may add extra fields
class Scenario(BaseModel):
id: str
title: str
short_description: str = Field(alias="shortDescription", default="")
givens_table: GivensTable = Field(alias="givensTable")
scenario_paragraph: str = Field(alias="scenarioParagraph", default="")
best_answer: BestAnswer = Field(alias="bestAnswer", default_factory=lambda: BestAnswer(answer="Unknown", rationale="Unknown"))
# Strictly padding exactly 3 elements to prevent IndexError during DB integration
other_answers: list[AnswerOption] = Field(
alias="otherAnswers",
default_factory=lambda: [
AnswerOption(answer="Unknown A", explanation="TBD"),
AnswerOption(answer="Unknown B", explanation="TBD"),
AnswerOption(answer="Unknown C", explanation="TBD")
]
)
event_type: Optional[str] = None
risk_level: str = Field(alias="riskLevel", description="Low, Medium, or High", default="Medium")
class Config:
populate_by_name = True
class ScenarioGenerationResult(BaseModel):
scenarios: list[Scenario]
total_possible_scenarios: int = Field(alias="totalPossibleScenarios")
class Config:
populate_by_name = True
# ═══════════════════════════════════════════════════════════════
# CHAT MODELS
# ═══════════════════════════════════════════════════════════════
class ChatResponse(BaseModel):
session_id: str
reply: str
# FinSim Investment Engine - Comprehensive Documentation & Setup Guide
## 1. Complete Step-by-Step Setup & Execution Guide
**Prerequisites:**
- **Python 3.12** or higher installed on your windows system.
- The **`uv`** package manager (highly recommended as the project uses a `uv.lock` file).
### Step 1: Open Terminal and Navigate to the Project Directory
Open PowerShell or Command Prompt and navigate to the project folder:
```powershell
cd C:\Users\Fares\OneDrive\Desktop\FinSim\investment_engine
```
### Step 2: Install Dependencies
Since the project relies on the modern `uv` build tool, we will use it to install the environment perfectly.
If you don't have `uv` installed globally in Python, install it first:
```powershell
pip install uv
```
Now, sync the dependencies. This command automatically creates a `.venv` virtual environment in the folder and strictly installs everything in `uv.lock` (like FastAPI, LangChain, Polars):
```powershell
uv sync
```
*(If you are avoiding `uv` for any reason, you can manually use standard pip instead: `python -m venv .venv`, then `.\.venv\Scripts\activate`, then `pip install -e .`)*
### Step 3: Configure Environment Variables
The application needs secure API keys to talk to the AI and Search platforms.
1. Make sure you are in `C:\Users\Fares\OneDrive\Desktop\FinSim\investment_engine`.
2. Create a new text file named exactly `.env` (with a dot at the start).
3. Open `.env` in Notepad or VSCode and paste the following, replacing the placeholders with your actual keys:
```env
GROQ_API_KEY="your_groq_api_key_here"
SERPAPI_API_KEY="your_serpapi_api_key_here"
```
*(Note: Important Database credentials for the remote CapRover MySQL instance are already hardcoded/defaulted safely in `config.py`, so you do not need to add DB keys here unless you want to override them).*
### Step 4: Run the Application
Start the FastAPI server. Because we used `uv`, we can use `uv run` to automatically use the virtual environment without needing to activate it manually.
```powershell
uv run python main.py
```
*Output should look like this:*
```text
Starting FinSim...
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started reloader process [...]
INFO: Started server process [...]
INFO: Waiting for application startup.
INFO: Application startup complete.
```
### Step 5: Access the Web Interface
1. Open your web browser (Chrome, Edge, etc.).
2. Go to: [http://localhost:8000](http://localhost:8000)
3. You will see the FinSim UI dashboard! You can generate historical scenarios on the left panel, and start chatting with the interactive AI on the right.
---
## 2. Granular File Descriptions
### Core Application Layer
- **`app.py`**
- **Purpose:** The central nervous system of the FastAPI app.
- **Details:** Mounts the `static/` folder to serve the UI on `/`. It defines the two main POST endpoints: `/generate` (which sequentially calls the Z-score engine, AI scenario generator, and Database insert functions) and `/chat` (which talks to the interactive agent). It also initializes the remote Database table on startup if it isn't there.
- **`main.py`**
- **Purpose:** The immediate execution point.
- **Details:** Calls `uvicorn.run("app:app", host="0.0.0.0", port=8000)`. It checks `config.py` upfront to warn you in the terminal if you forgot to set your `GROQ_API_KEY`.
- **`config.py`**
- **Purpose:** Environment and configuration management.
- **Details:** Uses `pydantic-settings`. Automatically loads the `.env` file. Defines all default values such as the Groq model name (`llama-3.3-70b-versatile`), remote MySQL server/credentials for CapRover, and the default math triggers for the Z-score logic (like a 100-day window).
- **`models.py`**
- **Purpose:** The strict data types (Pydantic).
- **Details:** Enforces rigid shapes for all data flowing through the app. It holds models for HTTP requests (`GenerateRequest`), internal Z-Score calculations (`ZScoreEvent`), and highly nested JSON structures that the LLM is forced to output (`Scenario`, `ScenarioGenerationResult`).
### Business Logic (`services/` Directory)
- **`services/zscore_engine.py`**
- **Purpose:** The high-speed quantitative volatility analyzer.
- **Details:** Connects to Yahoo Finance (`yfinance`) to pull 5 years of daily stock prices. Uses `polars` (a blazing fast data library written in Rust) to calculate rolling means, standard deviations, and final Z-scores. Filters out data that exceeds the trigger thresholds. It categorizes dates against a hardcoded list of `KNOWN_EVENTS` (e.g. 2008 Lehman Brothers collapse) to inject real historical context into the data points before returning them.
- **`services/scenario_gen.py`**
- **Purpose:** Connects to Groq AI to generate MCQs.
- **Details:** Takes the mathematical events found by `zscore_engine.py` and feeds them to the `llama-3.3-70b-versatile` model via LangChain. A massive system prompt forces the LLM to output pure JSON mapping exactly to the components required by the `Scenario` Pydantic model (Title, paragraph narrative, a best answer with rationale, and 3 decoy answers).
- **`services/database.py`**
- **Purpose:** MySQL persistence layer.
- **Details:** Sets up connection pooling to the `scenariodb.caprover.al-arcade.com` server. Includes SQL statements for `init_db()` (table creation) and `insert_scenario()` to log AI-generated MCQs robustly. Exports `get_random_scenario()` specifically for the chatbot to grab quiz questions.
- **`services/chat_agent.py`**
- **Purpose:** The interactive LangChain ReAct (Reasoning and Acting) bot.
- **Details:** Creates a conversational agent loop. It gives the AI tools: `@tool SerpApi_Search` for live web lookups (prices/news), and `@tool mcq_scenarios` to fetch DB questions. Maintains temporary session history in a dictionary `_sessions`, ensuring the bot remembers the last 20 messages per user. Complex extraction logic is included to pull the final response string from LangChain's diverse message structures.
### Frontend (`static/` Directory)
- **`static/index.html`**
- **Purpose:** The user-facing dashboard.
- **Details:** A clean, zero-dependency HTML file styled completely with CSS Variables (dark theme). It contains a form matching `models.GenerateRequest` on the left that fires Javascript `fetch('/generate')` requests. On the right, it implements a scrollable chat UI that tracks session variables and POSTs arrays of strings to `fetch('/chat')`.
### Dev Tools & Meta Files
- **`pyproject.toml`**
- **Purpose:** Python application package definitions.
- **Details:** Specifies that this requires Python >= 3.12 and strictly declares what packages the project needs (fastapi, langchain, yfinance, etc).
- **`uv.lock`**
- **Purpose:** The reproducible dependencies file.
- **Details:** Auto-generated by `uv`, it locks the exact hashes and versions of every library tree so developers sharing the project experience zero environment issues.
- **`.python-version`**
- **Purpose:** A tiny text file (just says `3.12`) telling version managers like `pyenv` or `uv` to use Python 3.12 by default here.
- **`debug_scenario.py`**
- **Purpose:** Terminal debugging.
- **Details:** A manual script to test the LangChain chat agent loop in isolation inside the terminal, skipping the FastAPI and HTML layer entirely. Great for diagnosing AI tool-calling prompt issues.
- **`test_extraction_mock.py`**
- **Purpose:** Unit testing for parsing LangChain AI formats.
- **Details:** LangChain AI messages can randomly return as plain strings, lists of dicts, or nested objects. This mocks fake responses and runs them through the parsing algorithm copied from `chat_agent.py` to assert it successfully extracts plain text in all scenarios without crashing.
[project]
name = "investment-engine"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"bcrypt>=5.0.0",
"beautifulsoup4>=4.14.3",
"chromadb>=1.5.5",
"fastapi>=0.135.1",
"langchain>=0.3.0",
"langchain-chroma>=1.1.0",
"langchain-community>=0.4.1",
"langchain-core>=0.3.0",
"langchain-groq>=0.2.0",
"langchain-huggingface>=1.2.1",
"langgraph>=1.1.1",
"mysql-connector-python>=8.0.0",
"numpy>=1.24",
"polars>=1.38.1",
"pyarrow>=23.0.1",
"pydantic>=2.12.5",
"pydantic-settings>=2.13.1",
"pypdf>=6.9.2",
"python-dotenv>=1.2.2",
"python-multipart>=0.0.22",
"sentence-transformers>=5.3.0",
"uvicorn>=0.41.0",
"yfinance>=1.2.0",
]
"""
Akinator 2.0 — Advanced Multi-Agent Investment Prediction Engine
Built with LangGraph StateGraph: 9 interconnected nodes
Graph Architecture:
START → Router → [conditional] → What-If Engine → Analyst Hub
→ Analyst Hub (direct)
Analyst Hub → News Sentiment → Confidence Scorer → Critique
→ JIT Education → Memo Generator → Format Response → END
"""
import json
import re
import urllib.request
import urllib.parse
from datetime import datetime, timedelta
from typing import TypedDict, Literal, Optional
from langchain_groq import ChatGroq
from langchain_core.messages import HumanMessage, AIMessage
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
from config import get_settings
# ── In-memory session store ──────────────────────────────────────
_akinator_sessions: dict[str, list] = {}
# ═══════════════════════════════════════════════════════════════════
# GRAPH STATE SCHEMA — persists data across all nodes
# ═══════════════════════════════════════════════════════════════════
class AkinatorState(TypedDict):
messages: list # Conversation history
user_query: str # Current user message
analyst_report: str # Raw output from ReAct analyst
news_sentiment: dict # {headlines, score, label, topic}
confidence_score: float # 0-100 confidence percentage
critique_note: str # Self-correction note
whatif_result: str # What-if analysis result
jargon_definitions: dict # {term: definition}
final_memo: str # Compiled investment memo
panel_mode: bool # Panel discussion mode
is_whatif: bool # Whether query is a what-if scenario
final_response: str # Combined final response to user
knowledge_base_id: str # Optional selected KB ID
rag_used: bool # Whether the RAG tool was called
# ═══════════════════════════════════════════════════════════════════
# ANALYST TOOLS — called by ReAct agent inside analyst_hub node
# ═══════════════════════════════════════════════════════════════════
@tool
def market_data_analyst(query: str) -> str:
"""
Market Data Analyst Agent: Fetches real-time and historical stock/asset prices,
key metrics (PE ratio, market cap, 52-week range), and recent performance data.
Use this when you need hard numbers: prices, returns, volumes, or financial ratios.
Input should be a stock symbol like 'AAPL' or asset name like 'Gold' or 'S&P 500'.
"""
try:
import yfinance as yf
symbols_to_try = [query.upper().strip()]
mappings = {
"GOLD": "GC=F", "SILVER": "SI=F", "OIL": "CL=F", "CRUDE": "CL=F",
"S&P": "^GSPC", "S&P 500": "^GSPC", "SP500": "^GSPC",
"NASDAQ": "^IXIC", "DOW": "^DJI", "BITCOIN": "BTC-USD", "BTC": "BTC-USD",
"ETHEREUM": "ETH-USD", "ETH": "ETH-USD", "EUR": "EURUSD=X",
"EGP": "USDEGP=X", "USD/EGP": "USDEGP=X", "EGYPTIAN POUND": "USDEGP=X",
"EGX30": "^CASE30", "EGX 30": "^CASE30", "EGYPT": "^CASE30",
}
mapped = mappings.get(query.upper().strip())
if mapped:
symbols_to_try.insert(0, mapped)
for symbol in symbols_to_try:
try:
ticker = yf.Ticker(symbol)
info = ticker.info
if not info or info.get("regularMarketPrice") is None:
continue
hist = ticker.history(period="3mo")
price = info.get("regularMarketPrice") or info.get("currentPrice", "N/A")
returns_data = {}
if not hist.empty and len(hist) > 1:
current = hist["Close"].iloc[-1]
if len(hist) >= 5:
returns_data["5d_return"] = f"{((current / hist['Close'].iloc[-5]) - 1) * 100:.2f}%"
if len(hist) >= 22:
returns_data["1m_return"] = f"{((current / hist['Close'].iloc[-22]) - 1) * 100:.2f}%"
if len(hist) >= 63:
returns_data["3m_return"] = f"{((current / hist['Close'].iloc[-63]) - 1) * 100:.2f}%"
result = {
"symbol": symbol,
"name": info.get("shortName", info.get("longName", symbol)),
"price": price, "currency": info.get("currency", "USD"),
"market_cap": info.get("marketCap"), "pe_ratio": info.get("trailingPE"),
"52w_high": info.get("fiftyTwoWeekHigh"), "52w_low": info.get("fiftyTwoWeekLow"),
"day_change": info.get("regularMarketChangePercent"),
"volume": info.get("regularMarketVolume"),
"sector": info.get("sector"), "industry": info.get("industry"),
"dividend_yield": info.get("dividendYield"), "returns": returns_data,
}
result = {k: v for k, v in result.items() if v is not None}
return f"[Market Analyst Report]\n{json.dumps(result, indent=2)}"
except Exception:
continue
return f"[Market Analyst] Could not find market data for '{query}'. Try a specific ticker symbol."
except Exception as e:
return f"[Market Analyst Error] {str(e)}"
@tool
def news_sentiment_analyst(query: str) -> str:
"""
News & Sentiment Analyst Agent: Searches the web for the latest financial news,
analyst opinions, earnings reports, and market sentiment about a topic.
Use this when you need current events, news context, or market sentiment.
"""
settings = get_settings()
if not settings.SERPAPI_API_KEY:
return "[News Analyst] SerpAPI key not configured."
url = f"https://serpapi.com/search.json?q={urllib.parse.quote(query + ' investment analysis')}&api_key={settings.SERPAPI_API_KEY}&num=6"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
results = []
if "answer_box" in data:
ans = data["answer_box"].get("answer") or data["answer_box"].get("snippet") or ""
if ans:
results.append(f"Quick Answer: {ans}")
if "organic_results" in data:
for res in data["organic_results"][:5]:
results.append(f"• {res.get('title', '')}: {res.get('snippet', '')}")
if not results:
return f"[News Analyst] No recent news found for '{query}'"
return f"[News & Sentiment Analyst Report]\n" + "\n".join(results)
except Exception as e:
return f"[News Analyst Error] {str(e)}"
@tool
def risk_assessment_analyst(query: str) -> str:
"""
Risk Assessment Analyst Agent: Analyzes the volatility, risk metrics, and downside
potential of a given asset. Calculates historical volatility and drawdown stats.
"""
try:
import yfinance as yf
import numpy as np
mappings = {
"GOLD": "GC=F", "BITCOIN": "BTC-USD", "BTC": "BTC-USD",
"S&P": "^GSPC", "S&P 500": "^GSPC", "EGX30": "^CASE30",
"EGYPT": "^CASE30", "EGP": "USDEGP=X",
}
symbol = mappings.get(query.upper().strip(), query.upper().strip())
ticker = yf.Ticker(symbol)
hist = ticker.history(period="1y")
if hist.empty or len(hist) < 30:
return f"[Risk Analyst] Insufficient data for '{query}'."
closes = hist["Close"].values
returns = np.diff(closes) / closes[:-1]
daily_vol = np.std(returns)
annual_vol = daily_vol * np.sqrt(252)
max_drawdown = 0
peak = closes[0]
for p in closes:
if p > peak:
peak = p
dd = (peak - p) / peak
if dd > max_drawdown:
max_drawdown = dd
mean_return = np.mean(returns) * 252
sharpe = (mean_return - 0.05) / annual_vol if annual_vol > 0 else 0
if annual_vol > 0.5:
risk_level = "VERY HIGH"
elif annual_vol > 0.3:
risk_level = "HIGH"
elif annual_vol > 0.15:
risk_level = "MODERATE"
else:
risk_level = "LOW"
result = {
"symbol": symbol, "risk_level": risk_level,
"annual_volatility": f"{annual_vol * 100:.1f}%",
"max_drawdown": f"{max_drawdown * 100:.1f}%",
"sharpe_ratio": f"{sharpe:.2f}",
"annualized_return": f"{mean_return * 100:.1f}%",
"worst_day": f"{min(returns) * 100:.2f}%",
"best_day": f"{max(returns) * 100:.2f}%",
"current_vs_52w_high": f"{((closes[-1] / max(closes)) - 1) * 100:.1f}%",
}
return f"[Risk Assessment Report]\n{json.dumps(result, indent=2)}"
except Exception as e:
return f"[Risk Analyst Error] {str(e)}"
@tool
def portfolio_strategy_search(query: str) -> str:
"""
Portfolio Strategy Analyst Agent: Searches for professional portfolio allocation
strategies, investment frameworks, and expert recommendations relevant to the query.
"""
settings = get_settings()
if not settings.SERPAPI_API_KEY:
return "[Strategy Analyst] SerpAPI key not configured."
url = f"https://serpapi.com/search.json?q={urllib.parse.quote(query + ' portfolio strategy expert advice')}&api_key={settings.SERPAPI_API_KEY}&num=5"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
results = []
if "organic_results" in data:
for res in data["organic_results"][:4]:
results.append(f"• {res.get('title', '')}: {res.get('snippet', '')}")
if not results:
return f"[Strategy Analyst] No strategy advice found for '{query}'"
return f"[Portfolio Strategy Research]\n" + "\n".join(results)
except Exception as e:
return f"[Strategy Analyst Error] {str(e)}"
@tool
def fetch_news_headlines(query: str) -> str:
"""NewsAPI Integration: Fetches top news headlines for a stock/topic via SerpAPI News tab.
Used by the News Sentiment node for headline-based sentiment scoring."""
settings = get_settings()
if not settings.SERPAPI_API_KEY:
return "[NewsAPI] API key not configured."
url = f"https://serpapi.com/search.json?q={urllib.parse.quote(query)}&api_key={settings.SERPAPI_API_KEY}&tbm=nws&num=8"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
if "news_results" not in data:
return "[NewsAPI] No news results found."
headlines = []
for n in data["news_results"][:8]:
headlines.append(f"• {n.get('title', '')} ({n.get('source', {}).get('name', 'Unknown')})")
return "[News Headlines]\n" + "\n".join(headlines)
except Exception as e:
return f"[NewsAPI Error] {str(e)}"
# ═══════════════════════════════════════════════════════════════════
# JIT EDUCATION — Financial jargon dictionary
# ═══════════════════════════════════════════════════════════════════
JARGON_DICT = {
"P/E Ratio": "Price-to-Earnings Ratio — how much investors pay for each dollar of earnings. High P/E = expensive, Low P/E = cheap.",
"Market Cap": "Total value of a company's shares. Calculated as Stock Price x Number of Shares.",
"Volatility": "How much a price swings up and down. High volatility = risky but potentially rewarding.",
"Sharpe Ratio": "Measures return per unit of risk. Above 1 is good, above 2 is great, below 0 means losing money.",
"Drawdown": "The peak-to-trough decline. A 20% drawdown means the price fell 20% from its highest point.",
"Dividend Yield": "Annual dividend payment as a percentage of stock price. Like earning interest on your investment.",
"RSI": "Relative Strength Index — momentum indicator. Above 70 = overbought (may drop), Below 30 = oversold (may rise).",
"MACD": "Moving Average Convergence Divergence — shows trend direction and momentum. Crossovers signal buy/sell.",
"Bollinger Bands": "Price channels around a moving average. Price near upper band = potentially overbought, near lower = oversold.",
"52-Week Range": "The lowest and highest prices in the past year. Shows how current price compares to recent history.",
"Beta": "How much a stock moves relative to the market. Beta > 1 = more volatile, Beta < 1 = more stable.",
"EPS": "Earnings Per Share — company's profit divided by number of shares. Higher is better.",
"ROI": "Return on Investment — profit as a percentage of what you invested.",
"Bull Market": "A market that is rising. Prices are going up and optimism is high.",
"Bear Market": "A market that is falling. Prices are dropping and pessimism grows.",
"Hedge": "A strategy to reduce risk by taking an offsetting position. Like insurance for your investment.",
"Liquidity": "How easily you can buy or sell without affecting the price. High liquidity = easy to trade.",
"Portfolio": "Your collection of investments — stocks, bonds, cash, etc. Diversification spreads risk across assets.",
"ETF": "Exchange-Traded Fund — a basket of stocks/bonds you can buy as a single investment. Easy diversification.",
"Stop-Loss": "An automatic sell order that triggers when price drops to a set level. Limits your losses.",
"Support Level": "A price floor where buying pressure tends to prevent further drops.",
"Resistance Level": "A price ceiling where selling pressure tends to prevent further rises.",
"Annualized Return": "Your return scaled to one year. A 5% return in 6 months is roughly 10% annualized.",
"Blue Chip": "Large, stable, well-established company with a history of reliable performance.",
"Diversification": "Spreading investments across different assets to reduce risk. Do not put all eggs in one basket.",
"Short Selling": "Betting that a stock will go DOWN. You borrow shares, sell them, then buy back cheaper (hopefully).",
"Margin": "Borrowing money from your broker to buy more stocks. Amplifies both gains AND losses.",
"IPO": "Initial Public Offering — when a company first sells its stock to the public.",
"Sector": "A segment of the economy grouping similar businesses — like Tech, Healthcare, or Energy.",
}
# ═══════════════════════════════════════════════════════════════════
# SENTIMENT SCORING KEYWORDS
# ═══════════════════════════════════════════════════════════════════
POSITIVE_WORDS = {
"surge", "jump", "gain", "rally", "rise", "climb", "soar", "boost", "growth", "profit",
"bullish", "upgrade", "beat", "record", "high", "strong", "outperform", "positive",
"opportunity", "momentum", "recover", "breakout", "optimistic", "confident",
}
NEGATIVE_WORDS = {
"crash", "drop", "fall", "decline", "plunge", "sink", "loss", "bearish", "downgrade",
"miss", "low", "weak", "underperform", "negative", "risk", "fear", "recession",
"selloff", "bubble", "warning", "cut", "concern", "volatile", "uncertainty",
}
# ═══════════════════════════════════════════════════════════════════
# SYSTEM PROMPTS
# ═══════════════════════════════════════════════════════════════════
AKINATOR_SYSTEM_PROMPT = """You are Akinator 2.0 — an elite Multi-Agent Investment Prediction Engine.
You operate as a panel of 4 expert analysts working together:
1. Market Data Analyst — Fetches real prices, ratios, returns
2. News & Sentiment Analyst — Finds latest news and market sentiment
3. Risk Assessment Analyst — Evaluates volatility and downside risk
4. Portfolio Strategy Advisor — Recommends allocation and strategy
YOUR PROCESS (Follow this EVERY time):
Step 1 — Gather Intelligence: Use ALL FOUR tools to collect data from every angle. DO NOT skip any tool.
Step 2 — Cross-Validate: Compare findings across all 4 analysts. Note agreements and contradictions.
Step 3 — Synthesize & Predict: Deliver your final recommendation using this format:
Akinator 2.0 Prediction Report
Market Analyst Findings:
[Summary of price data, trends, key metrics]
News & Sentiment Analysis:
[Summary of current news, sentiment direction, catalysts]
Risk Assessment:
[Risk level, volatility, max drawdown, risk-reward evaluation]
Strategic Recommendation:
[Clear, actionable advice: what to do, how to allocate, with reasoning]
Confidence Level:
[Rate your confidence: High/Medium/Low with explanation]
Disclaimer: This analysis is AI-generated for educational purposes. Always consult a licensed financial advisor before investing.
RULES:
- Today's date is {today}.
- ALWAYS use ALL 4 tools before giving your answer. This is MANDATORY.
- Give SPECIFIC numbers, prices, and percentages — never be vague.
- If the user asks about Egyptian investments, search for EGX 30, Egyptian stocks, EGP exchange rates, and Egyptian gold prices.
- For currency amounts in EGP, convert and compare opportunities in Egyptian market AND global markets.
- Be bold with predictions but honest about uncertainty.
- Keep responses well-structured and scannable.
- Investment topics ONLY — politely decline non-investment questions.
- IMPORTANT: Do NOT use markdown formatting like ## or ** in your response. Use plain text with clear section headers followed by colons. Use bullet points with dashes (-) for lists. Keep it clean and readable.
"""
PANEL_DISCUSSION_ADDENDUM = """
PANEL DISCUSSION MODE — ACTIVATED
BEFORE giving your final prediction, you MUST simulate a live roundtable discussion between 10 distinct investor personas. They will debate using the REAL DATA gathered by the 4 analyst tools above.
THE PANEL OF 10:
1. Diana "The Shield" Reeves (Risk Manager) — Ultra-conservative. Obsessed with downside protection.
2. Dr. Marcus Chen (The Quant) — Pure data, zero emotion. Speaks in numbers and ratios.
3. Jake "The Shark" Morrison (Aggressive Trader) — High risk, high reward. Loves volatility.
4. Eleanor Whitfield (Value Investor) — Warren Buffett disciple. Thinks in decades.
5. Professor Amir Hassan (Macro Economist) — Central banks, geopolitics, interest rates.
6. Kai Nakamura (Technical Analyst) — Lives and breathes charts. RSI, MACD, Bollinger Bands.
7. Victoria Sterling (Institutional Banker) — Conservative, establishment perspective.
8. Zack "Moon" Rodriguez (Crypto/Fintech) — Digital-first, loves disruption.
9. Dr. Sarah Park (Behavioral Psychologist) — Market psychology, herd behavior, FOMO.
10. Lena Greenwald (ESG Advocate) — Sustainable investing champion.
DISCUSSION RULES:
- Write it as a NATURAL, LIVELY conversation — not a list of opinions
- Personas MUST reference the ACTUAL data from the 4 analyst tools
- They should DISAGREE, interrupt each other, challenge assumptions
- Include 2-3 rounds of back-and-forth debate
- Each persona speaks IN CHARACTER with their distinct voice
After the discussion, present:
1. Panel Vote table (Persona | Verdict | Reason)
2. Panel Consensus summary
3. Debate Summary with key arguments, alliances, disagreements
4. Final Akinator 2.0 Prediction
5. Confidence Level
IMPORTANT: Do NOT use markdown formatting like ## or ** in your response. Use plain structured text.
"""
# ═══════════════════════════════════════════════════════════════════
# NODE 1: ROUTER — Classifies query type
# ═══════════════════════════════════════════════════════════════════
def router_node(state: AkinatorState) -> dict:
"""Classifies whether the user query is a What-If scenario or a regular query."""
query = state["user_query"].lower()
whatif_patterns = [
r"what if", r"what would", r"had i", r"if i had", r"would have",
r"hypothetically", r"back in", r"in hindsight", r"if i bought",
r"if i invested", r"if i sold", r"imagine i", r"suppose i",
r"\d+\s*years?\s*ago", r"\d+\s*months?\s*ago",
]
is_whatif = any(re.search(p, query) for p in whatif_patterns)
print(f"[Router Node] Query: {query[:60]}... | is_whatif={is_whatif}")
return {"is_whatif": is_whatif}
# ═══════════════════════════════════════════════════════════════════
# NODE 2: WHAT-IF ENGINE — Historical scenario analysis
# ═══════════════════════════════════════════════════════════════════
def whatif_engine_node(state: AkinatorState) -> dict:
"""Analyzes hypothetical past investment scenarios with real historical data."""
query = state["user_query"]
print(f"[What-If Engine] Analyzing: {query[:80]}...")
try:
import yfinance as yf
q_lower = query.lower()
# Extract ticker
ticker = None
common_tickers = {
"apple": "AAPL", "google": "GOOGL", "amazon": "AMZN", "tesla": "TSLA",
"microsoft": "MSFT", "nvidia": "NVDA", "meta": "META", "netflix": "NFLX",
"bitcoin": "BTC-USD", "ethereum": "ETH-USD", "gold": "GC=F", "silver": "SI=F",
"spy": "SPY", "s&p": "SPY", "nasdaq": "QQQ", "dow": "DIA",
}
for name, sym in common_tickers.items():
if name in q_lower:
ticker = sym
break
if not ticker:
ticker_match = re.search(r'\b([A-Z]{2,5})\b', query)
if ticker_match:
ticker = ticker_match.group(1)
if not ticker:
ticker = "SPY"
# Extract time period
years_ago = 1
year_match = re.search(r'(\d+)\s*years?\s*ago', q_lower)
month_match = re.search(r'(\d+)\s*months?\s*ago', q_lower)
specific_year = re.search(r'in\s*(20\d{2}|19\d{2})', q_lower)
if year_match:
years_ago = int(year_match.group(1))
elif month_match:
years_ago = int(month_match.group(1)) / 12
elif specific_year:
years_ago = datetime.now().year - int(specific_year.group(1))
# Extract investment amount
amount = 10000
amount_match = re.search(r'[\$]?\s*(\d[\d,]*(?:\.\d{2})?)', query)
if amount_match:
parsed_amount = float(amount_match.group(1).replace(',', ''))
if parsed_amount >= 10:
amount = parsed_amount
# Fetch historical data
start_date = datetime.now() - timedelta(days=int(years_ago * 365))
data = yf.download(ticker, start=start_date.strftime('%Y-%m-%d'), progress=False)
if data.empty or len(data) < 2:
return {"whatif_result": f"Could not fetch historical data for {ticker}."}
# Handle multi-level columns from yfinance (newer versions)
close_series = data['Close']
if hasattr(close_series, 'columns'):
close_series = close_series.iloc[:, 0] # DataFrame -> take first column
buy_price = float(close_series.iloc[0])
current_price = float(close_series.iloc[-1])
shares = amount / buy_price
current_value = shares * current_price
profit_loss = current_value - amount
pct_return = ((current_value / amount) - 1) * 100
# Max drawdown
closes = close_series.values.flatten()
peak = float(closes[0])
max_dd = 0
for p in closes:
pf = float(p)
if pf > peak:
peak = pf
dd = (peak - pf) / peak
if dd > max_dd:
max_dd = dd
max_price = float(close_series.max())
max_value = shares * max_price
buy_date = data.index[0].strftime('%Y-%m-%d')
verdict = "This would have been a profitable investment!" if profit_loss > 0 else "This would have resulted in a loss."
if pct_return > 100:
verdict += " Your money would have more than doubled!"
result = (
f"What-If Analysis: {ticker}\n\n"
f"Investment Details:\n"
f" - Amount Invested: ${amount:,.2f}\n"
f" - Buy Date: {buy_date}\n"
f" - Buy Price: ${buy_price:,.2f}\n"
f" - Shares Purchased: {shares:,.4f}\n\n"
f"Current Outcome:\n"
f" - Current Price: ${current_price:,.2f}\n"
f" - Current Value: ${current_value:,.2f}\n"
f" - {'Profit' if profit_loss >= 0 else 'Loss'}: ${abs(profit_loss):,.2f}\n"
f" - Total Return: {pct_return:+.2f}%\n\n"
f"Peak Performance:\n"
f" - Highest Price: ${max_price:,.2f}\n"
f" - Peak Value: ${max_value:,.2f} ({((max_value / amount) - 1) * 100:+.1f}%)\n"
f" - Max Drawdown: {max_dd * 100:.1f}%\n\n"
f"Verdict: {verdict}"
)
return {"whatif_result": result}
except Exception as e:
print(f"[What-If Engine Error] {e}")
return {"whatif_result": f"What-If analysis error: {str(e)}"}
# ═══════════════════════════════════════════════════════════════════
# NODE 3: ANALYST HUB — Main ReAct agent with tools
# ═══════════════════════════════════════════════════════════════════
def analyst_hub_node(state: AkinatorState) -> dict:
"""Core analysis node — runs the ReAct agent with all 5 analyst tools."""
print("[Analyst Hub] Running multi-agent analysis...")
settings = get_settings()
llm = ChatGroq(
api_key=settings.GROQ_API_KEY,
model_name=settings.GROQ_MODEL,
temperature=0.6 if state.get("panel_mode") else 0.5,
max_tokens=12000 if state.get("panel_mode") else 4000,
)
today = datetime.now().strftime("%Y-%m-%d")
prompt = AKINATOR_SYSTEM_PROMPT.replace("{today}", today)
if state.get("panel_mode"):
prompt += PANEL_DISCUSSION_ADDENDUM
from services.rag_engine import search_kb
@tool
def search_knowledge_base(query: str) -> str:
"""Searches the user-selected Knowledge Base for financial documents, PDFs, and definitions."""
kb_id = state.get("knowledge_base_id")
if not kb_id:
return "No Knowledge Base selected."
return search_kb(kb_id, query)
tools = [market_data_analyst, news_sentiment_analyst, risk_assessment_analyst,
portfolio_strategy_search, fetch_news_headlines, search_knowledge_base]
agent = create_react_agent(llm, tools, prompt=prompt)
# Build messages with optional what-if context
msgs = list(state.get("messages", []))
user_msg = state["user_query"]
if state.get("whatif_result"):
user_msg += f"\n\n[What-If Analysis Data — incorporate this into your response]:\n{state['whatif_result']}"
msgs.append(HumanMessage(content=user_msg))
# Try with tools first; retry without tools if Groq fails to generate a valid tool call
answer = ""
for attempt in range(2):
try:
if attempt == 0:
response = agent.invoke({"messages": msgs})
from langchain_core.messages import ToolMessage
for msg in response["messages"]:
if isinstance(msg, ToolMessage) and msg.name == "search_knowledge_base":
state["rag_used"] = True
else:
# Retry: direct LLM call without tools (fallback)
print("[Analyst Hub] Retrying without tools (fallback)...")
fallback_response = llm.invoke(msgs)
answer = fallback_response.content if isinstance(fallback_response.content, str) else str(fallback_response.content)
break
# Extract the final AI response
for msg in reversed(response["messages"]):
from langchain_core.messages import AIMessage
if isinstance(msg, AIMessage):
content = msg.content
if isinstance(content, str) and content.strip():
answer = content.strip()
break
elif isinstance(content, list):
parts = [b.get("text", "") if isinstance(b, dict) else str(b) for b in content]
joined = "".join(parts).strip()
if joined:
answer = joined
break
if answer:
break
except Exception as e:
err_str = str(e).lower()
if "tool_use_failed" in err_str or "failed_generation" in err_str or ("400" in err_str and "function" in err_str):
print(f"[Analyst Hub] Tool call failed (attempt {attempt+1}): {e}")
if attempt == 0:
continue # retry without tools
raise # re-raise other errors
if not answer:
answer = "I could not generate a prediction. Please try rephrasing your question."
print(f"[Analyst Hub] Report generated ({len(answer)} chars)")
return {"analyst_report": answer}
# ═══════════════════════════════════════════════════════════════════
# NODE 4: NEWS SENTIMENT — Headline fetching + sentiment scoring
# ═══════════════════════════════════════════════════════════════════
def news_sentiment_node(state: AkinatorState) -> dict:
"""Fetches news headlines and calculates sentiment score from keyword analysis."""
query = state["user_query"]
# Better topic extraction — keep the meaningful financial terms
topic = query.lower().strip()
# Remove question phrasing but keep the subject
for phrase in ["should i invest in", "should i buy", "should i sell",
"what do you think about", "how is", "how about",
"tell me about", "analyze", "predict", "is it good to buy",
"can i invest in", "what about", "give me analysis"]:
topic = topic.replace(phrase, "")
topic = topic.strip().strip("?.,!")
if len(topic) < 2:
topic = query[:50]
print(f"[News Sentiment Node] Topic: '{topic}'")
settings = get_settings()
if not settings.SERPAPI_API_KEY:
return {"news_sentiment": {"headlines": [], "score": 50, "label": "Neutral", "topic": topic}}
try:
url = f"https://serpapi.com/search.json?q={urllib.parse.quote(topic + ' stock market news')}&api_key={settings.SERPAPI_API_KEY}&tbm=nws&num=10"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode())
headlines = []
if "news_results" in data:
for n in data["news_results"][:10]:
headlines.append({
"title": n.get("title", ""),
"source": n.get("source", {}).get("name", "Unknown"),
"date": n.get("date", ""),
})
# Keyword-based sentiment scoring with expanded dictionaries
all_text = " ".join(h["title"].lower() for h in headlines)
pos_hits = sum(1 for w in POSITIVE_WORDS if w in all_text)
neg_hits = sum(1 for w in NEGATIVE_WORDS if w in all_text)
total = pos_hits + neg_hits
if total == 0 and headlines:
# Fallback: if no keywords matched but we have headlines, score as Mixed
score, label = 50, "Mixed"
elif total == 0:
score, label = 50, "Neutral"
else:
score = int((pos_hits / total) * 100)
label = "Bullish" if score >= 65 else "Bearish" if score <= 35 else "Mixed"
print(f"[News Sentiment Node] Score: {score}/100 ({label}) | +{pos_hits} -{neg_hits} | Headlines: {len(headlines)}")
return {"news_sentiment": {
"headlines": headlines[:5], "score": score, "label": label,
"topic": topic, "positive_signals": pos_hits, "negative_signals": neg_hits,
}}
except Exception as e:
print(f"[News Sentiment Error] {e}")
return {"news_sentiment": {"headlines": [], "score": 50, "label": "Neutral", "topic": topic}}
# ═══════════════════════════════════════════════════════════════════
# NODE 5: CONFIDENCE SCORER — Data-driven confidence calculation
# ═══════════════════════════════════════════════════════════════════
def confidence_scorer_node(state: AkinatorState) -> dict:
"""Calculates a confidence percentage based on data completeness and alignment."""
score = 40 # base score
report = state.get("analyst_report", "")
# Report substance (up to +15)
if len(report) > 500:
score += 15
elif len(report) > 200:
score += 8
# News data availability (+15) and direction strength (+5)
sentiment = state.get("news_sentiment", {})
if sentiment.get("headlines"):
score += 15
if sentiment.get("label") in ("Bullish", "Bearish"):
score += 5
# Specific data points in report (up to +15)
numbers_found = len(re.findall(r'\$[\d,.]+|\d+\.?\d*%', report))
score += min(15, numbers_found * 3)
# What-if analysis bonus (+10)
if state.get("whatif_result"):
score += 10
# Tool usage markers (+5 each, up to +20)
tool_markers = ["Market Analyst", "News", "Risk Assessment", "Portfolio Strategy"]
tools_used = sum(1 for m in tool_markers if m.lower() in report.lower())
score += tools_used * 5
score = min(95, max(10, score))
print(f"[Confidence Scorer] Score: {score}%")
return {"confidence_score": score}
# ═══════════════════════════════════════════════════════════════════
# NODE 6: CRITIQUE — Self-correction against news
# ═══════════════════════════════════════════════════════════════════
def critique_node(state: AkinatorState) -> dict:
"""Reviews the prediction against news sentiment for contradictions. Appends a Correction Note if needed."""
report = state.get("analyst_report", "")
sentiment = state.get("news_sentiment", {})
critique = ""
report_lower = report.lower()
sentiment_label = sentiment.get("label", "Neutral")
# Detect prediction direction
buy_signals = len(re.findall(r'\b(buy|bullish|upside|growth|opportunity|strong buy|accumulate)\b', report_lower))
sell_signals = len(re.findall(r'\b(sell|bearish|downside|decline|avoid|reduce|caution)\b', report_lower))
prediction_direction = "bullish" if buy_signals > sell_signals else "bearish" if sell_signals > buy_signals else "neutral"
# Check for contradiction
if prediction_direction == "bullish" and sentiment_label == "Bearish":
critique = ("Correction Note: The prediction leans bullish, but current news sentiment is predominantly bearish. "
"Recent negative headlines suggest increased short-term risk. Consider a more cautious entry or wait "
"for sentiment improvement before acting.")
elif prediction_direction == "bearish" and sentiment_label == "Bullish":
critique = ("Correction Note: The prediction leans bearish, but current news sentiment is actually bullish. "
"Positive headlines suggest improving conditions that may not be reflected in the technical analysis. "
"The downside risk may be lower than estimated.")
# Check for high-impact keywords in news
headlines_text = " ".join(h.get("title", "") for h in sentiment.get("headlines", [])).lower()
high_impact = ["fed", "interest rate", "recession", "war", "crisis", "crash", "bankruptcy", "default", "regulation", "ban"]
impacts_found = [w for w in high_impact if w in headlines_text]
if impacts_found and not critique:
critique = (f"Correction Note: High-impact events detected in current news: {', '.join(impacts_found).title()}. "
"These macro factors could significantly affect this prediction. Monitor closely before committing capital.")
if critique:
print(f"[Critique Node] Correction issued")
else:
print(f"[Critique Node] No contradictions found — prediction consistent with news")
return {"critique_note": critique}
# ═══════════════════════════════════════════════════════════════════
# NODE 7: JIT EDUCATION — Jargon detection & definitions
# ═══════════════════════════════════════════════════════════════════
def jit_education_node(state: AkinatorState) -> dict:
"""Scans the analyst report for financial jargon and provides plain-English definitions."""
report = state.get("analyst_report", "")
report_lower = report.lower()
found_terms = {}
for term, definition in JARGON_DICT.items():
if term.lower() in report_lower:
found_terms[term] = definition
print(f"[JIT Education] Found {len(found_terms)} jargon terms")
return {"jargon_definitions": found_terms}
# ═══════════════════════════════════════════════════════════════════
# NODE 8: MEMO GENERATOR — Professional investment memo
# ═══════════════════════════════════════════════════════════════════
def memo_generator_node(state: AkinatorState) -> dict:
"""Compiles the entire session into a structured investment memo."""
now = datetime.now().strftime("%B %d, %Y at %H:%M")
confidence = state.get("confidence_score", 0)
sentiment = state.get("news_sentiment", {})
critique = state.get("critique_note", "")
whatif = state.get("whatif_result", "")
conf_label = "High" if confidence >= 75 else "Moderate" if confidence >= 50 else "Low"
sentiment_label = sentiment.get("label", "N/A")
sentiment_score = sentiment.get("score", "N/A")
memo_lines = [
"INVESTMENT MEMO",
f"Generated: {now}",
f"AI Model: {get_settings().GROQ_MODEL}",
"",
f"Query: {state.get('user_query', '')}",
"",
f"Confidence Score: {confidence}% ({conf_label})",
f"News Sentiment: {sentiment_label} ({sentiment_score}/100)",
]
if critique:
memo_lines.append(f"Self-Correction: {critique}")
if whatif:
memo_lines.append("What-If Analysis: Included")
headlines = sentiment.get("headlines", [])
if headlines:
memo_lines.append("")
memo_lines.append("Key Headlines:")
for h in headlines[:3]:
memo_lines.append(f" - {h.get('title', '')} ({h.get('source', '')})")
jargon = state.get("jargon_definitions", {})
if jargon:
memo_lines.append("")
memo_lines.append(f"Educational Terms Identified: {len(jargon)}")
for term in list(jargon.keys())[:5]:
memo_lines.append(f" - {term}")
memo_lines.extend([
"",
"Data Sources: Market Data (yfinance), News (SerpAPI), Risk Analysis, Portfolio Strategy",
"Disclaimer: AI-generated for educational purposes only. Not financial advice.",
])
print(f"[Memo Generator] Memo compiled")
return {"final_memo": "\n".join(memo_lines)}
# ═══════════════════════════════════════════════════════════════════
# NODE 9: FORMAT RESPONSE — Assembles final user-facing output
# ═══════════════════════════════════════════════════════════════════
def format_response_node(state: AkinatorState) -> dict:
"""Combines all analysis data into the final formatted response."""
parts = [state.get("analyst_report", "")]
# What-if section
if state.get("whatif_result"):
parts.append("\n" + state["whatif_result"])
print(f"[Format Response] Assembling final output")
return {"final_response": "\n".join(parts)}
# ═══════════════════════════════════════════════════════════════════
# GRAPH ROUTING LOGIC
# ═══════════════════════════════════════════════════════════════════
def route_after_router(state: AkinatorState) -> str:
"""Conditional edge: routes to what-if engine or directly to analyst hub."""
if state.get("is_whatif"):
return "whatif_engine"
return "analyst_hub"
# ═══════════════════════════════════════════════════════════════════
# BUILD & COMPILE THE GRAPH
# ═══════════════════════════════════════════════════════════════════
def build_akinator_graph():
"""Constructs the LangGraph StateGraph with all 9 nodes and edges."""
graph = StateGraph(AkinatorState)
# Register all nodes
graph.add_node("router", router_node)
graph.add_node("whatif_engine", whatif_engine_node)
graph.add_node("analyst_hub", analyst_hub_node)
graph.add_node("news_sentiment", news_sentiment_node)
graph.add_node("confidence_scorer", confidence_scorer_node)
graph.add_node("critique", critique_node)
graph.add_node("jit_education", jit_education_node)
graph.add_node("memo_generator", memo_generator_node)
graph.add_node("format_response", format_response_node)
# Wire the edges
graph.add_edge(START, "router")
graph.add_conditional_edges("router", route_after_router, {
"whatif_engine": "whatif_engine",
"analyst_hub": "analyst_hub",
})
graph.add_edge("whatif_engine", "analyst_hub")
graph.add_edge("analyst_hub", "news_sentiment")
graph.add_edge("news_sentiment", "confidence_scorer")
graph.add_edge("confidence_scorer", "critique")
graph.add_edge("critique", "jit_education")
graph.add_edge("jit_education", "memo_generator")
graph.add_edge("memo_generator", "format_response")
graph.add_edge("format_response", END)
return graph.compile()
# Compile the graph once at module load
akinator_graph = build_akinator_graph()
# ═══════════════════════════════════════════════════════════════════
# PUBLIC API — Called by FastAPI endpoint
# ═══════════════════════════════════════════════════════════════════
def get_akinator_response(session_id: str, user_message: str, panel_mode: bool = False, knowledge_base_id: Optional[str] = None):
"""Run the full Akinator 2.0 LangGraph pipeline and return rich results."""
if session_id not in _akinator_sessions:
_akinator_sessions[session_id] = []
history = _akinator_sessions[session_id]
try:
# Build initial graph state
initial_state = {
"messages": list(history),
"user_query": user_message,
"analyst_report": "",
"news_sentiment": {},
"confidence_score": 0,
"critique_note": "",
"whatif_result": "",
"jargon_definitions": {},
"final_memo": "",
"panel_mode": panel_mode,
"is_whatif": False,
"final_response": "",
"knowledge_base_id": knowledge_base_id or "",
"rag_used": False,
}
# Run the graph
print(f"\n{'='*60}")
print(f"[Akinator 2.0] Starting LangGraph pipeline...")
print(f"[Akinator 2.0] Query: {user_message[:100]}...")
print(f"[Akinator 2.0] Panel Mode: {panel_mode}")
print(f"{'='*60}")
result = akinator_graph.invoke(initial_state)
# Update session history
history.append(HumanMessage(content=user_message))
history.append(AIMessage(content=result.get("final_response", "")))
if len(history) > 10:
_akinator_sessions[session_id] = history[-10:]
print(f"[Akinator 2.0] Pipeline complete. Confidence: {result.get('confidence_score', 0)}%")
# Return rich result
return {
"reply": result.get("final_response", ""),
"confidence_score": result.get("confidence_score", 0),
"news_sentiment": result.get("news_sentiment", {}),
"jargon_definitions": result.get("jargon_definitions", {}),
"memo": result.get("final_memo", ""),
"whatif_result": result.get("whatif_result", ""),
"critique_note": result.get("critique_note", ""),
"rag_used": result.get("rag_used", False),
}
except Exception as e:
error_result = _format_rate_limit_error(e)
if isinstance(error_result, dict) and error_result.get("rate_limited"):
return error_result
return {
"reply": error_result if isinstance(error_result, str) else f"Error: {str(e)}",
"confidence_score": 0, "news_sentiment": {}, "jargon_definitions": {},
"memo": "", "whatif_result": "", "critique_note": "", "rag_used": False,
}
def _format_rate_limit_error(e: Exception):
"""Parse Groq rate limit errors. Returns dict with wait_seconds if rate limited, string otherwise."""
import re
err_str = str(e).lower()
print(f"[Akinator Error] {e}")
rate_limit_keywords = [
"rate_limit", "rate limit", "429",
"tokens per minute", "tokens per day", "requests per minute",
"requests per day", "token_limit", "quota", "limit exceeded",
"resource_exhausted", "too many requests",
]
if any(kw in err_str for kw in rate_limit_keywords):
wait_seconds = None
match = re.search(r'try again in\s+(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+(?:\.\d+)?)s)?', err_str)
if match and any(match.group(i) for i in (1, 2, 3)):
h = int(match.group(1) or 0)
m = int(match.group(2) or 0)
s = float(match.group(3) or 0)
wait_seconds = h * 3600 + m * 60 + int(s) + (1 if s % 1 > 0 else 0)
if not wait_seconds:
match = re.search(r'retry.?after\s+(\d+)', err_str)
if match:
wait_seconds = int(match.group(1))
if not wait_seconds:
match = re.search(r'(\d+)\s*seconds?', err_str)
if match:
wait_seconds = int(match.group(1))
if not wait_seconds:
wait_seconds = 30
return {"rate_limited": True, "wait_seconds": wait_seconds}
return f"Akinator Error: {str(e)}"
"""
Interactive Advisor Bot — The pedagogical chat loop
"""
import json
import urllib.request
import urllib.parse
from datetime import datetime
from langchain_groq import ChatGroq
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool
from typing import Optional
from config import get_settings
from services.database import get_random_scenario
# Store active sessions in memory
_sessions: dict[str, list] = {}
@tool
def SerpApi_Search(query: str) -> str:
"""Searches the web for current prices, news, and market data."""
settings = get_settings()
if not settings.SERPAPI_API_KEY:
return "[SerpApi Error] API key not configured."
url = f"https://serpapi.com/search.json?q={urllib.parse.quote(query)}&api_key={settings.SERPAPI_API_KEY}"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode())
result = []
if "answer_box" in data:
ans = data['answer_box'].get('answer') or data['answer_box'].get('snippet') or ""
if ans:
result.append(f"Answer Box: {ans}")
if "organic_results" in data:
for res in data["organic_results"][:3]: # Top 3 results
result.append(f"Title: {res.get('title')}\nSnippet: {res.get('snippet')}")
if not result:
return f"No results found for '{query}'"
return "\n\n".join(result)
except Exception as e:
return f"[SerpApi Error] {str(e)}"
@tool
def mcq_scenarios() -> str:
"""Fetches a random investment scenario from the database."""
scenario = get_random_scenario()
if not scenario:
return json.dumps({"error": "No scenarios found in the database. Please generate some first!"})
return json.dumps({
"title": scenario["title"],
"scenario_paragraph": scenario["scenario_paragraph"],
"risk_level": scenario["risk_level"],
"best_answer": scenario["best_answer"],
"best_answer_rationale": scenario["best_answer_rationale"],
"other_options": [
{
"option": scenario["other_option1"],
"rationale": scenario["other_option1_exp"]
},
{
"option": scenario["other_option2"],
"rationale": scenario["other_option2_exp"]
},
{
"option": scenario["other_option3"],
"rationale": scenario["other_option3_exp"]
}
]
})
def get_chat_response(session_id: str, user_message: str, knowledge_base_id: Optional[str] = None) -> dict:
"""Handle a user's chat message and manage the educational loop via Agent."""
from services.rag_engine import search_kb
@tool
def search_knowledge_base(query: str) -> str:
"""Searches the user-selected Knowledge Base for financial documents, PDFs, and definitions."""
if not knowledge_base_id:
return "No Knowledge Base is currently attached. Do not attempt to search it again."
return search_kb(knowledge_base_id, query)
settings = get_settings()
llm = ChatGroq(
api_key=settings.GROQ_API_KEY,
model_name=settings.GROQ_MODEL,
temperature=0.7,
)
today = datetime.now().strftime("%Y-%m-%d")
system_prompt_str = f"""Role: Expert Investment Advisor AI for markets, strategy, and portfolio education.
1. INVESTMENT ADVICE & REAL-TIME DATA
Scope: Stocks, bonds, ETFs, diversification, risk, and analysis.
Tool: Use SerpApi_Search for all news, current prices (e.g., Gold in Egypt), and market data.
No-Refusal Rule: You have LIVE data access via SerpAPI. If asked for current info, IMMEDIATELY use the tool. NEVER say you cannot access real-time data.
Date: Today is {today}.
2. MCQ GENERATION (Practice)
Format: Question + 4 options (A-D).
Single-Line Rule: Write the question and each option into one continuous line each including the answers should be in bullet points.
Logic: Wait for user answer. Then provide the correct answer, detailed feedback for all choices, and educational context.
**. SCENARIO PRESENTATION (UI/UX):**
When a scenario is retrieved, present it with high readability using this exact structure:
---
### 📊 Investment Case Study
> [Insert a concise, professional paragraph describing the situation.]
**Key Market Data:**
* 💵 **Initial Capital:** [Value]
* 📈 **Asset Class:** [Type]
* ⏱️ **Time Horizon:** [Duration]
* ⚠️ **Risk Level:** [Rating]
**Select the Best Course of Action:**
* **A)** [Option A text]
* **B)** [Option B text]
* **C)** [Option C text]
* **D)** [Option D text]
---
*Instruction: Wait for the user's letter (A-D) before providing the rationale.*
3. MCQ SCENARIO QUESTIONS (Database)
Tool: Use mcq_scenarios.
Flow: Present scenario/table → Show randomized A-D options → Wait for answer → Provide feedback using bestAnswer or otherAnswer rationales.
4. CONSTRAINTS
Investment Only: Strictly ignore non-investment topics (politics, private life, etc.).
Evidence-Based: Support all advice with data from SerpApi_Search.
Clarity: Explain complex financial terms in accessible language."""
tools = [SerpApi_Search, mcq_scenarios, search_knowledge_base]
agent_executor = create_react_agent(llm, tools, prompt=system_prompt_str)
if session_id not in _sessions:
_sessions[session_id] = []
chat_history = _sessions[session_id]
try:
# Pass the accumulated history plus the new user message
messages_to_pass = chat_history + [HumanMessage(content=user_message)]
rag_used = False
# Try with tools first; retry without tools if Groq fails to generate a valid tool call
answer_str = ""
for attempt in range(2):
try:
if attempt == 0:
response = agent_executor.invoke({
"messages": messages_to_pass
})
# Check if the RAG tool was called
for msg in response["messages"]:
if isinstance(msg, ToolMessage) and msg.name == "search_knowledge_base":
rag_used = True
else:
# Retry: direct LLM call without tools (fallback)
print("[Chat Agent] Retrying without tools (fallback)...")
fallback_response = llm.invoke(messages_to_pass)
answer_str = fallback_response.content if isinstance(fallback_response.content, str) else str(fallback_response.content)
break
# Robustly extract text from the agent's response
for msg in reversed(response["messages"]):
if isinstance(msg, AIMessage):
content = msg.content
if isinstance(content, str) and content.strip():
answer_str = content.strip()
break
elif isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
joined = "".join(parts).strip()
if joined:
answer_str = joined
break
if answer_str:
break
except Exception as inner_e:
err_str = str(inner_e).lower()
if "tool_use_failed" in err_str or "failed_generation" in err_str or ("400" in err_str and "function" in err_str):
print(f"[Chat Agent] Tool call failed (attempt {attempt+1}): {inner_e}")
if attempt == 0:
continue # retry without tools
raise # re-raise other errors
if not answer_str:
answer_str = "I apologize, but I couldn't formulate a response. Please try asking in a different way."
# update history
chat_history.append(HumanMessage(content=user_message))
chat_history.append(AIMessage(content=answer_str))
# trim history if too large (keep last 20 messages)
if len(chat_history) > 20:
_sessions[session_id] = chat_history[-20:]
return {"reply": answer_str, "rag_used": rag_used}
except Exception as e:
return _format_chat_rate_limit_error(e)
def _format_chat_rate_limit_error(e: Exception):
"""Parse Groq rate limit errors. Returns dict with wait_seconds if rate limited, string otherwise."""
import re
err_str = str(e).lower()
print(f"[Chat Agent Error] {e}") # Log for debugging
# Only match REAL rate-limit errors — not generic "tokens" mentions
rate_limit_keywords = [
"rate_limit", "rate limit", "429",
"tokens per minute", "tokens per day", "requests per minute",
"requests per day", "token_limit", "quota", "limit exceeded",
"resource_exhausted", "too many requests",
]
if any(kw in err_str for kw in rate_limit_keywords):
wait_seconds = None
# Pattern: "try again in 1m23.456s" or "try again in 59.546s" or "try again in 2h1m30s"
match = re.search(r'try again in\s+(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+(?:\.\d+)?)s)?', err_str)
if match and any(match.group(i) for i in (1, 2, 3)):
h = int(match.group(1) or 0)
m = int(match.group(2) or 0)
s = float(match.group(3) or 0)
wait_seconds = h * 3600 + m * 60 + int(s) + (1 if s % 1 > 0 else 0) # round up
# Pattern: "retry after X seconds"
if not wait_seconds:
match = re.search(r'retry.?after\s+(\d+)', err_str)
if match:
wait_seconds = int(match.group(1))
# Pattern: bare "N seconds"
if not wait_seconds:
match = re.search(r'(\d+)\s*seconds?', err_str)
if match:
wait_seconds = int(match.group(1))
# Fallback: only if we truly couldn't parse anything
if not wait_seconds:
wait_seconds = 30
return {"rate_limited": True, "wait_seconds": wait_seconds}
return f"Agent error: {str(e)}"
"""
Database layer — MySQL operations connecting directly to CapRover instance.
"""
import json
import mysql.connector
from mysql.connector import pooling
from contextlib import contextmanager
from config import get_settings
from models import Scenario, ScenarioGenerationResult
_pool: pooling.MySQLConnectionPool | None = None
def _get_pool() -> pooling.MySQLConnectionPool:
global _pool
if _pool is None:
s = get_settings()
_pool = pooling.MySQLConnectionPool(
pool_name="scenario_pool",
pool_size=5,
host=s.MYSQL_HOST,
port=s.MYSQL_PORT,
user=s.MYSQL_USER,
password=s.MYSQL_PASSWORD,
database=s.MYSQL_DATABASE,
)
return _pool
@contextmanager
def get_connection():
conn = _get_pool().get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
"""Create the scenarios table if it doesn't exist."""
ddl = """
CREATE TABLE IF NOT EXISTS scenarios (
id VARCHAR(20) PRIMARY KEY,
title TEXT NOT NULL,
short_description TEXT,
givens_table JSON,
scenario_paragraph TEXT,
best_answer TEXT,
best_answer_rationale TEXT,
other_option1 TEXT,
other_option1_exp TEXT,
other_option2 TEXT,
other_option2_exp TEXT,
other_option3 TEXT,
other_option3_exp TEXT,
event_type VARCHAR(20) DEFAULT 'normal',
difficulty VARCHAR(20) DEFAULT 'medium',
category VARCHAR(100) DEFAULT 'General',
risk_level VARCHAR(20) DEFAULT 'Medium',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(ddl)
def insert_scenario(s: Scenario):
"""Insert one scenario — parameterized, avoiding SQL injection."""
sql = """
INSERT INTO scenarios (
id, title, short_description, givens_table,
scenario_paragraph, best_answer, best_answer_rationale,
other_option1, other_option1_exp,
other_option2, other_option2_exp,
other_option3, other_option3_exp,
event_type, difficulty, category, risk_level
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
short_description = VALUES(short_description),
givens_table = VALUES(givens_table),
scenario_paragraph = VALUES(scenario_paragraph),
risk_level = VALUES(risk_level)
"""
# Pad extra options if Gemini returns less than 3
others = s.other_answers + [
type("Obj", (), {"answer": "", "explanation": ""})()
] * 3
ctx = (s.givens_table.context or "").lower() if s.givens_table else ""
category = "Financial Crisis" if any(
kw in ctx for kw in ["crisis", "crash", "covid", "pandemic", "collapse"]
) else "General"
params = (
s.id,
s.title,
s.short_description,
json.dumps(s.givens_table.model_dump() if s.givens_table else {}),
s.scenario_paragraph,
s.best_answer.answer,
s.best_answer.rationale,
others[0].answer,
others[0].explanation,
others[1].answer,
others[1].explanation,
others[2].answer,
others[2].explanation,
s.event_type or "normal",
"medium",
category,
s.risk_level
)
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(sql, params)
def insert_all_scenarios(result: ScenarioGenerationResult) -> int:
"""Bulk insert all scenarios to CapRover database. Returns count inserted."""
count = 0
for s in result.scenarios:
insert_scenario(s)
count += 1
return count
def get_random_scenario() -> dict | None:
"""Pull one random scenario for the Interactive Advisor Bot."""
sql = """
SELECT id, title, short_description, givens_table,
scenario_paragraph, best_answer, best_answer_rationale,
other_option1, other_option1_exp,
other_option2, other_option2_exp,
other_option3, other_option3_exp,
event_type, difficulty, category, risk_level
FROM scenarios
ORDER BY RAND()
LIMIT 1
"""
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(sql)
row = cursor.fetchone()
return row
"""
RAG Engine — Handles document parsing, chunking, and ChromaDB vector storage.
"""
import os
import shutil
import uuid
import tempfile
from pathlib import Path
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
from langchain_community.document_loaders import PyPDFLoader, TextLoader, WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# We store the ChromaDB locally
DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "chroma_data")
os.makedirs(DB_DIR, exist_ok=True)
_embeddings = None
def get_embeddings():
global _embeddings
if not _embeddings:
_embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
return _embeddings
def get_kb_collection(kb_id: str) -> Chroma:
"""Gets the Chroma vectorstore for a specific knowledge base (collection)."""
return Chroma(
collection_name=kb_id,
embedding_function=get_embeddings(),
persist_directory=DB_DIR
)
def list_knowledge_bases():
"""Lists all available knowledge bases by inspecting the Chroma directory or client."""
# Since Chroma 0.4+, we interact with the persistent client directly to list collections
import chromadb
client = chromadb.PersistentClient(path=DB_DIR)
collections = client.list_collections()
# Each collection is a kb. Return list of dicts.
return [{"id": c.name, "name": c.name} for c in collections]
def ingest_document(kb_id: str, file_path_or_url: str, doc_type: str):
"""
Ingests a document or URL into the specified knowledge base.
"""
if doc_type == "pdf":
loader = PyPDFLoader(file_path_or_url)
docs = loader.load()
elif doc_type == "txt":
loader = TextLoader(file_path_or_url, encoding="utf-8")
docs = loader.load()
elif doc_type == "json":
import json
from langchain_core.documents import Document
with open(file_path_or_url, "r", encoding="utf-8") as f:
try:
data = json.load(f)
text_content = json.dumps(data, indent=2)
except json.JSONDecodeError:
f.seek(0)
text_content = f.read()
docs = [Document(page_content=text_content, metadata={"source": file_path_or_url})]
elif doc_type == "docx":
from langchain_community.document_loaders import Docx2txtLoader
loader = Docx2txtLoader(file_path_or_url)
docs = loader.load()
elif doc_type == "url":
loader = WebBaseLoader(file_path_or_url)
docs = loader.load()
else:
raise ValueError(f"Unsupported document type: {doc_type}")
# Chunking
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", " ", ""]
)
splits = text_splitter.split_documents(docs)
# Store in ChromaDB
vectorstore = get_kb_collection(kb_id)
vectorstore.add_documents(documents=splits)
return len(splits)
def search_kb(kb_id: str, query: str, top_k: int = 3) -> str:
"""
Searches the knowledge base and returns a formatted string of the top context chunks.
"""
try:
vectorstore = get_kb_collection(kb_id)
docs = vectorstore.similarity_search(query, k=top_k)
if not docs:
return "No relevant information found in the knowledge base."
results = []
for i, doc in enumerate(docs):
source = doc.metadata.get("source", "Unknown Source")
page = doc.metadata.get("page", "")
page_info = f" (Page {page})" if page else ""
results.append(f"--- Doc {i+1} | Source: {source}{page_info} ---\n{doc.page_content}")
return "\n\n".join(results)
except Exception as e:
print(f"Error searching KB {kb_id}: {e}")
return f"Error retrieving from Knowledge Base: {e}"
"""
Scenario Generator — Talks to Gemini using LangChain and strict Pydantic parsing.
"""
import json
import uuid
from typing import Optional
from langchain_groq import ChatGroq
from config import get_settings
from models import ZScoreResult, ScenarioGenerationResult
SYSTEM_PROMPT = """You are a Financial Scenario Generator AI.
Your task is to create high-quality, pedagogical financial scenarios using historical market data and Z-score analysis.
GENERATE EXACTLY 5 DIVERSE SCENARIOS from the provided events.
You MUST return ONLY valid JSON matching this exact structure:
{
"totalPossibleScenarios": 5,
"scenarios": [
{
"id": "10-character string (e.g. SCEN-00A3X)",
"title": "Clear, descriptive title",
"shortDescription": "1-2 sentence overview",
"givensTable": {
"date": "...",
"stockSymbol": "...",
"price": 0.0,
"zScore": 0.0,
"marketConditions": "...",
"eventType": "...",
"context": "..."
},
"scenarioParagraph": "Detailed narrative describing the market situation... Clearly mention whether this is a MAJOR CRISIS EVENT or NORMAL MARKET VOLATILITY.",
"bestAnswer": {
"answer": "...",
"rationale": "..."
},
"otherAnswers": [
{ "answer": "...", "explanation": "..." },
{ "answer": "...", "explanation": "..." },
{ "answer": "...", "explanation": "..." }
],
"riskLevel": "Low | Medium | High"
}
]
}
Event-Type Classification Rules:
- "major" → context contains crisis keywords (COVID, Crash, Financial Crisis, pandemic, collapse, 9/11) OR |Z-score| >= 3.0
- "normal" → everything else
Risk Classification Rules:
- "High" → Major market crashes, high volatility during crises.
- "Medium" → Notable daily volatility or uncertainty.
- "Low" → Slight corrections or rallies in otherwise stable periods.
Return ONLY valid JSON. No markdown fences, no commentary, no additional text outside the JSON object.
"""
def _build_user_prompt(
stock_symbol: str,
zscore_result: ZScoreResult,
) -> str:
events_data = [e.model_dump() for e in zscore_result.events]
return (
f"Stock Symbol: {stock_symbol}\n"
f"Total Events Available: {zscore_result.total_events}\n"
f"Window Size: {zscore_result.window_size}\n"
f"Data Points: {zscore_result.data_points}\n\n"
f"Events Data:\n{json.dumps(events_data, indent=2)}"
)
def generate_scenarios(stock_symbol: str, zscore_result: ZScoreResult, knowledge_base_id: Optional[str] = None) -> ScenarioGenerationResult:
"""
Call Groq API → parse structured JSON → return validated Pydantic model.
"""
settings = get_settings()
# Initialize Groq client
llm = ChatGroq(
api_key=settings.GROQ_API_KEY,
model_name=settings.GROQ_MODEL,
temperature=0.7,
max_tokens=8000
)
user_prompt = _build_user_prompt(stock_symbol, zscore_result)
system_message = SYSTEM_PROMPT
if knowledge_base_id:
from services.rag_engine import search_kb
style_context = search_kb(knowledge_base_id, "financial investment scenario examples style guide writing", top_k=2)
system_message += f"\n\n--- RAG STYLE EXAMPLES & CONTEXT ---\nPlease mimic the tone, structure, or terminology from this retrieved knowledge base if applicable:\n{style_context}\n------------------------------------"
# Send message to Groq
messages = [
("system", system_message),
("human", user_prompt),
]
response = llm.invoke(messages)
raw_text = response.content.strip()
# Parse & Validate
try:
data = json.loads(raw_text)
except json.JSONDecodeError as e:
# Fallback if markdown fences sneaked in
import re
match = re.search(r"```json?\s*(.*?)\s*```", raw_text, re.DOTALL)
if match:
data = json.loads(match.group(1))
else:
raise ValueError(f"Groq returned invalid JSON: {e}\n\n{raw_text[:500]}")
result = ScenarioGenerationResult.model_validate(data)
# Enrich event_type and context safely
for s in result.scenarios:
# Enforce globally unique IDs to prevent database collisions
s.id = f"SC-{uuid.uuid4().hex[:10].upper()}"
if s.givens_table:
if s.givens_table.event_type:
s.event_type = s.givens_table.event_type
else:
s.givens_table.event_type = "normal"
s.event_type = "normal"
if not s.givens_table.context:
s.givens_table.context = "General market activity"
else:
s.event_type = "normal"
return result
"""
Z-Score Engine — Polars Implementation.
Replaces the Pandas engine with high-performance lazy execution.
"""
import yfinance as yf
import polars as pl
from datetime import datetime
from models import ZScoreEvent, ZScoreResult
# Known major historical events for enriched context
KNOWN_EVENTS = {
(2020, 2): "COVID-19 Market Crash — Global pandemic triggers historic sell-off",
(2020, 3): "COVID-19 Market Crash — Peak pandemic fear and lockdowns",
(2008, 8): "2008 Financial Crisis — Lehman Brothers collapse begins",
(2008, 9): "2008 Financial Crisis — Full-blown credit market freeze",
(2008, 10): "2008 Financial Crisis — Global contagion and bank bailouts",
(2008, 11): "2008 Financial Crisis — Continued deleveraging",
(2001, 8): "Dot-com Bubble Aftermath — Tech sector implosion",
(2001, 9): "9/11 Attacks — Markets shut down then crash on reopening",
(2022, 5): "2022 Bear Market — Fed rate hikes crush growth stocks",
(2022, 6): "2022 Bear Market — Inflation fears peak",
(2020, 10): "Pre-Election Volatility — Uncertainty ahead of US elections",
(2011, 7): "US Debt Ceiling Crisis — S&P downgrades US credit rating",
(2011, 8): "US Debt Ceiling Crisis — Market turmoil continues",
(2018, 12): "Fed Tightening Scare — December 2018 sell-off",
(2015, 8): "China Devaluation Shock — Yuan devaluation rattles global markets",
}
def _classify_event(z: float, dt: datetime) -> tuple[str, str]:
"""Classify an event as major/normal and generate context."""
year = dt.year
month = dt.month
abs_z = abs(z)
known = KNOWN_EVENTS.get((year, month))
if known or abs_z >= 3.0:
event_type = "major"
if known:
context = known
elif z < 0:
context = "Significant market decline — potential crisis event"
else:
context = "Significant market rally — unusual positive movement"
else:
event_type = "normal"
if z < 0:
context = "Notable market decline — day-to-day volatility"
else:
context = "Notable market increase — day-to-day volatility"
return event_type, context
def identify_events(symbol: str, window: int = 100, trigger_min: float = -2.5, trigger_max: float = 2.5) -> ZScoreResult:
"""
Downloads data with yfinance and calculates rolling Z-scores using Polars.
Accepts empty symbol to aggregate a basket of top stocks.
Returns filtered ZScoreResult.
"""
import random
symbols_to_fetch = [symbol] if symbol else ["SPY", "QQQ", "AAPL", "MSFT", "TSLA", "AMZN", "NVDA", "META"]
if not symbol:
# Pick 3 random stocks + SPY to avoid massive token blowout
symbols_to_fetch = ["SPY"] + random.sample([s for s in symbols_to_fetch if s != "SPY"], 3)
all_events = []
total_data_points = 0
for current_symbol in symbols_to_fetch:
try:
# 1. Fetch data
data = yf.download(current_symbol, period="5y", progress=False)
if data.empty:
continue
# 2. Reset index to get Date as a column, handle multi-layer columns from yf
df_pd = data.reset_index()
# Flatten columns if yfinance returns multi-index
if isinstance(df_pd.columns, pl.DataFrame): # safety fallback
pass
new_cols = []
for col in df_pd.columns:
if isinstance(col, tuple):
# Filter out empty strings from the tuple and join
parts = [str(c) for c in col if c]
new_cols.append('_'.join(parts).strip('_'))
else:
new_cols.append(str(col))
df_pd.columns = new_cols
# Ensure we have Date and Close
date_col = next((c for c in df_pd.columns if 'date' in c.lower()), None)
close_col = next((c for c in df_pd.columns if 'close' in c.lower() and ('_' not in c or current_symbol.lower() in c.lower() or 'close' == c.lower())), None)
if not close_col: # Fallback to just the first column that has 'close'
close_col = next((c for c in df_pd.columns if 'close' in c.lower()), None)
if not date_col or not close_col:
print(f"Skipping {current_symbol}: Could not cleanly identify Date/Close columns.")
continue
df_pd = df_pd[[date_col, close_col]].rename(columns={date_col: "Date", close_col: "Close"})
# Drop NaN
df_pd = df_pd.dropna()
if len(df_pd) < window:
continue
# 3. Process with Polars
df = pl.from_pandas(df_pd)
# Ensure correct types
df = df.with_columns([
pl.col("Date").cast(pl.Datetime)
])
# 4. Calculate Z-Scores Lazy Execution
q = (
df.lazy()
.with_columns([
pl.col("Close").rolling_mean(window_size=window).alias("Mean"),
pl.col("Close").rolling_std(window_size=window).alias("Std")
])
.with_columns([
((pl.col("Close") - pl.col("Mean")) / pl.col("Std")).alias("Z_Score")
])
.drop_nulls()
)
# Force computation
processed_df = q.collect()
total_data_points += len(processed_df)
# Filter significant events
events_df = processed_df.filter(
(pl.col("Z_Score") <= trigger_min) | (pl.col("Z_Score") >= trigger_max)
)
# Convert to Pydantic models
for row in events_df.to_dicts():
z = round(float(row["Z_Score"]), 3)
dt = row["Date"]
event_type, context = _classify_event(z, dt)
# Append stock symbol to context for mixed baskets
if not symbol:
context = f"[{current_symbol}] " + context
all_events.append(ZScoreEvent(
date=dt.strftime("%Y-%m-%d"),
price=round(float(row["Close"]), 2),
z_score=z,
event_type=event_type,
context=context,
direction="decline" if z < 0 else "rally"
))
except Exception as e:
print(f"Warning: Z-Score calculation failed for {current_symbol}: {str(e)}")
continue
if not all_events:
raise RuntimeError(f"No events found for any requested symbols.")
# Sort events by date descending and limit to top 150 to keep LLM context size healthy
all_events = sorted(all_events, key=lambda x: abs(x.z_score), reverse=True)[:150]
return ZScoreResult(
events=all_events,
total_events=len(all_events),
window_size=window,
data_points=total_data_points
)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FinSim — Investment Scenario Engine</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#06080f;--bg2:#0c1120;--panel:#111827;--panel2:#1a2236;
--border:#1e293b;--border2:#2d3a52;
--text:#f1f5f9;--text2:#94a3b8;--text3:#64748b;
--accent:#6366f1;--accent2:#818cf8;--accent-glow:rgba(99,102,241,.15);
--green:#10b981;--green-bg:rgba(16,185,129,.12);
--red:#ef4444;--red-bg:rgba(239,68,68,.12);
--orange:#f59e0b;--orange-bg:rgba(245,158,11,.12);
--blue:#3b82f6;--blue-bg:rgba(59,130,246,.12);
--radius:12px;--radius-sm:8px;
--shadow:0 4px 24px rgba(0,0,0,.4);
}
html{scroll-behavior:smooth}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden}
a{color:var(--accent2);text-decoration:none}
/* ── Utility ── */
.hidden{display:none!important}
.fade-in{animation:fadeIn .35s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
@keyframes spin{to{transform:rotate(360deg)}}
.spinner{width:20px;height:20px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.badge-green{background:var(--green-bg);color:var(--green)}
.badge-red{background:var(--red-bg);color:var(--red)}
.badge-orange{background:var(--orange-bg);color:var(--orange)}
.badge-blue{background:var(--blue-bg);color:var(--blue)}
.badge-purple{background:var(--accent-glow);color:var(--accent2)}
/* ── Auth Screen ── */
#auth-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem;background:radial-gradient(ellipse at 50% 0%,rgba(99,102,241,.08),transparent 60%)}
.auth-card{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:2.5rem;width:100%;max-width:420px;box-shadow:var(--shadow)}
.auth-card h1{font-size:1.7rem;font-weight:700;text-align:center;margin-bottom:.25rem}
.auth-card .subtitle{text-align:center;color:var(--text2);font-size:.85rem;margin-bottom:2rem}
.auth-card .logo{text-align:center;font-size:2.2rem;margin-bottom:1rem}
.form-group{margin-bottom:1.1rem}
.form-group label{display:block;font-size:.78rem;font-weight:500;color:var(--text2);margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.5px}
.form-input{width:100%;padding:.75rem 1rem;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.9rem;transition:border .2s}
.form-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.form-input::placeholder{color:var(--text3)}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5rem;padding:.75rem 1.5rem;border-radius:var(--radius-sm);font-size:.9rem;font-weight:600;border:none;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-primary{background:var(--accent);color:#fff;width:100%}
.btn-primary:hover{background:var(--accent2);transform:translateY(-1px);box-shadow:0 4px 16px rgba(99,102,241,.3)}
.btn-primary:disabled{opacity:.5;cursor:not-allowed;transform:none}
.btn-secondary{background:var(--panel2);color:var(--text);border:1px solid var(--border)}
.btn-secondary:hover{background:var(--border);border-color:var(--border2)}
.btn-ghost{background:transparent;color:var(--text2);padding:.5rem .75rem;font-size:.82rem}
.btn-ghost:hover{color:var(--text)}
.btn-sm{padding:.5rem 1rem;font-size:.8rem}
.btn-green{background:var(--green);color:#fff}
.btn-green:hover{background:#0d9f6e}
.auth-toggle{text-align:center;margin-top:1.5rem;font-size:.85rem;color:var(--text2)}
.auth-toggle a{cursor:pointer;color:var(--accent2);font-weight:500}
.auth-error{background:var(--red-bg);border:1px solid rgba(239,68,68,.25);color:var(--red);padding:.65rem 1rem;border-radius:var(--radius-sm);font-size:.82rem;margin-bottom:1rem}
/* ── App Shell ── */
#app-screen{display:none;min-height:100vh}
/* Sidebar */
.sidebar{position:fixed;left:0;top:0;bottom:0;width:240px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:50;transition:transform .3s}
.sidebar-brand{padding:1.5rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid var(--border)}
.sidebar-brand span{font-size:1.2rem;font-weight:700}
.sidebar-brand .logo-icon{font-size:1.5rem}
.sidebar-nav{flex:1;padding:1rem .75rem;display:flex;flex-direction:column;gap:2px}
.nav-item{display:flex;align-items:center;gap:.7rem;padding:.7rem 1rem;border-radius:var(--radius-sm);color:var(--text2);font-size:.88rem;font-weight:500;cursor:pointer;transition:all .15s}
.nav-item:hover{background:var(--panel2);color:var(--text)}
.nav-item.active{background:var(--accent-glow);color:var(--accent2);font-weight:600}
.nav-item .icon{font-size:1.1rem;width:22px;text-align:center}
.sidebar-footer{padding:1rem;border-top:1px solid var(--border)}
.user-info{display:flex;align-items:center;gap:.6rem;padding:.5rem;border-radius:var(--radius-sm)}
.user-avatar{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#a855f7);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.8rem;color:#fff;flex-shrink:0}
.user-name{font-size:.85rem;font-weight:600;line-height:1.2}
.user-role{font-size:.7rem;color:var(--text3);text-transform:capitalize}
/* Main content */
.main{margin-left:240px;padding:2rem;min-height:100vh}
.page{display:none}
.page.active{display:block}
.page-header{margin-bottom:2rem}
.page-header h2{font-size:1.6rem;font-weight:700;margin-bottom:.3rem}
.page-header p{color:var(--text2);font-size:.9rem}
/* Cards */
.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:1.5rem;transition:border-color .2s}
.card:hover{border-color:var(--border2)}
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.25rem}
.stat-card{text-align:center;padding:1.8rem}
.stat-card .stat-icon{font-size:1.8rem;margin-bottom:.5rem}
.stat-card .stat-value{font-size:2rem;font-weight:800;background:linear-gradient(135deg,var(--accent2),#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.stat-card .stat-label{font-size:.78rem;color:var(--text2);margin-top:.25rem;text-transform:uppercase;letter-spacing:.5px}
/* ── Dashboard ── */
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:2rem}
@media(max-width:900px){.stats-grid{grid-template-columns:repeat(2,1fr)}}
.recent-table{width:100%;border-collapse:collapse;margin-top:1rem}
.recent-table th{text-align:left;padding:.75rem 1rem;font-size:.75rem;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);border-bottom:1px solid var(--border)}
.recent-table td{padding:.75rem 1rem;font-size:.85rem;border-bottom:1px solid var(--border)}
.recent-table tr:hover td{background:var(--panel2)}
/* ── Generator ── */
.gen-layout{display:grid;grid-template-columns:1fr 1fr;gap:2rem}
@media(max-width:800px){.gen-layout{grid-template-columns:1fr}}
.gen-status{margin-top:1.5rem;padding:1.25rem;border-radius:var(--radius-sm);background:var(--bg2);border:1px solid var(--border);font-size:.88rem;min-height:100px;white-space:pre-wrap;line-height:1.6}
/* ── AI Chat ── */
.chat-layout{height:calc(100vh - 120px);display:flex;flex-direction:column}
.chat-messages{flex:1;overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:1rem;background:var(--bg2);border-radius:var(--radius);border:1px solid var(--border);margin-bottom:1rem}
.chat-bubble{max-width:75%;padding:1rem 1.2rem;border-radius:16px;font-size:.9rem;line-height:1.6;white-space:pre-wrap}
.chat-bubble.user{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:4px}
.chat-bubble.bot{align-self:flex-start;background:var(--panel);border:1px solid var(--border);border-bottom-left-radius:4px}
.chat-bubble.typing{animation:pulse 1.5s infinite;color:var(--text3);font-style:italic}
.chat-input-bar{display:flex;gap:.75rem}
.chat-input-bar input{flex:1}
.chat-input-bar button{width:auto;flex-shrink:0}
/* ── Akinator 2.0 ── */
.akinator-banner{background:linear-gradient(135deg,rgba(139,92,246,.15),rgba(236,72,153,.12),rgba(245,158,11,.1));border:1px solid rgba(139,92,246,.25);border-radius:var(--radius);padding:1.5rem 2rem;margin-bottom:1.5rem;position:relative;overflow:hidden}
.akinator-banner::before{content:'';position:absolute;top:-50%;right:-20%;width:300px;height:300px;background:radial-gradient(circle,rgba(139,92,246,.12),transparent 70%);pointer-events:none}
.akinator-banner .ak-title{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,#a78bfa,#f472b6,#fbbf24);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.35rem;display:flex;align-items:center;gap:.5rem}
.akinator-banner .ak-desc{color:var(--text2);font-size:.88rem;line-height:1.5;max-width:600px}
.akinator-banner .ak-badges{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.75rem}
.akinator-chat-layout{height:calc(100vh - 260px);display:flex;flex-direction:column}
.akinator-chat-messages{flex:1;overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:1rem;background:linear-gradient(180deg,rgba(139,92,246,.03),var(--bg2));border-radius:var(--radius);border:1px solid rgba(139,92,246,.2);margin-bottom:1rem}
.akinator-chat-messages .chat-bubble.bot{border-color:rgba(139,92,246,.2);background:linear-gradient(135deg,var(--panel),rgba(139,92,246,.06))}
.akinator-input-bar{display:flex;gap:.75rem}
.akinator-input-bar input{flex:1}
.akinator-input-bar button{width:auto;flex-shrink:0;background:linear-gradient(135deg,#8b5cf6,#ec4899);border:none}
.akinator-input-bar button:hover{background:linear-gradient(135deg,#7c3aed,#db2777);transform:translateY(-1px);box-shadow:0 4px 16px rgba(139,92,246,.3)}
/* Panel Mode Toggle */
.panel-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:1rem;padding-top:1rem;border-top:1px solid rgba(139,92,246,.15)}
.panel-toggle-left{display:flex;align-items:center;gap:.6rem}
.panel-toggle-left .pt-icon{font-size:1.2rem}
.panel-toggle-left .pt-label{font-size:.82rem;font-weight:600;color:var(--text)}
.panel-toggle-left .pt-hint{font-size:.72rem;color:var(--text3);margin-top:.1rem}
.toggle-switch{position:relative;width:48px;height:26px;cursor:pointer}
.toggle-switch input{opacity:0;width:0;height:0}
.toggle-track{position:absolute;inset:0;background:var(--panel2);border:1px solid var(--border);border-radius:13px;transition:all .3s}
.toggle-track::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;background:#fff;border-radius:50%;transition:transform .3s,box-shadow .3s}
.toggle-switch input:checked+.toggle-track{background:linear-gradient(135deg,#8b5cf6,#ec4899);border-color:rgba(139,92,246,.5)}
.toggle-switch input:checked+.toggle-track::after{transform:translateX(22px);box-shadow:0 0 8px rgba(139,92,246,.5)}
.panel-status{display:inline-flex;align-items:center;gap:.35rem;font-size:.72rem;font-weight:600;padding:3px 10px;border-radius:20px;transition:all .3s}
.panel-status.off{color:var(--text3);background:transparent}
.panel-status.on{color:#c084fc;background:rgba(139,92,246,.12)}
/* Rendered Markdown in Chat Bubbles */
.chat-bubble.bot h1,.chat-bubble.bot h2,.chat-bubble.bot h3{font-size:1rem;font-weight:700;color:var(--accent2);margin:1rem 0 .4rem 0;padding-bottom:.3rem;border-bottom:1px solid var(--border)}
.chat-bubble.bot h1:first-child,.chat-bubble.bot h2:first-child,.chat-bubble.bot h3:first-child{margin-top:0}
.chat-bubble.bot p{margin:.4rem 0;line-height:1.65}
.chat-bubble.bot ul,.chat-bubble.bot ol{margin:.4rem 0 .4rem 1.2rem;line-height:1.65}
.chat-bubble.bot li{margin:.2rem 0}
.chat-bubble.bot strong{color:var(--text);font-weight:600}
.chat-bubble.bot em{color:var(--text2);font-style:italic}
.chat-bubble.bot hr{border:none;border-top:1px solid var(--border);margin:.8rem 0}
.chat-bubble.bot blockquote{border-left:3px solid var(--accent);padding-left:.8rem;margin:.5rem 0;color:var(--text2);font-style:italic}
.chat-bubble.bot code{background:rgba(139,92,246,.12);padding:1px 5px;border-radius:3px;font-size:.82rem}
.chat-bubble.bot table{width:100%;border-collapse:collapse;margin:.6rem 0;font-size:.82rem}
.chat-bubble.bot th{text-align:left;padding:.4rem .6rem;border-bottom:1px solid var(--border);color:var(--accent2);font-weight:600;font-size:.75rem;text-transform:uppercase}
.chat-bubble.bot td{padding:.4rem .6rem;border-bottom:1px solid rgba(30,41,59,.5)}
.chat-bubble.bot tr:hover td{background:rgba(139,92,246,.04)}
/* Akinator Metadata Panels */
.ak-meta-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem;padding-top:.8rem;border-top:1px solid var(--border)}
.ak-meta-card{display:flex;align-items:center;gap:.4rem;padding:.35rem .7rem;border-radius:20px;font-size:.75rem;font-weight:600}
.ak-meta-card.confidence{background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.2);color:#34d399}
.ak-meta-card.confidence.med{background:rgba(245,158,11,.1);border-color:rgba(245,158,11,.2);color:#fbbf24}
.ak-meta-card.confidence.low{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.2);color:#f87171}
.ak-meta-card.sentiment{background:rgba(96,165,250,.1);border:1px solid rgba(96,165,250,.2);color:#93c5fd}
.ak-meta-card.sentiment.bullish{background:rgba(16,185,129,.1);border-color:rgba(16,185,129,.2);color:#34d399}
.ak-meta-card.sentiment.bearish{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.2);color:#f87171}
.ak-critique-box{margin-top:.6rem;padding:.6rem .8rem;border-radius:8px;font-size:.78rem;line-height:1.5;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);color:#fcd34d}
.ak-jargon-panel{margin-top:.6rem;padding:.6rem .8rem;border-radius:8px;background:rgba(139,92,246,.06);border:1px solid rgba(139,92,246,.15)}
.ak-jargon-panel summary{font-size:.78rem;font-weight:600;color:var(--accent2);cursor:pointer;margin-bottom:.3rem}
.ak-jargon-panel .jargon-item{display:flex;gap:.4rem;padding:.25rem 0;font-size:.75rem;border-bottom:1px solid rgba(30,41,59,.3)}
.ak-jargon-panel .jargon-item:last-child{border-bottom:none}
.ak-jargon-panel .jt{font-weight:600;color:var(--accent2);white-space:nowrap;min-width:90px}
.ak-jargon-panel .jd{color:var(--text2)}
.ak-memo-panel{margin-top:.6rem}
.ak-memo-panel summary{font-size:.78rem;font-weight:600;color:var(--accent2);cursor:pointer}
.ak-memo-panel pre{margin-top:.4rem;padding:.6rem;border-radius:6px;background:var(--bg2);border:1px solid var(--border);font-size:.72rem;line-height:1.5;white-space:pre-wrap;color:var(--text2);font-family:'Inter',sans-serif;max-height:300px;overflow-y:auto}
/* Rate Limit Timer */
.rate-limit-bubble{align-self:flex-start;background:linear-gradient(135deg,rgba(245,158,11,.1),rgba(239,68,68,.08));border:1px solid rgba(245,158,11,.25);border-radius:16px;border-bottom-left-radius:4px;padding:1.2rem 1.5rem;max-width:75%;font-size:.9rem;line-height:1.6}
.rate-limit-bubble .rl-title{font-weight:700;color:var(--orange);margin-bottom:.4rem;font-size:.95rem}
.rate-limit-bubble .rl-timer{font-size:1.8rem;font-weight:800;font-variant-numeric:tabular-nums;color:var(--text);margin:.5rem 0;letter-spacing:1px}
.rate-limit-bubble .rl-done{color:var(--green);font-weight:600}
@keyframes timerPulse{0%,100%{opacity:1}50%{opacity:.6}}
.rl-timer.ticking{animation:timerPulse 1s ease-in-out infinite}
/* ── MCQ ── */
.quiz-setup{max-width:500px;margin:0 auto}
.quiz-progress{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}
.progress-bar{flex:1;height:6px;background:var(--bg2);border-radius:3px;overflow:hidden}
.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#a855f7);border-radius:3px;transition:width .4s ease}
.progress-text{font-size:.8rem;color:var(--text2);white-space:nowrap}
.question-card{max-width:800px;margin:0 auto}
.question-card .q-title{font-size:1.15rem;font-weight:600;margin-bottom:.5rem}
.question-card .q-paragraph{color:var(--text2);font-size:.88rem;line-height:1.7;margin-bottom:1.5rem;padding:1rem;background:var(--bg2);border-radius:var(--radius-sm);border-left:3px solid var(--accent)}
.givens-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.5rem;margin-bottom:1.5rem}
.givens-item{background:var(--bg2);padding:.6rem .8rem;border-radius:var(--radius-sm);font-size:.78rem}
.givens-item .g-label{color:var(--text3);text-transform:uppercase;letter-spacing:.5px;font-size:.65rem;margin-bottom:.15rem}
.givens-item .g-value{font-weight:600;color:var(--text)}
.option-btn{display:block;width:100%;text-align:left;padding:1rem 1.2rem;margin-bottom:.6rem;background:var(--panel2);border:2px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:.88rem;cursor:pointer;transition:all .2s;font-family:inherit;line-height:1.4}
.option-btn:hover{border-color:var(--accent);background:var(--accent-glow)}
.option-btn.selected{border-color:var(--accent);background:var(--accent-glow)}
.option-btn.correct{border-color:var(--green)!important;background:var(--green-bg)!important}
.option-btn.wrong{border-color:var(--red)!important;background:var(--red-bg)!important}
.option-btn .opt-label{font-weight:700;margin-right:.5rem;color:var(--accent2)}
.option-btn.correct .opt-label{color:var(--green)}
.option-btn.wrong .opt-label{color:var(--red)}
.option-btn:disabled{cursor:default}
.explanation-box{margin-top:1rem;padding:1.2rem;border-radius:var(--radius-sm);font-size:.85rem;line-height:1.6}
.explanation-box.correct-exp{background:var(--green-bg);border:1px solid rgba(16,185,129,.2)}
.explanation-box.wrong-exp{background:var(--red-bg);border:1px solid rgba(239,68,68,.2)}
.explanation-box strong{display:block;margin-bottom:.3rem}
/* ── Results ── */
.results-header{text-align:center;padding:2.5rem 1rem;margin-bottom:2rem}
.results-score{font-size:4rem;font-weight:800;margin:.5rem 0}
.results-score.good{color:var(--green)}
.results-score.ok{color:var(--orange)}
.results-score.bad{color:var(--red)}
.result-item{margin-bottom:1.25rem;padding:1.25rem;border-radius:var(--radius);border:1px solid var(--border);background:var(--panel)}
.result-item .ri-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem}
.result-item .ri-title{font-weight:600;font-size:.95rem}
.result-item .ri-detail{font-size:.82rem;color:var(--text2);line-height:1.6;margin-top:.5rem}
/* Leaderboard */
.lb-row{display:flex;align-items:center;gap:1rem;padding:.75rem 1rem;border-bottom:1px solid var(--border)}
.lb-row:last-child{border-bottom:none}
.lb-rank{font-size:1.1rem;font-weight:800;width:30px;text-align:center;color:var(--text3)}
.lb-rank.top{color:var(--orange)}
.lb-name{flex:1;font-weight:600;font-size:.9rem}
.lb-score{font-weight:700;color:var(--accent2);font-size:.95rem}
/* Mobile */
.mobile-header{display:none;position:fixed;top:0;left:0;right:0;height:56px;background:var(--panel);border-bottom:1px solid var(--border);z-index:40;align-items:center;padding:0 1rem}
.hamburger{background:none;border:none;color:var(--text);font-size:1.5rem;cursor:pointer;padding:.25rem}
@media(max-width:768px){
.sidebar{transform:translateX(-100%)}
.sidebar.open{transform:translateX(0)}
.mobile-header{display:flex}
.main{margin-left:0;padding:1rem;padding-top:70px}
.stats-grid{grid-template-columns:1fr 1fr}
.gen-layout{grid-template-columns:1fr}
.chat-bubble{max-width:90%}
}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:45}
.overlay.show{display:block}
</style>
</head>
<body>
<!-- ═══ AUTH SCREEN ═══ -->
<div id="auth-screen">
<div class="auth-card fade-in">
<div class="logo">📊</div>
<h1>FinSim</h1>
<p class="subtitle">Investment Scenario Engine</p>
<div id="auth-error" class="auth-error hidden"></div>
<!-- Login Form -->
<div id="login-form">
<div class="form-group"><label>Email</label><input class="form-input" id="login-email" type="email" placeholder="your@email.com"></div>
<div class="form-group"><label>Password</label><input class="form-input" id="login-password" type="password" placeholder="••••••••"></div>
<button class="btn btn-primary" id="login-btn" onclick="doLogin()">Sign In</button>
<p class="auth-toggle">Don't have an account? <a onclick="showRegister()">Create one</a></p>
</div>
<!-- Register Form -->
<div id="register-form" class="hidden">
<div class="form-group"><label>Username</label><input class="form-input" id="reg-username" placeholder="johndoe"></div>
<div class="form-group"><label>Email</label><input class="form-input" id="reg-email" type="email" placeholder="your@email.com"></div>
<div class="form-group"><label>Password</label><input class="form-input" id="reg-password" type="password" placeholder="Min 4 characters"></div>
<button class="btn btn-primary" id="reg-btn" onclick="doRegister()">Create Account</button>
<p class="auth-toggle">Already have an account? <a onclick="showLogin()">Sign in</a></p>
</div>
</div>
</div>
<!-- ═══ APP SCREEN ═══ -->
<div id="app-screen">
<div class="overlay" id="overlay" onclick="closeSidebar()"></div>
<div class="mobile-header"><button class="hamburger" onclick="toggleSidebar()"></button><span style="margin-left:.75rem;font-weight:700">FinSim</span></div>
<!-- Sidebar -->
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand"><span class="logo-icon">📊</span><span>FinSim</span></div>
<div class="sidebar-nav">
<div class="nav-item active" data-page="dashboard" onclick="navigate('dashboard')"><span class="icon">🏠</span>Dashboard</div>
<div class="nav-item" data-page="generator" onclick="navigate('generator')"><span class="icon"></span>Generator</div>
<div class="nav-item" data-page="chat" onclick="navigate('chat')"><span class="icon">🤖</span>AI Advisor</div>
<div class="nav-item" data-page="akinator" onclick="navigate('akinator')"><span class="icon">🔮</span>Akinator 2.0</div>
<div class="nav-item" data-page="knowledgebase" onclick="navigate('knowledgebase')"><span class="icon">🧠</span>Knowledge Base</div>
<div class="nav-item" data-page="quiz" onclick="navigate('quiz')"><span class="icon">📝</span>Practice MCQ</div>
<div class="nav-item" data-page="leaderboard" onclick="navigate('leaderboard')"><span class="icon">🏆</span>Leaderboard</div>
</div>
<div class="sidebar-footer">
<div class="user-info"><div class="user-avatar" id="user-avatar">?</div><div><div class="user-name" id="user-display-name">User</div><div class="user-role" id="user-display-role">user</div></div></div>
<button class="btn btn-ghost" style="width:100%;margin-top:.5rem" onclick="doLogout()">Sign Out</button>
</div>
</nav>
<!-- Main Content -->
<div class="main">
<!-- ── DASHBOARD ── -->
<div class="page active" id="page-dashboard">
<div class="page-header"><h2>Dashboard</h2><p>Your learning overview at a glance</p></div>
<div class="stats-grid" id="stats-grid">
<div class="card stat-card"><div class="stat-icon">🎯</div><div class="stat-value" id="stat-score">0</div><div class="stat-label">Total Score</div></div>
<div class="card stat-card"><div class="stat-icon">📝</div><div class="stat-value" id="stat-quizzes">0</div><div class="stat-label">Quizzes Taken</div></div>
<div class="card stat-card"><div class="stat-icon"></div><div class="stat-value" id="stat-accuracy">0%</div><div class="stat-label">Accuracy</div></div>
<div class="card stat-card"><div class="stat-icon">📊</div><div class="stat-value" id="stat-avg">0</div><div class="stat-label">Avg Score</div></div>
</div>
<div class="card">
<h3 style="margin-bottom:1rem;font-size:1.1rem">Recent Quiz Attempts</h3>
<div id="recent-attempts" style="color:var(--text2);font-size:.88rem">Loading...</div>
</div>
</div>
<!-- ── GENERATOR ── -->
<div class="page" id="page-generator">
<div class="page-header"><h2>Scenario Generator</h2><p>Fetch real market data, detect anomalies with Z-Scores, and generate AI-powered scenarios</p></div>
<div class="gen-layout">
<div class="card">
<h3 style="margin-bottom:1.25rem">Configuration</h3>
<div class="form-group"><label>Stock Symbol</label><input class="form-input" id="gen-symbol" placeholder="Leave empty for mixed basket (SPY, AAPL, TSLA...)" value=""></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem">
<div class="form-group"><label>Z-Score Min</label><input class="form-input" id="gen-zmin" type="number" value="-2.5" step="0.1"></div>
<div class="form-group"><label>Z-Score Max</label><input class="form-input" id="gen-zmax" type="number" value="2.5" step="0.1"></div>
<div class="form-group"><label>Window</label><input class="form-input" id="gen-window" type="number" value="100"></div>
</div>
<div class="form-group"><label>Style Knowledge Base (Optional)</label><select class="form-input kb-selector" id="gen-kb"><option value="">No Knowledge Base</option></select></div>
<button class="btn btn-primary" id="gen-btn" onclick="runGenerator()">⚡ Generate Scenarios</button>
</div>
<div class="card">
<h3 style="margin-bottom:1.25rem">Status</h3>
<div class="gen-status" id="gen-status">Ready. Configure parameters and click Generate to start the pipeline.<br><br>The pipeline will:<br>1. Fetch 5 years of stock data (yfinance)<br>2. Calculate rolling Z-Scores (Polars)<br>3. Detect market anomalies<br>4. Generate MCQ scenarios (Groq AI)<br>5. Save to database</div>
</div>
</div>
</div>
<!-- ── AI CHAT ── -->
<div class="page" id="page-chat">
<div class="page-header"><h2>AI Investment Advisor</h2><p>Chat with the AI for investment advice, market data, and scenario practice</p></div>
<div class="chat-layout">
<div class="chat-messages" id="chat-messages">
<div class="chat-bubble bot">👋 Hello! I'm your FinSim Investment Advisor. I can help you with:<br><br>• Real-time market data & prices<br>• Investment strategy questions<br>• Practice MCQ scenarios<br>• Financial concept explanations<br><br>What would you like to explore?</div>
</div>
<div class="chat-input-bar">
<select class="form-input kb-selector" id="chat-kb" style="max-width: 180px;"><option value="">No KB Context</option></select>
<input class="form-input" id="chat-input" placeholder="Ask about markets, investments, or request a scenario..." onkeydown="if(event.key==='Enter')sendChat()">
<button class="btn btn-primary" onclick="sendChat()">Send</button>
</div>
</div>
</div>
<!-- ── QUIZ ── -->
<div class="page" id="page-quiz">
<div id="quiz-setup-view">
<div class="page-header"><h2>Practice MCQ</h2><p>Test your knowledge with real financial scenarios</p></div>
<div class="quiz-setup card">
<h3 style="margin-bottom:1.25rem">Start a New Quiz</h3>
<div class="form-group"><label>Number of Questions</label>
<select class="form-input" id="quiz-num"><option value="5">5 Questions</option><option value="10" selected>10 Questions</option><option value="15">15 Questions</option><option value="20">20 Questions</option></select>
</div>
<div class="form-group"><label>Difficulty</label>
<select class="form-input" id="quiz-diff"><option value="">All Difficulties</option><option value="easy">Easy</option><option value="medium">Medium</option><option value="hard">Hard</option></select>
</div>
<button class="btn btn-primary" id="quiz-start-btn" onclick="startQuiz()">🚀 Start Quiz</button>
</div>
</div>
<div id="quiz-active-view" class="hidden">
<div class="quiz-progress"><div class="progress-bar"><div class="progress-fill" id="quiz-progress-fill"></div></div><span class="progress-text" id="quiz-progress-text">1/10</span></div>
<div id="quiz-question-area"></div>
<div style="display:flex;justify-content:flex-end;gap:.75rem;margin-top:1.5rem;max-width:800px;margin-left:auto;margin-right:auto" id="quiz-nav-btns">
<button class="btn btn-secondary btn-sm hidden" id="quiz-next-btn" onclick="nextQuestion()">Next Question →</button>
<button class="btn btn-green btn-sm hidden" id="quiz-finish-btn" onclick="finishQuiz()">Finish Quiz ✓</button>
</div>
</div>
<div id="quiz-results-view" class="hidden"></div>
</div>
<!-- ── AKINATOR 2.0 ── -->
<div class="page" id="page-akinator">
<div class="akinator-banner">
<div class="ak-title">🔮 Akinator 2.0</div>
<div class="ak-desc">The better Akinator that knows everything and could possibly make you rich. Powered by 4 expert AI analysts working in parallel — fetching live market data, scanning news sentiment, assessing risk, and crafting portfolio strategies — all in real time.</div>
<div class="ak-badges">
<span class="badge badge-purple">📈 Market Data</span>
<span class="badge badge-blue">📰 News & Sentiment</span>
<span class="badge badge-orange">⚠️ Risk Analysis</span>
<span class="badge badge-green">💼 Portfolio Strategy</span>
</div>
<div class="panel-toggle-row">
<div class="panel-toggle-left">
<span class="pt-icon">🎭</span>
<div>
<div class="pt-label">Panel Discussion Mode</div>
<div class="pt-hint">10 investor personas debate before predicting — slower but richer</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:.6rem">
<span class="panel-status off" id="panel-status-text">OFF</span>
<label class="toggle-switch">
<input type="checkbox" id="panel-mode-toggle" onchange="togglePanelMode()">
<span class="toggle-track"></span>
</label>
</div>
</div>
</div>
<div class="akinator-chat-layout">
<div class="akinator-chat-messages" id="akinator-messages">
<div class="chat-bubble bot">🔮 <strong>Akinator 2.0 — Multi-Agent Prediction Engine</strong>
I'm not your average chatbot. I run <strong>4 expert analyst agents</strong> simultaneously to give you the most comprehensive investment predictions:
📈 <strong>Market Data Analyst</strong> — Live prices, ratios & returns
📰 <strong>News & Sentiment Analyst</strong> — Breaking news & market mood
⚠️ <strong>Risk Assessment Analyst</strong> — Volatility & downside risk
💼 <strong>Portfolio Strategy Advisor</strong> — Allocation & strategy
Ask me anything about stocks, crypto, gold, forex, or how to invest your money. I'll query all 4 agents and deliver a unified prediction report.
<em>Try: "Should I invest in AAPL right now?" or "How should I invest 50,000 EGP?"</em></div>
</div>
<div class="akinator-input-bar">
<select class="form-input kb-selector" id="akinator-kb" style="max-width: 180px;"><option value="">No KB Context</option></select>
<input class="form-input" id="akinator-input" placeholder="Ask Akinator about any investment, stock, or market..." onkeydown="if(event.key==='Enter')sendAkinator()">
<button class="btn btn-primary" onclick="sendAkinator()">🔮 Predict</button>
</div>
</div>
</div>
<!-- ── LEADERBOARD ── -->
<div class="page" id="page-leaderboard">
<div class="page-header"><h2>Leaderboard</h2><p>Top performers across all quizzes</p></div>
<div class="card" id="leaderboard-content">Loading...</div>
</div>
<!-- ── KNOWLEDGE BASE ── -->
<div class="page" id="page-knowledgebase">
<div class="page-header"><h2>Knowledge Base</h2><p>Upload documents to ground the AI's responses with custom context.</p></div>
<div class="gen-layout">
<div class="card">
<h3 style="margin-bottom:1.25rem">Upload Document</h3>
<div class="form-group"><label>Knowledge Base Name (Identifier)</label><input class="form-input" id="kb-name" placeholder="E.g., personal_finance_101"></div>
<div class="form-group" id="kb-file-input">
<label>1. Select File(s)</label>
<input type="file" id="kb-files" class="form-input" multiple accept=".pdf,.txt,.json,.doc,.docx">
<div style="font-size:0.75rem; color:var(--text3); margin-top:4px;">Supported: PDF, JSON, TXT, DOC, DOCX</div>
</div>
<div class="form-group" id="kb-url-input">
<label>2. And/Or Add Website URLs</label>
<input type="text" id="kb-urls" class="form-input" placeholder="https://site1.com, https://site2.com">
<div style="font-size:0.75rem; color:var(--text3); margin-top:4px;">Separate multiple URLs with commas</div>
</div>
<button class="btn btn-primary" id="kb-upload-btn" onclick="uploadKB()">Upload to Knowledge Base</button>
</div>
<div class="card">
<h3 style="margin-bottom:1.25rem">Available Knowledge Bases</h3>
<div id="kb-list" style="color:var(--text2);font-size:.88rem">Loading...</div>
</div>
</div>
</div>
</div><!-- end .main -->
</div><!-- end #app-screen -->
<script>
// ── State ──
let token = localStorage.getItem('finsim_token');
let currentUser = null;
let chatSessionId = 'sess_' + Math.random().toString(36).slice(2,9);
let akinatorSessionId = 'ak_' + Math.random().toString(36).slice(2,9);
let panelModeEnabled = false;
let quizData = null;
let currentQ = 0;
let userAnswers = [];
let answered = false;
// ── Init ──
window.addEventListener('DOMContentLoaded', () => {
if (token) { fetchMe(); } else { showAuth(); }
document.querySelectorAll('.form-input').forEach(el => {
el.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const form = el.closest('#login-form,#register-form');
if (form) form.querySelector('.btn-primary').click();
}
});
});
});
function api(url, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (token) headers['Authorization'] = 'Bearer ' + token;
return fetch(url, { ...opts, headers });
}
// ── Auth ──
function showAuth() { document.getElementById('auth-screen').style.display = 'flex'; document.getElementById('app-screen').style.display = 'none'; }
function showApp() { document.getElementById('auth-screen').style.display = 'none'; document.getElementById('app-screen').style.display = 'block'; }
function showLogin() { document.getElementById('login-form').classList.remove('hidden'); document.getElementById('register-form').classList.add('hidden'); hideAuthErr(); }
function showRegister() { document.getElementById('register-form').classList.remove('hidden'); document.getElementById('login-form').classList.add('hidden'); hideAuthErr(); }
function showAuthErr(msg) { const el = document.getElementById('auth-error'); el.textContent = msg; el.classList.remove('hidden'); }
function hideAuthErr() { document.getElementById('auth-error').classList.add('hidden'); }
async function doLogin() {
const email = document.getElementById('login-email').value.trim();
const pw = document.getElementById('login-password').value;
if (!email || !pw) return showAuthErr('Please fill in all fields');
const btn = document.getElementById('login-btn'); btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Signing in...';
try {
const r = await api('/api/login', { method: 'POST', body: JSON.stringify({ email, password: pw }) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Login failed');
token = d.token; localStorage.setItem('finsim_token', token); currentUser = d.user;
setupApp(); showApp(); loadDashboard();
} catch (e) { showAuthErr(e.message); }
btn.disabled = false; btn.innerHTML = 'Sign In';
}
async function doRegister() {
const username = document.getElementById('reg-username').value.trim();
const email = document.getElementById('reg-email').value.trim();
const pw = document.getElementById('reg-password').value;
if (!username || !email || !pw) return showAuthErr('Please fill in all fields');
if (pw.length < 4) return showAuthErr('Password must be at least 4 characters');
const btn = document.getElementById('reg-btn'); btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Creating...';
try {
const r = await api('/api/register', { method: 'POST', body: JSON.stringify({ username, email, password: pw }) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Registration failed');
token = d.token; localStorage.setItem('finsim_token', token); currentUser = d.user;
setupApp(); showApp(); loadDashboard();
} catch (e) { showAuthErr(e.message); }
btn.disabled = false; btn.innerHTML = 'Create Account';
}
async function fetchMe() {
try {
const r = await api('/api/me');
if (!r.ok) throw new Error();
const d = await r.json(); currentUser = d.user;
setupApp(); showApp(); loadDashboard();
} catch { token = null; localStorage.removeItem('finsim_token'); showAuth(); }
}
function doLogout() { localStorage.removeItem('finsim_token'); api('/api/logout', { method: 'POST' }); token = null; currentUser = null; showAuth(); }
function setupApp() {
if (!currentUser) return;
document.getElementById('user-avatar').textContent = (currentUser.username || '?')[0].toUpperCase();
document.getElementById('user-display-name').textContent = currentUser.username;
document.getElementById('user-display-role').textContent = currentUser.role;
}
// ── Navigation ──
function navigate(page) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
closeSidebar();
if (page === 'dashboard') loadDashboard();
if (page === 'leaderboard') loadLeaderboard();
if (page === 'knowledgebase') loadKBs();
}
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); document.getElementById('overlay').classList.toggle('show'); }
function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); document.getElementById('overlay').classList.remove('show'); }
// ── Dashboard ──
async function loadDashboard() {
try {
const r = await api('/api/stats');
const d = await r.json();
document.getElementById('stat-score').textContent = d.total_score || 0;
document.getElementById('stat-quizzes').textContent = d.quizzes_taken || 0;
document.getElementById('stat-accuracy').textContent = (d.accuracy || 0) + '%';
document.getElementById('stat-avg').textContent = d.avg_score || 0;
const el = document.getElementById('recent-attempts');
if (!d.recent_attempts || d.recent_attempts.length === 0) {
el.innerHTML = '<p style="padding:1rem;color:var(--text3)">No quiz attempts yet. Go to Practice MCQ to get started!</p>';
} else {
let html = '<table class="recent-table"><thead><tr><th>Date</th><th>Score</th><th>Correct</th><th>Status</th></tr></thead><tbody>';
d.recent_attempts.forEach(a => {
const date = a.started_at ? new Date(a.started_at).toLocaleDateString() : '-';
const sc = a.score ?? 0;
const badge = sc >= 70 ? 'badge-green' : sc >= 40 ? 'badge-orange' : 'badge-red';
html += `<tr><td>${date}</td><td><span class="badge ${badge}">${sc}%</span></td><td>${a.correct_answers||0}/${a.total_questions||0}</td><td>${a.status}</td></tr>`;
});
el.innerHTML = html + '</tbody></table>';
}
} catch(e) { console.error(e); }
}
// ── Generator ──
async function runGenerator() {
const btn = document.getElementById('gen-btn');
const stat = document.getElementById('gen-status');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Running Pipeline...';
stat.innerHTML = '<span style="color:var(--orange)">⏳ Fetching market data & running AI analysis...\nThis may take 15-30 seconds.</span>';
try {
const body = {
stock_symbol: document.getElementById('gen-symbol').value.trim(),
zscore_window: parseInt(document.getElementById('gen-window').value) || 100,
zscore_trigger_min: parseFloat(document.getElementById('gen-zmin').value) || -2.5,
zscore_trigger_max: parseFloat(document.getElementById('gen-zmax').value) || 2.5,
knowledge_base_id: document.getElementById('gen-kb').value || null
};
const r = await api('/api/generate', { method: 'POST', body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Generation failed');
stat.innerHTML = `<span style="color:var(--green)">✅ Success!</span>\n\n📈 Symbol: ${d.stock_symbol}\n🔍 Events Analyzed: ${d.events_analyzed}\n🧠 Scenarios Generated: ${d.scenarios_generated}\n💾 Saved to Database: ${d.scenarios_saved_to_db}`;
} catch (e) { stat.innerHTML = `<span style="color:var(--red)">❌ Error: ${e.message}</span>`; }
btn.disabled = false; btn.innerHTML = '⚡ Generate Scenarios';
}
// ── Markdown Renderer ──
function renderMd(text) {
if (!text) return '';
if (typeof marked !== 'undefined' && marked.parse) {
try { return marked.parse(text); } catch(e) { /* fallback */ }
}
// Fallback: basic conversion
return text
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br>');
}
// ── Chat ──
async function sendChat() {
const input = document.getElementById('chat-input');
const kbId = document.getElementById('chat-kb').value;
const msg = input.value.trim();
if (!msg) return;
input.value = '';
const box = document.getElementById('chat-messages');
box.innerHTML += `<div class="chat-bubble user">${esc(msg)}</div>`;
const loadId = 'typing-' + Date.now();
box.innerHTML += `<div class="chat-bubble bot typing" id="${loadId}">Thinking...</div>`;
box.scrollTop = box.scrollHeight;
try {
const r = await api('/api/chat', { method: 'POST', body: JSON.stringify({ session_id: chatSessionId, message: msg, knowledge_base_id: kbId || null }) });
const d = await r.json();
document.getElementById(loadId)?.remove();
if (!r.ok) throw new Error(d.detail || 'Chat failed');
if (d.rate_limited) {
showRateLimitTimer(box, d.wait_seconds);
} else {
const ragBadge = d.rag_used ? `<span class="badge badge-purple" style="margin-bottom:0.5rem;display:inline-block">🧠 RAG Activated</span><br>` : '';
box.innerHTML += `<div class="chat-bubble bot">${ragBadge}${renderMd(d.reply)}</div>`;
}
} catch (e) {
document.getElementById(loadId)?.remove();
box.innerHTML += `<div class="chat-bubble bot" style="color:var(--red)">Error: ${esc(e.message)}</div>`;
}
box.scrollTop = box.scrollHeight;
}
// ── Akinator 2.0 Chat ──
function togglePanelMode() {
panelModeEnabled = document.getElementById('panel-mode-toggle').checked;
const statusEl = document.getElementById('panel-status-text');
if (panelModeEnabled) {
statusEl.textContent = '🎭 ON';
statusEl.className = 'panel-status on';
} else {
statusEl.textContent = 'OFF';
statusEl.className = 'panel-status off';
}
}
async function sendAkinator() {
const input = document.getElementById('akinator-input');
const kbId = document.getElementById('akinator-kb').value;
const msg = input.value.trim();
if (!msg) return;
input.value = '';
const box = document.getElementById('akinator-messages');
box.innerHTML += `<div class="chat-bubble user">${esc(msg)}</div>`;
const loadId = 'ak-typing-' + Date.now();
const loadMsg = panelModeEnabled
? '🎭 Running 9-node LangGraph pipeline: Router → What-If → Analyst Hub → News Sentiment → Confidence → Critique → JIT Education → Memo → Format... This may take 30-60 seconds.'
: '🔮 Running 9-node LangGraph pipeline with 5 analyst tools... This may take 15-30 seconds.';
box.innerHTML += `<div class="chat-bubble bot typing" id="${loadId}">${loadMsg}</div>`;
box.scrollTop = box.scrollHeight;
try {
const r = await api('/api/akinator', { method: 'POST', body: JSON.stringify({ session_id: akinatorSessionId, message: msg, panel_mode: panelModeEnabled, knowledge_base_id: kbId || null }) });
const d = await r.json();
document.getElementById(loadId)?.remove();
if (!r.ok) throw new Error(d.detail || 'Akinator failed');
if (d.rate_limited) {
showRateLimitTimer(box, d.wait_seconds);
} else {
let html = renderMd(d.reply || '');
if (d.rag_used) {
html = `<span class="badge badge-purple" style="margin-bottom:0.5rem;display:inline-block">🧠 RAG Activated</span><br>` + html;
}
// Akinator metadata panels
let metaHtml = '';
// Confidence badge
if (d.confidence_score) {
const cc = d.confidence_score >= 75 ? 'confidence' : d.confidence_score >= 50 ? 'confidence med' : 'confidence low';
const ce = d.confidence_score >= 75 ? '🟢' : d.confidence_score >= 50 ? '🟡' : '🔴';
metaHtml += `<span class="ak-meta-card ${cc}">${ce} Confidence: ${d.confidence_score}%</span>`;
}
// Sentiment badge
if (d.news_sentiment && d.news_sentiment.label) {
const sl = d.news_sentiment.label;
const sc = sl === 'Bullish' ? 'sentiment bullish' : sl === 'Bearish' ? 'sentiment bearish' : 'sentiment';
const se = sl === 'Bullish' ? '📈' : sl === 'Bearish' ? '📉' : '📊';
metaHtml += `<span class="ak-meta-card ${sc}">${se} Sentiment: ${sl} (${d.news_sentiment.score}/100)</span>`;
}
if (metaHtml) html += `<div class="ak-meta-row">${metaHtml}</div>`;
// Critique note
if (d.critique_note) {
html += `<div class="ak-critique-box">⚠️ ${esc(d.critique_note)}</div>`;
}
// Jargon definitions
if (d.jargon_definitions && Object.keys(d.jargon_definitions).length > 0) {
let jHtml = '<details class="ak-jargon-panel"><summary>📚 Financial Terms Explained (' + Object.keys(d.jargon_definitions).length + ' terms)</summary>';
for (const [term, def_] of Object.entries(d.jargon_definitions)) {
jHtml += `<div class="jargon-item"><span class="jt">${esc(term)}</span><span class="jd">${esc(def_)}</span></div>`;
}
jHtml += '</details>';
html += jHtml;
}
// Investment Memo
if (d.memo) {
html += `<details class="ak-memo-panel"><summary>📋 Investment Memo</summary><pre>${esc(d.memo)}</pre></details>`;
}
box.innerHTML += `<div class="chat-bubble bot">${html}</div>`;
}
} catch (e) {
document.getElementById(loadId)?.remove();
box.innerHTML += `<div class="chat-bubble bot" style="color:var(--red)">Error: ${esc(e.message)}</div>`;
}
box.scrollTop = box.scrollHeight;
}
function showRateLimitTimer(container, waitSeconds) {
const timerId = 'rl-timer-' + Date.now();
let remaining = Math.max(1, waitSeconds);
const formatTime = (s) => {
if (s >= 3600) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h}h ${String(m).padStart(2,'0')}m ${String(sec).padStart(2,'0')}s`;
} else if (s >= 60) {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}m ${String(sec).padStart(2,'0')}s`;
}
return `${s}s`;
};
container.innerHTML += `<div class="rate-limit-bubble" id="${timerId}">
<div class="rl-title">⏳ Rate Limit Reached</div>
<div>API cooldown — you can chat again in:</div>
<div class="rl-timer ticking" id="${timerId}-time">${formatTime(remaining)}</div>
</div>`;
container.scrollTop = container.scrollHeight;
const interval = setInterval(() => {
remaining--;
const el = document.getElementById(timerId + '-time');
if (!el) { clearInterval(interval); return; }
if (remaining <= 0) {
clearInterval(interval);
el.classList.remove('ticking');
el.className = 'rl-done';
el.textContent = '✅ You can chat again now!';
} else {
el.textContent = formatTime(remaining);
}
container.scrollTop = container.scrollHeight;
}, 1000);
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ── Quiz ──
async function startQuiz() {
const btn = document.getElementById('quiz-start-btn');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Preparing...';
try {
const body = {
num_questions: parseInt(document.getElementById('quiz-num').value),
difficulty: document.getElementById('quiz-diff').value || null,
};
const r = await api('/api/quiz/start', { method: 'POST', body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
quizData = d; currentQ = 0; userAnswers = new Array(d.questions.length).fill(null); answered = false;
document.getElementById('quiz-setup-view').classList.add('hidden');
document.getElementById('quiz-active-view').classList.remove('hidden');
document.getElementById('quiz-results-view').classList.add('hidden');
renderQuestion();
} catch (e) { alert(e.message); }
btn.disabled = false; btn.innerHTML = '🚀 Start Quiz';
}
function renderQuestion() {
const q = quizData.questions[currentQ];
const total = quizData.questions.length;
document.getElementById('quiz-progress-fill').style.width = ((currentQ + 1) / total * 100) + '%';
document.getElementById('quiz-progress-text').textContent = `${currentQ + 1}/${total}`;
answered = userAnswers[currentQ] !== null;
let givensHtml = '';
if (q.givens_table && typeof q.givens_table === 'object') {
Object.entries(q.givens_table).forEach(([k, v]) => {
if (v !== null && v !== undefined && v !== '') {
const label = k.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1');
givensHtml += `<div class="givens-item"><div class="g-label">${esc(label)}</div><div class="g-value">${esc(String(v))}</div></div>`;
}
});
}
let badges = '';
if (q.risk_level) { const c = q.risk_level === 'High' ? 'red' : q.risk_level === 'Medium' ? 'orange' : 'green'; badges += `<span class="badge badge-${c}">${q.risk_level} Risk</span> `; }
if (q.event_type) badges += `<span class="badge badge-purple">${q.event_type}</span> `;
if (q.difficulty) badges += `<span class="badge badge-blue">${q.difficulty}</span>`;
let optionsHtml = '';
q.options.forEach(opt => {
let cls = 'option-btn';
let disabled = '';
if (answered) {
disabled = 'disabled';
const isCorrect = opt.label === q.correct_label;
const isSelected = userAnswers[currentQ] === opt.text;
if (isCorrect) cls += ' correct';
else if (isSelected) cls += ' wrong';
} else if (userAnswers[currentQ] === opt.text) {
cls += ' selected';
}
optionsHtml += `<button class="${cls}" ${disabled} onclick="selectAnswer('${opt.label}','${esc(opt.text.replace(/'/g, "\\'"))}')" data-label="${opt.label}"><span class="opt-label">${opt.label})</span> ${esc(opt.text)}</button>`;
});
let explanationHtml = '';
if (answered) {
const selectedOpt = q.options.find(o => o.text === userAnswers[currentQ]);
const isCorrect = selectedOpt && selectedOpt.label === q.correct_label;
if (isCorrect) {
explanationHtml = `<div class="explanation-box correct-exp"><strong>✅ Correct!</strong>${esc(q.best_answer_rationale || '')}</div>`;
} else {
const wrongExp = q.other_explanations[userAnswers[currentQ]] || '';
explanationHtml = `<div class="explanation-box wrong-exp"><strong>❌ Incorrect</strong>Your choice: ${esc(userAnswers[currentQ] || '')}<br><em>${esc(wrongExp)}</em></div>`;
explanationHtml += `<div class="explanation-box correct-exp" style="margin-top:.5rem"><strong>✅ Correct Answer</strong>${esc(q.best_answer_rationale || '')}</div>`;
}
}
document.getElementById('quiz-question-area').innerHTML = `
<div class="question-card card fade-in">
<div style="margin-bottom:1rem">${badges}</div>
<div class="q-title">${esc(q.title)}</div>
<div class="q-paragraph">${esc(q.scenario_paragraph || q.short_description || '')}</div>
${givensHtml ? `<div class="givens-grid">${givensHtml}</div>` : ''}
<div>${optionsHtml}</div>
${explanationHtml}
</div>`;
// Nav buttons
const nextBtn = document.getElementById('quiz-next-btn');
const finishBtn = document.getElementById('quiz-finish-btn');
nextBtn.classList.toggle('hidden', !answered || currentQ >= total - 1);
finishBtn.classList.toggle('hidden', !answered || currentQ < total - 1);
}
function selectAnswer(label, text) {
if (answered) return;
const q = quizData.questions[currentQ];
// Decode the text back
const opt = q.options.find(o => o.label === label);
userAnswers[currentQ] = opt.text;
answered = true;
renderQuestion();
}
function nextQuestion() { currentQ++; answered = userAnswers[currentQ] !== null; renderQuestion(); }
async function finishQuiz() {
const btn = document.getElementById('quiz-finish-btn');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Submitting...';
try {
const answers = quizData.questions.map((q, i) => ({ scenario_id: q.scenario_id, selected_answer: userAnswers[i] || '' }));
const r = await api(`/api/quiz/${quizData.attempt_id}/submit`, { method: 'POST', body: JSON.stringify(answers) });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Submit failed');
showResults(d);
} catch (e) { alert(e.message); }
btn.disabled = false; btn.innerHTML = 'Finish Quiz ✓';
}
function showResults(data) {
document.getElementById('quiz-active-view').classList.add('hidden');
const view = document.getElementById('quiz-results-view');
view.classList.remove('hidden');
const pct = data.percentage;
const cls = pct >= 70 ? 'good' : pct >= 40 ? 'ok' : 'bad';
const emoji = pct >= 70 ? '🎉' : pct >= 40 ? '💪' : '📚';
let resultsHtml = '';
data.results.forEach((r, i) => {
const q = quizData.questions[i];
const statusBadge = r.is_correct ? '<span class="badge badge-green">Correct</span>' : '<span class="badge badge-red">Incorrect</span>';
resultsHtml += `
<div class="result-item fade-in">
<div class="ri-header"><span class="ri-title">${i + 1}. ${esc(q?.title || 'Question')}</span>${statusBadge}</div>
<div class="ri-detail">
<strong>Your answer:</strong> ${esc(r.selected_answer)}<br>
<strong>Correct answer:</strong> ${esc(r.correct_answer)}<br><br>
<strong>Explanation:</strong> ${esc(r.correct_rationale || r.explanation || '')}
${!r.is_correct && r.explanation ? `<br><br><strong>Why your answer was wrong:</strong> ${esc(r.explanation)}` : ''}
</div>
</div>`;
});
view.innerHTML = `
<div class="results-header card">
<div style="font-size:3rem">${emoji}</div>
<div class="results-score ${cls}">${pct}%</div>
<p style="color:var(--text2);font-size:1rem">${data.correct} out of ${data.total} correct</p>
</div>
${resultsHtml}
<div style="text-align:center;margin-top:1.5rem">
<button class="btn btn-primary" style="max-width:300px" onclick="resetQuiz()">Take Another Quiz</button>
</div>`;
}
function resetQuiz() {
quizData = null; currentQ = 0; userAnswers = [];
document.getElementById('quiz-setup-view').classList.remove('hidden');
document.getElementById('quiz-active-view').classList.add('hidden');
document.getElementById('quiz-results-view').classList.add('hidden');
}
// ── Leaderboard ──
async function loadLeaderboard() {
try {
const r = await api('/api/leaderboard');
const d = await r.json();
const el = document.getElementById('leaderboard-content');
if (!d.leaderboard || d.leaderboard.length === 0) { el.innerHTML = '<p style="padding:1rem;color:var(--text3)">No entries yet.</p>'; return; }
let html = '';
d.leaderboard.forEach((u, i) => {
const rank = i + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank;
html += `<div class="lb-row"><div class="lb-rank ${rank <= 3 ? 'top' : ''}">${medal}</div><div class="lb-name">${esc(u.username)}</div><div style="font-size:.78rem;color:var(--text3);margin-right:1rem">${u.quizzes_taken} quizzes</div><div class="lb-score">${u.total_score} pts</div></div>`;
});
el.innerHTML = html;
} catch (e) { console.error(e); }
}
// ── Knowledge Base ──
async function loadKBs() {
try {
const r = await api('/api/rag/kbs');
const data = await r.json();
const l = document.getElementById('kb-list');
const selects = document.querySelectorAll('.kb-selector');
if (!data.knowledge_bases || data.knowledge_bases.length === 0) {
l.innerHTML = '<p>No knowledge bases available yet. Upload one to get started!</p>';
selects.forEach(s => s.innerHTML = '<option value="">No KB Context</option>');
return;
}
let listHtml = '<ul style="list-style-type:none;padding:0;margin:0;">';
let opts = '<option value="">No KB Context</option>';
data.knowledge_bases.forEach(kb => {
listHtml += `<li style="padding:0.75rem;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-sm);margin-bottom:0.5rem;font-weight:600;">🧠 ${esc(kb)}</li>`;
opts += `<option value="${esc(kb)}">${esc(kb)}</option>`;
});
listHtml += '</ul>';
l.innerHTML = listHtml;
selects.forEach(s => {
const cur = s.value; // preserve selection if possible
s.innerHTML = opts;
if([...s.options].some(o => o.value === cur)) s.value = cur;
});
} catch (e) { console.error(e); }
}
async function uploadKB() {
const name = document.getElementById('kb-name').value.trim();
if (!name) return alert("Knowledge Base Name is required.");
const fileInput = document.getElementById('kb-files');
const urlsText = document.getElementById('kb-urls').value.trim();
if (!fileInput.files.length && !urlsText) {
return alert("Please select at least one file or enter a URL.");
}
const btn = document.getElementById('kb-upload-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Processing...';
try {
let formData = new FormData();
formData.append('kb_name', name);
if (fileInput.files.length) {
for (let i = 0; i < fileInput.files.length; i++) {
formData.append('files', fileInput.files[i]);
}
}
if (urlsText) {
formData.append('urls', urlsText);
}
const tokenStr = token ? ('Bearer ' + token) : '';
const r = await fetch('/api/rag/upload', {
method: 'POST',
headers: { ...(tokenStr ? { 'Authorization': tokenStr } : {}) },
body: formData
});
const d = await r.json();
if (!r.ok) {
let errMsg = d.detail || 'Upload failed';
if (typeof errMsg === 'object') errMsg = JSON.stringify(errMsg, null, 2);
throw new Error(errMsg);
}
alert(d.message || "Successfully uploaded to Knowledge Base: " + name);
document.getElementById('kb-name').value = '';
document.getElementById('kb-files').value = '';
document.getElementById('kb-urls').value = '';
loadKBs();
} catch(e) {
alert("Error uploading: " + e.message);
}
btn.disabled = false;
btn.innerHTML = 'Upload.to Knowledge Base';
}
// Call loadKBs once at init to prep the generator dropdowns
window.addEventListener('DOMContentLoaded', () => { setTimeout(loadKBs, 1000); });
</script>
</body>
</html>
import sys
import os
from unittest.mock import MagicMock
# Mock LangChain messages
class AIMessage:
def __init__(self, content):
self.content = content
class HumanMessage:
def __init__(self, content):
self.content = content
# Set the path so it can import from services
sys.path.append(os.getcwd())
def test_extraction_logic():
# Mock the response structure from agent_executor.invoke
mock_response_1 = {
"messages": [
HumanMessage("Hello"),
AIMessage([{"text": "Block 1 "}, {"text": "Block 2"}])
]
}
mock_response_2 = {
"messages": [
HumanMessage("Hello"),
AIMessage("Simple string response")
]
}
mock_response_empty = {
"messages": [
HumanMessage("Hello"),
AIMessage("")
]
}
# Simulation function based on the logic in chat_agent.py
def extract(response):
answer_str = ""
for msg in reversed(response["messages"]):
if isinstance(msg, AIMessage):
content = msg.content
if isinstance(content, str) and content.strip():
answer_str = content.strip()
break
elif isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
joined = "".join(parts).strip()
if joined:
answer_str = joined
break
return answer_str
print("Test 1 (List of dicts):", extract(mock_response_1))
assert extract(mock_response_1) == "Block 1 Block 2"
print("Test 2 (String):", extract(mock_response_2))
assert extract(mock_response_2) == "Simple string response"
print("Test 3 (Empty):", extract(mock_response_empty))
assert extract(mock_response_empty) == ""
if __name__ == "__main__":
try:
test_extraction_logic()
print("\nExtraction logic verification: SUCCESS")
except AssertionError as e:
print("\nExtraction logic verification: FAILED")
sys.exit(1)
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment