Spaces:
Sleeping
Sleeping
Deploy Script
commited on
Commit
·
b3b7a20
1
Parent(s):
bfa7e20
Deploy PuppyCompanion FastAPI 2025-06-02 09:57:27
Browse files- .hf_force_rebuild +1 -0
- Dockerfile +81 -0
- README.md +94 -5
- agent_workflow.py +392 -0
- all_books_preprocessed_chunks.json +0 -0
- books_config.json +46 -0
- embedding_models.py +236 -0
- main.py +444 -0
- pyproject.toml +126 -0
- rag_system.py +124 -0
- requirements.txt +31 -0
- static/_index.html +655 -0
- static/index.html +638 -0
.hf_force_rebuild
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# Force rebuild Lun 2 jui 2025 09:55:15 CEST
|
Dockerfile
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.10-slim
|
2 |
+
|
3 |
+
WORKDIR /app
|
4 |
+
|
5 |
+
# Install required system dependencies
|
6 |
+
RUN apt-get update && apt-get install -y \
|
7 |
+
build-essential \
|
8 |
+
curl \
|
9 |
+
software-properties-common \
|
10 |
+
git \
|
11 |
+
&& rm -rf /var/lib/apt/lists/*
|
12 |
+
|
13 |
+
# Copy configuration files first to optimize Docker cache
|
14 |
+
COPY pyproject.toml README.md ./
|
15 |
+
|
16 |
+
# Upgrade pip and install Python dependencies
|
17 |
+
RUN pip install --no-cache-dir --upgrade pip
|
18 |
+
RUN pip install --no-cache-dir -e .
|
19 |
+
|
20 |
+
# Check critical imports
|
21 |
+
RUN python -c "from langchain_qdrant import QdrantVectorStore; print('✅ Qdrant successfully imported')"
|
22 |
+
RUN python -c "from fastapi import FastAPI; print('✅ FastAPI successfully imported')"
|
23 |
+
RUN python -c "import uvicorn; print('✅ Uvicorn successfully imported')"
|
24 |
+
|
25 |
+
# Create necessary directories with correct permissions
|
26 |
+
RUN mkdir -p /app/static && \
|
27 |
+
mkdir -p /tmp/qdrant_storage && \
|
28 |
+
mkdir -p /tmp/cache && \
|
29 |
+
chmod -R 777 /tmp/qdrant_storage && \
|
30 |
+
chmod -R 777 /tmp/cache
|
31 |
+
|
32 |
+
# Copy application files
|
33 |
+
COPY main.py .
|
34 |
+
COPY rag_system.py .
|
35 |
+
COPY agent_workflow.py .
|
36 |
+
COPY embedding_models.py .
|
37 |
+
COPY books_config.json .
|
38 |
+
|
39 |
+
# Copy the static directory with the interface
|
40 |
+
COPY static/ ./static/
|
41 |
+
|
42 |
+
# Copy the chunks file to the root
|
43 |
+
COPY all_books_preprocessed_chunks.json .
|
44 |
+
|
45 |
+
# Check for the presence of the chunks file at the root
|
46 |
+
RUN if [ -f "all_books_preprocessed_chunks.json" ]; then \
|
47 |
+
echo "✅ Chunks file found at all_books_preprocessed_chunks.json"; \
|
48 |
+
echo "📊 File size: $(du -h all_books_preprocessed_chunks.json)"; \
|
49 |
+
else \
|
50 |
+
echo "⚠️ Warning: all_books_preprocessed_chunks.json not found at root"; \
|
51 |
+
echo "📁 Contents of root directory:"; \
|
52 |
+
ls -la *.json || echo "No JSON files found"; \
|
53 |
+
fi
|
54 |
+
|
55 |
+
# Ensure all files have correct permissions
|
56 |
+
RUN chmod -R 755 /app
|
57 |
+
RUN chmod +x main.py
|
58 |
+
|
59 |
+
# Expose the port for FastAPI
|
60 |
+
EXPOSE 7860
|
61 |
+
|
62 |
+
# Environment variables for FastAPI and Hugging Face Spaces
|
63 |
+
ENV PYTHONPATH="/app:$PYTHONPATH"
|
64 |
+
ENV UVICORN_HOST="0.0.0.0"
|
65 |
+
ENV UVICORN_PORT="7860"
|
66 |
+
ENV UVICORN_LOG_LEVEL="info"
|
67 |
+
|
68 |
+
# Variables to optimize performance
|
69 |
+
ENV PYTHONUNBUFFERED=1
|
70 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
71 |
+
|
72 |
+
# Variables for cache management
|
73 |
+
ENV MAX_CACHE_SIZE_MB=500
|
74 |
+
ENV MAX_CACHE_AGE_DAYS=7
|
75 |
+
|
76 |
+
# Healthcheck to verify the application is responding
|
77 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
78 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
79 |
+
|
80 |
+
# Command to launch the FastAPI application
|
81 |
+
CMD ["python", "main.py"]
|
README.md
CHANGED
@@ -1,10 +1,99 @@
|
|
|
|
|
|
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
---
|
9 |
|
10 |
-
|
|
|
1 |
+
# Fichier README.md pour Hugging Face Spaces
|
2 |
+
# À placer à la racine de votre Space
|
3 |
+
|
4 |
---
|
5 |
+
title: PuppyCompanion
|
6 |
+
emoji: 🐶
|
7 |
+
colorFrom: blue
|
8 |
+
colorTo: purple
|
9 |
sdk: docker
|
10 |
pinned: false
|
11 |
+
license: mit
|
12 |
+
app_port: 7860
|
13 |
+
---
|
14 |
+
|
15 |
+
# PuppyCompanion 🐶
|
16 |
+
|
17 |
+
An intelligent AI assistant specialized in puppy care and training, powered by advanced RAG (Retrieval-Augmented Generation) technology and a modern FastAPI backend.
|
18 |
+
|
19 |
+
## Features
|
20 |
+
|
21 |
+
- **🐕 Specialized Knowledge Base**: Expert information from professional canine care resources
|
22 |
+
- **🧠 Smart Agent Workflow**: LangGraph-powered decision making with tool selection
|
23 |
+
- **🔍 Dual Search System**:
|
24 |
+
- RAG system for specialized puppy knowledge from preprocessed chunks
|
25 |
+
- Web search integration via Tavily for up-to-date information
|
26 |
+
- **📱 Modern Mobile-like Interface**: Responsive web interface with real-time updates
|
27 |
+
- **🔧 Real-time Debug Console**: Watch the AI's decision process in real-time
|
28 |
+
- **⚡ FastAPI Backend**: High-performance async API with WebSocket support
|
29 |
+
- **📊 Detailed Source Attribution**: Shows exactly which knowledge sources were used
|
30 |
+
|
31 |
+
## How it Works
|
32 |
+
|
33 |
+
1. **Question Analysis**: The agent workflow analyzes your question and selects appropriate tools
|
34 |
+
2. **RAG Search**: For dog-related questions, searches the specialized knowledge base using Qdrant vector database
|
35 |
+
3. **Quality Evaluation**: Determines if the RAG response is comprehensive enough
|
36 |
+
4. **Web Search Fallback**: If needed, uses Tavily to search for additional current information
|
37 |
+
5. **Response Generation**: Combines knowledge sources to provide a comprehensive answer
|
38 |
+
6. **Real-time Updates**: All processing steps are visible in the debug console via WebSocket
|
39 |
+
|
40 |
+
## Usage
|
41 |
+
|
42 |
+
Simply ask any question about puppy care, training, behavior, or health. Examples:
|
43 |
+
- "How do I house train my 8-week-old puppy?"
|
44 |
+
- "What vaccination schedule should I follow?"
|
45 |
+
- "How to stop my puppy from biting furniture?"
|
46 |
+
- "Best foods for a growing German Shepherd puppy?"
|
47 |
+
|
48 |
+
## Technology Stack
|
49 |
+
|
50 |
+
- **FastAPI**: Modern async web framework for the API backend
|
51 |
+
- **LangGraph**: Agent workflow orchestration and tool selection
|
52 |
+
- **OpenAI GPT-4**: Language model for natural language processing
|
53 |
+
- **Qdrant**: Vector database for document embeddings and similarity search
|
54 |
+
- **OpenAI Embeddings**: Text embeddings for semantic search
|
55 |
+
- **Tavily**: Web search integration for real-time information
|
56 |
+
- **WebSocket**: Real-time communication for debug console
|
57 |
+
- **Docker**: Containerized deployment for HuggingFace Spaces
|
58 |
+
|
59 |
+
## Configuration
|
60 |
+
|
61 |
+
The application requires these environment variables:
|
62 |
+
|
63 |
+
### Required
|
64 |
+
- `OPENAI_API_KEY`: Your OpenAI API key for LLM processing and embeddings
|
65 |
+
|
66 |
+
### Optional
|
67 |
+
- `TAVILY_API_KEY`: For web search functionality (highly recommended)
|
68 |
+
|
69 |
+
**⚠️ Important**: Configure these as secrets in your HuggingFace Space settings:
|
70 |
+
1. Go to your space settings
|
71 |
+
2. Add the API keys in the "Repository secrets" section
|
72 |
+
3. Never commit API keys to your repository
|
73 |
+
|
74 |
+
## Knowledge Base
|
75 |
+
|
76 |
+
The application uses a preprocessed knowledge base of professional puppy care resources:
|
77 |
+
- **File**: `all_books_preprocessed_chunks.json` (2.5MB)
|
78 |
+
- **Content**: Chunked and processed expert knowledge about puppy training, health, and behavior
|
79 |
+
- **Vector Store**: Automatically indexed in Qdrant on first startup
|
80 |
+
|
81 |
+
## API Endpoints
|
82 |
+
|
83 |
+
- `GET /`: Main web interface
|
84 |
+
- `POST /chat`: Chat API endpoint for programmatic access
|
85 |
+
- `WebSocket /ws`: Real-time debug console connection
|
86 |
+
- `GET /health`: Application health check
|
87 |
+
|
88 |
+
## Development
|
89 |
+
|
90 |
+
The application features:
|
91 |
+
- **Async Architecture**: Full async/await support for optimal performance
|
92 |
+
- **Persistent Vector Store**: Qdrant storage persists between restarts
|
93 |
+
- **Connection Management**: WebSocket connection handling for multiple users
|
94 |
+
- **Error Handling**: Comprehensive error handling and logging
|
95 |
+
- **Health Monitoring**: Built-in health checks and monitoring
|
96 |
+
|
97 |
---
|
98 |
|
99 |
+
*Transform the chaos of new puppy ownership into confidence with AI-powered expert assistance!* 🐾
|
agent_workflow.py
ADDED
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# agent_workflow.py
|
2 |
+
import logging
|
3 |
+
from typing import Dict, List, Any, Annotated, TypedDict
|
4 |
+
|
5 |
+
from langchain_openai import ChatOpenAI
|
6 |
+
from langchain_core.documents import Document
|
7 |
+
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
|
8 |
+
from langchain_community.tools.tavily_search import TavilySearchResults
|
9 |
+
|
10 |
+
from langgraph.graph import StateGraph, START, END
|
11 |
+
from langgraph.graph.message import add_messages
|
12 |
+
|
13 |
+
# Logging configuration
|
14 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
15 |
+
logger = logging.getLogger(__name__)
|
16 |
+
|
17 |
+
class AgentState(TypedDict):
|
18 |
+
"""Agent state for the workflow"""
|
19 |
+
messages: Annotated[list, add_messages]
|
20 |
+
context: List[Document]
|
21 |
+
next_tool: str
|
22 |
+
question: str
|
23 |
+
retrieved_contexts: List[Document]
|
24 |
+
context_count: int
|
25 |
+
|
26 |
+
class AgentWorkflow:
|
27 |
+
"""Agent workflow with intelligent routing logic"""
|
28 |
+
|
29 |
+
def __init__(self, rag_tool, tavily_max_results: int = 5):
|
30 |
+
""" Initialize the agent workflow """
|
31 |
+
self.rag_tool = rag_tool
|
32 |
+
self.tavily_tool = TavilySearchResults(max_results=tavily_max_results)
|
33 |
+
|
34 |
+
# LLMs for routing and evaluation
|
35 |
+
self.router_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=50)
|
36 |
+
self.evaluator_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
|
37 |
+
self.final_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
|
38 |
+
|
39 |
+
# Compile the workflow
|
40 |
+
self.compiled_workflow = self._build_workflow()
|
41 |
+
|
42 |
+
def evaluate_response_quality(self, question: str, response: str) -> bool:
|
43 |
+
""" Evaluates if the response is satisfactory """
|
44 |
+
prompt = f"""Evaluate if this response to "{question}" is UNSATISFACTORY:
|
45 |
+
|
46 |
+
"{response}"
|
47 |
+
|
48 |
+
UNSATISFACTORY CRITERIA (if ANY ONE is present, the response is UNSATISFACTORY):
|
49 |
+
1. Contains "consult experts", "specialized training", "I'm sorry"
|
50 |
+
2. Doesn't provide concrete steps for "how to" questions
|
51 |
+
3. Gives general advice rather than specific methods
|
52 |
+
4. Redirects the user without directly answering
|
53 |
+
|
54 |
+
Quick example:
|
55 |
+
Q: "How do I train my dog to sit?"
|
56 |
+
UNSATISFACTORY: "Consult a professional trainer."
|
57 |
+
SATISFACTORY: "1. Use treats... 2. Be consistent..."
|
58 |
+
|
59 |
+
Reply only "UNSATISFACTORY" or "SATISFACTORY".
|
60 |
+
When in doubt, choose "UNSATISFACTORY".
|
61 |
+
"""
|
62 |
+
|
63 |
+
evaluation = self.evaluator_llm.invoke([SystemMessage(content=prompt)])
|
64 |
+
result = evaluation.content.strip().upper()
|
65 |
+
|
66 |
+
is_satisfactory = "UNSATISFACTORY" not in result
|
67 |
+
logger.info(f"[Evaluation] Response rated: {'SATISFACTORY' if is_satisfactory else 'UNSATISFACTORY'}")
|
68 |
+
|
69 |
+
return is_satisfactory
|
70 |
+
|
71 |
+
def _build_workflow(self):
|
72 |
+
"""Builds and compiles the agent workflow"""
|
73 |
+
|
74 |
+
# 1. Node for intelligent routing
|
75 |
+
def smart_router(state):
|
76 |
+
"""Determines if the question is about dogs or not"""
|
77 |
+
messages = state["messages"]
|
78 |
+
last_message = [msg for msg in messages if isinstance(msg, HumanMessage)][-1]
|
79 |
+
question = last_message.content
|
80 |
+
|
81 |
+
# Prompt using reverse logic - asking if it's NOT related to dogs
|
82 |
+
router_prompt = f"""Evaluate if this question is UNRELATED to dogs, puppies, or canine care:
|
83 |
+
|
84 |
+
Question: "{question}"
|
85 |
+
|
86 |
+
INDICATORS OF NON-DOG QUESTIONS (if ANY ONE is present, mark as "NOT_DOG_RELATED"):
|
87 |
+
1. Questions about weather, time, locations, or general information
|
88 |
+
2. Questions about other animals (cats, birds, etc.)
|
89 |
+
3. Questions about technology, politics, or human activities
|
90 |
+
4. Any question that doesn't explicitly mention or imply dogs/puppies/canines
|
91 |
+
|
92 |
+
Example check:
|
93 |
+
Q: "What is the weather in Paris today?"
|
94 |
+
This is NOT_DOG_RELATED (about weather)
|
95 |
+
|
96 |
+
Q: "How do I train my puppy to sit?"
|
97 |
+
This is DOG_RELATED (explicitly about puppy training)
|
98 |
+
|
99 |
+
Reply ONLY with "NOT_DOG_RELATED" or "DOG_RELATED".
|
100 |
+
When in doubt, choose "NOT_DOG_RELATED".
|
101 |
+
"""
|
102 |
+
|
103 |
+
router_response = self.router_llm.invoke([SystemMessage(content=router_prompt)])
|
104 |
+
result = router_response.content.strip().upper()
|
105 |
+
|
106 |
+
is_dog_related = "NOT_DOG_RELATED" not in result
|
107 |
+
logger.info(f"[Smart Router] Question {'' if is_dog_related else 'NOT '}related to dogs")
|
108 |
+
|
109 |
+
# If the question is not related to dogs, go directly to out_of_scope
|
110 |
+
if not is_dog_related:
|
111 |
+
return {
|
112 |
+
"next_tool": "out_of_scope",
|
113 |
+
"question": question
|
114 |
+
}
|
115 |
+
|
116 |
+
# If the question is related to dogs, go to the RAG tool
|
117 |
+
return {
|
118 |
+
"next_tool": "rag_tool",
|
119 |
+
"question": question
|
120 |
+
}
|
121 |
+
|
122 |
+
# 2. Node for out-of-scope questions
|
123 |
+
def out_of_scope(state):
|
124 |
+
"""Informs that the assistant only answers questions about dogs"""
|
125 |
+
out_of_scope_message = AIMessage(
|
126 |
+
content="I'm sorry, but I specialize only in canine care and puppy education. I cannot answer this question as it is outside my area of expertise. Feel free to ask me any questions about dogs and puppies!"
|
127 |
+
)
|
128 |
+
|
129 |
+
return {
|
130 |
+
"messages": [out_of_scope_message],
|
131 |
+
"next_tool": "final_response"
|
132 |
+
}
|
133 |
+
|
134 |
+
# 3. Node for using the RAG tool
|
135 |
+
def use_rag_tool(state):
|
136 |
+
"""Uses the RAG tool for dog-related questions"""
|
137 |
+
question = state["question"]
|
138 |
+
|
139 |
+
# Call the RAG tool directly
|
140 |
+
rag_result = self.rag_tool.invoke(question)
|
141 |
+
rag_response = rag_result["messages"][0].content
|
142 |
+
context = rag_result.get("context", [])
|
143 |
+
sources_info = rag_result.get("sources_info", [])
|
144 |
+
total_chunks = rag_result.get("total_chunks", 0)
|
145 |
+
|
146 |
+
# Evaluate the quality of the response
|
147 |
+
is_satisfactory = self.evaluate_response_quality(question, rag_response)
|
148 |
+
|
149 |
+
# Format detailed source information
|
150 |
+
sources_text = ""
|
151 |
+
if sources_info:
|
152 |
+
sources_text = f"*Based on {total_chunks} chunk(s):*\n"
|
153 |
+
for source in sources_info:
|
154 |
+
sources_text += f"- *Chunk {source['chunk_number']} - {source['source']} (Page: {source['page']})*\n"
|
155 |
+
else:
|
156 |
+
sources_text = "*Source: Livre \"Puppies for Dummies\"*"
|
157 |
+
|
158 |
+
# Create an AI message with the response and detailed sources
|
159 |
+
response_message = AIMessage(content=f"[Using RAG tool] - {sources_text}\n{rag_response}")
|
160 |
+
|
161 |
+
# If the response is not satisfactory, prepare to use Tavily
|
162 |
+
next_tool = "final_response" if is_satisfactory else "need_tavily"
|
163 |
+
|
164 |
+
return {
|
165 |
+
"messages": [response_message],
|
166 |
+
"context": context,
|
167 |
+
"sources_info": sources_info,
|
168 |
+
"next_tool": next_tool,
|
169 |
+
"retrieved_contexts": context,
|
170 |
+
"context_count": len(context)
|
171 |
+
}
|
172 |
+
|
173 |
+
# 4. Node for using the Tavily tool
|
174 |
+
def use_tavily_tool(state):
|
175 |
+
"""Uses the Tavily tool as a fallback for dog-related questions"""
|
176 |
+
question = state["question"]
|
177 |
+
|
178 |
+
# Call Tavily
|
179 |
+
tavily_result = self.tavily_tool.invoke(question)
|
180 |
+
|
181 |
+
# Format the sources and prepare content for LLM
|
182 |
+
sources_text = ""
|
183 |
+
sources_content = ""
|
184 |
+
has_useful_results = False
|
185 |
+
|
186 |
+
if tavily_result and len(tavily_result) > 0:
|
187 |
+
sources_text = f"*Based on {len(tavily_result[:3])} internet source(s):*\n"
|
188 |
+
|
189 |
+
for i, result in enumerate(tavily_result[:3], 1):
|
190 |
+
title = result.get('title', 'Unknown Source')
|
191 |
+
url = result.get('url', '')
|
192 |
+
content = result.get('content', '')
|
193 |
+
|
194 |
+
if content and len(content.strip()) > 50:
|
195 |
+
has_useful_results = True
|
196 |
+
# Format source in italics
|
197 |
+
domain = url.split('/')[2] if url and '/' in url else 'Web'
|
198 |
+
sources_text += f"- *Source {i} - {domain}: {title}*\n"
|
199 |
+
# Collect content for LLM processing
|
200 |
+
sources_content += f"Source {i} ({title}): {content[:300]}...\n\n"
|
201 |
+
|
202 |
+
if not has_useful_results:
|
203 |
+
# No useful results found
|
204 |
+
dont_know_message = AIMessage(
|
205 |
+
content=f"[Using Tavily tool] - *No reliable internet sources found for this question.*\n\nI couldn't find specific information about '{question}' in my knowledge base or through online searches. This might be a specialized topic that requires expertise from professionals in the field of canine education."
|
206 |
+
)
|
207 |
+
return {
|
208 |
+
"messages": [dont_know_message],
|
209 |
+
"next_tool": "final_response"
|
210 |
+
}
|
211 |
+
|
212 |
+
# Generate a proper response using LLM based on the sources
|
213 |
+
response_prompt = f"""Based on the following internet sources, provide a clear and helpful answer to the question: "{question}"
|
214 |
+
|
215 |
+
{sources_content}
|
216 |
+
|
217 |
+
Instructions:
|
218 |
+
- Provide a comprehensive answer based on the sources above
|
219 |
+
- Focus on practical, actionable information
|
220 |
+
- If the sources contain contradictory information, mention the different perspectives
|
221 |
+
- Keep the response clear and well-structured
|
222 |
+
- Do not mention the sources in your response (they will be displayed separately)
|
223 |
+
"""
|
224 |
+
|
225 |
+
try:
|
226 |
+
llm_response = self.final_llm.invoke([SystemMessage(content=response_prompt)])
|
227 |
+
generated_answer = llm_response.content
|
228 |
+
except Exception as e:
|
229 |
+
logger.error(f"Error generating Tavily response: {e}")
|
230 |
+
generated_answer = "I found some relevant information but couldn't process it properly."
|
231 |
+
|
232 |
+
# Create the final formatted message
|
233 |
+
response_message = AIMessage(content=f"[Using Tavily tool] - {sources_text}\n{generated_answer}")
|
234 |
+
|
235 |
+
return {
|
236 |
+
"messages": [response_message],
|
237 |
+
"next_tool": "final_response"
|
238 |
+
}
|
239 |
+
|
240 |
+
# 5. Node for cases where no source has a satisfactory answer
|
241 |
+
def say_dont_know(state):
|
242 |
+
"""Responds when no source has useful information"""
|
243 |
+
question = state["question"]
|
244 |
+
|
245 |
+
dont_know_message = AIMessage(content=f"I'm sorry, but I couldn't find specific information about '{question}' in my knowledge base or through online searches. This might be a specialized topic that requires expertise from professionals in the field of canine education.")
|
246 |
+
|
247 |
+
return {
|
248 |
+
"messages": [dont_know_message],
|
249 |
+
"next_tool": "final_response"
|
250 |
+
}
|
251 |
+
|
252 |
+
# 6. Node for generating the final response
|
253 |
+
def generate_final_response(state):
|
254 |
+
"""Generates a final response based on tool results"""
|
255 |
+
messages = state["messages"]
|
256 |
+
original_question = state["question"]
|
257 |
+
|
258 |
+
# Find tool messages
|
259 |
+
tool_responses = [msg.content for msg in messages if isinstance(msg, AIMessage)]
|
260 |
+
|
261 |
+
# If no tool messages, return a default response
|
262 |
+
if not tool_responses:
|
263 |
+
return {"messages": [AIMessage(content="I couldn't find information about your dog-related question.")]}
|
264 |
+
|
265 |
+
# Take the last tool message as the main content
|
266 |
+
tool_content = tool_responses[-1]
|
267 |
+
|
268 |
+
# If the tool message already contains detailed sources, return it as-is
|
269 |
+
if "[Using RAG tool]" in tool_content or "[Using Tavily tool]" in tool_content:
|
270 |
+
# Already contains detailed sources, return as-is
|
271 |
+
return {"messages": [AIMessage(content=tool_content)]}
|
272 |
+
|
273 |
+
# Use an LLM to generate a coherent final response but preserve source markers
|
274 |
+
system_prompt = f"""Here are the search results for the dog-related question: "{original_question}"
|
275 |
+
|
276 |
+
{tool_content}
|
277 |
+
|
278 |
+
Formulate a clear, helpful, and concise response based ONLY on these results.
|
279 |
+
IMPORTANT: If the search results start with "[Using RAG tool]" or "[Using Tavily tool]", keep these markers exactly as they are at the beginning of your response.
|
280 |
+
If the search results contain useful information, include it in your response rather than saying "I don't know".
|
281 |
+
Say "I don't know" only if the search results contain no useful information.
|
282 |
+
"""
|
283 |
+
|
284 |
+
response = self.final_llm.invoke([SystemMessage(content=system_prompt)])
|
285 |
+
|
286 |
+
return {"messages": [response]}
|
287 |
+
|
288 |
+
# 7. Routing function
|
289 |
+
def route_to_next_tool(state):
|
290 |
+
next_tool = state["next_tool"]
|
291 |
+
|
292 |
+
if next_tool == "rag_tool":
|
293 |
+
return "use_rag_tool"
|
294 |
+
elif next_tool == "out_of_scope":
|
295 |
+
return "out_of_scope"
|
296 |
+
elif next_tool == "tavily_tool":
|
297 |
+
return "use_tavily_tool"
|
298 |
+
elif next_tool == "need_tavily":
|
299 |
+
return "use_tavily_tool"
|
300 |
+
elif next_tool == "say_dont_know":
|
301 |
+
return "say_dont_know"
|
302 |
+
elif next_tool == "final_response":
|
303 |
+
return "generate_response"
|
304 |
+
else:
|
305 |
+
return "generate_response"
|
306 |
+
|
307 |
+
# 8. Building the LangGraph
|
308 |
+
workflow = StateGraph(AgentState)
|
309 |
+
|
310 |
+
# Adding nodes
|
311 |
+
workflow.add_node("smart_router", smart_router)
|
312 |
+
workflow.add_node("out_of_scope", out_of_scope)
|
313 |
+
workflow.add_node("use_rag_tool", use_rag_tool)
|
314 |
+
workflow.add_node("use_tavily_tool", use_tavily_tool)
|
315 |
+
workflow.add_node("say_dont_know", say_dont_know)
|
316 |
+
workflow.add_node("generate_response", generate_final_response)
|
317 |
+
|
318 |
+
# Connections
|
319 |
+
workflow.add_edge(START, "smart_router")
|
320 |
+
workflow.add_conditional_edges("smart_router", route_to_next_tool)
|
321 |
+
workflow.add_edge("out_of_scope", "generate_response")
|
322 |
+
workflow.add_conditional_edges("use_rag_tool", route_to_next_tool)
|
323 |
+
workflow.add_conditional_edges("use_tavily_tool", route_to_next_tool)
|
324 |
+
workflow.add_edge("say_dont_know", "generate_response")
|
325 |
+
workflow.add_edge("generate_response", END)
|
326 |
+
|
327 |
+
# Compile the graph
|
328 |
+
return workflow.compile()
|
329 |
+
|
330 |
+
def process_question(self, question: str):
|
331 |
+
""" Process a question with the agent workflow """
|
332 |
+
# Invoke the workflow
|
333 |
+
result = self.compiled_workflow.invoke({
|
334 |
+
"messages": HumanMessage(content=question),
|
335 |
+
"context": [],
|
336 |
+
"next_tool": "",
|
337 |
+
"question": "",
|
338 |
+
"retrieved_contexts": [],
|
339 |
+
"context_count": 0
|
340 |
+
})
|
341 |
+
|
342 |
+
return result
|
343 |
+
|
344 |
+
def get_final_response(self, result):
|
345 |
+
"""Extract the final response from the agent result with source information."""
|
346 |
+
messages = result.get("messages", [])
|
347 |
+
|
348 |
+
if not messages:
|
349 |
+
return "No response available."
|
350 |
+
|
351 |
+
# Get the last AI message
|
352 |
+
last_message = None
|
353 |
+
for msg in reversed(messages):
|
354 |
+
if hasattr(msg, 'content') and msg.content:
|
355 |
+
last_message = msg
|
356 |
+
break
|
357 |
+
|
358 |
+
if not last_message:
|
359 |
+
return "No valid response found."
|
360 |
+
|
361 |
+
response_content = last_message.content
|
362 |
+
|
363 |
+
# Extract and store source information in result for main.py to use
|
364 |
+
if "Tavily" in response_content and "Source" in response_content:
|
365 |
+
# Extract Tavily sources from the response content
|
366 |
+
tavily_sources = []
|
367 |
+
lines = response_content.split('\n')
|
368 |
+
|
369 |
+
for line in lines:
|
370 |
+
if line.strip().startswith('- *Source') and ':' in line:
|
371 |
+
# Parse line like "- *Source 1 - domain.com: Title*"
|
372 |
+
try:
|
373 |
+
# Extract source number, domain, and title
|
374 |
+
source_part = line.split('- *Source')[1].split('*')[0]
|
375 |
+
if ' - ' in source_part and ':' in source_part:
|
376 |
+
parts = source_part.split(' - ', 1)
|
377 |
+
source_num = parts[0].strip()
|
378 |
+
domain_title = parts[1]
|
379 |
+
if ':' in domain_title:
|
380 |
+
domain, title = domain_title.split(':', 1)
|
381 |
+
tavily_sources.append({
|
382 |
+
'source_num': source_num,
|
383 |
+
'domain': domain.strip(),
|
384 |
+
'title': title.strip()
|
385 |
+
})
|
386 |
+
except:
|
387 |
+
continue
|
388 |
+
|
389 |
+
# Store Tavily sources in result
|
390 |
+
result['tavily_sources'] = tavily_sources
|
391 |
+
|
392 |
+
return response_content
|
all_books_preprocessed_chunks.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
books_config.json
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"books": [
|
3 |
+
{
|
4 |
+
"filename": "HowtoRaisethePerfectDog.pdf",
|
5 |
+
"start_page": 15,
|
6 |
+
"end_page": 94,
|
7 |
+
"title": "How to Raise the Perfect Dog",
|
8 |
+
"description": "Guide for raising the perfect dog"
|
9 |
+
},
|
10 |
+
{
|
11 |
+
"filename": "OnTalkingTermsWithDogsCalmingSignal.pdf",
|
12 |
+
"start_page": 6,
|
13 |
+
"end_page": 169,
|
14 |
+
"title": "On Talking Terms With Dogs - Calming Signals",
|
15 |
+
"description": "Dog communication and calming signals"
|
16 |
+
},
|
17 |
+
{
|
18 |
+
"filename": "PerfectPuppyin7Days.pdf",
|
19 |
+
"start_page": 15,
|
20 |
+
"end_page": 260,
|
21 |
+
"title": "Perfect Puppy in 7 Days",
|
22 |
+
"description": "Guide for perfect puppy in 7 days"
|
23 |
+
},
|
24 |
+
{
|
25 |
+
"filename": "PuppiesForDummies.pdf",
|
26 |
+
"start_page": 9,
|
27 |
+
"end_page": 385,
|
28 |
+
"title": "Puppies For Dummies",
|
29 |
+
"description": "Complete guide for puppies"
|
30 |
+
},
|
31 |
+
{
|
32 |
+
"filename": "TheDoNoHarm DogTrainingandBehaviorHanbook.pdf",
|
33 |
+
"start_page": 34,
|
34 |
+
"end_page": 381,
|
35 |
+
"title": "The Do No Harm Dog Training and Behavior Handbook",
|
36 |
+
"description": "Non-violent dog training and behavior manual"
|
37 |
+
},
|
38 |
+
{
|
39 |
+
"filename": "DoNotShoottheDog.pdf",
|
40 |
+
"start_page": 6,
|
41 |
+
"end_page": 169,
|
42 |
+
"title": "Don't Shoot the Dog",
|
43 |
+
"description": "Positive training methods"
|
44 |
+
}
|
45 |
+
]
|
46 |
+
}
|
embedding_models.py
ADDED
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# embedding_models.py
|
2 |
+
import hashlib
|
3 |
+
import logging
|
4 |
+
import os
|
5 |
+
import shutil
|
6 |
+
import time
|
7 |
+
from pathlib import Path
|
8 |
+
from typing import List, Dict, Any, Optional
|
9 |
+
|
10 |
+
from langchain_openai import OpenAIEmbeddings
|
11 |
+
from langchain_core.documents import Document
|
12 |
+
from langchain_qdrant import QdrantVectorStore
|
13 |
+
from langchain.storage import LocalFileStore
|
14 |
+
from langchain.embeddings import CacheBackedEmbeddings
|
15 |
+
import qdrant_client
|
16 |
+
from qdrant_client.http.models import Distance, VectorParams
|
17 |
+
|
18 |
+
# Logging configuration
|
19 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
20 |
+
logger = logging.getLogger(__name__)
|
21 |
+
|
22 |
+
class CacheManager:
|
23 |
+
"""Cache manager with limits for Hugging Face Spaces"""
|
24 |
+
|
25 |
+
def __init__(self, cache_directory: str = "./cache", max_size_mb: int = 500, max_age_days: int = 7):
|
26 |
+
self.cache_directory = Path(cache_directory)
|
27 |
+
self.max_size_bytes = max_size_mb * 1024 * 1024 # Convert to bytes
|
28 |
+
self.max_age_seconds = max_age_days * 24 * 60 * 60 # Convert to seconds
|
29 |
+
|
30 |
+
def get_cache_size(self) -> int:
|
31 |
+
"""Compute the total cache size in bytes"""
|
32 |
+
total_size = 0
|
33 |
+
if self.cache_directory.exists():
|
34 |
+
for file_path in self.cache_directory.rglob('*'):
|
35 |
+
if file_path.is_file():
|
36 |
+
total_size += file_path.stat().st_size
|
37 |
+
return total_size
|
38 |
+
|
39 |
+
def get_cache_size_mb(self) -> float:
|
40 |
+
"""Return the cache size in MB"""
|
41 |
+
return self.get_cache_size() / (1024 * 1024)
|
42 |
+
|
43 |
+
def clean_old_files(self):
|
44 |
+
"""Delete cache files that are too old"""
|
45 |
+
if not self.cache_directory.exists():
|
46 |
+
return
|
47 |
+
|
48 |
+
current_time = time.time()
|
49 |
+
deleted_count = 0
|
50 |
+
|
51 |
+
for file_path in self.cache_directory.rglob('*'):
|
52 |
+
if file_path.is_file():
|
53 |
+
file_age = current_time - file_path.stat().st_mtime
|
54 |
+
if file_age > self.max_age_seconds:
|
55 |
+
try:
|
56 |
+
file_path.unlink()
|
57 |
+
deleted_count += 1
|
58 |
+
except Exception as e:
|
59 |
+
logger.warning(f"Unable to delete {file_path}: {e}")
|
60 |
+
|
61 |
+
if deleted_count > 0:
|
62 |
+
logger.info(f"🧹 Cache cleaned: {deleted_count} old files deleted")
|
63 |
+
|
64 |
+
def clear_cache_if_too_large(self):
|
65 |
+
"""Completely clear the cache if it exceeds the size limit"""
|
66 |
+
current_size_mb = self.get_cache_size_mb()
|
67 |
+
|
68 |
+
if current_size_mb > (self.max_size_bytes / (1024 * 1024)):
|
69 |
+
logger.warning(f"Cache too large ({current_size_mb:.1f}MB > {self.max_size_bytes/(1024*1024)}MB)")
|
70 |
+
try:
|
71 |
+
if self.cache_directory.exists():
|
72 |
+
shutil.rmtree(self.cache_directory)
|
73 |
+
self.cache_directory.mkdir(parents=True, exist_ok=True)
|
74 |
+
logger.info("Cache fully cleared to save disk space")
|
75 |
+
except Exception as e:
|
76 |
+
logger.error(f"Error while clearing cache: {e}")
|
77 |
+
|
78 |
+
def cleanup_cache(self):
|
79 |
+
"""Smart cache cleanup"""
|
80 |
+
# 1. Clean old files
|
81 |
+
self.clean_old_files()
|
82 |
+
|
83 |
+
# 2. Check size after cleaning
|
84 |
+
current_size_mb = self.get_cache_size_mb()
|
85 |
+
|
86 |
+
# 3. If still too large, clear completely
|
87 |
+
if current_size_mb > (self.max_size_bytes / (1024 * 1024)):
|
88 |
+
self.clear_cache_if_too_large()
|
89 |
+
else:
|
90 |
+
logger.info(f"Cache size: {current_size_mb:.1f}MB (OK)")
|
91 |
+
|
92 |
+
|
93 |
+
class OpenAIEmbeddingModel:
|
94 |
+
"""OpenAI embedding model with smart caching for Hugging Face Spaces"""
|
95 |
+
|
96 |
+
def __init__(self, model_name: str = "text-embedding-3-small", persist_directory: str = "./vector_stores",
|
97 |
+
max_cache_size_mb: int = 500, max_cache_age_days: int = 7):
|
98 |
+
self.name = "OpenAI Embeddings (Smart Cache)"
|
99 |
+
self.description = f"OpenAI embedding model {model_name} with smart caching for HF Spaces"
|
100 |
+
self.model_name = model_name
|
101 |
+
self.vector_dim = 1536 # Dimension of OpenAI vectors
|
102 |
+
|
103 |
+
# Setup directories
|
104 |
+
self.persist_directory = Path(persist_directory)
|
105 |
+
self.persist_directory.mkdir(parents=True, exist_ok=True)
|
106 |
+
self.cache_directory = Path("./cache")
|
107 |
+
self.cache_directory.mkdir(parents=True, exist_ok=True)
|
108 |
+
|
109 |
+
# Initialize cache manager with limits for HF Spaces
|
110 |
+
self.cache_manager = CacheManager(
|
111 |
+
cache_directory=str(self.cache_directory),
|
112 |
+
max_size_mb=max_cache_size_mb,
|
113 |
+
max_age_days=max_cache_age_days
|
114 |
+
)
|
115 |
+
|
116 |
+
# Initialize components
|
117 |
+
self.client = None
|
118 |
+
self.vector_store = None
|
119 |
+
self.retriever = None
|
120 |
+
self.embeddings = None
|
121 |
+
|
122 |
+
self._setup_embeddings()
|
123 |
+
|
124 |
+
def _setup_embeddings(self):
|
125 |
+
"""Setup OpenAI embeddings with smart caching"""
|
126 |
+
# Clean cache before starting
|
127 |
+
logger.info("🔍 Checking cache state...")
|
128 |
+
self.cache_manager.cleanup_cache()
|
129 |
+
|
130 |
+
# Create base OpenAI embeddings
|
131 |
+
base_embeddings = OpenAIEmbeddings(model=self.model_name)
|
132 |
+
|
133 |
+
# Create cached version
|
134 |
+
namespace_key = f"openai_{self.model_name}"
|
135 |
+
safe_namespace = hashlib.md5(namespace_key.encode()).hexdigest()
|
136 |
+
|
137 |
+
# Setup local file store for caching
|
138 |
+
store = LocalFileStore(str(self.cache_directory))
|
139 |
+
|
140 |
+
# Create cached embeddings
|
141 |
+
self.embeddings = CacheBackedEmbeddings.from_bytes_store(
|
142 |
+
base_embeddings,
|
143 |
+
store,
|
144 |
+
namespace=safe_namespace,
|
145 |
+
batch_size=32
|
146 |
+
)
|
147 |
+
|
148 |
+
cache_size = self.cache_manager.get_cache_size_mb()
|
149 |
+
logger.info(f"[{self.name}] Embeddings configured with smart cache (Size: {cache_size:.1f}MB)")
|
150 |
+
|
151 |
+
def _collection_exists(self, collection_name: str) -> bool:
|
152 |
+
"""Check if a collection already exists"""
|
153 |
+
try:
|
154 |
+
collections = self.client.get_collections()
|
155 |
+
return any(collection.name == collection_name for collection in collections.collections)
|
156 |
+
except Exception as e:
|
157 |
+
logger.warning(f"Error while checking collection {collection_name}: {e}")
|
158 |
+
return False
|
159 |
+
|
160 |
+
def create_vector_store(self, documents: List[Document], collection_name: str, k: int = 5) -> None:
|
161 |
+
"""Create the vector store for documents"""
|
162 |
+
# Path for persistent Qdrant storage - model-specific subdirectory
|
163 |
+
qdrant_path = self.persist_directory / "qdrant_db" / "openai_cached"
|
164 |
+
qdrant_path.mkdir(parents=True, exist_ok=True)
|
165 |
+
|
166 |
+
# Initialize Qdrant client with persistent storage
|
167 |
+
self.client = qdrant_client.QdrantClient(path=str(qdrant_path))
|
168 |
+
|
169 |
+
# Check if the collection already exists
|
170 |
+
if self._collection_exists(collection_name):
|
171 |
+
logger.info(f"[{self.name}] Collection '{collection_name}' already exists, loading...")
|
172 |
+
# Load the existing vector store
|
173 |
+
self.vector_store = QdrantVectorStore(
|
174 |
+
client=self.client,
|
175 |
+
collection_name=collection_name,
|
176 |
+
embedding=self.embeddings,
|
177 |
+
)
|
178 |
+
else:
|
179 |
+
logger.info(f"[{self.name}] Creating new collection '{collection_name}'...")
|
180 |
+
# Create a collection
|
181 |
+
self.client.create_collection(
|
182 |
+
collection_name=collection_name,
|
183 |
+
vectors_config=VectorParams(size=self.vector_dim, distance=Distance.COSINE)
|
184 |
+
)
|
185 |
+
|
186 |
+
# Create the vector store
|
187 |
+
self.vector_store = QdrantVectorStore(
|
188 |
+
client=self.client,
|
189 |
+
collection_name=collection_name,
|
190 |
+
embedding=self.embeddings,
|
191 |
+
)
|
192 |
+
|
193 |
+
# Add documents (caching will happen automatically)
|
194 |
+
logger.info(f"[{self.name}] Adding {len(documents)} documents (with embedding cache)...")
|
195 |
+
self.vector_store.add_documents(documents=documents)
|
196 |
+
logger.info(f"[{self.name}] Vector store created successfully")
|
197 |
+
|
198 |
+
# Create the retriever
|
199 |
+
self.retriever = self.vector_store.as_retriever(search_kwargs={"k": k})
|
200 |
+
|
201 |
+
# Check cache size after adding documents
|
202 |
+
cache_size = self.cache_manager.get_cache_size_mb()
|
203 |
+
if cache_size > 100: # Alert if > 100MB
|
204 |
+
logger.warning(f"Large cache: {cache_size:.1f}MB - consider cleaning soon")
|
205 |
+
|
206 |
+
def get_retriever(self):
|
207 |
+
"""Returns the retriever"""
|
208 |
+
if self.retriever is None:
|
209 |
+
raise ValueError("The vector store has not been initialized")
|
210 |
+
return self.retriever
|
211 |
+
|
212 |
+
def get_cache_info(self) -> Dict[str, Any]:
|
213 |
+
"""Return information about the cache state"""
|
214 |
+
return {
|
215 |
+
"cache_size_mb": self.cache_manager.get_cache_size_mb(),
|
216 |
+
"max_size_mb": self.cache_manager.max_size_bytes / (1024 * 1024),
|
217 |
+
"max_age_days": self.cache_manager.max_age_seconds / (24 * 60 * 60),
|
218 |
+
"cache_directory": str(self.cache_directory)
|
219 |
+
}
|
220 |
+
|
221 |
+
def manual_cache_cleanup(self):
|
222 |
+
"""Manual cache cleanup"""
|
223 |
+
logger.info("🧹 Manual cache cleanup requested...")
|
224 |
+
self.cache_manager.cleanup_cache()
|
225 |
+
|
226 |
+
|
227 |
+
def create_embedding_model(persist_directory: str = "./vector_stores",
|
228 |
+
max_cache_size_mb: int = 500,
|
229 |
+
max_cache_age_days: int = 7) -> OpenAIEmbeddingModel:
|
230 |
+
|
231 |
+
logger.info(f"Creating optimized OpenAI model (Max cache: {max_cache_size_mb}MB, Max age: {max_cache_age_days}d)")
|
232 |
+
return OpenAIEmbeddingModel(
|
233 |
+
persist_directory=persist_directory,
|
234 |
+
max_cache_size_mb=max_cache_size_mb,
|
235 |
+
max_cache_age_days=max_cache_age_days
|
236 |
+
)
|
main.py
ADDED
@@ -0,0 +1,444 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# main.py - FastAPI version of PuppyCompanion
|
2 |
+
import os
|
3 |
+
import logging
|
4 |
+
import json
|
5 |
+
import asyncio
|
6 |
+
from datetime import datetime
|
7 |
+
from typing import List, Dict, Any
|
8 |
+
from contextlib import asynccontextmanager
|
9 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
10 |
+
from fastapi.staticfiles import StaticFiles
|
11 |
+
from fastapi.responses import HTMLResponse, FileResponse
|
12 |
+
from pydantic import BaseModel
|
13 |
+
from dotenv import load_dotenv
|
14 |
+
|
15 |
+
# Import your existing modules
|
16 |
+
from rag_system import RAGSystem
|
17 |
+
from agent_workflow import AgentWorkflow
|
18 |
+
|
19 |
+
# Load environment variables
|
20 |
+
load_dotenv()
|
21 |
+
|
22 |
+
# Logging configuration
|
23 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
24 |
+
logger = logging.getLogger(__name__)
|
25 |
+
|
26 |
+
# Global variables
|
27 |
+
global_agent = None
|
28 |
+
global_qdrant_client = None
|
29 |
+
global_retriever = None
|
30 |
+
global_documents = None
|
31 |
+
initialization_completed = False
|
32 |
+
|
33 |
+
# Path to preprocessed data
|
34 |
+
PREPROCESSED_CHUNKS_PATH = "all_books_preprocessed_chunks.json"
|
35 |
+
|
36 |
+
# Pydantic models
|
37 |
+
class QuestionRequest(BaseModel):
|
38 |
+
question: str
|
39 |
+
|
40 |
+
class ChatResponse(BaseModel):
|
41 |
+
response: str
|
42 |
+
sources: List[Dict[str, Any]] = []
|
43 |
+
tool_used: str = ""
|
44 |
+
|
45 |
+
# WebSocket connection manager
|
46 |
+
class ConnectionManager:
|
47 |
+
def __init__(self):
|
48 |
+
self.active_connections: List[WebSocket] = []
|
49 |
+
|
50 |
+
async def connect(self, websocket: WebSocket):
|
51 |
+
await websocket.accept()
|
52 |
+
self.active_connections.append(websocket)
|
53 |
+
|
54 |
+
def disconnect(self, websocket: WebSocket):
|
55 |
+
self.active_connections.remove(websocket)
|
56 |
+
|
57 |
+
async def send_log(self, message: str, log_type: str = "info"):
|
58 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
59 |
+
log_data = {
|
60 |
+
"timestamp": timestamp,
|
61 |
+
"message": message,
|
62 |
+
"type": log_type
|
63 |
+
}
|
64 |
+
|
65 |
+
for connection in self.active_connections:
|
66 |
+
try:
|
67 |
+
await connection.send_text(json.dumps(log_data))
|
68 |
+
except:
|
69 |
+
pass
|
70 |
+
|
71 |
+
manager = ConnectionManager()
|
72 |
+
|
73 |
+
def load_preprocessed_chunks(file_path="all_books_preprocessed_chunks.json"):
|
74 |
+
"""Load preprocessed chunks from a JSON file."""
|
75 |
+
global global_documents
|
76 |
+
|
77 |
+
if global_documents is not None:
|
78 |
+
logger.info("Using cached document chunks")
|
79 |
+
return global_documents
|
80 |
+
|
81 |
+
logger.info(f"Loading preprocessed chunks from {file_path}")
|
82 |
+
try:
|
83 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
84 |
+
data = json.load(f)
|
85 |
+
|
86 |
+
from langchain_core.documents import Document
|
87 |
+
documents = []
|
88 |
+
for item in data:
|
89 |
+
doc = Document(
|
90 |
+
page_content=item['page_content'],
|
91 |
+
metadata=item['metadata']
|
92 |
+
)
|
93 |
+
documents.append(doc)
|
94 |
+
|
95 |
+
logger.info(f"Loaded {len(documents)} document chunks")
|
96 |
+
global_documents = documents
|
97 |
+
return documents
|
98 |
+
except Exception as e:
|
99 |
+
logger.error(f"Error loading preprocessed chunks: {str(e)}")
|
100 |
+
raise
|
101 |
+
|
102 |
+
def initialize_retriever(documents):
|
103 |
+
"""Create a retriever from documents using a shared Qdrant client."""
|
104 |
+
global global_qdrant_client, global_retriever
|
105 |
+
|
106 |
+
# Return existing retriever if already initialized
|
107 |
+
if global_retriever is not None:
|
108 |
+
logger.info("Using existing global retriever")
|
109 |
+
return global_retriever
|
110 |
+
|
111 |
+
logger.info("Creating retriever from documents")
|
112 |
+
try:
|
113 |
+
# Use langchain_qdrant to create a vector store
|
114 |
+
from qdrant_client import QdrantClient
|
115 |
+
from langchain_qdrant import QdrantVectorStore
|
116 |
+
from langchain_openai import OpenAIEmbeddings
|
117 |
+
|
118 |
+
# Create embedding object
|
119 |
+
embeddings = OpenAIEmbeddings()
|
120 |
+
logger.info("Created OpenAI embeddings object")
|
121 |
+
|
122 |
+
# Create a persistent path for embeddings storage
|
123 |
+
qdrant_path = "/tmp/qdrant_storage"
|
124 |
+
logger.info(f"Using persistent Qdrant storage path: {qdrant_path}")
|
125 |
+
|
126 |
+
# Create directory for Qdrant storage
|
127 |
+
os.makedirs(qdrant_path, exist_ok=True)
|
128 |
+
|
129 |
+
# Create or reuse global Qdrant client
|
130 |
+
if global_qdrant_client is None:
|
131 |
+
client = QdrantClient(path=qdrant_path)
|
132 |
+
global_qdrant_client = client
|
133 |
+
logger.info("Created new global Qdrant client with persistent storage")
|
134 |
+
else:
|
135 |
+
client = global_qdrant_client
|
136 |
+
logger.info("Using existing global Qdrant client")
|
137 |
+
|
138 |
+
# Check if collection already exists
|
139 |
+
try:
|
140 |
+
collections = client.get_collections()
|
141 |
+
collection_exists = any(collection.name == "puppies" for collection in collections.collections)
|
142 |
+
logger.info(f"Collection 'puppies' exists: {collection_exists}")
|
143 |
+
except Exception as e:
|
144 |
+
collection_exists = False
|
145 |
+
logger.info(f"Could not check collections, assuming none exist: {e}")
|
146 |
+
|
147 |
+
# OpenAI embeddings dimension
|
148 |
+
embedding_dim = 1536
|
149 |
+
|
150 |
+
# Create collection only if it doesn't exist
|
151 |
+
if not collection_exists:
|
152 |
+
from qdrant_client.http import models
|
153 |
+
client.create_collection(
|
154 |
+
collection_name="puppies",
|
155 |
+
vectors_config=models.VectorParams(
|
156 |
+
size=embedding_dim,
|
157 |
+
distance=models.Distance.COSINE
|
158 |
+
)
|
159 |
+
)
|
160 |
+
logger.info("Created new collection 'puppies'")
|
161 |
+
else:
|
162 |
+
logger.info("Using existing collection 'puppies'")
|
163 |
+
|
164 |
+
# Create vector store
|
165 |
+
vector_store = QdrantVectorStore(
|
166 |
+
client=client,
|
167 |
+
collection_name="puppies",
|
168 |
+
embedding=embeddings
|
169 |
+
)
|
170 |
+
|
171 |
+
# Add documents only if collection was just created (to avoid duplicates)
|
172 |
+
if not collection_exists:
|
173 |
+
vector_store.add_documents(documents)
|
174 |
+
logger.info(f"Added {len(documents)} documents to vector store")
|
175 |
+
else:
|
176 |
+
logger.info("Using existing embeddings in vector store")
|
177 |
+
|
178 |
+
# Create retriever
|
179 |
+
retriever = vector_store.as_retriever(search_kwargs={"k": 5})
|
180 |
+
logger.info("Created retriever")
|
181 |
+
|
182 |
+
# Store global retriever
|
183 |
+
global_retriever = retriever
|
184 |
+
|
185 |
+
return retriever
|
186 |
+
except Exception as e:
|
187 |
+
logger.error(f"Error creating retriever: {str(e)}")
|
188 |
+
raise
|
189 |
+
|
190 |
+
async def initialize_system():
|
191 |
+
"""Initialize the RAG system and agent"""
|
192 |
+
global global_agent, initialization_completed
|
193 |
+
|
194 |
+
if initialization_completed:
|
195 |
+
return global_agent
|
196 |
+
|
197 |
+
await manager.send_log("Starting system initialization...", "info")
|
198 |
+
|
199 |
+
try:
|
200 |
+
# Load documents
|
201 |
+
await manager.send_log("Loading document chunks...", "info")
|
202 |
+
documents = load_preprocessed_chunks()
|
203 |
+
await manager.send_log(f"Loaded {len(documents)} document chunks", "success")
|
204 |
+
|
205 |
+
# Create retriever
|
206 |
+
await manager.send_log("Creating retriever...", "info")
|
207 |
+
retriever = initialize_retriever(documents)
|
208 |
+
await manager.send_log("Retriever ready", "success")
|
209 |
+
|
210 |
+
# Create RAG system
|
211 |
+
await manager.send_log("Setting up RAG system...", "info")
|
212 |
+
rag_system = RAGSystem(retriever)
|
213 |
+
rag_tool = rag_system.create_rag_tool()
|
214 |
+
await manager.send_log("RAG system ready", "success")
|
215 |
+
|
216 |
+
# Create agent workflow
|
217 |
+
await manager.send_log("Initializing agent workflow...", "info")
|
218 |
+
agent = AgentWorkflow(rag_tool)
|
219 |
+
await manager.send_log("Agent workflow ready", "success")
|
220 |
+
|
221 |
+
global_agent = agent
|
222 |
+
initialization_completed = True
|
223 |
+
|
224 |
+
await manager.send_log("System initialization completed!", "success")
|
225 |
+
return agent
|
226 |
+
|
227 |
+
except Exception as e:
|
228 |
+
await manager.send_log(f"Error during initialization: {str(e)}", "error")
|
229 |
+
raise
|
230 |
+
|
231 |
+
@asynccontextmanager
|
232 |
+
async def lifespan(app: FastAPI):
|
233 |
+
"""Manage application lifespan"""
|
234 |
+
# Startup
|
235 |
+
try:
|
236 |
+
await initialize_system()
|
237 |
+
logger.info("System initialized successfully")
|
238 |
+
except Exception as e:
|
239 |
+
logger.error(f"Failed to initialize system: {e}")
|
240 |
+
raise # ⚠️ IMPORTANT: Arrêter l'application si l'initialisation échoue
|
241 |
+
|
242 |
+
yield
|
243 |
+
|
244 |
+
# Shutdown - cleanup if needed
|
245 |
+
logger.info("Application shutdown")
|
246 |
+
|
247 |
+
# FastAPI app with lifespan
|
248 |
+
app = FastAPI(
|
249 |
+
title="PuppyCompanion",
|
250 |
+
description="AI Assistant for Puppy Care",
|
251 |
+
lifespan=lifespan
|
252 |
+
)
|
253 |
+
|
254 |
+
@app.get("/", response_class=HTMLResponse)
|
255 |
+
async def get_index():
|
256 |
+
"""Serve the main HTML page"""
|
257 |
+
return FileResponse("static/index.html")
|
258 |
+
|
259 |
+
@app.get("/favicon.ico")
|
260 |
+
async def get_favicon():
|
261 |
+
"""Return a 204 No Content for favicon to avoid 404 errors"""
|
262 |
+
from fastapi import Response
|
263 |
+
return Response(status_code=204)
|
264 |
+
|
265 |
+
@app.websocket("/ws")
|
266 |
+
async def websocket_endpoint(websocket: WebSocket):
|
267 |
+
"""WebSocket endpoint for real-time logs"""
|
268 |
+
await manager.connect(websocket)
|
269 |
+
try:
|
270 |
+
while True:
|
271 |
+
# Keep connection alive
|
272 |
+
await asyncio.sleep(1)
|
273 |
+
except WebSocketDisconnect:
|
274 |
+
manager.disconnect(websocket)
|
275 |
+
|
276 |
+
@app.post("/chat", response_model=ChatResponse)
|
277 |
+
async def chat_endpoint(request: QuestionRequest):
|
278 |
+
"""Main chat endpoint"""
|
279 |
+
global global_agent
|
280 |
+
|
281 |
+
if not initialization_completed or not global_agent:
|
282 |
+
await manager.send_log("System not initialized, starting initialization...", "warning")
|
283 |
+
try:
|
284 |
+
global_agent = await initialize_system()
|
285 |
+
except Exception as e:
|
286 |
+
raise HTTPException(status_code=500, detail="System initialization failed")
|
287 |
+
|
288 |
+
question = request.question
|
289 |
+
await manager.send_log(f"New question: {question}", "info")
|
290 |
+
|
291 |
+
try:
|
292 |
+
# Process question with agent
|
293 |
+
await manager.send_log("Processing with agent workflow...", "info")
|
294 |
+
result = global_agent.process_question(question)
|
295 |
+
|
296 |
+
# Extract response and metadata
|
297 |
+
response_content = global_agent.get_final_response(result)
|
298 |
+
|
299 |
+
# Parse tool usage and send detailed info to debug console
|
300 |
+
tool_used = "Unknown"
|
301 |
+
sources = []
|
302 |
+
|
303 |
+
if "[Using RAG tool]" in response_content:
|
304 |
+
tool_used = "RAG Tool"
|
305 |
+
await manager.send_log("Used RAG tool - Knowledge base search", "tool")
|
306 |
+
|
307 |
+
# Send detailed RAG chunks to debug console
|
308 |
+
if "context" in result:
|
309 |
+
await manager.send_log(f"Retrieved {len(result['context'])} chunks from knowledge base:", "info")
|
310 |
+
for i, doc in enumerate(result["context"], 1):
|
311 |
+
source_name = doc.metadata.get('source', 'Unknown')
|
312 |
+
page = doc.metadata.get('page', 'N/A')
|
313 |
+
chapter = doc.metadata.get('chapter', '')
|
314 |
+
|
315 |
+
# Create detailed chunk info for console
|
316 |
+
if chapter:
|
317 |
+
chunk_header = f"Chunk {i} - {source_name} (Chapter: {chapter}, Page: {page})"
|
318 |
+
else:
|
319 |
+
chunk_header = f"Chunk {i} - {source_name} (Page: {page})"
|
320 |
+
|
321 |
+
await manager.send_log(chunk_header, "source")
|
322 |
+
|
323 |
+
# Send chunk content preview
|
324 |
+
content_preview = doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
|
325 |
+
await manager.send_log(f"Content: {content_preview}", "chunk")
|
326 |
+
|
327 |
+
# Collect for sources array (minimal info)
|
328 |
+
source_info = {
|
329 |
+
"chunk": i,
|
330 |
+
"source": source_name,
|
331 |
+
"page": page,
|
332 |
+
"chapter": chapter
|
333 |
+
}
|
334 |
+
sources.append(source_info)
|
335 |
+
|
336 |
+
elif "[Using Tavily tool]" in response_content:
|
337 |
+
tool_used = "Tavily Tool"
|
338 |
+
await manager.send_log("Used Tavily tool - Web search", "tool")
|
339 |
+
|
340 |
+
# Extract Tavily sources from response content and send to debug console
|
341 |
+
lines = response_content.split('\n')
|
342 |
+
tavily_sources_count = 0
|
343 |
+
|
344 |
+
for line in lines:
|
345 |
+
line_stripped = line.strip()
|
346 |
+
# Look for Tavily source lines like "- *Source 1 - domain.com: Title*"
|
347 |
+
if (line_stripped.startswith('- *Source') and ':' in line_stripped):
|
348 |
+
tavily_sources_count += 1
|
349 |
+
# Extract and format for debug console
|
350 |
+
try:
|
351 |
+
# Remove markdown formatting for clean display
|
352 |
+
clean_source = line_stripped.replace('- *', '').replace('*', '')
|
353 |
+
await manager.send_log(f"{clean_source}", "source")
|
354 |
+
except:
|
355 |
+
await manager.send_log(f"{line_stripped}", "source")
|
356 |
+
|
357 |
+
if tavily_sources_count > 0:
|
358 |
+
await manager.send_log(f"Found {tavily_sources_count} web sources", "info")
|
359 |
+
else:
|
360 |
+
await manager.send_log("Searched the web for current information", "source")
|
361 |
+
|
362 |
+
elif "out of scope" in response_content.lower():
|
363 |
+
tool_used = "Out of Scope"
|
364 |
+
await manager.send_log("Question outside scope (not dog-related)", "warning")
|
365 |
+
|
366 |
+
# Clean response content - REMOVE ALL source references for mobile interface
|
367 |
+
clean_response = response_content
|
368 |
+
|
369 |
+
# Remove tool markers
|
370 |
+
clean_response = clean_response.replace("[Using RAG tool]", "").replace("[Using Tavily tool]", "").strip()
|
371 |
+
|
372 |
+
# Remove ALL source-related lines with comprehensive patterns
|
373 |
+
lines = clean_response.split('\n')
|
374 |
+
cleaned_lines = []
|
375 |
+
for line in lines:
|
376 |
+
line_stripped = line.strip()
|
377 |
+
# Skip lines that are source references (comprehensive patterns)
|
378 |
+
skip_line = False
|
379 |
+
|
380 |
+
# Pattern 1: Lines starting with * containing Source/Chunk/Based on
|
381 |
+
if (line_stripped.startswith('*') and
|
382 |
+
('Chunk' in line or 'Source' in line or 'Based on' in line or 'Basé sur' in line)):
|
383 |
+
skip_line = True
|
384 |
+
|
385 |
+
# Pattern 2: Lines starting with - * containing Source/Chunk/Based on
|
386 |
+
if (line_stripped.startswith('- *') and
|
387 |
+
('Chunk' in line or 'Source' in line or 'Based on' in line or 'Basé sur' in line)):
|
388 |
+
skip_line = True
|
389 |
+
|
390 |
+
# Pattern 3: Lines that are just chunk references like "- *Chunk 1 - filename*"
|
391 |
+
if (line_stripped.startswith('- *Chunk') and line_stripped.endswith('*')):
|
392 |
+
skip_line = True
|
393 |
+
|
394 |
+
# Pattern 4: Lines that start with "- *Based on"
|
395 |
+
if line_stripped.startswith('- *Based on'):
|
396 |
+
skip_line = True
|
397 |
+
|
398 |
+
# Add line only if it's not a source reference and not empty
|
399 |
+
if not skip_line and line_stripped:
|
400 |
+
cleaned_lines.append(line)
|
401 |
+
|
402 |
+
# Final clean response for mobile interface
|
403 |
+
final_response = '\n'.join(cleaned_lines).strip()
|
404 |
+
|
405 |
+
# Additional cleanup - remove any remaining source markers at start
|
406 |
+
while final_response.startswith('- *') or final_response.startswith('*'):
|
407 |
+
# Find the end of the line to remove
|
408 |
+
if '\n' in final_response:
|
409 |
+
final_response = final_response.split('\n', 1)[1].strip()
|
410 |
+
else:
|
411 |
+
final_response = ""
|
412 |
+
break
|
413 |
+
|
414 |
+
# Ensure we have a response
|
415 |
+
if not final_response:
|
416 |
+
final_response = "I apologize, but I couldn't generate a proper response to your question."
|
417 |
+
|
418 |
+
await manager.send_log(f"Clean response ready for mobile interface", "success")
|
419 |
+
|
420 |
+
return ChatResponse(
|
421 |
+
response=final_response,
|
422 |
+
sources=sources, # Minimal info for API, detailed info already sent to debug console
|
423 |
+
tool_used=tool_used
|
424 |
+
)
|
425 |
+
|
426 |
+
except Exception as e:
|
427 |
+
await manager.send_log(f"Error processing question: {str(e)}", "error")
|
428 |
+
raise HTTPException(status_code=500, detail=str(e))
|
429 |
+
|
430 |
+
@app.get("/health")
|
431 |
+
async def health_check():
|
432 |
+
"""Health check endpoint"""
|
433 |
+
return {
|
434 |
+
"status": "healthy",
|
435 |
+
"initialized": initialization_completed,
|
436 |
+
"timestamp": datetime.now().isoformat()
|
437 |
+
}
|
438 |
+
|
439 |
+
# Mount static files
|
440 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
441 |
+
|
442 |
+
if __name__ == "__main__":
|
443 |
+
import uvicorn
|
444 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
pyproject.toml
ADDED
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[project]
|
2 |
+
name = "puppycompanion-fastapi"
|
3 |
+
version = "0.2.0"
|
4 |
+
description = "PuppyCompanion - Assistant IA pour l'éducation canine avec interface FastAPI moderne"
|
5 |
+
readme = "README.md"
|
6 |
+
authors = [
|
7 |
+
{name = "jthomazo", email = "[email protected]"},
|
8 |
+
]
|
9 |
+
dependencies = [
|
10 |
+
# FastAPI et serveur web
|
11 |
+
"fastapi>=0.104.1",
|
12 |
+
"uvicorn[standard]>=0.24.0",
|
13 |
+
"websockets>=12.0",
|
14 |
+
"aiofiles>=23.2.1",
|
15 |
+
|
16 |
+
# LangChain core
|
17 |
+
"langchain>=0.0.300",
|
18 |
+
"langchain-community>=0.0.16",
|
19 |
+
"langchain-core>=0.1.0",
|
20 |
+
"langchain-openai>=0.0.5",
|
21 |
+
"langgraph>=0.0.17",
|
22 |
+
|
23 |
+
# Vector databases et embeddings
|
24 |
+
"langchain-qdrant>=0.0.1",
|
25 |
+
"qdrant-client>=1.6.0",
|
26 |
+
"langchain-huggingface>=0.0.1",
|
27 |
+
"sentence-transformers>=2.7.0",
|
28 |
+
"transformers>=4.40.0",
|
29 |
+
"torch>=2.3.0",
|
30 |
+
|
31 |
+
# APIs et recherche web
|
32 |
+
"openai>=1.6.0",
|
33 |
+
"tavily-python>=0.2.4",
|
34 |
+
|
35 |
+
# Utilitaires core
|
36 |
+
"python-dotenv>=1.0.0",
|
37 |
+
"pydantic>=2.7.0",
|
38 |
+
"pandas>=2.0.0",
|
39 |
+
"numpy>=1.24.0",
|
40 |
+
"tiktoken>=0.7.0",
|
41 |
+
|
42 |
+
# Processing et évaluation
|
43 |
+
"ragas>=0.1.1",
|
44 |
+
"scikit-learn>=1.4.0",
|
45 |
+
"tqdm>=4.66.0",
|
46 |
+
|
47 |
+
# Document processing (si nécessaire pour preprocessing)
|
48 |
+
"pymupdf>=1.22.0",
|
49 |
+
"pypdf>=3.15.1",
|
50 |
+
"unstructured>=0.11.0",
|
51 |
+
"pdf2image>=1.16.0",
|
52 |
+
"pdfminer.six>=20221105",
|
53 |
+
|
54 |
+
# Monitoring et debug
|
55 |
+
"nest-asyncio>=1.5.6",
|
56 |
+
"matplotlib>=3.7.0",
|
57 |
+
"seaborn>=0.12.0",
|
58 |
+
|
59 |
+
# Support images (si nécessaire)
|
60 |
+
"pillow>=10.0.0",
|
61 |
+
"pi-heif>=0.12.0",
|
62 |
+
"wrapt>=1.15.0",
|
63 |
+
]
|
64 |
+
requires-python = ">=3.9,<4.0"
|
65 |
+
license = "MIT"
|
66 |
+
|
67 |
+
[build-system]
|
68 |
+
requires = ["setuptools>=42", "wheel"]
|
69 |
+
build-backend = "setuptools.build_meta"
|
70 |
+
|
71 |
+
[tool.setuptools]
|
72 |
+
py-modules = [
|
73 |
+
"main",
|
74 |
+
"rag_system",
|
75 |
+
"agent_workflow",
|
76 |
+
"embedding_models",
|
77 |
+
]
|
78 |
+
|
79 |
+
[tool.setuptools.packages.find]
|
80 |
+
exclude = [
|
81 |
+
"data*",
|
82 |
+
"metrics*",
|
83 |
+
"venv_dev_312*",
|
84 |
+
"doc*",
|
85 |
+
".venv*",
|
86 |
+
"__pycache__*",
|
87 |
+
"*.egg-info*",
|
88 |
+
"static*",
|
89 |
+
"backup_chainlit*"
|
90 |
+
]
|
91 |
+
|
92 |
+
[tool.pytest.ini_options]
|
93 |
+
testpaths = ["tests"]
|
94 |
+
|
95 |
+
[tool.black]
|
96 |
+
line-length = 100
|
97 |
+
target-version = ['py39']
|
98 |
+
|
99 |
+
[tool.ruff]
|
100 |
+
line-length = 88
|
101 |
+
target-version = "py39"
|
102 |
+
select = ["E", "F", "I"]
|
103 |
+
|
104 |
+
[project.optional-dependencies]
|
105 |
+
dev = [
|
106 |
+
"black>=23.10.0",
|
107 |
+
"isort>=5.12.0",
|
108 |
+
"mypy>=1.6.1",
|
109 |
+
"pytest>=7.4.3",
|
110 |
+
"ruff>=0.0.270",
|
111 |
+
"httpx>=0.25.0", # Pour tester l'API FastAPI
|
112 |
+
]
|
113 |
+
|
114 |
+
[project.scripts]
|
115 |
+
puppycompanion = "main:main"
|
116 |
+
|
117 |
+
[tool.isort]
|
118 |
+
profile = "black"
|
119 |
+
line_length = 100
|
120 |
+
|
121 |
+
[tool.mypy]
|
122 |
+
python_version = "3.9"
|
123 |
+
warn_return_any = true
|
124 |
+
warn_unused_configs = true
|
125 |
+
disallow_untyped_defs = true
|
126 |
+
disallow_incomplete_defs = true
|
rag_system.py
ADDED
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# rag_system.py
|
2 |
+
import logging
|
3 |
+
from typing import Dict, List, Optional, TypedDict
|
4 |
+
|
5 |
+
from langchain_openai import ChatOpenAI
|
6 |
+
from langchain_core.documents import Document
|
7 |
+
from langchain_core.messages import HumanMessage
|
8 |
+
from langchain.prompts import ChatPromptTemplate
|
9 |
+
from langchain_core.tools import tool
|
10 |
+
|
11 |
+
from langgraph.graph import StateGraph, START, END
|
12 |
+
|
13 |
+
# Logging configuration
|
14 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
15 |
+
logger = logging.getLogger(__name__)
|
16 |
+
|
17 |
+
# RAG prompt for puppy-related questions
|
18 |
+
RAG_PROMPT = """
|
19 |
+
You are an assistant specialized in puppy education and care.
|
20 |
+
Your role is to help new puppy owners by answering their questions with accuracy and kindness.
|
21 |
+
Use only the information provided in the context to formulate your answers.
|
22 |
+
If you cannot find the information in the context, just say "I don't know".
|
23 |
+
|
24 |
+
### Question
|
25 |
+
{question}
|
26 |
+
|
27 |
+
### Context
|
28 |
+
{context}
|
29 |
+
"""
|
30 |
+
|
31 |
+
class State(TypedDict):
|
32 |
+
question: str
|
33 |
+
context: List[Document]
|
34 |
+
response: str
|
35 |
+
|
36 |
+
class RAGSystem:
|
37 |
+
"""RAG system for puppy-related questions"""
|
38 |
+
|
39 |
+
def __init__(self, retriever, model_name: str = "gpt-4o-mini"):
|
40 |
+
self.retriever = retriever
|
41 |
+
self.llm = ChatOpenAI(model=model_name)
|
42 |
+
self.rag_prompt = ChatPromptTemplate.from_template(RAG_PROMPT)
|
43 |
+
self.graph_rag = self._build_graph()
|
44 |
+
|
45 |
+
def _build_graph(self):
|
46 |
+
"""Builds the RAG graph"""
|
47 |
+
|
48 |
+
def retrieve(state):
|
49 |
+
retrieved_docs = self.retriever.invoke(state["question"])
|
50 |
+
return {"context": retrieved_docs}
|
51 |
+
|
52 |
+
def generate(state):
|
53 |
+
docs_content = "\n\n".join([doc.page_content for doc in state["context"]])
|
54 |
+
messages = self.rag_prompt.format_messages(
|
55 |
+
question=state["question"],
|
56 |
+
context=docs_content
|
57 |
+
)
|
58 |
+
response = self.llm.invoke(messages)
|
59 |
+
return {"response": response.content}
|
60 |
+
|
61 |
+
# Build the graph
|
62 |
+
graph_builder = StateGraph(State).add_sequence([retrieve, generate])
|
63 |
+
graph_builder.add_edge(START, "retrieve")
|
64 |
+
return graph_builder.compile()
|
65 |
+
|
66 |
+
def process_query(self, question: str) -> Dict:
|
67 |
+
""" Processes a query and returns the response with context """
|
68 |
+
result = self.graph_rag.invoke({"question": question})
|
69 |
+
|
70 |
+
# Format detailed source information
|
71 |
+
sources_info = []
|
72 |
+
for i, doc in enumerate(result["context"], 1):
|
73 |
+
metadata = doc.metadata
|
74 |
+
# Extract useful metadata information
|
75 |
+
source_name = metadata.get('source', 'Unknown')
|
76 |
+
page = metadata.get('page', 'N/A')
|
77 |
+
chapter = metadata.get('chapter', '')
|
78 |
+
|
79 |
+
# Create a detailed source description
|
80 |
+
if chapter:
|
81 |
+
source_desc = f"Chunk {i} - {source_name} (Chapter: {chapter}, Page: {page})"
|
82 |
+
else:
|
83 |
+
source_desc = f"Chunk {i} - {source_name} (Page: {page})"
|
84 |
+
|
85 |
+
sources_info.append({
|
86 |
+
'chunk_number': i,
|
87 |
+
'description': source_desc,
|
88 |
+
'source': source_name,
|
89 |
+
'page': page,
|
90 |
+
'chapter': chapter,
|
91 |
+
'content_preview': doc.page_content[:100] + "..." if len(doc.page_content) > 100 else doc.page_content
|
92 |
+
})
|
93 |
+
|
94 |
+
return {
|
95 |
+
"response": result["response"],
|
96 |
+
"context": result["context"],
|
97 |
+
"sources_info": sources_info,
|
98 |
+
"total_chunks": len(result["context"])
|
99 |
+
}
|
100 |
+
|
101 |
+
def create_rag_tool(self):
|
102 |
+
"""Creates a RAG tool for the agent"""
|
103 |
+
|
104 |
+
# Reference to the current instance to use it in the tool
|
105 |
+
rag_system = self
|
106 |
+
|
107 |
+
@tool
|
108 |
+
def ai_rag_tool(question: str) -> Dict:
|
109 |
+
"""MANDATORY for all questions about puppies, their behavior, education or training.
|
110 |
+
This tool accesses a specialized knowledge base on puppies with expert and reliable information.
|
111 |
+
Any question regarding puppy care, education, behavior or health MUST be processed by this tool.
|
112 |
+
The input must be a complete question."""
|
113 |
+
|
114 |
+
# Invoke the RAG graph
|
115 |
+
result = rag_system.process_query(question)
|
116 |
+
|
117 |
+
return {
|
118 |
+
"messages": [HumanMessage(content=result["response"])],
|
119 |
+
"context": result["context"],
|
120 |
+
"sources_info": result["sources_info"],
|
121 |
+
"total_chunks": result["total_chunks"]
|
122 |
+
}
|
123 |
+
|
124 |
+
return ai_rag_tool
|
requirements.txt
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# FastAPI and web framework dependencies
|
2 |
+
fastapi==0.104.1
|
3 |
+
uvicorn[standard]==0.24.0
|
4 |
+
websockets==12.0
|
5 |
+
|
6 |
+
# Existing LangChain dependencies
|
7 |
+
langchain-openai==0.1.0
|
8 |
+
langchain-core==0.2.0
|
9 |
+
langchain-community==0.2.0
|
10 |
+
langchain-qdrant==0.1.0
|
11 |
+
langgraph==0.1.0
|
12 |
+
|
13 |
+
# Vector database and embeddings
|
14 |
+
qdrant-client==1.8.0
|
15 |
+
sentence-transformers==2.7.0
|
16 |
+
|
17 |
+
# Web search
|
18 |
+
tavily-python==0.3.0
|
19 |
+
|
20 |
+
# Core dependencies
|
21 |
+
python-dotenv==1.0.0
|
22 |
+
pydantic==2.7.0
|
23 |
+
pandas==2.2.0
|
24 |
+
numpy==1.26.0
|
25 |
+
scikit-learn==1.4.0
|
26 |
+
transformers==4.40.0
|
27 |
+
torch==2.3.0
|
28 |
+
tiktoken==0.7.0
|
29 |
+
|
30 |
+
# Additional utilities
|
31 |
+
aiofiles==23.2.1
|
static/_index.html
ADDED
@@ -0,0 +1,655 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>PuppyCompanion - AI Assistant</title>
|
7 |
+
<!-- Marked.js for Markdown rendering -->
|
8 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
|
9 |
+
<style>
|
10 |
+
* {
|
11 |
+
margin: 0;
|
12 |
+
padding: 0;
|
13 |
+
box-sizing: border-box;
|
14 |
+
}
|
15 |
+
|
16 |
+
body {
|
17 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
18 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
19 |
+
height: 100vh;
|
20 |
+
overflow: hidden;
|
21 |
+
}
|
22 |
+
|
23 |
+
.container {
|
24 |
+
display: flex;
|
25 |
+
height: 100vh;
|
26 |
+
max-width: 1400px;
|
27 |
+
margin: 0 auto;
|
28 |
+
background: rgba(255, 255, 255, 0.1);
|
29 |
+
backdrop-filter: blur(10px);
|
30 |
+
border-radius: 20px;
|
31 |
+
overflow: hidden;
|
32 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
33 |
+
}
|
34 |
+
|
35 |
+
/* Mobile Interface (Left) */
|
36 |
+
.mobile-interface {
|
37 |
+
flex: 1;
|
38 |
+
max-width: 400px;
|
39 |
+
background: linear-gradient(to bottom, #1e1e1e, #2d2d2d);
|
40 |
+
display: flex;
|
41 |
+
flex-direction: column;
|
42 |
+
position: relative;
|
43 |
+
border-radius: 20px 0 0 20px;
|
44 |
+
overflow: hidden;
|
45 |
+
}
|
46 |
+
|
47 |
+
.mobile-header {
|
48 |
+
background: rgba(0, 0, 0, 0.3);
|
49 |
+
padding: 20px;
|
50 |
+
text-align: center;
|
51 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
52 |
+
}
|
53 |
+
|
54 |
+
.mobile-header h1 {
|
55 |
+
color: #ffffff;
|
56 |
+
font-size: 1.5rem;
|
57 |
+
font-weight: 600;
|
58 |
+
margin-bottom: 5px;
|
59 |
+
}
|
60 |
+
|
61 |
+
.mobile-header p {
|
62 |
+
color: #a0a0a0;
|
63 |
+
font-size: 0.9rem;
|
64 |
+
}
|
65 |
+
|
66 |
+
.chat-container {
|
67 |
+
flex: 1;
|
68 |
+
display: flex;
|
69 |
+
flex-direction: column;
|
70 |
+
padding: 20px;
|
71 |
+
overflow: hidden;
|
72 |
+
}
|
73 |
+
|
74 |
+
.messages {
|
75 |
+
flex: 1;
|
76 |
+
overflow-y: auto;
|
77 |
+
margin-bottom: 20px;
|
78 |
+
padding-right: 5px;
|
79 |
+
}
|
80 |
+
|
81 |
+
.messages::-webkit-scrollbar {
|
82 |
+
width: 4px;
|
83 |
+
}
|
84 |
+
|
85 |
+
.messages::-webkit-scrollbar-track {
|
86 |
+
background: rgba(255, 255, 255, 0.1);
|
87 |
+
border-radius: 2px;
|
88 |
+
}
|
89 |
+
|
90 |
+
.messages::-webkit-scrollbar-thumb {
|
91 |
+
background: rgba(255, 255, 255, 0.3);
|
92 |
+
border-radius: 2px;
|
93 |
+
}
|
94 |
+
|
95 |
+
.message {
|
96 |
+
margin-bottom: 15px;
|
97 |
+
animation: slideIn 0.3s ease-out;
|
98 |
+
}
|
99 |
+
|
100 |
+
@keyframes slideIn {
|
101 |
+
from {
|
102 |
+
opacity: 0;
|
103 |
+
transform: translateY(20px);
|
104 |
+
}
|
105 |
+
to {
|
106 |
+
opacity: 1;
|
107 |
+
transform: translateY(0);
|
108 |
+
}
|
109 |
+
}
|
110 |
+
|
111 |
+
.message.user {
|
112 |
+
text-align: right;
|
113 |
+
}
|
114 |
+
|
115 |
+
.message.bot {
|
116 |
+
text-align: left;
|
117 |
+
}
|
118 |
+
|
119 |
+
.message-content {
|
120 |
+
display: inline-block;
|
121 |
+
max-width: 85%;
|
122 |
+
padding: 12px 16px;
|
123 |
+
border-radius: 18px;
|
124 |
+
word-wrap: break-word;
|
125 |
+
line-height: 1.4;
|
126 |
+
}
|
127 |
+
|
128 |
+
.message.user .message-content {
|
129 |
+
background: linear-gradient(135deg, #007AFF, #0051D5);
|
130 |
+
color: white;
|
131 |
+
}
|
132 |
+
|
133 |
+
.message.bot .message-content {
|
134 |
+
background: rgba(255, 255, 255, 0.1);
|
135 |
+
color: #ffffff;
|
136 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
137 |
+
}
|
138 |
+
|
139 |
+
/* Markdown styling for bot messages */
|
140 |
+
.message.bot .message-content h1,
|
141 |
+
.message.bot .message-content h2,
|
142 |
+
.message.bot .message-content h3 {
|
143 |
+
color: #ffffff;
|
144 |
+
margin: 8px 0 4px 0;
|
145 |
+
font-weight: 600;
|
146 |
+
}
|
147 |
+
|
148 |
+
.message.bot .message-content h1 {
|
149 |
+
font-size: 1.2em;
|
150 |
+
}
|
151 |
+
|
152 |
+
.message.bot .message-content h2 {
|
153 |
+
font-size: 1.1em;
|
154 |
+
}
|
155 |
+
|
156 |
+
.message.bot .message-content h3 {
|
157 |
+
font-size: 1em;
|
158 |
+
}
|
159 |
+
|
160 |
+
.message.bot .message-content ul,
|
161 |
+
.message.bot .message-content ol {
|
162 |
+
margin: 8px 0;
|
163 |
+
padding-left: 20px;
|
164 |
+
}
|
165 |
+
|
166 |
+
.message.bot .message-content li {
|
167 |
+
margin: 2px 0;
|
168 |
+
}
|
169 |
+
|
170 |
+
.message.bot .message-content p {
|
171 |
+
margin: 6px 0;
|
172 |
+
line-height: 1.4;
|
173 |
+
}
|
174 |
+
|
175 |
+
.message.bot .message-content strong {
|
176 |
+
color: #ffffff;
|
177 |
+
font-weight: 600;
|
178 |
+
}
|
179 |
+
|
180 |
+
.message.bot .message-content em {
|
181 |
+
font-style: italic;
|
182 |
+
color: rgba(255, 255, 255, 0.9);
|
183 |
+
}
|
184 |
+
|
185 |
+
.message.bot .message-content code {
|
186 |
+
background: rgba(0, 0, 0, 0.3);
|
187 |
+
padding: 2px 4px;
|
188 |
+
border-radius: 3px;
|
189 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
190 |
+
font-size: 0.9em;
|
191 |
+
}
|
192 |
+
|
193 |
+
.message.bot .message-content blockquote {
|
194 |
+
border-left: 3px solid rgba(255, 255, 255, 0.3);
|
195 |
+
margin: 8px 0;
|
196 |
+
padding-left: 12px;
|
197 |
+
font-style: italic;
|
198 |
+
color: rgba(255, 255, 255, 0.8);
|
199 |
+
}
|
200 |
+
|
201 |
+
.input-container {
|
202 |
+
display: flex;
|
203 |
+
gap: 10px;
|
204 |
+
padding: 15px;
|
205 |
+
background: rgba(255, 255, 255, 0.05);
|
206 |
+
border-radius: 25px;
|
207 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
208 |
+
}
|
209 |
+
|
210 |
+
.input-container input {
|
211 |
+
flex: 1;
|
212 |
+
background: transparent;
|
213 |
+
border: none;
|
214 |
+
color: white;
|
215 |
+
font-size: 16px;
|
216 |
+
outline: none;
|
217 |
+
padding: 10px 15px;
|
218 |
+
}
|
219 |
+
|
220 |
+
.input-container input::placeholder {
|
221 |
+
color: rgba(255, 255, 255, 0.6);
|
222 |
+
}
|
223 |
+
|
224 |
+
.input-container button {
|
225 |
+
background: linear-gradient(135deg, #007AFF, #0051D5);
|
226 |
+
border: none;
|
227 |
+
color: white;
|
228 |
+
padding: 10px 20px;
|
229 |
+
border-radius: 20px;
|
230 |
+
cursor: pointer;
|
231 |
+
font-weight: 600;
|
232 |
+
transition: all 0.3s ease;
|
233 |
+
}
|
234 |
+
|
235 |
+
.input-container button:hover {
|
236 |
+
transform: scale(1.05);
|
237 |
+
box-shadow: 0 5px 15px rgba(0, 122, 255, 0.4);
|
238 |
+
}
|
239 |
+
|
240 |
+
.input-container button:disabled {
|
241 |
+
opacity: 0.6;
|
242 |
+
cursor: not-allowed;
|
243 |
+
transform: none;
|
244 |
+
}
|
245 |
+
|
246 |
+
.loading {
|
247 |
+
display: none;
|
248 |
+
text-align: center;
|
249 |
+
color: rgba(255, 255, 255, 0.7);
|
250 |
+
font-style: italic;
|
251 |
+
margin: 10px 0;
|
252 |
+
}
|
253 |
+
|
254 |
+
.loading.show {
|
255 |
+
display: block;
|
256 |
+
}
|
257 |
+
|
258 |
+
/* Debug Terminal (Right) */
|
259 |
+
.debug-terminal {
|
260 |
+
flex: 1;
|
261 |
+
background: #1a1a1a;
|
262 |
+
display: flex;
|
263 |
+
flex-direction: column;
|
264 |
+
border-radius: 0 20px 20px 0;
|
265 |
+
}
|
266 |
+
|
267 |
+
.terminal-header {
|
268 |
+
background: #2d2d2d;
|
269 |
+
padding: 15px 20px;
|
270 |
+
border-bottom: 1px solid #404040;
|
271 |
+
display: flex;
|
272 |
+
align-items: center;
|
273 |
+
gap: 10px;
|
274 |
+
}
|
275 |
+
|
276 |
+
.terminal-dots {
|
277 |
+
display: flex;
|
278 |
+
gap: 6px;
|
279 |
+
}
|
280 |
+
|
281 |
+
.dot {
|
282 |
+
width: 12px;
|
283 |
+
height: 12px;
|
284 |
+
border-radius: 50%;
|
285 |
+
}
|
286 |
+
|
287 |
+
.dot.red { background: #ff5f56; }
|
288 |
+
.dot.yellow { background: #ffbd2e; }
|
289 |
+
.dot.green { background: #27ca3f; }
|
290 |
+
|
291 |
+
.terminal-title {
|
292 |
+
color: #ffffff;
|
293 |
+
font-weight: 600;
|
294 |
+
margin-left: 10px;
|
295 |
+
}
|
296 |
+
|
297 |
+
.terminal-content {
|
298 |
+
flex: 1;
|
299 |
+
overflow-y: auto;
|
300 |
+
padding: 20px;
|
301 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
302 |
+
font-size: 13px;
|
303 |
+
line-height: 1.5;
|
304 |
+
}
|
305 |
+
|
306 |
+
.terminal-content::-webkit-scrollbar {
|
307 |
+
width: 8px;
|
308 |
+
}
|
309 |
+
|
310 |
+
.terminal-content::-webkit-scrollbar-track {
|
311 |
+
background: #2d2d2d;
|
312 |
+
}
|
313 |
+
|
314 |
+
.terminal-content::-webkit-scrollbar-thumb {
|
315 |
+
background: #555;
|
316 |
+
border-radius: 4px;
|
317 |
+
}
|
318 |
+
|
319 |
+
.log-item {
|
320 |
+
margin-bottom: 8px;
|
321 |
+
padding: 8px 12px;
|
322 |
+
border-radius: 6px;
|
323 |
+
border-left: 3px solid #666;
|
324 |
+
font-size: 12px;
|
325 |
+
animation: logSlide 0.3s ease-out;
|
326 |
+
}
|
327 |
+
|
328 |
+
@keyframes logSlide {
|
329 |
+
from {
|
330 |
+
opacity: 0;
|
331 |
+
transform: translateX(-20px);
|
332 |
+
}
|
333 |
+
to {
|
334 |
+
opacity: 1;
|
335 |
+
transform: translateX(0);
|
336 |
+
}
|
337 |
+
}
|
338 |
+
|
339 |
+
.log-content {
|
340 |
+
display: flex;
|
341 |
+
align-items: flex-start;
|
342 |
+
gap: 8px;
|
343 |
+
}
|
344 |
+
|
345 |
+
.log-timestamp {
|
346 |
+
flex-shrink: 0;
|
347 |
+
color: #888;
|
348 |
+
font-size: 11px;
|
349 |
+
min-width: 60px;
|
350 |
+
}
|
351 |
+
|
352 |
+
.log-message {
|
353 |
+
flex: 1;
|
354 |
+
word-wrap: break-word;
|
355 |
+
}
|
356 |
+
|
357 |
+
/* Status indicator */
|
358 |
+
.status-indicator {
|
359 |
+
position: fixed;
|
360 |
+
top: 20px;
|
361 |
+
right: 20px;
|
362 |
+
padding: 8px 16px;
|
363 |
+
background: rgba(0, 0, 0, 0.8);
|
364 |
+
color: white;
|
365 |
+
border-radius: 20px;
|
366 |
+
font-size: 12px;
|
367 |
+
z-index: 1000;
|
368 |
+
transition: all 0.3s ease;
|
369 |
+
}
|
370 |
+
|
371 |
+
.status-indicator.connected {
|
372 |
+
background: rgba(39, 174, 96, 0.9);
|
373 |
+
}
|
374 |
+
|
375 |
+
.status-indicator.disconnected {
|
376 |
+
background: rgba(231, 76, 60, 0.9);
|
377 |
+
}
|
378 |
+
|
379 |
+
/* Responsive */
|
380 |
+
@media (max-width: 768px) {
|
381 |
+
.container {
|
382 |
+
flex-direction: column;
|
383 |
+
border-radius: 0;
|
384 |
+
}
|
385 |
+
|
386 |
+
.mobile-interface {
|
387 |
+
max-width: none;
|
388 |
+
border-radius: 0;
|
389 |
+
}
|
390 |
+
|
391 |
+
.debug-terminal {
|
392 |
+
border-radius: 0;
|
393 |
+
height: 40vh;
|
394 |
+
}
|
395 |
+
}
|
396 |
+
|
397 |
+
/* Smooth transitions */
|
398 |
+
* {
|
399 |
+
transition: background-color 0.3s ease;
|
400 |
+
}
|
401 |
+
</style>
|
402 |
+
</head>
|
403 |
+
<body>
|
404 |
+
<div class="status-indicator" id="status">Connecting...</div>
|
405 |
+
|
406 |
+
<div class="container">
|
407 |
+
<!-- Mobile Interface -->
|
408 |
+
<div class="mobile-interface">
|
409 |
+
<div class="mobile-header">
|
410 |
+
<h1>PuppyCompanion</h1>
|
411 |
+
<p>Your AI assistant for puppy care</p>
|
412 |
+
</div>
|
413 |
+
|
414 |
+
<div class="chat-container">
|
415 |
+
<div class="messages" id="messages">
|
416 |
+
<div class="message bot">
|
417 |
+
<div class="message-content">
|
418 |
+
Hello! I'm your AI assistant specialized in puppy care and training. Ask me anything about your furry friend!
|
419 |
+
</div>
|
420 |
+
</div>
|
421 |
+
</div>
|
422 |
+
|
423 |
+
<div class="loading" id="loading">
|
424 |
+
<div>Thinking...</div>
|
425 |
+
</div>
|
426 |
+
|
427 |
+
<div class="input-container">
|
428 |
+
<input type="text" id="messageInput" placeholder="Ask about puppy training, care, behavior..." maxlength="500">
|
429 |
+
<button id="sendButton" onclick="sendMessage()">Send</button>
|
430 |
+
</div>
|
431 |
+
</div>
|
432 |
+
</div>
|
433 |
+
|
434 |
+
<!-- Debug Terminal -->
|
435 |
+
<div class="debug-terminal">
|
436 |
+
<div class="terminal-header">
|
437 |
+
<div class="terminal-dots">
|
438 |
+
<div class="dot red"></div>
|
439 |
+
<div class="dot yellow"></div>
|
440 |
+
<div class="dot green"></div>
|
441 |
+
</div>
|
442 |
+
<div class="terminal-title">Debug Console</div>
|
443 |
+
</div>
|
444 |
+
<div class="terminal-content" id="logContainer">
|
445 |
+
<div class="log-item" style="border-left-color: #10b981; background: rgba(16, 185, 129, 0.1);">
|
446 |
+
<div class="log-content">
|
447 |
+
<span class="log-timestamp">00:00:00</span>
|
448 |
+
<span class="log-message" style="color: #10b981;">System ready - Connecting to backend...</span>
|
449 |
+
</div>
|
450 |
+
</div>
|
451 |
+
</div>
|
452 |
+
</div>
|
453 |
+
</div>
|
454 |
+
|
455 |
+
<script>
|
456 |
+
let ws = null;
|
457 |
+
let isConnected = false;
|
458 |
+
|
459 |
+
function connectWebSocket() {
|
460 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
461 |
+
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
462 |
+
|
463 |
+
ws = new WebSocket(wsUrl);
|
464 |
+
|
465 |
+
ws.onopen = function(event) {
|
466 |
+
isConnected = true;
|
467 |
+
updateStatus('Connected', 'connected');
|
468 |
+
addLogMessage(getCurrentTime(), 'WebSocket connected to debug console', 'success');
|
469 |
+
};
|
470 |
+
|
471 |
+
ws.onmessage = function(event) {
|
472 |
+
const data = JSON.parse(event.data);
|
473 |
+
addLogMessage(data.timestamp, data.message, data.type);
|
474 |
+
};
|
475 |
+
|
476 |
+
ws.onclose = function(event) {
|
477 |
+
isConnected = false;
|
478 |
+
updateStatus('Disconnected', 'disconnected');
|
479 |
+
addLogMessage(getCurrentTime(), 'WebSocket connection closed', 'error');
|
480 |
+
|
481 |
+
// Reconnect after 3 seconds
|
482 |
+
setTimeout(connectWebSocket, 3000);
|
483 |
+
};
|
484 |
+
|
485 |
+
ws.onerror = function(error) {
|
486 |
+
addLogMessage(getCurrentTime(), 'WebSocket error occurred', 'error');
|
487 |
+
};
|
488 |
+
}
|
489 |
+
|
490 |
+
function updateStatus(text, className) {
|
491 |
+
const status = document.getElementById('status');
|
492 |
+
status.textContent = text;
|
493 |
+
status.className = `status-indicator ${className}`;
|
494 |
+
}
|
495 |
+
|
496 |
+
function getCurrentTime() {
|
497 |
+
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
498 |
+
}
|
499 |
+
|
500 |
+
function addLogMessage(timestamp, message, type) {
|
501 |
+
const logContainer = document.getElementById('logContainer');
|
502 |
+
const logItem = document.createElement('div');
|
503 |
+
logItem.className = 'log-item';
|
504 |
+
|
505 |
+
let icon, color, bgColor;
|
506 |
+
switch(type) {
|
507 |
+
case 'success':
|
508 |
+
icon = '✅';
|
509 |
+
color = '#10b981';
|
510 |
+
bgColor = 'rgba(16, 185, 129, 0.1)';
|
511 |
+
break;
|
512 |
+
case 'error':
|
513 |
+
icon = '❌';
|
514 |
+
color = '#ef4444';
|
515 |
+
bgColor = 'rgba(239, 68, 68, 0.1)';
|
516 |
+
break;
|
517 |
+
case 'warning':
|
518 |
+
icon = '⚠️';
|
519 |
+
color = '#f59e0b';
|
520 |
+
bgColor = 'rgba(245, 158, 11, 0.1)';
|
521 |
+
break;
|
522 |
+
case 'tool':
|
523 |
+
icon = '🔧';
|
524 |
+
color = '#3b82f6';
|
525 |
+
bgColor = 'rgba(59, 130, 246, 0.1)';
|
526 |
+
break;
|
527 |
+
case 'source':
|
528 |
+
icon = '📄';
|
529 |
+
color = '#8b5cf6';
|
530 |
+
bgColor = 'rgba(139, 92, 246, 0.1)';
|
531 |
+
break;
|
532 |
+
case 'chunk':
|
533 |
+
icon = '📝';
|
534 |
+
color = '#06b6d4';
|
535 |
+
bgColor = 'rgba(6, 182, 212, 0.1)';
|
536 |
+
break;
|
537 |
+
default:
|
538 |
+
icon = 'ℹ️';
|
539 |
+
color = '#6b7280';
|
540 |
+
bgColor = 'rgba(107, 114, 128, 0.1)';
|
541 |
+
}
|
542 |
+
|
543 |
+
logItem.style.borderLeft = `3px solid ${color}`;
|
544 |
+
logItem.style.backgroundColor = bgColor;
|
545 |
+
|
546 |
+
logItem.innerHTML = `
|
547 |
+
<div class="log-content">
|
548 |
+
<span class="log-icon">${icon}</span>
|
549 |
+
<span class="log-timestamp">${timestamp}</span>
|
550 |
+
<span class="log-message" style="color: ${color};">${message}</span>
|
551 |
+
</div>
|
552 |
+
`;
|
553 |
+
|
554 |
+
logContainer.appendChild(logItem);
|
555 |
+
logContainer.scrollTop = logContainer.scrollHeight;
|
556 |
+
}
|
557 |
+
|
558 |
+
function addMessage(content, isUser = false) {
|
559 |
+
const messages = document.getElementById('messages');
|
560 |
+
const messageDiv = document.createElement('div');
|
561 |
+
messageDiv.className = `message ${isUser ? 'user' : 'bot'}`;
|
562 |
+
|
563 |
+
const contentDiv = document.createElement('div');
|
564 |
+
contentDiv.className = 'message-content';
|
565 |
+
|
566 |
+
if (isUser) {
|
567 |
+
// User messages as plain text
|
568 |
+
contentDiv.textContent = content;
|
569 |
+
} else {
|
570 |
+
// Bot messages rendered as Markdown
|
571 |
+
try {
|
572 |
+
contentDiv.innerHTML = marked.parse(content);
|
573 |
+
} catch (error) {
|
574 |
+
// Fallback to plain text if Markdown parsing fails
|
575 |
+
contentDiv.textContent = content;
|
576 |
+
}
|
577 |
+
}
|
578 |
+
|
579 |
+
messageDiv.appendChild(contentDiv);
|
580 |
+
messages.appendChild(messageDiv);
|
581 |
+
messages.scrollTop = messages.scrollHeight;
|
582 |
+
}
|
583 |
+
|
584 |
+
async function sendMessage() {
|
585 |
+
const input = document.getElementById('messageInput');
|
586 |
+
const sendButton = document.getElementById('sendButton');
|
587 |
+
const loading = document.getElementById('loading');
|
588 |
+
|
589 |
+
const question = input.value.trim();
|
590 |
+
if (!question) return;
|
591 |
+
|
592 |
+
// Add user message
|
593 |
+
addMessage(question, true);
|
594 |
+
|
595 |
+
// Clear input and disable form
|
596 |
+
input.value = '';
|
597 |
+
sendButton.disabled = true;
|
598 |
+
loading.classList.add('show');
|
599 |
+
|
600 |
+
try {
|
601 |
+
const response = await fetch('/chat', {
|
602 |
+
method: 'POST',
|
603 |
+
headers: {
|
604 |
+
'Content-Type': 'application/json',
|
605 |
+
},
|
606 |
+
body: JSON.stringify({ question: question })
|
607 |
+
});
|
608 |
+
|
609 |
+
if (!response.ok) {
|
610 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
611 |
+
}
|
612 |
+
|
613 |
+
const data = await response.json();
|
614 |
+
|
615 |
+
// Add bot response
|
616 |
+
addMessage(data.response);
|
617 |
+
|
618 |
+
} catch (error) {
|
619 |
+
console.error('Error:', error);
|
620 |
+
addMessage('Sorry, there was an error processing your question. Please try again.');
|
621 |
+
} finally {
|
622 |
+
sendButton.disabled = false;
|
623 |
+
loading.classList.remove('show');
|
624 |
+
input.focus();
|
625 |
+
}
|
626 |
+
}
|
627 |
+
|
628 |
+
// Event listeners
|
629 |
+
document.getElementById('messageInput').addEventListener('keypress', function(e) {
|
630 |
+
if (e.key === 'Enter') {
|
631 |
+
sendMessage();
|
632 |
+
}
|
633 |
+
});
|
634 |
+
|
635 |
+
// Initialize
|
636 |
+
document.addEventListener('DOMContentLoaded', function() {
|
637 |
+
// Check if marked.js loaded and configure it
|
638 |
+
if (typeof marked !== 'undefined') {
|
639 |
+
marked.setOptions({
|
640 |
+
breaks: true, // Convert \n to <br>
|
641 |
+
gfm: true, // GitHub Flavored Markdown
|
642 |
+
sanitize: false, // Allow HTML (we trust our backend)
|
643 |
+
smartLists: true, // Better list handling
|
644 |
+
smartypants: true // Smart quotes and dashes
|
645 |
+
});
|
646 |
+
} else {
|
647 |
+
console.warn('Marked.js not loaded, falling back to plain text');
|
648 |
+
}
|
649 |
+
|
650 |
+
connectWebSocket();
|
651 |
+
document.getElementById('messageInput').focus();
|
652 |
+
});
|
653 |
+
</script>
|
654 |
+
</body>
|
655 |
+
</html>
|
static/index.html
ADDED
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>PuppyCompanion - AI Assistant</title>
|
7 |
+
<!-- Marked.js for Markdown rendering -->
|
8 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script>
|
9 |
+
<style>
|
10 |
+
* {
|
11 |
+
margin: 0;
|
12 |
+
padding: 0;
|
13 |
+
box-sizing: border-box;
|
14 |
+
}
|
15 |
+
|
16 |
+
body {
|
17 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
18 |
+
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%);
|
19 |
+
height: 100vh;
|
20 |
+
overflow: hidden;
|
21 |
+
}
|
22 |
+
|
23 |
+
.container {
|
24 |
+
display: flex;
|
25 |
+
height: 100vh;
|
26 |
+
max-width: 1400px;
|
27 |
+
margin: 0 auto;
|
28 |
+
background: rgba(255, 255, 255, 0.1);
|
29 |
+
backdrop-filter: blur(10px);
|
30 |
+
border-radius: 20px;
|
31 |
+
overflow: hidden;
|
32 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
33 |
+
gap: 15px;
|
34 |
+
padding: 15px;
|
35 |
+
}
|
36 |
+
|
37 |
+
/* Mobile Interface (Left) */
|
38 |
+
.mobile-interface {
|
39 |
+
flex: 1;
|
40 |
+
max-width: 400px;
|
41 |
+
background: linear-gradient(to bottom, #1e1e1e, #2d2d2d);
|
42 |
+
display: flex;
|
43 |
+
flex-direction: column;
|
44 |
+
position: relative;
|
45 |
+
border-radius: 20px;
|
46 |
+
overflow: hidden;
|
47 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
48 |
+
}
|
49 |
+
|
50 |
+
.mobile-header {
|
51 |
+
background: linear-gradient(135deg, #FF8A65 0%, #FFAB91 50%, #FFCCBC 100%);
|
52 |
+
padding: 20px;
|
53 |
+
text-align: center;
|
54 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
55 |
+
}
|
56 |
+
|
57 |
+
.mobile-header h1 {
|
58 |
+
color: #ffffff;
|
59 |
+
font-size: 1.5rem;
|
60 |
+
font-weight: 600;
|
61 |
+
margin-bottom: 5px;
|
62 |
+
}
|
63 |
+
|
64 |
+
.mobile-header p {
|
65 |
+
color: #a0a0a0;
|
66 |
+
font-size: 0.9rem;
|
67 |
+
}
|
68 |
+
|
69 |
+
.chat-container {
|
70 |
+
flex: 1;
|
71 |
+
display: flex;
|
72 |
+
flex-direction: column;
|
73 |
+
padding: 20px;
|
74 |
+
overflow: hidden;
|
75 |
+
}
|
76 |
+
|
77 |
+
.messages {
|
78 |
+
flex: 1;
|
79 |
+
overflow-y: auto;
|
80 |
+
margin-bottom: 20px;
|
81 |
+
padding-right: 5px;
|
82 |
+
}
|
83 |
+
|
84 |
+
.messages::-webkit-scrollbar {
|
85 |
+
width: 4px;
|
86 |
+
}
|
87 |
+
|
88 |
+
.messages::-webkit-scrollbar-track {
|
89 |
+
background: rgba(255, 255, 255, 0.1);
|
90 |
+
border-radius: 2px;
|
91 |
+
}
|
92 |
+
|
93 |
+
.messages::-webkit-scrollbar-thumb {
|
94 |
+
background: rgba(255, 255, 255, 0.3);
|
95 |
+
border-radius: 2px;
|
96 |
+
}
|
97 |
+
|
98 |
+
.message {
|
99 |
+
margin-bottom: 15px;
|
100 |
+
animation: slideIn 0.3s ease-out;
|
101 |
+
}
|
102 |
+
|
103 |
+
@keyframes slideIn {
|
104 |
+
from {
|
105 |
+
opacity: 0;
|
106 |
+
transform: translateY(20px);
|
107 |
+
}
|
108 |
+
to {
|
109 |
+
opacity: 1;
|
110 |
+
transform: translateY(0);
|
111 |
+
}
|
112 |
+
}
|
113 |
+
|
114 |
+
.message.user {
|
115 |
+
text-align: right;
|
116 |
+
}
|
117 |
+
|
118 |
+
.message.bot {
|
119 |
+
text-align: left;
|
120 |
+
}
|
121 |
+
|
122 |
+
.message-content {
|
123 |
+
display: inline-block;
|
124 |
+
max-width: 85%;
|
125 |
+
padding: 12px 16px;
|
126 |
+
border-radius: 18px;
|
127 |
+
word-wrap: break-word;
|
128 |
+
line-height: 1.4;
|
129 |
+
}
|
130 |
+
|
131 |
+
.message.user .message-content {
|
132 |
+
background: linear-gradient(135deg, #FF7043, #FF8A65);
|
133 |
+
color: white;
|
134 |
+
}
|
135 |
+
|
136 |
+
.message.bot .message-content {
|
137 |
+
background: rgba(255, 255, 255, 0.1);
|
138 |
+
color: #ffffff;
|
139 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
140 |
+
}
|
141 |
+
|
142 |
+
/* Markdown styling for bot messages */
|
143 |
+
.message.bot .message-content h1,
|
144 |
+
.message.bot .message-content h2,
|
145 |
+
.message.bot .message-content h3 {
|
146 |
+
color: #ffffff;
|
147 |
+
margin: 8px 0 4px 0;
|
148 |
+
font-weight: 600;
|
149 |
+
}
|
150 |
+
|
151 |
+
.message.bot .message-content h1 {
|
152 |
+
font-size: 1.2em;
|
153 |
+
}
|
154 |
+
|
155 |
+
.message.bot .message-content h2 {
|
156 |
+
font-size: 1.1em;
|
157 |
+
}
|
158 |
+
|
159 |
+
.message.bot .message-content h3 {
|
160 |
+
font-size: 1em;
|
161 |
+
}
|
162 |
+
|
163 |
+
.message.bot .message-content ul,
|
164 |
+
.message.bot .message-content ol {
|
165 |
+
margin: 8px 0;
|
166 |
+
padding-left: 20px;
|
167 |
+
}
|
168 |
+
|
169 |
+
.message.bot .message-content li {
|
170 |
+
margin: 2px 0;
|
171 |
+
}
|
172 |
+
|
173 |
+
.message.bot .message-content p {
|
174 |
+
margin: 6px 0;
|
175 |
+
line-height: 1.4;
|
176 |
+
}
|
177 |
+
|
178 |
+
.message.bot .message-content strong {
|
179 |
+
color: #ffffff;
|
180 |
+
font-weight: 600;
|
181 |
+
}
|
182 |
+
|
183 |
+
.message.bot .message-content em {
|
184 |
+
font-style: italic;
|
185 |
+
color: rgba(255, 255, 255, 0.9);
|
186 |
+
}
|
187 |
+
|
188 |
+
.message.bot .message-content code {
|
189 |
+
background: rgba(0, 0, 0, 0.3);
|
190 |
+
padding: 2px 4px;
|
191 |
+
border-radius: 3px;
|
192 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
193 |
+
font-size: 0.9em;
|
194 |
+
}
|
195 |
+
|
196 |
+
.message.bot .message-content blockquote {
|
197 |
+
border-left: 3px solid rgba(255, 255, 255, 0.3);
|
198 |
+
margin: 8px 0;
|
199 |
+
padding-left: 12px;
|
200 |
+
font-style: italic;
|
201 |
+
color: rgba(255, 255, 255, 0.8);
|
202 |
+
}
|
203 |
+
|
204 |
+
.input-container {
|
205 |
+
display: flex;
|
206 |
+
gap: 10px;
|
207 |
+
padding: 15px;
|
208 |
+
background: rgba(255, 255, 255, 0.05);
|
209 |
+
border-radius: 25px;
|
210 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
211 |
+
}
|
212 |
+
|
213 |
+
.input-container input {
|
214 |
+
flex: 1;
|
215 |
+
background: transparent;
|
216 |
+
border: none;
|
217 |
+
color: white;
|
218 |
+
font-size: 16px;
|
219 |
+
outline: none;
|
220 |
+
padding: 10px 15px;
|
221 |
+
}
|
222 |
+
|
223 |
+
.input-container input::placeholder {
|
224 |
+
color: rgba(255, 255, 255, 0.6);
|
225 |
+
}
|
226 |
+
|
227 |
+
.input-container button {
|
228 |
+
background: linear-gradient(135deg, #FF7043, #FF8A65);
|
229 |
+
border: none;
|
230 |
+
color: white;
|
231 |
+
padding: 10px 20px;
|
232 |
+
border-radius: 20px;
|
233 |
+
cursor: pointer;
|
234 |
+
font-weight: 600;
|
235 |
+
transition: all 0.3s ease;
|
236 |
+
}
|
237 |
+
|
238 |
+
.input-container button:hover {
|
239 |
+
transform: scale(1.05);
|
240 |
+
box-shadow: 0 5px 15px rgba(255, 112, 67, 0.4);
|
241 |
+
}
|
242 |
+
|
243 |
+
.input-container button:disabled {
|
244 |
+
opacity: 0.6;
|
245 |
+
cursor: not-allowed;
|
246 |
+
transform: none;
|
247 |
+
}
|
248 |
+
|
249 |
+
.loading {
|
250 |
+
display: none;
|
251 |
+
text-align: center;
|
252 |
+
color: rgba(255, 255, 255, 0.7);
|
253 |
+
font-style: italic;
|
254 |
+
margin: 10px 0;
|
255 |
+
}
|
256 |
+
|
257 |
+
.loading.show {
|
258 |
+
display: block;
|
259 |
+
}
|
260 |
+
|
261 |
+
/* Debug Terminal (Right) */
|
262 |
+
.debug-terminal {
|
263 |
+
flex: 1;
|
264 |
+
background: #1a1a1a;
|
265 |
+
display: flex;
|
266 |
+
flex-direction: column;
|
267 |
+
border-radius: 20px;
|
268 |
+
overflow: hidden;
|
269 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
270 |
+
}
|
271 |
+
|
272 |
+
.terminal-header {
|
273 |
+
background: #2d2d2d;
|
274 |
+
padding: 15px 20px;
|
275 |
+
border-bottom: 1px solid #404040;
|
276 |
+
display: flex;
|
277 |
+
align-items: center;
|
278 |
+
}
|
279 |
+
|
280 |
+
.terminal-title {
|
281 |
+
color: #ffffff;
|
282 |
+
font-weight: 600;
|
283 |
+
}
|
284 |
+
|
285 |
+
.terminal-content {
|
286 |
+
flex: 1;
|
287 |
+
overflow-y: auto;
|
288 |
+
padding: 20px;
|
289 |
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
290 |
+
font-size: 13px;
|
291 |
+
line-height: 1.5;
|
292 |
+
}
|
293 |
+
|
294 |
+
.terminal-content::-webkit-scrollbar {
|
295 |
+
width: 8px;
|
296 |
+
}
|
297 |
+
|
298 |
+
.terminal-content::-webkit-scrollbar-track {
|
299 |
+
background: #2d2d2d;
|
300 |
+
}
|
301 |
+
|
302 |
+
.terminal-content::-webkit-scrollbar-thumb {
|
303 |
+
background: #555;
|
304 |
+
border-radius: 4px;
|
305 |
+
}
|
306 |
+
|
307 |
+
.log-item {
|
308 |
+
margin-bottom: 8px;
|
309 |
+
padding: 8px 12px;
|
310 |
+
border-radius: 6px;
|
311 |
+
border-left: 3px solid #666;
|
312 |
+
font-size: 12px;
|
313 |
+
animation: logSlide 0.3s ease-out;
|
314 |
+
}
|
315 |
+
|
316 |
+
@keyframes logSlide {
|
317 |
+
from {
|
318 |
+
opacity: 0;
|
319 |
+
transform: translateX(-20px);
|
320 |
+
}
|
321 |
+
to {
|
322 |
+
opacity: 1;
|
323 |
+
transform: translateX(0);
|
324 |
+
}
|
325 |
+
}
|
326 |
+
|
327 |
+
.log-content {
|
328 |
+
display: flex;
|
329 |
+
align-items: flex-start;
|
330 |
+
gap: 8px;
|
331 |
+
}
|
332 |
+
|
333 |
+
.log-timestamp {
|
334 |
+
flex-shrink: 0;
|
335 |
+
color: #888;
|
336 |
+
font-size: 11px;
|
337 |
+
min-width: 60px;
|
338 |
+
}
|
339 |
+
|
340 |
+
.log-message {
|
341 |
+
flex: 1;
|
342 |
+
word-wrap: break-word;
|
343 |
+
}
|
344 |
+
|
345 |
+
/* Status indicator */
|
346 |
+
.status-indicator {
|
347 |
+
position: fixed;
|
348 |
+
top: 20px;
|
349 |
+
right: 20px;
|
350 |
+
padding: 8px 16px;
|
351 |
+
background: rgba(0, 0, 0, 0.8);
|
352 |
+
color: white;
|
353 |
+
border-radius: 20px;
|
354 |
+
font-size: 12px;
|
355 |
+
z-index: 1000;
|
356 |
+
transition: all 0.3s ease;
|
357 |
+
}
|
358 |
+
|
359 |
+
.status-indicator.connected {
|
360 |
+
background: rgba(39, 174, 96, 0.9);
|
361 |
+
}
|
362 |
+
|
363 |
+
.status-indicator.disconnected {
|
364 |
+
background: rgba(231, 76, 60, 0.9);
|
365 |
+
}
|
366 |
+
|
367 |
+
/* Responsive */
|
368 |
+
@media (max-width: 768px) {
|
369 |
+
.container {
|
370 |
+
flex-direction: column;
|
371 |
+
border-radius: 0;
|
372 |
+
}
|
373 |
+
|
374 |
+
.mobile-interface {
|
375 |
+
max-width: none;
|
376 |
+
border-radius: 0;
|
377 |
+
}
|
378 |
+
|
379 |
+
.debug-terminal {
|
380 |
+
border-radius: 0;
|
381 |
+
height: 40vh;
|
382 |
+
}
|
383 |
+
}
|
384 |
+
|
385 |
+
/* Smooth transitions */
|
386 |
+
* {
|
387 |
+
transition: background-color 0.3s ease;
|
388 |
+
}
|
389 |
+
</style>
|
390 |
+
</head>
|
391 |
+
<body>
|
392 |
+
<div class="status-indicator" id="status">Connecting...</div>
|
393 |
+
|
394 |
+
<div class="container">
|
395 |
+
<!-- Mobile Interface -->
|
396 |
+
<div class="mobile-interface">
|
397 |
+
<div class="mobile-header">
|
398 |
+
<h1>🐶 PuppyCompanion</h1>
|
399 |
+
<p>Your AI assistant for puppy care</p>
|
400 |
+
</div>
|
401 |
+
|
402 |
+
<div class="chat-container">
|
403 |
+
<div class="messages" id="messages">
|
404 |
+
<div class="message bot">
|
405 |
+
<div class="message-content">
|
406 |
+
Hello! I'm your AI assistant specialized in puppy care and training. Ask me anything about your furry friend! 🐾
|
407 |
+
</div>
|
408 |
+
</div>
|
409 |
+
</div>
|
410 |
+
|
411 |
+
<div class="loading" id="loading">
|
412 |
+
<div>Thinking...</div>
|
413 |
+
</div>
|
414 |
+
|
415 |
+
<div class="input-container">
|
416 |
+
<input type="text" id="messageInput" placeholder="Ask me about your puppy" maxlength="500">
|
417 |
+
<button id="sendButton" onclick="sendMessage()">Send</button>
|
418 |
+
</div>
|
419 |
+
</div>
|
420 |
+
</div>
|
421 |
+
|
422 |
+
<!-- Debug Terminal -->
|
423 |
+
<div class="debug-terminal">
|
424 |
+
<div class="terminal-header">
|
425 |
+
<div class="terminal-title">Debug Console</div>
|
426 |
+
</div>
|
427 |
+
<div class="terminal-content" id="logContainer">
|
428 |
+
<div class="log-item" style="border-left-color: #10b981; background: rgba(16, 185, 129, 0.1);">
|
429 |
+
<div class="log-content">
|
430 |
+
<span class="log-timestamp">00:00:00</span>
|
431 |
+
<span class="log-message" style="color: #10b981;">System ready - Connecting to backend...</span>
|
432 |
+
</div>
|
433 |
+
</div>
|
434 |
+
</div>
|
435 |
+
</div>
|
436 |
+
</div>
|
437 |
+
|
438 |
+
<script>
|
439 |
+
let ws = null;
|
440 |
+
let isConnected = false;
|
441 |
+
|
442 |
+
function connectWebSocket() {
|
443 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
444 |
+
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
445 |
+
|
446 |
+
ws = new WebSocket(wsUrl);
|
447 |
+
|
448 |
+
ws.onopen = function(event) {
|
449 |
+
isConnected = true;
|
450 |
+
updateStatus('Connected', 'connected');
|
451 |
+
addLogMessage(getCurrentTime(), 'WebSocket connected to debug console', 'success');
|
452 |
+
};
|
453 |
+
|
454 |
+
ws.onmessage = function(event) {
|
455 |
+
const data = JSON.parse(event.data);
|
456 |
+
addLogMessage(data.timestamp, data.message, data.type);
|
457 |
+
};
|
458 |
+
|
459 |
+
ws.onclose = function(event) {
|
460 |
+
isConnected = false;
|
461 |
+
updateStatus('Disconnected', 'disconnected');
|
462 |
+
addLogMessage(getCurrentTime(), 'WebSocket connection closed', 'error');
|
463 |
+
|
464 |
+
// Reconnect after 3 seconds
|
465 |
+
setTimeout(connectWebSocket, 3000);
|
466 |
+
};
|
467 |
+
|
468 |
+
ws.onerror = function(error) {
|
469 |
+
addLogMessage(getCurrentTime(), 'WebSocket error occurred', 'error');
|
470 |
+
};
|
471 |
+
}
|
472 |
+
|
473 |
+
function updateStatus(text, className) {
|
474 |
+
const status = document.getElementById('status');
|
475 |
+
status.textContent = text;
|
476 |
+
status.className = `status-indicator ${className}`;
|
477 |
+
}
|
478 |
+
|
479 |
+
function getCurrentTime() {
|
480 |
+
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
481 |
+
}
|
482 |
+
|
483 |
+
function addLogMessage(timestamp, message, type) {
|
484 |
+
const logContainer = document.getElementById('logContainer');
|
485 |
+
const logItem = document.createElement('div');
|
486 |
+
logItem.className = 'log-item';
|
487 |
+
|
488 |
+
let icon, color, bgColor;
|
489 |
+
switch(type) {
|
490 |
+
case 'success':
|
491 |
+
icon = '✅';
|
492 |
+
color = '#10b981';
|
493 |
+
bgColor = 'rgba(16, 185, 129, 0.1)';
|
494 |
+
break;
|
495 |
+
case 'error':
|
496 |
+
icon = '❌';
|
497 |
+
color = '#ef4444';
|
498 |
+
bgColor = 'rgba(239, 68, 68, 0.1)';
|
499 |
+
break;
|
500 |
+
case 'warning':
|
501 |
+
icon = '⚠️';
|
502 |
+
color = '#f59e0b';
|
503 |
+
bgColor = 'rgba(245, 158, 11, 0.1)';
|
504 |
+
break;
|
505 |
+
case 'tool':
|
506 |
+
icon = '🔧';
|
507 |
+
color = '#3b82f6';
|
508 |
+
bgColor = 'rgba(59, 130, 246, 0.1)';
|
509 |
+
break;
|
510 |
+
case 'source':
|
511 |
+
icon = '📄';
|
512 |
+
color = '#8b5cf6';
|
513 |
+
bgColor = 'rgba(139, 92, 246, 0.1)';
|
514 |
+
break;
|
515 |
+
case 'chunk':
|
516 |
+
icon = '📝';
|
517 |
+
color = '#06b6d4';
|
518 |
+
bgColor = 'rgba(6, 182, 212, 0.1)';
|
519 |
+
break;
|
520 |
+
default:
|
521 |
+
icon = 'ℹ️';
|
522 |
+
color = '#6b7280';
|
523 |
+
bgColor = 'rgba(107, 114, 128, 0.1)';
|
524 |
+
}
|
525 |
+
|
526 |
+
logItem.style.borderLeft = `3px solid ${color}`;
|
527 |
+
logItem.style.backgroundColor = bgColor;
|
528 |
+
|
529 |
+
logItem.innerHTML = `
|
530 |
+
<div class="log-content">
|
531 |
+
<span class="log-icon">${icon}</span>
|
532 |
+
<span class="log-timestamp">${timestamp}</span>
|
533 |
+
<span class="log-message" style="color: ${color};">${message}</span>
|
534 |
+
</div>
|
535 |
+
`;
|
536 |
+
|
537 |
+
logContainer.appendChild(logItem);
|
538 |
+
logContainer.scrollTop = logContainer.scrollHeight;
|
539 |
+
}
|
540 |
+
|
541 |
+
function addMessage(content, isUser = false) {
|
542 |
+
const messages = document.getElementById('messages');
|
543 |
+
const messageDiv = document.createElement('div');
|
544 |
+
messageDiv.className = `message ${isUser ? 'user' : 'bot'}`;
|
545 |
+
|
546 |
+
const contentDiv = document.createElement('div');
|
547 |
+
contentDiv.className = 'message-content';
|
548 |
+
|
549 |
+
if (isUser) {
|
550 |
+
// User messages as plain text
|
551 |
+
contentDiv.textContent = content;
|
552 |
+
} else {
|
553 |
+
// Bot messages rendered as Markdown
|
554 |
+
try {
|
555 |
+
contentDiv.innerHTML = marked.parse(content);
|
556 |
+
} catch (error) {
|
557 |
+
// Fallback to plain text if Markdown parsing fails
|
558 |
+
contentDiv.textContent = content;
|
559 |
+
}
|
560 |
+
}
|
561 |
+
|
562 |
+
messageDiv.appendChild(contentDiv);
|
563 |
+
messages.appendChild(messageDiv);
|
564 |
+
messages.scrollTop = messages.scrollHeight;
|
565 |
+
}
|
566 |
+
|
567 |
+
async function sendMessage() {
|
568 |
+
const input = document.getElementById('messageInput');
|
569 |
+
const sendButton = document.getElementById('sendButton');
|
570 |
+
const loading = document.getElementById('loading');
|
571 |
+
|
572 |
+
const question = input.value.trim();
|
573 |
+
if (!question) return;
|
574 |
+
|
575 |
+
// Add user message
|
576 |
+
addMessage(question, true);
|
577 |
+
|
578 |
+
// Clear input and disable form
|
579 |
+
input.value = '';
|
580 |
+
sendButton.disabled = true;
|
581 |
+
loading.classList.add('show');
|
582 |
+
|
583 |
+
try {
|
584 |
+
const response = await fetch('/chat', {
|
585 |
+
method: 'POST',
|
586 |
+
headers: {
|
587 |
+
'Content-Type': 'application/json',
|
588 |
+
},
|
589 |
+
body: JSON.stringify({ question: question })
|
590 |
+
});
|
591 |
+
|
592 |
+
if (!response.ok) {
|
593 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
594 |
+
}
|
595 |
+
|
596 |
+
const data = await response.json();
|
597 |
+
|
598 |
+
// Add bot response
|
599 |
+
addMessage(data.response);
|
600 |
+
|
601 |
+
} catch (error) {
|
602 |
+
console.error('Error:', error);
|
603 |
+
addMessage('Sorry, there was an error processing your question. Please try again.');
|
604 |
+
} finally {
|
605 |
+
sendButton.disabled = false;
|
606 |
+
loading.classList.remove('show');
|
607 |
+
input.focus();
|
608 |
+
}
|
609 |
+
}
|
610 |
+
|
611 |
+
// Event listeners
|
612 |
+
document.getElementById('messageInput').addEventListener('keypress', function(e) {
|
613 |
+
if (e.key === 'Enter') {
|
614 |
+
sendMessage();
|
615 |
+
}
|
616 |
+
});
|
617 |
+
|
618 |
+
// Initialize
|
619 |
+
document.addEventListener('DOMContentLoaded', function() {
|
620 |
+
// Check if marked.js loaded and configure it
|
621 |
+
if (typeof marked !== 'undefined') {
|
622 |
+
marked.setOptions({
|
623 |
+
breaks: true, // Convert \n to <br>
|
624 |
+
gfm: true, // GitHub Flavored Markdown
|
625 |
+
sanitize: false, // Allow HTML (we trust our backend)
|
626 |
+
smartLists: true, // Better list handling
|
627 |
+
smartypants: true // Smart quotes and dashes
|
628 |
+
});
|
629 |
+
} else {
|
630 |
+
console.warn('Marked.js not loaded, falling back to plain text');
|
631 |
+
}
|
632 |
+
|
633 |
+
connectWebSocket();
|
634 |
+
document.getElementById('messageInput').focus();
|
635 |
+
});
|
636 |
+
</script>
|
637 |
+
</body>
|
638 |
+
</html>
|