aipa-cli 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/CHANGELOG.md +21 -0
  2. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/PKG-INFO +2 -2
  3. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/pyproject.toml +1 -1
  4. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/__init__.py +1 -1
  5. aipa_cli-0.1.4/src/aipriceaction_terminal/agents/config.py +57 -0
  6. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/chat.py +132 -66
  7. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/cli.py +16 -5
  8. aipa_cli-0.1.4/src/aipriceaction_terminal/cli_commands.py +154 -0
  9. aipa_cli-0.1.4/src/aipriceaction_terminal/deep_research.py +708 -0
  10. aipa_cli-0.1.4/src/aipriceaction_terminal/settings_tab.py +176 -0
  11. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/user_settings.py +3 -0
  12. aipa_cli-0.1.4/src/aipriceaction_terminal/utils.py +116 -0
  13. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/widgets/chat_input.py +1 -1
  14. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/workflows.py +81 -12
  15. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/conftest.py +7 -0
  16. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_chat.py +3 -3
  17. aipa_cli-0.1.4/tests/test_settings_api.py +381 -0
  18. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_workflows.py +1 -1
  19. aipa_cli-0.1.3/src/aipriceaction_terminal/agents/config.py +0 -35
  20. aipa_cli-0.1.3/src/aipriceaction_terminal/cli_commands.py +0 -51
  21. aipa_cli-0.1.3/src/aipriceaction_terminal/settings_tab.py +0 -76
  22. aipa_cli-0.1.3/src/aipriceaction_terminal/utils.py +0 -29
  23. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/.gitignore +0 -0
  24. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/LICENSE +0 -0
  25. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/README.md +0 -0
  26. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/__main__.py +0 -0
  27. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/actions.py +0 -0
  28. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/__init__.py +0 -0
  29. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/agent.py +0 -0
  30. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/callbacks.py +0 -0
  31. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/personas.py +0 -0
  32. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/tools.py +0 -0
  33. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/app.py +0 -0
  34. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/bindings.py +0 -0
  35. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/theme.py +0 -0
  36. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/ticker_data.py +0 -0
  37. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/widgets/__init__.py +0 -0
  38. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/src/aipriceaction_terminal/widgets/ticker_select.py +0 -0
  39. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/openrouter_responses.py +0 -0
  40. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_app.py +0 -0
  41. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_integration.py +0 -0
  42. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_thinking.py +0 -0
  43. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_tool_call_streaming.py +0 -0
  44. {aipa_cli-0.1.3 → aipa_cli-0.1.4}/tests/test_utils.py +0 -0
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.4] - 2026-05-09
9
+
10
+ ### Added
11
+ - Add unified LLM-powered analyze flow: `aipa analyze` now invokes LLM with question bank by default instead of dumping raw context
12
+ - Add `--question TEXT`, `--questions`, and `--context-only` flags to `aipa analyze` CLI command
13
+ - Add question bank `Select` dropdown and custom question `Input` to AnalyzePane in Workflows tab
14
+ - Add `deep_research.py` module adapting multi-agent pipeline (supervisor -> parallel workers -> aggregator -> reviewer) from examples into a proper importable module
15
+ - Add `--resume`, `--output`, and `--lang` flags to `aipa deep-research` command
16
+ - Extract `stream_agent_to_log()` shared helper from chat into utils.py, used by both ChatTab and AnalyzePane
17
+ - Extend `/analyze` slash command to support template index (`/analyze VCB 2`) and custom questions (`/analyze VCB --question ...`)
18
+
19
+ ### Changed
20
+ - Resolve effective language from saved `settings.json` when `--lang` is not explicitly passed on CLI
21
+ - Default analyze limit changed from 60 to 20 bars to reduce context size for smaller models
22
+ - Require `aipriceaction>=0.1.9`
23
+
24
+ ### Fixed
25
+ - Add retry logic (up to 3 attempts) for transient LLM failures in `cmd_analyze`
26
+ - Handle string/JSON args in deep-research supervisor tool calls for LLMs that don't parse tool args properly
27
+ - Increment `review_round` on each aggregator cycle so reviewer displays correct round numbers
28
+
8
29
  ## [0.1.3] - 2026-05-09
9
30
 
10
31
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aipa-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Terminal TUI for AI-powered ticker analysis
5
5
  Project-URL: Homepage, https://github.com/quanhua92/aipriceaction
6
6
  Project-URL: Repository, https://github.com/quanhua92/aipriceaction
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Office/Business :: Financial
17
17
  Requires-Python: >=3.13
18
- Requires-Dist: aipriceaction>=0.1.8
18
+ Requires-Dist: aipriceaction>=0.1.9
19
19
  Requires-Dist: langchain-core
20
20
  Requires-Dist: langchain-openai
21
21
  Requires-Dist: langgraph
@@ -9,7 +9,7 @@ license = "MIT"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  dependencies = [
12
- "aipriceaction>=0.1.8",
12
+ "aipriceaction>=0.1.9",
13
13
  "textual>=3.0.0",
14
14
  "textual-autocomplete>=4.0.6",
15
15
  "langchain-core",
@@ -1,4 +1,4 @@
1
1
  """AIPriceAction Terminal - TUI chat interface for ticker analysis."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
4
4
 
@@ -0,0 +1,57 @@
1
+ """Agent configuration: reads from env vars, then settings.json, then SDK defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+ from aipriceaction.settings import settings
9
+
10
+ from ..user_settings import load_settings
11
+
12
+
13
+ def _resolve(field_name: str, env_var: str, sdk_default: str) -> str:
14
+ """Resolve a config field: env var > settings.json > SDK default."""
15
+ env_val = os.environ.get(env_var, "")
16
+ if env_val:
17
+ return env_val
18
+ saved = load_settings().get(field_name, "")
19
+ if saved:
20
+ return saved
21
+ return sdk_default
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AgentConfig:
26
+ """Configuration for an agent session.
27
+
28
+ Resolution order per field: environment variable > settings.json > SDK default.
29
+ """
30
+
31
+ api_key: str = field(
32
+ default_factory=lambda: _resolve("api_key", "OPENAI_API_KEY", settings.openai_api_key),
33
+ )
34
+ base_url: str = field(
35
+ default_factory=lambda: _resolve("openai_base_url", "OPENAI_BASE_URL", settings.openai_base_url),
36
+ )
37
+ model: str = field(
38
+ default_factory=lambda: _resolve("openai_model", "OPENAI_MODEL", settings.openai_model),
39
+ )
40
+ lang: str = field(
41
+ default_factory=lambda: _resolve("language", "AI_CONTEXT_LANG", settings.ai_context_lang),
42
+ )
43
+ max_retries: int = 3
44
+ base_retry_delay: float = 5.0
45
+ max_retry_delay: float = 60.0
46
+
47
+
48
+ TRANSIENT_ERROR_KEYWORDS: tuple[str, ...] = (
49
+ "429",
50
+ "500",
51
+ "502",
52
+ "503",
53
+ "504",
54
+ "timeout",
55
+ "connection",
56
+ "overloaded",
57
+ )
@@ -10,7 +10,31 @@ from textual.containers import Vertical, VerticalScroll
10
10
  from textual.screen import Screen
11
11
 
12
12
  from .widgets import ChatInput
13
- from .utils import write_context_result, write_error, write_export_result
13
+ from .utils import write_error, write_export_result, stream_agent_to_log
14
+
15
+
16
+ def _resolve_tui_question(
17
+ builder: object,
18
+ ticker: str,
19
+ question_index: int | None,
20
+ custom_question: str | None,
21
+ ) -> str:
22
+ """Resolve the analysis question for TUI /analyze command."""
23
+ if custom_question:
24
+ return custom_question
25
+
26
+ templates = builder.questions("single")
27
+ if not templates:
28
+ return f"Analyze {ticker} based on the provided data."
29
+
30
+ idx = question_index if question_index is not None else 0
31
+ idx = max(0, min(idx, len(templates) - 1))
32
+ template = templates[idx]
33
+
34
+ try:
35
+ return template["question"].format(ticker=ticker)
36
+ except KeyError:
37
+ return template["question"]
14
38
 
15
39
 
16
40
  class ThinkingModal(Screen[None]):
@@ -179,10 +203,13 @@ class ChatTab(Vertical):
179
203
  if cmd == "/help":
180
204
  log.write(
181
205
  "[bold yellow]Available commands:[/bold yellow]\n"
182
- " /analyze <ticker> [interval] - Build AI context (e.g. /analyze VIC or /analyze STB 1h)\n"
206
+ " /analyze <ticker> [interval|index] [--question TEXT]\n"
207
+ " Analyze ticker with AI (e.g. /analyze VIC)\n"
208
+ " Use index 0-5 to pick question template\n"
209
+ " Use --question for custom question\n"
183
210
  " /export <ticker> [tickers...] [--interval 1D] [--path ~/dir/]\n"
184
211
  " - Export AI context to markdown file\n"
185
- " /deep-research [q] - Multi-agent deep research (not yet implemented)\n"
212
+ " /deep-research [q] - Multi-agent deep research\n"
186
213
  " /exit - Quit the application\n"
187
214
  " /help - Show this help message\n"
188
215
  " /clear - Clear chat history\n"
@@ -193,14 +220,15 @@ class ChatTab(Vertical):
193
220
  elif cmd == "/exit":
194
221
  self.app.exit()
195
222
  elif cmd == "/analyze":
196
- if not arg:
197
- log.write("[bold red]Usage: /analyze <ticker> [interval][/bold red] (e.g. /analyze VIC or /analyze STB 1h)")
198
- return
199
- interval = parts[2] if len(parts) > 2 else self.app.interval
200
- ticker = arg
201
- log.write(f"[bold cyan]You:[/bold cyan] /analyze {ticker} {interval}")
202
- log.write("[dim]Building context...[/dim]")
203
- self._run_analyze(ticker, interval)
223
+ self._handle_analyze(text, parts)
224
+ elif cmd == "/deep-research":
225
+ question = " ".join(parts[1:]) if len(parts) > 1 else ""
226
+ log.write("[bold cyan]You:[/bold cyan] /deep-research" + (f" {question}" if question else ""))
227
+ log.write(
228
+ "[bold yellow]Deep research is not yet implemented.[/bold yellow]\n"
229
+ "[dim]This will eventually run the multi-agent LangGraph pipeline "
230
+ "(supervisor -> parallel workers -> aggregator -> reviewer).[/dim]\n"
231
+ )
204
232
  elif cmd == "/deep-research":
205
233
  question = " ".join(parts[1:]) if len(parts) > 1 else ""
206
234
  log.write("[bold cyan]You:[/bold cyan] /deep-research" + (f" {question}" if question else ""))
@@ -240,20 +268,90 @@ class ChatTab(Vertical):
240
268
  else:
241
269
  log.write(f"[bold red]Unknown command:[/bold red] {cmd}")
242
270
 
271
+ _KNOWN_INTERVALS = frozenset(("1m", "5m", "15m", "30m", "1h", "4h", "1D", "1W", "1M"))
272
+
273
+ def _handle_analyze(self, text: str, parts: list[str]) -> None:
274
+ """Parse /analyze command and dispatch to _run_analyze."""
275
+ log = self.query_one("#chat-log", RichLog)
276
+ rest = text[len("/analyze"):].strip()
277
+
278
+ if not rest:
279
+ log.write(
280
+ "[bold red]Usage:[/bold red] /analyze <ticker> [interval|index] [--question TEXT]\n"
281
+ " e.g. /analyze VIC\n"
282
+ " /analyze STB 1h\n"
283
+ " /analyze VCB 2\n"
284
+ " /analyze VCB --question What is the support level?"
285
+ )
286
+ return
287
+
288
+ # Parse --question flag
289
+ custom_question: str | None = None
290
+ if "--question" in rest:
291
+ rest, _, custom_question = rest.partition("--question")
292
+ custom_question = custom_question.strip()
293
+ rest = rest.strip()
294
+
295
+ tokens = rest.split()
296
+ ticker = tokens[0].upper()
297
+
298
+ # Determine question_index and interval
299
+ question_index: int | None = None
300
+ interval = self.app.interval
301
+
302
+ if len(tokens) > 1:
303
+ second = tokens[1]
304
+ if second in self._KNOWN_INTERVALS:
305
+ interval = second
306
+ elif second.isdigit():
307
+ question_index = int(second)
308
+
309
+ log.write(f"[bold cyan]You:[/bold cyan] /analyze {ticker} {interval}")
310
+ log.write("[dim]Building context and analyzing...[/dim]")
311
+ self._run_analyze(ticker, interval, question_index=question_index, custom_question=custom_question)
312
+
243
313
  @work(exclusive=True)
244
- async def _run_analyze(self, ticker: str, interval: str) -> None:
245
- """Build AI context for a ticker in a background worker."""
314
+ async def _run_analyze(
315
+ self,
316
+ ticker: str,
317
+ interval: str,
318
+ *,
319
+ question_index: int | None = None,
320
+ custom_question: str | None = None,
321
+ ) -> None:
322
+ """Build context and stream AI analysis for a ticker."""
323
+ log = self.query_one("#chat-log", RichLog)
246
324
  try:
247
325
  builder = self.app.builder
248
326
 
327
+ # Build context without system prompt (agent has it already)
249
328
  context = await asyncio.to_thread(
250
- builder.build, ticker=ticker, interval=interval
329
+ builder.build, ticker=ticker, interval=interval,
330
+ include_system_prompt=False,
251
331
  )
252
332
 
253
- log = self.query_one("#chat-log", RichLog)
254
- write_context_result(log, ticker, interval, context)
333
+ log.write(f"[dim]Context ready: {len(context):,} chars[/dim]")
334
+
335
+ # Resolve question
336
+ question = _resolve_tui_question(
337
+ builder, ticker, question_index, custom_question,
338
+ )
339
+
340
+ # Compose the message for the agent
341
+ message = (
342
+ f"<analysis_context>\n{context}\n</analysis_context>\n\n"
343
+ f"{question}\n\n"
344
+ f"Base your analysis ONLY on the provided data above."
345
+ )
346
+
347
+ await stream_agent_to_log(
348
+ log,
349
+ self.app.agent,
350
+ message,
351
+ on_thinking_update=self._show_thinking_area,
352
+ on_thinking_done=self._on_thinking_done,
353
+ )
255
354
  except Exception as e:
256
- log = self.query_one("#chat-log", RichLog)
257
355
  write_error(log, e)
258
356
 
259
357
  @work(exclusive=True)
@@ -292,54 +390,22 @@ class ChatTab(Vertical):
292
390
  """Stream an agent response into the chat log."""
293
391
  log = self.query_one("#chat-log", RichLog)
294
392
  try:
295
- from .agents.callbacks import StreamEventType
296
- buffer: list[str] = []
297
- thinking_buf: list[str] = []
298
-
299
- def flush() -> None:
300
- """Write buffered tokens as a single line to the RichLog."""
301
- if buffer:
302
- log.write("".join(buffer))
303
- buffer.clear()
304
-
305
- def collapse_thinking() -> None:
306
- """Collapse thinking area: write summary to log, store text."""
307
- if thinking_buf:
308
- text = "".join(thinking_buf)
309
- thinking_buf.clear()
310
- # Skip trivial fragments (e.g. trailing "." from the model)
311
- if len(text.strip()) <= 1:
312
- self._hide_thinking_area()
313
- return
314
- self._store_thinking(text)
315
- self._hide_thinking_area()
316
- log.write(f"[dim]Thought for {len(text)} chars (Ctrl+O to view)[/dim]")
317
-
318
- async for event in self.app.agent.stream(message):
319
- if event.type == StreamEventType.THINKING:
320
- thinking_buf.append(event.content)
321
- self._show_thinking_area("".join(thinking_buf))
322
-
323
- elif event.type == StreamEventType.TOKEN:
324
- if thinking_buf:
325
- collapse_thinking()
326
- buffer.append(event.content)
327
- if "\n" in event.content:
328
- flush()
329
-
330
- elif event.type == StreamEventType.DONE:
331
- if thinking_buf:
332
- collapse_thinking()
333
- flush()
334
- log.write("")
335
-
336
- else:
337
- flush()
338
- if event.type == StreamEventType.TOOL_CALL_START:
339
- log.write(f"[dim italic]{event.content}[/dim italic]")
340
- elif event.type == StreamEventType.TOOL_RESULT:
341
- log.write(f"[dim]{event.content}[/dim]")
342
- elif event.type == StreamEventType.ERROR:
343
- log.write(f"[bold red]{event.content}[/bold red]")
393
+ await stream_agent_to_log(
394
+ log,
395
+ self.app.agent,
396
+ message,
397
+ on_thinking_update=self._show_thinking_area,
398
+ on_thinking_done=self._on_thinking_done,
399
+ )
344
400
  except Exception as e:
345
401
  log.write(f"[bold red]Agent error: {e}[/bold red]\n")
402
+
403
+ def _on_thinking_done(self, text: str) -> None:
404
+ """Called when a thinking block finishes. Store and collapse."""
405
+ if not text or len(text.strip()) <= 1:
406
+ self._hide_thinking_area()
407
+ return
408
+ self._store_thinking(text)
409
+ self._hide_thinking_area()
410
+ log = self.query_one("#chat-log", RichLog)
411
+ log.write(f"[dim]Thought for {len(text)} chars (Ctrl+O to view)[/dim]")
@@ -9,8 +9,8 @@ def run():
9
9
 
10
10
  # aipa analyze VCB [tickers...] [--interval 1D] [--limit N]
11
11
  # [--source vn] [--start-date] [--end-date] [--reference-ticker VNINDEX]
12
- # [--lang en] [--ma-type ema]
13
- p_analyze = sub.add_parser("analyze", help="Build AI context for a ticker")
12
+ # [--lang en] [--ma-type ema] [--question TEXT] [--questions] [--context-only]
13
+ p_analyze = sub.add_parser("analyze", help="AI analysis for ticker(s)")
14
14
  p_analyze.add_argument("tickers", nargs="+", help="Ticker symbol(s)")
15
15
  p_analyze.add_argument("--interval", default="1D")
16
16
  p_analyze.add_argument("--limit", type=int, default=None)
@@ -18,8 +18,11 @@ def run():
18
18
  p_analyze.add_argument("--start-date", default=None)
19
19
  p_analyze.add_argument("--end-date", default=None)
20
20
  p_analyze.add_argument("--reference-ticker", default="VNINDEX")
21
- p_analyze.add_argument("--lang", default="en", choices=["en", "vn"])
21
+ p_analyze.add_argument("--lang", default=None, choices=["en", "vn"])
22
22
  p_analyze.add_argument("--ma-type", default="ema", choices=["ema", "sma"])
23
+ p_analyze.add_argument("--question", default=None, help="Custom analysis question")
24
+ p_analyze.add_argument("--questions", action="store_true", help="List available question templates and exit")
25
+ p_analyze.add_argument("--context-only", action="store_true", help="Dump raw context without LLM (no API key needed)")
23
26
 
24
27
  # aipa get-ohlcv-data TICKER [--interval 1D] [--limit N]
25
28
  # [--start-date] [--end-date] [--source] [--ma] [--ema]
@@ -35,8 +38,11 @@ def run():
35
38
  p_ohlcv.add_argument("--ema", action="store_true", default=False)
36
39
 
37
40
  # aipa deep-research [question]
38
- p_deep = sub.add_parser("deep-research", help="Multi-agent deep research (not yet implemented)")
41
+ p_deep = sub.add_parser("deep-research", help="Multi-agent deep research")
39
42
  p_deep.add_argument("question", nargs="*", help="Research question")
43
+ p_deep.add_argument("--resume", default=None, help="Resume from checkpoint session ID")
44
+ p_deep.add_argument("--output", default=None, help="Save final report to file")
45
+ p_deep.add_argument("--lang", default=None, choices=["en", "vn"], help="Override language")
40
46
 
41
47
  args = parser.parse_args()
42
48
 
@@ -48,7 +54,12 @@ def run():
48
54
  cmd_get_ohlcv(args)
49
55
  elif args.command == "deep-research":
50
56
  from .cli_commands import cmd_deep_research
51
- cmd_deep_research(" ".join(args.question) if args.question else "")
57
+ cmd_deep_research(
58
+ question=" ".join(args.question) if args.question else "",
59
+ resume=args.resume,
60
+ output=args.output,
61
+ lang=args.lang,
62
+ )
52
63
  else:
53
64
  from .app import main
54
65
  main()
@@ -0,0 +1,154 @@
1
+ """CLI subcommand implementations — thin wrappers around SDK methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+
8
+
9
+ def _resolve_lang(arg_lang: str | None) -> str:
10
+ """Resolve effective language: CLI arg > saved settings > default."""
11
+ if arg_lang:
12
+ return arg_lang
13
+ from .user_settings import load_settings
14
+ return load_settings().get("language", "en")
15
+
16
+
17
+ def cmd_analyze(args) -> None:
18
+ from aipriceaction import AIContextBuilder
19
+
20
+ lang = _resolve_lang(args.lang)
21
+ builder = AIContextBuilder(lang=lang, ma_type=args.ma_type)
22
+
23
+ # --questions: list question bank and exit
24
+ if args.questions:
25
+ mode = "single" if len(args.tickers) == 1 else "multi"
26
+ templates = builder.questions(mode)
27
+ print(f"Available question templates ({mode}, lang={lang}):\n")
28
+ for i, t in enumerate(templates):
29
+ print(f" [{i}] {t['title']}")
30
+ print(f" {t['snippet']}\n")
31
+ return
32
+
33
+ # Build context
34
+ build_kwargs = dict(
35
+ interval=args.interval,
36
+ limit=args.limit if args.limit is not None else 20,
37
+ source=args.source,
38
+ start_date=args.start_date,
39
+ end_date=args.end_date,
40
+ reference_ticker=args.reference_ticker,
41
+ )
42
+
43
+ t0 = time.time()
44
+
45
+ if len(args.tickers) == 1:
46
+ context = builder.build(
47
+ ticker=args.tickers[0],
48
+ **build_kwargs,
49
+ include_system_prompt=not args.context_only,
50
+ )
51
+ else:
52
+ context = builder.build(
53
+ tickers=args.tickers,
54
+ **build_kwargs,
55
+ include_system_prompt=not args.context_only,
56
+ )
57
+
58
+ build_elapsed = time.time() - t0
59
+ ticker_label = args.tickers[0] if len(args.tickers) == 1 else ",".join(args.tickers)
60
+
61
+ # --context-only: dump raw context and exit
62
+ if args.context_only:
63
+ print(context)
64
+ return
65
+
66
+ # LLM analysis
67
+ question = _resolve_question(builder, args)
68
+ if not question:
69
+ print("No question resolved. Use --question TEXT or pick from --questions.", file=sys.stderr)
70
+ sys.exit(1)
71
+
72
+ print(f"[build] Context ready ({len(context):,} chars, {build_elapsed:.1f}s)", file=sys.stderr)
73
+ print(f"[analyze] Asking:\n{question}", file=sys.stderr)
74
+
75
+ # LLM analysis with retry on empty/transient failures
76
+ max_attempts = 3
77
+ response = ""
78
+ last_error: Exception | None = None
79
+ for attempt in range(max_attempts):
80
+ try:
81
+ response = builder.answer(question)
82
+ except Exception as e:
83
+ last_error = e
84
+ print(f"[error] Attempt {attempt + 1}/{max_attempts}: {type(e).__name__}: {e}", file=sys.stderr)
85
+ if attempt < max_attempts - 1:
86
+ import time as _time
87
+ _time.sleep(2)
88
+ continue
89
+ if response.strip():
90
+ break
91
+ print(f"[warn] Attempt {attempt + 1}/{max_attempts}: LLM returned empty response", file=sys.stderr)
92
+ last_error = RuntimeError("LLM returned empty response")
93
+ else:
94
+ elapsed = time.time() - t0
95
+ print(f"[done] Total: {elapsed:.1f}s", file=sys.stderr)
96
+ if last_error:
97
+ print(f"[error] Failed after {max_attempts} attempts: {last_error}", file=sys.stderr)
98
+ sys.exit(1)
99
+
100
+ elapsed = time.time() - t0
101
+ print(f"[done] Total: {elapsed:.1f}s", file=sys.stderr)
102
+ print()
103
+ print(response)
104
+
105
+
106
+ def cmd_get_ohlcv(args) -> None:
107
+ from aipriceaction import AIPriceAction
108
+
109
+ client = AIPriceAction()
110
+ df = client.get_ohlcv(
111
+ ticker=args.ticker,
112
+ interval=args.interval,
113
+ limit=args.limit,
114
+ start_date=args.start_date,
115
+ end_date=args.end_date,
116
+ source=args.source,
117
+ ma=args.ma,
118
+ ema=args.ema,
119
+ )
120
+ print(df.to_string(index=False))
121
+
122
+
123
+ def cmd_deep_research(
124
+ question: str = "",
125
+ resume: str | None = None,
126
+ output: str | None = None,
127
+ lang: str | None = None,
128
+ ) -> None:
129
+ from .deep_research import run_deep_research
130
+
131
+ effective_lang = _resolve_lang(lang)
132
+ run_deep_research(question=question, resume_id=resume, output_file=output, lang=effective_lang)
133
+
134
+
135
+ def _resolve_question(builder, args) -> str:
136
+ """Resolve the analysis question from args or question bank default."""
137
+ if args.question:
138
+ return args.question
139
+
140
+ # For single ticker, use question bank template 0 with {ticker} interpolated
141
+ if len(args.tickers) == 1:
142
+ templates = builder.questions("single")
143
+ if templates:
144
+ try:
145
+ return templates[0]["question"].format(ticker=args.tickers[0])
146
+ except KeyError:
147
+ return templates[0]["question"]
148
+
149
+ # For multi-ticker, use question bank template 0
150
+ templates = builder.questions("multi")
151
+ if templates:
152
+ return templates[0]["question"]
153
+
154
+ return ""