Deploy Script commited on
Commit
b3b7a20
·
1 Parent(s): bfa7e20

Deploy PuppyCompanion FastAPI 2025-06-02 09:57:27

Browse files
.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: Puppycompanion V3
3
- emoji: 🔥
4
- colorFrom: gray
5
- colorTo: green
6
  sdk: docker
7
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
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>