-
Notifications
You must be signed in to change notification settings - Fork 37
feat: add AI chatbot assistant with RAG support #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,86 @@ | ||||||||||||||||||||||||||||||||||||||||||
| import sqlite3 | ||||||||||||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| DB_DIR = Path(__file__).parent / "data" | ||||||||||||||||||||||||||||||||||||||||||
| DB_PATH = DB_DIR / "chat_logs.db" | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def init_db(): | ||||||||||||||||||||||||||||||||||||||||||
| """Initializes the SQLite database and creates chat_logs table if it doesn't exist.""" | ||||||||||||||||||||||||||||||||||||||||||
| DB_DIR.mkdir(parents=True, exist_ok=True) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| conn = sqlite3.connect(str(DB_PATH)) | ||||||||||||||||||||||||||||||||||||||||||
| cursor = conn.cursor() | ||||||||||||||||||||||||||||||||||||||||||
| cursor.execute(""" | ||||||||||||||||||||||||||||||||||||||||||
| CREATE TABLE IF NOT EXISTS chat_logs ( | ||||||||||||||||||||||||||||||||||||||||||
| id TEXT PRIMARY KEY, | ||||||||||||||||||||||||||||||||||||||||||
| question TEXT NOT NULL, | ||||||||||||||||||||||||||||||||||||||||||
| response TEXT NOT NULL, | ||||||||||||||||||||||||||||||||||||||||||
| current_page TEXT, | ||||||||||||||||||||||||||||||||||||||||||
| current_feature TEXT, | ||||||||||||||||||||||||||||||||||||||||||
| timestamp TEXT NOT NULL, | ||||||||||||||||||||||||||||||||||||||||||
| feedback TEXT | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| """) | ||||||||||||||||||||||||||||||||||||||||||
| conn.commit() | ||||||||||||||||||||||||||||||||||||||||||
| conn.close() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def log_chat_message( | ||||||||||||||||||||||||||||||||||||||||||
| msg_id: str, | ||||||||||||||||||||||||||||||||||||||||||
| question: str, | ||||||||||||||||||||||||||||||||||||||||||
| response: str, | ||||||||||||||||||||||||||||||||||||||||||
| current_page: str = None, | ||||||||||||||||||||||||||||||||||||||||||
| current_feature: str = None | ||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||
| """Logs a generated Q&A exchange to the database.""" | ||||||||||||||||||||||||||||||||||||||||||
| init_db() # Ensure DB and table are initialized | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| timestamp = datetime.now(timezone.utc).isoformat() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| conn = sqlite3.connect(str(DB_PATH)) | ||||||||||||||||||||||||||||||||||||||||||
| cursor = conn.cursor() | ||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||
| cursor.execute( | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| INSERT INTO chat_logs ( | ||||||||||||||||||||||||||||||||||||||||||
| id, question, response, current_page, current_feature, | ||||||||||||||||||||||||||||||||||||||||||
| timestamp, feedback | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| VALUES (?, ?, ?, ?, ?, ?, ?) | ||||||||||||||||||||||||||||||||||||||||||
| """, | ||||||||||||||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||||||||||||||
| msg_id, | ||||||||||||||||||||||||||||||||||||||||||
| question, | ||||||||||||||||||||||||||||||||||||||||||
| response, | ||||||||||||||||||||||||||||||||||||||||||
| current_page, | ||||||||||||||||||||||||||||||||||||||||||
| current_feature, | ||||||||||||||||||||||||||||||||||||||||||
| timestamp, | ||||||||||||||||||||||||||||||||||||||||||
| None, | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| conn.commit() | ||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||
| print(f"ChatLogger Error logging message: {e}") | ||||||||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||||||||
| conn.close() | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+62
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not swallow SQLite write errors. Both functions print exceptions and return normally, so API handlers can report success even when writes fail. 🛠️ Proposed fix- except Exception as e:
- print(f"ChatLogger Error logging message: {e}")
+ except sqlite3.Error as e:
+ conn.rollback()
+ raise RuntimeError("Failed to log chat message") from e
@@
- except Exception as e:
- print(f"ChatLogger Error updating feedback: {e}")
+ except sqlite3.Error as e:
+ conn.rollback()
+ raise RuntimeError("Failed to update chat feedback") from eAlso applies to: 83-86 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| def update_chat_feedback(msg_id: str, feedback: str): | ||||||||||||||||||||||||||||||||||||||||||
| """Updates feedback (e.g., 'up' or 'down') for a specific message ID.""" | ||||||||||||||||||||||||||||||||||||||||||
| init_db() # Ensure DB and table are initialized | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| conn = sqlite3.connect(str(DB_PATH)) | ||||||||||||||||||||||||||||||||||||||||||
| cursor = conn.cursor() | ||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||
| cursor.execute( | ||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||
| UPDATE chat_logs | ||||||||||||||||||||||||||||||||||||||||||
| SET feedback = ? | ||||||||||||||||||||||||||||||||||||||||||
| WHERE id = ? | ||||||||||||||||||||||||||||||||||||||||||
| """, | ||||||||||||||||||||||||||||||||||||||||||
| (feedback, msg_id) | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| conn.commit() | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+74
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate that feedback updates actually match a message ID.
✅ Proposed fix cursor.execute(
@@
(feedback, msg_id)
)
+ if cursor.rowcount == 0:
+ raise ValueError(f"Message ID not found: {msg_id}")
conn.commit()📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||
| print(f"ChatLogger Error updating feedback: {e}") | ||||||||||||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||||||||||||
| conn.close() | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import uuid | ||
| import logging | ||
| from typing import List, Optional | ||
| from fastapi import APIRouter, HTTPException | ||
| from pydantic import BaseModel, Field | ||
|
|
||
| from llm_provider import get_llm_provider | ||
| from rag_retriever import get_retriever | ||
| from chat_logger import log_chat_message, update_chat_feedback | ||
|
|
||
| logger = logging.getLogger("freshscan.chat") | ||
| router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) | ||
|
|
||
| # ── Pydantic Request/Response Models ────────────────────────────────────────── | ||
|
|
||
| class ChatHistoryItem(BaseModel): | ||
| role: str = Field(..., description="Either 'user' or 'assistant'") | ||
| content: str = Field(..., description="The message content") | ||
|
|
||
| class ChatMessageRequest(BaseModel): | ||
| question: str = Field(..., min_length=1, description="User question") | ||
| currentPage: Optional[str] = Field( | ||
| None, description="Active page user is viewing" | ||
| ) | ||
| currentFeature: Optional[str] = Field( | ||
| None, description="Feature area user is interacting with" | ||
| ) | ||
| history: Optional[List[ChatHistoryItem]] = Field( | ||
| default_factory=list, description="Recent conversation history" | ||
| ) | ||
|
|
||
| class ChatMessageResponse(BaseModel): | ||
| message_id: str = Field(..., description="Unique ID for this response") | ||
| response: str = Field(..., description="Generated markdown text answer") | ||
|
|
||
| class ChatFeedbackRequest(BaseModel): | ||
| message_id: str = Field(..., description="ID of the message being rated") | ||
| feedback: str = Field(..., description="Feedback direction: 'up' or 'down'") | ||
|
|
||
| # ── Endpoints ───────────────────────────────────────────────────────────────── | ||
|
|
||
| @router.post("/message", response_model=ChatMessageResponse) | ||
| async def chat_message(request: ChatMessageRequest): | ||
| """ | ||
|
Comment on lines
+43
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify async route + sync call chain
rg -n -C2 'async def chat_message|async def chat_feedback|get_retriever\(|generate_response\(|log_chat_message\(|update_chat_feedback\(' backend/chat_router.py
rg -n -C2 'httpx\.post\(' backend/llm_provider.py
rg -n -C2 'sqlite3\.connect\(' backend/chat_logger.pyRepository: jpdevhub/FreshScanAi Length of output: 2340 Remove These routes execute synchronous LLM HTTP and SQLite calls that block the event loop under concurrent load. Converting to synchronous handlers allows proper concurrent request handling. Proposed fix-async def chat_message(request: ChatMessageRequest):
+def chat_message(request: ChatMessageRequest):Also applies to Also applies to: 115-117, 151-158 🤖 Prompt for AI Agents |
||
| Main Chat Assistant endpoint. | ||
| Retrieves local RAG context, merges page context & history, sends to LLM, and logs analytics. | ||
| """ | ||
| try: | ||
| # 1. Retrieve local documentation context (RAG) | ||
| context = "" | ||
| try: | ||
| retriever = get_retriever() | ||
| context = retriever.retrieve_relevant_context(request.question) | ||
| except Exception as e: | ||
| logger.error(f"RAG retrieval error: {e}") | ||
| # Non-blocking, continue with empty context | ||
|
|
||
| # 2. Build system prompt | ||
| system_prompt = ( | ||
| "You are the official FreshScanAI Assistant.\n" | ||
| "Your primary purpose is helping users understand and navigate FreshScanAI.\n" | ||
| "Answer questions related to platform features, workflows, onboarding, " | ||
| "reports, dashboards, uploads, analysis processes, and troubleshooting.\n" | ||
| "Use retrieved documentation whenever available.\n" | ||
| "Never invent product features that do not exist.\n" | ||
| "If documentation does not contain the answer, politely explain that the " | ||
| "information is unavailable." | ||
| ) | ||
|
|
||
| # 3. Incorporate page and feature context if provided | ||
| context_details = [] | ||
| if request.currentPage: | ||
| context_details.append(f"- Active Page: {request.currentPage}") | ||
| if request.currentFeature: | ||
| context_details.append(f"- Active Feature/Section: {request.currentFeature}") | ||
|
|
||
| context_info = "" | ||
| if context_details: | ||
| context_info = "\nUser Current App Context:\n" + "\n".join(context_details) + "\n" | ||
|
|
||
| # 4. Integrate RAG documentation into LLM input | ||
| prompt = "" | ||
| if context: | ||
| prompt += ( | ||
| f"Retrieved Documentation:\n{context}\n\n" | ||
| f"Instructions:\n" | ||
| "Use the retrieved documentation to answer the user's question. " | ||
| "Be factual, concise, and helpful. " | ||
| "If the information to answer is not present in the retrieved documentation, " | ||
| "state that the information is not available in the platform's documentation.\n\n" | ||
| ) | ||
| else: | ||
| prompt += ( | ||
| "No documentation was retrieved for this question. " | ||
| "Answer using only verified platform information if you are certain, " | ||
| "or politely state that the info is unavailable.\n\n" | ||
| ) | ||
|
|
||
| if context_info: | ||
| prompt += context_info + "\n" | ||
|
|
||
| prompt += f"User Question: {request.question}" | ||
|
|
||
| # 5. Format history for provider | ||
| history_list = [] | ||
| if request.history: | ||
| # Limit history to last 5 turns to prevent token bloat | ||
| for item in request.history[-10:]: | ||
| history_list.append({ | ||
| "role": "user" if item.role == "user" else "assistant", | ||
| "content": item.content | ||
| }) | ||
|
|
||
| # 6. Generate answer via provider | ||
| try: | ||
| provider = get_llm_provider() | ||
| response_text = provider.generate_response(system_prompt, prompt, history_list) | ||
| except Exception as provider_err: | ||
| logger.error(f"LLM Provider execution failed: {provider_err}") | ||
| response_text = ( | ||
| "I'm sorry, I encountered a temporary connection issue " | ||
| "while trying to reach the AI model. " | ||
| "Please ensure LLM_PROVIDER and API keys are set correctly " | ||
| "in the environment configuration." | ||
| ) | ||
|
|
||
| # 7. Log exchange to SQLite for analytics | ||
| msg_id = str(uuid.uuid4()) | ||
| try: | ||
| log_chat_message( | ||
| msg_id=msg_id, | ||
| question=request.question, | ||
| response=response_text, | ||
| current_page=request.currentPage, | ||
| current_feature=request.currentFeature | ||
| ) | ||
| except Exception as log_err: | ||
| logger.error(f"Failed to log chat interaction: {log_err}") | ||
|
|
||
| return ChatMessageResponse(message_id=msg_id, response=response_text) | ||
|
|
||
| except Exception as e: | ||
| logger.error(f"Unhandled error in chat message handler: {e}") | ||
| # Always return a user-friendly error envelope, never expose stack traces | ||
| raise HTTPException( | ||
| status_code=500, | ||
| detail="An unexpected error occurred in the chat assistant. Please try again." | ||
| ) | ||
|
|
||
| @router.post("/feedback") | ||
| async def chat_feedback(request: ChatFeedbackRequest): | ||
| """Logs thumbs up/down user feedback for a given message ID.""" | ||
| if request.feedback not in ("up", "down"): | ||
| raise HTTPException(status_code=400, detail="Feedback must be either 'up' or 'down'") | ||
|
|
||
| try: | ||
| update_chat_feedback(request.message_id, request.feedback) | ||
| return {"success": True} | ||
| except Exception as e: | ||
| logger.error(f"Failed to record feedback for message {request.message_id}: {e}") | ||
| raise HTTPException( | ||
| status_code=500, | ||
| detail="Could not submit feedback due to an internal logger issue." | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove duplicated
CORS_ALLOW_ALL.The key is declared twice, which makes configuration precedence ambiguous during audits/debugging.
🧹 Proposed fix
CORS_ALLOW_ALL=true @@ OLLAMA_BASE_URL=http://localhost:11434 -CORS_ALLOW_ALL=true📝 Committable suggestion
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 40-40: [UnorderedKey] The GEMINI_API_KEY key should go before the LLM_PROVIDER key
(UnorderedKey)
[warning] 42-42: [UnorderedKey] The CLAUDE_API_KEY key should go before the GEMINI_API_KEY key
(UnorderedKey)
[warning] 43-43: [UnorderedKey] The OLLAMA_BASE_URL key should go before the OPENAI_API_KEY key
(UnorderedKey)
[warning] 44-44: [DuplicatedKey] The CORS_ALLOW_ALL key is duplicated
(DuplicatedKey)
[warning] 44-44: [UnorderedKey] The CORS_ALLOW_ALL key should go before the GEMINI_API_KEY key
(UnorderedKey)
🤖 Prompt for AI Agents
Source: Linters/SAST tools