devops-copilot 0.1.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.
@@ -0,0 +1,362 @@
1
+ import jwt
2
+ from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect, Query
3
+ from typing import List
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlmodel import select
6
+ from langchain_core.messages import HumanMessage, AIMessage
7
+
8
+ from app.core.config import settings
9
+ from app.core.database import get_session, async_session_maker
10
+ from app.modules.auth import User
11
+ from app.modules.auth.service import get_current_user
12
+ from app.modules.servers.models import Server
13
+ from app.modules.servers.service import servers_service
14
+ from app.modules.chat.models import ChatSession, ChatMessage, AgentAction
15
+ from app.modules.chat.schema import (
16
+ ChatSessionCreate,
17
+ ChatSessionResponse,
18
+ ChatMessageResponse,
19
+ AgentActionResponse
20
+ )
21
+ from app.modules.chat.agent import (
22
+ create_agent_executor,
23
+ StreamingCallbackHandler,
24
+ active_websocket,
25
+ active_session_id,
26
+ active_owner_id,
27
+ decrypt_data,
28
+ asyncssh
29
+ )
30
+
31
+ router = APIRouter()
32
+
33
+ # --- REST Endpoints for Chat History ---
34
+
35
+ @router.post("/sessions", response_model=ChatSessionResponse, status_code=status.HTTP_201_CREATED)
36
+ async def create_session(
37
+ payload: ChatSessionCreate,
38
+ current_user: User = Depends(get_current_user),
39
+ session: AsyncSession = Depends(get_session)
40
+ ):
41
+ """Create a new chat session thread for the logged-in administrator."""
42
+ new_session = ChatSession(title=payload.title, user_id=current_user.id)
43
+ session.add(new_session)
44
+ await session.commit()
45
+ await session.refresh(new_session)
46
+ return new_session
47
+
48
+ @router.get("/sessions", response_model=List[ChatSessionResponse])
49
+ async def list_sessions(
50
+ current_user: User = Depends(get_current_user),
51
+ session: AsyncSession = Depends(get_session)
52
+ ):
53
+ """Retrieve all chat sessions belonging to the current administrator."""
54
+ statement = select(ChatSession).where(ChatSession.user_id == current_user.id).order_by(ChatSession.created_at.desc())
55
+ result = await session.exec(statement)
56
+ return result.all()
57
+
58
+ @router.get("/sessions/{session_id}/messages", response_model=List[ChatMessageResponse])
59
+ async def get_messages(
60
+ session_id: int,
61
+ current_user: User = Depends(get_current_user),
62
+ session: AsyncSession = Depends(get_session)
63
+ ):
64
+ """Retrieve historical messages for a specific chat session thread."""
65
+ # Verify session ownership
66
+ sess_statement = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == current_user.id)
67
+ sess_result = await session.exec(sess_statement)
68
+ if not sess_result.first():
69
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found")
70
+
71
+ msg_statement = select(ChatMessage).where(ChatMessage.session_id == session_id).order_by(ChatMessage.created_at.asc())
72
+ msg_result = await session.exec(msg_statement)
73
+ return msg_result.all()
74
+
75
+ @router.delete("/sessions/{session_id}")
76
+ async def delete_session(
77
+ session_id: int,
78
+ current_user: User = Depends(get_current_user),
79
+ session: AsyncSession = Depends(get_session)
80
+ ):
81
+ """Delete a chat session and all its associated messages and actions."""
82
+ # Verify session ownership
83
+ sess_statement = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == current_user.id)
84
+ sess_result = await session.exec(sess_statement)
85
+ chat_session = sess_result.first()
86
+ if not chat_session:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_404_NOT_FOUND,
89
+ detail="Chat session not found"
90
+ )
91
+
92
+ # Delete messages
93
+ msg_statement = select(ChatMessage).where(ChatMessage.session_id == session_id)
94
+ msg_result = await session.exec(msg_statement)
95
+ for msg in msg_result.all():
96
+ await session.delete(msg)
97
+
98
+ # Delete actions
99
+ act_statement = select(AgentAction).where(AgentAction.session_id == session_id)
100
+ act_result = await session.exec(act_statement)
101
+ for act in act_result.all():
102
+ await session.delete(act)
103
+
104
+ await session.delete(chat_session)
105
+ await session.commit()
106
+ return {"status": "success", "message": f"Chat session {session_id} deleted successfully"}
107
+
108
+ # --- WebSocket Real-Time Chat & Execution Tunnel ---
109
+
110
+ @router.websocket("/ws")
111
+ async def websocket_endpoint(
112
+ websocket: WebSocket,
113
+ token: str = Query(...)
114
+ ):
115
+ """
116
+ Establish a WebSocket connection for real-time DevOps chat and live execution logging.
117
+ Validates the admin's JWT token query parameter prior to connection acceptance.
118
+ """
119
+ # 1. Manual JWT Token Verification for Handshake
120
+ try:
121
+ payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
122
+ username = payload.get("sub")
123
+ if not username:
124
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
125
+ return
126
+
127
+ async with async_session_maker() as session:
128
+ statement = select(User).where(User.username == username)
129
+ result = await session.exec(statement)
130
+ user = result.first()
131
+ if not user or not user.is_active:
132
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
133
+ return
134
+ except Exception:
135
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
136
+ return
137
+
138
+ # Accept client WebSocket connection
139
+ await websocket.accept()
140
+
141
+ try:
142
+ while True:
143
+ # Receive inbound JSON payloads
144
+ data = await websocket.receive_json()
145
+ msg_type = data.get("type")
146
+
147
+ if msg_type == "message":
148
+ session_id = data.get("session_id")
149
+ content = data.get("content")
150
+
151
+ if not session_id or not content:
152
+ continue
153
+
154
+ # 2. Load conversation history for memory injection
155
+ async with async_session_maker() as db_session:
156
+ # Verify session ownership
157
+ sess_stmt = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user.id)
158
+ sess = await db_session.exec(sess_stmt)
159
+ if not sess.first():
160
+ await websocket.send_json({"type": "error", "data": "Unauthorized session access"})
161
+ continue
162
+
163
+ # Record user message in DB
164
+ user_msg = ChatMessage(session_id=session_id, sender="user", content=content)
165
+ db_session.add(user_msg)
166
+ await db_session.commit()
167
+
168
+ # Fetch history
169
+ history_stmt = select(ChatMessage).where(ChatMessage.session_id == session_id).order_by(ChatMessage.created_at.asc())
170
+ history_result = await db_session.exec(history_stmt)
171
+ db_messages = history_result.all()
172
+
173
+ chat_history = []
174
+ # Map history database records to LangChain Human/AI message formats
175
+ for msg in db_messages[:-1]: # Exclude current message since it is input
176
+ if msg.sender == "user":
177
+ chat_history.append(HumanMessage(content=msg.content))
178
+ else:
179
+ chat_history.append(AIMessage(content=msg.content))
180
+
181
+ # 3. Setup context variables for the SSH execution tools
182
+ ws_token = active_websocket.set(websocket)
183
+ sess_token = active_session_id.set(session_id)
184
+ owner_token = active_owner_id.set(user.id)
185
+
186
+ try:
187
+ # Stream tokens via custom callback handler
188
+ callback = StreamingCallbackHandler(websocket)
189
+ agent_executor = create_agent_executor(callbacks=[callback])
190
+
191
+ # Invoke agent compiled StateGraph using messages schema
192
+ inputs = {
193
+ "messages": chat_history + [HumanMessage(content=content)]
194
+ }
195
+ agent_response = await agent_executor.ainvoke(inputs)
196
+
197
+ # Extract final text from output message list
198
+ ai_text = agent_response["messages"][-1].content
199
+
200
+ # Record final agent response in database
201
+ async with async_session_maker() as db_session:
202
+ ai_msg = ChatMessage(session_id=session_id, sender="ai", content=ai_text)
203
+ db_session.add(ai_msg)
204
+ await db_session.commit()
205
+
206
+ # Notify client execution complete
207
+ await websocket.send_json({
208
+ "type": "finished",
209
+ "data": ai_text
210
+ })
211
+
212
+ except Exception as e:
213
+ await websocket.send_json({"type": "error", "data": f"Agent Execution Error: {str(e)}"})
214
+ finally:
215
+ # Clean context variables
216
+ active_websocket.reset(ws_token)
217
+ active_session_id.reset(sess_token)
218
+ active_owner_id.reset(owner_token)
219
+
220
+ elif msg_type == "approve" or msg_type == "reject":
221
+ action_id = data.get("action_id")
222
+ if not action_id:
223
+ continue
224
+
225
+ async with async_session_maker() as db_session:
226
+ # Fetch queued action
227
+ act_stmt = select(AgentAction).where(AgentAction.id == action_id)
228
+ act_result = await db_session.exec(act_stmt)
229
+ action = act_result.first()
230
+
231
+ if not action or action.status != "pending":
232
+ await websocket.send_json({"type": "error", "data": "Action not found or already processed"})
233
+ continue
234
+
235
+ # Verify session ownership
236
+ sess_stmt = select(ChatSession).where(ChatSession.id == action.session_id, ChatSession.user_id == user.id)
237
+ sess = await db_session.exec(sess_stmt)
238
+ if not sess.first():
239
+ await websocket.send_json({"type": "error", "data": "Unauthorized session access"})
240
+ continue
241
+
242
+ if msg_type == "reject":
243
+ action.status = "rejected"
244
+ await db_session.commit()
245
+ await websocket.send_json({
246
+ "type": "stdout",
247
+ "data": f"\n[Action {action_id} Rejected by User]\n"
248
+ })
249
+ continue
250
+
251
+ # If approved, update status
252
+ action.status = "approved"
253
+ await db_session.commit()
254
+
255
+ # Load targeted server config
256
+ server_stmt = select(Server).where(Server.id == action.server_id)
257
+ srv_result = await db_session.exec(server_stmt)
258
+ server = srv_result.first()
259
+
260
+ await websocket.send_json({
261
+ "type": "stdout",
262
+ "data": f"\n[Action {action_id} Approved] Running command on {server.name}...\n"
263
+ })
264
+
265
+ # 4. Decrypt and execute approved write command
266
+ credential = decrypt_data(server.encrypted_credential)
267
+ conn_args = {
268
+ "host": server.ip_address,
269
+ "port": server.ssh_port,
270
+ "username": server.ssh_username,
271
+ "known_hosts": None
272
+ }
273
+ if server.ssh_auth_type == "password":
274
+ conn_args["password"] = credential
275
+ else:
276
+ conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
277
+
278
+ stdout_accumulated = []
279
+ stderr_accumulated = []
280
+ exit_status = -1
281
+
282
+ try:
283
+ # Connect and execute process, streaming stdout/stderr line-by-line
284
+ async with asyncssh.connect(**conn_args) as conn:
285
+ async with conn.create_process(action.command) as process:
286
+ async for line in process.stdout:
287
+ if isinstance(line, bytes):
288
+ line = line.decode("utf-8", errors="replace")
289
+ stdout_accumulated.append(line)
290
+ await websocket.send_json({"type": "stdout", "data": line})
291
+ async for line in process.stderr:
292
+ if isinstance(line, bytes):
293
+ line = line.decode("utf-8", errors="replace")
294
+ stderr_accumulated.append(line)
295
+ await websocket.send_json({"type": "stderr", "data": line})
296
+ exit_status = await process.wait()
297
+ exit_code = exit_status.exit_status if exit_status.exit_status is not None else 0
298
+
299
+ await websocket.send_json({
300
+ "type": "stdout",
301
+ "data": f"[Process finished with code {exit_code}]\n"
302
+ })
303
+
304
+ stdout_str = "".join(stdout_accumulated)
305
+ stderr_str = "".join(stderr_accumulated)
306
+
307
+ # Auto-index approved command output into RAG knowledge base
308
+ try:
309
+ from app.modules.knowledge.service import index_command_output
310
+ index_command_output(server.name, action.command, stdout_str, stderr_str, exit_code)
311
+ except Exception:
312
+ pass
313
+
314
+ # 5. Invoke agent loop with the execution output
315
+ ws_token = active_websocket.set(websocket)
316
+ sess_token = active_session_id.set(action.session_id)
317
+ owner_token = active_owner_id.set(user.id)
318
+
319
+ try:
320
+ callback = StreamingCallbackHandler(websocket)
321
+ agent_executor = create_agent_executor(callbacks=[callback])
322
+
323
+ # Feed output back to agent reasoning chain
324
+ resume_prompt = (
325
+ f"User approved execution of action '{action_id}'.\n"
326
+ f"Command '{action.command}' executed on server '{server.name}'.\n"
327
+ f"Exit Code: {exit_code}\n"
328
+ f"Stdout:\n{stdout_str}\n"
329
+ f"Stderr:\n{stderr_str}\n\n"
330
+ f"Please analyze these outputs and provide your final response to the user."
331
+ )
332
+
333
+ # Invoke the agent graph with the resume message
334
+ agent_response = await agent_executor.ainvoke({
335
+ "messages": [HumanMessage(content=resume_prompt)]
336
+ })
337
+
338
+ ai_text = agent_response["messages"][-1].content
339
+
340
+ # Save final summary response in database
341
+ async with async_session_maker() as db_session:
342
+ ai_msg = ChatMessage(session_id=action.session_id, sender="ai", content=ai_text)
343
+ db_session.add(ai_msg)
344
+ await db_session.commit()
345
+
346
+ await websocket.send_json({
347
+ "type": "finished",
348
+ "data": ai_text
349
+ })
350
+
351
+ except Exception as e:
352
+ await websocket.send_json({"type": "error", "data": f"Error during agent completion: {str(e)}"})
353
+ finally:
354
+ active_websocket.reset(ws_token)
355
+ active_session_id.reset(sess_token)
356
+ active_owner_id.reset(owner_token)
357
+
358
+ except Exception as e:
359
+ await websocket.send_json({"type": "error", "data": f"SSH Execution Failed: {str(e)}"})
360
+
361
+ except WebSocketDisconnect:
362
+ pass
@@ -0,0 +1,39 @@
1
+ from datetime import datetime
2
+ from pydantic import BaseModel, Field
3
+
4
+ class ChatSessionCreate(BaseModel):
5
+ """Payload to instantiate a new chat session."""
6
+ title: str = Field(..., min_length=1, max_length=100)
7
+
8
+ class ChatSessionResponse(BaseModel):
9
+ """Metadata response representing a chat session."""
10
+ id: int
11
+ title: str
12
+ user_id: int
13
+ created_at: datetime
14
+
15
+ class Config:
16
+ from_attributes = True
17
+
18
+ class ChatMessageResponse(BaseModel):
19
+ """Metadata response representing a single message in a session thread."""
20
+ id: int
21
+ session_id: int
22
+ sender: str
23
+ content: str
24
+ created_at: datetime
25
+
26
+ class Config:
27
+ from_attributes = True
28
+
29
+ class AgentActionResponse(BaseModel):
30
+ """Metadata response representing a queued server command approval."""
31
+ id: str
32
+ session_id: int
33
+ server_id: int
34
+ command: str
35
+ status: str
36
+ created_at: datetime
37
+
38
+ class Config:
39
+ from_attributes = True
@@ -0,0 +1 @@
1
+ # This makes the rag folder a Python package
@@ -0,0 +1,22 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+ class SecurityCheckRequest(BaseModel):
4
+ """Input payload to check a proposed bash command for security threats."""
5
+ command: str = Field(..., min_length=1)
6
+
7
+ class SecurityCheckResponse(BaseModel):
8
+ """Result of the semantic safety check against the dangerous commands database."""
9
+ safe: bool
10
+ reason: str
11
+ similarity: float
12
+
13
+ class BlacklistPatternCreate(BaseModel):
14
+ """Payload to register a new forbidden pattern inside the semantic guardrail database."""
15
+ pattern: str = Field(..., min_length=3)
16
+ reason: str = Field(..., min_length=3)
17
+
18
+ class BlacklistPatternResponse(BaseModel):
19
+ """Details of a registered dangerous command pattern in the vector store."""
20
+ id: str
21
+ pattern: str
22
+ reason: str
@@ -0,0 +1,121 @@
1
+ import uuid
2
+ import chromadb
3
+ from typing import List, Tuple
4
+
5
+ # Initialize ChromaDB persistent client pointing to data volume
6
+ # Will resolve to /app/data/chroma_db inside Docker containers
7
+ client = chromadb.PersistentClient(path="data/chroma_db")
8
+
9
+ # Fetch or create the security guardrail collection
10
+ collection = client.get_or_create_collection(name="security_blacklist")
11
+
12
+ async def init_and_seed_db():
13
+ """
14
+ Checks if the security blacklist collection has documents.
15
+ If empty, populates it with default dangerous command templates and reasons.
16
+ """
17
+ if collection.count() == 0:
18
+ default_seeds = [
19
+ {
20
+ "pattern": "rm -rf /",
21
+ "reason": "Recursive deletion of the root directory. Results in absolute OS loss."
22
+ },
23
+ {
24
+ "pattern": "rm -rf /etc",
25
+ "reason": "Recursive deletion of configuration directories. Breaks system configuration."
26
+ },
27
+ {
28
+ "pattern": "mkfs.ext4 /dev/sda",
29
+ "reason": "Formatting raw storage drive. Destroys filesystem structure and partition data."
30
+ },
31
+ {
32
+ "pattern": "dd if=/dev/zero of=/dev/sda",
33
+ "reason": "Zero-writing raw block device. Wipes partition tables and master boot records."
34
+ },
35
+ {
36
+ "pattern": "iptables -F",
37
+ "reason": "Flushing active firewall rules. Exposes the server to external connection attempts."
38
+ },
39
+ {
40
+ "pattern": "cat /etc/shadow",
41
+ "reason": "Exposing system shadow user file. Leaks hashed credentials database."
42
+ },
43
+ {
44
+ "pattern": "chmod 777 -R /",
45
+ "reason": "Recursive permissive access modifier. Allows unprivileged write and execution globally."
46
+ },
47
+ {
48
+ "pattern": "reboot",
49
+ "reason": "Forces hard system restart. Terminates running containers and processes instantly."
50
+ },
51
+ {
52
+ "pattern": "shutdown -h now",
53
+ "reason": "Forces host system shutdown. Takes the server offline."
54
+ }
55
+ ]
56
+
57
+ ids = [f"seed_{i}" for i in range(len(default_seeds))]
58
+ documents = [item["pattern"] for item in default_seeds]
59
+ metadatas = [{"reason": item["reason"]} for item in default_seeds]
60
+
61
+ # Sync add to collection
62
+ collection.add(
63
+ ids=ids,
64
+ documents=documents,
65
+ metadatas=metadatas
66
+ )
67
+
68
+ def is_command_safe(command: str) -> Tuple[bool, str, float]:
69
+ """
70
+ Performs semantic similarity check of proposed shell command against blacklist.
71
+ Returns (is_safe, reason, similarity_score).
72
+ """
73
+ # Fetch closest match (n_results=1)
74
+ results = collection.query(
75
+ query_texts=[command],
76
+ n_results=1
77
+ )
78
+
79
+ if results and results["documents"] and len(results["documents"][0]) > 0:
80
+ matched_doc = results["documents"][0][0]
81
+ distance = results["distances"][0][0] # L2 distance metric
82
+ metadata = results["metadatas"][0][0]
83
+
84
+ # Convert L2 distance to Cosine Similarity score: 1.0 - (L2 / 2.0)
85
+ # ChromaDB ONNX embeddings are L2-normalized, making this conversion mathematically valid.
86
+ similarity = 1.0 - (distance / 2.0)
87
+
88
+ # Flag commands with a similarity index higher than 0.75 (L2 distance < 0.5)
89
+ if similarity > 0.75:
90
+ return (
91
+ False,
92
+ f"Blocked: Command matches dangerous pattern '{matched_doc}'. Reason: {metadata['reason']}",
93
+ similarity
94
+ )
95
+
96
+ return True, "No critical security threats detected.", similarity
97
+
98
+ return True, "Guardrail threat database is empty.", 0.0
99
+
100
+ def add_blacklist_pattern(pattern: str, reason: str) -> dict:
101
+ """Adds a new custom forbidden pattern to the ChromaDB vector database."""
102
+ pattern_id = f"custom_{uuid.uuid4().hex}"
103
+ collection.add(
104
+ ids=[pattern_id],
105
+ documents=[pattern],
106
+ metadatas=[{"reason": reason}]
107
+ )
108
+ return {"id": pattern_id, "pattern": pattern, "reason": reason}
109
+
110
+ def get_all_blacklist_patterns() -> List[dict]:
111
+ """Retrieves all registered dangerous patterns inside the RAG collection."""
112
+ results = collection.get()
113
+ patterns = []
114
+ if results and "ids" in results:
115
+ for i in range(len(results["ids"])):
116
+ patterns.append({
117
+ "id": results["ids"][i],
118
+ "pattern": results["documents"][i],
119
+ "reason": results["metadatas"][i]["reason"]
120
+ })
121
+ return patterns
@@ -0,0 +1 @@
1
+ # This makes the knowledge folder a Python package
@@ -0,0 +1,123 @@
1
+ import uuid
2
+ import chromadb
3
+ from typing import List, Optional
4
+
5
+ # Shared ChromaDB persistent client (same data volume as guardrails)
6
+ client = chromadb.PersistentClient(path="data/chroma_db")
7
+
8
+ # --- 3 Separate Collections ---
9
+ command_history = client.get_or_create_collection(name="command_history")
10
+ server_logs = client.get_or_create_collection(name="server_logs")
11
+ server_configs = client.get_or_create_collection(name="server_configs")
12
+
13
+ CHUNK_SIZE = 500
14
+ CHUNK_OVERLAP = 50
15
+
16
+ def _chunk_text(text: str) -> List[str]:
17
+ """Split text into overlapping chunks for better retrieval."""
18
+ if len(text) <= CHUNK_SIZE:
19
+ return [text]
20
+ chunks = []
21
+ start = 0
22
+ while start < len(text):
23
+ end = start + CHUNK_SIZE
24
+ chunks.append(text[start:end])
25
+ start = end - CHUNK_OVERLAP
26
+ return chunks
27
+
28
+
29
+ def index_command_output(
30
+ server_name: str, command: str, stdout: str, stderr: str, exit_code: int
31
+ ):
32
+ """Auto-index SSH command execution results into command_history collection."""
33
+ doc = (
34
+ f"Server: {server_name}\n"
35
+ f"Command: {command}\n"
36
+ f"Exit Code: {exit_code}\n"
37
+ f"Stdout:\n{stdout}\n"
38
+ f"Stderr:\n{stderr}"
39
+ )
40
+ metadata = {
41
+ "server_name": server_name,
42
+ "command": command,
43
+ "exit_code": exit_code,
44
+ "source": "command_history",
45
+ }
46
+ chunks = _chunk_text(doc)
47
+ for i, chunk in enumerate(chunks):
48
+ command_history.add(
49
+ ids=[f"cmd_{uuid.uuid4().hex[:12]}"],
50
+ documents=[chunk],
51
+ metadatas=[{**metadata, "chunk_index": i}],
52
+ )
53
+
54
+
55
+ def index_log(server_name: str, log_source: str, content: str):
56
+ """Index server log output into server_logs collection."""
57
+ metadata = {
58
+ "server_name": server_name,
59
+ "log_source": log_source,
60
+ "source": "server_logs",
61
+ }
62
+ chunks = _chunk_text(content)
63
+ for i, chunk in enumerate(chunks):
64
+ server_logs.add(
65
+ ids=[f"log_{uuid.uuid4().hex[:12]}"],
66
+ documents=[chunk],
67
+ metadatas=[{**metadata, "chunk_index": i}],
68
+ )
69
+
70
+
71
+ def index_config(server_name: str, file_path: str, content: str):
72
+ """Index a server config file into server_configs collection."""
73
+ metadata = {
74
+ "server_name": server_name,
75
+ "file_path": file_path,
76
+ "source": "server_configs",
77
+ }
78
+ chunks = _chunk_text(content)
79
+ for i, chunk in enumerate(chunks):
80
+ server_configs.add(
81
+ ids=[f"cfg_{uuid.uuid4().hex[:12]}"],
82
+ documents=[chunk],
83
+ metadatas=[{**metadata, "chunk_index": i}],
84
+ )
85
+
86
+
87
+ # Map collection name -> collection object
88
+ _COLLECTIONS = {
89
+ "command_history": command_history,
90
+ "server_logs": server_logs,
91
+ "server_configs": server_configs,
92
+ }
93
+
94
+
95
+ def search(
96
+ query: str,
97
+ collection_names: Optional[List[str]] = None,
98
+ n_results: int = 3,
99
+ ) -> str:
100
+ """
101
+ Semantic search across one or more knowledge collections.
102
+ Returns formatted string of top results for LLM consumption.
103
+ """
104
+ targets = collection_names or list(_COLLECTIONS.keys())
105
+ all_results = []
106
+
107
+ for name in targets:
108
+ col = _COLLECTIONS.get(name)
109
+ if not col or col.count() == 0:
110
+ continue
111
+ results = col.query(query_texts=[query], n_results=min(n_results, col.count()))
112
+ if results and results["documents"]:
113
+ for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
114
+ all_results.append(
115
+ f"[{meta.get('source', name)}] "
116
+ f"Server: {meta.get('server_name', '?')} | "
117
+ f"{doc[:300]}"
118
+ )
119
+
120
+ if not all_results:
121
+ return "No relevant knowledge found in the database."
122
+
123
+ return "\n---\n".join(all_results)
@@ -0,0 +1 @@
1
+ from .models import Server