diff --git a/Dockerfile b/Dockerfile index f6c8e1db..ea920aea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ -FROM python:3.12-bullseye +FROM --platform=linux/x86_64 python:3.12.7-slim WORKDIR /usr/src/app -RUN pip install --upgrade pip +RUN apt-get update && apt-get install -y \ + build-essential \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* COPY requirements.txt . - RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/app/admin/chatlogs/__init__.py b/app/admin/chatlogs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/admin/chatlogs/routes.py b/app/admin/chatlogs/routes.py new file mode 100644 index 00000000..a193fd7d --- /dev/null +++ b/app/admin/chatlogs/routes.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter +from typing import Optional +from datetime import datetime +import app.admin.chatlogs.store as store + +router = APIRouter(prefix="/chatlogs", tags=["chatlogs"]) + + +@router.get("/") +async def list_chatlogs( + page: int = 1, + limit: int = 10, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, +): + """Get paginated chat conversation history with optional date filtering""" + return await store.list_chatlogs(page, limit, start_date, end_date) + + +@router.get("/{thread_id}") +async def get_chat_thread(thread_id: str): + """Get complete conversation history for a specific thread""" + conversation = await store.get_chat_thread(thread_id) + if not conversation: + return {"error": "Conversation not found"} + + return conversation diff --git a/app/admin/chatlogs/schemas.py b/app/admin/chatlogs/schemas.py new file mode 100644 index 00000000..f7d28498 --- /dev/null +++ b/app/admin/chatlogs/schemas.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from typing import Dict, List, Optional +from datetime import datetime + + +class ChatMessage(BaseModel): + text: str + context: Optional[Dict] = {} + + +class ChatThreadInfo(BaseModel): + thread_id: str + date: datetime + + +class BotNessage(BaseModel): + text: str + + +class ChatLog(BaseModel): + user_message: ChatMessage + bot_message: List[BotNessage] + date: datetime + context: Optional[Dict] = {} + + +class ChatLogResponse(BaseModel): + total: int + page: int + limit: int + conversations: List[ChatThreadInfo] diff --git a/app/admin/chatlogs/store.py b/app/admin/chatlogs/store.py new file mode 100644 index 00000000..009ea2c5 --- /dev/null +++ b/app/admin/chatlogs/store.py @@ -0,0 +1,83 @@ +from typing import List, Optional +from datetime import datetime +from app.database import client +from .schemas import ChatLog, ChatLogResponse, ChatThreadInfo + +# Initialize MongoDB collection +collection = client["chatbot"]["state"] + + +async def list_chatlogs( + page: int = 1, + limit: int = 10, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, +) -> ChatLogResponse: + skip = (page - 1) * limit + + # Build query filter + query = {} + if start_date or end_date: + query["date"] = {} + if start_date: + query["date"]["$gte"] = start_date + if end_date: + query["date"]["$lte"] = end_date + + # Get total count of unique threads for pagination + pipeline = [ + {"$match": query}, + {"$group": {"_id": "$thread_id"}}, + {"$count": "total"}, + ] + result = await collection.aggregate(pipeline).to_list(1) + total = result[0]["total"] if result else 0 + + # Get paginated results grouped by thread_id with latest date + pipeline = [ + {"$match": query}, + {"$sort": {"date": -1}}, + { + "$group": { + "_id": "$thread_id", + "thread_id": {"$first": "$thread_id"}, + "date": {"$first": "$date"}, + } + }, + {"$sort": {"date": -1}}, + {"$skip": skip}, + {"$limit": limit}, + ] + + conversations = [] + async for doc in collection.aggregate(pipeline): + conversations.append( + ChatThreadInfo(thread_id=doc["thread_id"], date=doc["date"]) + ) + + return ChatLogResponse( + total=total, page=page, limit=limit, conversations=conversations + ) + + +async def get_chat_thread(thread_id: str) -> List[ChatLog]: + """Get complete conversation history for a specific thread""" + + cursor = collection.find({"thread_id": thread_id}).sort("date", 1) + messages = await cursor.to_list(length=None) + + if not messages: + return None + + chat_logs = [] + for msg in messages: + chat_logs.append( + ChatLog( + user_message=msg["user_message"], + bot_message=msg["bot_message"], + date=msg["date"], + context=msg.get("context", {}), + ) + ) + + return chat_logs diff --git a/app/bot/memory/models.py b/app/bot/memory/models.py index b394e931..18787483 100644 --- a/app/bot/memory/models.py +++ b/app/bot/memory/models.py @@ -73,7 +73,6 @@ def update(self, user_message: UserMessage): self.missing_parameters = [] self.complete = False self.current_node = None - self.date = None def get_active_intent_id(self): if self.intent: diff --git a/app/bot/nlu/entity_extractors/crf_entity_extractor.py b/app/bot/nlu/entity_extractors/crf_entity_extractor.py index 33957e0e..27bab81d 100755 --- a/app/bot/nlu/entity_extractors/crf_entity_extractor.py +++ b/app/bot/nlu/entity_extractors/crf_entity_extractor.py @@ -2,8 +2,9 @@ import logging from typing import Dict, Any, List, Optional from app.bot.nlu.pipeline import NLUComponent +import os -MODEL_NAME = "crf__entity_extractor.model" +MODEL_NAME = "crf_entity_extractor.model" logger = logging.getLogger(__name__) @@ -119,7 +120,8 @@ def train(self, training_data: List[Dict[str, Any]], model_path: str) -> None: "feature.possible_transitions": True, } ) - trainer.train(f"{model_path}/{MODEL_NAME}") + path = os.path.join(model_path, MODEL_NAME) + trainer.train(path) def load(self, model_path: str) -> bool: """ @@ -129,7 +131,8 @@ def load(self, model_path: str) -> bool: """ try: self.tagger = pycrfsuite.Tagger() - self.tagger.open(f"{model_path}/entity_model.model") + path = os.path.join(model_path, MODEL_NAME) + self.tagger.open(path) return True except Exception as e: logger.error(f"Error loading CRF model: {e}") diff --git a/app/main.py b/app/main.py index c5c78fd7..53834f9e 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,8 @@ from app.admin.train.routes import router as train_router from app.admin.test.routes import router as test_router from app.admin.integrations.routes import router as integrations_router +from app.admin.chatlogs.routes import router as chatlogs_router + from app.bot.channels.rest.routes import router as rest_router from app.bot.channels.facebook.routes import router as facebook_router @@ -54,6 +56,8 @@ async def root(): admin_router.include_router(train_router) admin_router.include_router(test_router) admin_router.include_router(integrations_router) +admin_router.include_router(chatlogs_router) + app.include_router(admin_router) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8b14f983..e6edaa7b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,14 +2,12 @@ version: '2' name: ai-chatbot-framework-dev services: mongodb: - container_name: mongodb image: mongo:4.2.20 hostname: mongodb volumes: - mongodbdata:/data migrate: - container_name: migrate build: context: . dockerfile: Dockerfile @@ -21,7 +19,6 @@ services: - mongodb app: - container_name: backend build: context: . dockerfile: Dockerfile @@ -38,11 +35,9 @@ services: - mongodb frontend: - container_name: frontend - hostname: frontend build: context: ./frontend - dockerfile: ./dev.Dockerfile + dockerfile: dev.Dockerfile volumes: - ./frontend/app:/app/app - ./frontend/public:/app/public diff --git a/frontend/.dockerignore b/frontend/.dockerignore index c2658d7d..22052075 100755 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1 +1,2 @@ node_modules/ +.next/ \ No newline at end of file diff --git a/frontend/app/admin/chat/page.tsx b/frontend/app/admin/chat/page.tsx index 6a3658af..4e0acac8 100644 --- a/frontend/app/admin/chat/page.tsx +++ b/frontend/app/admin/chat/page.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { converse, ChatState, UserMessage } from '../../services/chat'; -import { v4 as uuidv4 } from 'uuid'; import './style.css'; interface Message { @@ -13,17 +12,12 @@ interface Message { const ChatPage: React.FC = () => { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); - const [threadId, setThreadId] = useState(''); + const threadId = "test-user" const [isLoading, setIsLoading] = useState(false); const [chatState, setChatState] = useState(null); const messagesEndRef = useRef(null); const isInitialMount = useRef(true); - // Initialize threadId on client side only - useEffect(() => { - setThreadId(uuidv4()); - }, []); - const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; @@ -41,7 +35,9 @@ const ChatPage: React.FC = () => { const initialMessage: UserMessage = { thread_id: threadId, text: '/init_conversation', - context: {} + context: { + username: "Admin" + } }; try { diff --git a/frontend/app/admin/chatlogs/page.tsx b/frontend/app/admin/chatlogs/page.tsx new file mode 100644 index 00000000..dad072b8 --- /dev/null +++ b/frontend/app/admin/chatlogs/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ChatLog, ChatThreadInfo, listChatLogs, getChatThread, formatTimestamp } from '@/app/services/chatlogs'; + +const ChatLogsPage = () => { + const [threads, setThreads] = useState([]); + const [selectedThread, setSelectedThread] = useState(null); + const [messages, setMessages] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [threadLoading, setThreadLoading] = useState(false); + const limit = 10; + + const fetchThreads = async () => { + try { + const data = await listChatLogs(page, limit); + setThreads(data.conversations); + setTotal(data.total); + } catch (error) { + console.error('Error fetching chat logs:', error); + } finally { + setLoading(false); + } + }; + + const fetchThreadMessages = async (threadId: string) => { + setThreadLoading(true); + try { + const messages = await getChatThread(threadId); + setMessages(messages); + setSelectedThread(threadId); + } catch (error) { + console.error('Error fetching thread messages:', error); + } finally { + setThreadLoading(false); + } + }; + + useEffect(() => { + fetchThreads(); + }, []); + + const totalPages = Math.ceil(total / limit); + + return ( +
+
+

Chat Logs

+

View and analyze conversation history

+
+ +
+ {/* Thread List */} +
+
+

Conversations

+
+
+ {loading ? ( +
+
+
+ ) : ( +
+ {threads.map((thread) => ( +
fetchThreadMessages(thread.thread_id)} + className={`p-4 cursor-pointer transition-all ${selectedThread === thread.thread_id ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-gray-50'}`} + > +
+
+ {thread.thread_id} +
+
{formatTimestamp(thread.date)}
+
+
+ ))} +
+ )} +
+ {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ + {/* Message Thread */} +
+
+

+ {selectedThread ? 'Conversation Details' : 'Select a Conversation'} +

+
+
+ {!selectedThread ? ( +
+ + + +

Select a conversation from the list to view messages

+
+ ) : threadLoading ? ( +
+
+
+ ) : ( +
+ {messages.map((message, index) => ( +
+ {/* User Message */} +
+
+
+

{message.user_message.text}

+
+
+
+ + + +
+
+ + {/* Bot Message */} +
+
+ + + +
+
+
+ {message.bot_message.map((msg, i) => ( +

{msg.text}

+ ))} +
+
+
+
+ ))} +
+ )} +
+
+
+
+ ); +}; + +export default ChatLogsPage; \ No newline at end of file diff --git a/frontend/app/components/Sidebar/Sidebar.tsx b/frontend/app/components/Sidebar/Sidebar.tsx index 90df3c1e..6c726eba 100644 --- a/frontend/app/components/Sidebar/Sidebar.tsx +++ b/frontend/app/components/Sidebar/Sidebar.tsx @@ -9,7 +9,8 @@ import { ChatBubbleLeftRightIcon, Cog6ToothIcon, CircleStackIcon, - CodeBracketIcon + CodeBracketIcon, + ClipboardDocumentListIcon } from "@heroicons/react/24/outline"; import "./Sidebar.css"; import Link from 'next/link'; @@ -19,6 +20,7 @@ const Sidebar = () => { { label: "Intents", icon: BeakerIcon, path: "/admin/intents" }, { label: "Entities", icon: TagIcon, path: "/admin/entities" }, { label: "Chat", icon: ChatBubbleLeftRightIcon, path: "/admin/chat" }, + { label: "Logs", icon: ClipboardDocumentListIcon, path: "/admin/chatlogs" }, ]; const settingsItems = [ diff --git a/frontend/app/services/chatlogs.ts b/frontend/app/services/chatlogs.ts new file mode 100644 index 00000000..a1aae423 --- /dev/null +++ b/frontend/app/services/chatlogs.ts @@ -0,0 +1,51 @@ +import { API_BASE_URL } from "./base"; + +export interface ChatLog { + thread_id: string; + user_message: { + text: string; + context: Record; + }; + bot_message: Array<{ text: string }>; + timestamp: string; +} + +export interface ChatThreadInfo { + thread_id: string; + date: string; +} + +export interface ChatLogsResponse { + total: number; + page: number; + limit: number; + conversations: ChatThreadInfo[]; +} + +export async function listChatLogs(page: number, limit: number): Promise { + const response = await fetch(`${API_BASE_URL}chatlogs?page=${page}&limit=${limit}`); + if (!response.ok) { + throw new Error('Failed to fetch chat logs'); + } + return response.json(); +} + +export async function getChatThread(threadId: string): Promise { + const response = await fetch(`${API_BASE_URL}chatlogs/${threadId}`); + if (!response.ok) { + throw new Error('Failed to fetch chat thread'); + } + return response.json(); +} + +export function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} \ No newline at end of file diff --git a/frontend/dev.Dockerfile b/frontend/dev.Dockerfile index 5b471523..58885750 100644 --- a/frontend/dev.Dockerfile +++ b/frontend/dev.Dockerfile @@ -1,23 +1,19 @@ # syntax=docker.io/docker/dockerfile:1 -FROM node:18-alpine +FROM node:20-alpine WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ -RUN \ - if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ - elif [ -f package-lock.json ]; then npm ci; \ - elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \ - # Allow install without lockfile, so example works even without Node.js installed locally - else echo "Warning: Lockfile not found. It is recommended to commit lockfiles to version control." && yarn install; \ - fi +COPY package.json package-lock.json* .npmrc* ./ +RUN npm ci COPY app ./app COPY public ./public COPY next.config.ts . COPY tsconfig.json . +COPY tailwind.config.ts . +COPY postcss.config.mjs . # Next.js collects completely anonymous telemetry data about general usage. Learn more here: https://nextjs.org/telemetry # Uncomment the following line to disable telemetry at run time @@ -26,9 +22,4 @@ COPY tsconfig.json . # Note: Don't expose ports here, Compose will handle that for us # Start Next.js in development mode based on the preferred package manager -CMD \ - if [ -f yarn.lock ]; then yarn dev; \ - elif [ -f package-lock.json ]; then npm run dev; \ - elif [ -f pnpm-lock.yaml ]; then pnpm dev; \ - else npm run dev; \ - fi +CMD npm run dev diff --git a/requirements.txt b/requirements.txt index 4f0108ee..ae04f5a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,6 +58,7 @@ sniffio==1.3.1 spacy==3.8.4 spacy-legacy==3.0.12 spacy-loggers==1.0.5 +en_core_web_md @ https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.8.0/en_core_web_md-3.8.0-py3-none-any.whl#sha256=5e6329fe3fecedb1d1a02c3ea2172ee0fede6cea6e4aefb6a02d832dba78a310 srsly==2.5.1 starlette==0.45.3 thinc==8.3.4 @@ -74,4 +75,4 @@ weasel==0.4.1 websockets==14.2 wheel==0.45.1 wrapt==1.17.2 -yarl==1.18.3 +yarl==1.18.3 \ No newline at end of file