ifs-rag-assistant-cli 2.2.2__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.
@@ -0,0 +1,5 @@
1
+ """
2
+ IFS RAG Assistant CLI - Command-line interface for IFS RAG Assistant Q&A system.
3
+ """
4
+
5
+ __version__ = "2.2.2"
@@ -0,0 +1,182 @@
1
+ """
2
+ Unified authentication and backend client wrapper.
3
+
4
+ Handles the complete flow of device code authentication,
5
+ session creation, and backend API access.
6
+ """
7
+
8
+ from ipaddress import ip_address
9
+ from typing import Optional
10
+ from urllib.parse import urlparse
11
+
12
+ from ifs_rag_assistant_cli.backend_client import BackendClient
13
+ from ifs_rag_assistant_cli.config import Config, load_config
14
+ from ifs_rag_assistant_cli.oauth import create_device_authenticator
15
+ from ifs_rag_assistant_cli.session import SessionManager
16
+
17
+
18
+ class AuthenticatedClient:
19
+ """
20
+ Unified client for authenticated backend API access.
21
+
22
+ Manages the complete authentication flow:
23
+ 1. Load cached session if available
24
+ 2. If not, perform Device Code OAuth authentication
25
+ 3. Create backend session with OAuth token
26
+ 4. Cache session for future use
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ backend_url: Optional[str] = None,
32
+ keycloak_url: Optional[str] = None,
33
+ realm: Optional[str] = None,
34
+ client_id: Optional[str] = None,
35
+ config: Optional[Config] = None
36
+ ):
37
+ """
38
+ Initialize authenticated client.
39
+
40
+ Args:
41
+ backend_url: Backend API URL (overrides config)
42
+ keycloak_url: Keycloak server URL (overrides config)
43
+ realm: Keycloak realm (overrides config)
44
+ client_id: OAuth client ID (overrides config)
45
+ config: Config instance. If None, loads from file/env
46
+ """
47
+ # Load config if not provided
48
+ if config is None:
49
+ config = load_config()
50
+
51
+ self.config = config
52
+
53
+ # Initialize session manager
54
+ cache_dir = getattr(config, 'cache_dir', '~/.ifs-rag-assistant-cli')
55
+ self.session_mgr = SessionManager(cache_dir)
56
+
57
+ # Initialize backend client
58
+ api_root = backend_url or getattr(config, 'api_root', 'http://localhost:8000')
59
+ timeout = getattr(config, 'timeout', 300)
60
+ self.backend = BackendClient(str(api_root), int(timeout))
61
+
62
+ # Initialize OAuth authenticator using factory
63
+ keycloak_url = keycloak_url or getattr(
64
+ config, 'keycloak_server_url', 'http://localhost:8080'
65
+ )
66
+ realm = realm or getattr(config, 'keycloak_realm', 'master')
67
+ client_id = client_id or getattr(config, 'keycloak_client_id', 'ifs-rag')
68
+
69
+ # Get OAuth mode and custom wrapper URL from config
70
+ oauth_mode = getattr(config, 'oauth_device_flow_mode', 'standard')
71
+ custom_wrapper_url = getattr(config, 'oauth_custom_wrapper_url', None)
72
+
73
+ self.oauth = create_device_authenticator(
74
+ mode=oauth_mode,
75
+ client_id=str(client_id),
76
+ keycloak_server_url=keycloak_url,
77
+ realm=realm,
78
+ custom_wrapper_url=custom_wrapper_url
79
+ )
80
+
81
+ @staticmethod
82
+ def _is_local_api_root(api_root: str) -> bool:
83
+ """Return True when API root points to a local development host."""
84
+ parsed = urlparse(str(api_root))
85
+ host = (parsed.hostname or "").strip().lower()
86
+
87
+ if not host:
88
+ return False
89
+
90
+ if host == "localhost":
91
+ return True
92
+
93
+ try:
94
+ addr = ip_address(host)
95
+ return addr.is_loopback
96
+ except ValueError:
97
+ return False
98
+
99
+ def _is_auth_disabled(self) -> bool:
100
+ """Return True when auth_mode is explicitly disabled."""
101
+ auth_mode = str(getattr(self.config, "auth_mode", "oauth")).strip().lower()
102
+ if auth_mode not in ("oauth", "disabled"):
103
+ raise RuntimeError(
104
+ f"Invalid auth_mode '{auth_mode}'. Supported values: 'oauth', 'disabled'."
105
+ )
106
+ return auth_mode == "disabled"
107
+
108
+ def ensure_authenticated(self) -> None:
109
+ """
110
+ Ensure user is authenticated.
111
+
112
+ Attempts to load cached session if available.
113
+ If not, performs Device Code OAuth flow and creates new session.
114
+
115
+ Raises:
116
+ RuntimeError: If authentication fails
117
+ """
118
+ if self._is_auth_disabled():
119
+ if not self._is_local_api_root(self.backend.backend_url):
120
+ raise RuntimeError(
121
+ "Authentication is disabled, but api_root is not local. "
122
+ "Set auth_mode='oauth' or use a localhost/loopback api_root for development."
123
+ )
124
+
125
+ print("ℹ️ Authentication disabled for local development (auth_mode=disabled)")
126
+ print("ℹ️ Proceeding without OAuth and backend login session")
127
+ return
128
+
129
+ # Try to load cached session
130
+ session_id = self.session_mgr.load_session()
131
+
132
+ if session_id:
133
+ print("✅ Using cached session")
134
+ # Set the session in the backend client
135
+ self.backend.set_session(session_id)
136
+ return
137
+
138
+ print("🔐 Authentication required")
139
+
140
+ # Perform Device Code OAuth flow
141
+ token, _token_type = self.oauth.authenticate()
142
+
143
+ # Exchange OAuth token for backend session
144
+ print("🔄 Creating backend session...")
145
+ try:
146
+ if self.oauth.uses_refresh_session_login():
147
+ session_id = self.backend.login_with_refresh_token(token)
148
+ else:
149
+ session_id = self.backend.login_with_token(token)
150
+ self.session_mgr.save_session(session_id)
151
+ print("✅ Authenticated successfully!")
152
+ except RuntimeError as e:
153
+ raise RuntimeError(f"Failed to create session: {e}") from e
154
+
155
+ def logout(self) -> None:
156
+ """
157
+ Logout and clear cached session.
158
+
159
+ Raises:
160
+ RuntimeError: If logout fails
161
+ """
162
+ if self._is_auth_disabled():
163
+ self.session_mgr.clear_all()
164
+ print("ℹ️ Authentication is disabled (auth_mode=disabled): no backend logout required")
165
+ print("✅ Local session cache cleared")
166
+ return
167
+
168
+ self.backend.logout()
169
+ self.session_mgr.clear_all()
170
+ print("✅ Logged out successfully")
171
+
172
+ def close(self) -> None:
173
+ """Close backend connection."""
174
+ self.backend.close()
175
+
176
+ def __enter__(self):
177
+ """Context manager entry."""
178
+ return self
179
+
180
+ def __exit__(self, exc_type, exc_val, exc_tb):
181
+ """Context manager exit."""
182
+ self.close()
@@ -0,0 +1,298 @@
1
+ """
2
+ Backend API client for IFS RAG Assistant service.
3
+
4
+ Handles communication with the FastAPI backend,
5
+ including authentication and request/response handling.
6
+ """
7
+
8
+ from typing import Any, Optional, cast
9
+
10
+ import requests
11
+ from requests.exceptions import RequestException
12
+
13
+ SESSION_EXPIRED_MESSAGE = (
14
+ "Authentication failed: your session may have expired or is no longer valid. "
15
+ "Please run 'ifs-rag-assistant-cli logout' and try again. "
16
+ "If the problem persists, contact your service administrator."
17
+ )
18
+
19
+
20
+ class SessionExpiredError(RuntimeError):
21
+ """Raised when backend returns 401 due to expired or invalid session."""
22
+
23
+
24
+ class BackendClient:
25
+ """
26
+ Client for communicating with IFS RAG backend API.
27
+
28
+ Handles authentication via OAuth tokens and session cookies,
29
+ and provides methods for RAG operations (ask, search, etc.).
30
+ """
31
+
32
+ def __init__(self, backend_url: str, timeout: int = 300):
33
+ """
34
+ Initialize backend client.
35
+
36
+ Args:
37
+ backend_url: Base URL of backend API
38
+ timeout: Request timeout in seconds
39
+ """
40
+ self.backend_url = backend_url.rstrip('/')
41
+ self.timeout = timeout
42
+ self.session = requests.Session()
43
+
44
+ def set_session(self, session_id: str) -> None:
45
+ """
46
+ Set session ID in client cookies.
47
+
48
+ Args:
49
+ session_id: Session ID to use for authentication
50
+ """
51
+ self.session.cookies.set('session_id', session_id)
52
+
53
+ def _raise_request_error(self, operation: str, error: RequestException) -> None:
54
+ """Raise a user-friendly error message for request failures."""
55
+ response = getattr(error, "response", None)
56
+ if response is not None and response.status_code == 401:
57
+ raise SessionExpiredError(SESSION_EXPIRED_MESSAGE) from error
58
+ raise RuntimeError(f"{operation} failed: {error}") from error
59
+
60
+ def login_with_token(self, access_token: str) -> str:
61
+ """
62
+ Authenticate with OAuth access token and create session.
63
+
64
+ Args:
65
+ access_token: OAuth access token from Keycloak
66
+
67
+ Returns:
68
+ Session ID (extracted from response cookies)
69
+
70
+ Raises:
71
+ RuntimeError: If login fails
72
+ """
73
+ try:
74
+ resp = self.session.post(
75
+ f"{self.backend_url}/login",
76
+ json={"access_token": access_token},
77
+ timeout=self.timeout
78
+ )
79
+ resp.raise_for_status()
80
+ except RequestException as e:
81
+ raise RuntimeError(f"Login failed: {e}") from e
82
+
83
+ # Session ID is returned in Set-Cookie header
84
+ if 'session_id' not in self.session.cookies:
85
+ raise RuntimeError("No session_id in login response")
86
+
87
+ return str(self.session.cookies.get('session_id'))
88
+
89
+ def login_with_refresh_token(self, refresh_token: str) -> str:
90
+ """
91
+ Authenticate with OAuth refresh token (offline token) and create session.
92
+
93
+ Args:
94
+ refresh_token: OAuth refresh token (offline token) from authentication
95
+
96
+ Returns:
97
+ Session ID (extracted from response cookies)
98
+
99
+ Raises:
100
+ RuntimeError: If login fails
101
+ """
102
+ try:
103
+ resp = self.session.post(
104
+ f"{self.backend_url}/login/refresh",
105
+ json={"refresh_token": refresh_token},
106
+ timeout=self.timeout
107
+ )
108
+ resp.raise_for_status()
109
+ except RequestException as e:
110
+ raise RuntimeError(f"Login with refresh token failed: {e}") from e
111
+
112
+ # Session ID is returned in Set-Cookie header
113
+ if 'session_id' not in self.session.cookies:
114
+ raise RuntimeError("No session_id in login response")
115
+
116
+ return str(self.session.cookies.get('session_id'))
117
+
118
+ def ask(self, query: str, conversation_id: Optional[str] = None, client_id: Optional[str] = None) -> dict[str, Any]:
119
+ """
120
+ Ask a question to the RAG system.
121
+
122
+ Args:
123
+ query: The question to ask
124
+ conversation_id: Optional conversation identifier to continue a thread
125
+ client_id: Optional client identifier for tracking
126
+
127
+ Returns:
128
+ Response dictionary with answer, citations, conversation_id, turn_id, question_id, answer_id, created_at
129
+
130
+ Raises:
131
+ RuntimeError: If request fails
132
+ """
133
+ payload = {"query": query}
134
+ if conversation_id:
135
+ payload["conversation_id"] = conversation_id
136
+ if client_id:
137
+ payload["client_id"] = client_id
138
+
139
+ try:
140
+ resp = self.session.post(
141
+ f"{self.backend_url}/ask",
142
+ json=payload,
143
+ timeout=self.timeout
144
+ )
145
+ resp.raise_for_status()
146
+ except RequestException as e:
147
+ self._raise_request_error("Ask request", e)
148
+
149
+ return cast(dict[str, Any], resp.json())
150
+
151
+ def search(self, query: str) -> dict[str, Any]:
152
+ """
153
+ Search the RAG index.
154
+
155
+ Args:
156
+ query: Search query
157
+
158
+ Returns:
159
+ Response dictionary with search results (context, citations)
160
+
161
+ Raises:
162
+ RuntimeError: If request fails
163
+ """
164
+ try:
165
+ resp = self.session.get(
166
+ f"{self.backend_url}/search",
167
+ params={"query": query},
168
+ timeout=self.timeout
169
+ )
170
+ resp.raise_for_status()
171
+ except RequestException as e:
172
+ self._raise_request_error("Search request", e)
173
+
174
+ return cast(dict[str, Any], resp.json())
175
+
176
+ def logout(self) -> None:
177
+ """
178
+ Logout from backend (invalidate session).
179
+
180
+ Raises:
181
+ RuntimeError: If logout request fails
182
+ """
183
+ try:
184
+ self.session.post(
185
+ f"{self.backend_url}/logout",
186
+ timeout=self.timeout
187
+ )
188
+ except RequestException as e:
189
+ raise RuntimeError(f"Logout failed: {e}") from e
190
+
191
+ def health(self) -> dict[str, Any]:
192
+ """
193
+ Check backend health status.
194
+
195
+ Returns:
196
+ Response dictionary with health information
197
+
198
+ Raises:
199
+ RuntimeError: If health check fails
200
+ """
201
+ try:
202
+ resp = self.session.get(
203
+ f"{self.backend_url}/health",
204
+ timeout=self.timeout
205
+ )
206
+ resp.raise_for_status()
207
+ except RequestException as e:
208
+ self._raise_request_error("Health check", e)
209
+
210
+ return cast(dict[str, Any], resp.json())
211
+
212
+ def submit_feedback(self, conversation_id: str, answer_id: str,
213
+ vote: Optional[str] = None, reason: Optional[str] = None,
214
+ client_id: Optional[str] = None) -> dict[str, Any]:
215
+ """
216
+ Submit thumbs up/down feedback for a previously returned answer.
217
+
218
+ Args:
219
+ conversation_id: Conversation identifier
220
+ answer_id: Answer identifier to provide feedback for
221
+ vote: 'up', 'down', or None to clear vote
222
+ reason: Optional feedback reason text
223
+ client_id: Optional client identifier for tracking
224
+
225
+ Returns:
226
+ Response dictionary with feedback status
227
+
228
+ Raises:
229
+ RuntimeError: If request fails
230
+ """
231
+ payload = {
232
+ "conversation_id": conversation_id,
233
+ "answer_id": answer_id,
234
+ }
235
+ if vote:
236
+ payload["vote"] = vote
237
+ if reason:
238
+ payload["reason"] = reason
239
+ if client_id:
240
+ payload["client_id"] = client_id
241
+
242
+ try:
243
+ resp = self.session.post(
244
+ f"{self.backend_url}/feedback",
245
+ json=payload,
246
+ timeout=self.timeout
247
+ )
248
+ resp.raise_for_status()
249
+ except RequestException as e:
250
+ self._raise_request_error("Submit feedback request", e)
251
+
252
+ return cast(dict[str, Any], resp.json())
253
+
254
+ def get_models(self) -> dict[str, Any]:
255
+ """
256
+ Get list of available models from the backend.
257
+
258
+ Returns:
259
+ Response dictionary with list of models
260
+
261
+ Raises:
262
+ RuntimeError: If request fails
263
+ """
264
+ try:
265
+ resp = self.session.get(
266
+ f"{self.backend_url}/models",
267
+ timeout=self.timeout
268
+ )
269
+ resp.raise_for_status()
270
+ except RequestException as e:
271
+ self._raise_request_error("Get models request", e)
272
+
273
+ return cast(dict[str, Any], resp.json())
274
+
275
+ def get_usage_guide(self) -> dict[str, Any]:
276
+ """
277
+ Get usage guide from the backend.
278
+
279
+ Returns:
280
+ Response dictionary with usage guide content
281
+
282
+ Raises:
283
+ RuntimeError: If request fails
284
+ """
285
+ try:
286
+ resp = self.session.get(
287
+ f"{self.backend_url}/usage",
288
+ timeout=self.timeout
289
+ )
290
+ resp.raise_for_status()
291
+ except RequestException as e:
292
+ self._raise_request_error("Get usage guide request", e)
293
+
294
+ return cast(dict[str, Any], resp.json())
295
+
296
+ def close(self) -> None:
297
+ """Close the HTTP session."""
298
+ self.session.close()