Commit 312e2567 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: Add Chat Reset feature with daily limit (5/day) + 15-message LLM context window

- Add reset_limit.py service for tracking daily reset usage via Redis
- Add archive_history method in conversation_manager.py to rename old messages
- Add POST /api/history/archive endpoint with limit check
- Update frontend with Reset button next to Send button
- Limit LLM context to 15 messages per session
- Filter chat history by current date (auto-clear next day)
parent b4329aaa
...@@ -83,7 +83,7 @@ async def chat_controller( ...@@ -83,7 +83,7 @@ async def chat_controller(
memory = await get_conversation_manager() memory = await get_conversation_manager()
# Load History # Load History
history_dicts = await memory.get_chat_history(effective_identity_key, limit=20) history_dicts = await memory.get_chat_history(effective_identity_key, limit=15)
messages = [ messages = [
HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"]) HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"])
for m in history_dicts for m in history_dicts
......
This diff is collapsed.
...@@ -9,6 +9,7 @@ Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddle ...@@ -9,6 +9,7 @@ Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddle
import logging import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import JSONResponse
from opentelemetry import trace from opentelemetry import trace
from agent.controller import chat_controller from agent.controller import chat_controller
...@@ -92,5 +93,13 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: ...@@ -92,5 +93,13 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
} }
except Exception as e: except Exception as e:
logger.error(f"Error in fashion_qa_chat: {e}", exc_info=True) logger.error(f"Error in fashion_qa_chat: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) from e # Trả về lỗi dạng JSON chuẩn với error_code="SYSTEM_ERROR"
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát."
}
)
...@@ -71,3 +71,72 @@ async def clear_chat_history(identity_key: str): ...@@ -71,3 +71,72 @@ async def clear_chat_history(identity_key: str):
logger.error(f"Error clearing chat history for {identity_key}: {e}") logger.error(f"Error clearing chat history for {identity_key}: {e}")
raise HTTPException(status_code=500, detail="Failed to clear chat history") raise HTTPException(status_code=500, detail="Failed to clear chat history")
from fastapi.responses import JSONResponse
from common.reset_limit import reset_limit_service
class ArchiveResponse(BaseModel):
status: str
success: bool
message: str
new_key: str
remaining_resets: int
@router.post("/api/history/archive", summary="Archive Chat History", response_model=ArchiveResponse)
async def archive_chat_history(request: Request):
"""
Lưu trữ lịch sử chat hiện tại (đổi tên key) và reset chat mới.
Giới hạn 5 lần/ngày.
"""
try:
identity = get_user_identity(request)
identity_key = identity.history_key
# if not identity.is_authenticated:
# return JSONResponse(
# status_code=403,
# content={
# "status": "error",
# "error_code": "LOGIN_REQUIRED",
# "message": "Tính năng chỉ dành cho thành viên đã đăng nhập.",
# "require_login": True
# }
# )
# Check reset limit
can_reset, usage, remaining = await reset_limit_service.check_limit(identity_key)
if not can_reset:
return JSONResponse(
status_code=429,
content={
"status": "error",
"error_code": "RESET_LIMIT_EXCEEDED",
"message": f"Bạn đã hết lượt tạo đoạn chat mới hôm nay ({reset_limit_service.limit}/{reset_limit_service.limit})."
}
)
manager = await get_conversation_manager()
new_key = await manager.archive_history(identity_key)
# Increment usage
await reset_limit_service.increment(identity_key)
return {
"status": "success",
"success": True,
"message": "History archived successfully",
"new_key": new_key,
"remaining_resets": remaining - 1 if remaining > 0 else 0
}
except Exception as e:
logger.error(f"Error archiving history: {e}")
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Failed to archive history"
}
)
...@@ -124,6 +124,7 @@ class ConversationManager: ...@@ -124,6 +124,7 @@ class ConversationManager:
SELECT message, is_human, timestamp, id SELECT message, is_human, timestamp, id
FROM {self.table_name} FROM {self.table_name}
WHERE identity_key = %s WHERE identity_key = %s
AND DATE(timestamp) = DATE(CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Ho_Chi_Minh')
""" """
params = [identity_key] params = [identity_key]
...@@ -182,6 +183,38 @@ class ConversationManager: ...@@ -182,6 +183,38 @@ class ConversationManager:
logger.error(f"Error retrieving chat history: {e}") logger.error(f"Error retrieving chat history: {e}")
return [] return []
async def archive_history(self, identity_key: str) -> str:
"""
Archive current chat history for identity_key by renaming it in the DB.
Only archives messages from TODAY (which are the visible ones).
Returns the new archived key.
"""
try:
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
new_key = f"{identity_key}_archived_{timestamp_suffix}"
pool = await self._get_pool()
async with pool.connection() as conn:
async with conn.cursor() as cursor:
# Rename identity_key for today's messages
await cursor.execute(
f"""
UPDATE {self.table_name}
SET identity_key = %s
WHERE identity_key = %s
AND DATE(timestamp) = DATE(CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Ho_Chi_Minh')
""",
(new_key, identity_key)
)
await conn.commit()
logger.info(f"Archived history for {identity_key} to {new_key}")
return new_key
except Exception as e:
logger.error(f"Error archiving history: {e}")
raise
async def clear_history(self, identity_key: str): async def clear_history(self, identity_key: str):
"""Clear all chat history for an identity""" """Clear all chat history for an identity"""
try: try:
......
...@@ -88,7 +88,7 @@ class LLMFactory: ...@@ -88,7 +88,7 @@ class LLMFactory:
"streaming": streaming, "streaming": streaming,
"api_key": key, "api_key": key,
"temperature": 0, "temperature": 0,
"max_tokens": 1000, "max_tokens": 1500,
} }
# Nếu bật json_mode, tiêm trực tiếp vào constructor # Nếu bật json_mode, tiêm trực tiếp vào constructor
......
...@@ -210,20 +210,19 @@ class MessageLimitService: ...@@ -210,20 +210,19 @@ class MessageLimitService:
can_send = False can_send = False
# Thông báo khi hết tổng quota (dù là user hay guest) # Thông báo khi hết tổng quota (dù là user hay guest)
if is_authenticated: if is_authenticated:
message = f"Bạn đã sử dụng hết {self.user_limit} tin nhắn hôm nay. Quay lại vào ngày mai nhé!" message = f"Bạn đã sử dụng hết tin nhắn hôm nay. Vui lòng quay lại vào hôm sau để dùng tiếp!"
else: else:
# Guest dùng hết user_limit tin (hiếm, vì guest bị chặn ở guest_limit rồi) # Guest dùng hết user_limit tin
message = f"Thiết bị này đã đạt giới hạn {self.user_limit} tin nhắn hôm nay." message = f"Thiết bị này đã đạt giới hạn {self.user_limit} tin nhắn hôm nay."
# Check Guest Limit (nếu chưa login và chưa bị chặn bởi total) # Check Guest Limit
elif not is_authenticated: elif not is_authenticated:
limit_display = self.guest_limit limit_display = self.guest_limit
if guest_used >= self.guest_limit: if guest_used >= self.guest_limit:
can_send = False can_send = False
require_login = True require_login = True
message = ( message = (
f"Bạn đã dùng hết {self.guest_limit} tin nhắn miễn phí. " "Bạn đã sử dụng hết tin nhắn hôm nay. Đăng nhập ngay để dùng tiếp: https://canifa.com/login"
f"Đăng nhập ngay để dùng tiếp (tối đa {self.user_limit} tin/ngày)!"
) )
# 3. Build Remaining Info # 3. Build Remaining Info
...@@ -237,6 +236,14 @@ class MessageLimitService: ...@@ -237,6 +236,14 @@ class MessageLimitService:
user_remaining = max(0, self.user_limit - total_used) user_remaining = max(0, self.user_limit - total_used)
remaining = min(guest_remaining, user_remaining) remaining = min(guest_remaining, user_remaining)
# Determine Error Code
error_code = None
if not can_send:
if require_login:
error_code = "GUEST_LIMIT_EXCEEDED"
else:
error_code = "USER_LIMIT_EXCEEDED"
info = { info = {
"limit": limit_display, "limit": limit_display,
"used": total_used if is_authenticated else guest_used, # Show cái user quan tâm "used": total_used if is_authenticated else guest_used, # Show cái user quan tâm
...@@ -248,6 +255,7 @@ class MessageLimitService: ...@@ -248,6 +255,7 @@ class MessageLimitService:
"is_authenticated": is_authenticated, "is_authenticated": is_authenticated,
"require_login": require_login, "require_login": require_login,
"message": message, "message": message,
"error_code": error_code,
} }
return can_send, info return can_send, info
...@@ -268,10 +276,7 @@ class MessageLimitService: ...@@ -268,10 +276,7 @@ class MessageLimitService:
self._memory_storage[identity_key] = {"guest": 0, "user": 0} self._memory_storage[identity_key] = {"guest": 0, "user": 0}
self._memory_storage[identity_key][field] += 1 self._memory_storage[identity_key][field] += 1
# Trả về info mới nhất (gọi lại check_limit để đồng bộ logic tính toán)
# Tuy nhiên để tối ưu performance, ta tự tính lại nhanh cũng được.
# Nhưng gọi check_limit an toàn hơn cho đồng nhất output structure.
_, info = await self.check_limit(identity_key, is_authenticated) _, info = await self.check_limit(identity_key, is_authenticated)
logger.debug( logger.debug(
......
...@@ -100,6 +100,13 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -100,6 +100,13 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
except Exception as e: except Exception as e:
logger.warning(f"Error reading device_id from body: {e}") logger.warning(f"Error reading device_id from body: {e}")
# Fallback: Nếu không có trong body, tìm trong header -> IP
if not device_id:
device_id = request.headers.get("device_id", "")
if not device_id:
device_id = f"unknown_{request.client.host}" if request.client else "unknown"
# ========== DEV MODE: Bypass auth ========== # ========== DEV MODE: Bypass auth ==========
dev_user_id = request.headers.get("X-Dev-User-Id") dev_user_id = request.headers.get("X-Dev-User-Id")
if dev_user_id: if dev_user_id:
...@@ -189,7 +196,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware): ...@@ -189,7 +196,7 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
status_code=429, status_code=429,
content={ content={
"status": "error", "status": "error",
"error_code": "MESSAGE_LIMIT_EXCEEDED", "error_code": limit_info.get("error_code") or "MESSAGE_LIMIT_EXCEEDED",
"message": limit_info["message"], "message": limit_info["message"],
"require_login": limit_info["require_login"], "require_login": limit_info["require_login"],
"limit_info": { "limit_info": {
......
import logging
from datetime import datetime
from common.cache import redis_cache
logger = logging.getLogger(__name__)
class ResetLimitService:
def __init__(self, limit: int = 5):
self.limit = limit
self.expiration_seconds = 86400 # 1 day
async def check_limit(self, identity_key: str) -> tuple[bool, int, int]:
"""
Check if user can reset chat.
Returns (can_reset, current_usage, remaining)
"""
redis_client = redis_cache.get_client()
if not redis_client:
# Fallback if Redis is down: allow reset
return True, 0, self.limit
today = datetime.now().strftime("%Y-%m-%d")
key = f"reset_limit:{identity_key}:{today}"
try:
count = await redis_client.get(key)
if count is None:
return True, 0, self.limit
current_usage = int(count)
remaining = self.limit - current_usage
if current_usage >= self.limit:
return False, current_usage, 0
return True, current_usage, remaining
except Exception as e:
logger.error(f"Error checking reset limit: {e}")
return True, 0, self.limit
async def increment(self, identity_key: str):
redis_client = redis_cache.get_client()
if not redis_client:
return
today = datetime.now().strftime("%Y-%m-%d")
key = f"reset_limit:{identity_key}:{today}"
try:
pipe = redis_client.pipeline()
pipe.incr(key)
pipe.expire(key, self.expiration_seconds)
await pipe.execute()
except Exception as e:
logger.error(f"Error incrementing reset limit: {e}")
reset_limit_service = ResetLimitService(limit=5)
...@@ -96,8 +96,14 @@ def get_user_identity(request: Request) -> UserIdentity: ...@@ -96,8 +96,14 @@ def get_user_identity(request: Request) -> UserIdentity:
Returns: Returns:
UserIdentity object UserIdentity object
""" """
# 1. Lấy device_id từ header (luôn có) # 1. Lấy device_id ưu tiên từ request.state (do middleware parse từ body), sau đó mới tới header
device_id = request.headers.get("device_id", "") device_id = ""
if hasattr(request.state, "device_id") and request.state.device_id:
device_id = request.state.device_id
if not device_id:
device_id = request.headers.get("device_id", "")
if not device_id: if not device_id:
device_id = f"unknown_{request.client.host}" if request.client else "unknown" device_id = f"unknown_{request.client.host}" if request.client else "unknown"
......
# API Documentation & UI Feature Updates
## 1. Reset / Archive Chat Feature
The user has requested a function to reset the chat session while preserving the history (archiving it).
### Backend Implementation
- **File:** `api/conservation_route.py`
- **Method:** `POST /api/history/archive`
- **Logic:**
1. Identify user (via `get_user_identity`).
2. Call `reset_limit_service.check_limit()` (Max 5 times/day).
3. Call `manager.archive_history(identity_key)`.
4. Returns `new_key`, `success` status, and `remaining_resets`.
### Frontend Implementation
- **File:** `static/index.html`
- **UI:** A reset button (🔄) in the chat header.
- **Action:** Calls value API. Displays error if limit exceeded.
## 2. Conversation Manager Updates
- **Logic:** `get_chat_history` has been updated to filter messages by `CURRENT_DATE AT TIME ZONE 'Asia/Ho_Chi_Minh'`.
- **Archiving:** New `archive_history` method renames the `identity_key`.
## 3. Limits
- **Chat Context:** Only the last 15 messages are sent to the LLM (modified in `agent/controller.py`).
- **Reset Limit:** Authenticated users (and guests) are limited to 5 resets per day.
This diff is collapsed.
This diff is collapsed.
# check_server.py
import pymysql
conn = pymysql.connect(
host="172.16.2.100", port=9030, user="anhvh", password="v0WYGeyLRCckXotT", database="shared_source"
)
cursor = conn.cursor()
# Check max connections
cursor.execute("SHOW VARIABLES LIKE 'max_connections'")
print("Max Connections:", cursor.fetchone())
# Check current connections
cursor.execute("SHOW PROCESSLIST")
processes = cursor.fetchall()
print(f"Current Active Connections: {len(processes)}")
# Check slow queries
cursor.execute("SHOW VARIABLES LIKE 'long_query_time'")
print("Slow Query Threshold:", cursor.fetchone())
conn.close()
# quick_test.py
import time
import pymysql
def quick_test():
print("🔍 Testing StarRocks connection...")
print("=" * 50)
print("\n📡 MySQL Connection Latency Test:")
latencies = []
for i in range(10):
start = time.time()
try:
conn = pymysql.connect(
host="172.16.2.100",
port=9030,
user="anhvh",
password="v0WYGeyLRCckXotT",
database="shared_source",
connect_timeout=10
)
latency = (time.time() - start) * 1000
latencies.append(latency)
print(f" ✅ Attempt {i+1}: {latency:.2f}ms")
# Lần đầu tiên thì check connection limits
if i == 0:
cursor = conn.cursor()
cursor.execute("SHOW VARIABLES LIKE 'max_connections'")
max_conn = cursor.fetchone()
cursor.execute("SHOW STATUS LIKE 'Threads_connected'")
current = cursor.fetchone()
cursor.execute("SHOW STATUS LIKE 'Max_used_connections'")
max_used = cursor.fetchone()
print(f"\n🔌 Connection Limits:")
print(f" Max Connections: {max_conn[1] if max_conn else 'N/A'}")
print(f" Current Active: {current[1] if current else 'N/A'}")
print(f" Peak Ever Used: {max_used[1] if max_used else 'N/A'}")
# Tính % usage
if max_conn and current:
usage = (int(current[1]) / int(max_conn[1])) * 100
print(f" Usage: {usage:.1f}%")
if usage > 80:
print(" ⚠️ WARNING: Connection pool > 80% full!")
conn.close()
except Exception as e:
print(f" ❌ Attempt {i+1} Failed: {e}")
time.sleep(0.3)
if latencies:
print(f"\n📊 Summary:")
print(f" Average: {sum(latencies)/len(latencies):.2f}ms")
print(f" Min: {min(latencies):.2f}ms")
print(f" Max: {max(latencies):.2f}ms")
if __name__ == "__main__":
quick_test()
\ No newline at end of file
import requests
import json
url = "http://172.16.2.207:5000/api/agent/chat"
print(f"\n--- TESTING DEVICE ID IN BODY ---")
# Payload có chứa device_id
payload = {
"user_query": "hello test device id in body",
"device_id": "device-id-from-body-test"
}
# Header KHÔNG có device-id
headers = {
"Content-Type": "application/json"
}
try:
res = requests.post(url, json=payload, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
limit = data.get('limit_info', {}).get('limit')
used = data.get('limit_info', {}).get('used')
print(f"✅ Status: {res.status_code}")
print(f"ℹ️ Limit: {limit} | Used: {used}")
# Nếu logic đúng, nó phải nhận ra device_id này và trả về limit = 10 (Guest)
# Nếu logic sai (không đọc được body), nó sẽ fallback về 'unknown' (cũng limit 10)
# Để chắc chắn, ta check xem limit đã bị trừ chưa (used > 0)
# Nhưng device_id unknown cũng được tính limit riêng.
# Ta có thể check log server, nhưng ở đây ta check limit behavior.
if limit == 10:
print("✅ Server recognized Guest mode (likely from body device_id).")
else:
print(f"⚠️ Unexpected limit: {limit}")
else:
print(f"❌ Failed: {res.status_code}")
print(res.text[:100])
except Exception as e:
print(f"❌ Error: {e}")
import requests
import json
url = "http://172.16.2.207:5000/api/agent/chat"
def test_request(name, headers):
print(f"\n--- TESTING {name} ---")
payload = {"user_query": "hello test user identify"}
try:
res = requests.post(url, json=payload, headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
limit = data.get('limit_info', {}).get('limit')
used = data.get('limit_info', {}).get('used')
print(f"✅ Status: {res.status_code}")
print(f"ℹ️ Limit: {limit} | Used: {used}")
print(f"ℹ️ Identity Check: {'Authenticated' if limit == 100 else 'Guest'}")
else:
print(f"❌ Failed: {res.status_code}")
print(res.text[:100])
except Exception as e:
print(f"❌ Error: {e}")
# 1. Guest (Chỉ có Device ID)
test_request("GUEST (Device ID Only)", {
"Content-Type": "application/json",
"device-id": "guest-device-final-check"
})
# 2. Authenticated (Token + Device ID)
test_request("AUTHENTICATED (Token)", {
"Content-Type": "application/json",
"Authorization": "Bearer 071w198x23ict4hs1i6bl889fit5p3f7",
"device-id": "guest-device-final-check"
})
import requests
import json
import time
url = "http://172.16.2.207:5000/api/agent/chat"
token = "071w198x23ict4hs1i6bl889fit5p3f7"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"device-id": "remote-check-01"
}
payload = {
"user_query": "hello, test server connection"
}
print(f"Testing connectivity to REMOTE server: {url}")
start = time.time()
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
print(f"Status Code: {response.status_code}")
print(f"Time taken: {time.time() - start:.2f}s")
if response.status_code == 200:
print("✅ Connection Successful!")
print("Response Preview:", str(response.json())[:200])
else:
print("❌ Server Error:")
print(response.text)
except Exception as e:
print(f"❌ Connection Failed: {e}")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment