Spaces:
Running
Running
bigwolfe
commited on
Commit
·
b86420d
1
Parent(s):
9ec186d
tools
Browse files- backend/src/services/rag_index.py +171 -132
- frontend/src/components/ChatMessage.tsx +19 -2
backend/src/services/rag_index.py
CHANGED
|
@@ -58,132 +58,128 @@ class RAGIndexService:
|
|
| 58 |
self._setup_gemini()
|
| 59 |
self._initialized = True
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
return
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
try:
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
}
|
| 139 |
-
doc = Document(
|
| 140 |
-
text=note["body"],
|
| 141 |
-
metadata=metadata,
|
| 142 |
-
id_=path # Use path as ID for stability
|
| 143 |
)
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
except Exception as e:
|
| 146 |
-
|
| 147 |
|
| 148 |
-
|
| 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
|
| 160 |
-
"""
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
try:
|
| 174 |
-
#
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
)
|
| 219 |
)
|
| 220 |
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 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 |
)}
|