CiscsoPonce commited on
Commit
d2f5b87
·
1 Parent(s): 88ae7af

feat: integrate LangSmith observability (Sprint 8 Epic 1)

Browse files

- Enable LANGCHAIN_TRACING_V2 in hunter.yml (GitHub Actions)
- Instrument llm.py with RunnableConfig tags/metadata for 429 tracking
- Extract analyst prompt to src/prompts/senior_broker.py (Hub + fallback)
- Add scripts/push_prompt_to_hub.py for Hub upload
- Fix broken langchain.prompts imports in news_intelligence.py & portfolio_manager.py
- Wire agent.py to use Hub prompt and run_name labels

.github/workflows/hunter.yml CHANGED
@@ -41,6 +41,10 @@ jobs:
41
  BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
42
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
43
  FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
 
 
 
 
44
  run: PYTHONPATH=. python src/whale_hunter.py
45
 
46
  # 🚨 CRITICAL NEW STEP: Save the memory file safely without crashing
 
41
  BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}
42
  OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
43
  FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
44
+ LANGCHAIN_TRACING_V2: "true"
45
+ LANGCHAIN_API_KEY: ${{ secrets.LANGCHAIN_API_KEY }}
46
+ LANGCHAIN_PROJECT: primogreedy
47
+ LANGSMITH_WORKSPACE_ID: ${{ secrets.LANGSMITH_WORKSPACE_ID }}
48
  run: PYTHONPATH=. python src/whale_hunter.py
49
 
50
  # 🚨 CRITICAL NEW STEP: Save the memory file safely without crashing
scripts/push_prompt_to_hub.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Push the Senior Broker prompt to LangSmith Hub via REST API.
3
+
4
+ Usage:
5
+ PYTHONPATH=. python scripts/push_prompt_to_hub.py
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ import requests
12
+ from dotenv import load_dotenv
13
+
14
+ load_dotenv()
15
+
16
+ API_KEY = os.getenv("LANGCHAIN_API_KEY")
17
+ TENANT_ID = os.getenv("LANGSMITH_WORKSPACE_ID", "cf298ed8-839f-4fb8-8fe7-1f14c64bfa15")
18
+ BASE_URL = "https://api.smith.langchain.com"
19
+
20
+ if not API_KEY:
21
+ print("ERROR: LANGCHAIN_API_KEY not set.")
22
+ sys.exit(1)
23
+
24
+ from src.prompts.senior_broker import SENIOR_BROKER_TEMPLATE
25
+
26
+ HEADERS = {
27
+ "x-api-key": API_KEY,
28
+ "X-Tenant-Id": TENANT_ID,
29
+ "Content-Type": "application/json",
30
+ }
31
+
32
+ # Step 1: Create the repo (prompt) if it doesn't exist
33
+ repo_name = "senior-broker"
34
+ print(f"Creating prompt repo: {repo_name}...")
35
+
36
+ create_resp = requests.post(
37
+ f"{BASE_URL}/repos/",
38
+ headers=HEADERS,
39
+ json={
40
+ "repo_handle": repo_name,
41
+ "description": "PrimoGreedy Senior Broker analyst prompt — Graham/Lynch/Munger framework",
42
+ "is_public": False,
43
+ "is_archived": False,
44
+ },
45
+ timeout=30,
46
+ )
47
+
48
+ if create_resp.status_code == 200:
49
+ print(f" ✅ Repo created: {repo_name}")
50
+ elif create_resp.status_code == 409:
51
+ print(f" ⏩ Repo already exists: {repo_name}")
52
+ else:
53
+ print(f" ℹ️ Repo response ({create_resp.status_code}): {create_resp.text[:200]}")
54
+
55
+ # Step 2: Push a commit (the prompt manifest) to the repo
56
+ print("Pushing prompt content...")
57
+
58
+ # Build the prompt manifest in LangChain serialization format
59
+ manifest = {
60
+ "lc": 1,
61
+ "type": "constructor",
62
+ "id": ["langchain", "prompts", "chat", "ChatPromptTemplate"],
63
+ "kwargs": {
64
+ "input_variables": [
65
+ "company_name", "ticker", "price", "eps", "book_value",
66
+ "ebitda", "thesis", "strategy", "deep_fundamentals", "sec_context"
67
+ ],
68
+ "messages": [
69
+ {
70
+ "lc": 1,
71
+ "type": "constructor",
72
+ "id": ["langchain", "prompts", "chat", "HumanMessagePromptTemplate"],
73
+ "kwargs": {
74
+ "prompt": {
75
+ "lc": 1,
76
+ "type": "constructor",
77
+ "id": ["langchain", "prompts", "prompt", "PromptTemplate"],
78
+ "kwargs": {
79
+ "input_variables": [
80
+ "company_name", "ticker", "price", "eps", "book_value",
81
+ "ebitda", "thesis", "strategy", "deep_fundamentals", "sec_context"
82
+ ],
83
+ "template": SENIOR_BROKER_TEMPLATE,
84
+ "template_format": "f-string",
85
+ },
86
+ }
87
+ },
88
+ }
89
+ ],
90
+ },
91
+ }
92
+
93
+ commit_resp = requests.post(
94
+ f"{BASE_URL}/commits/-/{repo_name}",
95
+ headers=HEADERS,
96
+ json={"manifest": manifest},
97
+ timeout=30,
98
+ )
99
+
100
+ if commit_resp.status_code in (200, 201):
101
+ print(f" ✅ Prompt pushed successfully!")
102
+ print(f" 🔗 View at: https://smith.langchain.com/hub/{repo_name}")
103
+ else:
104
+ print(f" ❌ Push failed ({commit_resp.status_code}): {commit_resp.text[:300]}")
105
+ sys.exit(1)
106
+
107
+ print("\nDone! The prompt is now live in LangSmith Hub.")
seen_tickers.json CHANGED
@@ -7,5 +7,8 @@
7
  "GL1.AX": 1772377050.6391022,
8
  "COYA": 1772434554.5477502,
9
  "SDI.L": 1772434597.6876407,
10
- "ADN.AX": 1772434660.052071
 
 
 
11
  }
 
7
  "GL1.AX": 1772377050.6391022,
8
  "COYA": 1772434554.5477502,
9
  "SDI.L": 1772434597.6876407,
10
+ "ADN.AX": 1772434660.052071,
11
+ "AAPL": 1772490201.247548,
12
+ "BTOG": 1772488713.657701,
13
+ "FCEL": 1772490234.947749
14
  }
src/agent.py CHANGED
@@ -26,6 +26,7 @@ from src.core.search import brave_search
26
  from src.core.ticker_utils import extract_tickers, resolve_ticker_suffix, normalize_price
27
  from src.core.memory import load_seen_tickers, mark_ticker_seen
28
  from src.core.state import AgentState
 
29
 
30
  from src.discovery.screener import screen_microcaps, get_trending_tickers_from_brave
31
  from src.discovery.scoring import rank_candidates
@@ -78,7 +79,7 @@ def chat_node(state):
78
  """
79
 
80
  try:
81
- response = invoke_with_fallback(prompt)
82
  except Exception as exc:
83
  logger.error("Chat LLM error: %s", exc)
84
  response = "I am experiencing issues right now. Please try again."
@@ -255,13 +256,8 @@ def analyst_node(state):
255
 
256
  news = brave_search(f"{ticker} stock {sector} catalysts insider buying")
257
 
258
- prompt = f"""
259
- Act as a Senior Financial Broker evaluating {state.get('company_name')} ({ticker}).
260
-
261
- HARD DATA: Price: ${price} | EPS: {eps} | Book/Share: {book_value} | EBITDA: {ebitda}
262
- QUANTITATIVE THESIS: {thesis}
263
- """
264
-
265
  if region == "USA" and "." not in ticker:
266
  logger.info("Researching Finnhub for %s...", ticker)
267
  context = ""
@@ -275,33 +271,27 @@ def analyst_node(state):
275
  insider = get_insider_buys(ticker)
276
  context += f"\nInsider Sentiment (6mo): {insider['sentiment']} | MSPR: {insider['mspr']} | Net Shares: {insider['change']}\n"
277
 
278
- prompt += f"\nDEEP FUNDAMENTALS (FINNHUB + INSIDER FEED):\n{context}\n"
279
  else:
280
- prompt += f"\nNEWS: {str(news)[:1500]}\n"
281
-
282
- prompt += f"""
283
- Your task is to write a highly structured investment memo combining strict {strategy} math with qualitative analysis and recent insider behavior/news. Do not use fluff or buzzwords.
284
-
285
- Format your response EXACTLY like this:
286
-
287
- ### THE QUANTITATIVE BASE (Graham / Asset Play)
288
- * State the current Price vs the calculated {strategy} valuation.
289
- * Briefly explain if the math supports a margin of safety.
290
-
291
- ### THE LYNCH PITCH (Why I would own this)
292
- * **The Core Action:** In one sentence, what are insiders doing (buying/selling/neutral)?
293
- * **The Catalyst:** Based on the news, what is the ONE simple reason this stock could run?
294
-
295
- ### THE MUNGER INVERT (How I could lose money)
296
- * **Structural Weakness:** What is the most likely way an investor loses money here based on fundamentals/news?
297
- * **The Bear Evidence:** What exact metric, news, or math would prove the bear case right?
298
-
299
- ### FINAL VERDICT
300
- STRONG BUY / BUY / WATCH / AVOID (Choose one, followed by a 1-sentence bottom line).
301
- """
302
 
303
  try:
304
- verdict = invoke_with_fallback(prompt)
305
  record_paper_trade(ticker, price, verdict, source="Chainlit UI")
306
  except Exception as exc:
307
  logger.error("LLM analysis failed for %s: %s", ticker, exc)
 
26
  from src.core.ticker_utils import extract_tickers, resolve_ticker_suffix, normalize_price
27
  from src.core.memory import load_seen_tickers, mark_ticker_seen
28
  from src.core.state import AgentState
29
+ from src.prompts.senior_broker import get_analyst_prompt
30
 
31
  from src.discovery.screener import screen_microcaps, get_trending_tickers_from_brave
32
  from src.discovery.scoring import rank_candidates
 
79
  """
80
 
81
  try:
82
+ response = invoke_with_fallback(prompt, run_name="chat_node")
83
  except Exception as exc:
84
  logger.error("Chat LLM error: %s", exc)
85
  response = "I am experiencing issues right now. Please try again."
 
256
 
257
  news = brave_search(f"{ticker} stock {sector} catalysts insider buying")
258
 
259
+ # --- Build deep-fundamentals context ---
260
+ deep_fundamentals = ""
 
 
 
 
 
261
  if region == "USA" and "." not in ticker:
262
  logger.info("Researching Finnhub for %s...", ticker)
263
  context = ""
 
271
  insider = get_insider_buys(ticker)
272
  context += f"\nInsider Sentiment (6mo): {insider['sentiment']} | MSPR: {insider['mspr']} | Net Shares: {insider['change']}\n"
273
 
274
+ deep_fundamentals = f"DEEP FUNDAMENTALS (FINNHUB + INSIDER FEED):\n{context}"
275
  else:
276
+ deep_fundamentals = f"NEWS: {str(news)[:1500]}"
277
+
278
+ # --- Build prompt from Hub (or local fallback) ---
279
+ template = get_analyst_prompt()
280
+ prompt = template.format(
281
+ company_name=state.get("company_name", ticker),
282
+ ticker=ticker,
283
+ price=price,
284
+ eps=eps,
285
+ book_value=book_value,
286
+ ebitda=ebitda,
287
+ thesis=thesis,
288
+ strategy=strategy,
289
+ deep_fundamentals=deep_fundamentals,
290
+ sec_context="", # Placeholder — SEC EDGAR data added in Epic 3
291
+ )
 
 
 
 
 
 
292
 
293
  try:
294
+ verdict = invoke_with_fallback(prompt, run_name="analyst_node")
295
  record_paper_trade(ticker, price, verdict, source="Chainlit UI")
296
  except Exception as exc:
297
  logger.error("LLM analysis failed for %s: %s", ticker, exc)
src/llm.py CHANGED
@@ -2,6 +2,7 @@ import os
2
  import time
3
  from dotenv import load_dotenv
4
  from langchain_openai import ChatOpenAI
 
5
 
6
  load_dotenv()
7
 
@@ -47,11 +48,14 @@ def get_llm() -> ChatOpenAI:
47
  return _llm_instance
48
 
49
 
50
- def invoke_with_fallback(prompt: str, max_retries: int = 2) -> str:
51
  """Invoke the LLM with automatic model fallback on 429 rate limits.
52
 
53
  Tries each model in MODEL_CHAIN until one succeeds. Returns the
54
  response content string.
 
 
 
55
  """
56
  from src.core.logger import get_logger
57
  logger = get_logger(__name__)
@@ -60,6 +64,8 @@ def invoke_with_fallback(prompt: str, max_retries: int = 2) -> str:
60
  if not api_key:
61
  raise ValueError("OPENROUTER_API_KEY not found.")
62
 
 
 
63
  for model_id in MODEL_CHAIN:
64
  for attempt in range(max_retries):
65
  try:
@@ -69,10 +75,23 @@ def invoke_with_fallback(prompt: str, max_retries: int = 2) -> str:
69
  base_url="https://openrouter.ai/api/v1",
70
  temperature=0,
71
  )
72
- response = llm.invoke(prompt)
 
 
 
 
 
 
 
 
 
 
 
 
73
  logger.info("LLM response from %s (attempt %d)", model_id, attempt + 1)
74
  return response.content
75
  except Exception as exc:
 
76
  err_str = str(exc)
77
  if "429" in err_str:
78
  logger.warning("Rate-limited on %s (attempt %d), trying next...", model_id, attempt + 1)
@@ -88,4 +107,4 @@ def invoke_with_fallback(prompt: str, max_retries: int = 2) -> str:
88
  else:
89
  break
90
 
91
- raise RuntimeError(f"All {len(MODEL_CHAIN)} models failed. Last tried: {MODEL_CHAIN[-1]}")
 
2
  import time
3
  from dotenv import load_dotenv
4
  from langchain_openai import ChatOpenAI
5
+ from langchain_core.runnables import RunnableConfig
6
 
7
  load_dotenv()
8
 
 
48
  return _llm_instance
49
 
50
 
51
+ def invoke_with_fallback(prompt: str, max_retries: int = 2, run_name: str = "llm_call") -> str:
52
  """Invoke the LLM with automatic model fallback on 429 rate limits.
53
 
54
  Tries each model in MODEL_CHAIN until one succeeds. Returns the
55
  response content string.
56
+
57
+ Each invocation is tagged with the model name so LangSmith can filter
58
+ by ``model:<name>`` and ``error:429`` for the error dashboard.
59
  """
60
  from src.core.logger import get_logger
61
  logger = get_logger(__name__)
 
64
  if not api_key:
65
  raise ValueError("OPENROUTER_API_KEY not found.")
66
 
67
+ last_error = None
68
+
69
  for model_id in MODEL_CHAIN:
70
  for attempt in range(max_retries):
71
  try:
 
75
  base_url="https://openrouter.ai/api/v1",
76
  temperature=0,
77
  )
78
+
79
+ # LangSmith: tag every call with model name + attempt number
80
+ config = RunnableConfig(
81
+ run_name=run_name,
82
+ tags=[f"model:{model_id}", f"attempt:{attempt + 1}"],
83
+ metadata={
84
+ "model_id": model_id,
85
+ "attempt": attempt + 1,
86
+ "fallback_position": MODEL_CHAIN.index(model_id),
87
+ },
88
+ )
89
+
90
+ response = llm.invoke(prompt, config=config)
91
  logger.info("LLM response from %s (attempt %d)", model_id, attempt + 1)
92
  return response.content
93
  except Exception as exc:
94
+ last_error = exc
95
  err_str = str(exc)
96
  if "429" in err_str:
97
  logger.warning("Rate-limited on %s (attempt %d), trying next...", model_id, attempt + 1)
 
107
  else:
108
  break
109
 
110
+ raise RuntimeError(f"All {len(MODEL_CHAIN)} models failed. Last tried: {MODEL_CHAIN[-1]}. Last error: {last_error}")
src/prompts/news_intelligence.py CHANGED
@@ -1,7 +1,7 @@
1
  from datetime import datetime
2
  from typing import List, Dict, Any
3
- from langchain.prompts import ChatPromptTemplate
4
- from langchain.output_parsers import ResponseSchema, StructuredOutputParser
5
 
6
 
7
  def get_news_analysis_template() -> ChatPromptTemplate:
@@ -125,26 +125,36 @@ Enhanced Summary:"""
125
  return ChatPromptTemplate.from_template(template)
126
 
127
 
128
- def get_news_response_schemas() -> List[ResponseSchema]:
129
  """
130
  Define response schemas for structured output parsing of news analysis.
131
  Simple validation schemas - detailed descriptions are in the prompt template.
132
  """
133
  return [
134
- ResponseSchema(name="news_relevance", description="Integer from -2 to 2"),
135
- ResponseSchema(name="sentiment", description="Integer from -2 to 2"),
136
- ResponseSchema(name="price_impact_potential", description="Integer from -2 to 2"),
137
- ResponseSchema(name="trend_direction", description="Integer from -2 to 2"),
138
- ResponseSchema(name="earnings_impact", description="Integer from -2 to 2"),
139
- ResponseSchema(name="investor_confidence", description="Integer from -2 to 2"),
140
- ResponseSchema(name="risk_profile_change", description="Integer from -2 to 2")
141
  ]
142
 
143
 
144
- def get_news_output_parser() -> StructuredOutputParser:
145
  """Create structured output parser for news analysis results."""
146
- response_schemas = get_news_response_schemas()
147
- return StructuredOutputParser.from_response_schemas(response_schemas)
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  def format_news_data(news_items: List[Dict[str, Any]]) -> str:
 
1
  from datetime import datetime
2
  from typing import List, Dict, Any
3
+ from langchain_core.prompts import ChatPromptTemplate
4
+ from langchain_core.output_parsers import JsonOutputParser
5
 
6
 
7
  def get_news_analysis_template() -> ChatPromptTemplate:
 
125
  return ChatPromptTemplate.from_template(template)
126
 
127
 
128
+ def get_news_response_schemas() -> List[Dict[str, str]]:
129
  """
130
  Define response schemas for structured output parsing of news analysis.
131
  Simple validation schemas - detailed descriptions are in the prompt template.
132
  """
133
  return [
134
+ {"name": "news_relevance", "description": "Integer from -2 to 2"},
135
+ {"name": "sentiment", "description": "Integer from -2 to 2"},
136
+ {"name": "price_impact_potential", "description": "Integer from -2 to 2"},
137
+ {"name": "trend_direction", "description": "Integer from -2 to 2"},
138
+ {"name": "earnings_impact", "description": "Integer from -2 to 2"},
139
+ {"name": "investor_confidence", "description": "Integer from -2 to 2"},
140
+ {"name": "risk_profile_change", "description": "Integer from -2 to 2"},
141
  ]
142
 
143
 
144
+ def get_news_output_parser() -> JsonOutputParser:
145
  """Create structured output parser for news analysis results."""
146
+ from pydantic import BaseModel, Field
147
+
148
+ class NewsAnalysis(BaseModel):
149
+ news_relevance: int = Field(description="Integer from -2 to 2")
150
+ sentiment: int = Field(description="Integer from -2 to 2")
151
+ price_impact_potential: int = Field(description="Integer from -2 to 2")
152
+ trend_direction: int = Field(description="Integer from -2 to 2")
153
+ earnings_impact: int = Field(description="Integer from -2 to 2")
154
+ investor_confidence: int = Field(description="Integer from -2 to 2")
155
+ risk_profile_change: int = Field(description="Integer from -2 to 2")
156
+
157
+ return JsonOutputParser(pydantic_object=NewsAnalysis)
158
 
159
 
160
  def format_news_data(news_items: List[Dict[str, Any]]) -> str:
src/prompts/portfolio_manager.py CHANGED
@@ -1,5 +1,5 @@
1
- from langchain.prompts import ChatPromptTemplate
2
- from langchain.output_parsers import ResponseSchema, StructuredOutputParser
3
  from typing import Dict, Any, List
4
 
5
 
@@ -150,22 +150,14 @@ def get_structured_output_parser():
150
  """
151
  Creates a structured output parser for portfolio manager decisions.
152
  """
153
- response_schemas = [
154
- ResponseSchema(
155
- name="trading_signal",
156
- description="The recommended trading action: BUY, SELL, or HOLD"
157
- ),
158
- ResponseSchema(
159
- name="confidence_level",
160
- description="Confidence level in the decision (0.1-1.0)"
161
- ),
162
- ResponseSchema(
163
- name="position_size",
164
- description="Recommended position size as percentage (10-100)"
165
- )
166
- ]
167
-
168
- return StructuredOutputParser.from_response_schemas(response_schemas)
169
 
170
 
171
  def format_basic_financials(financials_data: Dict[str, Any]) -> str:
 
1
+ from langchain_core.prompts import ChatPromptTemplate
2
+ from langchain_core.output_parsers import JsonOutputParser
3
  from typing import Dict, Any, List
4
 
5
 
 
150
  """
151
  Creates a structured output parser for portfolio manager decisions.
152
  """
153
+ from pydantic import BaseModel, Field
154
+
155
+ class TradingDecision(BaseModel):
156
+ trading_signal: str = Field(description="The recommended trading action: BUY, SELL, or HOLD")
157
+ confidence_level: float = Field(description="Confidence level in the decision (0.1-1.0)")
158
+ position_size: int = Field(description="Recommended position size as percentage (10-100)")
159
+
160
+ return JsonOutputParser(pydantic_object=TradingDecision)
 
 
 
 
 
 
 
 
161
 
162
 
163
  def format_basic_financials(financials_data: Dict[str, Any]) -> str:
src/prompts/senior_broker.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Senior Broker prompt template — the core analytical prompt for PrimoGreedy.
2
+
3
+ This module provides the analyst prompt via two paths:
4
+ 1. **LangSmith Hub** — pulled at runtime so the team can edit, version, and
5
+ A/B test prompt changes *without* redeploying code.
6
+ 2. **Local fallback** — hard-coded below so the agent still works offline
7
+ or if Hub is unreachable.
8
+
9
+ To upload / update the Hub prompt, run:
10
+ PYTHONPATH=. python scripts/push_prompt_to_hub.py
11
+ """
12
+
13
+ import os
14
+ from src.core.logger import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Local template — kept in sync with the Hub version
20
+ # ---------------------------------------------------------------------------
21
+ SENIOR_BROKER_TEMPLATE = """Act as a Senior Financial Broker evaluating {company_name} ({ticker}).
22
+
23
+ HARD DATA: Price: ${price} | EPS: {eps} | Book/Share: {book_value} | EBITDA: {ebitda}
24
+ QUANTITATIVE THESIS: {thesis}
25
+
26
+ {deep_fundamentals}
27
+
28
+ {sec_context}
29
+
30
+ Your task is to write a highly structured investment memo combining strict {strategy} math with qualitative analysis and recent insider behavior/news. Do not use fluff or buzzwords.
31
+
32
+ Format your response EXACTLY like this:
33
+
34
+ ### THE QUANTITATIVE BASE (Graham / Asset Play)
35
+ * State the current Price vs the calculated {strategy} valuation.
36
+ * Briefly explain if the math supports a margin of safety.
37
+
38
+ ### THE LYNCH PITCH (Why I would own this)
39
+ * **The Core Action:** In one sentence, what are insiders doing (buying/selling/neutral)?
40
+ * **The Catalyst:** Based on the news, what is the ONE simple reason this stock could run?
41
+
42
+ ### THE MUNGER INVERT (How I could lose money)
43
+ * **Structural Weakness:** What is the most likely way an investor loses money here based on fundamentals/news?
44
+ * **The Bear Evidence:** What exact metric, news, or math would prove the bear case right?
45
+
46
+ ### FINAL VERDICT
47
+ STRONG BUY / BUY / WATCH / AVOID (Choose one, followed by a 1-sentence bottom line).
48
+ """
49
+
50
+
51
+ def get_analyst_prompt() -> str:
52
+ """Return the Senior Broker prompt template string.
53
+
54
+ Tries LangSmith Hub first (if LANGCHAIN_API_KEY is set), otherwise
55
+ returns the local fallback.
56
+ """
57
+ if os.getenv("LANGCHAIN_API_KEY"):
58
+ try:
59
+ from langsmith import Client
60
+
61
+ client = Client()
62
+ hub_prompt = client.pull_prompt("primogreedy/senior-broker")
63
+
64
+ # Extract the template string from the ChatPromptTemplate
65
+ messages = hub_prompt.messages
66
+ if messages:
67
+ template_str = messages[0].prompt.template
68
+ logger.info("Loaded analyst prompt from LangSmith Hub")
69
+ return template_str
70
+ except Exception as exc:
71
+ logger.warning("Hub pull failed, using local fallback: %s", exc)
72
+
73
+ logger.info("Using local Senior Broker prompt template")
74
+ return SENIOR_BROKER_TEMPLATE