| import json |
| import datetime |
| import random |
| from app.llm_helper import LLMHelper |
| from app.tools.reporting import ReportGenerator |
| from app.memory_store import EpisodicMemory, ChromaVectorStore |
| from app.tools.financial_engine import FinancialRecommendationEngine |
| from app.tools.yfinance_client import fetch_yfinance_facts, generate_candlestick_chart |
|
|
| MAX_ITER = 12 |
|
|
| class ReActAgent: |
| """Orchestrates the ReAct reasoning loops (Thought -> Action -> Observation) and memory writes.""" |
| |
| def __init__(self, provider="gemini", api_key=None, max_steps=MAX_ITER, temperature=0.3, session_id="GLOBAL"): |
| self.provider = provider |
| self.api_key = api_key |
| self.max_steps = min(max_steps, MAX_ITER) |
| self.temperature = temperature |
| self.session_id = session_id |
| self.episodic_memory = EpisodicMemory() |
| self.vector_store = ChromaVectorStore() |
|
|
| def _check_if_query_answered(self, query: str, context: str) -> bool: |
| """Determines if the current retrieved context satisfies the user query using the LLM.""" |
| if not context: |
| return False |
| |
| |
| query_words = [w.strip("?,.!:;()\"'") for w in query.lower().split() if len(w) > 3] |
| if not query_words: |
| return False |
| match_count = sum(1 for word in query_words if word in context.lower()) |
| if match_count >= max(1, len(query_words) * 0.75): |
| return True |
| return False |
|
|
| def _format_sse(self, event_type: str, content: str, tier: int, iteration: int) -> dict: |
| data = { |
| "type": event_type, |
| "content": content, |
| "tier": tier, |
| "timestamp": datetime.datetime.utcnow().isoformat() + "Z", |
| "iteration": iteration |
| } |
| return {"data": json.dumps(data)} |
|
|
| def run(self, query: str, ticker: str = None): |
| """Runs the 2-Tier ReAct agent cycle for a given query.""" |
| tools_used = [] |
| failures = "None" |
| recovery = "N/A" |
| early_stopped = False |
| stopping_stage = 0 |
|
|
| accumulated_context = "" |
| iteration = 0 |
|
|
| |
| chat_history_list = self.episodic_memory.get_session_history(self.session_id) |
| chat_history_str = "" |
| if chat_history_list: |
| chat_history_str = "\n".join([f"{item.get('role', 'User')}: {item.get('text', '')}" for item in chat_history_list[-6:]]) |
| |
| |
| yield self._format_sse("thought", "Classifying query intent with conversation history...", 0, iteration) |
| intent_prompt = f"Does the following user query ask for a stock recommendation/financial analysis of a company, or is it asking to analyze uploaded documents/custom data? (Hint: if the query asks to 'summarize', mentions a 'section', or asks questions about text, it is likely 'ANALYSIS'). Reply with exactly 'RECOMMENDATION' or 'ANALYSIS'.\n\nPast Chat Context:\n{chat_history_str}\n\nQuery: {query}" |
| intent_response = LLMHelper.generate_text(intent_prompt, provider=self.provider, api_key=self.api_key) |
| |
| intent = "ANALYSIS" if "ANALYSIS" in intent_response.upper() and "RECOMMENDATION" not in intent_response.upper() else "RECOMMENDATION" |
| |
| |
| if "RECOMMENDATION" in intent_response.upper(): |
| intent = "RECOMMENDATION" |
| elif "ANALYSIS" in intent_response.upper(): |
| intent = "ANALYSIS" |
| |
| if "[DEMO MODE]" in intent_response: |
| intent = "RECOMMENDATION" |
|
|
| yield self._format_sse("observation", f"Intent Classified as: {intent}", 0, iteration) |
|
|
| if intent == "ANALYSIS": |
| yield self._format_sse("thought", "Executing Document Analysis Branch...", 0, iteration) |
| q_emb = LLMHelper.generate_embeddings([query], provider=self.provider, api_key=self.api_key)[0] |
| |
| yield self._format_sse("action", f"vector_store.similarity_search(source_type='user_upload', session_id='{self.session_id}', k=18)", 0, iteration) |
| doc_results = self.vector_store.similarity_search(q_emb, k=18, custom_where={"$and": [{"source_type": "user_upload"}, {"session_id": self.session_id}]}) |
| |
| if not doc_results: |
| yield self._format_sse("error", "No uploaded documents found to analyze. Please upload a PDF document first.", 0, iteration) |
| yield self._format_sse("done", "Episode complete", 0, iteration) |
| return |
| |
| context = "\n\n".join([res['snippet'] for res in doc_results]) |
| yield self._format_sse("observation", f"Retrieved {len(doc_results)} chunks from uploaded documents.", 0, iteration) |
| |
| yield self._format_sse("thought", "Generating answer based on uploaded documents...", 0, iteration) |
| analysis_prompt = f"""SYSTEM ROLE: You are a strict, precise financial document analyst. |
| You must use ONLY the provided Document Text to answer the user's query. |
| |
| RULES: |
| 1. If the user requests a summary, provide a concise, structured summary of the relevant sections. |
| 2. ALWAYS provide a conversational, fully-formed contextual answer. Do NOT answer with just a single number or word (e.g. "8%"). Explain your reasoning and quote the relevant figures. |
| 3. When analyzing tabular data, synthesize and summarize the table's contents, trends, and key metrics. Do NOT just copy-paste the raw table, and do NOT skip over important context. |
| 4. Answer ONLY from the retrieved context. Do not use outside knowledge. |
| 5. Do not provide investment recommendations unless explicitly requested. |
| 6. Do not assume facts that are not present, BUT you may recognize standard financial synonyms (e.g., Turnover = Revenue) when interpreting the user's query against the context. |
| 7. Do NOT introduce yourself. Do not say "I am a financial document analyst" or ask for a question. Just output the final answer directly. |
| 8. If the requested information is completely missing and cannot be found in the context, respond EXACTLY with: "The information is not available in the provided document." |
| |
| --- |
| DOCUMENT TEXT: |
| {context} |
| |
| --- |
| PREVIOUS CHAT HISTORY: |
| {chat_history_str} |
| |
| --- |
| USER QUERY: {query} |
| |
| CRITICAL INSTRUCTION: Based ONLY on the DOCUMENT TEXT above, fulfill the USER QUERY. Do not write anything else except the direct answer. |
| FINAL ANSWER:""" |
| report = LLMHelper.generate_text(analysis_prompt, provider=self.provider, api_key=self.api_key) |
| |
| if "Error executing text generation" in report and ("429" in report or "quota" in report.lower()): |
| yield self._format_sse("rate_limit_error", "Gemini Free-Tier Rate Limit Hit (429). Please switch to OpenAI or Ollama.", 0, iteration) |
| yield self._format_sse("done", "Episode complete", 0, iteration) |
| return |
|
|
| yield self._format_sse("report", report, 0, iteration) |
| yield self._format_sse("done", "Episode complete", 0, iteration) |
| return |
|
|
| |
| yield self._format_sse("thought", "Checking Vector DB for cached answers...", 0, iteration) |
| try: |
| q_emb = LLMHelper.generate_embeddings([query], provider=self.provider, api_key=self.api_key)[0] |
| cached_results = self.vector_store.collection.query( |
| query_embeddings=[q_emb], |
| n_results=1, |
| where={"$and": [{"source_type": "cache"}, {"query": query}]} |
| ) |
| if cached_results and cached_results.get('documents') and cached_results['documents'][0]: |
| doc = cached_results['documents'][0][0] |
| meta = cached_results['metadatas'][0][0] |
| cached_time_str = meta.get("timestamp", "") |
| if cached_time_str: |
| |
| |
| |
| clean_time_str = cached_time_str.replace("Z", "+00:00") |
| cached_time = datetime.datetime.fromisoformat(clean_time_str) |
| |
| if cached_time.tzinfo is not None: |
| cached_time = cached_time.replace(tzinfo=None) |
| |
| if (datetime.datetime.utcnow() - cached_time).total_seconds() < 86400: |
| yield self._format_sse("observation", "Found valid cached report in Vector DB (under 24 hours old).", 0, iteration) |
| |
| |
| ticker_meta = meta.get("ticker", "") |
| if ticker_meta and ticker_meta != "GENERIC": |
| import os, base64 |
| chart_path = os.path.join(os.path.dirname(__file__), "..", "temp_charts", f"{ticker_meta}_chart.png") |
| if os.path.exists(chart_path): |
| try: |
| with open(chart_path, "rb") as image_file: |
| encoded_string = base64.b64encode(image_file.read()).decode("utf-8") |
| yield self._format_sse("chart", f"data:image/png;base64,{encoded_string}", 0, iteration) |
| except Exception as e: |
| pass |
|
|
| yield self._format_sse("report", doc, 0, iteration) |
| yield self._format_sse("done", "Episode complete (served from cache)", 0, iteration) |
| return |
| except Exception as e: |
| yield self._format_sse("error", f"Cache lookup failed: {str(e)}", 0, iteration) |
|
|
| |
| if iteration < self.max_steps: |
| yield self._format_sse("thought", "Search Stage 1: Querying local Vector DB for verified SEC Filings (Tier 1).", 1, iteration) |
| |
| |
| q_emb = LLMHelper.generate_embeddings([query], provider=self.provider, api_key=self.api_key)[0] |
| |
| yield self._format_sse("action", "vector_store.similarity_search()", 1, iteration) |
| sec_results = self.vector_store.similarity_search(q_emb, k=3, filter_ticker=ticker) |
| tools_used.append("vector_store") |
| |
| if sec_results: |
| sec_snippet = sec_results[0]["snippet"] |
| yield self._format_sse("observation", f"Retrieved {len(sec_results)} relevant chunks from local database. Top chunk: {sec_snippet[:200]}...", 1, iteration) |
| accumulated_context += "SEC Filings (Tier 1):\n" |
| for res in sec_results: |
| accumulated_context += f"- {res['snippet']}\n" |
| else: |
| yield self._format_sse("observation", "No relevant SEC filings found in local Vector DB.", 1, iteration) |
|
|
| if self._check_if_query_answered(query, accumulated_context): |
| early_stopped = True |
| stopping_stage = 1 |
| yield self._format_sse("thought", "Query fully answered at Stage 1 (SEC Filings). Stopping search early.", 1, iteration) |
| |
| iteration += 1 |
|
|
|
|
| if iteration >= self.max_steps: |
| yield self._format_sse("error", "MAX_ITER overflow reached. Forcing report generation.", 0, iteration) |
|
|
| yield self._format_sse("thought", "Extracting exact financial metrics using live Yahoo Finance data...", 0, iteration) |
|
|
| |
| if not ticker: |
| import os |
| import csv |
| csv_path = os.path.join(os.path.dirname(__file__), "ticker_mapping.csv") |
| try: |
| if os.path.exists(csv_path): |
| with open(csv_path, mode='r', encoding='utf-8') as f: |
| reader = csv.reader(f) |
| next(reader, None) |
| |
| |
| rows = [row for row in reader if len(row) >= 2] |
| |
| rows.sort(key=lambda x: len(x[0]), reverse=True) |
| |
| for row in rows: |
| company_name = row[0] |
| mapped_ticker = row[1] |
| |
| clean_name = company_name.lower() |
| for suffix in [" limited", " ltd.", " ltd", " incorporation", " inc.", " inc", " corporation", " corp.", " corp"]: |
| if clean_name.endswith(suffix): |
| clean_name = clean_name[:-len(suffix)].strip() |
| |
| import re |
| if re.search(rf"\b{re.escape(clean_name)}\b", query.lower()) and len(clean_name) > 2: |
| ticker = mapped_ticker |
| yield self._format_sse("thought", f"Dynamically mapped '{company_name}' to ticker {ticker} from local registry.", 0, iteration) |
| break |
| except Exception as e: |
| yield self._format_sse("error", f"Failed to load ticker mapping: {str(e)}", 0, iteration) |
|
|
| if not ticker: |
| yield self._format_sse("error", "Failed to resolve a valid stock ticker from the query. Aborting financial analysis.", 0, iteration) |
| yield self._format_sse("done", "Episode complete", 0, iteration) |
| return |
|
|
| metrics = {} |
| if ticker: |
| yield self._format_sse("action", f"fetch_yfinance_facts(ticker='{ticker}')", 0, iteration) |
| try: |
| raw_metrics = fetch_yfinance_facts(ticker) |
| |
| if "error" in raw_metrics: |
| yield self._format_sse("error", f"Data Extraction Failed: {raw_metrics['error']}", 0, iteration) |
| yield self._format_sse("done", "Episode complete", 0, iteration) |
| return |
| |
| yield self._format_sse("observation", f"Retrieved official yfinance fundamental facts.", 0, iteration) |
| metrics = raw_metrics |
| except Exception as e: |
| yield self._format_sse("error", f"Failed to retrieve yfinance facts: {str(e)}", 0, iteration) |
|
|
| |
| engine = FinancialRecommendationEngine() |
| engine_result = engine.calculate(query, metrics) |
| |
| |
| metrics = engine_result.get("computed_metrics", metrics) |
| |
| yield self._format_sse("observation", f"Math Engine Final Output: {json.dumps(engine_result)}", 0, iteration) |
|
|
| |
| chart_sentiment = "UNAVAILABLE" |
| chart_path = None |
| wants_chart = any(word in query.lower() for word in ['chart', 'visual', 'graph', 'plot', 'candlestick']) |
| |
| if wants_chart: |
| yield self._format_sse("thought", "User requested visualization. Generating 2-month candlestick chart...", 0, iteration) |
| chart_path = generate_candlestick_chart(ticker) |
| |
| if chart_path: |
| yield self._format_sse("thought", "Evaluating visual market sentiment...", 0, iteration) |
| sentiment_prompt = "Analyze this 2-month candlestick chart. Evaluate the trend and reply with exactly one word representing the overall technical market sentiment: POSITIVE, NEGATIVE, or NEUTRAL." |
| raw_sentiment = LLMHelper.generate_text(sentiment_prompt, provider=self.provider, api_key=self.api_key, image_path=chart_path) |
| |
| if "Error executing text generation" in raw_sentiment and ("429" in raw_sentiment or "quota" in raw_sentiment.lower()): |
| yield self._format_sse("error", "Vision API limit exceeded. Skipping visual sentiment analysis.", 0, iteration) |
| chart_sentiment = "UNAVAILABLE" |
| else: |
| |
| if "POSITIVE" in raw_sentiment.upper(): chart_sentiment = "POSITIVE" |
| elif "NEGATIVE" in raw_sentiment.upper(): chart_sentiment = "NEGATIVE" |
| elif "NEUTRAL" in raw_sentiment.upper(): chart_sentiment = "NEUTRAL" |
| else: chart_sentiment = "UNAVAILABLE" |
| |
| yield self._format_sse("observation", f"Visual Sentiment Output: {chart_sentiment}", 0, iteration) |
| |
| |
| try: |
| import base64 |
| with open(chart_path, "rb") as image_file: |
| encoded_string = base64.b64encode(image_file.read()).decode("utf-8") |
| yield self._format_sse("chart", f"data:image/png;base64,{encoded_string}", 0, iteration) |
| except Exception as e: |
| yield self._format_sse("error", f"Failed to encode chart image: {str(e)}", 0, iteration) |
|
|
| yield self._format_sse("thought", "Math engine & chart execution complete. Compiling final report...", 0, iteration) |
|
|
| |
| |
| def humanize_number(val): |
| if isinstance(val, dict): |
| return {k: humanize_number(v) for k, v in val.items()} |
| elif isinstance(val, list): |
| return [humanize_number(v) for v in val] |
| elif isinstance(val, (int, float)) and val != 0: |
| abs_val = abs(val) |
| if abs_val >= 1e12: |
| return f"{val / 1e12:.2f} Trillion" |
| elif abs_val >= 1e9: |
| return f"{val / 1e9:.2f} Billion" |
| elif abs_val >= 1e6: |
| return f"{val / 1e6:.2f} Million" |
| return round(val, 2) |
| return val |
|
|
| formatted_metrics = humanize_number(metrics) |
|
|
| prompt = f"""You are an elite, top-tier Wall Street Financial Analyst AI evaluating the stock {ticker}. |
| |
| Analyze the following computed scores, chart sentiment, and raw data. |
| Your goal is to write a highly impressive, sophisticated, and institutional-grade financial report. Use elegant, professional Wall Street terminology and speak with absolute authority. Do not use boring, robotic, or generic phrasing. |
| |
| ENGINE OUTPUT: |
| Final Score: {engine_result.get('final_score', 50):.1f}/100 |
| Recommendation: {engine_result.get('recommendation', 'HOLD')} |
| Technical Chart Sentiment (2-Month): {chart_sentiment} |
| |
| Category Scores: |
| - Growth Score: {engine_result.get('growth_score', 50)} |
| - Profitability Score: {engine_result.get('profitability_score', 50)} |
| - Health Score: {engine_result.get('health_score', 50)} |
| Reasoning: {', '.join(engine_result.get('reasoning', []))} |
| |
| RAW METRICS & COMPUTED RATIOS: |
| {json.dumps(formatted_metrics, indent=2)} |
| |
| User Query: {query} |
| |
| Past Chat Context: |
| {chat_history_str} |
| |
| Retrieved Context: |
| {accumulated_context} |
| |
| Report Format: |
| 1. Executive Summary (Directly state the engine's {engine_result.get('recommendation', 'HOLD')} recommendation and final score in the first sentence) |
| 2. Recommendation Reasoning |
| - Use a simple bulleted list to explicitly explain WHY this recommendation was given. |
| - Use ✓ to list the top 2-3 Strengths (e.g. ✓ Strong cash flow generation) |
| - Use ⚠ to list the top 1-2 Weaknesses/Risks (e.g. ⚠ High debt to equity ratio) |
| 3. Key Financial Ratios |
| - Explicitly list the following ratios if available in the RAW METRICS (format cleanly): ROE, ROIC, Current Ratio, Debt/Equity, Net Debt/EBITDA, Interest Coverage, and FCF Yield. |
| 4. Scoring & Calculation |
| - Display the exact Engine Output provided above. |
| - DATA GAPS: If any metric or historical year is missing from the raw data, explicitly state that it is missing. Do not invent it. |
| - TONE: The writing should be highly impressive and sophisticated. Weave the data into a compelling institutional narrative rather than just robotically listing numbers. |
| - CRITICAL: These financial metrics are denominated in '{metrics.get('currency', 'USD')}'. Ensure you use the correct currency symbol (e.g., ₹ for INR, $ for USD). |
| - ANTI-HALLUCINATION GUARDRAIL: You are STRICTLY FORBIDDEN from calculating your own percentages, ratios, or math. You must ONLY use the exact numbers provided in the RAW METRICS. Do NOT invent or hallucinate metrics (like "-4.2% growth") that do not exist in the context above. |
| 5. Investment Thesis |
| - Bull Case: Provide 3 short bullet points outlining the core strengths of the company. |
| - Bear Case: Provide 3 short bullet points outlining the core risks or weaknesses. |
| 6. Qualitative Insights & Forward-Looking Risks (Based on Context/News) |
| - Explain management plans, why profit fell/rose, and any risks explicitly mentioned in the context. |
| 7. Sources & Verification |
| - Use this exact sentence: "This report is based on publicly available financial statements, market data, and company disclosures." Do not mention SEC filings unless they are explicitly in the context. |
| """ |
| report = LLMHelper.generate_text(prompt, provider=self.provider, api_key=self.api_key) |
| |
| if "Error executing text generation" in report and ("429" in report or "quota" in report.lower()): |
| yield self._format_sse("rate_limit_error", "Gemini Free-Tier Rate Limit Hit (429). Please switch to OpenAI or Ollama.", 0, iteration) |
| yield self._format_sse("done", "Episode complete", 0, iteration) |
| return |
|
|
| yield self._format_sse("report", report, 0, iteration) |
|
|
| |
| try: |
| cache_meta = [{ |
| "source_type": "cache", |
| "tier": "Cache", |
| "ticker": ticker or "GENERIC", |
| "source_name": "Agent Cache", |
| "query": query, |
| "timestamp": datetime.datetime.utcnow().isoformat() |
| }] |
| |
| c_emb = LLMHelper.generate_embeddings([query], provider=self.provider, api_key=self.api_key)[0] |
| self.vector_store.add_documents([report], [c_emb], cache_meta) |
| except Exception as e: |
| yield self._format_sse("error", f"Failed to cache final report: {str(e)}", 0, iteration) |
|
|
| status_str = "EARLY_STOPPED" if early_stopped else "SUCCESS" |
| |
| |
| chat_log = json.dumps([ |
| {"role": "User", "text": query}, |
| {"role": "Agent", "text": report} |
| ]) |
| |
| if not early_stopped: |
| self.episodic_memory.log_episode( |
| episode_id=f"EP-{random.randint(1000, 9999)}", |
| session_id=self.session_id, |
| query=query, |
| status=status_str, |
| tools_used=tools_used, |
| failures=failures, |
| recovery=recovery, |
| strategy=f"2-Tier strategy executed. Early stopped: {early_stopped} at Stage {stopping_stage}.", |
| chat_log=chat_log |
| ) |
|
|
| yield self._format_sse("done", "Episode complete", 0, iteration) |
|
|