cite-agent 1.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of cite-agent might be problematic. Click here for more details.
- cite_agent/__distribution__.py +7 -0
- cite_agent/__init__.py +66 -0
- cite_agent/account_client.py +130 -0
- cite_agent/agent_backend_only.py +172 -0
- cite_agent/ascii_plotting.py +296 -0
- cite_agent/auth.py +281 -0
- cite_agent/backend_only_client.py +83 -0
- cite_agent/cli.py +512 -0
- cite_agent/cli_enhanced.py +207 -0
- cite_agent/dashboard.py +339 -0
- cite_agent/enhanced_ai_agent.py +172 -0
- cite_agent/rate_limiter.py +298 -0
- cite_agent/setup_config.py +417 -0
- cite_agent/telemetry.py +85 -0
- cite_agent/ui.py +175 -0
- cite_agent/updater.py +187 -0
- cite_agent/web_search.py +203 -0
- cite_agent-1.0.0.dist-info/METADATA +234 -0
- cite_agent-1.0.0.dist-info/RECORD +23 -0
- cite_agent-1.0.0.dist-info/WHEEL +5 -0
- cite_agent-1.0.0.dist-info/entry_points.txt +3 -0
- cite_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
- cite_agent-1.0.0.dist-info/top_level.txt +1 -0
cite_agent/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nocturnal Archive - Beta Agent
|
|
3
|
+
|
|
4
|
+
A Groq-powered research and finance co-pilot with deterministic tooling and
|
|
5
|
+
prior stacks preserved only in Git history, kept out of the runtime footprint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .enhanced_ai_agent import EnhancedNocturnalAgent, ChatRequest, ChatResponse
|
|
9
|
+
|
|
10
|
+
__version__ = "0.9.0b1"
|
|
11
|
+
__author__ = "Nocturnal Archive Team"
|
|
12
|
+
__email__ = "contact@nocturnal.dev"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"EnhancedNocturnalAgent",
|
|
16
|
+
"ChatRequest",
|
|
17
|
+
"ChatResponse"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Package metadata
|
|
21
|
+
PACKAGE_NAME = "nocturnal-archive"
|
|
22
|
+
PACKAGE_VERSION = __version__
|
|
23
|
+
PACKAGE_DESCRIPTION = "Beta CLI agent for finance + research workflows"
|
|
24
|
+
PACKAGE_URL = "https://github.com/Spectating101/nocturnal-archive"
|
|
25
|
+
|
|
26
|
+
def get_version():
|
|
27
|
+
"""Get the package version"""
|
|
28
|
+
return __version__
|
|
29
|
+
|
|
30
|
+
def quick_start():
|
|
31
|
+
"""Print quick start instructions"""
|
|
32
|
+
print("""
|
|
33
|
+
🚀 Nocturnal Archive Quick Start
|
|
34
|
+
================================
|
|
35
|
+
|
|
36
|
+
1. Install the package and CLI:
|
|
37
|
+
pip install nocturnal-archive
|
|
38
|
+
|
|
39
|
+
2. Configure your Groq key:
|
|
40
|
+
nocturnal --setup
|
|
41
|
+
|
|
42
|
+
3. Ask a question:
|
|
43
|
+
nocturnal "Compare Apple and Microsoft net income this quarter"
|
|
44
|
+
|
|
45
|
+
4. Prefer embedding in code? Minimal example:
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from nocturnal_archive import EnhancedNocturnalAgent, ChatRequest
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
agent = EnhancedNocturnalAgent()
|
|
52
|
+
await agent.initialize()
|
|
53
|
+
|
|
54
|
+
response = await agent.process_request(ChatRequest(question="List repo workspace files"))
|
|
55
|
+
print(response.response)
|
|
56
|
+
|
|
57
|
+
await agent.close()
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Full installation instructions live in docs/INSTALL.md.
|
|
63
|
+
""")
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
quick_start()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Account provisioning utilities for the Nocturnal Archive CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AccountProvisioningError(RuntimeError):
|
|
12
|
+
"""Raised when account provisioning fails."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AccountCredentials:
|
|
17
|
+
"""User account credentials for backend authentication.
|
|
18
|
+
|
|
19
|
+
Production mode: User gets JWT tokens, NOT API keys.
|
|
20
|
+
Backend has the API keys, user just authenticates with JWT.
|
|
21
|
+
"""
|
|
22
|
+
account_id: str
|
|
23
|
+
email: str
|
|
24
|
+
auth_token: str # JWT for backend authentication
|
|
25
|
+
refresh_token: str
|
|
26
|
+
telemetry_token: str
|
|
27
|
+
issued_at: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_payload(cls, email: str, payload: Dict[str, Any]) -> "AccountCredentials":
|
|
31
|
+
try:
|
|
32
|
+
return cls(
|
|
33
|
+
account_id=str(payload["accountId"]),
|
|
34
|
+
email=email,
|
|
35
|
+
auth_token=str(payload["authToken"]),
|
|
36
|
+
refresh_token=str(payload.get("refreshToken", "")),
|
|
37
|
+
telemetry_token=str(payload.get("telemetryToken", "")),
|
|
38
|
+
issued_at=str(payload.get("issuedAt", "")) or None,
|
|
39
|
+
)
|
|
40
|
+
except KeyError as exc: # pragma: no cover - defensive guard
|
|
41
|
+
raise AccountProvisioningError(
|
|
42
|
+
f"Account provisioning payload missing field: {exc!s}" # noqa: TRY200
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AccountClient:
|
|
47
|
+
"""Minimal client for authenticating against the control plane.
|
|
48
|
+
|
|
49
|
+
If ``NOCTURNAL_CONTROL_PLANE_URL`` is unset the client falls back to an
|
|
50
|
+
offline deterministic token generator so local development and CI remain
|
|
51
|
+
hermetic.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, base_url: Optional[str] = None, timeout: int = 10):
|
|
55
|
+
self.base_url = (
|
|
56
|
+
base_url
|
|
57
|
+
or os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
|
|
58
|
+
or "https://cite-agent-api-720dfadd602c.herokuapp.com"
|
|
59
|
+
)
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
|
|
62
|
+
def provision(self, email: str, password: str) -> AccountCredentials:
|
|
63
|
+
if self.base_url:
|
|
64
|
+
payload = self._request_credentials(email, password)
|
|
65
|
+
return AccountCredentials.from_payload(email=email, payload=payload)
|
|
66
|
+
return self._generate_offline_credentials(email, password)
|
|
67
|
+
|
|
68
|
+
# -- internal helpers -------------------------------------------------
|
|
69
|
+
def _request_credentials(self, email: str, password: str) -> Dict[str, Any]:
|
|
70
|
+
try: # pragma: no cover - requires network
|
|
71
|
+
import requests # type: ignore
|
|
72
|
+
except Exception as exc: # pragma: no cover - executed when requests missing
|
|
73
|
+
raise AccountProvisioningError(
|
|
74
|
+
"The 'requests' package is required for control-plane authentication"
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
endpoint = self.base_url.rstrip("/") + "/api/beta/login"
|
|
78
|
+
body = {"email": email, "password": password, "client": "cli"}
|
|
79
|
+
try:
|
|
80
|
+
response = requests.post(endpoint, json=body, timeout=self.timeout)
|
|
81
|
+
except Exception as exc: # pragma: no cover - network failure
|
|
82
|
+
raise AccountProvisioningError("Failed to reach control plane") from exc
|
|
83
|
+
|
|
84
|
+
if response.status_code >= 400:
|
|
85
|
+
detail = self._extract_error_detail(response)
|
|
86
|
+
raise AccountProvisioningError(
|
|
87
|
+
f"Authentication failed (status {response.status_code}): {detail}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
payload = response.json()
|
|
92
|
+
except ValueError as exc: # pragma: no cover - invalid JSON
|
|
93
|
+
raise AccountProvisioningError("Control plane returned invalid JSON") from exc
|
|
94
|
+
|
|
95
|
+
if not isinstance(payload, dict): # pragma: no cover - sanity guard
|
|
96
|
+
raise AccountProvisioningError("Control plane response must be an object")
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _extract_error_detail(response: Any) -> str:
|
|
101
|
+
try: # pragma: no cover - best effort decoding
|
|
102
|
+
data = response.json()
|
|
103
|
+
if isinstance(data, dict) and data.get("detail"):
|
|
104
|
+
return str(data["detail"])
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return response.text.strip() or "unknown error"
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _generate_offline_credentials(email: str, password: str) -> AccountCredentials:
|
|
111
|
+
seed = f"{email.lower()}::{password}"
|
|
112
|
+
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()
|
|
113
|
+
auth_token = digest[12:44]
|
|
114
|
+
refresh_token = digest[44:]
|
|
115
|
+
telemetry_token = digest[24:56]
|
|
116
|
+
return AccountCredentials(
|
|
117
|
+
account_id=digest[:12],
|
|
118
|
+
email=email,
|
|
119
|
+
auth_token=auth_token,
|
|
120
|
+
refresh_token=refresh_token,
|
|
121
|
+
telemetry_token=telemetry_token,
|
|
122
|
+
issued_at=None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"AccountClient",
|
|
128
|
+
"AccountCredentials",
|
|
129
|
+
"AccountProvisioningError",
|
|
130
|
+
]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-Only Agent (Distribution Version)
|
|
3
|
+
All LLM queries go through centralized backend API.
|
|
4
|
+
Local API keys are not supported.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import requests
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ChatRequest:
|
|
15
|
+
question: str
|
|
16
|
+
user_id: str = "default"
|
|
17
|
+
conversation_id: str = "default"
|
|
18
|
+
context: Dict[str, Any] = None
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ChatResponse:
|
|
22
|
+
response: str
|
|
23
|
+
citations: list = None
|
|
24
|
+
tools_used: list = None
|
|
25
|
+
model: str = "backend"
|
|
26
|
+
timestamp: str = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
if self.timestamp is None:
|
|
30
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
31
|
+
if self.citations is None:
|
|
32
|
+
self.citations = []
|
|
33
|
+
if self.tools_used is None:
|
|
34
|
+
self.tools_used = []
|
|
35
|
+
|
|
36
|
+
class EnhancedNocturnalAgent:
|
|
37
|
+
"""
|
|
38
|
+
Backend-only agent for distribution.
|
|
39
|
+
Proxies all requests to centralized API.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.backend_url = (
|
|
44
|
+
os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
|
|
45
|
+
or "https://cite-agent-api-720dfadd602c.herokuapp.com"
|
|
46
|
+
)
|
|
47
|
+
self.auth_token = None
|
|
48
|
+
self._load_auth()
|
|
49
|
+
|
|
50
|
+
def _load_auth(self):
|
|
51
|
+
"""Load authentication token from config"""
|
|
52
|
+
# Try environment first
|
|
53
|
+
self.auth_token = os.getenv("NOCTURNAL_AUTH_TOKEN")
|
|
54
|
+
|
|
55
|
+
# Try config file
|
|
56
|
+
if not self.auth_token:
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
config_file = Path.home() / ".nocturnal_archive" / "config.env"
|
|
59
|
+
if config_file.exists():
|
|
60
|
+
with open(config_file) as f:
|
|
61
|
+
for line in f:
|
|
62
|
+
if line.startswith("NOCTURNAL_AUTH_TOKEN="):
|
|
63
|
+
self.auth_token = line.split("=", 1)[1].strip()
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
async def initialize(self):
|
|
67
|
+
"""Initialize agent"""
|
|
68
|
+
if not self.auth_token:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"Not authenticated. Please run 'cite-agent --setup' first."
|
|
71
|
+
)
|
|
72
|
+
print(f"✅ Connected to backend: {self.backend_url}")
|
|
73
|
+
|
|
74
|
+
async def chat(self, request: ChatRequest) -> ChatResponse:
|
|
75
|
+
"""
|
|
76
|
+
Send chat request to backend API.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
request: Chat request with question and context
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Chat response with answer and citations
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
RuntimeError: If authentication fails or backend unavailable
|
|
86
|
+
"""
|
|
87
|
+
if not self.auth_token:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"Not authenticated. Run 'cite-agent --setup' first."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
response = requests.post(
|
|
94
|
+
f"{self.backend_url}/api/query",
|
|
95
|
+
headers={
|
|
96
|
+
"Authorization": f"Bearer {self.auth_token}",
|
|
97
|
+
"Content-Type": "application/json"
|
|
98
|
+
},
|
|
99
|
+
json={
|
|
100
|
+
"query": request.question,
|
|
101
|
+
"context": request.context or {},
|
|
102
|
+
"user_id": request.user_id,
|
|
103
|
+
"conversation_id": request.conversation_id,
|
|
104
|
+
},
|
|
105
|
+
timeout=60
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if response.status_code == 401:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Authentication expired. Run 'cite-agent --setup' to log in again."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if response.status_code == 429:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
"Daily quota exceeded (25,000 tokens). Resets tomorrow."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if response.status_code >= 400:
|
|
119
|
+
error_detail = response.json().get("detail", response.text)
|
|
120
|
+
raise RuntimeError(f"Backend error: {error_detail}")
|
|
121
|
+
|
|
122
|
+
data = response.json()
|
|
123
|
+
|
|
124
|
+
return ChatResponse(
|
|
125
|
+
response=data.get("response", data.get("answer", "")),
|
|
126
|
+
citations=data.get("citations", []),
|
|
127
|
+
tools_used=data.get("tools_used", []),
|
|
128
|
+
model=data.get("model", "backend"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
except requests.RequestException as e:
|
|
132
|
+
raise RuntimeError(
|
|
133
|
+
f"Backend connection failed: {e}. Check your internet connection."
|
|
134
|
+
) from e
|
|
135
|
+
|
|
136
|
+
async def close(self):
|
|
137
|
+
"""Cleanup"""
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def get_health_status(self) -> Dict[str, Any]:
|
|
141
|
+
"""Get backend health status"""
|
|
142
|
+
try:
|
|
143
|
+
response = requests.get(
|
|
144
|
+
f"{self.backend_url}/api/health/",
|
|
145
|
+
timeout=5
|
|
146
|
+
)
|
|
147
|
+
return response.json()
|
|
148
|
+
except:
|
|
149
|
+
return {"status": "unavailable"}
|
|
150
|
+
|
|
151
|
+
def check_quota(self) -> Dict[str, Any]:
|
|
152
|
+
"""Check remaining daily quota"""
|
|
153
|
+
if not self.auth_token:
|
|
154
|
+
raise RuntimeError("Not authenticated")
|
|
155
|
+
|
|
156
|
+
response = requests.get(
|
|
157
|
+
f"{self.backend_url}/api/auth/me",
|
|
158
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
159
|
+
timeout=10
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if response.status_code == 401:
|
|
163
|
+
raise RuntimeError("Authentication expired")
|
|
164
|
+
|
|
165
|
+
response.raise_for_status()
|
|
166
|
+
data = response.json()
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"tokens_used": data.get("tokens_used_today", 0),
|
|
170
|
+
"tokens_remaining": data.get("tokens_remaining", 0),
|
|
171
|
+
"daily_limit": 25000,
|
|
172
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ASCII Plotting Module for Terminal Visualization
|
|
4
|
+
Uses plotext for clean, readable terminal charts
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Optional, Dict, Any, Tuple
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Try to import plotext
|
|
14
|
+
try:
|
|
15
|
+
import plotext as plt
|
|
16
|
+
PLOTEXT_AVAILABLE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
PLOTEXT_AVAILABLE = False
|
|
19
|
+
logger.warning("plotext not installed - ASCII plotting unavailable")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ASCIIPlotter:
|
|
23
|
+
"""
|
|
24
|
+
Terminal-based plotting using plotext
|
|
25
|
+
Generates clean ASCII charts for data visualization
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, width: int = 70, height: int = 20):
|
|
29
|
+
"""
|
|
30
|
+
Initialize plotter
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
width: Plot width in characters
|
|
34
|
+
height: Plot height in characters
|
|
35
|
+
"""
|
|
36
|
+
self.width = width
|
|
37
|
+
self.height = height
|
|
38
|
+
self.available = PLOTEXT_AVAILABLE
|
|
39
|
+
|
|
40
|
+
def plot_line(
|
|
41
|
+
self,
|
|
42
|
+
x: List[float],
|
|
43
|
+
y: List[float],
|
|
44
|
+
title: str = "Line Plot",
|
|
45
|
+
xlabel: str = "X",
|
|
46
|
+
ylabel: str = "Y",
|
|
47
|
+
label: Optional[str] = None
|
|
48
|
+
) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Create a line plot
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
x: X-axis data
|
|
54
|
+
y: Y-axis data
|
|
55
|
+
title: Plot title
|
|
56
|
+
xlabel: X-axis label
|
|
57
|
+
ylabel: Y-axis label
|
|
58
|
+
label: Data series label
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
ASCII art string representation of the plot
|
|
62
|
+
"""
|
|
63
|
+
if not self.available:
|
|
64
|
+
return self._fallback_plot(x, y, title)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
plt.clf() # Clear previous plot
|
|
68
|
+
plt.plot_size(self.width, self.height)
|
|
69
|
+
plt.plot(x, y, label=label)
|
|
70
|
+
plt.title(title)
|
|
71
|
+
plt.xlabel(xlabel)
|
|
72
|
+
plt.ylabel(ylabel)
|
|
73
|
+
if label:
|
|
74
|
+
plt.legend()
|
|
75
|
+
|
|
76
|
+
# Get the plot as string
|
|
77
|
+
return plt.build()
|
|
78
|
+
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"Plotting failed: {e}")
|
|
81
|
+
return self._fallback_plot(x, y, title)
|
|
82
|
+
|
|
83
|
+
def plot_multiple_lines(
|
|
84
|
+
self,
|
|
85
|
+
data: List[Tuple[List[float], List[float], str]],
|
|
86
|
+
title: str = "Multi-Line Plot",
|
|
87
|
+
xlabel: str = "X",
|
|
88
|
+
ylabel: str = "Y"
|
|
89
|
+
) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Create a multi-line plot
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
data: List of (x, y, label) tuples
|
|
95
|
+
title: Plot title
|
|
96
|
+
xlabel: X-axis label
|
|
97
|
+
ylabel: Y-axis label
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
ASCII art string representation of the plot
|
|
101
|
+
"""
|
|
102
|
+
if not self.available:
|
|
103
|
+
return f"[Plot unavailable: plotext not installed]\n{title}"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
plt.clf()
|
|
107
|
+
plt.plot_size(self.width, self.height)
|
|
108
|
+
|
|
109
|
+
for x, y, label in data:
|
|
110
|
+
plt.plot(x, y, label=label)
|
|
111
|
+
|
|
112
|
+
plt.title(title)
|
|
113
|
+
plt.xlabel(xlabel)
|
|
114
|
+
plt.ylabel(ylabel)
|
|
115
|
+
plt.legend()
|
|
116
|
+
|
|
117
|
+
return plt.build()
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Multi-line plotting failed: {e}")
|
|
121
|
+
return f"[Plot error: {str(e)}]\n{title}"
|
|
122
|
+
|
|
123
|
+
def plot_scatter(
|
|
124
|
+
self,
|
|
125
|
+
x: List[float],
|
|
126
|
+
y: List[float],
|
|
127
|
+
title: str = "Scatter Plot",
|
|
128
|
+
xlabel: str = "X",
|
|
129
|
+
ylabel: str = "Y",
|
|
130
|
+
label: Optional[str] = None
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Create a scatter plot"""
|
|
133
|
+
if not self.available:
|
|
134
|
+
return self._fallback_plot(x, y, title)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
plt.clf()
|
|
138
|
+
plt.plot_size(self.width, self.height)
|
|
139
|
+
plt.scatter(x, y, label=label)
|
|
140
|
+
plt.title(title)
|
|
141
|
+
plt.xlabel(xlabel)
|
|
142
|
+
plt.ylabel(ylabel)
|
|
143
|
+
if label:
|
|
144
|
+
plt.legend()
|
|
145
|
+
|
|
146
|
+
return plt.build()
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"Scatter plot failed: {e}")
|
|
150
|
+
return self._fallback_plot(x, y, title)
|
|
151
|
+
|
|
152
|
+
def plot_bar(
|
|
153
|
+
self,
|
|
154
|
+
categories: List[str],
|
|
155
|
+
values: List[float],
|
|
156
|
+
title: str = "Bar Chart",
|
|
157
|
+
xlabel: str = "Category",
|
|
158
|
+
ylabel: str = "Value"
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Create a bar chart"""
|
|
161
|
+
if not self.available:
|
|
162
|
+
return f"[Plot unavailable: plotext not installed]\n{title}"
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
plt.clf()
|
|
166
|
+
plt.plot_size(self.width, self.height)
|
|
167
|
+
plt.bar(categories, values)
|
|
168
|
+
plt.title(title)
|
|
169
|
+
plt.xlabel(xlabel)
|
|
170
|
+
plt.ylabel(ylabel)
|
|
171
|
+
|
|
172
|
+
return plt.build()
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.error(f"Bar chart failed: {e}")
|
|
176
|
+
return f"[Plot error: {str(e)}]\n{title}"
|
|
177
|
+
|
|
178
|
+
def plot_histogram(
|
|
179
|
+
self,
|
|
180
|
+
data: List[float],
|
|
181
|
+
bins: int = 20,
|
|
182
|
+
title: str = "Histogram",
|
|
183
|
+
xlabel: str = "Value",
|
|
184
|
+
ylabel: str = "Frequency"
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Create a histogram"""
|
|
187
|
+
if not self.available:
|
|
188
|
+
return f"[Plot unavailable: plotext not installed]\n{title}"
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
plt.clf()
|
|
192
|
+
plt.plot_size(self.width, self.height)
|
|
193
|
+
plt.hist(data, bins=bins)
|
|
194
|
+
plt.title(title)
|
|
195
|
+
plt.xlabel(xlabel)
|
|
196
|
+
plt.ylabel(ylabel)
|
|
197
|
+
|
|
198
|
+
return plt.build()
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.error(f"Histogram failed: {e}")
|
|
202
|
+
return f"[Plot error: {str(e)}]\n{title}"
|
|
203
|
+
|
|
204
|
+
def _fallback_plot(self, x: List[float], y: List[float], title: str) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Simple fallback visualization when plotext is unavailable
|
|
207
|
+
Creates a basic ASCII representation
|
|
208
|
+
"""
|
|
209
|
+
if not x or not y:
|
|
210
|
+
return f"[No data to plot]\n{title}"
|
|
211
|
+
|
|
212
|
+
# Simple text representation
|
|
213
|
+
output = [f"\n{title}", "─" * 40]
|
|
214
|
+
|
|
215
|
+
# Show min, max, mean
|
|
216
|
+
try:
|
|
217
|
+
output.append(f"Data points: {len(y)}")
|
|
218
|
+
output.append(f"Min: {min(y):.2f}")
|
|
219
|
+
output.append(f"Max: {max(y):.2f}")
|
|
220
|
+
output.append(f"Mean: {sum(y)/len(y):.2f}")
|
|
221
|
+
except Exception:
|
|
222
|
+
output.append("Data statistics unavailable")
|
|
223
|
+
|
|
224
|
+
output.append("─" * 40)
|
|
225
|
+
output.append("[Install plotext for visual charts: pip install plotext]")
|
|
226
|
+
|
|
227
|
+
return "\n".join(output)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def is_available() -> bool:
|
|
231
|
+
"""Check if plotting is available"""
|
|
232
|
+
return PLOTEXT_AVAILABLE
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Convenience functions for quick plotting
|
|
236
|
+
|
|
237
|
+
def plot_quick_line(x: List[float], y: List[float], title: str = "Plot") -> str:
|
|
238
|
+
"""Quick line plot"""
|
|
239
|
+
plotter = ASCIIPlotter()
|
|
240
|
+
return plotter.plot_line(x, y, title=title)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def plot_quick_bar(categories: List[str], values: List[float], title: str = "Chart") -> str:
|
|
244
|
+
"""Quick bar chart"""
|
|
245
|
+
plotter = ASCIIPlotter()
|
|
246
|
+
return plotter.plot_bar(categories, values, title=title)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Example usage and testing
|
|
250
|
+
def example_usage():
|
|
251
|
+
"""Demonstrate ASCII plotting capabilities"""
|
|
252
|
+
|
|
253
|
+
print("ASCII Plotter Demo\n")
|
|
254
|
+
print("=" * 70)
|
|
255
|
+
|
|
256
|
+
# Check availability
|
|
257
|
+
if not ASCIIPlotter.is_available():
|
|
258
|
+
print("❌ plotext not installed")
|
|
259
|
+
print("Install with: pip install plotext")
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
print("✅ plotext available\n")
|
|
263
|
+
|
|
264
|
+
# Example 1: Simple line plot
|
|
265
|
+
x = list(range(2020, 2025))
|
|
266
|
+
y = [100, 120, 115, 140, 150]
|
|
267
|
+
|
|
268
|
+
plotter = ASCIIPlotter(width=60, height=15)
|
|
269
|
+
|
|
270
|
+
print("Example 1: GDP Growth Over Time")
|
|
271
|
+
print(plotter.plot_line(x, y, title="GDP Growth (2020-2024)",
|
|
272
|
+
xlabel="Year", ylabel="GDP ($B)"))
|
|
273
|
+
|
|
274
|
+
# Example 2: Multiple lines
|
|
275
|
+
print("\n\nExample 2: Multi-Country Comparison")
|
|
276
|
+
data = [
|
|
277
|
+
(x, [100, 110, 105, 115, 120], "USA"),
|
|
278
|
+
(x, [90, 95, 92, 100, 105], "UK"),
|
|
279
|
+
(x, [80, 85, 88, 95, 100], "Japan")
|
|
280
|
+
]
|
|
281
|
+
print(plotter.plot_multiple_lines(data, title="GDP Growth Comparison",
|
|
282
|
+
xlabel="Year", ylabel="GDP Index"))
|
|
283
|
+
|
|
284
|
+
# Example 3: Bar chart
|
|
285
|
+
print("\n\nExample 3: Quarterly Revenue")
|
|
286
|
+
quarters = ["Q1", "Q2", "Q3", "Q4"]
|
|
287
|
+
revenue = [250, 280, 290, 310]
|
|
288
|
+
print(plotter.plot_bar(quarters, revenue, title="2024 Revenue by Quarter",
|
|
289
|
+
xlabel="Quarter", ylabel="Revenue ($M)"))
|
|
290
|
+
|
|
291
|
+
print("\n" + "=" * 70)
|
|
292
|
+
print("✅ ASCII plotting demo complete!")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
example_usage()
|