Commit b28d6c1e authored by MD. SHAHIDUL ISLAM's avatar MD. SHAHIDUL ISLAM

refactor: centralize CLI theming and standardize prompt styling for consistent…

refactor: centralize CLI theming and standardize prompt styling for consistent UI across user inputs.
parent 2b18c83e
...@@ -344,6 +344,13 @@ running the long multi-agent graph, use: ...@@ -344,6 +344,13 @@ running the long multi-agent graph, use:
dohasecuritiesstockai-dashboard GP --date 2026-08-10 dohasecuritiesstockai-dashboard GP --date 2026-08-10
``` ```
To reopen an already completed multi-agent run in the UI without rerunning the
analysis or making another AI call, use:
```bash
dohasecuritiesstockai-dashboard SQURPHARMA --date 2026-08-17 --saved-run --no-ai
```
The command sends only the collected, date-bounded evidence to the configured The command sends only the collected, date-bounded evidence to the configured
deep-thinking model and requires that provider's API key. Numeric valuation deep-thinking model and requires that provider's API key. Numeric valuation
methods remain calculation-backed; the AI assigns reliability weights, writes methods remain calculation-backed; the AI assigns reliability weights, writes
......
...@@ -9,7 +9,7 @@ from pathlib import Path ...@@ -9,7 +9,7 @@ from pathlib import Path
import typer import typer
from rich import box from rich import box
from rich.align import Align from rich.align import Align
from rich.console import Console from rich.console import Console, Group
from rich.layout import Layout from rich.layout import Layout
from rich.live import Live from rich.live import Live
from rich.markdown import Markdown from rich.markdown import Markdown
...@@ -20,6 +20,7 @@ from rich.table import Table ...@@ -20,6 +20,7 @@ from rich.table import Table
from rich.text import Text from rich.text import Text
from cli.stats_handler import StatsCallbackHandler from cli.stats_handler import StatsCallbackHandler
from cli.theme import CLI_THEME
from cli.utils import ( from cli.utils import (
ask_anthropic_effort, ask_anthropic_effort,
ask_gemini_thinking_config, ask_gemini_thinking_config,
...@@ -40,6 +41,7 @@ from cli.utils import ( ...@@ -40,6 +41,7 @@ from cli.utils import (
select_research_depth, select_research_depth,
select_shallow_thinking_agent, select_shallow_thinking_agent,
) )
from dohasecuritiesstockai.dataflows.errors import VendorError
from dohasecuritiesstockai.default_config import DEFAULT_CONFIG from dohasecuritiesstockai.default_config import DEFAULT_CONFIG
from dohasecuritiesstockai.graph.analyst_execution import ( from dohasecuritiesstockai.graph.analyst_execution import (
AnalystWallTimeTracker, AnalystWallTimeTracker,
...@@ -50,7 +52,7 @@ from dohasecuritiesstockai.graph.analyst_execution import ( ...@@ -50,7 +52,7 @@ from dohasecuritiesstockai.graph.analyst_execution import (
from dohasecuritiesstockai.graph.trading_graph import TradingAgentsGraph from dohasecuritiesstockai.graph.trading_graph import TradingAgentsGraph
from dohasecuritiesstockai.reporting import write_report_tree from dohasecuritiesstockai.reporting import write_report_tree
console = Console() console = Console(theme=CLI_THEME, highlight=False)
# The product and Python package share one canonical name. Legacy executable # The product and Python package share one canonical name. Legacy executable
# aliases remain available for existing shell scripts. # aliases remain available for existing shell scripts.
...@@ -58,6 +60,19 @@ PRODUCT_NAME = "DohasecuritiesStockAi" ...@@ -58,6 +60,19 @@ PRODUCT_NAME = "DohasecuritiesStockAi"
PRODUCT_DISPLAY_NAME = "Doha Securities Stock AI" PRODUCT_DISPLAY_NAME = "Doha Securities Stock AI"
PRODUCT_TAGLINE = "DSE Multi-Agent Stock Analysis" PRODUCT_TAGLINE = "DSE Multi-Agent Stock Analysis"
WORKFLOW_TEAMS = {
"Analyst Desk": [
"Market Analyst",
"Sentiment Analyst",
"News Analyst",
"Fundamentals Analyst",
],
"Research Desk": ["Bull Researcher", "Bear Researcher", "Research Manager"],
"Trade Desk": ["Trader"],
"Risk Desk": ["Aggressive Analyst", "Neutral Analyst", "Conservative Analyst"],
"Portfolio Desk": ["Portfolio Manager"],
}
# prompt_toolkit's win32 output module is importable only on Windows (it asserts # prompt_toolkit's win32 output module is importable only on Windows (it asserts
# the platform at import time), so gate on the platform rather than catching the # the platform at import time), so gate on the platform rather than catching the
# failure — that way a genuinely broken prompt_toolkit on Windows still surfaces # failure — that way a genuinely broken prompt_toolkit on Windows still surfaces
...@@ -131,15 +146,17 @@ class MessageBuffer: ...@@ -131,15 +146,17 @@ class MessageBuffer:
self.current_agent = None self.current_agent = None
self.report_sections = {} self.report_sections = {}
self.selected_analysts = [] self.selected_analysts = []
self.run_context = {}
self._processed_message_ids = set() self._processed_message_ids = set()
def init_for_analysis(self, selected_analysts): def init_for_analysis(self, selected_analysts, run_context=None):
"""Initialize agent status and report sections based on selected analysts. """Initialize agent status and report sections based on selected analysts.
Args: Args:
selected_analysts: List of analyst type strings (e.g., ["market", "news"]) selected_analysts: List of analyst type strings (e.g., ["market", "news"])
""" """
self.selected_analysts = [a.lower() for a in selected_analysts] self.selected_analysts = [a.lower() for a in selected_analysts]
self.run_context = dict(run_context or {})
# Build agent_status dynamically # Build agent_status dynamically
self.agent_status = {} self.agent_status = {}
...@@ -229,9 +246,7 @@ class MessageBuffer: ...@@ -229,9 +246,7 @@ class MessageBuffer:
"trader_investment_plan": "Trading Team Plan", "trader_investment_plan": "Trading Team Plan",
"final_trade_decision": "Portfolio Management Decision", "final_trade_decision": "Portfolio Management Decision",
} }
self.current_report = ( self.current_report = f"### {section_titles[latest_section]}\n{latest_content}"
f"### {section_titles[latest_section]}\n{latest_content}"
)
# Update the final complete report # Update the final complete report
self._update_final_report() self._update_final_report()
...@@ -240,21 +255,22 @@ class MessageBuffer: ...@@ -240,21 +255,22 @@ class MessageBuffer:
report_parts = [] report_parts = []
# Analyst Team Reports - use .get() to handle missing sections # Analyst Team Reports - use .get() to handle missing sections
analyst_sections = ["market_report", "sentiment_report", "news_report", "fundamentals_report"] analyst_sections = [
"market_report",
"sentiment_report",
"news_report",
"fundamentals_report",
]
if any(self.report_sections.get(section) for section in analyst_sections): if any(self.report_sections.get(section) for section in analyst_sections):
report_parts.append("## Analyst Team Reports") report_parts.append("## Analyst Team Reports")
if self.report_sections.get("market_report"): if self.report_sections.get("market_report"):
report_parts.append( report_parts.append(f"### Market Analysis\n{self.report_sections['market_report']}")
f"### Market Analysis\n{self.report_sections['market_report']}"
)
if self.report_sections.get("sentiment_report"): if self.report_sections.get("sentiment_report"):
report_parts.append( report_parts.append(
f"### Social Sentiment\n{self.report_sections['sentiment_report']}" f"### Social Sentiment\n{self.report_sections['sentiment_report']}"
) )
if self.report_sections.get("news_report"): if self.report_sections.get("news_report"):
report_parts.append( report_parts.append(f"### News Analysis\n{self.report_sections['news_report']}")
f"### News Analysis\n{self.report_sections['news_report']}"
)
if self.report_sections.get("fundamentals_report"): if self.report_sections.get("fundamentals_report"):
report_parts.append( report_parts.append(
f"### Fundamentals Analysis\n{self.report_sections['fundamentals_report']}" f"### Fundamentals Analysis\n{self.report_sections['fundamentals_report']}"
...@@ -282,266 +298,443 @@ message_buffer = MessageBuffer() ...@@ -282,266 +298,443 @@ message_buffer = MessageBuffer()
def create_layout(): def create_layout():
"""Build the live analysis desk: run map, activity feed, and live brief."""
layout = Layout() layout = Layout()
layout.split_column( layout.split_column(
Layout(name="header", size=3), Layout(name="header", size=5),
Layout(name="main"), Layout(name="main"),
Layout(name="footer", size=3), Layout(name="footer", size=3),
) )
layout["main"].split_column( layout["main"].split_row(
Layout(name="upper", ratio=3), Layout(name="analysis", ratio=5) Layout(name="run_map", size=42),
Layout(name="workspace"),
) )
layout["upper"].split_row( layout["workspace"].split_column(
Layout(name="progress", ratio=2), Layout(name="messages", ratio=3) Layout(name="activity", size=15),
Layout(name="analysis"),
) )
return layout return layout
def create_live_display(layout: Layout) -> Live:
"""Create the live renderer on the same themed console as every panel.
Rich resolves named styles at render time. Using its implicit global
console here leaves custom styles such as ``brand`` unknown, which can stop
the auto-refresh thread after the empty Layout placeholder is painted.
"""
return Live(
layout,
console=console,
refresh_per_second=8,
vertical_overflow="crop",
)
def format_tokens(n): def format_tokens(n):
"""Format token count for display.""" """Format token count for display."""
if n >= 1000: if n >= 1000:
return f"{n/1000:.1f}k" return f"{n / 1000:.1f}k"
return str(n) return str(n)
def update_display(layout, spinner_text=None, stats_handler=None, start_time=None): def _active_agent() -> str | None:
# Header with welcome message return next(
layout["header"].update( (agent for agent, status in message_buffer.agent_status.items() if status == "in_progress"),
Panel( None,
f"[bold green]{PRODUCT_DISPLAY_NAME}[/bold green]\n"
"[dim]Dhaka Stock Exchange market intelligence[/dim]",
title=PRODUCT_NAME,
border_style="green",
padding=(1, 2),
expand=True,
)
) )
# Progress panel showing agent status
progress_table = Table( def _phase_status(agents: list[str]) -> str:
show_header=True, statuses = [message_buffer.agent_status.get(agent, "pending") for agent in agents]
header_style="bold magenta", if any(status == "error" for status in statuses):
show_footer=False, return "error"
box=box.SIMPLE_HEAD, # Use simple header with horizontal lines if statuses and all(status == "completed" for status in statuses):
title=None, # Remove the redundant Progress title return "completed"
padding=(0, 2), # Add horizontal padding if any(status == "in_progress" for status in statuses):
expand=True, # Make table expand to fill available space return "in_progress"
) return "pending"
progress_table.add_column("Team", style="cyan", justify="center", width=20)
progress_table.add_column("Agent", style="green", justify="center", width=20)
progress_table.add_column("Status", style="yellow", justify="center", width=20) def _status_text(status: str, *, compact: bool = False) -> Text:
labels = {
# Group agents by team - filter to only include agents in agent_status "pending": ("○", "QUEUED", "stage.pending"),
all_teams = { "in_progress": ("●", "RUNNING", "stage.active"),
"Analyst Team": [ "completed": ("✓", "DONE", "stage.done"),
"Market Analyst", "error": ("×", "ERROR", "danger"),
"Sentiment Analyst",
"News Analyst",
"Fundamentals Analyst",
],
"Research Team": ["Bull Researcher", "Bear Researcher", "Research Manager"],
"Trading Team": ["Trader"],
"Risk Management": ["Aggressive Analyst", "Neutral Analyst", "Conservative Analyst"],
"Portfolio Management": ["Portfolio Manager"],
} }
mark, label, style = labels.get(status, ("·", status.upper(), "muted"))
return Text(mark if compact else f"{mark} {label}", style=style)
def _render_live_header() -> Panel:
context = message_buffer.run_context
ticker = str(context.get("ticker") or "DSE")
analysis_date = str(context.get("analysis_date") or "—")
provider = str(context.get("llm_provider") or "—").upper()
active_agent = _active_agent()
is_complete = bool(message_buffer.agent_status) and all(
status == "completed" for status in message_buffer.agent_status.values()
)
# Filter teams to only include agents that are in agent_status header = Table.grid(expand=True)
teams = {} header.add_column(ratio=2)
for team, agents in all_teams.items(): header.add_column(justify="right", ratio=1)
active_agents = [a for a in agents if a in message_buffer.agent_status] header.add_row(
if active_agents: Text.assemble(
teams[team] = active_agents ("DOHA SECURITIES", "brand"),
(" / ", "muted"),
for team, agents in teams.items(): ("INTELLIGENCE DESK", "label"),
# Add first agent with team name ),
first_agent = agents[0] Text(ticker, style="value"),
status = message_buffer.agent_status.get(first_agent, "pending") )
if status == "in_progress": header.add_row(
spinner = Spinner( Text(
"dots", text="[blue]in_progress[/blue]", style="bold cyan" "DSE multi-agent research, trading and risk orchestration",
) style="muted",
status_cell = spinner ),
else: Text(f"{analysis_date} · {provider}", style="muted"),
status_color = { )
"pending": "yellow", state = (
"completed": "green", Text("✓ RUN COMPLETE", style="success")
"error": "red", if is_complete
}.get(status, "white") else Text(f"● {active_agent or 'INITIALIZING'}", style="stage.active")
status_cell = f"[{status_color}]{status}[/{status_color}]"
progress_table.add_row(team, first_agent, status_cell)
# Add remaining agents in team
for agent in agents[1:]:
status = message_buffer.agent_status.get(agent, "pending")
if status == "in_progress":
spinner = Spinner(
"dots", text="[blue]in_progress[/blue]", style="bold cyan"
)
status_cell = spinner
else:
status_color = {
"pending": "yellow",
"completed": "green",
"error": "red",
}.get(status, "white")
status_cell = f"[{status_color}]{status}[/{status_color}]"
progress_table.add_row("", agent, status_cell)
# Add horizontal line after each team
progress_table.add_row("─" * 20, "─" * 20, "─" * 20, style="dim")
layout["progress"].update(
Panel(progress_table, title="Progress", border_style="cyan", padding=(1, 2))
) )
return Panel(
header,
title=state,
title_align="right",
border_style="#334155",
box=box.ROUNDED,
padding=(0, 1),
)
def _render_run_map() -> Panel:
run_map = Table.grid(expand=True, padding=(0, 1))
run_map.add_column(width=3, justify="right")
run_map.add_column(ratio=1)
run_map.add_column(width=10, justify="right")
# Messages panel showing recent messages and tool calls stage_number = 0
messages_table = Table( for team, configured_agents in WORKFLOW_TEAMS.items():
show_header=True, agents = [agent for agent in configured_agents if agent in message_buffer.agent_status]
header_style="bold magenta", if not agents:
show_footer=False, continue
expand=True, # Make table expand to fill available space
box=box.MINIMAL, # Use minimal box style for a lighter look stage_number += 1
show_lines=True, # Keep horizontal lines phase_status = _phase_status(agents)
padding=(0, 1), # Add some padding between columns complete_count = sum(
message_buffer.agent_status.get(agent) == "completed" for agent in agents
)
number_style = "brand" if phase_status == "in_progress" else "muted"
team_style = "value" if phase_status == "in_progress" else "label"
run_map.add_row(
Text(f"{stage_number:02}", style=number_style),
Text(team.upper(), style=team_style),
Text(f"{complete_count}/{len(agents)}", style="muted"),
)
for agent in agents:
status = message_buffer.agent_status.get(agent, "pending")
agent_style = "value" if status == "in_progress" else "muted"
run_map.add_row(
"",
Text(f" {agent}", style=agent_style),
_status_text(status),
)
run_map.add_row("", "", "")
return Panel(
run_map,
title=Text(" RUN MAP ", style="label"),
subtitle=Text("ANALYZE → DECIDE → CONTROL", style="muted"),
border_style="#334155",
box=box.ROUNDED,
padding=(1, 1),
) )
messages_table.add_column("Time", style="cyan", width=8, justify="center")
messages_table.add_column("Type", style="green", width=10, justify="center")
messages_table.add_column(
"Content", style="white", no_wrap=False, ratio=1
) # Make content column expand
# Combine tool calls and messages
all_messages = []
# Add tool calls def _activity_style(message_type: str) -> str:
return {
"Tool": "activity.tool",
"Agent": "activity.agent",
"Data": "activity.agent",
"System": "activity.system",
"User": "activity.user",
}.get(message_type, "label")
def _render_activity_feed() -> Panel:
events = []
for timestamp, tool_name, args in message_buffer.tool_calls: for timestamp, tool_name, args in message_buffer.tool_calls:
formatted_args = format_tool_args(args) events.append((timestamp, "Tool", f"{tool_name} {format_tool_args(args)}"))
all_messages.append((timestamp, "Tool", f"{tool_name}: {formatted_args}")) for timestamp, message_type, content in message_buffer.messages:
content_text = str(content or "").replace("\n", " ")
# Add regular messages if len(content_text) > 180:
for timestamp, msg_type, content in message_buffer.messages: content_text = content_text[:177] + "..."
content_str = str(content) if content else "" events.append((timestamp, message_type, content_text))
if len(content_str) > 200: events.sort(key=lambda event: event[0], reverse=True)
content_str = content_str[:197] + "..."
all_messages.append((timestamp, msg_type, content_str)) feed = Table.grid(expand=True, padding=(0, 1))
feed.add_column(width=8, style="muted")
# Sort by timestamp descending (newest first) feed.add_column(width=9)
all_messages.sort(key=lambda x: x[0], reverse=True) feed.add_column(ratio=1, overflow="fold")
for timestamp, message_type, content in events[:9]:
# Calculate how many messages we can show based on available space feed.add_row(
max_messages = 12 timestamp,
Text(message_type.upper(), style=_activity_style(message_type)),
# Get the first N messages (newest ones) Text(content, overflow="fold"),
recent_messages = all_messages[:max_messages]
# Add messages to table (already in newest-first order)
for timestamp, msg_type, content in recent_messages:
# Format content with word wrapping
wrapped_content = Text(content, overflow="fold")
messages_table.add_row(timestamp, msg_type, wrapped_content)
layout["messages"].update(
Panel(
messages_table,
title="Messages & Tools",
border_style="blue",
padding=(1, 2),
) )
if not events:
feed.add_row("", Text("SYSTEM", style="activity.system"), "Preparing run…")
return Panel(
feed,
title=Text(" ACTIVITY FEED ", style="label"),
border_style="#334155",
box=box.ROUNDED,
padding=(0, 1),
) )
# Analysis panel showing current report
if message_buffer.current_report:
layout["analysis"].update(
Panel(
Markdown(message_buffer.current_report),
title="Current Report",
border_style="green",
padding=(1, 2),
)
)
else:
layout["analysis"].update(
Panel(
"[italic]Waiting for analysis report...[/italic]",
title="Current Report",
border_style="green",
padding=(1, 2),
)
)
# Footer with statistics def _render_live_brief(spinner_text: str | None) -> Panel:
# Agent progress - derived from agent_status dict content = (
agents_completed = sum( Markdown(message_buffer.current_report)
1 for status in message_buffer.agent_status.values() if status == "completed" if message_buffer.current_report
else Spinner(
"dots2",
text=Text(
f" {spinner_text or 'Waiting for the first analyst brief…'}",
style="muted",
),
style="brand",
)
) )
agents_total = len(message_buffer.agent_status) return Panel(
content,
title=Text(" LIVE BRIEF ", style="label"),
subtitle=Text("LATEST COMPLETED OUTPUT", style="muted"),
border_style="#334155",
box=box.ROUNDED,
padding=(1, 2),
)
# Report progress - based on agent completion (not just content existence) def _render_metrics(stats_handler=None, start_time=None) -> Panel:
agents_completed = sum(status == "completed" for status in message_buffer.agent_status.values())
agents_total = len(message_buffer.agent_status)
reports_completed = message_buffer.get_completed_reports_count() reports_completed = message_buffer.get_completed_reports_count()
reports_total = len(message_buffer.report_sections) reports_total = len(message_buffer.report_sections)
values = [
("AGENTS", f"{agents_completed}/{agents_total}"),
("REPORTS", f"{reports_completed}/{reports_total}"),
]
# Build stats parts
stats_parts = [f"Agents: {agents_completed}/{agents_total}"]
# LLM and tool stats from callback handler
if stats_handler: if stats_handler:
stats = stats_handler.get_stats() stats = stats_handler.get_stats()
stats_parts.append(f"LLM: {stats['llm_calls']}") tokens = (
stats_parts.append(f"Tools: {stats['tool_calls']}") f"{format_tokens(stats['tokens_in'])}↑ {format_tokens(stats['tokens_out'])}↓"
if stats["tokens_in"] > 0 or stats["tokens_out"] > 0
else "—"
)
values.extend(
[
("LLM", str(stats["llm_calls"])),
("TOOLS", str(stats["tool_calls"])),
("TOKENS", tokens),
]
)
if start_time:
elapsed = time.time() - start_time
values.append(("ELAPSED", f"{int(elapsed // 60):02d}:{int(elapsed % 60):02d}"))
# Token display with graceful fallback metrics = Table.grid(expand=True)
if stats["tokens_in"] > 0 or stats["tokens_out"] > 0: for _ in values:
tokens_str = f"Tokens: {format_tokens(stats['tokens_in'])}\u2191 {format_tokens(stats['tokens_out'])}\u2193" metrics.add_column(justify="center")
else: metrics.add_row(
tokens_str = "Tokens: --" *[Text.assemble((f"{label} ", "muted"), (value, "value")) for label, value in values]
stats_parts.append(tokens_str) )
return Panel(
metrics,
border_style="#334155",
box=box.ROUNDED,
padding=(0, 1),
)
stats_parts.append(f"Reports: {reports_completed}/{reports_total}")
# Elapsed time def update_display(layout, spinner_text=None, stats_handler=None, start_time=None):
if start_time: """Refresh every region of the live intelligence desk."""
elapsed = time.time() - start_time
elapsed_str = f"\u23f1 {int(elapsed // 60):02d}:{int(elapsed % 60):02d}"
stats_parts.append(elapsed_str)
stats_table = Table(show_header=False, box=None, padding=(0, 2), expand=True) layout["header"].update(_render_live_header())
stats_table.add_column("Stats", justify="center") layout["run_map"].update(_render_run_map())
stats_table.add_row(" | ".join(stats_parts)) layout["activity"].update(_render_activity_feed())
layout["analysis"].update(_render_live_brief(spinner_text))
layout["footer"].update(_render_metrics(stats_handler, start_time))
layout["footer"].update(Panel(stats_table, border_style="grey50"))
def _compact_panel_width() -> int:
return max(36, min(92, console.size.width - 4))
def get_user_selections():
"""Get all user selections before starting the analysis display.""" def render_startup_header() -> Panel:
# Display ASCII art welcome message """Render a compact launch identity without the old oversized ASCII logo."""
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
welcome_ascii = f.read() title = Text.assemble(
("DOHA SECURITIES", "brand"),
# Create welcome box content (" / ", "muted"),
welcome_content = f"{welcome_ascii}\n" ("STOCK AI", "brand.secondary"),
welcome_content += f"[bold green]{PRODUCT_DISPLAY_NAME}[/bold green]\n" )
welcome_content += f"[dim]{PRODUCT_TAGLINE}[/dim]\n\n" pipeline = Text.assemble(
welcome_content += "[bold]Workflow Steps:[/bold]\n" ("01 ANALYZE", "brand"),
welcome_content += "I. Analyst Team → II. Research Team → III. Trader → IV. Risk Management → V. Portfolio Management" (
" ─ 02 RESEARCH ─ 03 TRADE ─ 04 RISK ─ 05 PORTFOLIO",
# Create and center the welcome box "muted",
welcome_box = Panel( ),
welcome_content, )
border_style="green", return Panel(
Group(
title,
Text("DSE multi-agent market intelligence desk", style="muted"),
Text(""),
pipeline,
),
title=Text(" MARKET INTELLIGENCE CONSOLE ", style="label"),
subtitle=Text("DECISION SUPPORT · READ-ONLY MARKET DATA", style="muted"),
border_style="#334155",
box=box.ROUNDED,
padding=(1, 2), padding=(1, 2),
title=f"Welcome to {PRODUCT_NAME}", width=_compact_panel_width(),
subtitle=PRODUCT_TAGLINE, )
def render_setup_step(
number: int,
title: str,
description: str,
default: str | None = None,
) -> Panel:
"""Render one compact setup card with consistent hierarchy."""
content = Table.grid(expand=True)
content.add_column(width=5)
content.add_column(ratio=1)
content.add_row(Text(f"{number:02}", style="brand"), Text(title.upper(), style="value"))
content.add_row("", Text(description, style="muted"))
if default:
content.add_row(
"",
Text.assemble(("DEFAULT ", "label"), (default, "brand.secondary")),
)
return Panel(
content,
border_style="#334155",
box=box.ROUNDED,
padding=(0, 1),
width=_compact_panel_width(),
)
def _print_setup_step(*args, **kwargs) -> None:
console.print(Align.center(render_setup_step(*args, **kwargs)))
def _print_setup_value(
label: str,
value: str,
*,
source: str | None = None,
) -> None:
line = Text.assemble(
(" ✓ ", "success"),
(f"{label.upper():<13}", "label"),
(value, "value"),
)
if source:
line.append(" · ", style="muted")
line.append(source.upper(), style="muted")
console.print(Align.center(Align.left(line, width=_compact_panel_width())))
def render_run_brief(selections: dict) -> Panel:
"""Summarize the configured run before the live workspace takes over."""
analysts = ", ".join(analyst.value for analyst in selections["analysts"])
rows = [
("SYMBOL", selections["ticker"], "DATE", selections["analysis_date"]),
("ANALYSTS", analysts, "DEPTH", f"{selections['research_depth']} rounds"),
(
"PROVIDER",
selections["llm_provider"].upper(),
"LANGUAGE",
selections["output_language"],
),
]
table = Table.grid(expand=True, padding=(0, 1))
table.add_column(width=11, style="label")
table.add_column(ratio=1, style="value")
table.add_column(width=11, style="label")
table.add_column(ratio=1, style="value")
for row in rows:
table.add_row(*[str(value) for value in row])
return Panel(
table,
title=Text(" RUN BRIEF ", style="brand"),
subtitle=Text("CONFIGURATION LOCKED · STARTING ANALYSIS", style="muted"),
border_style="#22d3ee",
box=box.ROUNDED,
padding=(1, 2),
width=_compact_panel_width(),
) )
console.print(Align.center(welcome_box))
console.print()
# Create a boxed questionnaire for each step
def create_question_box(title, prompt, default=None): def render_completion_card(selections: dict, timing_summary: str) -> Panel:
box_content = f"[bold]{title}[/bold]\n" """Render a calm handoff from the live workspace to report actions."""
box_content += f"[dim]{prompt}[/dim]"
if default: heading = Text.assemble(
box_content += f"\n[dim]Default: {default}[/dim]" ("✓ RUN COMPLETE", "success"),
return Panel(box_content, border_style="blue", padding=(1, 2)) (" / ", "muted"),
(str(selections["ticker"]), "value"),
)
details = Table.grid(expand=True, padding=(0, 1))
details.add_column(width=12, style="label")
details.add_column(ratio=1, style="value")
details.add_row("AS-OF DATE", str(selections["analysis_date"]))
details.add_row("STATUS", "Decision chain complete")
details.add_row("TIMING", timing_summary)
return Panel(
Group(heading, Text(""), details),
title=Text(" ANALYSIS HANDOFF ", style="label"),
subtitle=Text("REPORT ACTIONS", style="muted"),
border_style="#34d399",
box=box.ROUNDED,
padding=(1, 2),
width=_compact_panel_width(),
)
def _print_notice(title: str, message: str, *, level: str = "warning") -> None:
style = {"success": "#34d399", "error": "#fb7185"}.get(level, "#fbbf24")
title_style = {"success": "success", "error": "danger"}.get(level, "warning")
console.print(
Align.center(
Panel(
Text(message),
title=Text(f" {title.upper()} ", style=title_style),
border_style=style,
box=box.ROUNDED,
padding=(0, 1),
width=_compact_panel_width(),
)
)
)
def get_user_selections():
"""Get all user selections before starting the analysis display."""
console.print(Align.center(render_startup_header()))
console.print()
def thinking_value_or_prompt(env_var, config_key, label, box_title, box_body, prompt_fn): def thinking_value_or_prompt(env_var, config_key, label, box_title, box_body, prompt_fn):
"""Return the env-configured reasoning/thinking value, or prompt for it. """Return the env-configured reasoning/thinking value, or prompt for it.
...@@ -552,20 +745,20 @@ def get_user_selections(): ...@@ -552,20 +745,20 @@ def get_user_selections():
""" """
if os.environ.get(env_var): if os.environ.get(env_var):
value = DEFAULT_CONFIG[config_key] value = DEFAULT_CONFIG[config_key]
console.print(f"[green]✓ {label} from environment:[/green] {value}") _print_setup_value(label, str(value), source="environment")
return value return value
console.print(create_question_box(box_title, box_body)) _print_setup_step(8, box_title.replace("Step 8: ", ""), box_body)
return prompt_fn() return prompt_fn()
# Step 1: Ticker symbol # Step 1: Ticker symbol
console.print( _print_setup_step(
create_question_box( 1,
"Step 1: Ticker Symbol", "Instrument",
"Enter the base Dhaka Stock Exchange trading code (e.g. GP, BRACBANK, SQURPHARMA)", "Enter a Dhaka Stock Exchange trading code, such as GP or BRACBANK.",
"GP", "GP",
)
) )
selected_ticker = get_ticker() selected_ticker = get_ticker()
_print_setup_value("Instrument", selected_ticker)
has_dse_credentials = bool( has_dse_credentials = bool(
os.environ.get("DSE_EMAIL_OR_PHONE") and os.environ.get("DSE_PASSWORD") os.environ.get("DSE_EMAIL_OR_PHONE") and os.environ.get("DSE_PASSWORD")
) )
...@@ -574,56 +767,54 @@ def get_user_selections(): ...@@ -574,56 +767,54 @@ def get_user_selections():
and not os.environ.get("DSE_ACCESS_TOKEN") and not os.environ.get("DSE_ACCESS_TOKEN")
and not has_dse_credentials and not has_dse_credentials
): ):
console.print( _print_notice(
"[yellow]DSE authentication is not configured. Set DSE_ACCESS_TOKEN or both " "DSE login required",
"DSE_EMAIL_OR_PHONE and DSE_PASSWORD before running DSE data calls.[/yellow]" "Set DSE_ACCESS_TOKEN, or configure both DSE_EMAIL_OR_PHONE and "
"DSE_PASSWORD before starting market-data analysis.",
) )
asset_type = detect_asset_type(selected_ticker) asset_type = detect_asset_type(selected_ticker)
# Only announce when it's not the default stock path, to avoid printing # Only announce when it's not the default stock path, to avoid printing
# "stock" on every run. # "stock" on every run.
if asset_type.value != "stock": if asset_type.value != "stock":
console.print( _print_setup_value("Asset class", asset_type.value)
f"[green]Detected asset type:[/green] {asset_type.value}"
)
# Step 2: Analysis date # Step 2: Analysis date
default_date = datetime.datetime.now().strftime("%Y-%m-%d") default_date = datetime.datetime.now().strftime("%Y-%m-%d")
console.print( _print_setup_step(
create_question_box( 2,
"Step 2: Analysis Date", "As-of date",
"Enter the analysis date (YYYY-MM-DD)", "Choose the market-information cutoff date in YYYY-MM-DD format.",
default_date, default_date,
)
) )
analysis_date = get_analysis_date() analysis_date = get_analysis_date()
_print_setup_value("As-of date", analysis_date)
# Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE) # Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE)
if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"): if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"):
output_language = DEFAULT_CONFIG["output_language"] output_language = DEFAULT_CONFIG["output_language"]
console.print( _print_setup_value("Language", output_language, source="environment")
f"[green]✓ Output language from environment:[/green] {output_language}"
)
else: else:
console.print( _print_setup_step(
create_question_box( 3,
"Step 3: Output Language", "Report language",
"Select the language for analyst reports and final decision" "Choose the language used for every analyst brief and final decision.",
)
) )
output_language = ask_output_language() output_language = ask_output_language()
_print_setup_value("Language", output_language)
# Step 4: Select analysts # Step 4: Select analysts
console.print( _print_setup_step(
create_question_box( 4,
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" "Analyst desk",
) "Build the specialist team that will investigate the instrument.",
) )
selected_analysts = select_analysts( selected_analysts = select_analysts(
asset_type, asset_type,
social_media_enabled=DEFAULT_CONFIG.get("social_media_enabled", False), social_media_enabled=DEFAULT_CONFIG.get("social_media_enabled", False),
) )
console.print( _print_setup_value(
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" "Analysts",
", ".join(analyst.value for analyst in selected_analysts),
) )
# Step 5: Research depth (skipped when both round counts are set via env). # Step 5: Research depth (skipped when both round counts are set via env).
...@@ -635,18 +826,20 @@ def get_user_selections(): ...@@ -635,18 +826,20 @@ def get_user_selections():
) )
if depth_from_env: if depth_from_env:
selected_research_depth = DEFAULT_CONFIG["max_debate_rounds"] selected_research_depth = DEFAULT_CONFIG["max_debate_rounds"]
console.print( _print_setup_value(
f"[green]✓ Research depth from environment:[/green] " "Research",
f"{DEFAULT_CONFIG['max_debate_rounds']} debate / " f"{DEFAULT_CONFIG['max_debate_rounds']} debate / "
f"{DEFAULT_CONFIG['max_risk_discuss_rounds']} risk rounds" f"{DEFAULT_CONFIG['max_risk_discuss_rounds']} risk rounds",
source="environment",
) )
else: else:
console.print( _print_setup_step(
create_question_box( 5,
"Step 5: Research Depth", "Select your research depth level" "Research depth",
) "Set the number of debate and risk-challenge rounds.",
) )
selected_research_depth = select_research_depth() selected_research_depth = select_research_depth()
_print_setup_value("Research", f"{selected_research_depth} rounds")
# Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER). # Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER).
# The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set, # The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set,
...@@ -658,15 +851,15 @@ def get_user_selections(): ...@@ -658,15 +851,15 @@ def get_user_selections():
backend_url = resolve_backend_url( backend_url = resolve_backend_url(
selected_llm_provider, env_url=DEFAULT_CONFIG["backend_url"] selected_llm_provider, env_url=DEFAULT_CONFIG["backend_url"]
) )
console.print(f"[green]✓ LLM provider from environment:[/green] {selected_llm_provider}") _print_setup_value("LLM provider", selected_llm_provider, source="environment")
console.print(f"[green]✓ Backend URL:[/green] {backend_url}") _print_setup_value("Endpoint", str(backend_url), source="resolved")
# Still confirm/persist the API key so the run doesn't fail later. # Still confirm/persist the API key so the run doesn't fail later.
ensure_api_key(selected_llm_provider) ensure_api_key(selected_llm_provider)
else: else:
console.print( _print_setup_step(
create_question_box( 6,
"Step 6: LLM Provider", "Select your LLM provider" "Intelligence engine",
) "Select the model provider that will power the analyst desk.",
) )
selected_llm_provider, backend_url = select_llm_provider() selected_llm_provider, backend_url = select_llm_provider()
...@@ -700,23 +893,27 @@ def get_user_selections(): ...@@ -700,23 +893,27 @@ def get_user_selections():
# one and persist it to .env if it's missing, so the analysis run # one and persist it to .env if it's missing, so the analysis run
# doesn't fail later at the first API call. # doesn't fail later at the first API call.
ensure_api_key(selected_llm_provider) ensure_api_key(selected_llm_provider)
_print_setup_value("LLM provider", selected_llm_provider)
_print_setup_value("Endpoint", str(backend_url or "SDK managed"), source="resolved")
# Step 7: Thinking agents (skipped when either model is set via environment) # Step 7: Thinking agents (skipped when either model is set via environment)
if os.environ.get("TRADINGAGENTS_QUICK_THINK_LLM") or os.environ.get("TRADINGAGENTS_DEEP_THINK_LLM"): if os.environ.get("TRADINGAGENTS_QUICK_THINK_LLM") or os.environ.get(
"TRADINGAGENTS_DEEP_THINK_LLM"
):
selected_shallow_thinker = DEFAULT_CONFIG["quick_think_llm"] selected_shallow_thinker = DEFAULT_CONFIG["quick_think_llm"]
selected_deep_thinker = DEFAULT_CONFIG["deep_think_llm"] selected_deep_thinker = DEFAULT_CONFIG["deep_think_llm"]
console.print( thinker_source = "environment"
f"[green]✓ Thinking agents from environment:[/green] "
f"quick={selected_shallow_thinker}, deep={selected_deep_thinker}"
)
else: else:
console.print( _print_setup_step(
create_question_box( 7,
"Step 7: Thinking Agents", "Select your thinking agents for analysis" "Thinking models",
) "Assign fast and deep models to the appropriate reasoning work.",
) )
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider) selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
thinker_source = None
_print_setup_value("Quick model", selected_shallow_thinker, source=thinker_source)
_print_setup_value("Deep model", selected_deep_thinker, source=thinker_source)
# Step 8: Provider-specific reasoning/thinking configuration. Each knob is # Step 8: Provider-specific reasoning/thinking configuration. Each knob is
# settable via its TRADINGAGENTS_* env var; when that var is set (or the # settable via its TRADINGAGENTS_* env var; when that var is set (or the
...@@ -734,24 +931,33 @@ def get_user_selections(): ...@@ -734,24 +931,33 @@ def get_user_selections():
anthropic_effort = DEFAULT_CONFIG["anthropic_effort"] anthropic_effort = DEFAULT_CONFIG["anthropic_effort"]
elif provider_lower == "google": elif provider_lower == "google":
thinking_level = thinking_value_or_prompt( thinking_level = thinking_value_or_prompt(
"TRADINGAGENTS_GOOGLE_THINKING_LEVEL", "google_thinking_level", "TRADINGAGENTS_GOOGLE_THINKING_LEVEL",
"Gemini thinking mode", "Step 8: Thinking Mode", "google_thinking_level",
"Configure Gemini thinking mode", ask_gemini_thinking_config, "Gemini thinking mode",
"Step 8: Thinking Mode",
"Configure Gemini thinking mode",
ask_gemini_thinking_config,
) )
elif provider_lower == "openai": elif provider_lower == "openai":
reasoning_effort = thinking_value_or_prompt( reasoning_effort = thinking_value_or_prompt(
"TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort", "TRADINGAGENTS_OPENAI_REASONING_EFFORT",
"Reasoning effort", "Step 8: Reasoning Effort", "openai_reasoning_effort",
"Configure OpenAI reasoning effort level", ask_openai_reasoning_effort, "Reasoning effort",
"Step 8: Reasoning Effort",
"Configure OpenAI reasoning effort level",
ask_openai_reasoning_effort,
) )
elif provider_lower == "anthropic": elif provider_lower == "anthropic":
anthropic_effort = thinking_value_or_prompt( anthropic_effort = thinking_value_or_prompt(
"TRADINGAGENTS_ANTHROPIC_EFFORT", "anthropic_effort", "TRADINGAGENTS_ANTHROPIC_EFFORT",
"Claude effort", "Step 8: Effort Level", "anthropic_effort",
"Configure Claude effort level", ask_anthropic_effort, "Claude effort",
"Step 8: Effort Level",
"Configure Claude effort level",
ask_anthropic_effort,
) )
return { selections = {
"ticker": selected_ticker, "ticker": selected_ticker,
"asset_type": asset_type.value, "asset_type": asset_type.value,
"analysis_date": analysis_date, "analysis_date": analysis_date,
...@@ -766,24 +972,32 @@ def get_user_selections(): ...@@ -766,24 +972,32 @@ def get_user_selections():
"anthropic_effort": anthropic_effort, "anthropic_effort": anthropic_effort,
"output_language": output_language, "output_language": output_language,
} }
console.print()
console.print(Align.center(render_run_brief(selections)))
console.print()
return selections
def get_analysis_date(): def get_analysis_date():
"""Get the analysis date from user input.""" """Get the analysis date from user input."""
while True: while True:
date_str = typer.prompt( date_str = typer.prompt("", default=datetime.datetime.now().strftime("%Y-%m-%d"))
"", default=datetime.datetime.now().strftime("%Y-%m-%d")
)
try: try:
# Validate date format and ensure it's not in the future # Validate date format and ensure it's not in the future
analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d") analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d")
if analysis_date.date() > datetime.datetime.now().date(): if analysis_date.date() > datetime.datetime.now().date():
console.print("[red]Error: Analysis date cannot be in the future[/red]") _print_notice(
"Invalid date",
"The analysis date cannot be in the future.",
level="error",
)
continue continue
return date_str return date_str
except ValueError: except ValueError:
console.print( _print_notice(
"[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]" "Invalid date",
"Use the YYYY-MM-DD format, for example 2026-08-17.",
level="error",
) )
...@@ -793,64 +1007,87 @@ def save_report_to_disk(final_state, ticker: str, save_path: Path): ...@@ -793,64 +1007,87 @@ def save_report_to_disk(final_state, ticker: str, save_path: Path):
def display_complete_report(final_state): def display_complete_report(final_state):
"""Display the complete analysis report sequentially (avoids truncation).""" """Display the complete analysis dossier without redundant section panels."""
def report_panel(stage: str, desk: str, title: str, content: str, accent: str):
panel_title = Text.assemble(
(f" {stage} ", "muted"),
(f"{desk.upper()} / ", "label"),
(title.upper(), "value"),
(" ", "muted"),
)
return Panel(
Markdown(content),
title=panel_title,
title_align="left",
border_style=accent,
box=box.ROUNDED,
padding=(1, 2),
)
console.print() console.print()
console.print(Rule("Complete Analysis Report", style="bold green")) console.print(
Rule(
# I. Analyst Team Reports Text.assemble(
analysts = [] ("ANALYSIS DOSSIER", "brand"),
if final_state.get("market_report"): (" / ", "muted"),
analysts.append(("Market Analyst", final_state["market_report"])) ("COMPLETE DECISION CHAIN", "label"),
if final_state.get("sentiment_report"): ),
analysts.append(("Sentiment Analyst", final_state["sentiment_report"])) style="#334155",
if final_state.get("news_report"): )
analysts.append(("News Analyst", final_state["news_report"])) )
if final_state.get("fundamentals_report"):
analysts.append(("Fundamentals Analyst", final_state["fundamentals_report"])) analyst_reports = [
if analysts: ("Market Analyst", final_state.get("market_report")),
console.print(Panel("[bold]I. Analyst Team Reports[/bold]", border_style="cyan")) ("Sentiment Analyst", final_state.get("sentiment_report")),
for title, content in analysts: ("News Analyst", final_state.get("news_report")),
console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) ("Fundamentals Analyst", final_state.get("fundamentals_report")),
]
# II. Research Team Reports for title, content in analyst_reports:
if final_state.get("investment_debate_state"): if content:
debate = final_state["investment_debate_state"] console.print(report_panel("01", "Analyst Desk", title, content, "#22d3ee"))
research = []
if debate.get("bull_history"): debate = final_state.get("investment_debate_state") or {}
research.append(("Bull Researcher", debate["bull_history"])) research_reports = [
if debate.get("bear_history"): ("Bull Researcher", debate.get("bull_history")),
research.append(("Bear Researcher", debate["bear_history"])) ("Bear Researcher", debate.get("bear_history")),
if debate.get("judge_decision"): ("Research Manager", debate.get("judge_decision")),
research.append(("Research Manager", debate["judge_decision"])) ]
if research: for title, content in research_reports:
console.print(Panel("[bold]II. Research Team Decision[/bold]", border_style="magenta")) if content:
for title, content in research: console.print(report_panel("02", "Research Desk", title, content, "#a78bfa"))
console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2)))
# III. Trading Team
if final_state.get("trader_investment_plan"): if final_state.get("trader_investment_plan"):
console.print(Panel("[bold]III. Trading Team Plan[/bold]", border_style="yellow")) console.print(
console.print(Panel(Markdown(final_state["trader_investment_plan"]), title="Trader", border_style="blue", padding=(1, 2))) report_panel(
"03",
# IV. Risk Management Team "Trade Desk",
if final_state.get("risk_debate_state"): "Trader",
risk = final_state["risk_debate_state"] final_state["trader_investment_plan"],
risk_reports = [] "#fbbf24",
if risk.get("aggressive_history"): )
risk_reports.append(("Aggressive Analyst", risk["aggressive_history"])) )
if risk.get("conservative_history"):
risk_reports.append(("Conservative Analyst", risk["conservative_history"])) risk = final_state.get("risk_debate_state") or {}
if risk.get("neutral_history"): risk_reports = [
risk_reports.append(("Neutral Analyst", risk["neutral_history"])) ("Aggressive Analyst", risk.get("aggressive_history")),
if risk_reports: ("Conservative Analyst", risk.get("conservative_history")),
console.print(Panel("[bold]IV. Risk Management Team Decision[/bold]", border_style="red")) ("Neutral Analyst", risk.get("neutral_history")),
for title, content in risk_reports: ]
console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) for title, content in risk_reports:
if content:
# V. Portfolio Manager Decision console.print(report_panel("04", "Risk Desk", title, content, "#fb7185"))
if risk.get("judge_decision"):
console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) if risk.get("judge_decision"):
console.print(Panel(Markdown(risk["judge_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) console.print(
report_panel(
"05",
"Portfolio Desk",
"Portfolio Manager",
risk["judge_decision"],
"#34d399",
)
)
def update_research_team_status(status): def update_research_team_status(status):
...@@ -923,6 +1160,7 @@ def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): ...@@ -923,6 +1160,7 @@ def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None):
): ):
message_buffer.update_agent_status("Bull Researcher", "in_progress") message_buffer.update_agent_status("Bull Researcher", "in_progress")
def extract_content_string(content): def extract_content_string(content):
"""Extract string content from various message formats. """Extract string content from various message formats.
Returns None if no meaningful text content is found. Returns None if no meaningful text content is found.
...@@ -931,7 +1169,7 @@ def extract_content_string(content): ...@@ -931,7 +1169,7 @@ def extract_content_string(content):
def is_empty(val): def is_empty(val):
"""Check if value is empty using Python's truthiness.""" """Check if value is empty using Python's truthiness."""
if val is None or val == '': if val is None or val == "":
return True return True
if isinstance(val, str): if isinstance(val, str):
s = val.strip() s = val.strip()
...@@ -950,16 +1188,17 @@ def extract_content_string(content): ...@@ -950,16 +1188,17 @@ def extract_content_string(content):
return content.strip() return content.strip()
if isinstance(content, dict): if isinstance(content, dict):
text = content.get('text', '') text = content.get("text", "")
return text.strip() if not is_empty(text) else None return text.strip() if not is_empty(text) else None
if isinstance(content, list): if isinstance(content, list):
text_parts = [ text_parts = [
item.get('text', '').strip() if isinstance(item, dict) and item.get('type') == 'text' item.get("text", "").strip()
else (item.strip() if isinstance(item, str) else '') if isinstance(item, dict) and item.get("type") == "text"
else (item.strip() if isinstance(item, str) else "")
for item in content for item in content
] ]
result = ' '.join(t for t in text_parts if t and not is_empty(t)) result = " ".join(t for t in text_parts if t and not is_empty(t))
return result if result else None return result if result else None
return str(content).strip() if not is_empty(content) else None return str(content).strip() if not is_empty(content) else None
...@@ -974,7 +1213,7 @@ def classify_message_type(message) -> tuple[str, str | None]: ...@@ -974,7 +1213,7 @@ def classify_message_type(message) -> tuple[str, str | None]:
""" """
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
content = extract_content_string(getattr(message, 'content', None)) content = extract_content_string(getattr(message, "content", None))
if isinstance(message, HumanMessage): if isinstance(message, HumanMessage):
if content and content.strip() == "Continue": if content and content.strip() == "Continue":
...@@ -995,9 +1234,10 @@ def format_tool_args(args, max_length=80) -> str: ...@@ -995,9 +1234,10 @@ def format_tool_args(args, max_length=80) -> str:
"""Format tool arguments for terminal display.""" """Format tool arguments for terminal display."""
result = str(args) result = str(args)
if len(result) > max_length: if len(result) > max_length:
return result[:max_length - 3] + "..." return result[: max_length - 3] + "..."
return result return result
def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
"""Assemble the run config from interactive selections, honoring env precedence. """Assemble the run config from interactive selections, honoring env precedence.
...@@ -1052,7 +1292,14 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1052,7 +1292,14 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
) )
# Initialize message buffer with selected analysts # Initialize message buffer with selected analysts
message_buffer.init_for_analysis(selected_analyst_keys) message_buffer.init_for_analysis(
selected_analyst_keys,
run_context={
"ticker": selections["ticker"],
"analysis_date": selections["analysis_date"],
"llm_provider": selections["llm_provider"],
},
)
# Track start time for elapsed display # Track start time for elapsed display
start_time = time.time() start_time = time.time()
...@@ -1067,6 +1314,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1067,6 +1314,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
def save_message_decorator(obj, func_name): def save_message_decorator(obj, func_name):
func = getattr(obj, func_name) func = getattr(obj, func_name)
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
func(*args, **kwargs) func(*args, **kwargs)
...@@ -1074,10 +1322,12 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1074,10 +1322,12 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
content = content.replace("\n", " ") # Replace newlines with spaces content = content.replace("\n", " ") # Replace newlines with spaces
with open(log_file, "a", encoding="utf-8") as f: with open(log_file, "a", encoding="utf-8") as f:
f.write(f"{timestamp} [{message_type}] {content}\n") f.write(f"{timestamp} [{message_type}] {content}\n")
return wrapper return wrapper
def save_tool_call_decorator(obj, func_name): def save_tool_call_decorator(obj, func_name):
func = getattr(obj, func_name) func = getattr(obj, func_name)
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
func(*args, **kwargs) func(*args, **kwargs)
...@@ -1085,57 +1335,68 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1085,57 +1335,68 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
args_str = ", ".join(f"{k}={v}" for k, v in args.items()) args_str = ", ".join(f"{k}={v}" for k, v in args.items())
with open(log_file, "a", encoding="utf-8") as f: with open(log_file, "a", encoding="utf-8") as f:
f.write(f"{timestamp} [Tool Call] {tool_name}({args_str})\n") f.write(f"{timestamp} [Tool Call] {tool_name}({args_str})\n")
return wrapper return wrapper
def save_report_section_decorator(obj, func_name): def save_report_section_decorator(obj, func_name):
func = getattr(obj, func_name) func = getattr(obj, func_name)
@wraps(func) @wraps(func)
def wrapper(section_name, content): def wrapper(section_name, content):
func(section_name, content) func(section_name, content)
if section_name in obj.report_sections and obj.report_sections[section_name] is not None: if (
section_name in obj.report_sections
and obj.report_sections[section_name] is not None
):
content = obj.report_sections[section_name] content = obj.report_sections[section_name]
if content: if content:
file_name = f"{section_name}.md" file_name = f"{section_name}.md"
text = "\n".join(str(item) for item in content) if isinstance(content, list) else content text = (
"\n".join(str(item) for item in content)
if isinstance(content, list)
else content
)
with open(report_dir / file_name, "w", encoding="utf-8") as f: with open(report_dir / file_name, "w", encoding="utf-8") as f:
f.write(text) f.write(text)
return wrapper return wrapper
message_buffer.add_message = save_message_decorator(message_buffer, "add_message") message_buffer.add_message = save_message_decorator(message_buffer, "add_message")
message_buffer.add_tool_call = save_tool_call_decorator(message_buffer, "add_tool_call") message_buffer.add_tool_call = save_tool_call_decorator(message_buffer, "add_tool_call")
message_buffer.update_report_section = save_report_section_decorator(message_buffer, "update_report_section") message_buffer.update_report_section = save_report_section_decorator(
message_buffer, "update_report_section"
)
# Now start the display layout # Populate every leaf before Live mounts. This prevents Rich's diagnostic
# Layout placeholders from ever becoming the first visible frame.
layout = create_layout() layout = create_layout()
spinner_text = f"Analyzing {selections['ticker']} on {selections['analysis_date']}…"
update_display(
layout,
spinner_text,
stats_handler=stats_handler,
start_time=start_time,
)
with Live(layout, refresh_per_second=4): with create_live_display(layout) as live:
# Initial display
update_display(layout, stats_handler=stats_handler, start_time=start_time)
# Add initial messages # Add initial messages
message_buffer.add_message("System", f"Selected ticker: {selections['ticker']}") message_buffer.add_message("System", f"Selected ticker: {selections['ticker']}")
if selections["asset_type"] != "stock": if selections["asset_type"] != "stock":
message_buffer.add_message("System", f"Detected asset type: {selections['asset_type']}") message_buffer.add_message("System", f"Detected asset type: {selections['asset_type']}")
message_buffer.add_message( message_buffer.add_message("System", f"Analysis date: {selections['analysis_date']}")
"System", f"Analysis date: {selections['analysis_date']}"
)
message_buffer.add_message( message_buffer.add_message(
"System", "System",
f"Selected analysts: {', '.join(analyst.value for analyst in selections['analysts'])}", f"Selected analysts: {', '.join(analyst.value for analyst in selections['analysts'])}",
) )
update_display(layout, stats_handler=stats_handler, start_time=start_time) update_display(layout, stats_handler=stats_handler, start_time=start_time)
live.refresh()
# Update agent status to in_progress for the first analyst # Update agent status to in_progress for the first analyst
first_analyst = get_initial_analyst_node(analyst_execution_plan) first_analyst = get_initial_analyst_node(analyst_execution_plan)
message_buffer.update_agent_status(first_analyst, "in_progress") message_buffer.update_agent_status(first_analyst, "in_progress")
analyst_wall_time_tracker.mark_started(selected_analyst_keys[0]) analyst_wall_time_tracker.mark_started(selected_analyst_keys[0])
update_display(layout, stats_handler=stats_handler, start_time=start_time)
# Create spinner text
spinner_text = (
f"Analyzing {selections['ticker']} on {selections['analysis_date']}..."
)
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
live.refresh()
# Initialize state and get graph args with callbacks. # Initialize state and get graph args with callbacks.
# Resolve the instrument identity once here so all agents anchor to # Resolve the instrument identity once here so all agents anchor to
...@@ -1255,6 +1516,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1255,6 +1516,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
# Update the display # Update the display
update_display(layout, stats_handler=stats_handler, start_time=start_time) update_display(layout, stats_handler=stats_handler, start_time=start_time)
live.refresh()
trace.append(chunk) trace.append(chunk)
...@@ -1279,6 +1541,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1279,6 +1541,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
message_buffer.update_report_section(section, final_state[section]) message_buffer.update_report_section(section, final_state[section])
update_display(layout, stats_handler=stats_handler, start_time=start_time) update_display(layout, stats_handler=stats_handler, start_time=start_time)
live.refresh()
# The interactive CLI streams the graph directly for live rendering. Save # The interactive CLI streams the graph directly for live rendering. Save
# its merged final state explicitly so the API/UI consumes this exact run # its merged final state explicitly so the API/UI consumes this exact run
...@@ -1290,8 +1553,10 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1290,8 +1553,10 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
) )
# Post-analysis prompts (outside Live context for clean interaction) # Post-analysis prompts (outside Live context for clean interaction)
console.print("\n[bold cyan]Analysis Complete![/bold cyan]\n") timing_summary = analyst_wall_time_tracker.format_summary()
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") console.print()
console.print(Align.center(render_completion_card(selections, timing_summary)))
console.print()
# Prompt to save report # Prompt to save report
save_choice = typer.prompt("Save report?", default="Y").strip().upper() save_choice = typer.prompt("Save report?", default="Y").strip().upper()
...@@ -1299,16 +1564,18 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1299,16 +1564,18 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
default_path = Path.cwd() / "reports" / f"{selections['ticker']}_{timestamp}" default_path = Path.cwd() / "reports" / f"{selections['ticker']}_{timestamp}"
save_path_str = typer.prompt( save_path_str = typer.prompt(
"Save path (press Enter for default)", "Save path (press Enter for default)", default=str(default_path)
default=str(default_path)
).strip() ).strip()
save_path = Path(save_path_str) save_path = Path(save_path_str)
try: try:
report_file = save_report_to_disk(final_state, selections["ticker"], save_path) report_file = save_report_to_disk(final_state, selections["ticker"], save_path)
console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") _print_notice(
console.print(f" [dim]Complete report:[/dim] {report_file.name}") "Report saved",
f"{save_path.resolve()}\nComplete report: {report_file.name}",
level="success",
)
except Exception as e: except Exception as e:
console.print(f"[red]Error saving report: {e}[/red]") _print_notice("Report save failed", str(e), level="error")
# Prompt to display full report # Prompt to display full report
display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper()
...@@ -1323,7 +1590,11 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1323,7 +1590,11 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
prepare_dashboard_analysis, prepare_dashboard_analysis,
) )
console.print("\n[bold cyan]Preparing the Angular dashboard…[/bold cyan]") _print_notice(
"Dashboard",
"Preparing the interactive analysis workspace…",
level="success",
)
try: try:
persisted_state = AnalysisRepository.load_state(state_log_path) persisted_state = AnalysisRepository.load_state(state_log_path)
analysis, analysis_path = prepare_dashboard_analysis( analysis, analysis_path = prepare_dashboard_analysis(
...@@ -1331,13 +1602,16 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1331,13 +1602,16 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
selections["analysis_date"], selections["analysis_date"],
persisted_state, persisted_state,
config["results_dir"], config["results_dir"],
use_ai=False,
) )
host = os.environ.get("TRADINGAGENTS_API_HOST", "127.0.0.1") host = os.environ.get("TRADINGAGENTS_API_HOST", "127.0.0.1")
port = int(os.environ.get("TRADINGAGENTS_API_PORT", "8000")) port = int(os.environ.get("TRADINGAGENTS_API_PORT", "8000"))
url = dashboard_url(host, port, analysis.symbol, analysis.analysis_date) url = dashboard_url(host, port, analysis.symbol, analysis.analysis_date)
console.print(f"[green]✓ Dashboard data saved:[/green] {analysis_path}") _print_notice(
console.print(f"[green]✓ Opening:[/green] {url}") "Dashboard ready",
console.print("[dim]Keep this terminal open; press Ctrl+C to stop the UI.[/dim]") f"Opening {url}\nData: {analysis_path}\nKeep this terminal open; Ctrl+C stops the UI.",
level="success",
)
launch_dashboard( launch_dashboard(
analysis.symbol, analysis.symbol,
analysis.analysis_date, analysis.analysis_date,
...@@ -1348,7 +1622,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False): ...@@ -1348,7 +1622,7 @@ def run_analysis(checkpoint: bool | None = None, *, open_ui: bool = False):
# DSE and dashboard helpers deliberately redact credentials/tokens # DSE and dashboard helpers deliberately redact credentials/tokens
# from their exception messages, so surface the actionable stage # from their exception messages, so surface the actionable stage
# error without losing the already completed CLI report. # error without losing the already completed CLI report.
console.print(f"[red]Dashboard launch failed: {exc}[/red]") _print_notice("Dashboard launch failed", str(exc), level="error")
@app.command() @app.command()
...@@ -1375,13 +1649,12 @@ def analyze( ...@@ -1375,13 +1649,12 @@ def analyze(
if clear_checkpoints: if clear_checkpoints:
from dohasecuritiesstockai.graph.checkpointer import clear_all_checkpoints from dohasecuritiesstockai.graph.checkpointer import clear_all_checkpoints
n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"]) n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"])
console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]") console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]")
try: try:
should_open_ui = ( should_open_ui = (
_env_flag("TRADINGAGENTS_OPEN_UI_AFTER_ANALYSIS") _env_flag("TRADINGAGENTS_OPEN_UI_AFTER_ANALYSIS") if open_ui is None else open_ui
if open_ui is None
else open_ui
) )
run_analysis(checkpoint=checkpoint, open_ui=should_open_ui) run_analysis(checkpoint=checkpoint, open_ui=should_open_ui)
except _NO_CONSOLE_ERRORS: except _NO_CONSOLE_ERRORS:
...@@ -1395,6 +1668,12 @@ def analyze( ...@@ -1395,6 +1668,12 @@ def analyze(
err=True, err=True,
) )
raise typer.Exit(code=1) from None raise typer.Exit(code=1) from None
except VendorError as exc:
# Market-data failures are expected operational states. Present the
# actionable, already-redacted vendor message without a wall of stack
# frames; unexpected programming errors still propagate normally.
_print_notice("Market data unavailable", str(exc), level="error")
raise typer.Exit(code=1) from None
if __name__ == "__main__": if __name__ == "__main__":
......
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