aipa-cli 0.1.2__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.
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/CHANGELOG.md +26 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/PKG-INFO +2 -2
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/pyproject.toml +3 -3
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/__init__.py +2 -1
- aipa_cli-0.1.4/src/aipriceaction_terminal/agents/config.py +57 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/chat.py +132 -66
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/cli.py +16 -5
- aipa_cli-0.1.4/src/aipriceaction_terminal/cli_commands.py +154 -0
- aipa_cli-0.1.4/src/aipriceaction_terminal/deep_research.py +708 -0
- aipa_cli-0.1.4/src/aipriceaction_terminal/settings_tab.py +176 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/user_settings.py +3 -0
- aipa_cli-0.1.4/src/aipriceaction_terminal/utils.py +116 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/widgets/chat_input.py +1 -1
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/workflows.py +81 -12
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/conftest.py +7 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_chat.py +3 -3
- aipa_cli-0.1.4/tests/test_settings_api.py +381 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_workflows.py +1 -1
- aipa_cli-0.1.2/src/aipriceaction_terminal/agents/config.py +0 -35
- aipa_cli-0.1.2/src/aipriceaction_terminal/cli_commands.py +0 -51
- aipa_cli-0.1.2/src/aipriceaction_terminal/settings_tab.py +0 -76
- aipa_cli-0.1.2/src/aipriceaction_terminal/utils.py +0 -29
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/.gitignore +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/LICENSE +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/README.md +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/__main__.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/actions.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/__init__.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/agent.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/callbacks.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/personas.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/agents/tools.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/app.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/bindings.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/theme.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/ticker_data.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/widgets/__init__.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/src/aipriceaction_terminal/widgets/ticker_select.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/openrouter_responses.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_app.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_integration.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_thinking.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_tool_call_streaming.py +0 -0
- {aipa_cli-0.1.2 → aipa_cli-0.1.4}/tests/test_utils.py +0 -0
|
@@ -5,6 +5,32 @@ 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
|
+
|
|
29
|
+
## [0.1.3] - 2026-05-09
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Route CLI entry point through `cli:run` so `--help`, `analyze`, `get-ohlcv-data`, and `deep-research` subcommands work without launching the TUI
|
|
33
|
+
|
|
8
34
|
## [0.1.2] - 2026-05-09
|
|
9
35
|
|
|
10
36
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aipa-cli
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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.
|
|
12
|
+
"aipriceaction>=0.1.9",
|
|
13
13
|
"textual>=3.0.0",
|
|
14
14
|
"textual-autocomplete>=4.0.6",
|
|
15
15
|
"langchain-core",
|
|
@@ -31,8 +31,8 @@ Homepage = "https://github.com/quanhua92/aipriceaction"
|
|
|
31
31
|
Repository = "https://github.com/quanhua92/aipriceaction"
|
|
32
32
|
|
|
33
33
|
[project.scripts]
|
|
34
|
-
aipa = "aipriceaction_terminal.
|
|
35
|
-
aipa-cli = "aipriceaction_terminal.
|
|
34
|
+
aipa = "aipriceaction_terminal.cli:run"
|
|
35
|
+
aipa-cli = "aipriceaction_terminal.cli:run"
|
|
36
36
|
|
|
37
37
|
[build-system]
|
|
38
38
|
requires = ["hatchling"]
|
|
@@ -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
|
|
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]
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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(
|
|
245
|
-
|
|
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
|
|
254
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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="
|
|
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=
|
|
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
|
|
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(
|
|
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 ""
|