bigwolfe commited on
Commit
b86420d
·
1 Parent(s): 9ec186d
backend/src/services/rag_index.py CHANGED
@@ -58,132 +58,128 @@ class RAGIndexService:
58
  self._setup_gemini()
59
  self._initialized = True
60
 
61
- def _setup_gemini(self):
62
- """Configure global LlamaIndex settings for Gemini."""
63
- if not Gemini or not GeminiEmbedding:
64
- logger.error("Google GenAI modules not loaded. RAG setup skipped.")
65
- return
66
 
67
- api_key = self.config.google_api_key
68
- if not api_key:
69
- logger.warning("GOOGLE_API_KEY not set. RAG features will fail.")
70
- return
71
-
72
- # Log key status (masked)
73
- masked_key = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "***"
74
- logger.info(f"Configuring Gemini with API key: {masked_key}")
75
-
76
- # Set up Gemini
77
- try:
78
- # Configure global settings
79
- Settings.llm = Gemini(
80
- model="gemini-2.0-flash",
81
- api_key=self.config.google_api_key
82
- )
83
- Settings.embed_model = GeminiEmbedding(
84
- model_name="models/text-embedding-004",
85
- api_key=self.config.google_api_key
86
- )
87
- except Exception as e:
88
- logger.error(f"Failed to setup Gemini: {e}")
89
 
90
  def get_persist_dir(self, user_id: str) -> str:
91
- """Get persistence directory for a user's index."""
92
- user_dir = self.config.llamaindex_persist_dir / user_id
93
- user_dir.mkdir(parents=True, exist_ok=True)
94
- return str(user_dir)
95
 
96
  def get_or_build_index(self, user_id: str) -> VectorStoreIndex:
97
- """Load existing index or build a new one from vault notes."""
98
- with self._index_lock:
99
- persist_dir = self.get_persist_dir(user_id)
100
-
101
- # check if index files exist (docstore.json, index_store.json etc)
102
- try:
103
- storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
104
- index = load_index_from_storage(storage_context)
105
- logger.info(f"Loaded existing index for user {user_id}")
106
- return index
107
- except Exception:
108
- logger.info(f"No valid index found for {user_id}, building new one...")
109
- return self.build_index(user_id)
110
 
111
  def build_index(self, user_id: str) -> VectorStoreIndex:
112
- """Build a new index from the user's vault."""
113
- if not self.config.google_api_key:
114
- raise ValueError("GOOGLE_API_KEY required to build index")
115
-
116
- # Read notes from VaultService
117
- notes = self.vault_service.list_notes(user_id)
118
- if not notes:
119
- # Handle empty vault (Fix #8)
120
- logger.info(f"No notes found for {user_id}, creating empty index")
121
- index = VectorStoreIndex.from_documents([])
122
- # Persist empty index to avoid rebuilding every time?
123
- # LlamaIndex might not persist empty index well.
124
- # Let's just return it.
125
- return index
126
-
127
- documents = []
128
-
129
- for note_summary in notes:
130
- path = note_summary["path"]
 
 
 
 
 
 
 
 
131
  try:
132
- note = self.vault_service.read_note(user_id, path)
133
- # Create Document
134
- metadata = {
135
- "path": path,
136
- "title": note["title"],
137
- **note.get("metadata", {})
138
- }
139
- doc = Document(
140
- text=note["body"],
141
- metadata=metadata,
142
- id_=path # Use path as ID for stability
143
  )
144
- documents.append(doc)
 
 
 
 
145
  except Exception as e:
146
- logger.warning(f"Failed to index note {path}: {e}")
147
 
148
- logger.info(f"Indexing {len(documents)} documents for {user_id}")
149
-
150
- index = VectorStoreIndex.from_documents(documents)
151
-
152
- # Persist
153
- persist_dir = self.get_persist_dir(user_id)
154
- index.storage_context.persist(persist_dir=persist_dir)
155
- logger.info(f"Persisted index to {persist_dir}")
156
-
157
- return index
158
 
159
- def rebuild_index(self, user_id: str) -> VectorStoreIndex:
160
- """Force rebuild of index."""
161
- return self.build_index(user_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
- def get_status(self, user_id: str) -> StatusResponse:
164
- """Get index status."""
165
- persist_dir = self.get_persist_dir(user_id)
166
- doc_store_path = os.path.join(persist_dir, "docstore.json")
167
-
168
- doc_count = 0
169
- status = "building"
170
-
171
- if os.path.exists(doc_store_path):
172
- status = "ready"
 
 
 
 
173
  try:
174
- # Simple line count or file size check to avoid loading whole JSON
175
- # Actually, docstore.json is a dict.
176
- # Let's just load it if it's small, or stat it.
177
- # For MVP, just checking existence is "ready".
178
- # To get count, we can try loading keys.
179
- import json
180
- with open(doc_store_path, 'r') as f:
181
- data = json.load(f)
182
- doc_count = len(data.get("docstore/data", {}))
183
- except Exception:
184
- logger.warning(f"Failed to read docstore for status: {doc_store_path}")
185
-
186
- return StatusResponse(status=status, doc_count=doc_count, last_updated=None)
 
187
 
188
  async def chat(self, user_id: str, messages: list[ChatMessage]) -> ChatResponse:
189
  """Run RAG chat query with history."""
@@ -207,35 +203,78 @@ class RAGIndexService:
207
  role = MessageRole.USER if m.role == "user" else MessageRole.ASSISTANT
208
  history.append(LlamaChatMessage(role=role, content=m.content))
209
 
210
- # Use chat engine with context mode
211
- chat_engine = index.as_chat_engine(
212
- chat_mode="context",
213
- system_prompt=(
214
- "You are a helpful assistant for a documentation vault. "
215
- "Answer questions based on the provided context. "
216
- "If the answer is not in the context, say you don't know. "
217
- "Always cite your sources."
 
 
 
 
 
218
  )
219
  )
220
 
221
- response = await chat_engine.achat(query_text, chat_history=history)
 
 
 
 
 
 
 
 
 
 
 
222
 
223
  return self._format_response(response)
224
 
225
  def _format_response(self, response: LlamaResponse) -> ChatResponse:
226
  """Convert LlamaIndex response to ChatResponse."""
227
  sources = []
228
- for node_with_score in response.source_nodes:
229
- node = node_with_score.node
230
- metadata = node.metadata
231
- sources.append(SourceReference(
232
- path=metadata.get("path", "unknown"),
233
- title=metadata.get("title", "Untitled"),
234
- snippet=node.get_content()[:500], # Truncate snippet
235
- score=node_with_score.score
236
- ))
237
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  return ChatResponse(
239
  answer=str(response),
240
- sources=sources
 
241
  )
 
58
  self._setup_gemini()
59
  self._initialized = True
60
 
61
+ Document,
62
+ Settings
63
+ )
64
+ from llama_index.core.tools import FunctionTool
 
65
 
66
+ # Try to import Gemini, handle missing dependency gracefully
67
+ # ...
68
+
69
+ # ...
70
+
71
+ def _setup_gemini(self):
72
+ # ... (existing)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  def get_persist_dir(self, user_id: str) -> str:
75
+ # ... (existing)
 
 
 
76
 
77
  def get_or_build_index(self, user_id: str) -> VectorStoreIndex:
78
+ # ... (existing)
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  def build_index(self, user_id: str) -> VectorStoreIndex:
81
+ # ... (existing)
82
+
83
+ def rebuild_index(self, user_id: str) -> VectorStoreIndex:
84
+ # ... (existing)
85
+
86
+ def get_status(self, user_id: str) -> StatusResponse:
87
+ # ... (existing)
88
+
89
+ def _create_note_tool(self, user_id: str):
90
+ """Create a tool for writing new notes."""
91
+ def create_note(title: str, content: str, folder: str = "agent-notes") -> str:
92
+ """
93
+ Create a new Markdown note in the vault.
94
+
95
+ Args:
96
+ title: The title of the note.
97
+ content: The markdown content of the note.
98
+ folder: The folder to place the note in (default: agent-notes).
99
+ """
100
+ # Sanitize folder path to prevent escaping agent-notes (simple check)
101
+ # Actually, spec says "constrained to agent-notes/".
102
+ # But user might want to organize within agent-notes/.
103
+ safe_folder = folder if folder.startswith("agent-notes") else f"agent-notes/{folder}"
104
+ safe_folder = safe_folder.strip("/")
105
+
106
+ path = f"{safe_folder}/{title}.md"
107
+
108
  try:
109
+ self.vault_service.write_note(
110
+ user_id,
111
+ path,
112
+ title=title,
113
+ body=content,
114
+ metadata={"created_by": "gemini-agent"}
 
 
 
 
 
115
  )
116
+ # Index the new note immediately so agent knows about it?
117
+ # write_note does NOT auto-update the RAG index (it updates FTS5).
118
+ # We might need to add it to the index.
119
+ # For now, just return success.
120
+ return f"Note created successfully at {path}"
121
  except Exception as e:
122
+ return f"Failed to create note: {e}"
123
 
124
+ return FunctionTool.from_defaults(fn=create_note)
 
 
 
 
 
 
 
 
 
125
 
126
+ def _move_note_tool(self, user_id: str):
127
+ """Create a tool for moving notes."""
128
+ def move_note(path: str, target_folder: str) -> str:
129
+ """
130
+ Move an existing note to a new folder.
131
+
132
+ Args:
133
+ path: The current path of the note (e.g. "agent-notes/My Note.md").
134
+ target_folder: The destination folder (e.g. "agent-notes/archive").
135
+ """
136
+ # Constraint: Can only move notes created by agent (in agent-notes/)?
137
+ # Or allow moving anywhere? Spec said "not deleting or editing existing".
138
+ # Moving is technically deleting + creating.
139
+ # Let's restrict source to agent-notes/ to be safe?
140
+ # Or just allow it. "We need one for moving notes into folder".
141
+
142
+ if not path.endswith(".md"):
143
+ path += ".md"
144
+
145
+ filename = os.path.basename(path)
146
+ new_path = f"{target_folder}/{filename}"
147
+
148
+ try:
149
+ self.vault_service.move_note(user_id, path, new_path)
150
+ return f"Note moved from {path} to {new_path}"
151
+ except Exception as e:
152
+ return f"Failed to move note: {e}"
153
 
154
+ return FunctionTool.from_defaults(fn=move_note)
155
+
156
+ def _create_folder_tool(self, user_id: str):
157
+ """Create a tool for creating new folders."""
158
+ def create_folder(folder: str) -> str:
159
+ """
160
+ Create a new folder in the vault.
161
+
162
+ Args:
163
+ folder: The path of the folder to create (e.g. "agent-notes/archive").
164
+ """
165
+ # Sanitize path?
166
+ safe_folder = folder.strip("/")
167
+
168
  try:
169
+ # Create a placeholder to ensure directory exists
170
+ placeholder = f"{safe_folder}/.placeholder.md"
171
+ self.vault_service.write_note(
172
+ user_id,
173
+ placeholder,
174
+ title="Folder Placeholder",
175
+ body="# Folder\nCreated by agent.",
176
+ metadata={"created_by": "gemini-agent"}
177
+ )
178
+ return f"Folder created successfully at {safe_folder}"
179
+ except Exception as e:
180
+ return f"Failed to create folder: {e}"
181
+
182
+ return FunctionTool.from_defaults(fn=create_folder)
183
 
184
  async def chat(self, user_id: str, messages: list[ChatMessage]) -> ChatResponse:
185
  """Run RAG chat query with history."""
 
203
  role = MessageRole.USER if m.role == "user" else MessageRole.ASSISTANT
204
  history.append(LlamaChatMessage(role=role, content=m.content))
205
 
206
+ tools = [
207
+ self._create_note_tool(user_id),
208
+ self._move_note_tool(user_id),
209
+ self._create_folder_tool(user_id)
210
+ ]
211
+
212
+ from llama_index.core.tools import QueryEngineTool, ToolMetadata
213
+
214
+ query_tool = QueryEngineTool(
215
+ query_engine=index.as_query_engine(),
216
+ metadata=ToolMetadata(
217
+ name="vault_search",
218
+ description="Search information in the documentation vault."
219
  )
220
  )
221
 
222
+ all_tools = tools + [query_tool]
223
+
224
+ from llama_index.core.agent import ReActAgent
225
+ agent = ReActAgent.from_tools(
226
+ all_tools,
227
+ llm=Settings.llm,
228
+ chat_history=history,
229
+ verbose=True,
230
+ context="You are a documentation assistant. Use vault_search to find info. You can create notes and folders."
231
+ )
232
+
233
+ response = await agent.achat(query_text)
234
 
235
  return self._format_response(response)
236
 
237
  def _format_response(self, response: LlamaResponse) -> ChatResponse:
238
  """Convert LlamaIndex response to ChatResponse."""
239
  sources = []
240
+ notes_written = []
241
+
242
+ # Handle source nodes (RAG retrieval)
243
+ if hasattr(response, "source_nodes"):
244
+ for node_with_score in response.source_nodes:
245
+ node = node_with_score.node
246
+ metadata = node.metadata
247
+ sources.append(SourceReference(
248
+ path=metadata.get("path", "unknown"),
249
+ title=metadata.get("title", "Untitled"),
250
+ snippet=node.get_content()[:500], # Truncate snippet
251
+ score=node_with_score.score
252
+ ))
253
+
254
+ # Handle tool outputs (Agent actions)
255
+ if hasattr(response, "sources"):
256
+ for tool_output in response.sources:
257
+ if tool_output.tool_name == "create_note":
258
+ args = tool_output.raw_input
259
+ if args and "title" in args:
260
+ notes_written.append(NoteWritten(
261
+ path=f"agent-notes/{args['title']}.md",
262
+ title=args["title"],
263
+ action="created"
264
+ ))
265
+ elif tool_output.tool_name == "move_note":
266
+ args = tool_output.raw_input
267
+ if args and "path" in args:
268
+ notes_written.append(NoteWritten(
269
+ path=args.get("target_folder", "") + "/" + os.path.basename(args["path"]),
270
+ title=os.path.basename(args["path"]),
271
+ action="updated"
272
+ ))
273
+ elif tool_output.tool_name == "create_folder":
274
+ pass # No badge for folders yet
275
+
276
  return ChatResponse(
277
  answer=str(response),
278
+ sources=sources,
279
+ notes_written=notes_written
280
  )
frontend/src/components/ChatMessage.tsx CHANGED
@@ -1,6 +1,6 @@
1
- import type { ChatMessage as ChatMessageType } from '@/types/rag';
2
  import { cn } from '@/lib/utils';
3
- import { User, Bot } from 'lucide-react';
4
  import { SourceList } from './SourceList';
5
 
6
  interface ChatMessageProps {
@@ -25,6 +25,23 @@ export function ChatMessage({ message, onSourceClick }: ChatMessageProps) {
25
  {message.content}
26
  </div>
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  {!isUser && message.sources && (
29
  <SourceList sources={message.sources} onSourceClick={onSourceClick} />
30
  )}
 
1
+ import type { ChatMessage as ChatMessageType, NoteWritten } from '@/types/rag';
2
  import { cn } from '@/lib/utils';
3
+ import { User, Bot, FilePlus, Edit } from 'lucide-react';
4
  import { SourceList } from './SourceList';
5
 
6
  interface ChatMessageProps {
 
25
  {message.content}
26
  </div>
27
 
28
+ {!isUser && message.notes_written && message.notes_written.length > 0 && (
29
+ <div className="flex flex-wrap gap-2 mt-2">
30
+ {message.notes_written.map((note, i) => (
31
+ <button
32
+ key={i}
33
+ onClick={() => onSourceClick(note.path)}
34
+ className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20 hover:bg-green-500/20 text-xs transition-colors"
35
+ >
36
+ {note.action === 'created' ? <FilePlus className="h-3 w-3" /> : <Edit className="h-3 w-3" />}
37
+ <span className="font-medium">
38
+ {note.action === 'created' ? 'Created' : 'Updated'}: {note.title}
39
+ </span>
40
+ </button>
41
+ ))}
42
+ </div>
43
+ )}
44
+
45
  {!isUser && message.sources && (
46
  <SourceList sources={message.sources} onSourceClick={onSourceClick} />
47
  )}