@drico2008/fincli 0.1.3 → 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 +41 -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 +10 -4
- package/fincli/app/cli/router.py +211 -18
- 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/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/manager.py +1 -1
- package/fincli/app/providers/market/news_provider.py +4 -4
- 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/database.py +17 -0
- package/fincli/app/storage/secrets.py +5 -2
- package/fincli/app/tui/components.py +1 -1
- package/fincli/app/tui/layout.py +8 -7
- package/fincli/app/tui/market_provider_selector.py +3 -0
- package/fincli/app/tui/model_selector.py +11 -3
- 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
|
@@ -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"))
|
|
@@ -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"))
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""OpenRouter provider
|
|
1
|
+
"""OpenRouter 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 = "openrouter"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class OpenRouterProvider(OpenAICompatibleProvider):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__("openrouter", "https://openrouter.ai/api/v1", api_key or os.getenv("OPENROUTER_API_KEY"))
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Together AI provider
|
|
1
|
+
"""Together AI 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 = "together"
|
|
7
|
+
from fincli.app.providers.ai.http_provider import OpenAICompatibleProvider
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
class TogetherProvider(OpenAICompatibleProvider):
|
|
11
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
12
|
+
super().__init__("together", "https://api.together.xyz/v1", api_key or os.getenv("TOGETHER_API_KEY"))
|
|
@@ -32,7 +32,7 @@ MARKET_PROVIDERS: dict[str, MarketProviderInfo] = {
|
|
|
32
32
|
name="custom",
|
|
33
33
|
realtime=True,
|
|
34
34
|
status="configured",
|
|
35
|
-
notes="Custom API provider
|
|
35
|
+
notes="Custom REST API provider. Requires MARKET_DATA_API_KEY and MARKET_DATA_BASE_URL.",
|
|
36
36
|
),
|
|
37
37
|
"finnhub": MarketProviderInfo(
|
|
38
38
|
name="finnhub",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Base news-only provider adapter."""
|
|
2
2
|
|
|
3
3
|
from fincli.app.providers.market.base import BaseMarketProvider, Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
|
|
4
4
|
from fincli.app.utils.errors import ProviderError
|
|
@@ -14,7 +14,7 @@ class NewsProvider(BaseMarketProvider):
|
|
|
14
14
|
raise ProviderError("News provider tidak menyediakan OHLCV.")
|
|
15
15
|
|
|
16
16
|
async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
|
|
17
|
-
raise ProviderError("News
|
|
17
|
+
raise ProviderError("News provider belum dikonfigurasi untuk mengambil berita.", "Gunakan /news_model untuk memilih provider market/news aktif.")
|
|
18
18
|
|
|
19
19
|
async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
|
|
20
20
|
raise ProviderError("News provider tidak menyediakan fundamental.")
|
|
@@ -23,6 +23,6 @@ class NewsProvider(BaseMarketProvider):
|
|
|
23
23
|
return ProviderStatus(
|
|
24
24
|
name=self.name,
|
|
25
25
|
realtime=False,
|
|
26
|
-
status="
|
|
27
|
-
message="News
|
|
26
|
+
status="unavailable",
|
|
27
|
+
message="News-only adapter belum memiliki source aktif. Gunakan /news_model untuk provider aktual.",
|
|
28
28
|
)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Lightweight web research service for AI assistance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from html import unescape
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
import re
|
|
9
|
+
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
|
|
10
|
+
from xml.etree import ElementTree
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from fincli.app.utils.errors import ProviderError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class WebSearchResult:
|
|
19
|
+
title: str
|
|
20
|
+
url: str
|
|
21
|
+
snippet: str = ""
|
|
22
|
+
content: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WebResearchService:
|
|
26
|
+
"""Search and fetch public web pages without browser automation."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, client: httpx.AsyncClient | None = None, timeout_seconds: float = 6.0) -> None:
|
|
29
|
+
self._client = client
|
|
30
|
+
self.timeout_seconds = timeout_seconds
|
|
31
|
+
|
|
32
|
+
async def research(self, query: str, limit: int = 3) -> list[WebSearchResult]:
|
|
33
|
+
normalized = query.strip()
|
|
34
|
+
if not normalized:
|
|
35
|
+
return []
|
|
36
|
+
search_results = await self.search(normalized, limit=limit)
|
|
37
|
+
enriched: list[WebSearchResult] = []
|
|
38
|
+
for result in search_results[:limit]:
|
|
39
|
+
content = await self.fetch_text(result.url)
|
|
40
|
+
enriched.append(
|
|
41
|
+
WebSearchResult(
|
|
42
|
+
title=result.title,
|
|
43
|
+
url=result.url,
|
|
44
|
+
snippet=result.snippet,
|
|
45
|
+
content=content,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return enriched
|
|
49
|
+
|
|
50
|
+
async def search(self, query: str, limit: int = 5) -> list[WebSearchResult]:
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
for searcher in (self._search_duckduckgo, self._search_google_news):
|
|
53
|
+
try:
|
|
54
|
+
results = await searcher(query, limit)
|
|
55
|
+
except ProviderError as exc:
|
|
56
|
+
errors.append(str(exc))
|
|
57
|
+
continue
|
|
58
|
+
if results:
|
|
59
|
+
return results
|
|
60
|
+
detail = "\n".join(f"- {error}" for error in errors) if errors else "Tidak ada hasil publik."
|
|
61
|
+
raise ProviderError(
|
|
62
|
+
"Semua web search provider gagal atau kosong.",
|
|
63
|
+
f"{detail}\nCoba ulangi, sederhanakan query, atau cek koneksi/DNS.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def _search_duckduckgo(self, query: str, limit: int) -> list[WebSearchResult]:
|
|
67
|
+
html = await self._get_text(f"https://duckduckgo.com/html/?q={quote_plus(query)}")
|
|
68
|
+
parser = _DuckDuckGoParser()
|
|
69
|
+
parser.feed(html)
|
|
70
|
+
results: list[WebSearchResult] = []
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for item in parser.results:
|
|
73
|
+
target = _clean_duckduckgo_url(item.url)
|
|
74
|
+
if not target or target in seen:
|
|
75
|
+
continue
|
|
76
|
+
seen.add(target)
|
|
77
|
+
results.append(WebSearchResult(title=_clean_text(item.title), url=target, snippet=_clean_text(item.snippet)))
|
|
78
|
+
if len(results) >= limit:
|
|
79
|
+
break
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
async def _search_google_news(self, query: str, limit: int) -> list[WebSearchResult]:
|
|
83
|
+
rss = await self._get_text(f"https://news.google.com/rss/search?q={quote_plus(query)}&hl=id&gl=ID&ceid=ID:id")
|
|
84
|
+
try:
|
|
85
|
+
root = ElementTree.fromstring(rss)
|
|
86
|
+
except ElementTree.ParseError as exc:
|
|
87
|
+
raise ProviderError("Google News RSS tidak valid.") from exc
|
|
88
|
+
|
|
89
|
+
results: list[WebSearchResult] = []
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
for item in root.findall(".//item"):
|
|
92
|
+
title = _clean_text(item.findtext("title") or "")
|
|
93
|
+
url = _clean_text(item.findtext("link") or "")
|
|
94
|
+
snippet = _clean_text(_html_to_text(item.findtext("description") or ""))
|
|
95
|
+
if not title or not url or url in seen:
|
|
96
|
+
continue
|
|
97
|
+
seen.add(url)
|
|
98
|
+
results.append(WebSearchResult(title=title, url=url, snippet=snippet))
|
|
99
|
+
if len(results) >= limit:
|
|
100
|
+
break
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
async def fetch_text(self, url: str, max_chars: int = 2400) -> str:
|
|
104
|
+
if not url.startswith(("http://", "https://")):
|
|
105
|
+
return ""
|
|
106
|
+
try:
|
|
107
|
+
html = await self._get_text(url)
|
|
108
|
+
except ProviderError:
|
|
109
|
+
return ""
|
|
110
|
+
text = _html_to_text(html)
|
|
111
|
+
return text[:max_chars]
|
|
112
|
+
|
|
113
|
+
async def _get_text(self, url: str) -> str:
|
|
114
|
+
headers = {
|
|
115
|
+
"User-Agent": "FinCLI/0.1 web research (+https://www.npmjs.com/package/@drico2008/fincli)",
|
|
116
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7",
|
|
117
|
+
}
|
|
118
|
+
close_client = self._client is None
|
|
119
|
+
client = self._client or httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True, headers=headers)
|
|
120
|
+
try:
|
|
121
|
+
response = await client.get(url, headers=headers)
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
return response.text
|
|
124
|
+
except httpx.TimeoutException as exc:
|
|
125
|
+
raise ProviderError("Web research timeout.", f"URL: {url}") from exc
|
|
126
|
+
except httpx.HTTPStatusError as exc:
|
|
127
|
+
raise ProviderError(f"Web research gagal: HTTP {exc.response.status_code}.", f"URL: {url}") from exc
|
|
128
|
+
except httpx.RequestError as exc:
|
|
129
|
+
raise ProviderError(f"Web research gagal terhubung: {exc}.", f"URL: {url}") from exc
|
|
130
|
+
finally:
|
|
131
|
+
if close_client:
|
|
132
|
+
await client.aclose()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def should_use_web_research(prompt: str) -> bool:
|
|
136
|
+
"""Detect prompts that benefit from current public web context."""
|
|
137
|
+
normalized = prompt.lower()
|
|
138
|
+
keywords = (
|
|
139
|
+
"terkini",
|
|
140
|
+
"terbaru",
|
|
141
|
+
"hari ini",
|
|
142
|
+
"sekarang",
|
|
143
|
+
"saat ini",
|
|
144
|
+
"update",
|
|
145
|
+
"berita",
|
|
146
|
+
"news",
|
|
147
|
+
"web",
|
|
148
|
+
"search",
|
|
149
|
+
"cari",
|
|
150
|
+
"penyebab",
|
|
151
|
+
"mengapa",
|
|
152
|
+
"kenapa",
|
|
153
|
+
"rupiah",
|
|
154
|
+
"inflasi",
|
|
155
|
+
"suku bunga",
|
|
156
|
+
"bank indonesia",
|
|
157
|
+
"fed",
|
|
158
|
+
"dollar",
|
|
159
|
+
"dolar",
|
|
160
|
+
"yield",
|
|
161
|
+
"minyak",
|
|
162
|
+
"emas",
|
|
163
|
+
)
|
|
164
|
+
return any(keyword in normalized for keyword in keywords)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_web_research_context(results: list[WebSearchResult]) -> str:
|
|
168
|
+
if not results:
|
|
169
|
+
return "Web Research: no public web context returned."
|
|
170
|
+
sections = ["Web Research Context:"]
|
|
171
|
+
for index, result in enumerate(results, start=1):
|
|
172
|
+
sections.extend(
|
|
173
|
+
[
|
|
174
|
+
f"{index}. {result.title}",
|
|
175
|
+
f"URL: {result.url}",
|
|
176
|
+
f"Snippet: {result.snippet or 'N/A'}",
|
|
177
|
+
f"Extract: {result.content or 'N/A'}",
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
return "\n".join(sections)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class _DuckResult:
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
self.title = ""
|
|
186
|
+
self.url = ""
|
|
187
|
+
self.snippet = ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _DuckDuckGoParser(HTMLParser):
|
|
191
|
+
def __init__(self) -> None:
|
|
192
|
+
super().__init__()
|
|
193
|
+
self.results: list[_DuckResult] = []
|
|
194
|
+
self._current: _DuckResult | None = None
|
|
195
|
+
self._capture: str | None = None
|
|
196
|
+
self._buffer: list[str] = []
|
|
197
|
+
|
|
198
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
199
|
+
attr = dict(attrs)
|
|
200
|
+
classes = attr.get("class", "")
|
|
201
|
+
if tag == "a" and "result__a" in classes:
|
|
202
|
+
self._current = _DuckResult()
|
|
203
|
+
self._current.url = attr.get("href", "") or ""
|
|
204
|
+
self._capture = "title"
|
|
205
|
+
self._buffer = []
|
|
206
|
+
elif self._current is not None and tag in {"a", "div"} and "result__snippet" in classes:
|
|
207
|
+
self._capture = "snippet"
|
|
208
|
+
self._buffer = []
|
|
209
|
+
|
|
210
|
+
def handle_data(self, data: str) -> None:
|
|
211
|
+
if self._capture:
|
|
212
|
+
self._buffer.append(data)
|
|
213
|
+
|
|
214
|
+
def handle_endtag(self, tag: str) -> None:
|
|
215
|
+
if self._current is None or self._capture is None:
|
|
216
|
+
return
|
|
217
|
+
if self._capture == "title" and tag == "a":
|
|
218
|
+
self._current.title = _clean_text(" ".join(self._buffer))
|
|
219
|
+
self._capture = None
|
|
220
|
+
self._buffer = []
|
|
221
|
+
elif self._capture == "snippet" and tag in {"a", "div"}:
|
|
222
|
+
self._current.snippet = _clean_text(" ".join(self._buffer))
|
|
223
|
+
self.results.append(self._current)
|
|
224
|
+
self._current = None
|
|
225
|
+
self._capture = None
|
|
226
|
+
self._buffer = []
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _TextExtractor(HTMLParser):
|
|
230
|
+
def __init__(self) -> None:
|
|
231
|
+
super().__init__()
|
|
232
|
+
self.parts: list[str] = []
|
|
233
|
+
self._skip_depth = 0
|
|
234
|
+
|
|
235
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
236
|
+
if tag in {"script", "style", "noscript", "svg"}:
|
|
237
|
+
self._skip_depth += 1
|
|
238
|
+
|
|
239
|
+
def handle_endtag(self, tag: str) -> None:
|
|
240
|
+
if tag in {"script", "style", "noscript", "svg"} and self._skip_depth:
|
|
241
|
+
self._skip_depth -= 1
|
|
242
|
+
|
|
243
|
+
def handle_data(self, data: str) -> None:
|
|
244
|
+
if not self._skip_depth:
|
|
245
|
+
cleaned = _clean_text(data)
|
|
246
|
+
if cleaned:
|
|
247
|
+
self.parts.append(cleaned)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _html_to_text(html: str) -> str:
|
|
251
|
+
extractor = _TextExtractor()
|
|
252
|
+
extractor.feed(html)
|
|
253
|
+
return _clean_text(" ".join(extractor.parts))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _clean_text(value: str) -> str:
|
|
257
|
+
text = unescape(value)
|
|
258
|
+
text = re.sub(r"\s+", " ", text)
|
|
259
|
+
return text.strip()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _clean_duckduckgo_url(url: str) -> str:
|
|
263
|
+
parsed = urlparse(url)
|
|
264
|
+
if parsed.netloc.endswith("duckduckgo.com") and parsed.path.startswith("/l/"):
|
|
265
|
+
target = parse_qs(parsed.query).get("uddg", [""])[0]
|
|
266
|
+
return unquote(target)
|
|
267
|
+
return url
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Lightweight in-memory cache
|
|
1
|
+
"""Lightweight in-memory TTL cache for provider responses."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -16,7 +16,7 @@ class CacheEntry(Generic[T]):
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class TTLCache(Generic[T]):
|
|
19
|
-
"""Simple TTL cache
|
|
19
|
+
"""Simple runtime TTL cache used by router and provider workflows."""
|
|
20
20
|
|
|
21
21
|
def __init__(self, default_ttl: int = 300) -> None:
|
|
22
22
|
self.default_ttl = default_ttl
|
|
@@ -80,6 +80,23 @@ class FinCLIDatabase:
|
|
|
80
80
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
81
81
|
PRIMARY KEY (namespace, cache_key)
|
|
82
82
|
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
title TEXT NOT NULL,
|
|
87
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
88
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
|
92
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
session_id TEXT NOT NULL,
|
|
94
|
+
command TEXT NOT NULL,
|
|
95
|
+
status TEXT NOT NULL,
|
|
96
|
+
output_preview TEXT DEFAULT '',
|
|
97
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
98
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
99
|
+
);
|
|
83
100
|
"""
|
|
84
101
|
)
|
|
85
102
|
except sqlite3.Error as exc:
|
|
@@ -24,7 +24,7 @@ def load_local_secrets(path: Path | None = None, *, override: bool = False) -> N
|
|
|
24
24
|
key, value = stripped.split("=", 1)
|
|
25
25
|
key = key.strip()
|
|
26
26
|
value = _unquote(value.strip())
|
|
27
|
-
if key and (override or key not in os.environ):
|
|
27
|
+
if key and (override or key not in os.environ or os.environ.get(key, "") == ""):
|
|
28
28
|
os.environ[key] = value
|
|
29
29
|
|
|
30
30
|
|
|
@@ -72,8 +72,11 @@ def read_secrets(path: Path | None = None) -> dict[str, str]:
|
|
|
72
72
|
def secret_source(env_key: str, path: Path | None = None) -> str:
|
|
73
73
|
"""Return a display-safe source for a secret."""
|
|
74
74
|
path = path or SECRETS_FILE
|
|
75
|
+
current = os.getenv(env_key)
|
|
76
|
+
if not current:
|
|
77
|
+
return "-"
|
|
75
78
|
if env_key in os.environ:
|
|
76
|
-
if
|
|
79
|
+
if read_secrets(path).get(env_key) == current:
|
|
77
80
|
return "~/.fincli/secrets.env"
|
|
78
81
|
return "environment/.env"
|
|
79
82
|
return "-"
|