diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8b87868 --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# FreshScanAi - Backend error resolution + +## Plan (approved) +- Fix build-time errors and likely import/module conflicts in `backend/main.py`. +- Ensure `backend/main.py` correctly imports local modules when run as a package. +- Make DB insert failures visible (no silent success) for scan endpoints. + +## Steps +1. Update `backend/main.py` to use package-relative imports (`from .auth ...`, `from .turnstile ...`, `from .rate_limiter ...`, etc.). +2. Fix vendor router registration import to use relative imports. +3. Update scan endpoints (`/api/v1/scan` and `/api/v1/scan-auto`) so DB write failures raise HTTP 500 (instead of printing and continuing success). +4. Run backend import check (e.g., `python -c "from backend.main import app"`) and run unit tests if available. + diff --git a/backend/.env.example b/backend/.env.example index d552fce..900edb3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,4 +32,13 @@ TURNSTILE_SECRET_KEY= # MODEL_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx ========= # ── CORS ────────────────────────────────────────────────────────────────────── -CORS_ALLOW_ALL=true \ No newline at end of file +CORS_ALLOW_ALL=true + +# ── LLM Provider (for AI Chat Assistant) ────────────────────────────────────── +# Options: gemini (default), openai, claude, ollama +LLM_PROVIDER=gemini +GEMINI_API_KEY= +OPENAI_API_KEY= +CLAUDE_API_KEY= +OLLAMA_BASE_URL=http://localhost:11434 +CORS_ALLOW_ALL=true diff --git a/backend/chat_logger.py b/backend/chat_logger.py new file mode 100644 index 0000000..81ef509 --- /dev/null +++ b/backend/chat_logger.py @@ -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() + +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() + except Exception as e: + print(f"ChatLogger Error updating feedback: {e}") + finally: + conn.close() diff --git a/backend/chat_router.py b/backend/chat_router.py new file mode 100644 index 0000000..0221fa1 --- /dev/null +++ b/backend/chat_router.py @@ -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): + """ + 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." + ) diff --git a/backend/llm_provider.py b/backend/llm_provider.py new file mode 100644 index 0000000..e50b3fb --- /dev/null +++ b/backend/llm_provider.py @@ -0,0 +1,336 @@ +import os +import logging +from typing import List, Dict + +logger = logging.getLogger("freshscan.llm") + + +class LLMProvider: + def generate_response( + self, system_prompt: str, prompt: str, + history: List[Dict[str, str]] = None, + ) -> str: + """ + Generate a response from the LLM. + + Args: + system_prompt: The system instruction for the LLM. + prompt: The user prompt with RAG context. + history: list of {"role": "user"|"assistant", "content": str} + """ + raise NotImplementedError( + "Subclasses must implement generate_response" + ) + + +class GeminiProvider(LLMProvider): + def __init__(self, api_key: str): + self.api_key = api_key + self.model = os.environ.get( + "GEMINI_MODEL", "gemini-2.5-flash" + ) + + def generate_response( + self, system_prompt: str, prompt: str, + history: List[Dict[str, str]] = None, + ) -> str: + import httpx + url = ( + "https://generativelanguage.googleapis.com" + f"/v1beta/models/{self.model}:generateContent" + f"?key={self.api_key}" + ) + + # Build contents list + contents = [] + if history: + for turn in history: + role = "user" if turn["role"] == "user" else "model" + contents.append({ + "role": role, + "parts": [{"text": turn["content"]}] + }) + + # Add the current user prompt + contents.append({ + "role": "user", + "parts": [{"text": prompt}] + }) + + payload = { + "contents": contents, + "systemInstruction": { + "parts": [{"text": system_prompt}] + }, + "generationConfig": { + "temperature": 0.2, + "maxOutputTokens": 1024 + } + } + + try: + response = httpx.post(url, json=payload, timeout=30.0) + response.raise_for_status() + data = response.json() + text = data["candidates"][0]["content"]["parts"][0]["text"] + return text + except Exception as e: + logger.error(f"Gemini API error: {e}") + raise RuntimeError(f"Gemini provider failed: {e}") + + +class OpenAIProvider(LLMProvider): + def __init__(self, api_key: str): + self.api_key = api_key + self.model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") + + def generate_response( + self, system_prompt: str, prompt: str, + history: List[Dict[str, str]] = None, + ) -> str: + import httpx + url = "https://api.openai.com/v1/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + messages = [{"role": "system", "content": system_prompt}] + if history: + for turn in history: + messages.append({ + "role": turn["role"], + "content": turn["content"], + }) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "temperature": 0.2, + "max_tokens": 1024 + } + + try: + response = httpx.post( + url, json=payload, headers=headers, timeout=30.0, + ) + response.raise_for_status() + data = response.json() + text = data["choices"][0]["message"]["content"] + return text + except Exception as e: + logger.error(f"OpenAI API error: {e}") + raise RuntimeError(f"OpenAI provider failed: {e}") + + +class ClaudeProvider(LLMProvider): + def __init__(self, api_key: str): + self.api_key = api_key + self.model = os.environ.get( + "CLAUDE_MODEL", "claude-3-5-sonnet-20241022" + ) + + def generate_response( + self, system_prompt: str, prompt: str, + history: List[Dict[str, str]] = None, + ) -> str: + import httpx + url = "https://api.anthropic.com/v1/messages" + headers = { + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json" + } + + messages = [] + if history: + for turn in history: + messages.append({ + "role": turn["role"], + "content": turn["content"], + }) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "system": system_prompt, + "messages": messages, + "max_tokens": 1024, + "temperature": 0.2 + } + + try: + response = httpx.post( + url, json=payload, headers=headers, timeout=30.0, + ) + response.raise_for_status() + data = response.json() + text = data["content"][0]["text"] + return text + except Exception as e: + logger.error(f"Claude API error: {e}") + raise RuntimeError(f"Claude provider failed: {e}") + + +class OllamaProvider(LLMProvider): + def __init__(self, base_url: str): + self.base_url = base_url.rstrip('/') + self.model = os.environ.get("OLLAMA_MODEL", "llama3") + + def generate_response( + self, system_prompt: str, prompt: str, + history: List[Dict[str, str]] = None, + ) -> str: + import httpx + url = f"{self.base_url}/api/chat" + + messages = [{"role": "system", "content": system_prompt}] + if history: + for turn in history: + messages.append({ + "role": turn["role"], + "content": turn["content"], + }) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": self.model, + "messages": messages, + "stream": False, + "options": { + "temperature": 0.2 + } + } + + try: + response = httpx.post( + url, json=payload, timeout=60.0, + ) + response.raise_for_status() + data = response.json() + text = data["message"]["content"] + return text + except Exception as e: + logger.error(f"Ollama API error: {e}") + raise RuntimeError(f"Ollama provider failed: {e}") + + +class MockProvider(LLMProvider): + """Fallback provider when no API keys are configured.""" + + def generate_response( + self, system_prompt: str, prompt: str, + history: List[Dict[str, str]] = None, + ) -> str: + p_lower = prompt.lower() + + reply = ( + "\U0001f916 **FreshScanAI Assistant [DEMO MODE]**\n\n" + "No active LLM API key detected in your " + "environment. I am running in local document " + "retrieval fallback mode. " + "To enable fully conversational answers, " + "please set `GEMINI_API_KEY` (or other provider " + "credentials) in `backend/.env`.\n\n" + ) + + if ( + "hello" in p_lower + or "hi" in p_lower + or "hey" in p_lower + ): + reply += ( + "Hello! Welcome to FreshScanAI. " + "How can I help you navigate the platform today?" + ) + elif "upload" in p_lower: + reply += ( + "To upload a file for freshness assessment:\n" + "1. Go to the **Scanner** page.\n" + "2. Click the **Upload File** button, " + "or drag and drop your fish image.\n" + "3. The system will process your image and " + "auto-navigate to the detailed " + "**Analysis Dashboard**." + ) + elif "work" in p_lower or "how does" in p_lower: + reply += ( + "FreshScanAI works by analyzing three " + "biologically-significant freshness markers:\n" + "- **Gills**: Evaluates hemoglobin oxidation " + "(color saturation).\n" + "- **Eyes**: Analyzes corneal clarity and " + "pupil reflex.\n" + "- **Body**: Assesses epidermal tension, " + "scale adhesion, and mucus integrity.\n\n" + "A dual-stream CNN fuses these outputs into a " + "single **Freshness Index (0-100)** and " + "letter grade." + ) + elif "map" in p_lower or "vendor" in p_lower: + reply += ( + "The **Market Trust Map** aggregates " + "anonymized scans to rank markets and " + "vendors. Markets are color-coded based on " + "average freshness: Green (85+), " + "Yellow (70-84), and Red (<70)." + ) + else: + reply += ( + "Here is the local documentation context " + "I retrieved for your query:\n\n" + "> *Query context matches your question:*\n" + "*(Full AI responses will be active once " + "GEMINI_API_KEY is configured in " + "backend/.env)*" + ) + + return reply + + +def get_llm_provider() -> LLMProvider: + provider_name = os.environ.get("LLM_PROVIDER", "gemini").lower() + + if provider_name == "gemini": + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + logger.warning( + "GEMINI_API_KEY is not set. " + "Falling back to MockProvider." + ) + return MockProvider() + return GeminiProvider(api_key) + + elif provider_name == "openai": + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + logger.warning( + "OPENAI_API_KEY is not set. " + "Falling back to MockProvider." + ) + return MockProvider() + return OpenAIProvider(api_key) + + elif provider_name == "claude": + api_key = os.environ.get("CLAUDE_API_KEY") + if not api_key: + logger.warning( + "CLAUDE_API_KEY is not set. " + "Falling back to MockProvider." + ) + return MockProvider() + return ClaudeProvider(api_key) + + elif provider_name == "ollama": + base_url = os.environ.get( + "OLLAMA_BASE_URL", "http://localhost:11434" + ) + return OllamaProvider(base_url) + + else: + logger.warning( + f"Unknown LLM provider: {provider_name}. " + "Falling back to MockProvider." + ) + return MockProvider() diff --git a/backend/main.py b/backend/main.py index 5ab26df..15e522d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,13 +11,15 @@ from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from rate_limiter import limiter +from chat_router import router as chat_router + # Load .env file if present (python-dotenv) try: from dotenv import load_dotenv - load_dotenv(Path(__file__).parent / ".env") + load_dotenv(Path(__file__).parent / ".env", override=True) except ImportError: pass @@ -120,6 +122,7 @@ async def lifespan(app: FastAPI): app.state.limiter = limiter app.add_exception_handler(429, _rate_limit_exceeded_handler) app.add_middleware(SlowAPIMiddleware) +app.include_router(chat_router) @app.exception_handler(RateLimitExceeded) async def rate_limit_handler(request: Request, exc: RateLimitExceeded): diff --git a/backend/rag_retriever.py b/backend/rag_retriever.py new file mode 100644 index 0000000..d1ea3ec --- /dev/null +++ b/backend/rag_retriever.py @@ -0,0 +1,258 @@ +import re +import math +from pathlib import Path +from typing import List, Dict, Tuple, Set + +# Common stop words to exclude from TF-IDF indexing +STOP_WORDS: Set[str] = { + 'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been', 'being', + 'in', 'on', 'at', 'to', 'for', 'of', 'by', 'with', 'about', 'against', 'between', 'into', + 'through', 'during', 'before', 'after', 'above', 'below', 'from', 'up', 'down', 'in', 'out', + 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', + 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', + 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', + 'just', 'don', 'should', 'now', 'i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', + 'you', 'your', 'yours', 'yourself', 'yourselves', 'he', 'him', 'his', 'himself', 'she', + 'her', 'hers', 'herself', 'it', 'its', 'itself', 'they', 'them', 'their', 'theirs', 'themselves' +} + +class RAGChunk: + def __init__(self, source: str, heading: str, content: str): + self.source = source # e.g., "README.md" or "DOCUMENTATION.md" + self.heading = heading # Heading name for context + self.content = content # The chunk text content + self.full_text = f"Source: {source} > {heading}\n{content}" + self.tokens: List[str] = [] + self.tf: Dict[str, float] = {} + +def tokenize(text: str) -> List[str]: + # Lowercase and replace non-alphanumeric with spaces + text = text.lower() + text = re.sub(r'[^a-z0-9\s]', ' ', text) + tokens = text.split() + # Remove stopwords and short tokens + return [t for t in tokens if t not in STOP_WORDS and len(t) > 1] + +class RAGRetriever: + def __init__(self): + self.chunks: List[RAGChunk] = [] + self.idf: Dict[str, float] = {} + self.load_and_index_docs() + + def load_and_index_docs(self): + """Loads README.md and DOCUMENTATION.md from project root and indexes them.""" + # Backend directory is at FreshScanAi/backend + # Project root is at FreshScanAi/ + backend_dir = Path(__file__).parent + project_root = backend_dir.parent + + files_to_load = [ + ("README.md", project_root / "README.md"), + ("DOCUMENTATION.md", project_root / "DOCUMENTATION.md") + ] + + raw_chunks: List[Tuple[str, str, str]] = [] # (source, heading, content) + + for name, path in files_to_load: + if not path.exists(): + print(f"RAG WARNING: {name} not found at {path.absolute()}") + continue + + try: + content = path.read_text(encoding='utf-8') + file_chunks = self.split_by_markdown_headers(name, content) + raw_chunks.extend(file_chunks) + except Exception as e: + print(f"RAG ERROR reading {name}: {e}") + + # If no chunks were loaded, create a default fallback chunk so we don't crash + if not raw_chunks: + raw_chunks.append(( + "System", "System Info", + "FreshScanAI provides edge and server fish freshness scanning using PyTorch. " + "It has a scanner, a live market map, scan history, and support for 47+ species." + )) + + # Build chunks and TF-IDF + self.chunks = [] + doc_frequency: Dict[str, int] = {} + + for src, heading, text in raw_chunks: + chunk = RAGChunk(src, heading, text) + chunk.tokens = tokenize(chunk.full_text) + + # Term Frequency (TF) + if chunk.tokens: + word_counts = {} + for token in chunk.tokens: + word_counts[token] = word_counts.get(token, 0) + 1 + + num_tokens = len(chunk.tokens) + for word, count in word_counts.items(): + chunk.tf[word] = count / num_tokens + + # Track Document Frequency (DF) + for word in word_counts.keys(): + doc_frequency[word] = doc_frequency.get(word, 0) + 1 + + self.chunks.append(chunk) + + # Compute Inverse Document Frequency (IDF) + num_docs = len(self.chunks) + for word, df in doc_frequency.items(): + # Standard smooth IDF formula + self.idf[word] = math.log(1.0 + (num_docs / (1.0 + df))) + + print( + f"RAG Indexing complete. Indexed {len(self.chunks)} chunks " + f"across {len(files_to_load)} files." + ) + + def split_by_markdown_headers( + self, source: str, content: str + ) -> List[Tuple[str, str, str]]: + """Parses markdown and splits it into chunks based on header sections.""" + chunks: List[Tuple[str, str, str]] = [] + lines = content.splitlines() + + current_heading = "Introduction" + current_lines: List[str] = [] + + # Heading regex for #, ##, ###, #### + heading_re = re.compile(r'^(#{1,4})\s+(.+)$') + + for line in lines: + match = heading_re.match(line) + if match: + # Flush the previous chunk if it has content + if current_lines: + text_block = "\n".join(current_lines).strip() + if len(text_block) > 40: # Ignore tiny trivial chunks + chunks.extend(self.split_large_text(source, current_heading, text_block)) + + current_heading = match.group(2).strip() + current_lines = [line] + else: + current_lines.append(line) + + # Flush the last section + if current_lines: + text_block = "\n".join(current_lines).strip() + if len(text_block) > 40: + chunks.extend(self.split_large_text(source, current_heading, text_block)) + + return chunks + + def split_large_text( + self, source: str, heading: str, text: str, max_chars: int = 1500 + ) -> List[Tuple[str, str, str]]: + """Sub-splits a markdown section if it is too long to maintain granularity.""" + if len(text) <= max_chars: + return [(source, heading, text)] + + # Split by paragraph + paragraphs = text.split('\n\n') + sub_chunks: List[Tuple[str, str, str]] = [] + + current_chunk_lines: List[str] = [] + current_len = 0 + + for para in paragraphs: + para = para.strip() + if not para: + continue + + # If a single paragraph is extremely large, just force split by length + if len(para) > max_chars: + # Flush existing chunk + if current_chunk_lines: + sub_chunks.append((source, heading, "\n\n".join(current_chunk_lines))) + current_chunk_lines = [] + current_len = 0 + + # Split large paragraph by sentences or fixed size + sentences = re.split(r'(?<=[.!?])\s+', para) + for sentence in sentences: + if current_len + len(sentence) > max_chars: + if current_chunk_lines: + sub_chunks.append((source, heading, " ".join(current_chunk_lines))) + current_chunk_lines = [] + current_len = 0 + current_chunk_lines.append(sentence) + current_len += len(sentence) + else: + if current_len + len(para) > max_chars: + if current_chunk_lines: + sub_chunks.append((source, heading, "\n\n".join(current_chunk_lines))) + current_chunk_lines = [] + current_len = 0 + current_chunk_lines.append(para) + current_len += len(para) + + # Flush remaining + if current_chunk_lines: + sub_chunks.append((source, heading, "\n\n".join(current_chunk_lines))) + + return sub_chunks + + def retrieve_relevant_context(self, query: str, limit: int = 3) -> str: + """Retrieves and formats the top K matching chunks as a single context block.""" + query_tokens = tokenize(query) + if not query_tokens: + return "" + + # Compute query TF-IDF representation + # (For simple cosine matching, TF is query_term_count / query_len) + query_tf: Dict[str, float] = {} + for token in query_tokens: + query_tf[token] = query_tf.get(token, 0) + 1 / len(query_tokens) + + scored_chunks: List[Tuple[RAGChunk, float]] = [] + + for chunk in self.chunks: + score = 0.0 + # Cosine similarity dot product + for word, q_tf in query_tf.items(): + if word in chunk.tf: + # Score contribution = (Query TF * IDF) * (Chunk TF * IDF) + w_idf = self.idf.get(word, 0.0) + score += (q_tf * w_idf) * (chunk.tf[word] * w_idf) + + if score > 0: + scored_chunks.append((chunk, score)) + + # Sort by score descending + scored_chunks.sort(key=lambda x: x[1], reverse=True) + top_chunks = scored_chunks[:limit] + + if not top_chunks: + # Fallback to simple sub-string search on the query words if TF-IDF yields nothing + overlap_chunks = [] + for chunk in self.chunks: + matches = sum(1 for token in query_tokens if token in chunk.full_text.lower()) + if matches > 0: + overlap_chunks.append((chunk, matches)) + overlap_chunks.sort(key=lambda x: x[1], reverse=True) + top_chunks = overlap_chunks[:limit] + + if not top_chunks: + return "" + + # Format retrieved chunks + formatted_blocks = [] + for rank, (chunk, score) in enumerate(top_chunks, 1): + formatted_blocks.append( + f"[Document {rank}]\n" + f"{chunk.full_text.strip()}" + ) + + return "\n\n---\n\n".join(formatted_blocks) + +# Singleton retriever instance loaded at application startup +_retriever = None + +def get_retriever() -> RAGRetriever: + global _retriever + if _retriever is None: + _retriever = RAGRetriever() + return _retriever diff --git a/src/components/ChatAssistant.tsx b/src/components/ChatAssistant.tsx new file mode 100644 index 0000000..80ce932 --- /dev/null +++ b/src/components/ChatAssistant.tsx @@ -0,0 +1,338 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { MessageSquareCode, X, Send, ThumbsUp, ThumbsDown, Sparkles } from 'lucide-react'; +import { api } from '../lib/api'; + +// Definition of Chat Message +interface Message { + id?: string; + role: 'user' | 'assistant'; + content: string; + feedback?: 'up' | 'down'; +} + +// Map react-router-dom location pathnames to descriptive page and feature names +function getPageContext(pathname: string): { page: string; feature: string } { + switch (pathname) { + case '/scanner': + return { page: 'Scanner', feature: 'Real-time Camera/Upload Image Assessment' }; + case '/map': + return { page: 'Market Map', feature: 'Crowdsourced Market Trust Heatmap' }; + case '/mode': + return { page: 'Mode Selection', feature: 'Scan Protocol selector (Auto vs Multi-Image)' }; + case '/analysis': + return { page: 'Analysis Dashboard', feature: 'Biomarker fresh index (gill, eye, body) results' }; + case '/results': + return { page: 'History Results', feature: 'Aggregate user scans history & metrics' }; + case '/auth': + return { page: 'Authentication', feature: 'Google Secure Sign-in / Dev Bypass' }; + case '/': + return { page: 'Landing Page', feature: 'Hero CTA & Platform features summary' }; + default: + return { page: 'App Shell', feature: 'General Navigation' }; + } +} + +export default function ChatAssistant() { + const location = useLocation(); + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom of chat window when new messages are added + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isLoading]); + + // Suggested onboarding questions shown on initial load + const suggestedQuestions = [ + { label: 'How does FreshScanAI work?', text: 'How does FreshScanAI work?' }, + { label: 'How do I upload a file?', text: 'How do I upload a file?' }, + { label: 'Where can I see the Trust Map?', text: 'Where can I see the Trust Map?' }, + { label: '⚡ I am new here (Start Onboarding)', text: 'I am new here' } + ]; + + const handleSendMessage = async (text: string) => { + if (!text.trim() || isLoading) return; + + const userMsg: Message = { role: 'user', content: text }; + setMessages(prev => [...prev, userMsg]); + setInputValue(''); + setIsLoading(true); + + try { + // Determine page context from current path + const { page, feature } = getPageContext(location.pathname); + + // Clean history to match expected format on backend: [{role, content}] + const historyPayload = messages.map(m => ({ + role: m.role, + content: m.content + })); + + // Call API + const response = await api.chatMessage(text, page, feature, historyPayload); + + const assistantMsg: Message = { + id: response.message_id, + role: 'assistant', + content: response.response + }; + + setMessages(prev => [...prev, assistantMsg]); + } catch (err) { + console.error('Failed to get chat response:', err); + const errorMsg: Message = { + role: 'assistant', + content: '⚠️ I encountered an error connecting to the FreshScanAI neural chat hub. Please verify your internet connection or check LLM configurations.' + }; + setMessages(prev => [...prev, errorMsg]); + } finally { + setIsLoading(false); + } + }; + + const handleFeedback = async (msgId: string, idx: number, type: 'up' | 'down') => { + const msg = messages[idx]; + if (msg.feedback) return; // Prevent double rating + + try { + await api.submitChatFeedback(msgId, type); + setMessages(prev => { + const updated = [...prev]; + updated[idx] = { ...updated[idx], feedback: type }; + return updated; + }); + } catch (err) { + console.error('Failed to submit feedback:', err); + } + }; + + // Basic formatter for markdown-like text to avoid requiring external packages + const formatText = (text: string) => { + const paragraphs = text.split('\n'); + return paragraphs.map((para, pIdx) => { + // Inline formatting helpers: bold (**text**), code (`code`) + let content: React.ReactNode = para; + + // Handle bold blocks + if (para.includes('**')) { + const parts = para.split('**'); + content = parts.map((part, i) => i % 2 === 1 ? {part} : part); + } + + // Check if paragraph is a bullet point + if (para.trim().startsWith('- ') || para.trim().startsWith('* ')) { + const listText = para.trim().substring(2); + return ( +
  • + {listText} +
  • + ); + } + + // Check if paragraph is numbered list + const numMatch = para.trim().match(/^(\d+)\.\s+(.+)$/); + if (numMatch) { + return ( +
  • + {numMatch[2]} +
  • + ); + } + + // Check for code blocks / console styling + if (para.trim().startsWith('>')) { + return ( +
    + {para.trim().substring(1).trim()} +
    + ); + } + + return ( +

    + {content} +

    + ); + }); + }; + + return ( + <> + {/* Floating Toggle Button */} + + + {/* Chat Window Panel */} + {isOpen && ( +
    + {/* Header */} +
    +
    + + + FRESHSCAN_AI_ASSISTANT_HUD + +
    + +
    + + {/* Messages Area */} +
    + {messages.length === 0 ? ( +
    +
    + +
    +
    +

    + AI CHAT ASSISTANT +

    +

    + Ask questions about fish freshness analysis, scanning workflows, market mappings, or troubleshoot issues. +

    +
    + + {/* Suggestions List */} +
    + + Suggested Prompts: + +
    + {suggestedQuestions.map((q, i) => ( + + ))} +
    +
    +
    + ) : ( + messages.map((msg, idx) => { + const isUser = msg.role === 'user'; + return ( +
    + {/* Role Tag */} + + {isUser ? '[USER]' : '[ASSISTANT]'} + + + {/* Message Bubble */} +
    + {formatText(msg.content)} + + {/* Feedback Rating Icons (Assistant Only) */} + {!isUser && msg.id && ( +
    + + +
    + )} +
    +
    + ); + }) + )} + + {/* Typing Loader */} + {isLoading && ( +
    + + [ASSISTANT] + +
    +
    + + + + + Processing... + +
    +
    +
    + )} + +
    +
    + + {/* Input Area */} +
    { + e.preventDefault(); + handleSendMessage(inputValue); + }} + className="flex border-t border-outline-variant/25 bg-surface-low" + > + setInputValue(e.target.value)} + placeholder="ASK CHAT ASSISTANT..." + disabled={isLoading} + className="flex-1 bg-surface-lowest text-xs text-on-surface px-4 py-3.5 border-none focus:outline-none focus:bg-surface-mid placeholder:font-mono placeholder:text-[9px] placeholder:text-outline disabled:opacity-60" + /> + +
    +
    + )} + + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 4eec2ee..6423d1f 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -2,6 +2,7 @@ import { Outlet } from 'react-router-dom'; import Navbar from './Navbar'; import BottomNav from './BottomNav'; import Footer from './Footer'; +import ChatAssistant from './ChatAssistant'; export default function Layout() { return ( @@ -29,6 +30,9 @@ export default function Layout() { {/* Mobile Bottom Nav */} + + {/* AI Chat Assistant */} +
    ); } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 2543436..3e94084 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -133,13 +133,10 @@ export interface EdgeInferenceMeta { export const api = { loginUrl: async (turnstileToken?: string): Promise => { if (turnstileToken) { - const response = await apiFetch<{ redirect_url: string }>( - "/api/v1/auth/login/google", - { - method: "POST", - body: JSON.stringify({ turnstile_token: turnstileToken }), - }, - ); + const response = await apiFetch<{ redirect_url: string }>('/api/v1/auth/login/google', { + method: 'POST', + body: JSON.stringify({ turnstile_token: turnstileToken }), + }); return response.redirect_url; } @@ -236,7 +233,27 @@ export const api = { }, getMarkets: (): Promise => - apiFetch("/api/v1/maps/markets"), + apiFetch('/api/v1/maps/markets'), + + chatMessage: ( + question: string, + currentPage?: string, + currentFeature?: string, + history: Array<{ role: 'user' | 'assistant'; content: string }> = [] + ): Promise<{ message_id: string; response: string }> => + apiFetch<{ message_id: string; response: string }>('/api/v1/chat/message', { + method: 'POST', + body: JSON.stringify({ question, currentPage, currentFeature, history }), + }), + + submitChatFeedback: ( + messageId: string, + feedback: 'up' | 'down' + ): Promise<{ success: boolean }> => + apiFetch<{ success: boolean }>('/api/v1/chat/feedback', { + method: 'POST', + body: JSON.stringify({ message_id: messageId, feedback }), + }), getLiveMarkets: ( lat: number,