@drico2008/fincli 0.1.2 → 0.1.9
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.
- package/README.md +81 -7
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/assistant_context.py +27 -1
- package/fincli/app/analysis/indicators.py +1 -1
- package/fincli/app/analysis/market_structure.py +1 -1
- package/fincli/app/cli/commands.py +12 -4
- package/fincli/app/cli/router.py +253 -13
- package/fincli/app/modules/session_history.py +113 -0
- package/fincli/app/providers/ai/anthropic_provider.py +8 -7
- package/fincli/app/providers/ai/gemini_provider.py +8 -7
- package/fincli/app/providers/ai/groq_provider.py +8 -7
- package/fincli/app/providers/ai/http_provider.py +3 -3
- package/fincli/app/providers/ai/huggingface_provider.py +8 -7
- package/fincli/app/providers/ai/openai_provider.py +8 -7
- package/fincli/app/providers/ai/openrouter_provider.py +8 -7
- package/fincli/app/providers/ai/together_provider.py +8 -7
- package/fincli/app/providers/market/custom_provider.py +2 -2
- package/fincli/app/providers/market/finnhub_provider.py +1 -1
- package/fincli/app/providers/market/manager.py +6 -5
- package/fincli/app/providers/market/news_provider.py +4 -4
- package/fincli/app/providers/market/twelvedata_provider.py +1 -1
- package/fincli/app/providers/market/yfinance_provider.py +1 -1
- package/fincli/app/services/web_research.py +267 -0
- package/fincli/app/storage/cache.py +2 -2
- package/fincli/app/storage/config.py +3 -4
- package/fincli/app/storage/config_paths.py +9 -0
- package/fincli/app/storage/database.py +17 -0
- package/fincli/app/storage/secrets.py +104 -0
- package/fincli/app/tui/components.py +1 -1
- package/fincli/app/tui/layout.py +8 -7
- package/fincli/app/tui/market_provider_selector.py +42 -2
- package/fincli/app/tui/model_selector.py +97 -55
- package/fincli/app/utils/formatting.py +50 -0
- package/npm/bin/fincli.js +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/fincli/app/cli/router.py
CHANGED
|
@@ -6,16 +6,19 @@ import asyncio
|
|
|
6
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from datetime import date
|
|
9
|
+
import io
|
|
9
10
|
import os
|
|
10
11
|
import shlex
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
14
|
+
from rich.console import Console
|
|
13
15
|
from rich.panel import Panel
|
|
14
16
|
from rich.table import Table
|
|
15
17
|
|
|
16
18
|
from fincli.app.cli.commands import CommandRegistry
|
|
17
19
|
from fincli.app.analysis.analyzer import build_market_analysis_prompt, build_technical_ai_summary
|
|
18
20
|
from fincli.app.analysis.assistant_context import (
|
|
21
|
+
build_web_research_answer_prompt,
|
|
19
22
|
build_fincli_assistant_prompt,
|
|
20
23
|
coding_refusal,
|
|
21
24
|
extract_market_symbols,
|
|
@@ -37,6 +40,7 @@ from fincli.app.modules.journal_analytics import JournalStats, build_journal_rev
|
|
|
37
40
|
from fincli.app.modules.journal import JournalService
|
|
38
41
|
from fincli.app.modules.portfolio import PortfolioService
|
|
39
42
|
from fincli.app.modules.scanner import ScanResult, scan_symbols
|
|
43
|
+
from fincli.app.modules.session_history import SessionHistoryService
|
|
40
44
|
from fincli.app.modules.transactions import TransactionService
|
|
41
45
|
from fincli.app.modules.watchlist import WatchlistService
|
|
42
46
|
from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
|
|
@@ -46,11 +50,19 @@ from fincli.app.providers.market.manager import MarketProviderManager
|
|
|
46
50
|
from fincli.app.providers.market.yfinance_provider import YahooTable, YFinanceProvider
|
|
47
51
|
from fincli.app.services.market_data import MarketDataService
|
|
48
52
|
from fincli.app.services.market_overview import MarketOverview, build_market_overview
|
|
53
|
+
from fincli.app.services.web_research import (
|
|
54
|
+
WebResearchService,
|
|
55
|
+
WebSearchResult,
|
|
56
|
+
build_web_research_context,
|
|
57
|
+
should_use_web_research,
|
|
58
|
+
)
|
|
49
59
|
from fincli.app.storage.cache import TTLCache
|
|
50
60
|
from fincli.app.storage.config import ConfigManager
|
|
51
61
|
from fincli.app.storage.database import FinCLIDatabase
|
|
52
62
|
from fincli.app.storage.market_cache import MarketCache
|
|
63
|
+
from fincli.app.storage.secrets import save_secret
|
|
53
64
|
from fincli.app.utils.errors import CommandError, FinCLIError
|
|
65
|
+
from fincli.app.utils.formatting import AIResponseView, MarkdownBlock
|
|
54
66
|
|
|
55
67
|
|
|
56
68
|
@dataclass(slots=True)
|
|
@@ -85,8 +97,16 @@ class CommandRouter:
|
|
|
85
97
|
self.portfolio = PortfolioService(self.db)
|
|
86
98
|
self.transactions = TransactionService(self.db, self.portfolio)
|
|
87
99
|
self.journal = JournalService(self.db)
|
|
100
|
+
self.history = SessionHistoryService(self.db)
|
|
101
|
+
self.session_id = self.history.start_session()
|
|
102
|
+
self.web_research = WebResearchService()
|
|
88
103
|
|
|
89
104
|
def route(self, raw: str) -> CommandResult:
|
|
105
|
+
result = self._route(raw)
|
|
106
|
+
self._record_history(raw, result)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def _route(self, raw: str) -> CommandResult:
|
|
90
110
|
raw = raw.strip()
|
|
91
111
|
if not raw:
|
|
92
112
|
return CommandResult(Panel("Ketik /help untuk melihat command.", title="FinCLI"))
|
|
@@ -116,6 +136,8 @@ class CommandRouter:
|
|
|
116
136
|
return CommandResult("Keluar dari FinCLI.", should_exit=True)
|
|
117
137
|
if root == "/config":
|
|
118
138
|
return CommandResult(self._config_panel())
|
|
139
|
+
if root == "/history":
|
|
140
|
+
return self._history(args)
|
|
119
141
|
if root == "/ai_model":
|
|
120
142
|
return self._ai_model(args)
|
|
121
143
|
if root == "/news_model":
|
|
@@ -132,8 +154,8 @@ class CommandRouter:
|
|
|
132
154
|
return self._tx(args)
|
|
133
155
|
if root == "/journal":
|
|
134
156
|
return self._journal(args)
|
|
135
|
-
if root
|
|
136
|
-
return self.
|
|
157
|
+
if root == "/quote":
|
|
158
|
+
return self._quote(args)
|
|
137
159
|
if root == "/market":
|
|
138
160
|
return self._market(args)
|
|
139
161
|
if root == "/technical":
|
|
@@ -142,6 +164,8 @@ class CommandRouter:
|
|
|
142
164
|
return self._structure(args)
|
|
143
165
|
if root == "/news":
|
|
144
166
|
return self._news(args)
|
|
167
|
+
if root == "/web":
|
|
168
|
+
return self._web(args)
|
|
145
169
|
if root == "/funda":
|
|
146
170
|
return self._fundamentals(args)
|
|
147
171
|
if root == "/yahoo":
|
|
@@ -179,6 +203,57 @@ class CommandRouter:
|
|
|
179
203
|
table.add_row(command.name, command.group, command.description, command.example)
|
|
180
204
|
return table
|
|
181
205
|
|
|
206
|
+
def _record_history(self, raw: str, result: CommandResult) -> None:
|
|
207
|
+
normalized = raw.strip().lower()
|
|
208
|
+
if not normalized or normalized.startswith("/history"):
|
|
209
|
+
return
|
|
210
|
+
try:
|
|
211
|
+
preview = _render_history_preview(result.renderable)
|
|
212
|
+
self.history.record_event(self.session_id, raw, result.status, preview)
|
|
213
|
+
except FinCLIError:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
def _history(self, args: list[str]) -> CommandResult:
|
|
217
|
+
action = args[0].lower() if args else "current"
|
|
218
|
+
if action in {"current", "show"}:
|
|
219
|
+
session_id = self.session_id if action == "current" or len(args) == 1 else args[1]
|
|
220
|
+
if action == "show" and len(args) < 2:
|
|
221
|
+
raise CommandError("Format: /history show <session_id>")
|
|
222
|
+
session = self.history.get_session(session_id)
|
|
223
|
+
if not session:
|
|
224
|
+
raise CommandError(f"Session tidak ditemukan: {session_id}")
|
|
225
|
+
events = self.history.get_events(session_id)
|
|
226
|
+
return CommandResult(_format_session_events(session, events, current=session_id == self.session_id))
|
|
227
|
+
if action in {"sessions", "list"}:
|
|
228
|
+
sessions = self.history.list_sessions()
|
|
229
|
+
return CommandResult(_format_sessions(sessions, self.session_id))
|
|
230
|
+
if action == "save":
|
|
231
|
+
title = " ".join(args[1:]).strip()
|
|
232
|
+
if not title:
|
|
233
|
+
raise CommandError("Format: /history save <session_title>")
|
|
234
|
+
self.history.save_session(self.session_id, title)
|
|
235
|
+
return CommandResult(Panel(f"Current session disimpan sebagai: {title}", title="History", border_style="green"))
|
|
236
|
+
if action == "delete":
|
|
237
|
+
if len(args) < 2:
|
|
238
|
+
raise CommandError("Format: /history delete <session_id>")
|
|
239
|
+
if args[1] == self.session_id:
|
|
240
|
+
self.history.clear_events(self.session_id)
|
|
241
|
+
self.history.save_session(self.session_id, "FinCLI session")
|
|
242
|
+
return CommandResult(Panel("Current session dikosongkan.", title="History", border_style="yellow"))
|
|
243
|
+
self.history.delete_session(args[1])
|
|
244
|
+
return CommandResult(Panel(f"Session dihapus: {args[1]}", title="History", border_style="green"))
|
|
245
|
+
if action == "clear":
|
|
246
|
+
target = args[1].lower() if len(args) >= 2 else "current"
|
|
247
|
+
if target == "all":
|
|
248
|
+
self.history.clear_all()
|
|
249
|
+
self.session_id = self.history.start_session()
|
|
250
|
+
return CommandResult(Panel("Semua history session dihapus. Session baru dibuat.", title="History"))
|
|
251
|
+
self.history.clear_events(self.session_id)
|
|
252
|
+
return CommandResult(Panel("Current session history dikosongkan.", title="History"))
|
|
253
|
+
raise CommandError(
|
|
254
|
+
"Format: /history [current|sessions|show <id>|save <title>|delete <id>|clear current|clear all]"
|
|
255
|
+
)
|
|
256
|
+
|
|
182
257
|
def _dashboard(self) -> Table:
|
|
183
258
|
return _format_dashboard(
|
|
184
259
|
provider_chain=[provider.name for provider in self.market_service.providers],
|
|
@@ -211,6 +286,28 @@ class CommandRouter:
|
|
|
211
286
|
if len(args) == 0:
|
|
212
287
|
current = self.config.settings
|
|
213
288
|
return CommandResult(Panel(f"{current.ai_provider} / {current.ai_model}", title="Active AI Model"))
|
|
289
|
+
if args[0].lower() == "key":
|
|
290
|
+
if len(args) < 3:
|
|
291
|
+
raise CommandError("Format: /ai_model key <provider> <api_key>")
|
|
292
|
+
provider = args[1].lower()
|
|
293
|
+
info = AIProviderManager().get(provider)
|
|
294
|
+
if info is None:
|
|
295
|
+
raise CommandError(f"AI provider tidak dikenal: {provider}")
|
|
296
|
+
save_secret(info.env_key, args[2])
|
|
297
|
+
model = self.config.settings.ai_model if self.config.settings.ai_provider == provider else info.default_model
|
|
298
|
+
self.config.set_ai_model(provider, model)
|
|
299
|
+
self.ai_provider = AIProviderManager().create(provider)
|
|
300
|
+
return CommandResult(
|
|
301
|
+
Panel(
|
|
302
|
+
(
|
|
303
|
+
f"API key AI untuk {provider} disimpan global di ~/.fincli/secrets.env.\n"
|
|
304
|
+
f"Provider aktif disimpan: {provider} / {model}.\n"
|
|
305
|
+
"Key tidak ditampilkan di terminal dan dipakai lintas session."
|
|
306
|
+
),
|
|
307
|
+
title="AI API Key Saved",
|
|
308
|
+
border_style="green",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
214
311
|
if len(args) < 2:
|
|
215
312
|
raise CommandError("Format: /ai_model <provider> <model>")
|
|
216
313
|
self.config.set_ai_model(args[0], args[1])
|
|
@@ -232,6 +329,31 @@ class CommandRouter:
|
|
|
232
329
|
title="Active Data Provider",
|
|
233
330
|
)
|
|
234
331
|
)
|
|
332
|
+
if args[0].lower() == "key":
|
|
333
|
+
if len(args) < 3:
|
|
334
|
+
raise CommandError("Format: /news_model key <provider> <api_key> [base_url untuk custom]")
|
|
335
|
+
provider = args[1].lower()
|
|
336
|
+
env_keys = _market_provider_secret_keys(provider)
|
|
337
|
+
if not env_keys:
|
|
338
|
+
raise CommandError(f"Provider {provider} tidak membutuhkan API key atau tidak dikenal.")
|
|
339
|
+
save_secret(env_keys[0], args[2])
|
|
340
|
+
if provider == "custom" and len(args) >= 4:
|
|
341
|
+
save_secret("MARKET_DATA_BASE_URL", args[3])
|
|
342
|
+
self.config.set_market_provider_priority([provider, *self._priority_tail(provider)])
|
|
343
|
+
self._refresh_market_service()
|
|
344
|
+
self.cache.clear()
|
|
345
|
+
extra = "\nBase URL custom juga disimpan." if provider == "custom" and len(args) >= 4 else ""
|
|
346
|
+
return CommandResult(
|
|
347
|
+
Panel(
|
|
348
|
+
(
|
|
349
|
+
f"API key market/news untuk {provider} disimpan global di ~/.fincli/secrets.env.{extra}\n"
|
|
350
|
+
f"Provider market/news aktif disimpan: {provider}.\n"
|
|
351
|
+
"Key tidak ditampilkan di terminal dan dipakai lintas session."
|
|
352
|
+
),
|
|
353
|
+
title="Market API Key Saved",
|
|
354
|
+
border_style="green",
|
|
355
|
+
)
|
|
356
|
+
)
|
|
235
357
|
self.config.set_market_provider(args[0])
|
|
236
358
|
self.config.set_news_provider(args[0])
|
|
237
359
|
self.config.set_market_provider_priority([args[0], *self._priority_tail(args[0])])
|
|
@@ -442,7 +564,9 @@ class CommandRouter:
|
|
|
442
564
|
response = self._run_async(self.ai_provider.complete(AIRequest(prompt=prompt, model=self.config.settings.ai_model)))
|
|
443
565
|
if not isinstance(response, AIResponse):
|
|
444
566
|
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
445
|
-
return CommandResult(
|
|
567
|
+
return CommandResult(
|
|
568
|
+
MarkdownBlock("Journal Review", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
|
|
569
|
+
)
|
|
446
570
|
|
|
447
571
|
if args[0].lower() == "add":
|
|
448
572
|
if len(args) < 3:
|
|
@@ -472,9 +596,9 @@ class CommandRouter:
|
|
|
472
596
|
table.add_row("-", "-", "-", 'Belum ada journal. Gunakan /journal add BTC-USD bullish "Alasan entry"', "-")
|
|
473
597
|
return table
|
|
474
598
|
|
|
475
|
-
def
|
|
599
|
+
def _quote(self, args: list[str]) -> CommandResult:
|
|
476
600
|
if not args:
|
|
477
|
-
raise CommandError("Format: /
|
|
601
|
+
raise CommandError("Format: /quote <symbol>")
|
|
478
602
|
symbol = args[0].upper()
|
|
479
603
|
cache_key = f"quote:{symbol}"
|
|
480
604
|
cached = self.cache.get(cache_key)
|
|
@@ -556,6 +680,9 @@ class CommandRouter:
|
|
|
556
680
|
return CommandResult(_format_ai_response(response))
|
|
557
681
|
|
|
558
682
|
market_context = self._freechat_market_context(prompt)
|
|
683
|
+
web_context = self._freechat_web_context(prompt)
|
|
684
|
+
if web_context:
|
|
685
|
+
market_context = f"{market_context}\n\n{web_context}".strip()
|
|
559
686
|
assistant_prompt = build_fincli_assistant_prompt(prompt, market_context)
|
|
560
687
|
request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
|
|
561
688
|
response = self._run_async(self.ai_provider.complete(request))
|
|
@@ -563,6 +690,26 @@ class CommandRouter:
|
|
|
563
690
|
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
564
691
|
return CommandResult(_format_ai_response(response))
|
|
565
692
|
|
|
693
|
+
def _web(self, args: list[str]) -> CommandResult:
|
|
694
|
+
if not args:
|
|
695
|
+
raise CommandError("Format: /web <query>")
|
|
696
|
+
if args[0].lower() in {"sources", "source", "raw"}:
|
|
697
|
+
source_query = " ".join(args[1:]).strip()
|
|
698
|
+
if not source_query:
|
|
699
|
+
raise CommandError("Format: /web sources <query>")
|
|
700
|
+
results = self._run_async(self.web_research.research(source_query, limit=5))
|
|
701
|
+
return CommandResult(_format_web_results(source_query, results))
|
|
702
|
+
|
|
703
|
+
query = " ".join(args)
|
|
704
|
+
results = self._run_async(self.web_research.research(query, limit=5))
|
|
705
|
+
context = build_web_research_context(results)
|
|
706
|
+
assistant_prompt = build_web_research_answer_prompt(query, context)
|
|
707
|
+
request = AIRequest(prompt=assistant_prompt, model=self.config.settings.ai_model)
|
|
708
|
+
response = self._run_async(self.ai_provider.complete(request))
|
|
709
|
+
if not isinstance(response, AIResponse):
|
|
710
|
+
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
711
|
+
return CommandResult(_format_ai_response(response))
|
|
712
|
+
|
|
566
713
|
def _analyze(self, args: list[str]) -> CommandResult:
|
|
567
714
|
if not args:
|
|
568
715
|
raise CommandError("Format: /analyze <symbol> [timeframe]")
|
|
@@ -579,7 +726,9 @@ class CommandRouter:
|
|
|
579
726
|
response = self._run_async(self.ai_provider.complete(request))
|
|
580
727
|
if not isinstance(response, AIResponse):
|
|
581
728
|
raise CommandError("AI provider mengembalikan data tidak valid.")
|
|
582
|
-
return CommandResult(
|
|
729
|
+
return CommandResult(
|
|
730
|
+
MarkdownBlock(f"AI Market Analysis: {symbol}", _format_ai_response(response), "Disclaimer: bukan nasihat keuangan.")
|
|
731
|
+
)
|
|
583
732
|
|
|
584
733
|
def _scan(self, args: list[str]) -> CommandResult:
|
|
585
734
|
if not args or args[0].lower() != "watchlist":
|
|
@@ -718,6 +867,21 @@ class CommandRouter:
|
|
|
718
867
|
sections.append(self._symbol_freechat_context(symbol))
|
|
719
868
|
return "\n\n".join(sections)
|
|
720
869
|
|
|
870
|
+
def _freechat_web_context(self, prompt: str) -> str:
|
|
871
|
+
if not should_use_web_research(prompt):
|
|
872
|
+
return ""
|
|
873
|
+
cache_key = f"web:{prompt.lower()[:180]}"
|
|
874
|
+
cached = self.cache.get(cache_key)
|
|
875
|
+
if isinstance(cached, str):
|
|
876
|
+
return cached
|
|
877
|
+
try:
|
|
878
|
+
results = self._run_async(self.web_research.research(prompt, limit=3))
|
|
879
|
+
except FinCLIError as exc:
|
|
880
|
+
return f"Web Research: unavailable ({exc})"
|
|
881
|
+
context = build_web_research_context(results)
|
|
882
|
+
self.cache.set(cache_key, context)
|
|
883
|
+
return context
|
|
884
|
+
|
|
721
885
|
def _symbol_freechat_context(self, symbol: str) -> str:
|
|
722
886
|
lines = [f"Symbol: {symbol}"]
|
|
723
887
|
try:
|
|
@@ -840,6 +1004,63 @@ def _format_quote(quote: Quote) -> str:
|
|
|
840
1004
|
)
|
|
841
1005
|
|
|
842
1006
|
|
|
1007
|
+
def _format_sessions(sessions: list[dict[str, object]], current_session_id: str) -> Table:
|
|
1008
|
+
table = Table(title="FinCLI Sessions", expand=True)
|
|
1009
|
+
table.add_column("Current", justify="center", width=7)
|
|
1010
|
+
table.add_column("Session ID", style="cyan", no_wrap=True)
|
|
1011
|
+
table.add_column("Title", style="white")
|
|
1012
|
+
table.add_column("Events", justify="right")
|
|
1013
|
+
table.add_column("Updated", style="dim")
|
|
1014
|
+
for session in sessions:
|
|
1015
|
+
session_id = str(session["id"])
|
|
1016
|
+
table.add_row(
|
|
1017
|
+
"*" if session_id == current_session_id else "",
|
|
1018
|
+
session_id,
|
|
1019
|
+
str(session["title"]),
|
|
1020
|
+
str(session["event_count"]),
|
|
1021
|
+
str(session["updated_at"]),
|
|
1022
|
+
)
|
|
1023
|
+
if not sessions:
|
|
1024
|
+
table.add_row("-", "-", "Belum ada session.", "0", "-")
|
|
1025
|
+
table.caption = "/history current | /history show <session_id> | /history delete <session_id>"
|
|
1026
|
+
return table
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _format_session_events(session: dict[str, object], events: list[dict[str, object]], current: bool = False) -> Table:
|
|
1030
|
+
marker = "current" if current else "saved"
|
|
1031
|
+
table = Table(title=f"Session {session['id']} ({marker}) - {session['title']}", expand=True)
|
|
1032
|
+
table.add_column("#", justify="right", width=4)
|
|
1033
|
+
table.add_column("Time", style="dim", no_wrap=True)
|
|
1034
|
+
table.add_column("Status", style="cyan", no_wrap=True)
|
|
1035
|
+
table.add_column("Command", style="white")
|
|
1036
|
+
table.add_column("Output Preview", style="dim")
|
|
1037
|
+
for event in events:
|
|
1038
|
+
table.add_row(
|
|
1039
|
+
str(event["id"]),
|
|
1040
|
+
str(event["created_at"]),
|
|
1041
|
+
str(event["status"]),
|
|
1042
|
+
str(event["command"]),
|
|
1043
|
+
str(event["output_preview"] or "")[:180],
|
|
1044
|
+
)
|
|
1045
|
+
if not events:
|
|
1046
|
+
table.add_row("-", "-", "-", "Belum ada command di session ini.", "")
|
|
1047
|
+
table.caption = "/history sessions | /history save <title> | /history clear current"
|
|
1048
|
+
return table
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _render_history_preview(renderable: Any) -> str:
|
|
1052
|
+
if renderable is None:
|
|
1053
|
+
return ""
|
|
1054
|
+
if isinstance(renderable, str):
|
|
1055
|
+
return renderable[:1200]
|
|
1056
|
+
console = Console(width=100, record=True, force_terminal=False, file=io.StringIO())
|
|
1057
|
+
try:
|
|
1058
|
+
console.print(renderable)
|
|
1059
|
+
return console.export_text(clear=False).strip()[:1200]
|
|
1060
|
+
except Exception:
|
|
1061
|
+
return str(renderable)[:1200]
|
|
1062
|
+
|
|
1063
|
+
|
|
843
1064
|
def _format_dashboard(
|
|
844
1065
|
provider_chain: list[str],
|
|
845
1066
|
watchlist_rows: list[dict[str, object]],
|
|
@@ -1121,6 +1342,14 @@ def _format_provider_key_status(manager: MarketProviderManager) -> Table:
|
|
|
1121
1342
|
return table
|
|
1122
1343
|
|
|
1123
1344
|
|
|
1345
|
+
def _market_provider_secret_keys(provider: str) -> tuple[str, ...]:
|
|
1346
|
+
return {
|
|
1347
|
+
"custom": ("MARKET_DATA_API_KEY", "MARKET_DATA_BASE_URL"),
|
|
1348
|
+
"finnhub": ("FINNHUB_API_KEY",),
|
|
1349
|
+
"twelvedata": ("TWELVE_DATA_API_KEY",),
|
|
1350
|
+
}.get(provider.lower(), ())
|
|
1351
|
+
|
|
1352
|
+
|
|
1124
1353
|
def _format_transactions(rows: list[dict[str, object]]) -> Table:
|
|
1125
1354
|
table = Table(title="Transaction Ledger", expand=True)
|
|
1126
1355
|
table.add_column("ID", justify="right")
|
|
@@ -1171,6 +1400,21 @@ def _format_news(symbol: str, items: list[NewsItem]) -> str:
|
|
|
1171
1400
|
return "\n".join(lines)
|
|
1172
1401
|
|
|
1173
1402
|
|
|
1403
|
+
def _format_web_results(query: str, results: list[WebSearchResult]) -> Table:
|
|
1404
|
+
table = Table(title=f"Web Research: {query}", expand=True)
|
|
1405
|
+
table.add_column("#", justify="right", width=3)
|
|
1406
|
+
table.add_column("Title", style="cyan", overflow="fold")
|
|
1407
|
+
table.add_column("Snippet / Extract", overflow="fold")
|
|
1408
|
+
table.add_column("URL", style="dim", overflow="fold")
|
|
1409
|
+
for index, result in enumerate(results, start=1):
|
|
1410
|
+
extract = result.content[:500] if result.content else result.snippet
|
|
1411
|
+
table.add_row(str(index), result.title, extract or "-", result.url)
|
|
1412
|
+
if not results:
|
|
1413
|
+
table.add_row("-", "No results", "Search providers returned no public context.", "-")
|
|
1414
|
+
table.caption = "Web context is public web data; verify source quality before using it for financial decisions."
|
|
1415
|
+
return table
|
|
1416
|
+
|
|
1417
|
+
|
|
1174
1418
|
def _format_fundamentals(snapshot: FundamentalSnapshot) -> str:
|
|
1175
1419
|
return (
|
|
1176
1420
|
f"Fundamental Snapshot: {snapshot.symbol}\n"
|
|
@@ -1230,12 +1474,8 @@ def _format_fundamental_context(snapshot: FundamentalSnapshot) -> str:
|
|
|
1230
1474
|
)
|
|
1231
1475
|
|
|
1232
1476
|
|
|
1233
|
-
def _format_ai_response(response: AIResponse) ->
|
|
1234
|
-
return (
|
|
1235
|
-
f"Provider: {response.provider}\n"
|
|
1236
|
-
f"Model: {response.model}\n"
|
|
1237
|
-
f"Response:\n{response.content}"
|
|
1238
|
-
)
|
|
1477
|
+
def _format_ai_response(response: AIResponse) -> AIResponseView:
|
|
1478
|
+
return AIResponseView(response)
|
|
1239
1479
|
|
|
1240
1480
|
|
|
1241
1481
|
def _fmt(value: float | None) -> str:
|
|
@@ -1253,5 +1493,5 @@ class UnavailableAIProvider:
|
|
|
1253
1493
|
async def complete(self, request: AIRequest) -> AIResponse:
|
|
1254
1494
|
raise CommandError(
|
|
1255
1495
|
f"AI provider {self.name} belum siap dipakai.",
|
|
1256
|
-
"
|
|
1496
|
+
"Gunakan /ai_model untuk memilih provider dan /ai_model key <provider> <api_key> untuk menyimpan API key.",
|
|
1257
1497
|
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Session history service for FinCLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import re
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from fincli.app.storage.database import FinCLIDatabase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_AI_NEWS_KEY_PATTERN = re.compile(r"^/(ai_model|news_model)\s+key\s+(\S+)\s+(.+)$", re.IGNORECASE)
|
|
13
|
+
_PROVIDER_KEY_PATTERN = re.compile(r"^/provider\s+key\s+(\S+)\s+(.+)$", re.IGNORECASE)
|
|
14
|
+
_SECRET_VALUE_PATTERNS = (
|
|
15
|
+
re.compile(r"(?i)(api[_ -]?key|token|secret|password)\s*[:=]\s*\S+"),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionHistoryService:
|
|
20
|
+
"""Persist local command sessions and sanitized command events."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, db: FinCLIDatabase) -> None:
|
|
23
|
+
self.db = db
|
|
24
|
+
|
|
25
|
+
def start_session(self, title: str = "FinCLI session") -> str:
|
|
26
|
+
session_id = uuid4().hex[:12]
|
|
27
|
+
now = _now()
|
|
28
|
+
self.db.execute(
|
|
29
|
+
"INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
30
|
+
(session_id, title, now, now),
|
|
31
|
+
)
|
|
32
|
+
return session_id
|
|
33
|
+
|
|
34
|
+
def save_session(self, session_id: str, title: str) -> None:
|
|
35
|
+
self.db.execute(
|
|
36
|
+
"UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
|
|
37
|
+
(title.strip() or "FinCLI session", _now(), session_id),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def record_event(self, session_id: str, command: str, status: str, output_preview: str = "") -> None:
|
|
41
|
+
sanitized_command = sanitize_history_text(command.strip())
|
|
42
|
+
sanitized_output = sanitize_history_text(output_preview.strip())[:1200]
|
|
43
|
+
if not sanitized_command:
|
|
44
|
+
return
|
|
45
|
+
self.db.execute(
|
|
46
|
+
"""
|
|
47
|
+
INSERT INTO session_events (session_id, command, status, output_preview, created_at)
|
|
48
|
+
VALUES (?, ?, ?, ?, ?)
|
|
49
|
+
""",
|
|
50
|
+
(session_id, sanitized_command, status, sanitized_output, _now()),
|
|
51
|
+
)
|
|
52
|
+
self.db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), session_id))
|
|
53
|
+
|
|
54
|
+
def list_sessions(self, limit: int = 20) -> list[dict[str, object]]:
|
|
55
|
+
rows = self.db.query(
|
|
56
|
+
"""
|
|
57
|
+
SELECT s.id, s.title, s.created_at, s.updated_at, COUNT(e.id) AS event_count
|
|
58
|
+
FROM sessions s
|
|
59
|
+
LEFT JOIN session_events e ON e.session_id = s.id
|
|
60
|
+
GROUP BY s.id
|
|
61
|
+
ORDER BY s.updated_at DESC
|
|
62
|
+
LIMIT ?
|
|
63
|
+
""",
|
|
64
|
+
(limit,),
|
|
65
|
+
)
|
|
66
|
+
return [dict(row) for row in rows]
|
|
67
|
+
|
|
68
|
+
def get_events(self, session_id: str, limit: int = 100) -> list[dict[str, object]]:
|
|
69
|
+
rows = self.db.query(
|
|
70
|
+
"""
|
|
71
|
+
SELECT id, command, status, output_preview, created_at
|
|
72
|
+
FROM session_events
|
|
73
|
+
WHERE session_id = ?
|
|
74
|
+
ORDER BY id ASC
|
|
75
|
+
LIMIT ?
|
|
76
|
+
""",
|
|
77
|
+
(session_id, limit),
|
|
78
|
+
)
|
|
79
|
+
return [dict(row) for row in rows]
|
|
80
|
+
|
|
81
|
+
def get_session(self, session_id: str) -> dict[str, object] | None:
|
|
82
|
+
rows = self.db.query("SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?", (session_id,))
|
|
83
|
+
return dict(rows[0]) if rows else None
|
|
84
|
+
|
|
85
|
+
def delete_session(self, session_id: str) -> int:
|
|
86
|
+
self.db.execute("DELETE FROM session_events WHERE session_id = ?", (session_id,))
|
|
87
|
+
self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
def clear_events(self, session_id: str) -> None:
|
|
91
|
+
self.db.execute("DELETE FROM session_events WHERE session_id = ?", (session_id,))
|
|
92
|
+
self.db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), session_id))
|
|
93
|
+
|
|
94
|
+
def clear_all(self) -> None:
|
|
95
|
+
self.db.execute("DELETE FROM session_events")
|
|
96
|
+
self.db.execute("DELETE FROM sessions")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sanitize_history_text(value: str) -> str:
|
|
100
|
+
sanitized = value
|
|
101
|
+
match = _AI_NEWS_KEY_PATTERN.match(sanitized)
|
|
102
|
+
if match:
|
|
103
|
+
return f"/{match.group(1)} key {match.group(2)} <redacted>"
|
|
104
|
+
match = _PROVIDER_KEY_PATTERN.match(sanitized)
|
|
105
|
+
if match:
|
|
106
|
+
return f"/provider key {match.group(1)} <redacted>"
|
|
107
|
+
for pattern in _SECRET_VALUE_PATTERNS:
|
|
108
|
+
sanitized = pattern.sub(lambda match: f"{match.group(1)}=<redacted>", sanitized)
|
|
109
|
+
return sanitized
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _now() -> str:
|
|
113
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Anthropic provider
|
|
1
|
+
"""Anthropic provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "anthropic"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class AnthropicProvider(AnthropicProviderHTTP):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__(api_key or os.getenv("ANTHROPIC_API_KEY"))
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Gemini provider
|
|
1
|
+
"""Gemini provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "gemini"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import GeminiProviderHTTP
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class GeminiProvider(GeminiProviderHTTP):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__(api_key or os.getenv("GEMINI_API_KEY"))
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Groq provider
|
|
1
|
+
"""Groq provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "groq"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class GroqProvider(OpenAICompatibleProvider):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__("groq", "https://api.groq.com/openai/v1", api_key or os.getenv("GROQ_API_KEY"))
|
|
@@ -27,7 +27,7 @@ class OpenAICompatibleProvider(BaseAIProvider):
|
|
|
27
27
|
if not self.api_key:
|
|
28
28
|
raise ProviderError(
|
|
29
29
|
f"API key untuk provider {self.name} belum diatur.",
|
|
30
|
-
"
|
|
30
|
+
f"Gunakan /ai_model key {self.name} <api_key> atau set environment variable.",
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
payload = {
|
|
@@ -69,7 +69,7 @@ class GeminiProviderHTTP(BaseAIProvider):
|
|
|
69
69
|
|
|
70
70
|
async def complete(self, request: AIRequest) -> AIResponse:
|
|
71
71
|
if not self.api_key:
|
|
72
|
-
raise ProviderError("API key untuk provider gemini belum diatur.")
|
|
72
|
+
raise ProviderError("API key untuk provider gemini belum diatur.", "Gunakan /ai_model key gemini <api_key>.")
|
|
73
73
|
url = f"{self.base_url}/models/{request.model}:generateContent?key={self.api_key}"
|
|
74
74
|
payload = {"contents": [{"parts": [{"text": request.prompt}]}]}
|
|
75
75
|
try:
|
|
@@ -100,7 +100,7 @@ class AnthropicProviderHTTP(BaseAIProvider):
|
|
|
100
100
|
|
|
101
101
|
async def complete(self, request: AIRequest) -> AIResponse:
|
|
102
102
|
if not self.api_key:
|
|
103
|
-
raise ProviderError("API key untuk provider anthropic belum diatur.")
|
|
103
|
+
raise ProviderError("API key untuk provider anthropic belum diatur.", "Gunakan /ai_model key anthropic <api_key>.")
|
|
104
104
|
payload = {
|
|
105
105
|
"model": request.model,
|
|
106
106
|
"max_tokens": 1200,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""HuggingFace provider
|
|
1
|
+
"""HuggingFace provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "huggingface"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class HuggingFaceProvider(OpenAICompatibleProvider):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__("huggingface", "https://router.huggingface.co/v1", api_key or os.getenv("HUGGINGFACE_API_KEY"))
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""OpenAI provider
|
|
1
|
+
"""OpenAI provider compatibility wrapper."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from fincli.app.utils.errors import ProviderError
|
|
3
|
+
from __future__ import annotations
|
|
5
4
|
|
|
5
|
+
import os
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
name = "openai"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class OpenAIProvider(OpenAICompatibleProvider):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__("openai", "https://api.openai.com/v1", api_key or os.getenv("OPENAI_API_KEY"))
|