diff --git a/.gitignore b/.gitignore index 2f411f6dc..b7224a694 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,6 @@ windsurf.md .codeium/ .tabnine/ .kite/ +.claude/ +.mcp.json +AGENTS.md \ No newline at end of file diff --git a/backend/lcfs/settings.py b/backend/lcfs/settings.py index 3a8dae839..2158c61c9 100644 --- a/backend/lcfs/settings.py +++ b/backend/lcfs/settings.py @@ -93,6 +93,10 @@ class Settings(BaseSettings): ches_client_secret: str = "" ches_sender_email: str = "noreply@gov.bc.ca" ches_sender_name: str = "LCFS Notification System" + ches_support_email: str = "lcfs@gov.bc.ca" + + # Variable for LCFS Assistant Chat + rag_service_url: str = "http://localhost:1416" def __init__(self, **kwargs): # Map APP_ENVIRONMENT to environment if present @@ -152,6 +156,11 @@ def redis_url(self) -> URL: path=path, ) + # Chat service settings + OLLAMA_URL: str = "http://ollama:11434" + RAG_SERVICE_URL: str = "http://rag-llm:1416" + CHAT_RAG_ENABLED: bool = True + class Config: env_file = ".env" env_prefix = "LCFS_" diff --git a/backend/lcfs/web/api/chat/__init__.py b/backend/lcfs/web/api/chat/__init__.py new file mode 100644 index 000000000..84d00ad73 --- /dev/null +++ b/backend/lcfs/web/api/chat/__init__.py @@ -0,0 +1,5 @@ +"""Chat API with OpenAI compatibility.""" + +from lcfs.web.api.chat.views import router + +__all__ = ["router"] \ No newline at end of file diff --git a/backend/lcfs/web/api/chat/schemas.py b/backend/lcfs/web/api/chat/schemas.py new file mode 100644 index 000000000..381af31e0 --- /dev/null +++ b/backend/lcfs/web/api/chat/schemas.py @@ -0,0 +1,128 @@ +"""OpenAI-compatible chat schemas.""" + +from typing import List, Optional, Literal +from pydantic import BaseModel, Field +import time + + +class ChatMessage(BaseModel): + """A chat message in OpenAI format.""" + + role: Literal["user", "assistant", "system"] + content: str + name: Optional[str] = None + + +class ChatCompletionRequest(BaseModel): + """OpenAI chat completion request format.""" + + messages: List[ChatMessage] + model: str = "lcfs-rag" + temperature: Optional[float] = Field(default=0.7, ge=0.0, le=2.0) + max_tokens: Optional[int] = Field(default=500, gt=0, le=2000) + stream: Optional[bool] = False + top_p: Optional[float] = Field(default=1.0, ge=0.0, le=1.0) + frequency_penalty: Optional[float] = Field(default=0.0, ge=-2.0, le=2.0) + presence_penalty: Optional[float] = Field(default=0.0, ge=-2.0, le=2.0) + stop: Optional[List[str]] = None + user: Optional[str] = None + + +class Usage(BaseModel): + """Token usage information.""" + + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +class ChatCompletionChoice(BaseModel): + """A chat completion choice.""" + + index: int + message: ChatMessage + finish_reason: Optional[Literal["stop", "length", "content_filter"]] = None + + +class ChatCompletionResponse(BaseModel): + """OpenAI chat completion response format.""" + + id: str + object: str = "chat.completion" + created: int = Field(default_factory=lambda: int(time.time())) + model: str + choices: List[ChatCompletionChoice] + usage: Optional[Usage] = None + + +# Streaming schemas +class ChatCompletionChunkDelta(BaseModel): + """Delta object for streaming responses.""" + + role: Optional[Literal["assistant"]] = None + content: Optional[str] = None + metadata: Optional[dict] = None + + +class ChatCompletionChunkChoice(BaseModel): + """A streaming chat completion choice.""" + + index: int + delta: ChatCompletionChunkDelta + finish_reason: Optional[Literal["stop", "length", "content_filter"]] = None + + +class ChatCompletionChunk(BaseModel): + """OpenAI chat completion chunk for streaming.""" + + id: str + object: str = "chat.completion.chunk" + created: int = Field(default_factory=lambda: int(time.time())) + model: str + choices: List[ChatCompletionChunkChoice] + + +class ErrorDetail(BaseModel): + """Error detail object.""" + + message: str + type: str + param: Optional[str] = None + code: Optional[str] = None + + +class ErrorResponse(BaseModel): + """OpenAI-compatible error response.""" + + error: ErrorDetail + + +class EscalationRequest(BaseModel): + """Support escalation request from the chat assistant.""" + + issue_type: str = Field( + ..., + description="Type of issue: question, issue, feedback", + ) + description: str = Field(..., description="User's description of their issue") + user_email: str = Field(..., description="User's email for response") + user_name: str = Field(..., description="User's name") + organization_name: Optional[str] = Field( + None, description="User's organization name" + ) + organization_id: Optional[int] = Field(None, description="User's organization ID") + conversation_history: Optional[str] = Field( + None, description="Full conversation history with the assistant" + ) + is_low_confidence: bool = Field( + False, description="Whether this escalation was triggered by low AI confidence" + ) + submitted_at: str = Field(..., description="Timestamp of submission") + + +class EscalationResponse(BaseModel): + """Response after submitting an escalation request.""" + + status: str + message: str + ticket_id: Optional[str] = None diff --git a/backend/lcfs/web/api/chat/services.py b/backend/lcfs/web/api/chat/services.py new file mode 100644 index 000000000..c9be94a73 --- /dev/null +++ b/backend/lcfs/web/api/chat/services.py @@ -0,0 +1,42 @@ +"""Simplified chat service that forwards requests to RAG pipeline.""" + +from typing import Dict, Any +import httpx +import structlog + +from lcfs.web.api.chat.schemas import ChatCompletionRequest +from lcfs.db.models.user import UserProfile +from lcfs.settings import settings + +logger = structlog.get_logger(__name__) + + +class ChatService: + """Simplified service that forwards chat requests to RAG pipeline.""" + + def __init__(self): + self.rag_service_url = settings.rag_service_url + + async def create_completion( + self, request: ChatCompletionRequest, user: UserProfile + ) -> Dict[str, Any]: + """Forward chat completion request to the RAG service and return JSON response.""" + messages = [msg.dict(exclude_none=True) for msg in request.messages] + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.rag_service_url}/lcfs_rag/run", + json={"messages": messages}, + ) + response.raise_for_status() + rag_result = response.json() + + return rag_result.get("result") or rag_result + except Exception as exc: + logger.error( + "chat_completion_error", + error=str(exc), + error_type=type(exc).__name__, + ) + raise diff --git a/backend/lcfs/web/api/chat/views.py b/backend/lcfs/web/api/chat/views.py new file mode 100644 index 000000000..9e2d8b4ef --- /dev/null +++ b/backend/lcfs/web/api/chat/views.py @@ -0,0 +1,187 @@ +"""Chat API endpoints with OpenAI compatibility.""" + +import uuid +from datetime import datetime +from typing import Any, Dict +from fastapi import APIRouter, Depends, HTTPException, Request +import structlog + +from lcfs.web.api.chat.schemas import ( + ChatCompletionRequest, + ErrorResponse, + EscalationRequest, + EscalationResponse, +) +from lcfs.web.api.chat.services import ChatService +from lcfs.web.api.email.services import CHESEmailService +from lcfs.db.models.user import UserProfile +from lcfs.web.core.decorators import view_handler +from lcfs.db.base import get_current_user +from lcfs.settings import settings + +router = APIRouter() +logger = structlog.get_logger(__name__) + + +@router.post( + "/completions", + responses={ + 400: {"model": ErrorResponse}, + 500: {"model": ErrorResponse}, + 503: {"model": ErrorResponse}, + }, +) +@view_handler(["*"]) +async def chat_completions( + request: Request, + chat_request: ChatCompletionRequest, + current_user: UserProfile = Depends(get_current_user), +) -> Dict[str, Any]: + """ + Create a chat completion using the RAG service and return + an OpenAI-compatible JSON response. + + Args: + request: FastAPI Request object + chat_request: Chat completion request in OpenAI format + current_user: Current authenticated user + + Returns: + Chat completion JSON response + """ + if not chat_request.messages: + raise HTTPException( + status_code=400, detail="messages field is required and cannot be empty" + ) + + # Validate messages + for i, message in enumerate(chat_request.messages): + if not message.content.strip(): + raise HTTPException( + status_code=400, detail=f"Message at index {i} has empty content" + ) + + chat_service = ChatService() + + result = await chat_service.create_completion(chat_request, current_user) + return result + + +@router.post( + "/escalate", + response_model=EscalationResponse, + responses={ + 400: {"model": ErrorResponse}, + 500: {"model": ErrorResponse}, + }, +) +@view_handler(["*"]) +async def escalate_to_support( + request: Request, + escalation_request: EscalationRequest, + current_user: UserProfile = Depends(get_current_user), + email_service: CHESEmailService = Depends(), +) -> EscalationResponse: + """ + Escalate a chat conversation to support. + + Sends the conversation history and user's issue to the support team. + """ + # Generate a ticket ID for tracking + ticket_id = ( + f"LCFS-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}" + ) + + # Format the issue type for display + issue_type_labels = { + "question": "General Question", + "issue": "Report an Issue", + "feedback": "Feedback", + } + issue_type_display = issue_type_labels.get( + escalation_request.issue_type, escalation_request.issue_type + ) + + # Build the email body + email_body = f""" +

LCFS Assistant Support Request

+ +

Ticket ID: {ticket_id}

+

Submitted: {escalation_request.submitted_at}

+

Low Confidence Escalation: {"Yes" if escalation_request.is_low_confidence else "No"}

+ +
+ +

User Information

+

Name: {escalation_request.user_name}

+

Email: {escalation_request.user_email}

+

Organization: {escalation_request.organization_name or "N/A"}

+

Organization ID: {escalation_request.organization_id or "N/A"}

+ +
+ +

Issue Details

+

Issue Type: {issue_type_display}

+

Description:

+

{escalation_request.description}

+ +
+ +

Conversation History

+
+{escalation_request.conversation_history or "No conversation history available."}
+
+ """.strip() + + # Build email payload + email_payload = { + "bcc": [], + "bodyType": "html", + "body": email_body, + "cc": [], + "delayTS": 0, + "encoding": "utf-8", + "from": settings.ches_sender_email, + "priority": "normal", + "subject": f"[{ticket_id}] LCFS Assistant Support Request - {issue_type_display}", + "to": [settings.ches_support_email], + "tag": "lcfs-assistant-escalation", + } + + try: + success = await email_service.send_email(email_payload) + if success: + logger.info( + "Escalation email sent successfully", + ticket_id=ticket_id, + user_email=escalation_request.user_email, + issue_type=escalation_request.issue_type, + ) + return EscalationResponse( + status="success", + message="Your request has been submitted successfully.", + ticket_id=ticket_id, + ) + else: + logger.warning( + "Escalation email sending returned False", + ticket_id=ticket_id, + user_email=escalation_request.user_email, + ) + # Still return success to user since we don't want to block them + return EscalationResponse( + status="success", + message="Your request has been submitted. Our team will review it shortly.", + ticket_id=ticket_id, + ) + except Exception as e: + logger.error( + "Failed to send escalation email", + error=str(e), + ticket_id=ticket_id, + user_email=escalation_request.user_email, + ) + raise HTTPException( + status_code=500, + detail="Failed to submit your request. Please try again later.", + ) diff --git a/backend/lcfs/web/api/router.py b/backend/lcfs/web/api/router.py index f2de766cc..aec84b365 100644 --- a/backend/lcfs/web/api/router.py +++ b/backend/lcfs/web/api/router.py @@ -31,6 +31,7 @@ credit_ledger, forms, geocoder, + chat, ) api_router = APIRouter() @@ -104,3 +105,4 @@ ) api_router.include_router(forms.router, prefix="/forms", tags=["forms"]) api_router.include_router(geocoder.router, prefix="/geocoder", tags=["geocoder"]) +api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) diff --git a/docker-compose.rag.yml b/docker-compose.rag.yml new file mode 100644 index 000000000..562adfaee --- /dev/null +++ b/docker-compose.rag.yml @@ -0,0 +1,100 @@ +services: + ollama: + image: ollama/ollama:latest + container_name: lcfs-ollama + restart: unless-stopped + ports: + - "11434:11434" # Ollama REST / OpenAI-compatible API + volumes: + - ollama_models:/root/.ollama # persist pulled models + - ./rag-system/scripts/pull-model.sh:/usr/local/bin/pull-model.sh:ro + environment: + OLLAMA_MODEL: "qwen2:1.5b" + entrypoint: ["/bin/bash", "/usr/local/bin/pull-model.sh"] + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:11434/api/tags"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 60s # Give more time for model download + networks: + - shared_network + # Qdrant Vector Database for RAG system + qdrant: + image: qdrant/qdrant:v1.15.4 + container_name: lcfs-qdrant + restart: "no" + ports: + - "6333:6333" # REST API + Web Dashboard at http://localhost:6333/dashboard + - "6334:6334" # gRPC API + volumes: + - qdrant_storage:/qdrant/storage + networks: + - shared_network + # Resource limits for OpenShift compatibility + deploy: + resources: + limits: + memory: 1G # Qdrant is memory efficient + cpus: "0.5" # Moderate CPU needs + reservations: + memory: 256M # Minimum memory + cpus: "0.1" # Low CPU reservation + labels: + - "lcfs.service=qdrant" + - "lcfs.description=Vector database for LCFS RAG system" + + # RAG System - Simple LLM API using Haystack + Hayhooks with open-source models + rag-llm: + container_name: lcfs-rag-llm + restart: "no" + build: + context: ./rag-system + dockerfile: Dockerfile + environment: + HAYSTACK_TELEMETRY_ENABLED: "false" + # No API keys needed - using local open-source models + HF_HUB_DISABLE_TELEMETRY: "1" + TOKENIZERS_PARALLELISM: "false" # Prevent tokenizer warnings + # Qdrant connection settings + QDRANT_HOST: "qdrant" + QDRANT_PORT: "6333" + OLLAMA_URL: "http://ollama:11434" # Ollama API URL for Haystack integration + OLLAMA_MODEL: "qwen2:1.5b" # Qwen2 1.5B + ports: + - "1416:1416" # Hayhooks default port + volumes: + # Cache Hugging Face models locally + - huggingface_cache:/root/.cache/huggingface + networks: + - shared_network + depends_on: + - qdrant + - ollama + # Match OpenShift production specs exactly + deploy: + resources: + limits: + memory: 6G # Increased for qwen2:1.5b (requires 4-6GB) + cpus: "1.0" # Allow 1 full CPU for model loading + reservations: + memory: 4G # Increased minimum for qwen2:1.5b + cpus: "0.5" # 500m CPU request (vs 50m default) + labels: + - "lcfs.service=rag-llm" + - "lcfs.description=LCFS RAG System LLM API using open-source models" + +# Add volumes for Hugging Face model cache and Qdrant storage +volumes: + huggingface_cache: + name: lcfs_huggingface_cache + qdrant_storage: + name: lcfs_qdrant_storage + ollama_models: + name: lcfs_ollama_models + +# Use existing network from main docker-compose.yml +networks: + shared_network: + external: true + name: shared_network diff --git a/docker-compose.yml b/docker-compose.yml index 72787f067..15a60cbad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,13 +17,12 @@ services: redis: container_name: redis restart: "no" - image: bitnami/redis:7.4.2 - environment: - REDIS_PASSWORD: development_only + image: redis:7.4.2 + command: redis-server --requirepass development_only ports: - - '6379:6379' + - "6379:6379" volumes: - - redis_data:/bitnami/redis/data + - redis_data:/data networks: - shared_network @@ -38,7 +37,7 @@ services: - ./docker/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf ports: - "15672:15672" # Management UI - - "5672:5672" # RabbitMQ Port + - "5672:5672" # RabbitMQ Port networks: - shared_network @@ -90,7 +89,7 @@ services: restart: "no" build: context: ./backend - dockerfile: 'Dockerfile' + dockerfile: "Dockerfile" target: dev volumes: - type: bind @@ -108,9 +107,12 @@ services: LCFS_REDIS_PASSWORD: development_only LCFS_RELOAD: true APP_ENVIRONMENT: dev + LCFS_OLLAMA_URL: http://ollama:11434 + LCFS_RAG_SERVICE_URL: http://rag-llm:1416 + LCFS_CHAT_RAG_ENABLED: true ports: - - '8000:8000' # Application port - - '5678:5678' # Debugger port + - "8000:8000" # Application port + - "5678:5678" # Debugger port depends_on: - db - redis @@ -164,6 +166,104 @@ services: - "lcfs.environment=development-only" - "lcfs.description=Model Context Protocol server for local development" + # Ollama service for LLM capabilities with streaming support + ollama: + image: ollama/ollama:latest + container_name: lcfs-ollama + restart: "no" + profiles: ["rag"] + ports: + - "11434:11434" + volumes: + - ollama_models:/root/.ollama + - ./rag-system/scripts/pull-model.sh:/usr/local/bin/pull-model.sh:ro + environment: + OLLAMA_MODEL: "smollm2:135m" + OLLAMA_KEEP_ALIVE: "5m" + OLLAMA_NUM_PARALLEL: "4" + OLLAMA_MAX_LOADED_MODELS: "2" + entrypoint: ["/bin/bash", "/usr/local/bin/pull-model.sh"] + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:11434/api/tags"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 60s + networks: + - shared_network + + # Qdrant Vector Database for RAG system + qdrant: + image: qdrant/qdrant:v1.15.4 + container_name: lcfs-qdrant + restart: "no" + profiles: ["rag"] + ports: + - "6333:6333" # REST API + Web Dashboard + - "6334:6334" # gRPC API + volumes: + - qdrant_storage:/qdrant/storage + networks: + - shared_network + deploy: + resources: + limits: + memory: 1G + cpus: "0.5" + reservations: + memory: 256M + cpus: "0.1" + labels: + - "lcfs.service=qdrant" + - "lcfs.description=Vector database for LCFS RAG system" + + # RAG System - LLM API using Haystack + Hayhooks + rag-llm: + container_name: lcfs-rag-llm + restart: "no" + profiles: ["rag"] + build: + context: ./rag-system + dockerfile: Dockerfile + environment: + HAYSTACK_TELEMETRY_ENABLED: "false" + HF_HUB_DISABLE_TELEMETRY: "1" + TOKENIZERS_PARALLELISM: "false" + QDRANT_HOST: "qdrant" + QDRANT_PORT: "6333" + OLLAMA_URL: "http://ollama:11434" + OLLAMA_MODEL: "smollm2:135m" # Use same model as Ollama + EMBEDDING_MODEL: "BAAI/bge-small-en-v1.5" # Embedding model for document vectors + RERANKER_MODEL: "cross-encoder/ms-marco-MiniLM-L-6-v2" # Reranker for result improvement + ports: + - "1416:1416" + volumes: + - huggingface_cache:/root/.cache/huggingface + - type: bind + source: ./rag-system/pipelines + target: /opt/pipelines + consistency: cached + - type: bind + source: ./rag-system/data + target: /opt/data + consistency: cached + networks: + - shared_network + depends_on: + - qdrant + - ollama + deploy: + resources: + limits: + memory: 5G + cpus: "1.0" + reservations: + memory: 2G + cpus: "0.5" + labels: + - "lcfs.service=rag-llm" + - "lcfs.description=LCFS RAG System LLM API using open-source models" + volumes: postgres_data: name: lcfs_postgres_data @@ -177,7 +277,13 @@ volumes: name: clamav clamsocket: name: clamsocket + ollama_models: + name: lcfs_ollama_models + huggingface_cache: + name: lcfs_huggingface_cache + qdrant_storage: + name: lcfs_qdrant_storage networks: shared_network: - name: shared_network \ No newline at end of file + name: shared_network diff --git a/frontend/public/config/config.js b/frontend/public/config/config.js index 1376191b8..d5851e96a 100644 --- a/frontend/public/config/config.js +++ b/frontend/public/config/config.js @@ -16,7 +16,8 @@ export const config = { fseImportExport: true, allocationAgreementImportExport: true, governmentAdjustment: true, - obfuscatedLinks: true + obfuscatedLinks: true, + lcfsAssistant: true } } diff --git a/frontend/src/components/BCFooter/index.jsx b/frontend/src/components/BCFooter/index.jsx index 159b9ccde..bd2bd7f2c 100644 --- a/frontend/src/components/BCFooter/index.jsx +++ b/frontend/src/components/BCFooter/index.jsx @@ -4,6 +4,9 @@ import BCBox from '@/components/BCBox' import BCTypography from '@/components/BCTypography' import { GitHub } from '@mui/icons-material' import typography from '@/themes/base/typography' +import ChatWidget from '@/components/LCFSAssistant' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { isFeatureEnabled, FEATURE_FLAGS } from '@/constants/config' function Footer({ repoDetails = { @@ -22,6 +25,13 @@ function Footer({ ] }) { const { size } = typography + const { data: currentUser } = useCurrentUser() + + // Only show chat assistant if feature flag is enabled and user is BCeID (non-government) + const isGovernmentUser = currentUser?.isGovernmentUser + const isAssistantEnabled = isFeatureEnabled(FEATURE_FLAGS.LCFS_ASSISTANT) + const showChatAssistant = + isAssistantEnabled && currentUser && !isGovernmentUser const renderLinks = () => links.map((link) => ( @@ -56,76 +66,79 @@ function Footer({ )) return ( - ({ - display: 'flex', - flexDirection: { xs: 'column', lg: 'row' }, - justifyContent: 'space-between', - alignItems: 'center', - backgroundColor: primary.nav, - borderTop: `2px solid ${secondary.main}`, - color: white.main, - minHeight: pxToRem(46), - position: 'relative' - })} - > - ({ - display: 'flex', - flexWrap: 'wrap', - alignItems: 'center', - justifyContent: 'center', - listStyle: 'none', - flexDirection: 'row', - mt: 3, - mb: 0, - p: 0, - - [breakpoints.up('lg')]: { - mt: 0 - } - })} - > - {renderLinks()} - + <> + {showChatAssistant && } ({ display: 'flex', - justifyContent: 'center', + flexDirection: { xs: 'column', lg: 'row' }, + justifyContent: 'space-between', alignItems: 'center', - flexWrap: 'wrap' - }} + backgroundColor: primary.nav, + borderTop: `2px solid ${secondary.main}`, + color: white.main, + minHeight: pxToRem(46), + position: 'relative' + })} > - ({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + justifyContent: 'center', + listStyle: 'none', + flexDirection: 'row', + mt: 3, + mb: 0, + p: 0, + + [breakpoints.up('lg')]: { + mt: 0 + } + })} + > + {renderLinks()} + + - - - {repoDetails.name} - - + + + {repoDetails.name} + + + - + ) } diff --git a/frontend/src/components/LCFSAssistant/ChatInput.jsx b/frontend/src/components/LCFSAssistant/ChatInput.jsx new file mode 100644 index 000000000..270ce458b --- /dev/null +++ b/frontend/src/components/LCFSAssistant/ChatInput.jsx @@ -0,0 +1,279 @@ +import { useState, useEffect, useRef } from 'react' +import PropTypes from 'prop-types' +import { Box, TextField, IconButton, Tooltip, Typography } from '@mui/material' +import { + Send as SendIcon, + Mic as MicIcon, + MicOff as MicOffIcon +} from '@mui/icons-material' +import { useVoice } from '@/hooks/useVoice' + +const ChatInput = ({ onSend, disabled }) => { + const [input, setInput] = useState('') + const maxLength = 500 + const voice = useVoice() + + const handleSubmit = (e) => { + e.preventDefault() + if (input.trim() && !disabled) { + onSend(input.trim()) + setInput('') + } + } + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit(e) + } + } + + const handleChange = (e) => { + const value = e.target.value + if (value.length <= maxLength) { + setInput(value) + } + } + + // Update input when voice transcript changes + useEffect(() => { + if (voice.transcript && voice.transcript.trim()) { + const fullTranscript = + voice.transcript + + (voice.interimTranscript ? ' ' + voice.interimTranscript : '') + setInput(fullTranscript.slice(0, maxLength)) + } else if (voice.interimTranscript) { + setInput(voice.interimTranscript.slice(0, maxLength)) + } + }, [voice.transcript, voice.interimTranscript, maxLength]) + + // Detect when voice recognition stops and send immediately + const wasListeningRef = useRef(false) + useEffect(() => { + if ( + wasListeningRef.current && + !voice.isListening && + input.trim() && + !disabled + ) { + // User was listening and now stopped - send immediately + setTimeout(() => { + onSend(input.trim()) + setInput('') + }, 800) // Brief delay to ensure transcript is complete + } + wasListeningRef.current = voice.isListening + }, [voice.isListening, input, disabled, onSend]) + + // Handle microphone button click + const handleMicClick = () => { + if (voice.isListening) { + voice.stopListening() + } else { + setInput('') // Clear input when starting to listen + voice.startListening() + } + } + + return ( + + {/* Voice recording indicator */} + {voice.isListening && ( + + + + Listening... {voice.interimTranscript && '(processing)'} + + + )} + + + + + + {input.length} / {maxLength} + + + {voice.isSupported && ( + + + + {voice.isListening ? ( + + ) : ( + + )} + + + + )} + + + + + + + + + + + + ) +} + +ChatInput.propTypes = { + onSend: PropTypes.func.isRequired, + disabled: PropTypes.bool +} + +export default ChatInput diff --git a/frontend/src/components/LCFSAssistant/ChatMessage.jsx b/frontend/src/components/LCFSAssistant/ChatMessage.jsx new file mode 100644 index 000000000..faaebe15c --- /dev/null +++ b/frontend/src/components/LCFSAssistant/ChatMessage.jsx @@ -0,0 +1,676 @@ +import { useState, useEffect, useMemo, useRef } from 'react' +import PropTypes from 'prop-types' +import { + Box, + Typography, + IconButton, + Tooltip, + TextField, + Button +} from '@mui/material' +import { + VolumeUp as VolumeUpIcon, + Stop as StopIcon, + Edit as EditIcon, + Check as CheckIcon, + Close as CloseIcon, + ContentCopy as CopyIcon, + Refresh as RefreshIcon, + CheckCircle as CheckCircleIcon +} from '@mui/icons-material' +import { useVoice } from '@/hooks/useVoice' +import { copyToClipboard } from '@/utils/clipboard' + +const stripTrailingSources = (content = '') => { + const marker = '\nsources:' + const lowerContent = content.toLowerCase() + const lastIdx = lowerContent.lastIndexOf(marker) + + if (lastIdx === -1) { + return content + } + + return content.slice(0, lastIdx).trimEnd() +} + +// Track which messages have been animated (persists across re-mounts) +const animatedMessages = new Set() + +const ChatMessage = ({ message, onEdit, onRegenerate }) => { + const isUser = message.role === 'user' + const voice = useVoice() + const [isEditing, setIsEditing] = useState(false) + const [editedContent, setEditedContent] = useState(message.content) + const [showCopySuccess, setShowCopySuccess] = useState(false) + const editInputRef = useRef(null) + const citationList = message.metadata?.citations || message.sources || [] + const displayContent = useMemo( + () => stripTrailingSources(message.content), + [message.content] + ) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) + + // Check if this message has already been animated - capture at mount time + const messageKey = `${message.id}-${message.role}` + const wasAlreadyAnimatedRef = useRef(animatedMessages.has(messageKey)) + const alreadyAnimated = wasAlreadyAnimatedRef.current + + const [visibleChars, setVisibleChars] = useState( + alreadyAnimated ? displayContent.length : 0 + ) + const [showSources, setShowSources] = useState(alreadyAnimated) + + // Should we animate? Only if this is a fresh message + const shouldAnimateRefs = !alreadyAnimated && !prefersReducedMotion + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return + } + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') + const updatePreference = (event) => { + setPrefersReducedMotion(event.matches) + } + + setPrefersReducedMotion(mediaQuery.matches) + mediaQuery.addEventListener('change', updatePreference) + + return () => { + mediaQuery.removeEventListener('change', updatePreference) + } + }, []) + + // Progressive text reveal for assistant messages - only on first appearance + useEffect(() => { + // Skip animation if: user message, editing, reduced motion, or already animated + if (isUser || isEditing || prefersReducedMotion || alreadyAnimated) { + setVisibleChars(displayContent.length) + setShowSources(true) + animatedMessages.add(messageKey) + return + } + + setVisibleChars(0) + setShowSources(false) + + const charsPerTick = 3 // Characters to reveal per tick + const tickInterval = 15 // Milliseconds between ticks + + const totalChars = displayContent.length + let currentChars = 0 + + const timer = setInterval(() => { + currentChars += charsPerTick + if (currentChars >= totalChars) { + setVisibleChars(totalChars) + setShowSources(true) + animatedMessages.add(messageKey) // Mark as animated globally + clearInterval(timer) + } else { + setVisibleChars(currentChars) + } + }, tickInterval) + + return () => clearInterval(timer) + }, [ + displayContent, + isUser, + isEditing, + prefersReducedMotion, + alreadyAnimated, + messageKey + ]) + + // Set cursor to end when editing starts + useEffect(() => { + if (isEditing && editInputRef.current) { + const input = editInputRef.current + const length = input.value.length + input.setSelectionRange(length, length) + } + }, [isEditing]) + + const handleSpeak = () => { + if (voice.isSpeaking) { + voice.stopSpeaking() + } else { + voice.speak(displayContent || message.content) + } + } + + const handleEditClick = () => { + setIsEditing(true) + setEditedContent(message.content) + } + + const handleSaveEdit = () => { + if (editedContent.trim() && editedContent !== message.content) { + onEdit(message.id, editedContent.trim()) + } + setIsEditing(false) + } + + const handleCancelEdit = () => { + setIsEditing(false) + setEditedContent(message.content) + } + + const handleCopy = async () => { + const success = await copyToClipboard(displayContent || message.content) + if (success) { + setShowCopySuccess(true) + } + } + + // Auto-hide copy success notification after 2 seconds + useEffect(() => { + if (showCopySuccess) { + const timer = setTimeout(() => { + setShowCopySuccess(false) + }, 2000) + return () => clearTimeout(timer) + } + }, [showCopySuccess]) + + const handleRegenerate = () => { + if (onRegenerate) { + onRegenerate(message.id) + } + } + + return ( + + + {/* Role label with voice button for assistant */} + + + {isUser ? 'You' : 'Assistant'} + + {isUser && onEdit && !isEditing && ( + + + + + + )} + {!isUser && voice.isSupported && ( + + + {voice.isSpeaking ? ( + + ) : ( + + )} + + + )} + + + {/* Minimal message box */} + + {isEditing ? ( + + setEditedContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + handleCancelEdit() + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if ( + editedContent.trim() && + editedContent !== message.content + ) { + handleSaveEdit() + } + } + }} + autoFocus + inputRef={editInputRef} + placeholder="Edit your message..." + variant="standard" + InputProps={{ + disableUnderline: true + }} + sx={{ + '& .MuiInputBase-root': { + fontSize: '0.9375rem', + lineHeight: 1.6, + p: 0 + }, + '& .MuiInputBase-input': { + p: 0 + } + }} + /> + + + + + + ) : ( + <> + + {isUser || prefersReducedMotion + ? displayContent + : displayContent.slice(0, visibleChars)} + {!isUser && + !prefersReducedMotion && + visibleChars < displayContent.length && ( + + )} + + + {citationList.length > 0 && showSources && ( + + + References + + + {citationList.map((source, idx) => { + const link = source.url || source.origin + return ( + + + {source.title || 'LCFS Reference Document'} + + {typeof source.score === 'number' && ( + + Relevance: {(source.score * 100).toFixed(0)}% + + )} + + ) + })} + + + )} + + )} + + {/* Action buttons for assistant messages */} + {!isUser && !isEditing && message.content && ( + + + + {showCopySuccess ? ( + + ) : ( + + )} + + + + + + + + + + )} + + + + ) +} + +ChatMessage.propTypes = { + message: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + role: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + sources: PropTypes.array, + metadata: PropTypes.shape({ + citations: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string, + url: PropTypes.string, + origin: PropTypes.string, + score: PropTypes.number + }) + ) + }) + }).isRequired, + onEdit: PropTypes.func, + onRegenerate: PropTypes.func +} + +export default ChatMessage diff --git a/frontend/src/components/LCFSAssistant/ChatWidget.jsx b/frontend/src/components/LCFSAssistant/ChatWidget.jsx new file mode 100644 index 000000000..8f61638ed --- /dev/null +++ b/frontend/src/components/LCFSAssistant/ChatWidget.jsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { Fab, Paper, Fade, Box, useMediaQuery } from '@mui/material' +import { Chat as ChatIcon, Close as CloseIcon } from '@mui/icons-material' +import ChatWindow from './ChatWindow' +import { useChatAssistant } from '@/hooks/useChatAssistant' + +const ChatWidget = () => { + const [open, setOpen] = useState(false) + const [isMaximized, setIsMaximized] = useState(false) + const [chatKey, setChatKey] = useState(0) // Key to force remount + const isMobile = useMediaQuery('(max-width: 650px)') + const chat = useChatAssistant() + + const handleToggle = () => { + if (!open) { + // Reset chat when opening - increment key to force remount + chat.clearMessages() + setChatKey((prev) => prev + 1) + setIsMaximized(false) + } + setOpen(!open) + } + + return ( + <> + {/* Chat Window */} + + + + setIsMaximized(!isMaximized)} + /> + + + + + {/* Floating Action Button - hidden on mobile when chat is open */} + + {open ? ( + <> + {' '} + + + + Close Assistant + + ) : ( + <> + + + + LCFS Assistant + + )} + + + ) +} + +export default ChatWidget diff --git a/frontend/src/components/LCFSAssistant/ChatWindow.jsx b/frontend/src/components/LCFSAssistant/ChatWindow.jsx new file mode 100644 index 000000000..5b2237387 --- /dev/null +++ b/frontend/src/components/LCFSAssistant/ChatWindow.jsx @@ -0,0 +1,658 @@ +import { useRef, useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { + Box, + Typography, + Divider, + Alert, + AlertTitle, + Button, + Link, + useMediaQuery +} from '@mui/material' +import { + Chat as ChatIcon, + FileDownload as DownloadIcon, + RestartAlt as ClearIcon, + Headset as SupportIcon, + HelpOutline as HelpIcon +} from '@mui/icons-material' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import ChatMessage from './ChatMessage' +import ChatInput from './ChatInput' +import EscalationForm from './EscalationForm' +import { + ChatHeader, + HeaderIconButton, + HeaderDivider +} from './components/ChatHeader' + +const ChatWindow = ({ onClose, chat, isMaximized, onToggleMaximize }) => { + const isMobile = useMediaQuery('(max-width: 650px)') + const { data: currentUser } = useCurrentUser() + const messagesContainerRef = useRef(null) + const scrollTargetRef = useRef(null) + const lastUserMessageIdRef = useRef(null) + const wasLoadingRef = useRef(false) + const [showEscalationForm, setShowEscalationForm] = useState(false) + const [isLowConfidence, setIsLowConfidence] = useState(false) + + const quickQuestions = [ + 'How do I download an Excel file?', + 'How can I submit an allocation agreement?' + ] + + const handleDownloadConversation = () => { + const conversationText = chat.messages + .map((msg) => { + const role = msg.role === 'user' ? 'You' : 'Assistant' + return `${role}:\n${msg.content}\n` + }) + .join('\n---\n\n') + + const blob = new Blob([conversationText], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `lcfs-conversation-${new Date().toISOString().slice(0, 10)}.txt` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + const handleClearConversation = () => { + chat.clearMessages() + } + + // Scroll to latest user message when a new one is sent + useEffect(() => { + if (chat.messages.length === 0) return + + // Find the last user message + const lastUserMessage = [...chat.messages] + .reverse() + .find((msg) => msg.role === 'user') + + // If there's a new user message, scroll to it + if ( + lastUserMessage && + lastUserMessage.id !== lastUserMessageIdRef.current && + scrollTargetRef.current + ) { + lastUserMessageIdRef.current = lastUserMessage.id + + // Small delay to ensure DOM is updated + setTimeout(() => { + scrollTargetRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }) + }, 100) + } + }, [chat.messages]) + + // Auto-scroll when response finishes loading + useEffect(() => { + // Detect when loading finishes + if (wasLoadingRef.current && !chat.isLoading && scrollTargetRef.current) { + // Auto-scroll to show user message and response + setTimeout(() => { + scrollTargetRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }) + }, 100) + } + wasLoadingRef.current = chat.isLoading + }, [chat.isLoading]) + + const handleSendMessage = async (content) => { + await chat.sendMessage(content) + } + + const handleOpenEscalation = (lowConfidence = false) => { + setIsLowConfidence(lowConfidence) + setShowEscalationForm(true) + } + + const handleCloseEscalation = () => { + setShowEscalationForm(false) + setIsLowConfidence(false) + } + + // Reset escalation form when messages are cleared (widget reopened) + useEffect(() => { + if (chat.messages.length === 0) { + setShowEscalationForm(false) + setIsLowConfidence(false) + } + }, [chat.messages.length]) + + // Show escalation form instead of chat + if (showEscalationForm) { + return ( + + + + ) + } + + return ( + + {/* Header */} + 0 && ( + <> + + + + + ) + } + /> + + + + {/* Messages Area */} + + {chat.messages.length === 0 && !chat.isLoading && ( + + {/* Combined Welcome Box */} + + {/* Greeting */} + + Hello{' '} + + {currentUser?.organization?.name || + `${currentUser?.first_name || 'User'}`} + + + + {/* Assistant Message with Icon */} + + + I can assist you with questions about LCFS compliance, + reporting, fuel codes, allocation agreements, and navigating + the portal. + + + ? + + + + {/* Footer Links */} + + + Privacy + + + | + + handleOpenEscalation(false)} + sx={{ + fontSize: '0.875rem', + cursor: 'pointer' + }} + > + Support + + + + + {/* Quick Action Buttons - Stuck to bottom */} + + + Suggested questions + + {quickQuestions.map((question, index) => ( + + ))} + + + )} + + {chat.messages + .filter((message) => message.content && message.content.trim()) + .map((message, index, array) => { + // Find the last user message and scroll to it + // This shows the user's question at the top with the AI response below + const userMessages = array.filter((m) => m.role === 'user') + const lastUserMessage = userMessages[userMessages.length - 1] + const isScrollTarget = message === lastUserMessage + const isLastAssistantMessage = + message.role === 'assistant' && index === array.length - 1 + + return ( + + + {/* Show "Not helpful?" option after the last assistant message */} + {isLastAssistantMessage && !chat.isLoading && ( + + + + )} + + ) + })} + + {/* Loading indicator while AI is generating response */} + {chat.isLoading && ( + + {/* Role label */} + + + Assistant + + + + {/* Loading box */} + + {/* Typing indicator */} + + + {[0, 1, 2].map((i) => ( + + ))} + + + Generating response... + + + + {/* Skeleton lines for content preview */} + + {[100, 85, 70].map((width, i) => ( + + ))} + + + {/* Skeleton for references */} + + + {[1, 2].map((i) => ( + + + + ))} + + + + )} + + {chat.error && ( + + Error + {chat.error} + + )} + + + + + {/* Input Area */} + + + {/* Footer with disclaimer */} + + + AI-generated responses may be inaccurate. Please verify important + information. + + + + ) +} + +ChatWindow.propTypes = { + onClose: PropTypes.func.isRequired, + chat: PropTypes.shape({ + messages: PropTypes.array.isRequired, + isLoading: PropTypes.bool.isRequired, + error: PropTypes.string, + sendMessage: PropTypes.func.isRequired, + editMessage: PropTypes.func, + regenerateResponse: PropTypes.func, + clearMessages: PropTypes.func.isRequired + }).isRequired, + isMaximized: PropTypes.bool, + onToggleMaximize: PropTypes.func +} + +export default ChatWindow diff --git a/frontend/src/components/LCFSAssistant/EscalationForm.jsx b/frontend/src/components/LCFSAssistant/EscalationForm.jsx new file mode 100644 index 000000000..931e5306a --- /dev/null +++ b/frontend/src/components/LCFSAssistant/EscalationForm.jsx @@ -0,0 +1,411 @@ +import { useState } from 'react' +import PropTypes from 'prop-types' +import { + Box, + TextField, + Button, + Alert, + CircularProgress, + InputLabel +} from '@mui/material' +import { + Send as SendIcon, + CheckCircleOutline as SuccessIcon, + Headset as SupportIcon +} from '@mui/icons-material' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { useApiService } from '@/services/useApiService' +import BCTypography from '@/components/BCTypography' +import { + ChatHeader, + HeaderBackButton, + HeaderDivider +} from './components/ChatHeader' + +const ISSUE_TYPES = [ + { value: 'question', label: 'General Question' }, + { value: 'issue', label: 'Report an Issue' }, + { value: 'feedback', label: 'Feedback' } +] + +const FormField = ({ label, required, children }) => ( + + + {label} + {required && *} + + {children} + +) + +FormField.propTypes = { + label: PropTypes.string.isRequired, + required: PropTypes.bool, + children: PropTypes.node.isRequired +} + +const EscalationForm = ({ + onClose, + onCloseWidget, + conversationHistory = [], + isLowConfidence = false, + isMaximized = false, + onToggleMaximize, + isMobile = false +}) => { + const { data: currentUser } = useCurrentUser() + const apiService = useApiService() + + const [formData, setFormData] = useState({ + issueType: '', + description: '' + }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [submitStatus, setSubmitStatus] = useState(null) + const [errorMessage, setErrorMessage] = useState('') + + const handleChange = (field) => (event) => { + setFormData((prev) => ({ + ...prev, + [field]: event.target.value + })) + if (submitStatus === 'error') { + setSubmitStatus(null) + setErrorMessage('') + } + } + + const isFormValid = () => { + return ( + formData.issueType.trim() !== '' && formData.description.trim() !== '' + ) + } + + const userName = currentUser + ? `${currentUser.firstName || ''} ${currentUser.lastName || ''}`.trim() + : '' + const userEmail = currentUser?.email || '' + + const handleSubmit = async (e) => { + e.preventDefault() + if (!isFormValid()) return + + setIsSubmitting(true) + setSubmitStatus(null) + + try { + const formattedConversation = conversationHistory + .map((msg) => { + const role = msg.role === 'user' ? 'User' : 'Assistant' + return `${role}: ${msg.content}` + }) + .join('\n\n---\n\n') + + const payload = { + issue_type: formData.issueType, + description: formData.description, + user_email: userEmail, + user_name: userName, + organization_name: currentUser?.organization?.name || 'Unknown', + organization_id: currentUser?.organization?.organizationId, + conversation_history: formattedConversation, + is_low_confidence: isLowConfidence, + submitted_at: new Date().toISOString() + } + + await apiService.post('/chat/escalate', payload) + setSubmitStatus('success') + } catch (error) { + console.error('Failed to submit escalation:', error) + setSubmitStatus('error') + setErrorMessage( + error.response?.data?.detail || + 'Failed to submit your request. Please try again.' + ) + } finally { + setIsSubmitting(false) + } + } + + // Success state + if (submitStatus === 'success') { + return ( + + {/* Header */} + } + /> + + {/* Content */} + + + + + + + + Thank you! + + + + Your request has been sent to our support team. We'll respond + within 5-10 business days at{' '} + {userEmail}. + + + + + + + ) + } + + return ( + + {/* Header */} + + + + + } + /> + + {/* Scrollable Form Content */} + + {/* Info text */} + + Please describe your issue and our support team will assist you. + + + {submitStatus === 'error' && ( + + {errorMessage} + + )} + + {/* Form Fields */} + + + + {ISSUE_TYPES.map((type) => ( + + ))} + + + + + + + + {/* Info box */} + + + Expected response: 5-10 business days + + {conversationHistory.length > 0 && ( + + Note: {conversationHistory.length} message(s) + from your conversation will be included + + )} + + + {/* Buttons */} + + + + + + + ) +} + +EscalationForm.propTypes = { + onClose: PropTypes.func.isRequired, + onCloseWidget: PropTypes.func.isRequired, + conversationHistory: PropTypes.array, + isLowConfidence: PropTypes.bool, + isMaximized: PropTypes.bool, + onToggleMaximize: PropTypes.func, + isMobile: PropTypes.bool +} + +export default EscalationForm diff --git a/frontend/src/components/LCFSAssistant/PrivacyNotice.jsx b/frontend/src/components/LCFSAssistant/PrivacyNotice.jsx new file mode 100644 index 000000000..1692daf4f --- /dev/null +++ b/frontend/src/components/LCFSAssistant/PrivacyNotice.jsx @@ -0,0 +1,28 @@ +import { Box, Alert } from '@mui/material' +import { PrivacyTip as PrivacyIcon } from '@mui/icons-material' + +const PrivacyNotice = () => { + return ( + + } + sx={{ + py: 0.5, + '& .MuiAlert-message': { + py: 0.5 + } + }} + > + +
  • Your conversation is not stored on our servers
  • +
  • When you close this chat, your history is deleted
  • +
  • Anonymous usage metrics are collected for service improvement
  • +
  • For support, email us at lcfs@gov.bc.ca
  • +
    +
    +
    + ) +} + +export default PrivacyNotice diff --git a/frontend/src/components/LCFSAssistant/components/ChatHeader.jsx b/frontend/src/components/LCFSAssistant/components/ChatHeader.jsx new file mode 100644 index 000000000..1a47483f3 --- /dev/null +++ b/frontend/src/components/LCFSAssistant/components/ChatHeader.jsx @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types' +import { Box, Typography, IconButton, Tooltip, Button } from '@mui/material' +import { + OpenInFull as MaximizeIcon, + CloseFullscreen as MinimizeIcon, + Close as CloseIcon, + ArrowBack as ArrowBackIcon +} from '@mui/icons-material' + +// Reusable icon button with consistent styling +const HeaderIconButton = ({ + icon: Icon, + onClick, + tooltip, + ariaLabel, + isDark = false +}) => { + const baseColor = isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.7)' + const hoverBg = isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)' + const hoverColor = isDark ? '#fff' : '#000' + + return ( + + + + + + ) +} + +// Reusable back button with text +const HeaderBackButton = ({ onClick, label = 'Back', isDark = false }) => { + const baseColor = isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.7)' + const hoverBg = isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)' + const hoverColor = isDark ? '#fff' : '#000' + + return ( + + ) +} + +HeaderBackButton.propTypes = { + onClick: PropTypes.func.isRequired, + label: PropTypes.string, + isDark: PropTypes.bool +} + +HeaderIconButton.propTypes = { + icon: PropTypes.elementType.isRequired, + onClick: PropTypes.func.isRequired, + tooltip: PropTypes.string.isRequired, + ariaLabel: PropTypes.string.isRequired, + isDark: PropTypes.bool +} + +// Vertical divider for header +const HeaderDivider = ({ isDark = false }) => ( + +) + +HeaderDivider.propTypes = { + isDark: PropTypes.bool +} + +// Main ChatHeader component +const ChatHeader = ({ + title, + icon: Icon, + bgcolor = '#fcba19', + isDark = false, + isMaximized = false, + isMobile = false, + onToggleMaximize, + onClose, + leftActions, + rightActions +}) => { + const textColor = isDark ? '#fff' : '#000' + const iconBgColor = isDark + ? 'rgba(255, 255, 255, 0.15)' + : 'rgba(0, 0, 0, 0.1)' + + return ( + + {/* Left side - Icon and Title */} + + {leftActions} + + + + + {title} + + + + {/* Right side - Actions */} + + {rightActions} + + {/* Maximize/Minimize - hide on mobile */} + {!isMobile && onToggleMaximize && ( + { + e.currentTarget.blur() + onToggleMaximize() + }} + tooltip={isMaximized ? 'Minimize' : 'Maximize'} + ariaLabel={isMaximized ? 'Minimize' : 'Maximize'} + isDark={isDark} + /> + )} + + {/* Close button */} + {onClose && ( + + )} + + + ) +} + +ChatHeader.propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.elementType.isRequired, + bgcolor: PropTypes.string, + isDark: PropTypes.bool, + isMaximized: PropTypes.bool, + isMobile: PropTypes.bool, + onToggleMaximize: PropTypes.func, + onClose: PropTypes.func, + leftActions: PropTypes.node, + rightActions: PropTypes.node +} + +export { ChatHeader, HeaderIconButton, HeaderDivider, HeaderBackButton } +export default ChatHeader diff --git a/frontend/src/components/LCFSAssistant/index.js b/frontend/src/components/LCFSAssistant/index.js new file mode 100644 index 000000000..cdf8f8c92 --- /dev/null +++ b/frontend/src/components/LCFSAssistant/index.js @@ -0,0 +1,6 @@ +export { default as ChatWidget } from './ChatWidget' +export { default as ChatWindow } from './ChatWindow' +export { default as ChatMessage } from './ChatMessage' +export { default as ChatInput } from './ChatInput' +export { default as PrivacyNotice } from './PrivacyNotice' +export { default } from './ChatWidget' diff --git a/frontend/src/constants/config.js b/frontend/src/constants/config.js index 5711225a5..c64393c6a 100644 --- a/frontend/src/constants/config.js +++ b/frontend/src/constants/config.js @@ -38,7 +38,8 @@ export const FEATURE_FLAGS = { FSE_IMPORT_EXPORT: 'fseImportExport', ALLOCATION_AGREEMENT_IMPORT_EXPORT: 'allocationAgreementImportExport', GOVERNMENT_ADJUSTMENT: 'governmentAdjustment', - OBFUSCATED_LINKS: 'obfuscatedLinks' + OBFUSCATED_LINKS: 'obfuscatedLinks', + LCFS_ASSISTANT: 'lcfsAssistant' } export const CONFIG = { @@ -68,6 +69,7 @@ export const CONFIG = { window.lcfs_config.feature_flags.allocationAgreementImportExport ?? false, governmentAdjustment: window.lcfs_config.feature_flags.governmentAdjustment ?? false, - obfuscatedLinks: window.lcfs_config.feature_flags.obfuscatedLinks ?? false + obfuscatedLinks: window.lcfs_config.feature_flags.obfuscatedLinks ?? false, + lcfsAssistant: window.lcfs_config.feature_flags.lcfsAssistant ?? false } } diff --git a/frontend/src/hooks/useChat.js b/frontend/src/hooks/useChat.js new file mode 100644 index 000000000..763c46772 --- /dev/null +++ b/frontend/src/hooks/useChat.js @@ -0,0 +1,156 @@ +import { useState, useCallback, useRef } from 'react' +import { useKeycloak } from '@react-keycloak/web' +import { useSnackbar } from 'notistack' +import { CONFIG } from '@/constants/config' + +export const useChat = () => { + const [messages, setMessages] = useState([]) + const [isStreaming, setIsStreaming] = useState(false) + const [error, setError] = useState(null) + const { keycloak } = useKeycloak() + const { enqueueSnackbar } = useSnackbar() + const abortControllerRef = useRef(null) + + const addMessage = useCallback((message) => { + setMessages(prev => [...prev, message]) + }, []) + + const updateLastMessage = useCallback((content) => { + setMessages(prev => { + const newMessages = [...prev] + const lastMessage = newMessages[newMessages.length - 1] + if (lastMessage && lastMessage.role === 'assistant') { + lastMessage.content = content + } + return newMessages + }) + }, []) + + const streamChat = useCallback(async (userMessage) => { + if (!keycloak.authenticated) { + enqueueSnackbar('Please log in to use the chat feature', { variant: 'error' }) + return + } + + setError(null) + setIsStreaming(true) + + // Add user message + const userMsg = { role: 'user', content: userMessage } + addMessage(userMsg) + + // Add placeholder assistant message + const assistantMsg = { role: 'assistant', content: '' } + addMessage(assistantMsg) + + // Create abort controller for this request + abortControllerRef.current = new AbortController() + + try { + const response = await fetch(`${CONFIG.API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${keycloak.token}`, + 'Accept': 'text/event-stream' + }, + body: JSON.stringify({ + messages: [...messages, userMsg], + model: 'lcfs-rag', + temperature: 0.7, + max_tokens: 500, + stream: true + }), + signal: abortControllerRef.current.signal + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let assistantContent = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const lines = chunk.split('\n').filter(line => line.trim() !== '') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + + if (data === '[DONE]') { + setIsStreaming(false) + return + } + + try { + const parsed = JSON.parse(data) + const content = parsed.choices?.[0]?.delta?.content + + if (content) { + assistantContent += content + updateLastMessage(assistantContent) + } + + // Check for finish reason + if (parsed.choices?.[0]?.finish_reason) { + setIsStreaming(false) + return + } + } catch (parseError) { + console.warn('Failed to parse SSE data:', data, parseError) + } + } + } + } + } catch (error) { + if (error.name === 'AbortError') { + // Stream was intentionally stopped + setIsStreaming(false) + return + } + + console.error('Chat stream error:', error) + setError(error.message || 'Failed to get response') + enqueueSnackbar('Failed to get response from chat service', { variant: 'error' }) + + // Remove the empty assistant message on error + setMessages(prev => prev.slice(0, -1)) + } finally { + setIsStreaming(false) + abortControllerRef.current = null + } + }, [messages, keycloak, addMessage, updateLastMessage, enqueueSnackbar]) + + const stopStreaming = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + setIsStreaming(false) + } + }, []) + + const clearMessages = useCallback(() => { + setMessages([]) + setError(null) + }, []) + + const sendMessage = useCallback(async (content) => { + if (!content.trim() || isStreaming) return + await streamChat(content.trim()) + }, [streamChat, isStreaming]) + + return { + messages, + isStreaming, + error, + sendMessage, + stopStreaming, + clearMessages, + addMessage + } +} \ No newline at end of file diff --git a/frontend/src/hooks/useChatAssistant.js b/frontend/src/hooks/useChatAssistant.js new file mode 100644 index 000000000..23217833e --- /dev/null +++ b/frontend/src/hooks/useChatAssistant.js @@ -0,0 +1,164 @@ +import { useState, useCallback } from 'react' +import { useKeycloak } from '@react-keycloak/web' +import { CONFIG } from '@/constants/config' + +const createAssistantMessage = (responseData) => { + const content = + responseData?.choices?.[0]?.message?.content?.trim() || + 'I was unable to generate a response. Please try again.' + + return { + role: 'assistant', + content, + metadata: responseData?.lcfs_metadata, + id: Date.now() + Math.random() + } +} + +export const useChatAssistant = () => { + const [messages, setMessages] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const { keycloak } = useKeycloak() + + const fetchCompletion = useCallback( + async (conversationMessages) => { + const response = await fetch(`${CONFIG.API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${keycloak.token}` + }, + body: JSON.stringify({ + messages: conversationMessages, + model: 'lcfs-rag', + stream: false + }) + }) + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`) + } + + return response.json() + }, + [keycloak.token] + ) + + /** + * Send a message to the chat assistant + */ + const sendMessage = useCallback( + async (content) => { + if (!content.trim()) return + + const userMessage = { + role: 'user', + content: content.trim(), + id: Date.now() + Math.random() + } + + setMessages((prev) => [...prev, userMessage]) + setIsLoading(true) + setError(null) + + try { + const conversationMessages = [...messages, userMessage] + const completion = await fetchCompletion(conversationMessages) + const assistantMessage = createAssistantMessage(completion) + + setMessages((prev) => [...prev, assistantMessage]) + } catch (err) { + console.error('Chat error:', err) + setError(err.message || 'Failed to send message. Please try again.') + } finally { + setIsLoading(false) + } + }, + [messages, fetchCompletion] + ) + + /** + * Edit a user message and optionally resend + */ + const editMessage = useCallback( + async (messageId, newContent) => { + if (!newContent.trim()) return + + const messageIndex = messages.findIndex((m) => m.id === messageId) + if (messageIndex === -1) return + + const updatedMessages = messages.slice(0, messageIndex + 1) + updatedMessages[messageIndex] = { + ...updatedMessages[messageIndex], + content: newContent.trim() + } + + setMessages(updatedMessages) + setIsLoading(true) + setError(null) + + try { + const completion = await fetchCompletion(updatedMessages) + const assistantMessage = createAssistantMessage(completion) + setMessages((prev) => [...prev, assistantMessage]) + } catch (err) { + console.error('Edit message error:', err) + setError(err.message || 'Failed to resend message. Please try again.') + } finally { + setIsLoading(false) + } + }, + [messages, fetchCompletion] + ) + + /** + * Regenerate the last assistant response + */ + const regenerateResponse = useCallback( + async (assistantMessageId) => { + const messageIndex = messages.findIndex( + (m) => m.id === assistantMessageId + ) + if (messageIndex === -1) return + + const messagesBeforeAssistant = messages.slice(0, messageIndex) + setMessages(messagesBeforeAssistant) + setIsLoading(true) + setError(null) + + try { + const completion = await fetchCompletion(messagesBeforeAssistant) + const assistantMessage = createAssistantMessage(completion) + setMessages((prev) => [...prev, assistantMessage]) + } catch (err) { + console.error('Regenerate error:', err) + setError(err.message || 'Failed to regenerate response.') + } finally { + setIsLoading(false) + } + }, + [messages, fetchCompletion] + ) + + /** + * Clear all messages (privacy-first: nothing stored server-side) + */ + const clearMessages = useCallback(() => { + setMessages([]) + setError(null) + setIsLoading(false) + }, []) + + return { + messages, + isLoading, + error, + sendMessage, + editMessage, + regenerateResponse, + clearMessages + } +} + +export default useChatAssistant diff --git a/frontend/src/hooks/useVoice.js b/frontend/src/hooks/useVoice.js new file mode 100644 index 000000000..fa7bb2ff1 --- /dev/null +++ b/frontend/src/hooks/useVoice.js @@ -0,0 +1,225 @@ +import { useState, useEffect, useRef, useCallback } from 'react' + +export const useVoice = () => { + const [isListening, setIsListening] = useState(false) + const [isSpeaking, setIsSpeaking] = useState(false) + const [transcript, setTranscript] = useState('') + const [interimTranscript, setInterimTranscript] = useState('') + const [error, setError] = useState(null) + const [isSupported, setIsSupported] = useState(true) + const [confidence, setConfidence] = useState(0) + + const recognitionRef = useRef(null) + const synthesisRef = useRef(null) + const silenceTimerRef = useRef(null) + + // Initialize speech recognition and synthesis + useEffect(() => { + // Check for Speech Recognition support + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition + + if (!SpeechRecognition) { + setIsSupported(false) + setError('Speech recognition is not supported in this browser') + return + } + + // Initialize recognition with professional settings + const recognition = new SpeechRecognition() + recognition.continuous = true // Keep listening + recognition.interimResults = true // Show real-time results + recognition.lang = 'en-US' + recognition.maxAlternatives = 1 + + recognition.onstart = () => { + setIsListening(true) + setError(null) + setTranscript('') + setInterimTranscript('') + setConfidence(0) + } + + recognition.onresult = (event) => { + let interimText = '' + let finalText = '' + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + const transcriptPart = result[0].transcript + + if (result.isFinal) { + finalText += transcriptPart + ' ' + setConfidence(result[0].confidence) + + // Auto-stop after getting final result (professional UX) + if (silenceTimerRef.current) { + clearTimeout(silenceTimerRef.current) + } + silenceTimerRef.current = setTimeout(() => { + if (recognitionRef.current) { + recognition.stop() + } + }, 1500) // Stop after 1.5 seconds of silence + } else { + interimText += transcriptPart + } + } + + if (finalText) { + setTranscript((prev) => (prev + finalText).trim()) + } + setInterimTranscript(interimText) + } + + recognition.onerror = (event) => { + // Only show user-friendly errors + if (event.error === 'no-speech') { + setError('No speech detected. Please try again.') + } else if (event.error === 'audio-capture') { + setError('Microphone not available') + } else if (event.error === 'not-allowed') { + setError('Microphone permission denied') + } + setIsListening(false) + setInterimTranscript('') + } + + recognition.onend = () => { + setIsListening(false) + setInterimTranscript('') + if (silenceTimerRef.current) { + clearTimeout(silenceTimerRef.current) + } + } + + recognitionRef.current = recognition + + // Check for Speech Synthesis support + if (!window.speechSynthesis) { + setError('Text-to-speech is not supported in this browser') + } else { + synthesisRef.current = window.speechSynthesis + } + + // Cleanup + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop() + } + if (synthesisRef.current) { + synthesisRef.current.cancel() + } + } + }, []) + + // Start listening + const startListening = useCallback(() => { + if (!recognitionRef.current || isListening) return + + setTranscript('') + setError(null) + + try { + recognitionRef.current.start() + } catch (err) { + setError('Failed to start speech recognition') + console.error(err) + } + }, [isListening]) + + // Stop listening + const stopListening = useCallback(() => { + if (!recognitionRef.current || !isListening) return + + try { + recognitionRef.current.stop() + } catch (err) { + console.error(err) + } + }, [isListening]) + + // Speak text with natural voice selection + const speak = useCallback((text) => { + if (!synthesisRef.current) { + setError('Text-to-speech is not available') + return + } + + // Cancel any ongoing speech + synthesisRef.current.cancel() + + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = 'en-US' + utterance.rate = 0.95 // Slightly slower for clarity + utterance.pitch = 1.0 + utterance.volume = 1.0 + + // Select the most natural voice available (prefer neural/premium voices) + const voices = synthesisRef.current.getVoices() + const preferredVoice = + voices.find((v) => v.lang === 'en-US' && v.name.includes('Premium')) || + voices.find((v) => v.lang === 'en-US' && v.name.includes('Enhanced')) || + voices.find((v) => v.lang === 'en-US' && v.name.includes('Natural')) || + voices.find((v) => v.lang === 'en-US' && v.name.includes('Google')) || + voices.find((v) => v.lang === 'en-US') + + if (preferredVoice) { + utterance.voice = preferredVoice + } + + utterance.onstart = () => { + setIsSpeaking(true) + setError(null) + } + + utterance.onend = () => { + setIsSpeaking(false) + } + + utterance.onerror = (event) => { + // Silently handle interruption (not an error) + if (event.error !== 'interrupted' && event.error !== 'cancelled') { + setError('Unable to play audio') + } + setIsSpeaking(false) + } + + try { + synthesisRef.current.speak(utterance) + } catch (err) { + setError('Failed to speak text') + console.error(err) + setIsSpeaking(false) + } + }, []) + + // Stop speaking + const stopSpeaking = useCallback(() => { + if (!synthesisRef.current) return + + try { + synthesisRef.current.cancel() + setIsSpeaking(false) + } catch (err) { + console.error(err) + } + }, []) + + return { + // State + isListening, + isSpeaking, + transcript, + interimTranscript, + confidence, + error, + isSupported, + + // Methods + startListening, + stopListening, + speak, + stopSpeaking + } +} diff --git a/frontend/src/routes/routeConfig/miscRoutes.jsx b/frontend/src/routes/routeConfig/miscRoutes.jsx index ec86319c8..70a8b7140 100644 --- a/frontend/src/routes/routeConfig/miscRoutes.jsx +++ b/frontend/src/routes/routeConfig/miscRoutes.jsx @@ -1,6 +1,7 @@ import ROUTES from '../routes' import { Dashboard } from '@/views/Dashboard' import FormView from '@/views/Forms/FormView' +import { Chat } from '@/views/Chat' export const miscRoutes = [ { @@ -19,5 +20,12 @@ export const miscRoutes = [ path: ROUTES.FORMS.VIEW_AUTHENTICATED, element: , handle: { title: 'Form' } + }, + { + name: 'Chat', + key: 'chat', + path: ROUTES.CHAT, + element: , + handle: { title: 'LCFS Assistant' } } ] diff --git a/frontend/src/routes/routes.js b/frontend/src/routes/routes.js index 573100d4e..013a27bfd 100644 --- a/frontend/src/routes/routes.js +++ b/frontend/src/routes/routes.js @@ -108,7 +108,9 @@ export const ROUTES = { FORMS: { VIEW: '/forms/:formSlug/:linkKey', VIEW_AUTHENTICATED: '/forms/:formSlug' - } + }, + + CHAT: '/chat' } /** diff --git a/frontend/src/views/Chat/Chat.jsx b/frontend/src/views/Chat/Chat.jsx new file mode 100644 index 000000000..94d1974b3 --- /dev/null +++ b/frontend/src/views/Chat/Chat.jsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react' +import { + Box, + Paper, + Container, + Toolbar, + IconButton, + Divider +} from '@mui/material' +import ClearIcon from '@mui/icons-material/Clear' +import BCTypography from '@/components/BCTypography' +import BCButton from '@/components/BCButton' +import { useChat } from '@/hooks/useChat' +import { ChatMessages } from './components/ChatMessages' +import { ChatInput } from './components/ChatInput' + +export const Chat = () => { + const [input, setInput] = useState('') + const { + messages, + isStreaming, + error, + sendMessage, + stopStreaming, + clearMessages + } = useChat() + + const handleSend = async () => { + if (!input.trim() || isStreaming) return + + const messageContent = input.trim() + setInput('') // Clear input immediately for better UX + await sendMessage(messageContent) + } + + const handleStop = () => { + stopStreaming() + } + + const handleClear = () => { + clearMessages() + } + + return ( + + + {/* Header */} + + + LCFS Assistant + + + {messages.length > 0 && ( + } + onClick={handleClear} + disabled={isStreaming} + sx={{ + color: 'primary.contrastText', + borderColor: 'primary.contrastText', + '&:hover': { + bgcolor: 'rgba(255, 255, 255, 0.1)', + borderColor: 'primary.contrastText' + } + }} + > + Clear Chat + + )} + + + + + {/* Messages Area */} + + + + + {/* Input Area */} + + + + {/* Footer */} + + + LCFS Assistant can help with accounting, compliance, and regulatory + questions. Information may not always be accurate. + + + + ) +} diff --git a/frontend/src/views/Chat/components/ChatInput.jsx b/frontend/src/views/Chat/components/ChatInput.jsx new file mode 100644 index 000000000..06543b9aa --- /dev/null +++ b/frontend/src/views/Chat/components/ChatInput.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import { Box, TextField, IconButton, InputAdornment } from '@mui/material' +import BCButton from '@/components/BCButton' +import SendIcon from '@mui/icons-material/Send' +import StopIcon from '@mui/icons-material/Stop' + +export const ChatInput = ({ + value, + onChange, + onSend, + onStop, + disabled = false, + isStreaming = false, + placeholder = "Ask me about accounting, LCFS regulations, or compliance..." +}) => { + const handleKeyPress = (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + if (!disabled && !isStreaming && value.trim()) { + onSend() + } + } + } + + const handleSend = () => { + if (!disabled && !isStreaming && value.trim()) { + onSend() + } + } + + const handleStop = () => { + if (isStreaming && onStop) { + onStop() + } + } + + return ( + { + e.preventDefault() + handleSend() + }} + > + + onChange(e.target.value)} + onKeyPress={handleKeyPress} + placeholder={placeholder} + disabled={disabled} + variant="outlined" + size="small" + InputProps={{ + endAdornment: ( + + {isStreaming ? ( + + + + ) : ( + + + + )} + + ) + }} + /> + + + {isStreaming ? 'Stop' : 'Send'} + + + + ) +} \ No newline at end of file diff --git a/frontend/src/views/Chat/components/ChatMessage.jsx b/frontend/src/views/Chat/components/ChatMessage.jsx new file mode 100644 index 000000000..65727a9e0 --- /dev/null +++ b/frontend/src/views/Chat/components/ChatMessage.jsx @@ -0,0 +1,53 @@ +import React from 'react' +import { Box, Avatar, Paper } from '@mui/material' +import BCTypography from '@/components/BCTypography' +import PersonIcon from '@mui/icons-material/Person' +import SmartToyIcon from '@mui/icons-material/SmartToy' +import { StreamingMessage } from './StreamingMessage' + +export const ChatMessage = ({ message, isStreaming = false }) => { + const isUser = message.role === 'user' + const isAssistant = message.role === 'assistant' + + return ( + + + {isUser ? : } + + + + {isAssistant && isStreaming ? ( + + ) : ( + + {message.content} + + )} + + + ) +} \ No newline at end of file diff --git a/frontend/src/views/Chat/components/ChatMessages.jsx b/frontend/src/views/Chat/components/ChatMessages.jsx new file mode 100644 index 000000000..43c358b26 --- /dev/null +++ b/frontend/src/views/Chat/components/ChatMessages.jsx @@ -0,0 +1,98 @@ +import React, { useEffect, useRef } from 'react' +import { Box, Alert } from '@mui/material' +import { ChatMessage } from './ChatMessage' +import BCTypography from '@/components/BCTypography' + +export const ChatMessages = ({ + messages = [], + isStreaming = false, + error = null +}) => { + const messagesEndRef = useRef(null) + const messagesContainerRef = useRef(null) + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }) + } + }, [messages, isStreaming]) + + const renderWelcomeMessage = () => ( + + + LCFS Assistant + + + Ask me questions about accounting principles, LCFS regulations, compliance reporting, + or any other topics related to the Low Carbon Fuel Standard program. + + + ) + + const renderMessages = () => ( + + {messages.map((message, index) => { + const isLastAssistantMessage = + index === messages.length - 1 && message.role === 'assistant' + + return ( + + ) + })} + + +
    + + ) + + return ( + + {error && ( + + + {error} + + + )} + + {messages.length === 0 ? renderWelcomeMessage() : renderMessages()} + + ) +} \ No newline at end of file diff --git a/frontend/src/views/Chat/components/StreamingMessage.jsx b/frontend/src/views/Chat/components/StreamingMessage.jsx new file mode 100644 index 000000000..7a8db7995 --- /dev/null +++ b/frontend/src/views/Chat/components/StreamingMessage.jsx @@ -0,0 +1,29 @@ +import React from 'react' +import { Typography, Box } from '@mui/material' +import { keyframes } from '@mui/system' +import BCTypography from '@/components/BCTypography' + +const blink = keyframes` + 0% { opacity: 1; } + 50% { opacity: 0; } + 100% { opacity: 1; } +` + +export const StreamingMessage = ({ content, isStreaming }) => { + return ( + + + {content} + {isStreaming && ( + + โ–Š + + )} + + + ) +} \ No newline at end of file diff --git a/frontend/src/views/Chat/index.js b/frontend/src/views/Chat/index.js new file mode 100644 index 000000000..ecc98299e --- /dev/null +++ b/frontend/src/views/Chat/index.js @@ -0,0 +1 @@ +export { Chat } from './Chat' \ No newline at end of file diff --git a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx index 82ff4f7b1..e23d525a8 100644 --- a/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx +++ b/frontend/src/views/ComplianceReports/EditViewComplianceReport.jsx @@ -844,7 +844,7 @@ export const EditViewComplianceReport = ({ isError, error }) => { sx={{ position: 'fixed', bottom: 75, - right: 24 + right: 210 }} > {isScrollingUp ? ( diff --git a/frontend/src/views/ComplianceReports/ViewLegacyComplianceReport.jsx b/frontend/src/views/ComplianceReports/ViewLegacyComplianceReport.jsx index 4f847ae38..83b121aa0 100644 --- a/frontend/src/views/ComplianceReports/ViewLegacyComplianceReport.jsx +++ b/frontend/src/views/ComplianceReports/ViewLegacyComplianceReport.jsx @@ -179,7 +179,7 @@ export const ViewLegacyComplianceReport = ({ reportData, error, isError }) => { sx={{ position: 'fixed', bottom: 75, - right: 24 + right: 210 }} > {isScrollingUp ? ( diff --git a/rag-system/Dockerfile b/rag-system/Dockerfile new file mode 100644 index 000000000..c865b9cc0 --- /dev/null +++ b/rag-system/Dockerfile @@ -0,0 +1,43 @@ +FROM deepset/hayhooks:v0.10.1 + +# Copy requirements file +COPY requirements.txt /tmp/requirements.txt + +# Install all dependencies from requirements.txt +RUN pip install -r /tmp/requirements.txt && rm /tmp/requirements.txt + +# Set default model names as build args (can be overridden) +ARG EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 +ARG RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2 + +# Pre-download embedding models during build to avoid runtime downloads +# This significantly speeds up container startup time +RUN python -c "import os; \ + from sentence_transformers import SentenceTransformer; \ + embedding_model = os.getenv('EMBEDDING_MODEL', '${EMBEDDING_MODEL}'); \ + reranker_model = os.getenv('RERANKER_MODEL', '${RERANKER_MODEL}'); \ + print(f'Downloading {embedding_model} embedding model...'); \ + SentenceTransformer(embedding_model); \ + print(f'{embedding_model} downloaded successfully'); \ + print(f'Downloading {reranker_model} reranker model...'); \ + SentenceTransformer(reranker_model); \ + print(f'{reranker_model} downloaded successfully'); \ + print('All models pre-cached in image');" + +# Copy utils directory for reusable RAG components +COPY utils/ /opt/utils/ + +# Copy data directory for document ingestion (including test files) +COPY data/ /opt/data/ + +# Pipelines directory will be mounted as volume for development +# Set working directory +WORKDIR /opt/pipelines + +# Set environment variables +ENV HAYSTACK_TELEMETRY_ENABLED=False +ENV TOKENIZERS_PARALLELISM=false +ENV PYTHONUNBUFFERED=1 + +# Run hayhooks server +CMD ["hayhooks", "run", "--host", "0.0.0.0", "--port", "1416"] \ No newline at end of file diff --git a/rag-system/README.md b/rag-system/README.md new file mode 100644 index 000000000..87240261e --- /dev/null +++ b/rag-system/README.md @@ -0,0 +1,186 @@ +# LCFS RAG System + +Simple LLM API using Haystack and Hayhooks with open-source models for the LCFS project. + +## Quick Start + +### Using Docker Compose + +```bash +# Start core LCFS services + RAG LLM +docker-compose -f docker-compose.yml -f docker-compose.rag.yml up -d + +# Or start just the RAG LLM service +docker-compose -f docker-compose.rag.yml up rag-llm + +# Run automated tests +./test_rag.sh +``` + +### RAG System Features โœจ + +- **Document Ingestion**: Automatically loads and embeds knowledge from `data/` directory +- **Semantic Search**: Uses sentence-transformers for similarity-based retrieval +- **Context Augmentation**: Relevant document chunks included in LLM prompts +- **No API Keys Required**: Uses local open-source models from Hugging Face + +## API Usage + +The service exposes a Hayhooks API on port 1416: + +### Query the RAG System + +```bash +# RAG query with context retrieval +curl -X POST http://localhost:1416/lcfs_pipeline/run \ + -H "Content-Type: application/json" \ + -d '{"query": "What are the key accounting principles?"}' + +# Dedicated RAG pipeline with detailed context +curl -X POST http://localhost:1416/rag_pipeline/run \ + -H "Content-Type: application/json" \ + -d '{"query": "Explain revenue recognition principle"}' | jq + +# Control number of retrieved documents +curl -X POST http://localhost:1416/lcfs_pipeline/run \ + -H "Content-Type: application/json" \ + -d '{"query": "How does depreciation work?", "top_k": 5}' | jq +``` + +### Example RAG Response + +```json +{ + "answer": "Revenue recognition principle states that revenue should be recorded when earned...", + "query": "Explain revenue recognition principle", + "context": [ + { + "content": "Revenue should be recorded when it is earned, regardless of when cash is received...", + "source": "accounting_principles.md", + "score": 0.89 + } + ], + "num_documents_retrieved": 3, + "model": "microsoft/DialoGPT-small", + "embedding_model": "sentence-transformers/all-MiniLM-L6-v2", + "rag_enabled": true +} +``` + +### Health Check + +```bash +curl http://localhost:1416/status +``` + +## Available Endpoints + +Current pipelines: +- `POST /lcfs_pipeline/run` - LCFS-focused RAG with context retrieval +- `POST /rag_pipeline/run` - General RAG pipeline with detailed context display +- `POST /simple_echo/run` - Echo test pipeline + +System endpoints: +- `GET /status` - Service health check and list all pipelines +- `GET /docs` - Interactive Swagger UI documentation +- `GET /openapi.json` - OpenAPI specification + +## Creating New Pipelines + +The system uses a clean directory-based structure for managing multiple pipelines: + +### Directory Structure + +``` +rag-system/ +โ”œโ”€โ”€ Dockerfile # Simple: just copies pipelines/ directory +โ”œโ”€โ”€ README.md # This file +โ””โ”€โ”€ pipelines/ # All pipelines organized here + โ”œโ”€โ”€ lcfs_pipeline/ + โ”‚ โ””โ”€โ”€ pipeline_wrapper.py # LLM chat pipeline + โ”œโ”€โ”€ simple_echo/ + โ”‚ โ””โ”€โ”€ pipeline_wrapper.py # Echo test pipeline + โ””โ”€โ”€ your_new_pipeline/ + โ””โ”€โ”€ pipeline_wrapper.py # Your custom pipeline +``` + +### Adding a New Pipeline + +1. **Create the directory structure**: +```bash +mkdir rag-system/pipelines/your_pipeline_name/ +``` + +2. **Create the pipeline wrapper** (`pipeline_wrapper.py`): +```python +"""Your custom pipeline wrapper""" + +from typing import Dict, Any +from haystack import Pipeline +from hayhooks import BasePipelineWrapper + +class PipelineWrapper(BasePipelineWrapper): + """Your Pipeline Description""" + + def setup(self) -> None: + """Setup your pipeline components""" + # Create your pipeline here + self.pipeline = Pipeline() + # Add components, connections, etc. + + def run_api(self, your_input: str) -> Dict[str, Any]: + """ + Describe your API endpoint here. + + Args: + your_input: Description of input parameter + + Returns: + Dictionary containing your response + """ + result = self.pipeline.run({"component": {"input": your_input}}) + return {"output": result} +``` + +3. **Rebuild and deploy**: +```bash +docker compose -f docker-compose.rag.yml up --build -d +``` + +4. **Your new endpoint is automatically available**: +```bash +# New endpoint: POST /your_pipeline_name/run +curl -X POST http://localhost:1416/your_pipeline_name/run \ + -H "Content-Type: application/json" \ + -d '{"your_input": "test"}' +``` + +### Pipeline Wrapper Requirements + +Each `pipeline_wrapper.py` must: +- Extend `BasePipelineWrapper` +- Have a `setup()` method that creates `self.pipeline` +- Have a `run_api()` method with proper type hints and docstring +- Be completely self-contained (all imports, dependencies, etc.) + +### Key Benefits + +- โœ… **No Dockerfile changes needed** - just create new directories +- โœ… **Automatic API generation** - endpoint created from directory name +- โœ… **Built-in Swagger docs** - docstrings become API documentation +- โœ… **Easy to manage** - each pipeline isolated in its own directory + +## Architecture + +- **Haystack 2.0**: LLM pipeline framework +- **Hayhooks**: REST API server for Haystack pipelines +- **HuggingFace Local**: Open-source models (DialoGPT-small) +- **Docker**: Containerized deployment +- **Directory-based pipelines**: Clean separation and easy management + +## Models Available + +The current setup uses `microsoft/DialoGPT-small` but you can easily switch to: +- `google/flan-t5-base` - Google's T5 model +- `microsoft/DialoGPT-medium` - Larger version +- Any other Hugging Face model compatible with `HuggingFaceLocalGenerator` \ No newline at end of file diff --git a/rag-system/data/LCFS_Portal_bceid_user_guide_v2.pdf b/rag-system/data/LCFS_Portal_bceid_user_guide_v2.pdf new file mode 100644 index 000000000..147feac1a Binary files /dev/null and b/rag-system/data/LCFS_Portal_bceid_user_guide_v2.pdf differ diff --git a/rag-system/data/final_rlcf007-2023_-_summary_2010-2024.pdf b/rag-system/data/final_rlcf007-2023_-_summary_2010-2024.pdf new file mode 100644 index 000000000..0cc585e95 Binary files /dev/null and b/rag-system/data/final_rlcf007-2023_-_summary_2010-2024.pdf differ diff --git a/rag-system/data/rlcf-005_exemption_reports_12feb2025.pdf b/rag-system/data/rlcf-005_exemption_reports_12feb2025.pdf new file mode 100644 index 000000000..c75ce458a Binary files /dev/null and b/rag-system/data/rlcf-005_exemption_reports_12feb2025.pdf differ diff --git a/rag-system/data/rlcf-014_projects_supported_under_ia_202503.pdf b/rag-system/data/rlcf-014_projects_supported_under_ia_202503.pdf new file mode 100644 index 000000000..e721aa593 Binary files /dev/null and b/rag-system/data/rlcf-014_projects_supported_under_ia_202503.pdf differ diff --git a/rag-system/data/rlcf-015a_allocation_agreements_202503.pdf b/rag-system/data/rlcf-015a_allocation_agreements_202503.pdf new file mode 100644 index 000000000..76402b0a8 Binary files /dev/null and b/rag-system/data/rlcf-015a_allocation_agreements_202503.pdf differ diff --git a/rag-system/data/rlcf-020_2024_12_30.pdf b/rag-system/data/rlcf-020_2024_12_30.pdf new file mode 100644 index 000000000..d79ea89e2 Binary files /dev/null and b/rag-system/data/rlcf-020_2024_12_30.pdf differ diff --git a/rag-system/data/rlcf-022_prescribed_purposes_other_than_transportation_202503.pdf b/rag-system/data/rlcf-022_prescribed_purposes_other_than_transportation_202503.pdf new file mode 100644 index 000000000..e2d4e0c06 Binary files /dev/null and b/rag-system/data/rlcf-022_prescribed_purposes_other_than_transportation_202503.pdf differ diff --git a/rag-system/data/rlcf-024-proxy_carbon_intensities_202503.pdf b/rag-system/data/rlcf-024-proxy_carbon_intensities_202503.pdf new file mode 100644 index 000000000..94ec8ff91 Binary files /dev/null and b/rag-system/data/rlcf-024-proxy_carbon_intensities_202503.pdf differ diff --git a/rag-system/data/rlcf-031-naval_distillate_fuel_2025_06_26.pdf b/rag-system/data/rlcf-031-naval_distillate_fuel_2025_06_26.pdf new file mode 100644 index 000000000..add554415 Binary files /dev/null and b/rag-system/data/rlcf-031-naval_distillate_fuel_2025_06_26.pdf differ diff --git a/rag-system/data/rlcf003_compliance_reporting_requirements_march2025.pdf b/rag-system/data/rlcf003_compliance_reporting_requirements_march2025.pdf new file mode 100644 index 000000000..ea29dda2e Binary files /dev/null and b/rag-system/data/rlcf003_compliance_reporting_requirements_march2025.pdf differ diff --git a/rag-system/data/rlcf004_-_labelling_requirements_27feb2025.pdf b/rag-system/data/rlcf004_-_labelling_requirements_27feb2025.pdf new file mode 100644 index 000000000..047c20b46 Binary files /dev/null and b/rag-system/data/rlcf004_-_labelling_requirements_27feb2025.pdf differ diff --git a/rag-system/data/rlcf006_carbon_intensity_records_feb132025.pdf b/rag-system/data/rlcf006_carbon_intensity_records_feb132025.pdf new file mode 100644 index 000000000..43587ef34 Binary files /dev/null and b/rag-system/data/rlcf006_carbon_intensity_records_feb132025.pdf differ diff --git a/rag-system/data/rlcf008_-_carbon_intensity_applications_2025-06-17_final.pdf b/rag-system/data/rlcf008_-_carbon_intensity_applications_2025-06-17_final.pdf new file mode 100644 index 000000000..0592ac81f Binary files /dev/null and b/rag-system/data/rlcf008_-_carbon_intensity_applications_2025-06-17_final.pdf differ diff --git a/rag-system/data/rlcf009_reporting_responsibility_for_type_b_fuels2025feb27.pdf b/rag-system/data/rlcf009_reporting_responsibility_for_type_b_fuels2025feb27.pdf new file mode 100644 index 000000000..dd58cd12a Binary files /dev/null and b/rag-system/data/rlcf009_reporting_responsibility_for_type_b_fuels2025feb27.pdf differ diff --git a/rag-system/data/rlcf011_-_approved_version_of_ghgenius_and_global_warming_potentialsdec2023_202503.pdf b/rag-system/data/rlcf011_-_approved_version_of_ghgenius_and_global_warming_potentialsdec2023_202503.pdf new file mode 100644 index 000000000..613464b62 Binary files /dev/null and b/rag-system/data/rlcf011_-_approved_version_of_ghgenius_and_global_warming_potentialsdec2023_202503.pdf differ diff --git a/rag-system/data/rlcf012_approved_carbon_intensities_archive_11june2025.pdf b/rag-system/data/rlcf012_approved_carbon_intensities_archive_11june2025.pdf new file mode 100644 index 000000000..280caced7 Binary files /dev/null and b/rag-system/data/rlcf012_approved_carbon_intensities_archive_11june2025.pdf differ diff --git a/rag-system/data/rlcf012_approved_carbon_intensities_current_11june2025.pdf b/rag-system/data/rlcf012_approved_carbon_intensities_current_11june2025.pdf new file mode 100644 index 000000000..647cc6deb Binary files /dev/null and b/rag-system/data/rlcf012_approved_carbon_intensities_current_11june2025.pdf differ diff --git a/rag-system/data/rlcf016_-_fuel_identification_requirements_202503.pdf b/rag-system/data/rlcf016_-_fuel_identification_requirements_202503.pdf new file mode 100644 index 000000000..31cdc705b Binary files /dev/null and b/rag-system/data/rlcf016_-_fuel_identification_requirements_202503.pdf differ diff --git a/rag-system/data/rlcf018_-_treatment_of_ethanol_made_from_mmsw_2025mar.pdf b/rag-system/data/rlcf018_-_treatment_of_ethanol_made_from_mmsw_2025mar.pdf new file mode 100644 index 000000000..8d7299540 Binary files /dev/null and b/rag-system/data/rlcf018_-_treatment_of_ethanol_made_from_mmsw_2025mar.pdf differ diff --git a/rag-system/data/rlcf019_-_coprocessing_methodology_final.pdf b/rag-system/data/rlcf019_-_coprocessing_methodology_final.pdf new file mode 100644 index 000000000..7f8d04707 Binary files /dev/null and b/rag-system/data/rlcf019_-_coprocessing_methodology_final.pdf differ diff --git a/rag-system/data/rlcf021_administrative_monetary_penalties_202408_202503.pdf b/rag-system/data/rlcf021_administrative_monetary_penalties_202408_202503.pdf new file mode 100644 index 000000000..93636631c Binary files /dev/null and b/rag-system/data/rlcf021_administrative_monetary_penalties_202408_202503.pdf differ diff --git a/rag-system/data/rlcf023_supplying_lng_or_cng_in_bc_june_2024_2025mar.pdf b/rag-system/data/rlcf023_supplying_lng_or_cng_in_bc_june_2024_2025mar.pdf new file mode 100644 index 000000000..272ff0d3b Binary files /dev/null and b/rag-system/data/rlcf023_supplying_lng_or_cng_in_bc_june_2024_2025mar.pdf differ diff --git a/rag-system/data/rlcf025_use_of_alternative_methods_for_determining_carbon_intensities_june2024_202503.pdf b/rag-system/data/rlcf025_use_of_alternative_methods_for_determining_carbon_intensities_june2024_202503.pdf new file mode 100644 index 000000000..ca4c1e562 Binary files /dev/null and b/rag-system/data/rlcf025_use_of_alternative_methods_for_determining_carbon_intensities_june2024_202503.pdf differ diff --git a/rag-system/data/rlcf030_uci_for_lng_used_in_dual_fuel_marine_vessels.pdf b/rag-system/data/rlcf030_uci_for_lng_used_in_dual_fuel_marine_vessels.pdf new file mode 100644 index 000000000..9050cd1aa Binary files /dev/null and b/rag-system/data/rlcf030_uci_for_lng_used_in_dual_fuel_marine_vessels.pdf differ diff --git a/rag-system/data/rlcf_13_transfer_of_compliance_units_07292025.pdf b/rag-system/data/rlcf_13_transfer_of_compliance_units_07292025.pdf new file mode 100644 index 000000000..5800db335 Binary files /dev/null and b/rag-system/data/rlcf_13_transfer_of_compliance_units_07292025.pdf differ diff --git a/rag-system/pipelines/lcfs_rag/pipeline_wrapper.py b/rag-system/pipelines/lcfs_rag/pipeline_wrapper.py new file mode 100644 index 000000000..eeef7b467 --- /dev/null +++ b/rag-system/pipelines/lcfs_rag/pipeline_wrapper.py @@ -0,0 +1,318 @@ +"""LCFS RAG Pipeline - Refactored to use extracted utils.""" + +import os +import sys +from pathlib import Path +from typing import Dict, Any, List + +# Add utils to path +utils_path = Path("/opt/utils") +sys.path.insert(0, str(utils_path)) + +# Import utils +from document_stores import ( # noqa: E402 + create_document_store, + create_bm25_store, + populate_bm25_from_vector_store, +) +from models import ( # noqa: E402 + load_text_embedder, + load_document_embedder, + load_reranker, + load_ollama_generator, + create_retrievers, + create_document_joiner, +) +from document_processing import ( # noqa: E402 + check_for_existing_embeddings, + load_documents_step, + process_documents_for_indexing, +) +from openai_adapter import run_openai_chat # noqa: E402 +from config import get_env_config, get_search_config # noqa: E402 +from pipeline_builder import build_hybrid_pipeline, create_prompt_builder # noqa: E402 +from progress_logging import ( + log_header, + log_completion, + log_error, + log_progress, +) # noqa: E402 +from constants import DEFAULT_SUPPORTED_FORMATS, get_collection_name # noqa: E402 + +from hayhooks import BasePipelineWrapper # noqa: E402 + + +class PipelineWrapper(BasePipelineWrapper): + """LCFS RAG Pipeline using extracted utils - focused on domain-specific logic.""" + + def __init__(self): + log_header("LCFS RAG Pipeline - Initializing with utils") + super().__init__() + + # Get configuration from utils + self.config = get_env_config() + self.search_config = get_search_config() + + # Debug logging configuration (from environment) + self.debug_logging = os.getenv("RAG_DEBUG_LOGGING", "false").lower() == "true" + + # LCFS-specific configuration + self.domain_name = "LCFS" + self.domain_description = ( + "BC's Low Carbon Fuel Standard (LCFS) regulations and related fuel policies" + ) + self.collection_name = get_collection_name("lcfs") + self.source_base_url = self.config.get("source_base_url") + + # LCFS-specific search parameters (override defaults for regulatory precision) + self.search_config["relevance_threshold"] = ( + 0.80 # Slightly stricter filter keeps only high-signal passages + ) + self.search_config["embedding_top_k"] = ( + 3 # Lower fan-out = faster retrieval with minimal quality loss + ) + self.search_config["bm25_top_k"] = ( + 3 # Match embedding fan-out to keep join + rerank light + ) + self.search_config["reranker_top_k"] = 2 # Re-rank fewer docs for latency gains + + # LCFS fallback message + self.fallback_message = ( + "I don't have information about that topic in my LCFS knowledge base. " + "I can only answer questions about Low Carbon Fuel Standard regulations, " + "compliance reporting, carbon intensities, fuel pathways, credit trading, " + "and related BC government fuel regulations. Please ask me about LCFS topics." + ) + + # LCFS-specific metadata enricher + self.metadata_enricher = lambda meta: { + **meta, + "type": "lcfs_regulation", + "domain": "lcfs", + } + + log_completion("LCFS pipeline initialized with domain-specific configuration") + + def setup(self) -> None: + """Setup the LCFS RAG pipeline using utils.""" + try: + log_header("Starting LCFS RAG pipeline setup...") + + # Create document stores using utils + log_progress("Setting up document stores...") + self.document_store, is_persistent = create_document_store( + store_type="auto", + index_name=self.collection_name, + embedding_dim=self.config["embedding_dim"], + ) + self.bm25_store = create_bm25_store() + + # Load models using utils + log_progress("Loading models...") + text_embedder = load_text_embedder(self.config["embedding_model"]) + + # Create retrievers using utils + log_progress("Creating retrievers...") + embedding_retriever, bm25_retriever = create_retrievers( + self.document_store, + self.bm25_store, + self.search_config["embedding_top_k"], + self.search_config["bm25_top_k"], + is_persistent, + ) + + # Create document joiner using utils + document_joiner = create_document_joiner() + + # Load reranker using utils + reranker = load_reranker( + self.config["reranker_model"], self.search_config["reranker_top_k"] + ) + + # Create LCFS-specific prompt template (government professional) + lcfs_template = """You are the official assistant for British Columbiaโ€™s Low Carbon Fuel Standard (LCFS) program. Respond like a government policy analyst: precise, confident, and grounded ONLY in the LCFS PDFs below. Never output placeholder textโ€”every section must contain real information drawn from the documents. + +STRICT RULES +1. Cite the exact bulletin/PDF title and section or page for every fact (e.g., โ€œRLCF-015a ยง3.1 (Marโ€ฏ2025)โ€ or โ€œLCFS_Portal_bceid_user_guide_v2.pdf pp. 18โ€‘20โ€). +2. If a user asks โ€œtell me moreโ€ or โ€œwhat topics,โ€ list LCFS subject areas (compliance reporting, carbon intensity applications, credit transfers, exemptions, LCFS Portal workflows, initiative agreements, LNG/CNG reporting, marine fuel UCIs, prescribed purposes, Type B fuels, etc.) rather than using the fallback. +3. Use "[FALLBACK_MESSAGE]" only when the request is clearly outside LCFS scope (e.g., taxes, weather, driver licensing). +4. If the PDFs do not contain the requested detail, say so plainly (โ€œThe LCFS bulletins provided do not specify โ€ฆโ€) and direct the user to lcfs@gov.bc.ca where appropriate. Never invent steps, links, or deadlines. +5. Keep answers concise (about 4โ€‘6 sentences total across all sections) but information-dense, highlighting deadlines, thresholds, portal locations, signatures, or forms explicitly named in the PDFs. +6. Do not echo the userโ€™s question, do not produce โ€œQuestion/Answerโ€ pairs, and do not leave bracketed instructions in the response. Fill the format below with real content only. +7. Sources must list only the PDFs actually cited (one per line, e.g., โ€œRLCF-003 Compliance Reporting Requirements (Mar 2025).pdfโ€). + +REQUIRED FORMAT (replace brackets with actual facts): +Summary: +- Bullet describing the key obligation/outcome + citation +- Bullet highlighting deadline/eligibility/portal detail + citation + +Guidance: +1. Chronological step referencing the exact PDF section, portal area, form, signature, or threshold +2. Next requirement with citation +3. Up to 5 total steps if supported by the documents + +Recommended Actions: +- Actionable next step + PDF citation + timing/condition +- Preparation/documentation task + citation +- Follow-up/escalation step + citation + +Sources: +- PDF title/filename cited (one per line) + +If the user only needs LCFS topic ideas, respond with a concise list of LCFS subject areas drawn from these PDFs (e.g., compliance reporting, carbon intensity applications, allocation agreements). Do not trigger the fallback unless the topic is clearly outside LCFS scope. + +Documents: +{% for document in documents %} +{{ document.content }} +--- +{% endfor %} + +Question: {{ query }} + +Answer:""" + + lcfs_template = lcfs_template.replace( + "[FALLBACK_MESSAGE]", self.fallback_message + ) + prompt_builder = create_prompt_builder(lcfs_template) + + # Load Ollama generator using utils + generator = load_ollama_generator( + self.config["ollama_model"], self.config["ollama_url"] + ) + + # Build pipeline using utils + log_progress("Assembling pipeline...") + self.pipeline = build_hybrid_pipeline( + text_embedder, + embedding_retriever, + bm25_retriever, + document_joiner, + reranker, + prompt_builder, + generator, + ) + + # Handle document indexing using utils + log_progress("Processing LCFS documents...") + self._handle_document_indexing() + + log_completion("LCFS RAG pipeline setup complete and ready to serve!") + + except Exception as e: + log_error(f"LCFS pipeline setup failed: {e}") + raise + + def _handle_document_indexing(self): + """Handle LCFS document indexing using utils.""" + try: + # Check existing embeddings using utils + existing_count = check_for_existing_embeddings(self.document_store) + + if existing_count > 0: + # Populate BM25 from existing using utils + populate_bm25_from_vector_store(self.bm25_store, self.document_store) + log_completion( + "LCFS document processing complete - using existing embeddings" + ) + return + + # Load LCFS documents using utils + docs = load_documents_step( + supported_formats=DEFAULT_SUPPORTED_FORMATS, + metadata_enricher=self.metadata_enricher, + ) + + if docs: + # Create document embedder for indexing + document_embedder = load_document_embedder( + self.config["embedding_model"] + ) + + # Process documents using utils (only pass splitting params) + chunk_count = process_documents_for_indexing( + docs, + document_embedder, + self.document_store, + self.bm25_store, + split_by=self.config.get("split_by", "word"), + split_length=self.config.get("split_length", 100), + split_overlap=self.config.get("split_overlap", 20), + split_threshold=self.config.get("split_threshold", 3), + ) + + log_completion( + f"LCFS document indexing complete - indexed {chunk_count} chunks" + ) + else: + log_completion( + "LCFS document processing complete - no documents to index" + ) + + except Exception as e: + log_error(f"LCFS document indexing failed: {e}") + # Don't raise - indexing failure shouldn't break pipeline + + def run_api(self, messages: List[Dict[str, str]]) -> Dict[str, Any]: + """Process OpenAI-format chat messages using utils.""" + try: + return run_openai_chat( + self.pipeline, + messages, + self.search_config["embedding_top_k"], + self.search_config["bm25_top_k"], + self.search_config["relevance_threshold"], + self.fallback_message, + "lcfs-rag", + debug_logging=self.debug_logging, + doc_base_url=self.source_base_url, + append_sources_to_answer=False, + ) + except Exception as e: + log_error(f"LCFS chat processing failed: {e}") + raise + + def _get_lcfs_terms(self) -> List[str]: + """Return list of LCFS-related terms for reference (domain-specific logic).""" + return [ + "lcfs", + "low carbon fuel", + "carbon intensity", + "fuel pathway", + "credit", + "compliance", + "reporting", + "regulation", + "fuel standard", + "emissions", + "ghg", + "greenhouse gas", + "biofuel", + "renewable", + "ethanol", + "biodiesel", + "hydrogen", + "electric", + "cng", + "lng", + "aviation fuel", + "marine fuel", + "allocation", + "transfer", + "penalty", + "exemption", + "supplier", + "fuel supply", + "carbon", + "energy", + "transportation", + "fuel", + "diesel", + "gasoline", + "rlcf", + "bulletin", + "ghgenius", + "proxy", + "ci value", + "fuel pool", + ] diff --git a/rag-system/requirements.txt b/rag-system/requirements.txt new file mode 100644 index 000000000..3c1b3cbd0 --- /dev/null +++ b/rag-system/requirements.txt @@ -0,0 +1,29 @@ +# RAG System Dependencies +# Generated from Dockerfile pip install commands + +# Core ML/AI dependencies +torch +transformers +sentence-transformers +accelerate +nltk + +# Haystack and integrations +ollama-haystack +qdrant-client +qdrant-haystack + +# Document processing dependencies +lxml>=5.3.0 +pypdf==6.0.0 +markdown-it-py +mdit_plain +python-pptx +python-docx +jq +openpyxl +tabulate +pandas +trafilatura + +# Note: hayhooks is included in the base image (deepset/hayhooks:v0.10.1) \ No newline at end of file diff --git a/rag-system/scripts/pull-model.sh b/rag-system/scripts/pull-model.sh new file mode 100755 index 000000000..ba2348b2e --- /dev/null +++ b/rag-system/scripts/pull-model.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +MODEL_NAME=${OLLAMA_MODEL:-"flan-t5-small"} + +echo "๐Ÿš€ Starting Ollama model management for: $MODEL_NAME" + +# Start ollama in the background +ollama serve & +OLLAMA_PID=$! + +# Wait for ollama to be ready +echo "โณ Waiting for Ollama service to be ready..." +while ! ollama list > /dev/null 2>&1; do + echo " Ollama not ready yet, waiting 2 seconds..." + sleep 2 +done +echo "โœ… Ollama service is ready!" + +# Check if model already exists +echo "๐Ÿ” Checking if model '$MODEL_NAME' is already downloaded..." +if ollama list | grep -q "$MODEL_NAME"; then + echo "โœ… Model '$MODEL_NAME' already exists, skipping download" +else + echo "๐Ÿ“ฅ Model '$MODEL_NAME' not found, downloading..." + ollama pull "$MODEL_NAME" + echo "โœ… Model '$MODEL_NAME' downloaded successfully!" +fi + +# List available models for confirmation +echo "๐Ÿ“‹ Available models:" +ollama list + +echo "๐ŸŽ‰ Model management complete. Ollama will continue running..." + +# Keep ollama running in the foreground +wait $OLLAMA_PID \ No newline at end of file diff --git a/rag-system/test_utils.py b/rag-system/test_utils.py new file mode 100644 index 000000000..904c83a08 --- /dev/null +++ b/rag-system/test_utils.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Test script to validate extracted utils functionality.""" + +import sys +from pathlib import Path + +# Add utils to path +utils_path = Path(__file__).parent / "utils" +sys.path.insert(0, str(utils_path)) + +def test_imports(): + """Test that all utils can be imported.""" + print("๐Ÿงช Testing utils imports...") + + try: + from document_stores import create_document_store + from models import load_text_embedder + from document_processing import load_documents + from openai_adapter import run_openai_chat + from config import get_env_config + from pipeline_builder import build_hybrid_pipeline + from progress_logging import log_success + + print("โœ… All utils imported successfully") + return True + + except Exception as e: + print(f"โŒ Import failed: {e}") + return False + +def test_config(): + """Test configuration utilities.""" + print("๐Ÿงช Testing config utilities...") + + try: + from config import get_env_config, validate_config + + config = get_env_config() + is_valid = validate_config(config) + + print(f"โœ… Config loaded with {len(config)} settings") + print(f"โœ… Config validation: {'passed' if is_valid else 'failed'}") + return True + + except Exception as e: + print(f"โŒ Config test failed: {e}") + return False + +def test_document_store(): + """Test document store creation.""" + print("๐Ÿงช Testing document store creation...") + + try: + from document_stores import create_document_store, create_bm25_store + + # Test in-memory store (should always work) + store, is_persistent = create_document_store("memory") + bm25_store = create_bm25_store() + + print(f"โœ… Document stores created (persistent: {is_persistent})") + return True + + except Exception as e: + print(f"โŒ Document store test failed: {e}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + return False + +def test_openai_adapter(): + """Test OpenAI adapter utilities.""" + print("๐Ÿงช Testing OpenAI adapter...") + + try: + from openai_adapter import validate_openai_messages, extract_user_query, estimate_tokens + + # Test message validation + valid_messages = [{"role": "user", "content": "test"}] + invalid_messages = [{"role": "invalid"}] + + assert validate_openai_messages(valid_messages) == True + assert validate_openai_messages(invalid_messages) == False + + # Test query extraction + query = extract_user_query(valid_messages) + assert query == "test" + + # Test token estimation + tokens = estimate_tokens(valid_messages) + assert tokens > 0 + + print("โœ… OpenAI adapter utilities working") + return True + + except Exception as e: + print(f"โŒ OpenAI adapter test failed: {e}") + return False + +def test_logging(): + """Test logging utilities.""" + print("๐Ÿงช Testing logging utilities...") + + try: + from progress_logging import log_progress, log_success, log_error, log_header + + log_header("Testing logging functions") + log_progress("Progress message test") + log_success("Success message test") + log_error("Error message test (this is a test)") + + print("โœ… Logging utilities working") + return True + + except Exception as e: + print(f"โŒ Logging test failed: {e}") + return False + +def main(): + """Run all tests.""" + print("๐Ÿš€ Starting utils validation tests...\n") + + tests = [ + ("Import Test", test_imports), + ("Config Test", test_config), + ("Document Store Test", test_document_store), + ("OpenAI Adapter Test", test_openai_adapter), + ("Logging Test", test_logging), + ] + + passed = 0 + total = len(tests) + + for test_name, test_func in tests: + print(f"\n--- {test_name} ---") + try: + if test_func(): + passed += 1 + except Exception as e: + print(f"โŒ {test_name} failed with exception: {e}") + + print(f"\n๐ŸŽฏ Test Results: {passed}/{total} tests passed") + + if passed == total: + print("๐ŸŽ‰ All utils validation tests passed! Utils are ready for use.") + return True + else: + print("โš ๏ธ Some tests failed. Check the output above for details.") + return False + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/rag-system/utils/__init__.py b/rag-system/utils/__init__.py new file mode 100644 index 000000000..b76ee76e6 --- /dev/null +++ b/rag-system/utils/__init__.py @@ -0,0 +1,26 @@ +"""RAG System Utilities - Reusable components for building domain-specific RAG pipelines.""" + +from .document_stores import create_document_store +from .models import load_text_embedder, load_document_embedder, load_reranker +from .document_processing import load_documents, process_documents_for_indexing +from .openai_adapter import run_openai_chat, estimate_tokens +from .config import get_env_config +from .progress_logging import log_progress, log_error, log_success +from .pipeline_builder import build_hybrid_pipeline + +__version__ = "1.0.0" +__all__ = [ + "create_document_store", + "load_text_embedder", + "load_document_embedder", + "load_reranker", + "load_documents", + "process_documents_for_indexing", + "run_openai_chat", + "estimate_tokens", + "get_env_config", + "log_progress", + "log_error", + "log_success", + "build_hybrid_pipeline" +] \ No newline at end of file diff --git a/rag-system/utils/config.py b/rag-system/utils/config.py new file mode 100644 index 000000000..585620354 --- /dev/null +++ b/rag-system/utils/config.py @@ -0,0 +1,152 @@ +"""Configuration management utilities.""" + +import os +from typing import Dict, Any, Optional, Union + + +def get_env_config() -> Dict[str, Any]: + """ + Get standard RAG configuration from environment variables. + + Returns: + Dictionary of configuration values with defaults + """ + return { + # Qdrant settings + "qdrant_host": os.getenv("QDRANT_HOST", "localhost"), + "qdrant_port": int(os.getenv("QDRANT_PORT", "6333")), + # Model settings + "ollama_model": os.getenv("OLLAMA_MODEL", "smollm2:135m"), + "ollama_url": os.getenv("OLLAMA_URL", "http://ollama:11434"), + "embedding_model": os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5"), + "reranker_model": os.getenv( + "RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2" + ), + # Search settings + "embedding_top_k": int(os.getenv("EMBEDDING_TOP_K", "3")), + "bm25_top_k": int(os.getenv("BM25_TOP_K", "3")), + "reranker_top_k": int(os.getenv("RERANKER_TOP_K", "2")), + "relevance_threshold": float(os.getenv("RELEVANCE_THRESHOLD", "0.8")), + # Document processing + "embedding_dim": int(os.getenv("EMBEDDING_DIM", "384")), + "split_length": int(os.getenv("SPLIT_LENGTH", "100")), + "split_overlap": int(os.getenv("SPLIT_OVERLAP", "20")), + "split_threshold": int(os.getenv("SPLIT_THRESHOLD", "3")), + # Citation/link handling + "source_base_url": os.getenv( + "LCFS_SOURCE_BASE_URL", + "https://www2.gov.bc.ca/assets/gov/environment/climate-change/industry/transportation-fuels/low-carbon-fuels/", + ), + } + + +def get_qdrant_config() -> Dict[str, Any]: + """Get Qdrant-specific configuration.""" + config = get_env_config() + return { + "host": config["qdrant_host"], + "port": config["qdrant_port"], + "embedding_dim": config["embedding_dim"], + } + + +def get_model_config() -> Dict[str, Any]: + """Get model-specific configuration.""" + config = get_env_config() + return { + "ollama_model": config["ollama_model"], + "ollama_url": config["ollama_url"], + "embedding_model": config["embedding_model"], + "reranker_model": config["reranker_model"], + } + + +def get_search_config() -> Dict[str, Any]: + """Get search-specific configuration.""" + config = get_env_config() + return { + "embedding_top_k": config["embedding_top_k"], + "bm25_top_k": config["bm25_top_k"], + "reranker_top_k": config["reranker_top_k"], + "relevance_threshold": config["relevance_threshold"], + } + + +def get_processing_config() -> Dict[str, Any]: + """Get document processing configuration.""" + config = get_env_config() + return { + "split_length": config["split_length"], + "split_overlap": config["split_overlap"], + "split_threshold": config["split_threshold"], + } + + +def get_env_var( + key: str, + default: Optional[Union[str, int, float, bool]] = None, + var_type: type = str, +) -> Union[str, int, float, bool]: + """ + Get environment variable with type conversion and default. + + Args: + key: Environment variable name + default: Default value if not found + var_type: Type to convert to (str, int, float, bool) + + Returns: + Environment variable value or default, converted to specified type + """ + value = os.getenv(key) + + if value is None: + return default + + if var_type is bool: + return value.lower() in ("true", "1", "yes", "on") + elif var_type is int: + return int(value) + elif var_type is float: + return float(value) + else: + return value + + +def validate_config(config: Dict[str, Any]) -> bool: + """ + Validate configuration values. + + Args: + config: Configuration dictionary to validate + + Returns: + True if valid, False otherwise + """ + required_keys = [ + "qdrant_host", + "qdrant_port", + "embedding_model", + "embedding_top_k", + "bm25_top_k", + "reranker_top_k", + ] + + for key in required_keys: + if key not in config: + return False + + # Validate types and ranges + if not isinstance(config["qdrant_port"], int) or config["qdrant_port"] <= 0: + return False + + if not isinstance(config["embedding_top_k"], int) or config["embedding_top_k"] <= 0: + return False + + if not isinstance(config["bm25_top_k"], int) or config["bm25_top_k"] <= 0: + return False + + if not isinstance(config["reranker_top_k"], int) or config["reranker_top_k"] <= 0: + return False + + return True diff --git a/rag-system/utils/constants.py b/rag-system/utils/constants.py new file mode 100644 index 000000000..1c61820fc --- /dev/null +++ b/rag-system/utils/constants.py @@ -0,0 +1,41 @@ +"""Common constants for RAG pipelines.""" + +# Default supported file formats for document processing +DEFAULT_SUPPORTED_FORMATS = {".md", ".docx", ".pdf", ".txt", ".html", ".csv", ".json", ".pptx", ".xlsx"} + +# Default search parameters +DEFAULT_EMBEDDING_TOP_K = 3 +DEFAULT_BM25_TOP_K = 3 +DEFAULT_RERANKER_TOP_K = 2 +DEFAULT_RELEVANCE_THRESHOLD = 0.8 + +# Default document processing parameters +DEFAULT_SPLIT_BY = "word" +DEFAULT_SPLIT_LENGTH = 100 +DEFAULT_SPLIT_OVERLAP = 20 +DEFAULT_SPLIT_THRESHOLD = 3 + +# Default embedding dimensions for common models +EMBEDDING_DIMENSIONS = { + "BAAI/bge-small-en-v1.5": 384, + "intfloat/e5-small-v2": 384, + "sentence-transformers/all-MiniLM-L6-v2": 384, + "sentence-transformers/all-mpnet-base-v2": 768, +} + +# Default generation parameters for Ollama +DEFAULT_GENERATION_KWARGS = { + "num_predict": 500, + "temperature": 0.1, + "top_p": 0.9, + "top_k": 20, + "repeat_penalty": 1.1, +} + +# Common fallback messages +DEFAULT_FALLBACK_MESSAGE = "I don't have information about that topic in my knowledge base." + +# Collection name patterns +def get_collection_name(domain: str) -> str: + """Generate a standardized collection name for a domain.""" + return f"{domain.lower().replace(' ', '_')}_embeddings" \ No newline at end of file diff --git a/rag-system/utils/document_processing.py b/rag-system/utils/document_processing.py new file mode 100644 index 000000000..c4e795176 --- /dev/null +++ b/rag-system/utils/document_processing.py @@ -0,0 +1,288 @@ +"""Document processing utilities for loading and indexing various file formats.""" + +import os +import time +from pathlib import Path +from typing import List, Dict, Any, Optional, Callable, Set +try: + from .progress_logging import ( + log_progress, log_error, log_success, log_subsection, + log_bullet, log_timing, log_step + ) +except ImportError: + from progress_logging import ( + log_progress, log_error, log_success, log_subsection, + log_bullet, log_timing, log_step + ) + + +def load_documents( + data_paths: Optional[List[Path]] = None, + supported_formats: Optional[Set[str]] = None, + metadata_enricher: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None +) -> List[object]: + """ + Load documents from multiple paths with format support and metadata enrichment. + + Args: + data_paths: List of paths to search for documents + supported_formats: Set of supported file extensions (with dots) + metadata_enricher: Optional function to enrich document metadata + + Returns: + List of Document objects + """ + if data_paths is None: + data_paths = [ + Path("/opt/data"), # Container path + Path(__file__).parent.parent.parent / "data", # Local development path + ] + + if supported_formats is None: + supported_formats = {".md", ".docx", ".pdf", ".txt", ".html", ".csv", ".json", ".pptx", ".xlsx"} + + documents = [] + + # Try to initialize the multi-file converter + converter, converter_available = _initialize_converter() + + for data_path in data_paths: + if data_path.exists(): + # Find all supported files in the directory + supported_files = [] + for file_path in data_path.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in supported_formats: + supported_files.append(file_path) + + if supported_files and converter_available: + try: + log_progress(f"Found {len(supported_files)} supported files:") + for file_path in supported_files: + log_bullet(f"{file_path.name} ({file_path.suffix})") + + # Convert all supported files at once + result = converter.run(sources=supported_files) + converted_docs = result.get("documents", []) + + # Add metadata to converted documents + for doc in converted_docs: + if doc.meta is None: + doc.meta = {} + + # Apply base metadata + doc.meta.update({ + "processed_by": "MultiFileConverter" + }) + + # Apply domain-specific metadata enrichment if provided + if metadata_enricher: + doc.meta.update(metadata_enricher(doc.meta)) + + documents.append(doc) + + log_success(f"Successfully converted {len(converted_docs)} documents") + + except Exception as e: + log_error(f"Error converting files with MultiFileConverter: {e}") + # Fallback to manual processing for .md files only + log_progress("Falling back to manual .md processing") + _fallback_md_processing(data_path, documents, metadata_enricher) + + elif not converter_available: + # MultiFileConverter not available, use fallback + log_progress("Using fallback .md processing (MultiFileConverter unavailable)") + _fallback_md_processing(data_path, documents, metadata_enricher) + + break # Stop after finding first valid path + + return documents + + +def process_documents_for_indexing( + documents: List[object], + document_embedder: object, + document_store: object, + bm25_store: Optional[object] = None, + split_by: str = "word", + split_length: int = 100, + split_overlap: int = 20, + split_threshold: int = 3 +) -> int: + """ + Process documents for indexing: split, embed, and store. + + Args: + documents: List of documents to process + document_embedder: Embedder for generating document embeddings + document_store: Store for embeddings + bm25_store: Optional store for BM25 search + split_by: How to split documents ("word", "sentence", etc.) + split_length: Length of each chunk + split_overlap: Overlap between chunks + split_threshold: Minimum chunk size threshold + + Returns: + Number of chunks indexed + """ + if not documents: + log_error("No documents provided for indexing") + return 0 + + log_step(3, 3, "Starting live indexing") + log_progress("Indexing documents (this takes 2-3 minutes on first run)...") + log_subsection("Note: Data is persisted - subsequent restarts will be instant") + + # Create document writer + from haystack.components.writers import DocumentWriter + writer = DocumentWriter(document_store=document_store) + + # Build indexing pipeline + from haystack.components.preprocessors import DocumentSplitter + splitter = DocumentSplitter( + split_by=split_by, + split_length=split_length, + split_overlap=split_overlap, + split_threshold=split_threshold + ) + + # Run bulk indexing (faster than batching) + start_time = time.time() + + log_progress(f"Processing {len(documents)} documents...") + log_subsection("Splitting documents into chunks...") + split_docs = splitter.run(documents=documents) + chunks = split_docs["documents"] + log_subsection(f"Generated {len(chunks)} text chunks") + + log_subsection("Generating embeddings (this is the slow part - please wait)...") + log_subsection(f"Processing {len(chunks)} chunks with {document_embedder.model}") + embed_start = time.time() + embedded_docs = document_embedder.run(documents=chunks) + embed_time = time.time() - embed_start + log_subsection(f"Embeddings complete ({embed_time:.1f}s)") + + log_subsection("Writing to document store...") + writer.run(documents=embedded_docs["documents"]) + log_subsection("Write complete") + + # Populate BM25 index if provided + if bm25_store: + log_progress("Populating BM25 index...") + bm25_writer = DocumentWriter(document_store=bm25_store) + bm25_writer.run(documents=chunks) + + points_count = len(chunks) + log_success(f"Indexing complete: {points_count} document chunks indexed") + log_progress("Ready to serve queries with hybrid search!") + + return points_count + + +def _initialize_converter() -> tuple: + """ + Initialize the MultiFileConverter with fallback handling. + + Returns: + Tuple of (converter_instance, is_available) + """ + try: + from haystack.components.converters import MultiFileConverter + converter = MultiFileConverter() + log_progress("MultiFileConverter loaded successfully") + return converter, True + except ImportError as e: + log_error(f"MultiFileConverter not available: {e}") + log_progress("Falling back to manual .md processing") + return None, False + + +def _fallback_md_processing( + data_path: Path, + documents: List[object], + metadata_enricher: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None +) -> None: + """ + Fallback method to manually process .md files if MultiFileConverter fails. + + Args: + data_path: Path to search for markdown files + documents: List to append processed documents to + metadata_enricher: Optional function to enrich document metadata + """ + from haystack import Document + + for md_file in data_path.glob("*.md"): + try: + content = md_file.read_text(encoding="utf-8") + + meta = { + "filename": md_file.name, + "source": str(md_file), + "processed_by": "manual_fallback" + } + + # Apply domain-specific metadata enrichment if provided + if metadata_enricher: + meta.update(metadata_enricher(meta)) + + doc = Document(content=content, meta=meta) + documents.append(doc) + log_success(f"Manually processed: {md_file.name}") + + except Exception as e: + log_error(f"Error reading {md_file}: {e}") + + +def check_for_existing_embeddings(document_store: object) -> int: + """ + Check if document store already contains embeddings. + + Args: + document_store: The document store to check + + Returns: + Number of existing documents + """ + log_step(1, 3, "Checking for existing embeddings...") + + try: + points_count = document_store.count_documents() + if points_count > 0: + log_success(f"Found collection with {points_count} embeddings") + return points_count + else: + log_progress("Collection exists but is empty") + log_progress("Will populate with embeddings...") + return 0 + except Exception as e: + log_progress(f"Collection doesn't exist: {e}") + log_progress("Will create collection and populate with embeddings") + return 0 + + +def load_documents_step( + data_paths: Optional[List[Path]] = None, + supported_formats: Optional[Set[str]] = None, + metadata_enricher: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None +) -> List[object]: + """ + Step 2 wrapper for loading documents with consistent logging. + + Args: + data_paths: List of paths to search for documents + supported_formats: Set of supported file extensions + metadata_enricher: Optional function to enrich document metadata + + Returns: + List of loaded documents + """ + log_step(2, 3, "Loading documents...") + + docs = load_documents(data_paths, supported_formats, metadata_enricher) + + if not docs: + log_error("No documents found in data directory - cannot proceed") + return [] + + log_progress(f"Found {len(docs)} documents to index") + return docs \ No newline at end of file diff --git a/rag-system/utils/document_stores.py b/rag-system/utils/document_stores.py new file mode 100644 index 000000000..eeb72c3b4 --- /dev/null +++ b/rag-system/utils/document_stores.py @@ -0,0 +1,146 @@ +"""Document store creation and management utilities.""" + +import os +from typing import Tuple, Optional, Union +try: + from .progress_logging import log_progress, log_error, log_success +except ImportError: + from progress_logging import log_progress, log_error, log_success + + +def create_document_store( + store_type: str = "auto", + qdrant_host: Optional[str] = None, + qdrant_port: Optional[int] = None, + index_name: str = "embeddings", + embedding_dim: int = 384, + recreate_index: bool = False, + return_embedding: bool = True, + wait_result_from_api: bool = True +) -> Tuple[object, bool]: + """ + Create a document store with automatic fallback. + + Args: + store_type: "qdrant", "memory", or "auto" (try Qdrant first, fallback to memory) + qdrant_host: Qdrant host (defaults to QDRANT_HOST env var or localhost) + qdrant_port: Qdrant port (defaults to QDRANT_PORT env var or 6333) + index_name: Collection/index name + embedding_dim: Embedding dimensions + recreate_index: Whether to recreate the index if it exists + return_embedding: Whether to store embeddings in the store + wait_result_from_api: Whether to wait for API responses + + Returns: + Tuple of (document_store, is_persistent) + is_persistent indicates if the store persists data between restarts + """ + + # Get connection details + qdrant_host = qdrant_host or os.getenv("QDRANT_HOST", "localhost") + qdrant_port = qdrant_port or int(os.getenv("QDRANT_PORT", "6333")) + + if store_type in ("qdrant", "auto"): + try: + log_progress(f"Connecting to Qdrant vector database at {qdrant_host}:{qdrant_port}") + + from haystack_integrations.document_stores.qdrant import QdrantDocumentStore + + log_progress("Creating QdrantDocumentStore...") + document_store = QdrantDocumentStore( + url=f"http://{qdrant_host}:{qdrant_port}", + index=index_name, + embedding_dim=embedding_dim, + recreate_index=recreate_index, + return_embedding=return_embedding, + wait_result_from_api=wait_result_from_api, + ) + + log_success("Successfully connected to Qdrant") + return document_store, True + + except Exception as e: + if store_type == "qdrant": + # If specifically requested Qdrant, don't fallback + log_error(f"Failed to connect to Qdrant: {e}") + raise + else: + # Auto mode - fallback to memory + log_error(f"Failed to connect to Qdrant: {e}") + log_progress("Falling back to in-memory document store") + + # Create in-memory store (fallback or explicitly requested) + if store_type in ("memory", "auto"): + from haystack.document_stores.in_memory import InMemoryDocumentStore + + log_progress("Creating InMemoryDocumentStore...") + document_store = InMemoryDocumentStore() + log_success("InMemoryDocumentStore created") + return document_store, False + + raise ValueError(f"Unknown store_type: {store_type}. Use 'qdrant', 'memory', or 'auto'") + + +def create_bm25_store() -> object: + """ + Create an in-memory BM25 document store for keyword search. + + Returns: + InMemoryDocumentStore configured for BM25 search + """ + from haystack.document_stores.in_memory import InMemoryDocumentStore + + log_progress("Creating in-memory BM25 store") + store = InMemoryDocumentStore() + log_success("In-memory BM25 store created") + return store + + +def populate_bm25_from_vector_store(bm25_store: object, vector_store: object) -> bool: + """ + Populate BM25 store from documents in vector store. + + Args: + bm25_store: BM25 document store to populate + vector_store: Vector store containing documents + + Returns: + True if successful, False otherwise + """ + try: + log_progress("Populating BM25 index from existing documents...") + + # Get all documents from vector store + all_docs = vector_store.filter_documents() + + if all_docs: + from haystack.components.writers import DocumentWriter + writer = DocumentWriter(document_store=bm25_store) + writer.run(documents=all_docs) + log_success(f"Populated BM25 index with {len(all_docs)} document chunks") + return True + else: + log_error("No documents found in vector store to populate BM25") + return False + + except Exception as e: + log_error(f"Could not populate BM25 from vector store: {e}") + log_progress("BM25 search will be unavailable") + return False + + +def check_document_count(document_store: object) -> int: + """ + Get the number of documents in a document store. + + Args: + document_store: The document store to check + + Returns: + Number of documents in the store + """ + try: + return document_store.count_documents() + except Exception as e: + log_error(f"Error counting documents: {e}") + return 0 \ No newline at end of file diff --git a/rag-system/utils/models.py b/rag-system/utils/models.py new file mode 100644 index 000000000..b67873999 --- /dev/null +++ b/rag-system/utils/models.py @@ -0,0 +1,245 @@ +"""Model loading utilities for embedders, rerankers, and generators.""" + +import os +from typing import Optional, Dict, Any + +try: + from .progress_logging import log_progress, log_error, log_success, log_subsection +except ImportError: + from progress_logging import log_progress, log_error, log_success, log_subsection + + +def load_text_embedder( + model_name: Optional[str] = None, progress_bar: bool = True, **kwargs +) -> object: + """ + Load a text embedder for query processing. + + Args: + model_name: Model name/path (defaults to EMBEDDING_MODEL env var) + progress_bar: Whether to show progress bar during loading + **kwargs: Additional arguments for the embedder + + Returns: + Configured SentenceTransformersTextEmbedder instance + + Raises: + Exception: If model loading fails + """ + model_name = model_name or os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5") + + log_progress(f"Loading {model_name} embedding model for queries...") + log_subsection("Creating SentenceTransformersTextEmbedder for query processing...") + + try: + from haystack.components.embedders import SentenceTransformersTextEmbedder + + embedder = SentenceTransformersTextEmbedder( + model=model_name, progress_bar=progress_bar, **kwargs + ) + + log_success("Text embedder loaded") + return embedder + + except Exception as e: + log_error(f"Failed to load text embedder: {e}") + raise + + +def load_document_embedder( + model_name: Optional[str] = None, + progress_bar: bool = True, + warm_up: bool = True, + **kwargs, +) -> object: + """ + Load a document embedder for indexing. + + Args: + model_name: Model name/path (defaults to EMBEDDING_MODEL env var) + progress_bar: Whether to show progress bar during loading + warm_up: Whether to warm up the model after loading + **kwargs: Additional arguments for the embedder + + Returns: + Configured SentenceTransformersDocumentEmbedder instance + + Raises: + Exception: If model loading fails + """ + model_name = model_name or os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5") + + log_progress(f"Loading {model_name} for document indexing...") + + try: + from haystack.components.embedders import SentenceTransformersDocumentEmbedder + + embedder = SentenceTransformersDocumentEmbedder( + model=model_name, progress_bar=progress_bar, **kwargs + ) + + if warm_up: + log_subsection("Warming up document embedder...") + embedder.warm_up() + + log_success("Document embedder loaded") + return embedder + + except Exception as e: + log_error(f"Failed to load document embedder: {e}") + raise + + +def load_reranker(model_name: Optional[str] = None, top_k: int = 2, **kwargs) -> object: + """ + Load a reranker model for improving search results. + + Args: + model_name: Model name/path (defaults to RERANKER_MODEL env var) + top_k: Number of top results to return after reranking + **kwargs: Additional arguments for the reranker + + Returns: + Configured SentenceTransformersSimilarityRanker instance + + Raises: + Exception: If model loading fails + """ + model_name = model_name or os.getenv( + "RERANKER_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2" + ) + + log_progress(f"Loading {model_name} reranker model...") + log_subsection("This may take 1-2 minutes on first run to download (~80MB)") + log_subsection("Creating SentenceTransformersSimilarityRanker...") + + try: + from haystack.components.rankers import SentenceTransformersSimilarityRanker + + reranker = SentenceTransformersSimilarityRanker( + model=model_name, top_k=top_k, **kwargs + ) + + log_success("Reranker model loaded successfully") + return reranker + + except Exception as e: + log_error(f"Error loading reranker: {e}") + raise + + +def load_ollama_generator( + model_name: Optional[str] = None, + url: Optional[str] = None, + generation_kwargs: Optional[Dict[str, Any]] = None, + **kwargs, +) -> object: + """ + Load an Ollama generator for text generation. + + Args: + model_name: Model name (defaults to OLLAMA_MODEL env var) + url: Ollama server URL (defaults to OLLAMA_URL env var) + generation_kwargs: Generation parameters + **kwargs: Additional arguments for the generator + + Returns: + Configured OllamaGenerator instance + + Raises: + Exception: If generator initialization fails + """ + model_name = model_name or os.getenv("OLLAMA_MODEL", "smollm2:135m") + url = url or os.getenv("OLLAMA_URL", "http://ollama:11434") + + if generation_kwargs is None: + generation_kwargs = { + "num_predict": 420, # Trim max tokens to cut generation time ~30% + "temperature": 0.22, # Slightly higher for quicker convergence but still steady + "top_p": 0.9, # Focused sampling for regulatory tone + "top_k": 30, # Smaller candidate list reduces per-token latency + "repeat_penalty": 1.18, # Keep structure without looping + "num_ctx": 1536, # Smaller context window = faster decoding + "num_thread": 4, # Use all available CPU threads in container + } + + log_progress(f"Initializing Ollama generator with model: {model_name}") + log_subsection(f"Connecting to Ollama at: {url}") + + try: + from haystack_integrations.components.generators.ollama import OllamaGenerator + + generator = OllamaGenerator( + model=model_name, url=url, generation_kwargs=generation_kwargs, **kwargs + ) + + log_success("Ollama generator initialized successfully") + return generator + + except Exception as e: + log_error(f"Error initializing Ollama generator: {e}") + raise + + +def create_retrievers( + embedding_store: object, + bm25_store: object, + embedding_top_k: int = 3, + bm25_top_k: int = 3, + use_qdrant: bool = True, +) -> tuple: + """ + Create embedding and BM25 retrievers. + + Args: + embedding_store: Document store for embeddings + bm25_store: Document store for BM25 search + embedding_top_k: Number of results from embedding search + bm25_top_k: Number of results from BM25 search + use_qdrant: Whether using Qdrant (affects retriever type) + + Returns: + Tuple of (embedding_retriever, bm25_retriever) + """ + log_progress("Creating embedding retriever...") + + if use_qdrant: + from haystack_integrations.components.retrievers.qdrant import ( + QdrantEmbeddingRetriever, + ) + + embedding_retriever = QdrantEmbeddingRetriever(document_store=embedding_store) + log_success("QdrantEmbeddingRetriever created") + else: + from haystack.components.retrievers import InMemoryEmbeddingRetriever + + embedding_retriever = InMemoryEmbeddingRetriever(document_store=embedding_store) + log_success("InMemoryEmbeddingRetriever created") + + log_progress("Creating BM25 retriever for keyword search...") + from haystack.components.retrievers.in_memory import InMemoryBM25Retriever + + bm25_retriever = InMemoryBM25Retriever(document_store=bm25_store) + log_success("BM25 retriever created") + + return embedding_retriever, bm25_retriever + + +def create_document_joiner(join_mode: str = "reciprocal_rank_fusion") -> object: + """ + Create a document joiner for combining retriever results. + + Args: + join_mode: How to combine documents from multiple retrievers + + Returns: + Configured DocumentJoiner instance + """ + log_progress("Creating document joiner for hybrid search...") + + from haystack.components.joiners import DocumentJoiner + + joiner = DocumentJoiner(join_mode=join_mode) + + log_success("Document joiner created") + return joiner diff --git a/rag-system/utils/openai_adapter.py b/rag-system/utils/openai_adapter.py new file mode 100644 index 000000000..c64bc944a --- /dev/null +++ b/rag-system/utils/openai_adapter.py @@ -0,0 +1,394 @@ +"""OpenAI API compatibility adapter for Haystack pipelines.""" + +import time +import uuid +from pathlib import Path +from typing import Dict, Any, List, Optional, Tuple, Set +from urllib.parse import urljoin + +try: + from .progress_logging import log_progress +except ImportError: + from progress_logging import log_progress + + +def run_openai_chat( + pipeline: object, + messages: List[Dict[str, str]], + embedding_top_k: int = 3, + bm25_top_k: int = 3, + relevance_threshold: float = 0.8, + fallback_message: str = "I don't have information about that topic.", + model_name: str = "rag-pipeline", + debug_logging: bool = True, + doc_base_url: Optional[str] = None, + max_citations: int = 5, + append_sources_to_answer: bool = True, +) -> Dict[str, Any]: + """ + Process OpenAI-format chat messages through a Haystack RAG pipeline. + + Args: + pipeline: Configured Haystack pipeline + messages: List of message objects with 'role' and 'content' keys + embedding_top_k: Number of results from embedding search + bm25_top_k: Number of results from BM25 search + relevance_threshold: Minimum document relevance score + fallback_message: Message to return when no relevant docs found + model_name: Model name for response metadata + debug_logging: Whether to log debug information + doc_base_url: Optional base URL to build citation links + max_citations: Maximum number of citations to return + append_sources_to_answer: Whether to append a human-readable "Sources" section + + Returns: + OpenAI-compatible chat completion response + """ + try: + # Extract the last user message + user_messages = [msg for msg in messages if msg.get("role") == "user"] + if not user_messages: + raise ValueError("No user message found") + + query = user_messages[-1]["content"] + + # Run the RAG pipeline with intermediate outputs to check document retrieval + result = pipeline.run( + data={ + "text_embedder": {"text": query}, + "embedding_retriever": {"top_k": embedding_top_k}, + "bm25_retriever": {"query": query, "top_k": bm25_top_k}, + "reranker": {"query": query}, + "prompt_builder": {"query": query}, + }, + include_outputs_from={ + "reranker", # Get the final documents after reranking + }, + ) + + # Check if any relevant documents were found + final_docs = result.get("reranker", {}).get("documents", []) + + # Debug logging if enabled + if debug_logging: + log_progress(f"Query: {query}") + log_progress(f"Found {len(final_docs)} documents") + for i, doc in enumerate(final_docs): + score = getattr(doc, "score", "no_score") + log_progress( + f"Doc {i}: score={score}, content_preview={doc.content[:100]}..." + ) + + # Filter by relevance threshold + relevant_docs = [ + doc for doc in final_docs if getattr(doc, "score", 0) > relevance_threshold + ] + + if debug_logging: + log_progress( + f"Docs with score > {relevance_threshold}: {len(relevant_docs)}" + ) + + if not relevant_docs: + return _create_fallback_response(fallback_message, messages, model_name) + + # Extract the answer + answer = result.get("generator", {}).get("replies", ["No response generated"])[ + 0 + ] + + citations = _build_citation_entries(relevant_docs, doc_base_url, max_citations) + processed_answer = ( + _append_citations_to_answer(answer, citations) + if append_sources_to_answer + else answer + ) + + # Format as OpenAI response + return _create_success_response( + processed_answer, messages, model_name, citations + ) + + except Exception as e: + # Return error in OpenAI format + return { + "error": { + "message": str(e), + "type": "rag_pipeline_error", + "code": "internal_error", + } + } + + +def _create_fallback_response( + fallback_message: str, messages: List[Dict[str, str]], model_name: str +) -> Dict[str, Any]: + """Create a fallback response when no relevant documents are found.""" + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + + return { + "id": completion_id, + "object": "chat.completion", + "created": int(time.time()), + "model": model_name, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": fallback_message}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": estimate_tokens(messages), + "completion_tokens": estimate_tokens( + [{"role": "assistant", "content": fallback_message}] + ), + "total_tokens": 0, + }, + } + + +def _create_success_response( + answer: str, + messages: List[Dict[str, str]], + model_name: str, + citations: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Create a successful response with the generated answer.""" + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + + response = { + "id": completion_id, + "object": "chat.completion", + "created": int(time.time()), + "model": model_name, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": answer}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": estimate_tokens(messages), + "completion_tokens": estimate_tokens( + [{"role": "assistant", "content": answer}] + ), + "total_tokens": 0, # Will be calculated if needed + }, + } + + if citations: + response["lcfs_metadata"] = {"citations": citations} + + return response + + +def estimate_tokens(messages: List[Dict[str, str]]) -> int: + """ + Rough token estimation (4 chars โ‰ˆ 1 token). + + Args: + messages: List of message objects + + Returns: + Estimated token count + """ + total_chars = sum(len(msg.get("content", "")) for msg in messages) + return max(1, total_chars // 4) + + +def validate_openai_messages(messages: List[Dict[str, str]]) -> bool: + """ + Validate that messages conform to OpenAI chat format. + + Args: + messages: List of message objects to validate + + Returns: + True if valid, False otherwise + """ + if not isinstance(messages, list) or not messages: + return False + + for msg in messages: + if not isinstance(msg, dict): + return False + if "role" not in msg or "content" not in msg: + return False + if msg["role"] not in ["system", "user", "assistant"]: + return False + if not isinstance(msg["content"], str): + return False + + return True + + +def extract_user_query(messages: List[Dict[str, str]]) -> Optional[str]: + """ + Extract the most recent user message from the conversation. + + Args: + messages: List of message objects + + Returns: + The content of the last user message, or None if not found + """ + user_messages = [msg for msg in messages if msg.get("role") == "user"] + if user_messages: + return user_messages[-1]["content"] + return None + + +def create_system_message(content: str) -> Dict[str, str]: + """ + Create a system message in OpenAI format. + + Args: + content: The system message content + + Returns: + Formatted system message + """ + return {"role": "system", "content": content} + + +def create_user_message(content: str) -> Dict[str, str]: + """ + Create a user message in OpenAI format. + + Args: + content: The user message content + + Returns: + Formatted user message + """ + return {"role": "user", "content": content} + + +def create_assistant_message(content: str) -> Dict[str, str]: + """ + Create an assistant message in OpenAI format. + + Args: + content: The assistant message content + + Returns: + Formatted assistant message + """ + return {"role": "assistant", "content": content} + + +def _build_citation_entries( + documents: List[Any], base_url: Optional[str], max_items: int +) -> List[Dict[str, Any]]: + """ + Build a list of citation metadata from retrieved documents. + """ + citations: List[Dict[str, Any]] = [] + seen: Set[Tuple[str, Optional[str]]] = set() + + for doc in documents: + if len(citations) >= max_items: + break + + meta = getattr(doc, "meta", {}) or {} + title = _derive_citation_title(meta) + url = _derive_citation_url(meta, base_url) + origin = _extract_origin(meta) + + key = (title, url or origin) + if key in seen: + continue + seen.add(key) + + entry: Dict[str, Any] = { + "title": title, + "url": url, + "origin": origin, + } + + score = getattr(doc, "score", None) + if isinstance(score, (int, float)): + entry["score"] = round(float(score), 4) + elif score is not None: + entry["score"] = score + + citations.append(entry) + + return citations + + +def _derive_citation_title(meta: Dict[str, Any]) -> str: + """Choose the most helpful title for a citation.""" + title_keys = [ + "title", + "document_title", + "filename", + "file_name", + "display_name", + ] + + for key in title_keys: + value = meta.get(key) + if value: + return str(value) + + origin = _extract_origin(meta) + if origin: + return Path(str(origin)).name + + return "LCFS Reference Document" + + +def _derive_citation_url( + meta: Dict[str, Any], base_url: Optional[str] +) -> Optional[str]: + """Resolve the best available URL for a citation.""" + for key in ("source_url", "document_url", "url"): + value = meta.get(key) + if value: + return str(value) + + origin = _extract_origin(meta) + if origin and str(origin).startswith(("http://", "https://")): + return str(origin) + + filename = None + if meta.get("filename"): + filename = meta["filename"] + elif meta.get("file_name"): + filename = meta["file_name"] + elif origin: + filename = Path(str(origin)).name + + if base_url and filename: + return urljoin(base_url.rstrip("/") + "/", str(filename)) + + return None + + +def _extract_origin(meta: Dict[str, Any]) -> Optional[str]: + """Extract the raw origin/path for a document.""" + for key in ("source", "file_path", "path", "document_id"): + value = meta.get(key) + if value: + return str(value) + return None + + +def _append_citations_to_answer(answer: str, citations: List[Dict[str, Any]]) -> str: + """Append a formatted Sources section to the answer.""" + if not citations: + return answer + + lines = ["", "", "Sources:"] + for idx, citation in enumerate(citations, 1): + label = citation.get("title", "LCFS Reference") + url = citation.get("url") or citation.get("origin") + if url: + lines.append(f"{idx}. {label} โ€” {url}") + else: + lines.append(f"{idx}. {label}") + + return answer.rstrip() + "\n".join(lines) diff --git a/rag-system/utils/pipeline_builder.py b/rag-system/utils/pipeline_builder.py new file mode 100644 index 000000000..a245c3ad0 --- /dev/null +++ b/rag-system/utils/pipeline_builder.py @@ -0,0 +1,192 @@ +"""Pipeline building utilities for assembling Haystack RAG pipelines.""" + +from typing import Dict, Any, Optional +try: + from .progress_logging import log_progress, log_success +except ImportError: + from progress_logging import log_progress, log_success + + +def build_hybrid_pipeline( + text_embedder: object, + embedding_retriever: object, + bm25_retriever: object, + document_joiner: object, + reranker: object, + prompt_builder: object, + generator: object +) -> object: + """ + Build a hybrid RAG pipeline with embedding and BM25 search. + + Args: + text_embedder: Text embedder for queries + embedding_retriever: Retriever for embedding search + bm25_retriever: Retriever for keyword search + document_joiner: Joiner for combining results + reranker: Reranker for improving results + prompt_builder: Prompt template builder + generator: Text generator + + Returns: + Configured Haystack Pipeline + """ + log_progress("Building hybrid RAG pipeline (BM25 + embedding search)...") + + from haystack import Pipeline + + pipeline = Pipeline() + + # Add components + pipeline.add_component("text_embedder", text_embedder) + pipeline.add_component("embedding_retriever", embedding_retriever) + pipeline.add_component("bm25_retriever", bm25_retriever) + pipeline.add_component("document_joiner", document_joiner) + pipeline.add_component("reranker", reranker) + pipeline.add_component("prompt_builder", prompt_builder) + pipeline.add_component("generator", generator) + + # Connect components for hybrid retrieval with reranking + pipeline.connect("text_embedder.embedding", "embedding_retriever.query_embedding") + pipeline.connect("embedding_retriever.documents", "document_joiner.documents") + pipeline.connect("bm25_retriever.documents", "document_joiner.documents") + pipeline.connect("document_joiner.documents", "reranker.documents") + pipeline.connect("reranker.documents", "prompt_builder.documents") + pipeline.connect("prompt_builder", "generator") + + log_success("Pipeline components connected successfully") + return pipeline + + +def create_prompt_builder( + template: str, + required_variables: Optional[list] = None +) -> object: + """ + Create a prompt builder with the specified template. + + Args: + template: Jinja2 template string + required_variables: List of required template variables + + Returns: + Configured PromptBuilder + """ + log_progress("Creating prompt builder...") + + from haystack.components.builders import PromptBuilder + + if required_variables is None: + required_variables = ["query", "documents"] + + prompt_builder = PromptBuilder( + template=template, + required_variables=required_variables, + ) + + log_success("Prompt builder created") + return prompt_builder + + +def create_indexing_pipeline( + document_embedder: object, + document_splitter: object, + document_writer: object +) -> object: + """ + Create a document indexing pipeline. + + Args: + document_embedder: Embedder for documents + document_splitter: Splitter for chunking documents + document_writer: Writer for storing documents + + Returns: + Configured indexing Pipeline + """ + log_progress("Building document indexing pipeline...") + + from haystack import Pipeline + + pipeline = Pipeline() + + # Add components + pipeline.add_component("splitter", document_splitter) + pipeline.add_component("embedder", document_embedder) + pipeline.add_component("writer", document_writer) + + # Connect components + pipeline.connect("splitter", "embedder") + pipeline.connect("embedder", "writer") + + log_success("Indexing pipeline created") + return pipeline + + +def get_default_generation_kwargs() -> Dict[str, Any]: + """ + Get default generation parameters for Ollama. + + Returns: + Dictionary of generation parameters + """ + return { + "num_predict": 500, # Allow complete responses + "temperature": 0.1, # Low temperature for accuracy + "top_p": 0.9, + "top_k": 20, + "repeat_penalty": 1.1, + } + + +def create_standard_rag_template( + domain_name: str, + domain_description: str, + fallback_message: str +) -> str: + """ + Create a standard RAG prompt template for a domain. + + Args: + domain_name: Name of the domain (e.g., "LCFS", "Legal") + domain_description: Description of what the domain covers + fallback_message: Message for out-of-scope questions + + Returns: + Jinja2 template string + """ + return f"""You are an expert assistant for {domain_description}. + +Use the following context documents to answer questions about {domain_name} topics. If the question is not related to {domain_name}, respond with: "{fallback_message}" + +Context: +{{% for document in documents %}} +{{{{ document.content }}}} +--- +{{% endfor %}} + +Question: {{{{ query }}}} + +Answer:""" + + +def validate_pipeline_components(components: Dict[str, object]) -> bool: + """ + Validate that all required pipeline components are present. + + Args: + components: Dictionary of component name -> component object + + Returns: + True if all required components present, False otherwise + """ + required = [ + "text_embedder", "embedding_retriever", "bm25_retriever", + "document_joiner", "reranker", "prompt_builder", "generator" + ] + + for component_name in required: + if component_name not in components or components[component_name] is None: + return False + + return True \ No newline at end of file diff --git a/rag-system/utils/progress_logging.py b/rag-system/utils/progress_logging.py new file mode 100644 index 000000000..b8f332715 --- /dev/null +++ b/rag-system/utils/progress_logging.py @@ -0,0 +1,140 @@ +"""Progress logging utilities for consistent user feedback.""" + +import sys +from typing import Optional + + +def log_progress(message: str, flush: bool = True) -> None: + """ + Log a progress message with emoji. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f"๐Ÿ”„ {message}", flush=flush) + + +def log_success(message: str, flush: bool = True) -> None: + """ + Log a success message with emoji. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f"โœ… {message}", flush=flush) + + +def log_error(message: str, flush: bool = True) -> None: + """ + Log an error message with emoji. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f"โŒ {message}", flush=flush) + + +def log_warning(message: str, flush: bool = True) -> None: + """ + Log a warning message with emoji. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f"โš ๏ธ {message}", flush=flush) + + +def log_info(message: str, flush: bool = True) -> None: + """ + Log an info message with emoji. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f"โ„น๏ธ {message}", flush=flush) + + +def log_step(step_number: int, total_steps: int, description: str, flush: bool = True) -> None: + """ + Log a numbered step in a process. + + Args: + step_number: Current step number + total_steps: Total number of steps + description: Description of the step + flush: Whether to flush the output immediately + """ + print(f"๐Ÿ” Step {step_number}/{total_steps}: {description}", flush=flush) + + +def log_subsection(message: str, flush: bool = True) -> None: + """ + Log a subsection message with indentation. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f" {message}", flush=flush) + + +def log_bullet(message: str, flush: bool = True) -> None: + """ + Log a bullet point message. + + Args: + message: The message to display + flush: Whether to flush the output immediately + """ + print(f" - {message}", flush=flush) + + +def log_metric(label: str, value: str, flush: bool = True) -> None: + """ + Log a metric with consistent formatting. + + Args: + label: The metric label + value: The metric value + flush: Whether to flush the output immediately + """ + print(f"๐Ÿ“Š {label}: {value}", flush=flush) + + +def log_timing(operation: str, duration_seconds: float, flush: bool = True) -> None: + """ + Log operation timing information. + + Args: + operation: Name of the operation + duration_seconds: Duration in seconds + flush: Whether to flush the output immediately + """ + print(f"โฑ๏ธ {operation} completed in {duration_seconds:.1f}s", flush=flush) + + +def log_header(message: str, flush: bool = True) -> None: + """ + Log a header message for major sections. + + Args: + message: The header message + flush: Whether to flush the output immediately + """ + print(f"๐Ÿš€ {message}", flush=flush) + + +def log_completion(message: str, flush: bool = True) -> None: + """ + Log a completion message for major milestones. + + Args: + message: The completion message + flush: Whether to flush the output immediately + """ + print(f"๐ŸŽ‰ {message}", flush=flush) \ No newline at end of file