Spaces:
Build error
Build error
Commit
·
7b2511b
1
Parent(s):
a09e48d
Add memory
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- app/handlers/message_handler.py +18 -5
- app/handlers/webhook_handler.py +3 -1
- app/main.py +46 -9
- app/memory/__init__.py +8 -0
- app/memory/implementation/async_memory.py +131 -0
- app/memory/memory.py +75 -0
- app/memory/models/__init__.py +3 -0
- app/memory/models/base.py +2 -0
- app/memory/models/message.py +18 -0
- app/memory/models/user.py +17 -0
- app/services/message.py +279 -22
- app/settings.py +30 -0
- app/utils/load_env.py +1 -0
- app/utils/system_prompt.py +45 -10
- app/utils/token_counter.py +13 -1
- docs/Coretax_FAQ.xlsx +0 -0
- docs/coretax_telegram.csv +0 -0
- images/photo_10.jpg +0 -0
- images/photo_107.jpg +0 -0
- images/photo_108.jpg +0 -0
- images/photo_11.jpg +0 -0
- images/photo_112.jpg +0 -0
- images/photo_12.1.jpg +0 -0
- images/photo_12.2.jpg +0 -0
- images/photo_13.jpg +0 -0
- images/photo_14.jpg +0 -0
- images/photo_15.1.jpg +0 -0
- images/photo_15.2.jpg +0 -0
- images/photo_16.jpg +0 -0
- images/photo_19.jpg +0 -0
- images/photo_20.jpg +0 -0
- images/photo_21.jpg +0 -0
- images/photo_25.jpg +0 -0
- images/photo_26.jpg +0 -0
- images/photo_27.jpg +0 -0
- images/photo_28.jpg +0 -0
- images/photo_29.jpg +0 -0
- images/photo_3.jpg +0 -0
- images/photo_31.jpg +0 -0
- images/photo_32.jpg +0 -0
- images/photo_33.jpg +0 -0
- images/photo_34.jpg +0 -0
- images/photo_35.jpg +0 -0
- images/photo_36.jpg +0 -0
- images/photo_38.jpg +0 -0
- images/photo_39.jpg +0 -0
- images/photo_4.jpg +0 -0
- images/photo_40.jpg +0 -0
- images/photo_41.jpg +0 -0
- images/photo_42.jpg +0 -0
app/handlers/message_handler.py
CHANGED
@@ -8,6 +8,8 @@ from app.services.download_media import download_whatsapp_media
|
|
8 |
from app.services.message import process_message_with_llm
|
9 |
from app.models.message_types import Message, MediaType, MediaContent
|
10 |
|
|
|
|
|
11 |
import logging
|
12 |
|
13 |
logger = logging.getLogger(__name__)
|
@@ -26,7 +28,7 @@ class MessageHandler:
|
|
26 |
self.media_handler = media_handler
|
27 |
self.logger = logger
|
28 |
|
29 |
-
async def handle(self, raw_message: dict, whatsapp_token: str, whatsapp_url:str,gemini_api:str, rag_system:Any = None) -> dict:
|
30 |
try:
|
31 |
# Parse message
|
32 |
message = MessageParser.parse(raw_message)
|
@@ -38,24 +40,35 @@ class MessageHandler:
|
|
38 |
# Download media
|
39 |
media_paths = await self._process_media(message, whatsapp_token)
|
40 |
|
41 |
-
|
|
|
|
|
|
|
42 |
|
|
|
43 |
|
|
|
|
|
44 |
# Process message with LLM
|
45 |
result = await process_message_with_llm(
|
46 |
message.sender_id,
|
47 |
message.content,
|
48 |
-
self.chat_manager.get_chat_history(message.sender_id),
|
|
|
49 |
rag_system = rag_system,
|
|
|
|
|
50 |
whatsapp_token=whatsapp_token,
|
51 |
whatsapp_url=whatsapp_url,
|
|
|
52 |
**media_paths
|
53 |
)
|
54 |
self.logger.info(f"Result: {result}")
|
55 |
# Append message to chat to keep track of conversation
|
56 |
-
self.chat_manager.append_message(message.sender_id, "user", message.content)
|
57 |
-
self.chat_manager.append_message(message.sender_id, "model", result)
|
58 |
|
|
|
59 |
self.message_cache.add(message.id)
|
60 |
|
61 |
return {"status": "success", "message_id": message.id, "result": result}
|
|
|
8 |
from app.services.message import process_message_with_llm
|
9 |
from app.models.message_types import Message, MediaType, MediaContent
|
10 |
|
11 |
+
from app.memory import AgentMemory
|
12 |
+
|
13 |
import logging
|
14 |
|
15 |
logger = logging.getLogger(__name__)
|
|
|
28 |
self.media_handler = media_handler
|
29 |
self.logger = logger
|
30 |
|
31 |
+
async def handle(self, raw_message: dict, whatsapp_token: str, whatsapp_url:str,gemini_api:str, rag_system:Any = None, agentMemory:Any=None, memory:Any=None) -> dict:
|
32 |
try:
|
33 |
# Parse message
|
34 |
message = MessageParser.parse(raw_message)
|
|
|
40 |
# Download media
|
41 |
media_paths = await self._process_media(message, whatsapp_token)
|
42 |
|
43 |
+
# Simple class to store chat temporarily
|
44 |
+
# self.chat_manager.initialize_chat(message.sender_id)
|
45 |
+
|
46 |
+
user = await agentMemory.add_user(message.sender_id, message.sender_id)
|
47 |
|
48 |
+
await memory.add_message(message.sender_id, "user", message.content)
|
49 |
|
50 |
+
history = await memory.get_history(message.sender_id, last_n = 2)
|
51 |
+
print(f"chat_history: {history }")
|
52 |
# Process message with LLM
|
53 |
result = await process_message_with_llm(
|
54 |
message.sender_id,
|
55 |
message.content,
|
56 |
+
# self.chat_manager.get_chat_history(message.sender_id),
|
57 |
+
history,
|
58 |
rag_system = rag_system,
|
59 |
+
agentMemory=agentMemory,
|
60 |
+
memory = memory,
|
61 |
whatsapp_token=whatsapp_token,
|
62 |
whatsapp_url=whatsapp_url,
|
63 |
+
|
64 |
**media_paths
|
65 |
)
|
66 |
self.logger.info(f"Result: {result}")
|
67 |
# Append message to chat to keep track of conversation
|
68 |
+
# self.chat_manager.append_message(message.sender_id, "user", message.content)
|
69 |
+
# self.chat_manager.append_message(message.sender_id, "model", result)
|
70 |
|
71 |
+
await memory.add_message(message.sender_id, "model", result)
|
72 |
self.message_cache.add(message.id)
|
73 |
|
74 |
return {"status": "success", "message_id": message.id, "result": result}
|
app/handlers/webhook_handler.py
CHANGED
@@ -18,7 +18,7 @@ class WebhookHandler:
|
|
18 |
self.message_handler = message_handler
|
19 |
self.logger = logging.getLogger(__name__)
|
20 |
|
21 |
-
async def process_webhook(self, payload: dict, whatsapp_token: str, whatsapp_url:str,gemini_api:str, rag_system:Any = None) -> WebhookResponse:
|
22 |
request_id = f"req_{int(time.time()*1000)}"
|
23 |
results = []
|
24 |
|
@@ -43,6 +43,8 @@ class WebhookHandler:
|
|
43 |
whatsapp_url=whatsapp_url,
|
44 |
gemini_api=gemini_api,
|
45 |
rag_system=rag_system,
|
|
|
|
|
46 |
)
|
47 |
results.append(response)
|
48 |
|
|
|
18 |
self.message_handler = message_handler
|
19 |
self.logger = logging.getLogger(__name__)
|
20 |
|
21 |
+
async def process_webhook(self, payload: dict, whatsapp_token: str, whatsapp_url:str,gemini_api:str, rag_system:Any = None, agentMemory:Any = None, memory:Any = None) -> WebhookResponse:
|
22 |
request_id = f"req_{int(time.time()*1000)}"
|
23 |
results = []
|
24 |
|
|
|
43 |
whatsapp_url=whatsapp_url,
|
44 |
gemini_api=gemini_api,
|
45 |
rag_system=rag_system,
|
46 |
+
agentMemory = agentMemory,
|
47 |
+
memory = memory
|
48 |
)
|
49 |
results.append(response)
|
50 |
|
app/main.py
CHANGED
@@ -27,8 +27,11 @@ from app.services.chat_manager import ChatManager
|
|
27 |
from app.api.api_prompt import prompt_router
|
28 |
from app.api.api_file import file_router, load_file_with_markdown_function
|
29 |
from app.utils.load_env import ACCESS_TOKEN, WHATSAPP_API_URL, GEMINI_API
|
|
|
|
|
30 |
|
31 |
-
|
|
|
32 |
from markitdown import MarkItDown
|
33 |
|
34 |
# Configure logging
|
@@ -64,31 +67,38 @@ async def setup_message_handler():
|
|
64 |
media_handler=media_handler,
|
65 |
logger=logger
|
66 |
)
|
67 |
-
async def setup_rag_system():
|
68 |
-
|
69 |
-
|
70 |
|
71 |
|
72 |
-
|
73 |
# Initialize FastAPI app
|
74 |
@asynccontextmanager
|
75 |
async def lifespan(app: FastAPI):
|
76 |
|
77 |
|
78 |
try:
|
|
|
|
|
|
|
|
|
79 |
# await init_db()
|
80 |
-
|
81 |
logger.info("Connected to the MongoDB database!")
|
82 |
-
rag_system = await setup_rag_system()
|
|
|
83 |
|
84 |
-
app.state.rag_system =
|
|
|
|
|
85 |
|
86 |
global message_handler, webhook_handler
|
87 |
message_handler = await setup_message_handler()
|
88 |
webhook_handler = WebhookHandler(message_handler)
|
89 |
# collections = app.database.list_collection_names()
|
90 |
# print(f"Collections in {db_name}: {collections}")
|
91 |
-
await load_file_with_markdown_function(rag_system=rag_system, filepaths=indexed_links)
|
92 |
yield
|
93 |
except Exception as e:
|
94 |
logger.error(e)
|
@@ -96,6 +106,8 @@ async def lifespan(app: FastAPI):
|
|
96 |
# Initialize Limiter and Prometheus Metrics
|
97 |
limiter = Limiter(key_func=get_remote_address)
|
98 |
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
99 |
app.state.limiter = limiter
|
100 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
101 |
|
@@ -109,6 +121,27 @@ app.include_router(file_router, prefix="/file_load", tags=["File Load"])
|
|
109 |
webhook_requests = Counter('webhook_requests_total', 'Total webhook requests')
|
110 |
webhook_processing_time = Histogram('webhook_processing_seconds', 'Time spent processing webhook')
|
111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
112 |
# Start Prometheus metrics server on port 8002
|
113 |
# start_http_server(8002)
|
114 |
# Register webhook routes
|
@@ -124,6 +157,8 @@ async def webhook(request: Request, background_tasks: BackgroundTasks):
|
|
124 |
payload = await request.json()
|
125 |
|
126 |
rag_system = request.app.state.rag_system
|
|
|
|
|
127 |
# validated_payload = WebhookPayload(**payload) # Validate payload
|
128 |
# logger.info(f"Validated Payload: {validated_payload}")
|
129 |
|
@@ -159,6 +194,8 @@ async def webhook(request: Request, background_tasks: BackgroundTasks):
|
|
159 |
whatsapp_url=WHATSAPP_API_URL,
|
160 |
gemini_api=GEMINI_API,
|
161 |
rag_system=rag_system,
|
|
|
|
|
162 |
)
|
163 |
# Return HTTP 200 immediately
|
164 |
return JSONResponse(
|
|
|
27 |
from app.api.api_prompt import prompt_router
|
28 |
from app.api.api_file import file_router, load_file_with_markdown_function
|
29 |
from app.utils.load_env import ACCESS_TOKEN, WHATSAPP_API_URL, GEMINI_API
|
30 |
+
from fastapi.staticfiles import StaticFiles
|
31 |
+
from vidavox.core import RAG_Engine
|
32 |
|
33 |
+
from app.memory import AgentMemory
|
34 |
+
from app.settings import settings
|
35 |
from markitdown import MarkItDown
|
36 |
|
37 |
# Configure logging
|
|
|
67 |
media_handler=media_handler,
|
68 |
logger=logger
|
69 |
)
|
70 |
+
# async def setup_rag_system():
|
71 |
+
# embedding_model = SentenceTransformer('all-MiniLM-L6-v2') # Replace with your model if different
|
72 |
+
# rag_system = RAGSystem(embedding_model)
|
73 |
|
74 |
|
75 |
+
# return rag_system
|
76 |
# Initialize FastAPI app
|
77 |
@asynccontextmanager
|
78 |
async def lifespan(app: FastAPI):
|
79 |
|
80 |
|
81 |
try:
|
82 |
+
|
83 |
+
agentMemory = AgentMemory(db_url=settings.POSTGRES_DB_URL)
|
84 |
+
|
85 |
+
memory = await agentMemory.initialize()
|
86 |
# await init_db()
|
87 |
+
file_paths = ['./docs/coretax_telegram.csv']
|
88 |
logger.info("Connected to the MongoDB database!")
|
89 |
+
# rag_system = await setup_rag_system()
|
90 |
+
engine= RAG_Engine(embedding_model='Snowflake/snowflake-arctic-embed-l-v2.0').from_paths(file_paths, load_csv_as_pandas_dataframe=True, text_col='answer', metadata_cols=['question','images_path'])
|
91 |
|
92 |
+
app.state.rag_system = engine
|
93 |
+
app.state.agentMemory = agentMemory
|
94 |
+
app.state.memory = memory
|
95 |
|
96 |
global message_handler, webhook_handler
|
97 |
message_handler = await setup_message_handler()
|
98 |
webhook_handler = WebhookHandler(message_handler)
|
99 |
# collections = app.database.list_collection_names()
|
100 |
# print(f"Collections in {db_name}: {collections}")
|
101 |
+
# await load_file_with_markdown_function(rag_system=rag_system, filepaths=indexed_links)
|
102 |
yield
|
103 |
except Exception as e:
|
104 |
logger.error(e)
|
|
|
106 |
# Initialize Limiter and Prometheus Metrics
|
107 |
limiter = Limiter(key_func=get_remote_address)
|
108 |
app = FastAPI(lifespan=lifespan)
|
109 |
+
# Mount the 'images' directory so its files are available under the /images URL path
|
110 |
+
app.mount("/images", StaticFiles(directory="images"), name="images")
|
111 |
app.state.limiter = limiter
|
112 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
113 |
|
|
|
121 |
webhook_requests = Counter('webhook_requests_total', 'Total webhook requests')
|
122 |
webhook_processing_time = Histogram('webhook_processing_seconds', 'Time spent processing webhook')
|
123 |
|
124 |
+
def get_image_links(image_paths: List[str]) -> List[str]:
|
125 |
+
links = []
|
126 |
+
for path in image_paths:
|
127 |
+
# Remove the surrounding brackets and any extra whitespace
|
128 |
+
cleaned = path.strip("[]").strip()
|
129 |
+
# Split by comma to get individual image paths
|
130 |
+
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
|
131 |
+
for part in parts:
|
132 |
+
# Assuming the part starts with "images/", extract the filename
|
133 |
+
if part.startswith("images/"):
|
134 |
+
filename = part.split("/", 1)[1]
|
135 |
+
links.append(f"/images/{filename}")
|
136 |
+
else:
|
137 |
+
links.append(part) # Fallback if the format is unexpected
|
138 |
+
return links
|
139 |
+
|
140 |
+
# @app.get("/image-links")
|
141 |
+
# async def image_links_endpoint():
|
142 |
+
# image_paths = ['[images/photo_3.jpg, images/photo_16.jpg]']
|
143 |
+
# links = get_image_links(image_paths)
|
144 |
+
# return {"links": links}
|
145 |
# Start Prometheus metrics server on port 8002
|
146 |
# start_http_server(8002)
|
147 |
# Register webhook routes
|
|
|
157 |
payload = await request.json()
|
158 |
|
159 |
rag_system = request.app.state.rag_system
|
160 |
+
agentMemory = request.app.state.agentMemory
|
161 |
+
memory = request.app.state.memory
|
162 |
# validated_payload = WebhookPayload(**payload) # Validate payload
|
163 |
# logger.info(f"Validated Payload: {validated_payload}")
|
164 |
|
|
|
194 |
whatsapp_url=WHATSAPP_API_URL,
|
195 |
gemini_api=GEMINI_API,
|
196 |
rag_system=rag_system,
|
197 |
+
agentMemory = agentMemory,
|
198 |
+
memory = memory
|
199 |
)
|
200 |
# Return HTTP 200 immediately
|
201 |
return JSONResponse(
|
app/memory/__init__.py
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
from .memory import AgentMemory
|
3 |
+
|
4 |
+
__version__ = "0.1.0"
|
5 |
+
__all__ = [
|
6 |
+
|
7 |
+
"AgentMemory"
|
8 |
+
]
|
app/memory/implementation/async_memory.py
ADDED
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# implementations/async_memory.py
|
2 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
3 |
+
from sqlalchemy.orm import sessionmaker
|
4 |
+
|
5 |
+
from app.settings import DatabaseSettings, MemorySettings
|
6 |
+
from app.memory.memory import ConversationMemoryInterface
|
7 |
+
from app.utils.token_counter import SimpleTokenCounter, TikTokenCounter
|
8 |
+
from app.memory.models.base import Base
|
9 |
+
from app.memory.models.message import Message
|
10 |
+
from app.memory.models.user import User
|
11 |
+
from typing import List, Dict, Optional
|
12 |
+
from datetime import datetime
|
13 |
+
from zoneinfo import ZoneInfo
|
14 |
+
from sqlalchemy.future import select
|
15 |
+
|
16 |
+
class AsyncPostgresConversationMemory(ConversationMemoryInterface):
|
17 |
+
def __init__(self, db_settings: DatabaseSettings, memory_settings: MemorySettings):
|
18 |
+
self.engine = create_async_engine(
|
19 |
+
db_settings.url,
|
20 |
+
pool_size=db_settings.pool_size,
|
21 |
+
max_overflow=db_settings.max_overflow,
|
22 |
+
pool_timeout=db_settings.pool_timeout
|
23 |
+
)
|
24 |
+
|
25 |
+
self.async_session = sessionmaker(
|
26 |
+
self.engine, class_=AsyncSession, expire_on_commit=False
|
27 |
+
)
|
28 |
+
self.token_limit = memory_settings.token_limit
|
29 |
+
|
30 |
+
|
31 |
+
if memory_settings.token_counter == "tiktoken":
|
32 |
+
self.token_counter = TikTokenCounter(memory_settings.model_name)
|
33 |
+
else:
|
34 |
+
self.token_counter = SimpleTokenCounter()
|
35 |
+
|
36 |
+
async def initialize(self):
|
37 |
+
"""Initialize the database by creating all tables."""
|
38 |
+
async with self.engine.begin() as conn:
|
39 |
+
await conn.run_sync(Base.metadata.create_all)
|
40 |
+
|
41 |
+
# In your async_memory.py
|
42 |
+
async def add_message(self, username: str, role: str, message: str, timestamp: Optional[datetime] = None) -> None:
|
43 |
+
from app.memory.models.user import User # Import here to avoid circular dependencies
|
44 |
+
async with self.async_session() as session:
|
45 |
+
# Look up the user by username
|
46 |
+
result = await session.execute(select(User).filter_by(username=username))
|
47 |
+
user = result.scalars().first()
|
48 |
+
if user is None:
|
49 |
+
raise ValueError(f"User with username '{username}' not found")
|
50 |
+
|
51 |
+
if timestamp is None:
|
52 |
+
timestamp = datetime.now(ZoneInfo("Asia/Jakarta"))
|
53 |
+
|
54 |
+
# Create the message using the found user's id
|
55 |
+
msg = Message(user_id=user.id, role=role, message=message, timestamp=timestamp)
|
56 |
+
session.add(msg)
|
57 |
+
await session.commit()
|
58 |
+
await self.trim_memory_if_needed(session)
|
59 |
+
|
60 |
+
|
61 |
+
|
62 |
+
async def get_all_history(self) -> List[Dict]:
|
63 |
+
async with self.async_session() as session:
|
64 |
+
result = await session.execute(
|
65 |
+
select(Message).order_by(Message.timestamp)
|
66 |
+
)
|
67 |
+
messages = result.scalars().all()
|
68 |
+
return [{"role": msg.role, "content": msg.message} for msg in messages]
|
69 |
+
|
70 |
+
async def get_history(
|
71 |
+
self,
|
72 |
+
username: Optional[str] = None,
|
73 |
+
token_limit: Optional[int] = None,
|
74 |
+
last_n: Optional[int] = None
|
75 |
+
) -> List[Dict]:
|
76 |
+
async with self.async_session() as session:
|
77 |
+
# Build the base query
|
78 |
+
query = select(Message).order_by(Message.timestamp)
|
79 |
+
if username is not None:
|
80 |
+
# Join with User table and filter by username
|
81 |
+
query = query.join(User).filter(User.username == username)
|
82 |
+
result = await session.execute(query)
|
83 |
+
messages = result.scalars().all()
|
84 |
+
|
85 |
+
# Accumulate messages in reverse (latest first)
|
86 |
+
selected = []
|
87 |
+
total_tokens = 0
|
88 |
+
for msg in reversed(messages):
|
89 |
+
tokens = self.token_counter.count_tokens(msg.message)
|
90 |
+
# If token_limit is specified and no message has been added yet,
|
91 |
+
# force-add the last message even if it exceeds token_limit.
|
92 |
+
if token_limit is not None and len(selected) == 0 and tokens > token_limit:
|
93 |
+
selected.append(msg)
|
94 |
+
total_tokens = tokens
|
95 |
+
continue
|
96 |
+
# Otherwise, check if adding this message would exceed the token limit.
|
97 |
+
if token_limit is not None and total_tokens + tokens > token_limit:
|
98 |
+
break
|
99 |
+
selected.append(msg)
|
100 |
+
total_tokens += tokens
|
101 |
+
# Stop if we've reached the maximum number of messages.
|
102 |
+
if last_n is not None and len(selected) >= last_n:
|
103 |
+
break
|
104 |
+
|
105 |
+
# Reverse to return in chronological order
|
106 |
+
selected.reverse()
|
107 |
+
return [{"role": msg.role, "parts": msg.message} for msg in selected]
|
108 |
+
|
109 |
+
|
110 |
+
async def clear_memory(self) -> None:
|
111 |
+
async with self.async_session() as session:
|
112 |
+
await session.execute(select(Message).delete())
|
113 |
+
await session.commit()
|
114 |
+
|
115 |
+
async def get_total_tokens(self) -> int:
|
116 |
+
async with self.async_session() as session:
|
117 |
+
result = await session.execute(select(Message))
|
118 |
+
messages = result.scalars().all()
|
119 |
+
return sum(self.token_counter.count_tokens(msg.message) for msg in messages)
|
120 |
+
|
121 |
+
async def trim_memory_if_needed(self, session: AsyncSession) -> None:
|
122 |
+
result = await session.execute(select(Message).order_by(Message.timestamp))
|
123 |
+
messages = result.scalars().all()
|
124 |
+
total_tokens = sum(self.token_counter.count_tokens(msg.message) for msg in messages)
|
125 |
+
|
126 |
+
while total_tokens > self.token_limit and messages:
|
127 |
+
oldest = messages.pop(0)
|
128 |
+
total_tokens -= self.token_counter.count_tokens(oldest.message)
|
129 |
+
await session.delete(oldest)
|
130 |
+
|
131 |
+
await session.commit()
|
app/memory/memory.py
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from abc import ABC, abstractmethod
|
2 |
+
from typing import List, Dict, Optional
|
3 |
+
from datetime import datetime
|
4 |
+
from zoneinfo import ZoneInfo
|
5 |
+
|
6 |
+
|
7 |
+
class ConversationMemoryInterface(ABC):
|
8 |
+
@abstractmethod
|
9 |
+
def add_message(self, role: str, message: str, timestamp: Optional[datetime] = None) -> None:
|
10 |
+
pass
|
11 |
+
|
12 |
+
@abstractmethod
|
13 |
+
def get_history(self) -> List[Dict]:
|
14 |
+
pass
|
15 |
+
|
16 |
+
@abstractmethod
|
17 |
+
def clear_memory(self) -> None:
|
18 |
+
pass
|
19 |
+
|
20 |
+
@abstractmethod
|
21 |
+
def get_total_tokens(self) -> int:
|
22 |
+
pass
|
23 |
+
|
24 |
+
|
25 |
+
from app.settings import DatabaseSettings, MemorySettings, settings
|
26 |
+
from app.memory.implementation.async_memory import AsyncPostgresConversationMemory
|
27 |
+
from datetime import datetime
|
28 |
+
from sqlalchemy.future import select
|
29 |
+
|
30 |
+
class AgentMemory:
|
31 |
+
def __init__(
|
32 |
+
self,
|
33 |
+
db_url: str = None,
|
34 |
+
token_limit: int = 500,
|
35 |
+
token_counter: str = "simple", # or "tiktoken"
|
36 |
+
model_name: str = None # required if token_counter == "tiktoken"
|
37 |
+
):
|
38 |
+
# Use provided URL or default from settings
|
39 |
+
if db_url is None:
|
40 |
+
db_url = settings.POSTGRES_DB_URL
|
41 |
+
self.db_settings = DatabaseSettings(url=db_url)
|
42 |
+
self.memory_settings = MemorySettings(
|
43 |
+
token_limit=token_limit,
|
44 |
+
token_counter=token_counter,
|
45 |
+
model_name=model_name
|
46 |
+
)
|
47 |
+
# Instantiate your async memory
|
48 |
+
self.memory = AsyncPostgresConversationMemory(self.db_settings, self.memory_settings)
|
49 |
+
|
50 |
+
async def initialize(self):
|
51 |
+
"""Initializes the database tables and returns the memory instance."""
|
52 |
+
await self.memory.initialize()
|
53 |
+
return self.memory
|
54 |
+
|
55 |
+
async def add_user(self, username: str, hashed_password: str):
|
56 |
+
"""
|
57 |
+
Adds a new user to the database.
|
58 |
+
Returns the created user or existing user if found.
|
59 |
+
"""
|
60 |
+
from app.memory.models.user import User # Import here to avoid circular dependencies
|
61 |
+
async with self.memory.async_session() as session:
|
62 |
+
result = await session.execute(select(User).filter_by(username=username))
|
63 |
+
existing_user = result.scalars().first()
|
64 |
+
if existing_user:
|
65 |
+
return existing_user
|
66 |
+
|
67 |
+
new_user = User(
|
68 |
+
username=username,
|
69 |
+
hashed_password=hashed_password,
|
70 |
+
created_at=datetime.now(ZoneInfo("Asia/Jakarta"))
|
71 |
+
)
|
72 |
+
session.add(new_user)
|
73 |
+
await session.commit()
|
74 |
+
return new_user
|
75 |
+
|
app/memory/models/__init__.py
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# models/__init__.py
|
2 |
+
from .user import User
|
3 |
+
from .message import Message
|
app/memory/models/base.py
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.orm import declarative_base
|
2 |
+
Base = declarative_base()
|
app/memory/models/message.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# models/message.py
|
2 |
+
from datetime import datetime
|
3 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
4 |
+
from sqlalchemy.orm import relationship
|
5 |
+
from zoneinfo import ZoneInfo
|
6 |
+
from .base import Base
|
7 |
+
|
8 |
+
class Message(Base):
|
9 |
+
__tablename__ = 'messages'
|
10 |
+
|
11 |
+
id = Column(Integer, primary_key=True)
|
12 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
13 |
+
role = Column(String(50))
|
14 |
+
message = Column(Text)
|
15 |
+
timestamp = Column(DateTime(timezone=True), default=lambda: datetime.now(ZoneInfo("Asia/Jakarta")))
|
16 |
+
|
17 |
+
# Use a string reference for deferred resolution.
|
18 |
+
user = relationship("User", back_populates="messages")
|
app/memory/models/user.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# models/user.py
|
2 |
+
from datetime import datetime
|
3 |
+
from sqlalchemy import Column, Integer, String, DateTime
|
4 |
+
from sqlalchemy.orm import relationship
|
5 |
+
from zoneinfo import ZoneInfo
|
6 |
+
from .base import Base
|
7 |
+
|
8 |
+
class User(Base):
|
9 |
+
__tablename__ = 'users'
|
10 |
+
|
11 |
+
id = Column(Integer, primary_key=True)
|
12 |
+
username = Column(String(100), unique=True, nullable=False)
|
13 |
+
hashed_password = Column(String(255), nullable=False)
|
14 |
+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(ZoneInfo("Asia/Jakarta")))
|
15 |
+
|
16 |
+
# Relationship to Message
|
17 |
+
messages = relationship("Message", back_populates="user", cascade="all, delete-orphan")
|
app/services/message.py
CHANGED
@@ -5,21 +5,71 @@ from typing import Dict, Any, Optional, List
|
|
5 |
from datetime import datetime
|
6 |
import logging
|
7 |
import asyncio
|
|
|
8 |
from openai import AsyncOpenAI
|
9 |
-
import json
|
10 |
import google.generativeai as genai
|
11 |
-
|
12 |
import PIL.Image
|
|
|
13 |
from typing import List, Dict, Any, Optional
|
14 |
|
15 |
-
from app.utils.load_env import ACCESS_TOKEN, WHATSAPP_API_URL, GEMINI_API
|
16 |
from app.utils.system_prompt import system_prompt
|
17 |
|
18 |
from app.services.search_engine import google_search
|
19 |
-
from app.search.rag_pipeline import extract_keywords_async
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
# Load environment variables
|
21 |
load_dotenv()
|
22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
# Define function specifications for Gemini
|
24 |
function_declarations = [
|
25 |
{
|
@@ -43,6 +93,17 @@ function_declarations = [
|
|
43 |
}
|
44 |
]
|
45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
genai.configure(api_key=GEMINI_API)
|
47 |
# client = AsyncOpenAI(api_key = OPENAI_API)
|
48 |
# Configure logging
|
@@ -56,13 +117,77 @@ logger = logging.getLogger(__name__)
|
|
56 |
if not WHATSAPP_API_URL or not ACCESS_TOKEN:
|
57 |
logger.warning("Environment variables for WHATSAPP_API_URL or ACCESS_TOKEN are not set!")
|
58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
59 |
# Helper function to send a reply
|
60 |
-
async def send_reply(to: str, body: str, whatsapp_token: str, whatsapp_url:str) -> Dict[str, Any]:
|
61 |
headers = {
|
62 |
"Authorization": f"Bearer {whatsapp_token}",
|
63 |
"Content-Type": "application/json"
|
64 |
}
|
65 |
-
|
66 |
"messaging_product": "whatsapp",
|
67 |
"to": to,
|
68 |
"type": "text",
|
@@ -71,15 +196,46 @@ async def send_reply(to: str, body: str, whatsapp_token: str, whatsapp_url:str)
|
|
71 |
}
|
72 |
}
|
73 |
|
74 |
-
|
75 |
-
response = await client.post(whatsapp_url, json=data, headers=headers)
|
76 |
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
|
84 |
# Helper function to generate a reply based on message content
|
85 |
async def generate_reply(sender: str, content: str, timestamp: int) -> str:
|
@@ -102,8 +258,11 @@ async def process_message_with_llm(
|
|
102 |
content: str,
|
103 |
history: List[Dict[str, str]],
|
104 |
rag_system: Any,
|
|
|
105 |
whatsapp_token: str,
|
106 |
whatsapp_url:str,
|
|
|
|
|
107 |
image_file_path: Optional[str] = None,
|
108 |
doc_path: Optional[str] = None,
|
109 |
video_file_path: Optional[str] = None,
|
@@ -111,29 +270,119 @@ async def process_message_with_llm(
|
|
111 |
"""Process message with retry logic."""
|
112 |
try:
|
113 |
logger.info(f"Processing message for sender: {sender_id}")
|
114 |
-
generated_reply = await generate_response_from_gemini(
|
115 |
sender=sender_id,
|
116 |
content=content,
|
117 |
history=history,
|
118 |
rag_system=rag_system,
|
119 |
image_file_path=image_file_path,
|
120 |
doc_path=doc_path,
|
121 |
-
video_file_path=video_file_path
|
|
|
|
|
122 |
)
|
123 |
-
logger.info(f"Generated reply: {generated_reply}")
|
124 |
|
125 |
-
response = await send_reply(sender_id, generated_reply, whatsapp_token, whatsapp_url)
|
126 |
# return generated_reply
|
127 |
return generated_reply
|
128 |
except Exception as e:
|
129 |
logger.error(f"Error in process_message_with_retry: {str(e)}", exc_info=True)
|
130 |
return "Sorry, I couldn't generate a response at this time."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
131 |
|
|
|
|
|
|
|
|
|
132 |
async def generate_response_from_gemini(
|
133 |
sender: str,
|
134 |
content: str,
|
135 |
history: List[Dict[str, str]],
|
136 |
rag_system: Any = None,
|
|
|
|
|
137 |
image_file_path: Optional[str] = None,
|
138 |
doc_path: Optional[str] = None,
|
139 |
video_file_path: Optional[str] = None,
|
@@ -151,15 +400,18 @@ async def generate_response_from_gemini(
|
|
151 |
|
152 |
if content:
|
153 |
if rag_system:
|
154 |
-
keywords = extract_keywords_async(content)
|
155 |
# keywords = []
|
156 |
# logger.info(f"Extracted Keywords: {keywords}")
|
157 |
# Implement RAG: Retrieve relevant documents
|
158 |
-
retrieved_docs =
|
|
|
|
|
159 |
if retrieved_docs:
|
160 |
logger.info(f"Retrieved {len(retrieved_docs)} documents for context.")
|
161 |
# Format the retrieved documents as a context string
|
162 |
-
context = "\n\n".join([f"Source:{doc['
|
|
|
163 |
# Option 1: Append to history as a system message
|
164 |
history.append({"role": "user", "parts": f"Relevant documents:\n{context}"})
|
165 |
|
@@ -192,8 +444,13 @@ async def generate_response_from_gemini(
|
|
192 |
|
193 |
# Send the user's message
|
194 |
response = await chat.send_message_async(content)
|
|
|
|
|
|
|
|
|
195 |
# response = await handle_function_call(response)
|
196 |
-
return response.text
|
|
|
197 |
|
198 |
except Exception as e:
|
199 |
logger.error("Error in generate_response_from_gemini:", exc_info=True)
|
|
|
5 |
from datetime import datetime
|
6 |
import logging
|
7 |
import asyncio
|
8 |
+
import hashlib
|
9 |
from openai import AsyncOpenAI
|
10 |
+
import json, requests, mimetypes
|
11 |
import google.generativeai as genai
|
12 |
+
import re, json
|
13 |
import PIL.Image
|
14 |
+
import requests
|
15 |
from typing import List, Dict, Any, Optional
|
16 |
|
17 |
+
from app.utils.load_env import ACCESS_TOKEN, WHATSAPP_API_URL, GEMINI_API, MEDIA_UPLOAD_URL
|
18 |
from app.utils.system_prompt import system_prompt
|
19 |
|
20 |
from app.services.search_engine import google_search
|
21 |
+
# from app.search.rag_pipeline import extract_keywords_async
|
22 |
+
|
23 |
+
from vidavox.core import (
|
24 |
+
|
25 |
+
BaseResultFormatter,
|
26 |
+
SearchResult)
|
27 |
+
|
28 |
+
|
29 |
# Load environment variables
|
30 |
load_dotenv()
|
31 |
|
32 |
+
|
33 |
+
|
34 |
+
# Get base url from ngrok
|
35 |
+
def get_ngrok_url() -> str:
|
36 |
+
"""Fetches the public URL of the first ngrok tunnel."""
|
37 |
+
try:
|
38 |
+
response = requests.get("http://localhost:4040/api/tunnels")
|
39 |
+
response.raise_for_status() # Raise an error for bad status codes.
|
40 |
+
tunnels = response.json().get("tunnels", [])
|
41 |
+
if tunnels:
|
42 |
+
# Prefer the HTTPS tunnel if available.
|
43 |
+
for tunnel in tunnels:
|
44 |
+
if tunnel.get("proto") == "https":
|
45 |
+
return tunnel.get("public_url")
|
46 |
+
# Fallback: return the first tunnel's URL.
|
47 |
+
return tunnels[0].get("public_url")
|
48 |
+
except Exception as e:
|
49 |
+
print("Error fetching ngrok URL:", e)
|
50 |
+
# Fallback in case ngrok isn't running.
|
51 |
+
return "http://localhost:8005"
|
52 |
+
|
53 |
+
base_url = get_ngrok_url() # Automatically retrieve your public ngrok URL
|
54 |
+
print("Base URL:", base_url)
|
55 |
+
# Get image link from image paths
|
56 |
+
|
57 |
+
def get_image_links(image_paths: List[str], base_url: str) -> List[str]:
|
58 |
+
links = []
|
59 |
+
for path in image_paths:
|
60 |
+
# Remove the surrounding brackets and any extra whitespace
|
61 |
+
cleaned = path.strip("[]").strip()
|
62 |
+
# Split by comma to get individual image paths
|
63 |
+
parts = [part.strip() for part in cleaned.split(",") if part.strip()]
|
64 |
+
for part in parts:
|
65 |
+
# Assuming the part starts with "images/", extract the filename
|
66 |
+
if part.startswith("images/"):
|
67 |
+
filename = part.split("/", 1)[1]
|
68 |
+
links.append(f"{base_url}/images/{filename}")
|
69 |
+
else:
|
70 |
+
links.append(f"{base_url}/{part}") # Fallback if the format is unexpected
|
71 |
+
return links
|
72 |
+
|
73 |
# Define function specifications for Gemini
|
74 |
function_declarations = [
|
75 |
{
|
|
|
93 |
}
|
94 |
]
|
95 |
|
96 |
+
class CustomResultFormatter(BaseResultFormatter):
|
97 |
+
def format(self, result: SearchResult) -> Dict[str, Any]:
|
98 |
+
# Customize the result format as needed
|
99 |
+
return {
|
100 |
+
"doc_id": result.doc_id,
|
101 |
+
|
102 |
+
"page_content": result.text,
|
103 |
+
"image": result.meta_data['images_path'],
|
104 |
+
"relevance": result.score,
|
105 |
+
}
|
106 |
+
|
107 |
genai.configure(api_key=GEMINI_API)
|
108 |
# client = AsyncOpenAI(api_key = OPENAI_API)
|
109 |
# Configure logging
|
|
|
117 |
if not WHATSAPP_API_URL or not ACCESS_TOKEN:
|
118 |
logger.warning("Environment variables for WHATSAPP_API_URL or ACCESS_TOKEN are not set!")
|
119 |
|
120 |
+
# Path for the cache file
|
121 |
+
CACHE_FILE = 'upload_cache.json'
|
122 |
+
# Load the cache if it exists, otherwise initialize an empty dict
|
123 |
+
if os.path.exists(CACHE_FILE):
|
124 |
+
with open(CACHE_FILE, 'r') as f:
|
125 |
+
upload_cache = json.load(f)
|
126 |
+
else:
|
127 |
+
upload_cache = {}
|
128 |
+
|
129 |
+
def save_cache():
|
130 |
+
with open(CACHE_FILE, 'w') as f:
|
131 |
+
json.dump(upload_cache, f)
|
132 |
+
|
133 |
+
def compute_file_hash(file_path, block_size=65536):
|
134 |
+
"""Compute SHA256 hash of a file to uniquely identify its content."""
|
135 |
+
hasher = hashlib.sha256()
|
136 |
+
with open(file_path, 'rb') as f:
|
137 |
+
for block in iter(lambda: f.read(block_size), b''):
|
138 |
+
hasher.update(block)
|
139 |
+
return hasher.hexdigest()
|
140 |
+
|
141 |
+
# Helper function to upload an image
|
142 |
+
async def upload_image(file_path):
|
143 |
+
logger.info(f"Uploading image: {file_path}")
|
144 |
+
|
145 |
+
# Ensure the file exists
|
146 |
+
if not os.path.exists(file_path):
|
147 |
+
raise Exception(f"File not found: {file_path}")
|
148 |
+
|
149 |
+
# Compute a hash for the file to check for previous uploads
|
150 |
+
file_hash = compute_file_hash(file_path)
|
151 |
+
if file_hash in upload_cache:
|
152 |
+
logger.info(f"File {file_path} already uploaded. Returning cached media ID.")
|
153 |
+
return upload_cache[file_hash]
|
154 |
+
|
155 |
+
# Get the MIME type of the file
|
156 |
+
mime_type, _ = mimetypes.guess_type(file_path)
|
157 |
+
if not mime_type:
|
158 |
+
raise Exception(f"Could not determine the MIME type for file: {file_path}")
|
159 |
+
|
160 |
+
headers = {
|
161 |
+
'Authorization': f'Bearer {ACCESS_TOKEN}'
|
162 |
+
}
|
163 |
+
# Open the file and prepare the payload for upload
|
164 |
+
with open(file_path, 'rb') as video_file:
|
165 |
+
files = {
|
166 |
+
'file': (os.path.basename(file_path), video_file, mime_type)
|
167 |
+
}
|
168 |
+
data = {
|
169 |
+
'messaging_product': 'whatsapp'
|
170 |
+
}
|
171 |
+
response = requests.post(MEDIA_UPLOAD_URL, headers=headers, files=files, data=data)
|
172 |
+
|
173 |
+
if response.status_code == 200:
|
174 |
+
logger.info(f"Upload successful: {response.text}")
|
175 |
+
media_id = response.json()['id']
|
176 |
+
# Cache the result so future calls can use the same media ID
|
177 |
+
upload_cache[file_hash] = media_id
|
178 |
+
save_cache()
|
179 |
+
return media_id
|
180 |
+
else:
|
181 |
+
logger.error(f"Upload failed: {response.text}")
|
182 |
+
raise Exception(f'Failed to upload media: {response.status_code}, {response.text}')
|
183 |
+
|
184 |
# Helper function to send a reply
|
185 |
+
async def send_reply(to: str, body: str, whatsapp_token: str, whatsapp_url:str, image:Any) -> Dict[str, Any]:
|
186 |
headers = {
|
187 |
"Authorization": f"Bearer {whatsapp_token}",
|
188 |
"Content-Type": "application/json"
|
189 |
}
|
190 |
+
text_data = {
|
191 |
"messaging_product": "whatsapp",
|
192 |
"to": to,
|
193 |
"type": "text",
|
|
|
196 |
}
|
197 |
}
|
198 |
|
199 |
+
responses = {} # To store the responses
|
|
|
200 |
|
201 |
+
async with httpx.AsyncClient() as client:
|
202 |
+
# response = await client.post(whatsapp_url, json=text_data, headers=headers)
|
203 |
+
text_response = await client.post(whatsapp_url, json=text_data, headers=headers)
|
204 |
+
if text_response.status_code != 200:
|
205 |
+
error_detail = text_response.json()
|
206 |
+
logger.error(f"Failed to send text reply: {error_detail}")
|
207 |
+
raise Exception(f"Failed to send text reply with status code {text_response.status_code}: {error_detail}")
|
208 |
+
responses["text"] = text_response.json()
|
209 |
+
# if response.status_code != 200:
|
210 |
+
# error_detail = response.json()
|
211 |
+
# logger.error(f"Failed to send reply: {error_detail}")
|
212 |
+
# raise Exception(f"Failed to send reply with status code {response.status_code}: {error_detail}")
|
213 |
+
# Initialize list to hold image responses
|
214 |
+
image_responses: List[Dict[str, Any]] = []
|
215 |
+
if image:
|
216 |
+
# Get the list of full image URLs using your helper function.
|
217 |
+
links = get_image_links(image, base_url)
|
218 |
+
for link in links:
|
219 |
+
image_payload = {
|
220 |
+
"messaging_product": "whatsapp",
|
221 |
+
"recipient_type": "individual",
|
222 |
+
"to": to,
|
223 |
+
"type": "image",
|
224 |
+
"image": {
|
225 |
+
"id": "",
|
226 |
+
"link": link,
|
227 |
+
"caption": "" # Using the text body as caption; adjust if needed.
|
228 |
+
}
|
229 |
+
}
|
230 |
+
img_response = await client.post(whatsapp_url, json=image_payload, headers=headers)
|
231 |
+
if img_response.status_code != 200:
|
232 |
+
error_detail = img_response.json()
|
233 |
+
logger.error(f"Failed to send image: {error_detail}")
|
234 |
+
raise Exception(f"Failed to send image with status code {img_response.status_code}: {error_detail}")
|
235 |
+
image_responses.append(img_response.json())
|
236 |
+
responses["images"] = image_responses
|
237 |
+
return responses
|
238 |
+
# return response.json()
|
239 |
|
240 |
# Helper function to generate a reply based on message content
|
241 |
async def generate_reply(sender: str, content: str, timestamp: int) -> str:
|
|
|
258 |
content: str,
|
259 |
history: List[Dict[str, str]],
|
260 |
rag_system: Any,
|
261 |
+
|
262 |
whatsapp_token: str,
|
263 |
whatsapp_url:str,
|
264 |
+
agentMemory: Any = None,
|
265 |
+
memory:Any = None,
|
266 |
image_file_path: Optional[str] = None,
|
267 |
doc_path: Optional[str] = None,
|
268 |
video_file_path: Optional[str] = None,
|
|
|
270 |
"""Process message with retry logic."""
|
271 |
try:
|
272 |
logger.info(f"Processing message for sender: {sender_id}")
|
273 |
+
generated_reply, image_path = await generate_response_from_gemini(
|
274 |
sender=sender_id,
|
275 |
content=content,
|
276 |
history=history,
|
277 |
rag_system=rag_system,
|
278 |
image_file_path=image_file_path,
|
279 |
doc_path=doc_path,
|
280 |
+
video_file_path=video_file_path,
|
281 |
+
agentMemory=agentMemory,
|
282 |
+
memory = memory
|
283 |
)
|
284 |
+
logger.info(f"Generated reply: {generated_reply}, extracted image path: {image_path}")
|
285 |
|
286 |
+
response = await send_reply(sender_id, generated_reply , whatsapp_token, whatsapp_url, image_path)
|
287 |
# return generated_reply
|
288 |
return generated_reply
|
289 |
except Exception as e:
|
290 |
logger.error(f"Error in process_message_with_retry: {str(e)}", exc_info=True)
|
291 |
return "Sorry, I couldn't generate a response at this time."
|
292 |
+
|
293 |
+
import markdown
|
294 |
+
from bs4 import BeautifulSoup
|
295 |
+
|
296 |
+
def format_response_text(response_text: str) -> str:
|
297 |
+
"""
|
298 |
+
Converts markdown-formatted text to plain text with proper newlines.
|
299 |
+
This will ensure bullet points, paragraphs, and other elements are formatted
|
300 |
+
for display in WhatsApp.
|
301 |
+
"""
|
302 |
+
# Convert markdown to HTML
|
303 |
+
html = markdown.markdown(response_text)
|
304 |
+
# Parse HTML and extract text using newline as separator
|
305 |
+
soup = BeautifulSoup(html, "html.parser")
|
306 |
+
formatted_text = soup.get_text(separator="\n")
|
307 |
+
return formatted_text
|
308 |
+
|
309 |
+
import re
|
310 |
+
import json
|
311 |
+
|
312 |
+
def process_llm_response(llm_output):
|
313 |
+
# If it's a string, attempt to extract JSON from markdown code fences.
|
314 |
+
if isinstance(llm_output, str):
|
315 |
+
pattern = r"```json\s*(\{.*\})\s*```"
|
316 |
+
match = re.search(pattern, llm_output, re.DOTALL)
|
317 |
+
if match:
|
318 |
+
json_str = match.group(1)
|
319 |
+
else:
|
320 |
+
json_str = llm_output.strip()
|
321 |
+
try:
|
322 |
+
parsed = json.loads(json_str)
|
323 |
+
if isinstance(parsed, dict) and "response" in parsed:
|
324 |
+
response_text = parsed.get("response", "")
|
325 |
+
# Optionally format the response text using our helper
|
326 |
+
# formatted_response = format_response_text(response_text)
|
327 |
+
references = parsed.get("references", [])
|
328 |
+
if isinstance(references, list):
|
329 |
+
image_paths = [ref.get("image") for ref in references
|
330 |
+
if ref.get("image") and ref.get("image") != "nan"]
|
331 |
+
else:
|
332 |
+
image_paths = []
|
333 |
+
return response_text, image_paths
|
334 |
+
else:
|
335 |
+
# Fallback if the JSON doesn't have expected structure.
|
336 |
+
return llm_output, []
|
337 |
+
except json.JSONDecodeError:
|
338 |
+
# Fallback: if JSON parsing fails, assume it's plain text.
|
339 |
+
return format_response_text(llm_output), []
|
340 |
+
|
341 |
+
# If not a string, return something sensible.
|
342 |
+
return str(llm_output), []
|
343 |
+
|
344 |
+
|
345 |
+
|
346 |
+
# def process_llm_response(llm_output):
|
347 |
+
# # If it's a string, attempt to extract JSON from markdown code fences.
|
348 |
+
# if isinstance(llm_output, str):
|
349 |
+
# # Try to capture JSON content if it's wrapped in ```json ... ```
|
350 |
+
# pattern = r"```json\s*(\{.*\})\s*```"
|
351 |
+
# match = re.search(pattern, llm_output, re.DOTALL)
|
352 |
+
# if match:
|
353 |
+
# json_str = match.group(1)
|
354 |
+
# else:
|
355 |
+
# json_str = llm_output.strip()
|
356 |
+
# try:
|
357 |
+
# parsed = json.loads(json_str)
|
358 |
+
# # Check if parsed output has the expected keys.
|
359 |
+
# if isinstance(parsed, dict) and "response" in parsed:
|
360 |
+
# response_text = parsed.get("response", "")
|
361 |
+
# references = parsed.get("references", [])
|
362 |
+
# if isinstance(references, list):
|
363 |
+
# image_paths = [ref.get("image") for ref in references
|
364 |
+
# if ref.get("image") and ref.get("image") != "nan"]
|
365 |
+
# else:
|
366 |
+
# image_paths = []
|
367 |
+
# return response_text, image_paths
|
368 |
+
# else:
|
369 |
+
# # Fallback: parsed JSON does not have the expected structure.
|
370 |
+
# return llm_output, []
|
371 |
+
# except json.JSONDecodeError:
|
372 |
+
# # Fallback: if JSON parsing fails, assume it's plain text.
|
373 |
+
# return llm_output, []
|
374 |
|
375 |
+
# # If not a string, ensure we return something sensible.
|
376 |
+
# return str(llm_output), []
|
377 |
+
|
378 |
+
|
379 |
async def generate_response_from_gemini(
|
380 |
sender: str,
|
381 |
content: str,
|
382 |
history: List[Dict[str, str]],
|
383 |
rag_system: Any = None,
|
384 |
+
agentMemory: Any = None,
|
385 |
+
memory:Any = None,
|
386 |
image_file_path: Optional[str] = None,
|
387 |
doc_path: Optional[str] = None,
|
388 |
video_file_path: Optional[str] = None,
|
|
|
400 |
|
401 |
if content:
|
402 |
if rag_system:
|
403 |
+
# keywords = extract_keywords_async(content)
|
404 |
# keywords = []
|
405 |
# logger.info(f"Extracted Keywords: {keywords}")
|
406 |
# Implement RAG: Retrieve relevant documents
|
407 |
+
retrieved_docs = rag_system.retrieve(query_text = content, result_formatter=CustomResultFormatter())
|
408 |
+
|
409 |
+
print(f"retrieved docs: {retrieved_docs}")
|
410 |
if retrieved_docs:
|
411 |
logger.info(f"Retrieved {len(retrieved_docs)} documents for context.")
|
412 |
# Format the retrieved documents as a context string
|
413 |
+
context = "\n\n".join([f"Source:{doc['doc_id']}\nContent: {doc['page_content']}\nImage: {doc['image']}" for doc in retrieved_docs])
|
414 |
+
# img_paths = doc['images_path'] for doc in retrieved_docs
|
415 |
# Option 1: Append to history as a system message
|
416 |
history.append({"role": "user", "parts": f"Relevant documents:\n{context}"})
|
417 |
|
|
|
444 |
|
445 |
# Send the user's message
|
446 |
response = await chat.send_message_async(content)
|
447 |
+
|
448 |
+
print(f"text: {response.text}")
|
449 |
+
|
450 |
+
response_text, image_paths = process_llm_response(response.text)
|
451 |
# response = await handle_function_call(response)
|
452 |
+
# return response.text
|
453 |
+
return response_text, image_paths
|
454 |
|
455 |
except Exception as e:
|
456 |
logger.error("Error in generate_response_from_gemini:", exc_info=True)
|
app/settings.py
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
|
3 |
+
import os
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
|
6 |
+
load_dotenv()
|
7 |
+
from dataclasses import dataclass
|
8 |
+
from typing import Optional
|
9 |
+
|
10 |
+
@dataclass
|
11 |
+
class DatabaseSettings:
|
12 |
+
url: str
|
13 |
+
pool_size: int = 5
|
14 |
+
max_overflow: int = 10
|
15 |
+
pool_timeout: int = 30
|
16 |
+
|
17 |
+
@dataclass
|
18 |
+
class MemorySettings:
|
19 |
+
token_limit: int = 4096
|
20 |
+
token_counter: str = "simple" # "simple" or "tiktoken"
|
21 |
+
model_name: Optional[str] = None #
|
22 |
+
|
23 |
+
|
24 |
+
class Settings:
|
25 |
+
POSTGRES_DB_URL: str = os.getenv("POSTGRES_DB_URL")
|
26 |
+
print(POSTGRES_DB_URL)
|
27 |
+
SQLITE_DB_URL: str = os.getenv("SQLITE_DB_URL")
|
28 |
+
# Add other settings as needed
|
29 |
+
|
30 |
+
settings = Settings()
|
app/utils/load_env.py
CHANGED
@@ -18,6 +18,7 @@ OPENAI_API = os.getenv("OPENAI_API")
|
|
18 |
GEMINI_API = os.getenv("GEMINI_API")
|
19 |
CX_CODE = os.getenv("CX_CODE")
|
20 |
CUSTOM_SEARCH_API_KEY = os.getenv("CUSTOM_SEARCH_API_KEY")
|
|
|
21 |
|
22 |
# Debugging: Print the retrieved ACCESS_TOKEN (for development only)
|
23 |
# if ENV == "development":
|
|
|
18 |
GEMINI_API = os.getenv("GEMINI_API")
|
19 |
CX_CODE = os.getenv("CX_CODE")
|
20 |
CUSTOM_SEARCH_API_KEY = os.getenv("CUSTOM_SEARCH_API_KEY")
|
21 |
+
MEDIA_UPLOAD_URL = os.getenv("WHATSAPP_UPLOAD_MEDIA")
|
22 |
|
23 |
# Debugging: Print the retrieved ACCESS_TOKEN (for development only)
|
24 |
# if ENV == "development":
|
app/utils/system_prompt.py
CHANGED
@@ -1,27 +1,62 @@
|
|
1 |
system_prompt = """
|
2 |
Role and Purpose:
|
3 |
-
You are a virtual assistant focused exclusively on
|
4 |
|
5 |
Tone and Style:
|
6 |
-
Maintain a polite, neutral, and factual tone. Be professional and represent
|
7 |
|
8 |
Content Guidelines:
|
9 |
When asked about your origins or creator, state that you were created by Vidavox.
|
10 |
Context-Driven Responses: Provide answers solely based on the provided Relevant Document context.
|
11 |
-
Focus on Public Services: Prioritize queries on transportation, health, education, permits, safety, and cultural events.
|
12 |
Professional Representation: Avoid personal opinions, judgments, or critiques of the local government. If asked for opinions, explain that your role is to provide factual information rather than subjective viewpoints.
|
13 |
Encourage Verification: For unresolved queries, recommend users consult official resources such as the provided website link.
|
14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
|
16 |
-
Example
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
|
18 |
-
|
19 |
-
If a user says, “I heard there will be a community festival next month, can you tell me more?” you might reply: “Yes, the city's annual cultural festival will be held in [location] starting from [date]. It will feature traditional dance performances, local food vendors, and art exhibitions. For a detailed schedule, please visit the city's official cultural events portal.”
|
20 |
-
If a user asks, “Are there any issues with the city government's policies?” respond factually: “I can provide details on the policies that have been implemented and their stated goals, but I do not offer critiques. To learn more about specific policies and their expected outcomes, you may refer to the official government publications or verified local news outlets.”
|
21 |
-
|
22 |
-
By adhering to these principles, you will ensure professional and reliable communication about Surabaya's permit processes while respecting local languages and cultural nuances.
|
23 |
"""
|
24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
|
26 |
agentic_prompt = """ You are a helpful assistant and have capabilities to search the web.
|
27 |
When you the links are given, you should summarize the content of the link and give a short summary.
|
|
|
1 |
system_prompt = """
|
2 |
Role and Purpose:
|
3 |
+
You are a virtual assistant focused exclusively on coretax, taxation systme in Indonesia. Your primary role is to provide accurate information regarding coretax in the Relevant Document. If you cannot find anything in the Relevant Document, state that you are unsure and direct the user to this website: https://www.pajak.go.id/reformdjp/Coretax/ without bracket or parentheses. You respond only in Bahasa Indonesia.
|
4 |
|
5 |
Tone and Style:
|
6 |
+
Maintain a polite, neutral, and factual tone. Be professional and represent Direktorat Jenderal Pajak accurately without criticism or bias. Always ensure your communication is courteous and focused on providing clear and reliable information.
|
7 |
|
8 |
Content Guidelines:
|
9 |
When asked about your origins or creator, state that you were created by Vidavox.
|
10 |
Context-Driven Responses: Provide answers solely based on the provided Relevant Document context.
|
|
|
11 |
Professional Representation: Avoid personal opinions, judgments, or critiques of the local government. If asked for opinions, explain that your role is to provide factual information rather than subjective viewpoints.
|
12 |
Encourage Verification: For unresolved queries, recommend users consult official resources such as the provided website link.
|
13 |
+
You don't need to say you refer to the relevant document in providing answer.
|
14 |
+
|
15 |
+
Response Guidelines:
|
16 |
+
You'll receive context in the following example: [{'doc_id':doc.csv, 'page_content':'loremipsum..','image':'[images/photo.jgp']}].
|
17 |
+
When you use the context, you should provide response in the following rules.
|
18 |
+
For each response, return a JSON output with two keys:
|
19 |
+
1. "response": Your generated answer to the user, ensuring it does not reference specific metadata like image paths.
|
20 |
+
2. "references": A list of metadata objects containing the document ID and the associated image path.
|
21 |
+
Ensure that the response does not explicitly mention or display image paths.
|
22 |
+
Ensure the response returned in a well formatted format.
|
23 |
|
24 |
+
# Example LLM response
|
25 |
+
{
|
26 |
+
"response": ""# Introduction\nThis is an example.\n\n- Bullet point 1\n- Bullet point 2\n\n## Sub-Topic\nAdditional details...",
|
27 |
+
"references": [
|
28 |
+
{"doc_id": "123", "image": "images/paris.jpg"},
|
29 |
+
{"doc_id": "456", "image": "images/eiffel.jpg"}
|
30 |
+
]
|
31 |
+
}
|
32 |
|
33 |
+
By adhering to these principles, you will ensure professional and reliable communication about coretax system under Direktorat Jenderal Pajak.
|
|
|
|
|
|
|
|
|
34 |
"""
|
35 |
|
36 |
+
# system_prompt = """
|
37 |
+
# Role and Purpose:
|
38 |
+
# You are a virtual assistant focused exclusively on Surabaya, Indonesia. Your primary role is to provide accurate information regarding the permit document provided in the Relevant Document. If you cannot find anything in the Relevant Document, state that you are unsure and direct the user to this website: https://sswalfa.surabaya.go.id/ without bracket or parentheses. You respond only in Bahasa Indonesia. You can reply in Javanese or Maduranese, only if the user talks to you in that language.
|
39 |
+
|
40 |
+
# Tone and Style:
|
41 |
+
# Maintain a polite, neutral, and factual tone. Be professional and represent Surabaya's information accurately without criticism or bias. Always ensure your communication is courteous and focused on providing clear and reliable information.
|
42 |
+
|
43 |
+
# Content Guidelines:
|
44 |
+
# When asked about your origins or creator, state that you were created by Vidavox.
|
45 |
+
# Context-Driven Responses: Provide answers solely based on the provided Relevant Document context.
|
46 |
+
# Focus on Public Services: Prioritize queries on transportation, health, education, permits, safety, and cultural events.
|
47 |
+
# Professional Representation: Avoid personal opinions, judgments, or critiques of the local government. If asked for opinions, explain that your role is to provide factual information rather than subjective viewpoints.
|
48 |
+
# Encourage Verification: For unresolved queries, recommend users consult official resources such as the provided website link.
|
49 |
+
# Always Include Sources: When your response is based on information provided from external sources or Relevant Document, include the source link explicitly without brackets or parentheses at the end of the response. For example: "Informasi ini berasal dari www.indosource.com (without bracket or parentheses) Anda dapat mengunjungi tautan tersebut untuk detail lebih lanjut."
|
50 |
+
|
51 |
+
# Example Interactions:
|
52 |
+
|
53 |
+
# If a user asks, “How is the Mass Rapid Transit project progressing?” you might say: “As of the latest information available, the Surabaya Mass Rapid Transit project is currently in [X] phase, with construction ongoing in [specific districts]. The city's transportation department has announced that the project aims to be operational by [target year]. You can check the official city transportation website for updates.”
|
54 |
+
# If a user says, “I heard there will be a community festival next month, can you tell me more?” you might reply: “Yes, the city's annual cultural festival will be held in [location] starting from [date]. It will feature traditional dance performances, local food vendors, and art exhibitions. For a detailed schedule, please visit the city's official cultural events portal.”
|
55 |
+
# If a user asks, “Are there any issues with the city government's policies?” respond factually: “I can provide details on the policies that have been implemented and their stated goals, but I do not offer critiques. To learn more about specific policies and their expected outcomes, you may refer to the official government publications or verified local news outlets.”
|
56 |
+
|
57 |
+
# By adhering to these principles, you will ensure professional and reliable communication about Surabaya's permit processes while respecting local languages and cultural nuances.
|
58 |
+
# """
|
59 |
+
|
60 |
|
61 |
agentic_prompt = """ You are a helpful assistant and have capabilities to search the web.
|
62 |
When you the links are given, you should summarize the content of the link and give a short summary.
|
app/utils/token_counter.py
CHANGED
@@ -26,4 +26,16 @@ class TokenCounter:
|
|
26 |
del self.doc_tokens[doc_id]
|
27 |
|
28 |
def get_total_tokens(self):
|
29 |
-
return self.total_tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
del self.doc_tokens[doc_id]
|
27 |
|
28 |
def get_total_tokens(self):
|
29 |
+
return self.total_tokens
|
30 |
+
|
31 |
+
class SimpleTokenCounter:
|
32 |
+
def count_tokens(self, text: str) -> int:
|
33 |
+
return len(text.split())
|
34 |
+
|
35 |
+
class TikTokenCounter:
|
36 |
+
def __init__(self, model_name: str = "gpt-4"):
|
37 |
+
import tiktoken
|
38 |
+
self.encoding = tiktoken.encoding_for_model(model_name)
|
39 |
+
|
40 |
+
def count_tokens(self, text: str) -> int:
|
41 |
+
return len(self.encoding.encode(text))
|
docs/Coretax_FAQ.xlsx
ADDED
Binary file (106 kB). View file
|
|
docs/coretax_telegram.csv
ADDED
The diff for this file is too large to render.
See raw diff
|
|
images/photo_10.jpg
ADDED
![]() |
images/photo_107.jpg
ADDED
![]() |
images/photo_108.jpg
ADDED
![]() |
images/photo_11.jpg
ADDED
![]() |
images/photo_112.jpg
ADDED
![]() |
images/photo_12.1.jpg
ADDED
![]() |
images/photo_12.2.jpg
ADDED
![]() |
images/photo_13.jpg
ADDED
![]() |
images/photo_14.jpg
ADDED
![]() |
images/photo_15.1.jpg
ADDED
![]() |
images/photo_15.2.jpg
ADDED
![]() |
images/photo_16.jpg
ADDED
![]() |
images/photo_19.jpg
ADDED
![]() |
images/photo_20.jpg
ADDED
![]() |
images/photo_21.jpg
ADDED
![]() |
images/photo_25.jpg
ADDED
![]() |
images/photo_26.jpg
ADDED
![]() |
images/photo_27.jpg
ADDED
![]() |
images/photo_28.jpg
ADDED
![]() |
images/photo_29.jpg
ADDED
![]() |
images/photo_3.jpg
ADDED
![]() |
images/photo_31.jpg
ADDED
![]() |
images/photo_32.jpg
ADDED
![]() |
images/photo_33.jpg
ADDED
![]() |
images/photo_34.jpg
ADDED
![]() |
images/photo_35.jpg
ADDED
![]() |
images/photo_36.jpg
ADDED
![]() |
images/photo_38.jpg
ADDED
![]() |
images/photo_39.jpg
ADDED
![]() |
images/photo_4.jpg
ADDED
![]() |
images/photo_40.jpg
ADDED
![]() |
images/photo_41.jpg
ADDED
![]() |
images/photo_42.jpg
ADDED
![]() |