Soumik Bose commited on
Commit ·
585bc48
1
Parent(s): 03993cf
go
Browse files- Dockerfile +17 -5
- __pycache__/controller.cpython-311.pyc +0 -0
- __pycache__/extract_csv_metadata_service.cpython-311.pyc +0 -0
- __pycache__/mongo_service.cpython-311.pyc +0 -0
- __pycache__/pydantic_mongo_executor_model.cpython-311.pyc +0 -0
- __pycache__/table_info_model.cpython-311.pyc +0 -0
- controller.py +187 -10
- download_messages_log_helper.py +87 -0
- download_messages_service.py +941 -0
- pydantic_migration_model.py +28 -0
- requirements.txt +4 -0
Dockerfile
CHANGED
|
@@ -3,20 +3,31 @@ FROM python:3.11-slim
|
|
| 3 |
|
| 4 |
# Install system dependencies required for Math/Data libraries
|
| 5 |
# libgomp1 is CRITICAL for scikit-learn & numpy parallelization to prevent segfaults
|
| 6 |
-
RUN apt-get update && \
|
| 7 |
-
apt-get install -y --no-install-recommends \
|
| 8 |
curl \
|
| 9 |
gcc \
|
| 10 |
g++ \
|
| 11 |
libgomp1 \
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
# Set the working directory inside the container
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
# Create required directories with permissions
|
| 18 |
-
RUN
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# Create log files
|
| 22 |
RUN touch /app/pandasai.log /app/api_key_rotation.log && \
|
|
@@ -24,6 +35,7 @@ RUN touch /app/pandasai.log /app/api_key_rotation.log && \
|
|
| 24 |
|
| 25 |
# Set environment variables
|
| 26 |
ENV MPLCONFIGDIR=/app/cache
|
|
|
|
| 27 |
|
| 28 |
# Copy requirements and install
|
| 29 |
COPY requirements.txt .
|
|
|
|
| 3 |
|
| 4 |
# Install system dependencies required for Math/Data libraries
|
| 5 |
# libgomp1 is CRITICAL for scikit-learn & numpy parallelization to prevent segfaults
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
|
| 7 |
curl \
|
| 8 |
gcc \
|
| 9 |
g++ \
|
| 10 |
libgomp1 \
|
| 11 |
+
libpango-1.0-0 \
|
| 12 |
+
libpangoft2-1.0-0 \
|
| 13 |
+
libjpeg62-turbo-dev \
|
| 14 |
+
libopenjp2-7-dev \
|
| 15 |
+
libffi-dev \
|
| 16 |
+
fonts-dejavu \
|
| 17 |
+
fonts-liberation \
|
| 18 |
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
|
| 20 |
# Set the working directory inside the container
|
| 21 |
+
RUN mkdir -p /app/output \
|
| 22 |
+
/app/chat_pdfs \
|
| 23 |
+
/app/generated_charts \
|
| 24 |
+
/app/cache
|
| 25 |
|
| 26 |
# Create required directories with permissions
|
| 27 |
+
RUN chmod -R 777 /app/output \
|
| 28 |
+
/app/chat_pdfs \
|
| 29 |
+
/app/generated_charts \
|
| 30 |
+
/app/cache
|
| 31 |
|
| 32 |
# Create log files
|
| 33 |
RUN touch /app/pandasai.log /app/api_key_rotation.log && \
|
|
|
|
| 35 |
|
| 36 |
# Set environment variables
|
| 37 |
ENV MPLCONFIGDIR=/app/cache
|
| 38 |
+
ENV PYTHONUNBUFFERED=1
|
| 39 |
|
| 40 |
# Copy requirements and install
|
| 41 |
COPY requirements.txt .
|
__pycache__/controller.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/controller.cpython-311.pyc and b/__pycache__/controller.cpython-311.pyc differ
|
|
|
__pycache__/extract_csv_metadata_service.cpython-311.pyc
ADDED
|
Binary file (4.04 kB). View file
|
|
|
__pycache__/mongo_service.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/mongo_service.cpython-311.pyc and b/__pycache__/mongo_service.cpython-311.pyc differ
|
|
|
__pycache__/pydantic_mongo_executor_model.cpython-311.pyc
CHANGED
|
Binary files a/__pycache__/pydantic_mongo_executor_model.cpython-311.pyc and b/__pycache__/pydantic_mongo_executor_model.cpython-311.pyc differ
|
|
|
__pycache__/table_info_model.cpython-311.pyc
ADDED
|
Binary file (1.4 kB). View file
|
|
|
controller.py
CHANGED
|
@@ -37,13 +37,15 @@ import uvicorn
|
|
| 37 |
from csv_analysis_service import execute_analysis_logic
|
| 38 |
from csv_chart_service import execute_python_code
|
| 39 |
from csv_metadata_service import CsvDataRequest, CsvInfoRequest, CsvInfoResponse, PythonExecutionRequest, PythonExecutionResponse, execute_python_logic, get_csv_basic_info, get_robust_csv_rows
|
|
|
|
| 40 |
from extract_csv_metadata_service import extract_csv_metadata_logic
|
| 41 |
from mongo_service import convert_oid, execute_mongo_operation, extract_database_name, sanitize_json_input
|
| 42 |
from pydantic_csv_analysis_model import AnalysisRequest, AnalysisResponse
|
| 43 |
from pydantic_csv_charts_model import ChartExecutionPayload, ChartExecutionResponse
|
|
|
|
| 44 |
from pydantic_mongo_executor_model import ExecutorPayload, ExecutorResponse
|
| 45 |
from report_service import FileBoxProps, ReportRequest, execute_report_generation
|
| 46 |
-
from supabase_service import upload_bytes_to_supabase
|
| 47 |
from table_info_model import CsvFieldsRequest, CsvFieldsResponse
|
| 48 |
|
| 49 |
# --- Configuration & Setup ---
|
|
@@ -367,6 +369,52 @@ async def _execute_async_postgres(db_url: str, sql_query: str, max_rows: int = 2
|
|
| 367 |
}
|
| 368 |
except Exception as e:
|
| 369 |
return {"success": False, "error": str(e), "executionTime": 0.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
# ==============================================================================
|
| 372 |
# ROUTES
|
|
@@ -587,6 +635,144 @@ async def execute_python_endpoint(payload: PythonExecutionRequest, token: str =
|
|
| 587 |
return PythonExecutionResponse(success=execution_result['error'] is None, output=execution_result['output'], result=jsonable_encoder(execution_result['result']), isStructured=execution_result['isStructured'], error=execution_result['error'], request_id=request_id)
|
| 588 |
except Exception as e:
|
| 589 |
raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
|
| 591 |
# --- Batch Handlers ---
|
| 592 |
async def batch_parallel_handler(func, requests: List[Any], token: str):
|
|
@@ -633,15 +819,6 @@ async def root():
|
|
| 633 |
async def ping():
|
| 634 |
return {"message": "I am alive!"}
|
| 635 |
|
| 636 |
-
@app.post("/api/get_csv_fields", response_model=CsvFieldsResponse)
|
| 637 |
-
async def get_csv_fields_endpoint(payload: CsvFieldsRequest, token: str = Depends(validate_token)):
|
| 638 |
-
request_id = str(uuid.uuid4())[:8]
|
| 639 |
-
try:
|
| 640 |
-
fields = await run_in_threadpool(extract_csv_metadata_logic, csv_url=payload.csv_url)
|
| 641 |
-
return CsvFieldsResponse(success=True, fields=fields, request_id=request_id)
|
| 642 |
-
except Exception as e:
|
| 643 |
-
raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
|
| 644 |
-
|
| 645 |
if __name__ == "__main__":
|
| 646 |
host = os.getenv("HOST", "0.0.0.0")
|
| 647 |
port = int(os.getenv("PORT", 7860))
|
|
|
|
| 37 |
from csv_analysis_service import execute_analysis_logic
|
| 38 |
from csv_chart_service import execute_python_code
|
| 39 |
from csv_metadata_service import CsvDataRequest, CsvInfoRequest, CsvInfoResponse, PythonExecutionRequest, PythonExecutionResponse, execute_python_logic, get_csv_basic_info, get_robust_csv_rows
|
| 40 |
+
from download_messages_log_helper import ChatLogRequest, ChatLogResponse, generate_chat_pdf
|
| 41 |
from extract_csv_metadata_service import extract_csv_metadata_logic
|
| 42 |
from mongo_service import convert_oid, execute_mongo_operation, extract_database_name, sanitize_json_input
|
| 43 |
from pydantic_csv_analysis_model import AnalysisRequest, AnalysisResponse
|
| 44 |
from pydantic_csv_charts_model import ChartExecutionPayload, ChartExecutionResponse
|
| 45 |
+
from pydantic_migration_model import MigrationRequest, MigrationResponse
|
| 46 |
from pydantic_mongo_executor_model import ExecutorPayload, ExecutorResponse
|
| 47 |
from report_service import FileBoxProps, ReportRequest, execute_report_generation
|
| 48 |
+
from supabase_service import upload_bytes_to_supabase, upload_file_to_supabase
|
| 49 |
from table_info_model import CsvFieldsRequest, CsvFieldsResponse
|
| 50 |
|
| 51 |
# --- Configuration & Setup ---
|
|
|
|
| 369 |
}
|
| 370 |
except Exception as e:
|
| 371 |
return {"success": False, "error": str(e), "executionTime": 0.0}
|
| 372 |
+
|
| 373 |
+
# ==============================================================================
|
| 374 |
+
# MIGRATION EXECUTORS
|
| 375 |
+
# ==============================================================================
|
| 376 |
+
async def _migrate_postgres(db_url: str, query: str) -> int:
|
| 377 |
+
"""Executes a SQL script (Create + Insert) in Postgres"""
|
| 378 |
+
pool = await async_pool_manager.get_pg_pool(db_url)
|
| 379 |
+
async with pool.acquire() as conn:
|
| 380 |
+
async with conn.transaction():
|
| 381 |
+
# asyncpg can execute scripts with multiple statements using .execute()
|
| 382 |
+
# It returns string like "INSERT 0 100", "CREATE TABLE"
|
| 383 |
+
await conn.execute(query)
|
| 384 |
+
# We estimate rows by counting newlines in values or relying on frontend counts
|
| 385 |
+
# For accurate counts, we'd need to parse the return tag, but 'execute'
|
| 386 |
+
# might run multiple statements.
|
| 387 |
+
return 0 # Row counting is handled by frontend or specific return parsing
|
| 388 |
+
|
| 389 |
+
async def _migrate_mysql(db_url: str, query: str) -> int:
|
| 390 |
+
"""Executes a SQL script in MySQL"""
|
| 391 |
+
pool = await async_pool_manager.get_mysql_pool(db_url)
|
| 392 |
+
async with pool.acquire() as conn:
|
| 393 |
+
async with conn.cursor() as cursor:
|
| 394 |
+
# asyncmy usually expects single statements unless client flags allow multi
|
| 395 |
+
# We will split by ';' for safety or execute raw if configured
|
| 396 |
+
statements = [s.strip() for s in query.split(';') if s.strip()]
|
| 397 |
+
for stmt in statements:
|
| 398 |
+
await cursor.execute(stmt)
|
| 399 |
+
return 0
|
| 400 |
+
|
| 401 |
+
def _migrate_mongo(db_url: str, collection_name: str, data: List[Dict]) -> int:
|
| 402 |
+
"""Bulk Inserts documents into MongoDB"""
|
| 403 |
+
# Use existing extract logic
|
| 404 |
+
db_name = extract_database_name(db_url)
|
| 405 |
+
if not db_name:
|
| 406 |
+
raise ValueError("Could not determine database name from URI")
|
| 407 |
+
|
| 408 |
+
from mongo_service import mongo_manager
|
| 409 |
+
client = mongo_manager.get_client(db_url)
|
| 410 |
+
db = client[db_name]
|
| 411 |
+
collection = db[collection_name]
|
| 412 |
+
|
| 413 |
+
if not data:
|
| 414 |
+
return 0
|
| 415 |
+
|
| 416 |
+
result = collection.insert_many(data)
|
| 417 |
+
return len(result.inserted_ids)
|
| 418 |
|
| 419 |
# ==============================================================================
|
| 420 |
# ROUTES
|
|
|
|
| 635 |
return PythonExecutionResponse(success=execution_result['error'] is None, output=execution_result['output'], result=jsonable_encoder(execution_result['result']), isStructured=execution_result['isStructured'], error=execution_result['error'], request_id=request_id)
|
| 636 |
except Exception as e:
|
| 637 |
raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
|
| 638 |
+
|
| 639 |
+
# --- Migration Route ---
|
| 640 |
+
@app.post("/api/migrate", response_model=MigrationResponse)
|
| 641 |
+
async def execute_migration_endpoint(payload: MigrationRequest, token: str = Depends(validate_token)):
|
| 642 |
+
request_id = str(uuid.uuid4())[:8]
|
| 643 |
+
start_time = time.time()
|
| 644 |
+
|
| 645 |
+
try:
|
| 646 |
+
rows_processed = 0
|
| 647 |
+
|
| 648 |
+
# 1. PostgreSQL Migration
|
| 649 |
+
if payload.db_type == 'postgresql':
|
| 650 |
+
if not payload.migration_query:
|
| 651 |
+
raise ValueError("Migration query is required for SQL databases")
|
| 652 |
+
|
| 653 |
+
clean_url = normalize_postgres_uri(payload.db_url)
|
| 654 |
+
await _migrate_postgres(db_url=clean_url, query=payload.migration_query)
|
| 655 |
+
# Row count is difficult to get exactly from a multi-statement script
|
| 656 |
+
# without complex parsing, usually the frontend knows how much data it sent.
|
| 657 |
+
rows_processed = payload.migration_query.upper().count("VALUES") # Rough estimate or 0
|
| 658 |
+
|
| 659 |
+
# 2. MySQL Migration
|
| 660 |
+
elif payload.db_type == 'mysql':
|
| 661 |
+
if not payload.migration_query:
|
| 662 |
+
raise ValueError("Migration query is required for SQL databases")
|
| 663 |
+
|
| 664 |
+
clean_url = normalize_mysql_uri(payload.db_url)
|
| 665 |
+
# Note: run_in_threadpool might be needed if async driver issues arise,
|
| 666 |
+
# but here we defined async wrappers, so we await directly or use logic
|
| 667 |
+
# Since _migrate_mysql is async, we await it.
|
| 668 |
+
await _migrate_mysql(db_url=clean_url, query=payload.migration_query)
|
| 669 |
+
rows_processed = payload.migration_query.upper().count("VALUES")
|
| 670 |
+
|
| 671 |
+
# 3. MongoDB Migration
|
| 672 |
+
elif payload.db_type == 'mongodb':
|
| 673 |
+
if not payload.migration_data:
|
| 674 |
+
raise ValueError("Migration data (JSON) is required for MongoDB")
|
| 675 |
+
if not payload.collection_name:
|
| 676 |
+
raise ValueError("Collection name is required for MongoDB")
|
| 677 |
+
|
| 678 |
+
# Mongo operations are blocking in pymongo, so run in threadpool
|
| 679 |
+
rows_processed = await run_in_threadpool(
|
| 680 |
+
_migrate_mongo,
|
| 681 |
+
db_url=payload.db_url,
|
| 682 |
+
collection_name=payload.collection_name,
|
| 683 |
+
data=payload.migration_data
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
else:
|
| 687 |
+
raise ValueError(f"Unsupported database type: {payload.db_type}")
|
| 688 |
+
|
| 689 |
+
duration = f"{time.time() - start_time:.2f}s"
|
| 690 |
+
|
| 691 |
+
logger.info(f"Migration Complete: {rows_processed} batches processed in {duration}")
|
| 692 |
+
|
| 693 |
+
return MigrationResponse(
|
| 694 |
+
success=True,
|
| 695 |
+
rowsProcessed=rows_processed,
|
| 696 |
+
duration=duration,
|
| 697 |
+
errors=[],
|
| 698 |
+
request_id=request_id
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
except Exception as e:
|
| 702 |
+
logger.error(f"Migration Failed: {e}")
|
| 703 |
+
duration = f"{time.time() - start_time:.2f}s"
|
| 704 |
+
return MigrationResponse(
|
| 705 |
+
success=False,
|
| 706 |
+
rowsProcessed=0,
|
| 707 |
+
duration=duration,
|
| 708 |
+
errors=[str(e)],
|
| 709 |
+
request_id=request_id
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
# --- CSV Metadata ---
|
| 713 |
+
@app.post("/api/get_csv_fields", response_model=CsvFieldsResponse)
|
| 714 |
+
async def get_csv_fields_endpoint(payload: CsvFieldsRequest, token: str = Depends(validate_token)):
|
| 715 |
+
request_id = str(uuid.uuid4())[:8]
|
| 716 |
+
try:
|
| 717 |
+
fields = await run_in_threadpool(extract_csv_metadata_logic, csv_url=payload.csv_url)
|
| 718 |
+
return CsvFieldsResponse(success=True, fields=fields, request_id=request_id)
|
| 719 |
+
except Exception as e:
|
| 720 |
+
raise HTTPException(status_code=500, detail={"success": False, "error": str(e), "request_id": request_id})
|
| 721 |
+
|
| 722 |
+
# --- Chat Messages Log ---
|
| 723 |
+
@app.post("/api/get_chat_log", response_model=ChatLogResponse)
|
| 724 |
+
async def get_chat_log_endpoint(payload: ChatLogRequest, token: str = Depends(validate_token)):
|
| 725 |
+
request_id = str(uuid.uuid4())[:8]
|
| 726 |
+
generated_pdf_path = None
|
| 727 |
+
|
| 728 |
+
try:
|
| 729 |
+
# 1. Generate the PDF locally
|
| 730 |
+
# run_in_threadpool is crucial here because PDF generation is CPU/IO blocking
|
| 731 |
+
generated_pdf_path = await run_in_threadpool(
|
| 732 |
+
generate_chat_pdf,
|
| 733 |
+
messages=payload.messages,
|
| 734 |
+
title=payload.title,
|
| 735 |
+
chat_id=payload.chatId,
|
| 736 |
+
output_dir="chat_pdfs", # Matches the default in your helper
|
| 737 |
+
include_stats=True
|
| 738 |
+
)
|
| 739 |
+
|
| 740 |
+
if not generated_pdf_path or not os.path.exists(generated_pdf_path):
|
| 741 |
+
raise Exception("PDF generation failed or file not found.")
|
| 742 |
+
|
| 743 |
+
# 2. Upload to Supabase
|
| 744 |
+
# Extract filename from path (e.g., "chat_log_123.pdf")
|
| 745 |
+
filename = os.path.basename(generated_pdf_path)
|
| 746 |
+
|
| 747 |
+
public_url = await run_in_threadpool(
|
| 748 |
+
upload_file_to_supabase,
|
| 749 |
+
file_path=generated_pdf_path,
|
| 750 |
+
file_name=filename,
|
| 751 |
+
chat_id=payload.chatId
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
return ChatLogResponse(
|
| 755 |
+
success=True,
|
| 756 |
+
pdf_url=public_url,
|
| 757 |
+
request_id=request_id,
|
| 758 |
+
message="PDF generated and uploaded successfully."
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
except Exception as e:
|
| 762 |
+
logger.error(f"Chat Log Error [{request_id}]: {str(e)}")
|
| 763 |
+
raise HTTPException(
|
| 764 |
+
status_code=500,
|
| 765 |
+
detail={"success": False, "error": str(e), "request_id": request_id}
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
finally:
|
| 769 |
+
# 3. Cleanup: Delete the local file to save space
|
| 770 |
+
if generated_pdf_path and os.path.exists(generated_pdf_path):
|
| 771 |
+
try:
|
| 772 |
+
os.remove(generated_pdf_path)
|
| 773 |
+
logger.info(f"Cleaned up local PDF: {generated_pdf_path}")
|
| 774 |
+
except Exception as cleanup_error:
|
| 775 |
+
logger.warning(f"Failed to delete local PDF {generated_pdf_path}: {cleanup_error}")
|
| 776 |
|
| 777 |
# --- Batch Handlers ---
|
| 778 |
async def batch_parallel_handler(func, requests: List[Any], token: str):
|
|
|
|
| 819 |
async def ping():
|
| 820 |
return {"message": "I am alive!"}
|
| 821 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
if __name__ == "__main__":
|
| 823 |
host = os.getenv("HOST", "0.0.0.0")
|
| 824 |
port = int(os.getenv("PORT", 7860))
|
download_messages_log_helper.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---------------------------------------------------------
|
| 2 |
+
# HELPER METHOD FOR QUICK PDF GENERATION
|
| 3 |
+
# ---------------------------------------------------------
|
| 4 |
+
from typing import Dict, List, Optional
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import List, Dict, Any, Optional
|
| 9 |
+
|
| 10 |
+
class ChatLogRequest(BaseModel):
|
| 11 |
+
chatId: str = Field(..., description="Unique identifier for the chat session")
|
| 12 |
+
messages: List[Dict[str, Any]] = Field(..., description="List of message objects containing content, role, etc.")
|
| 13 |
+
title: Optional[str] = "Chat Conversation Log"
|
| 14 |
+
|
| 15 |
+
class ChatLogResponse(BaseModel):
|
| 16 |
+
success: bool
|
| 17 |
+
pdf_url: str
|
| 18 |
+
request_id: str
|
| 19 |
+
message: Optional[str] = None
|
| 20 |
+
|
| 21 |
+
from download_messages_service import ChatLogGenerator
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def generate_chat_pdf(messages: List[Dict],
|
| 27 |
+
title: str = "Chat Conversation Log",
|
| 28 |
+
chat_id: str = None,
|
| 29 |
+
output_dir: str = "chat_pdfs",
|
| 30 |
+
participants: List[str] = None,
|
| 31 |
+
date_range: str = None,
|
| 32 |
+
cover_page: bool = False,
|
| 33 |
+
include_stats: bool = True) -> Optional[str]:
|
| 34 |
+
"""
|
| 35 |
+
Quick helper method to generate a chat PDF and return the file path.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
messages: List of message dictionaries
|
| 39 |
+
title: Title of the chat log
|
| 40 |
+
chat_id: Unique chat identifier
|
| 41 |
+
output_dir: Directory to save PDF
|
| 42 |
+
participants: List of participant names
|
| 43 |
+
date_range: Date range of conversation
|
| 44 |
+
cover_page: Whether to include cover page
|
| 45 |
+
include_stats: Whether to include statistics
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Full path to generated PDF file, or None if failed
|
| 49 |
+
|
| 50 |
+
Example:
|
| 51 |
+
messages = [{"role": "user", "content": "Hello", "createdAt": "2026-01-10T09:00:00Z"}]
|
| 52 |
+
pdf_path = generate_chat_pdf(messages, title="My Chat", chat_id="abc123")
|
| 53 |
+
logger.info(f"PDF saved at: {pdf_path}")
|
| 54 |
+
"""
|
| 55 |
+
try:
|
| 56 |
+
logger.info(f"Generating chat PDF: {title}")
|
| 57 |
+
|
| 58 |
+
generator = ChatLogGenerator(
|
| 59 |
+
title=title,
|
| 60 |
+
chat_id=chat_id,
|
| 61 |
+
participants=participants,
|
| 62 |
+
date_range=date_range,
|
| 63 |
+
output_dir=output_dir
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Generate unique filename
|
| 67 |
+
filename = generator._generate_unique_filename()
|
| 68 |
+
|
| 69 |
+
# Generate PDF
|
| 70 |
+
success = generator.generate(
|
| 71 |
+
messages=messages,
|
| 72 |
+
filename=filename,
|
| 73 |
+
cover_page=cover_page,
|
| 74 |
+
include_stats=include_stats
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if success:
|
| 78 |
+
logger.info(f"PDF successfully generated: {filename}")
|
| 79 |
+
return filename
|
| 80 |
+
else:
|
| 81 |
+
logger.error("PDF generation failed")
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.exception(f"Error generating PDF: {e}")
|
| 86 |
+
return None
|
| 87 |
+
|
download_messages_service.py
ADDED
|
@@ -0,0 +1,941 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Professional Chat Message Log PDF Generator
|
| 3 |
+
Generates beautifully formatted PDF reports from chat conversations
|
| 4 |
+
Uses WeasyPrint for superior CSS support and rendering
|
| 5 |
+
Supports file attachments (CSV and Image files)
|
| 6 |
+
"""
|
| 7 |
+
import markdown
|
| 8 |
+
from weasyprint import HTML, CSS
|
| 9 |
+
import requests
|
| 10 |
+
import os
|
| 11 |
+
import tempfile
|
| 12 |
+
import re
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import List, Dict, Optional
|
| 15 |
+
import json
|
| 16 |
+
import random
|
| 17 |
+
import string
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------
|
| 21 |
+
# LOGGING CONFIGURATION
|
| 22 |
+
# ---------------------------------------------------------
|
| 23 |
+
logging.basicConfig(
|
| 24 |
+
level=logging.INFO,
|
| 25 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 26 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 27 |
+
)
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------
|
| 31 |
+
# CHAT LOG THEME - WEASYPRINT OPTIMIZED
|
| 32 |
+
# ---------------------------------------------------------
|
| 33 |
+
CSS_STYLE = """
|
| 34 |
+
@page {
|
| 35 |
+
size: letter;
|
| 36 |
+
margin: 0.75in 0.75in 1in 0.75in;
|
| 37 |
+
|
| 38 |
+
@top-center {
|
| 39 |
+
content: element(header);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
@bottom-center {
|
| 43 |
+
content: element(footer);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
body {
|
| 48 |
+
font-family: 'Helvetica', Arial, sans-serif;
|
| 49 |
+
color: #1a1a1a;
|
| 50 |
+
line-height: 1.6;
|
| 51 |
+
font-size: 11pt;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.header {
|
| 55 |
+
position: running(header);
|
| 56 |
+
font-size: 9pt;
|
| 57 |
+
color: #666;
|
| 58 |
+
border-bottom: 1px solid #ddd;
|
| 59 |
+
padding-bottom: 4px;
|
| 60 |
+
margin-bottom: 20px;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.footer {
|
| 64 |
+
position: running(footer);
|
| 65 |
+
font-size: 9pt;
|
| 66 |
+
color: #666;
|
| 67 |
+
border-top: 1px solid #ddd;
|
| 68 |
+
padding-top: 4px;
|
| 69 |
+
text-align: center;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.cover-page {
|
| 73 |
+
text-align: center;
|
| 74 |
+
padding-top: 3in;
|
| 75 |
+
page-break-after: always;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.cover-title {
|
| 79 |
+
font-size: 32pt;
|
| 80 |
+
font-weight: bold;
|
| 81 |
+
color: #003366;
|
| 82 |
+
margin-bottom: 0.5in;
|
| 83 |
+
letter-spacing: 1px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.cover-subtitle {
|
| 87 |
+
font-size: 18pt;
|
| 88 |
+
color: #0055a5;
|
| 89 |
+
margin-bottom: 1in;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.cover-meta {
|
| 93 |
+
font-size: 14pt;
|
| 94 |
+
color: #666;
|
| 95 |
+
margin-bottom: 0.2in;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.cover-date {
|
| 99 |
+
font-size: 11pt;
|
| 100 |
+
color: #999;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.message-container {
|
| 104 |
+
margin-bottom: 20px;
|
| 105 |
+
page-break-inside: avoid;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.message-user {
|
| 109 |
+
background-color: #ffffff;
|
| 110 |
+
border-left: 0.5px solid #0055a5;
|
| 111 |
+
padding: 16px 20px;
|
| 112 |
+
margin-bottom: 16px;
|
| 113 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.message-assistant {
|
| 117 |
+
background-color: #ffffff;
|
| 118 |
+
border-left: 0.5px solid #003366;
|
| 119 |
+
padding: 16px 20px;
|
| 120 |
+
margin-bottom: 16px;
|
| 121 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.message-header {
|
| 125 |
+
font-weight: bold;
|
| 126 |
+
font-size: 10pt;
|
| 127 |
+
margin-bottom: 8px;
|
| 128 |
+
display: flex;
|
| 129 |
+
justify-content: space-between;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.role-user {
|
| 133 |
+
color: #003366;
|
| 134 |
+
font-size: 11pt;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.role-assistant {
|
| 138 |
+
color: #003366;
|
| 139 |
+
font-size: 11pt;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.timestamp {
|
| 143 |
+
font-size: 9pt;
|
| 144 |
+
color: #999;
|
| 145 |
+
font-weight: normal;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.message-content {
|
| 149 |
+
color: #333;
|
| 150 |
+
font-size: 10pt;
|
| 151 |
+
line-height: 1.5;
|
| 152 |
+
word-wrap: break-word;
|
| 153 |
+
overflow-wrap: break-word;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
a {
|
| 157 |
+
color: #0055a5;
|
| 158 |
+
text-decoration: none;
|
| 159 |
+
word-wrap: break-word;
|
| 160 |
+
overflow-wrap: break-word;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
a:hover {
|
| 164 |
+
text-decoration: underline;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.message-content a {
|
| 168 |
+
font-weight: 500;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.message-divider {
|
| 172 |
+
border-top: 1px solid #ddd;
|
| 173 |
+
margin: 16px 0;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
h1, h2, h3, h4 {
|
| 177 |
+
color: #003366;
|
| 178 |
+
margin-top: 12px;
|
| 179 |
+
margin-bottom: 8px;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
h1 { font-size: 16pt; }
|
| 183 |
+
h2 { font-size: 14pt; color: #0055a5; }
|
| 184 |
+
h3 { font-size: 12pt; }
|
| 185 |
+
h4 { font-size: 11pt; }
|
| 186 |
+
|
| 187 |
+
table {
|
| 188 |
+
width: 100%;
|
| 189 |
+
border-collapse: collapse;
|
| 190 |
+
margin: 12px 0;
|
| 191 |
+
font-size: 9pt;
|
| 192 |
+
table-layout: fixed;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* For tables with many columns, reduce font size */
|
| 196 |
+
table.many-columns {
|
| 197 |
+
font-size: 7pt;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
table.many-columns th,
|
| 201 |
+
table.many-columns td {
|
| 202 |
+
padding: 4px 6px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
th {
|
| 206 |
+
background-color: #003366;
|
| 207 |
+
color: white;
|
| 208 |
+
padding: 8px;
|
| 209 |
+
text-align: left;
|
| 210 |
+
font-weight: bold;
|
| 211 |
+
border: 1px solid #002244;
|
| 212 |
+
word-wrap: break-word;
|
| 213 |
+
overflow-wrap: break-word;
|
| 214 |
+
hyphens: auto;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
td {
|
| 218 |
+
padding: 6px 8px;
|
| 219 |
+
border: 1px solid #ddd;
|
| 220 |
+
word-wrap: break-word;
|
| 221 |
+
overflow-wrap: break-word;
|
| 222 |
+
max-width: 0;
|
| 223 |
+
overflow: hidden;
|
| 224 |
+
hyphens: auto;
|
| 225 |
+
font-size: inherit;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
tr:nth-child(even) {
|
| 229 |
+
background-color: #f8f9fa;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
code {
|
| 233 |
+
background-color: #f0f7ff;
|
| 234 |
+
padding: 2px 6px;
|
| 235 |
+
border-radius: 4px;
|
| 236 |
+
font-family: 'Courier New', monospace;
|
| 237 |
+
font-size: 9pt;
|
| 238 |
+
color: #1a1a1a;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
pre {
|
| 242 |
+
background-color: #f0f7ff;
|
| 243 |
+
border: 1px solid #c7ddff;
|
| 244 |
+
border-left: none;
|
| 245 |
+
padding: 12px;
|
| 246 |
+
font-size: 8pt;
|
| 247 |
+
line-height: 1.4;
|
| 248 |
+
margin: 10px 0;
|
| 249 |
+
border-radius: 6px;
|
| 250 |
+
white-space: pre-wrap;
|
| 251 |
+
word-wrap: break-word;
|
| 252 |
+
overflow-wrap: break-word;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
pre code {
|
| 256 |
+
background-color: transparent;
|
| 257 |
+
padding: 0;
|
| 258 |
+
color: #1a1a1a;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
blockquote {
|
| 262 |
+
border-left: 3px solid #0055a5;
|
| 263 |
+
margin-left: 0;
|
| 264 |
+
padding-left: 12px;
|
| 265 |
+
color: #555;
|
| 266 |
+
font-style: italic;
|
| 267 |
+
background-color: #f8f9fa;
|
| 268 |
+
padding: 8px 8px 8px 12px;
|
| 269 |
+
margin: 10px 0;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
ul, ol {
|
| 273 |
+
margin: 8px 0;
|
| 274 |
+
padding-left: 20px;
|
| 275 |
+
list-style-type: none;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
ul li:before {
|
| 279 |
+
content: "• ";
|
| 280 |
+
color: #0055a5;
|
| 281 |
+
font-weight: bold;
|
| 282 |
+
display: inline-block;
|
| 283 |
+
width: 1em;
|
| 284 |
+
margin-left: -1em;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
li {
|
| 288 |
+
margin: 4px 0;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.figure-container {
|
| 292 |
+
text-align: center;
|
| 293 |
+
margin: 16px 0;
|
| 294 |
+
page-break-inside: avoid;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.figure-container img {
|
| 298 |
+
max-width: 100%;
|
| 299 |
+
max-height: 400px;
|
| 300 |
+
height: auto;
|
| 301 |
+
border: 1px solid #ddd;
|
| 302 |
+
padding: 4px;
|
| 303 |
+
background: white;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.figure-caption {
|
| 307 |
+
font-size: 9pt;
|
| 308 |
+
color: #666;
|
| 309 |
+
margin-top: 6px;
|
| 310 |
+
font-style: italic;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.file-item {
|
| 314 |
+
margin-bottom: 12px;
|
| 315 |
+
padding: 12px;
|
| 316 |
+
background-color: #f8f9fa;
|
| 317 |
+
border-left: 3px solid #0055a5;
|
| 318 |
+
page-break-inside: avoid;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.file-label {
|
| 322 |
+
color: #003366;
|
| 323 |
+
font-weight: bold;
|
| 324 |
+
font-size: 9pt;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.file-value {
|
| 328 |
+
color: #333;
|
| 329 |
+
font-size: 9pt;
|
| 330 |
+
word-wrap: break-word;
|
| 331 |
+
overflow-wrap: break-word;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.file-url {
|
| 335 |
+
color: #0055a5;
|
| 336 |
+
font-size: 7pt;
|
| 337 |
+
font-family: 'Courier New', monospace;
|
| 338 |
+
display: block;
|
| 339 |
+
padding: 6px 8px;
|
| 340 |
+
background-color: #f0f7ff;
|
| 341 |
+
border-radius: 3px;
|
| 342 |
+
margin-top: 4px;
|
| 343 |
+
word-wrap: break-word;
|
| 344 |
+
overflow-wrap: break-word;
|
| 345 |
+
line-height: 1.5;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.file-url a {
|
| 349 |
+
color: #0055a5;
|
| 350 |
+
word-wrap: break-word;
|
| 351 |
+
overflow-wrap: break-word;
|
| 352 |
+
font-size: 7pt;
|
| 353 |
+
font-family: 'Courier New', monospace;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.stats-summary {
|
| 357 |
+
margin: 30px 0 20px 0;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.stats-summary p {
|
| 361 |
+
margin: 0;
|
| 362 |
+
padding: 0;
|
| 363 |
+
font-size: 10pt;
|
| 364 |
+
color: #666;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.stats-summary strong {
|
| 368 |
+
color: #003366;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.section-divider {
|
| 372 |
+
border: none;
|
| 373 |
+
border-top: 2px solid #0055a5;
|
| 374 |
+
margin: 20px 0 30px 0;
|
| 375 |
+
}
|
| 376 |
+
"""
|
| 377 |
+
|
| 378 |
+
# ---------------------------------------------------------
|
| 379 |
+
# CHAT LOG GENERATOR CLASS
|
| 380 |
+
# ---------------------------------------------------------
|
| 381 |
+
class ChatLogGenerator:
|
| 382 |
+
"""
|
| 383 |
+
Generates professional PDF reports from chat message logs.
|
| 384 |
+
Supports multiple JSON formats (createdAt, timestamp, chatId, chat_id, etc.)
|
| 385 |
+
"""
|
| 386 |
+
|
| 387 |
+
def __init__(self,
|
| 388 |
+
title: str = "Chat Conversation Log",
|
| 389 |
+
chat_id: str = None,
|
| 390 |
+
participants: List[str] = None,
|
| 391 |
+
date_range: str = None,
|
| 392 |
+
confidential: bool = False,
|
| 393 |
+
output_dir: str = "chat_pdfs"):
|
| 394 |
+
"""
|
| 395 |
+
Initialize the chat log generator.
|
| 396 |
+
|
| 397 |
+
Args:
|
| 398 |
+
title: Title of the chat log report
|
| 399 |
+
chat_id: Unique identifier for the chat session
|
| 400 |
+
participants: List of participant names
|
| 401 |
+
date_range: Date range of the conversation
|
| 402 |
+
confidential: Whether to mark as confidential
|
| 403 |
+
output_dir: Directory to save PDF files
|
| 404 |
+
"""
|
| 405 |
+
self.title = title
|
| 406 |
+
self.chat_id = chat_id
|
| 407 |
+
self.participants = participants or ["User", "Assistant"]
|
| 408 |
+
self.date_range = date_range
|
| 409 |
+
self.confidential = confidential
|
| 410 |
+
self.output_dir = output_dir
|
| 411 |
+
|
| 412 |
+
# Create output directory if it doesn't exist
|
| 413 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 414 |
+
logger.info(f"ChatLogGenerator initialized - output_dir: {self.output_dir}")
|
| 415 |
+
|
| 416 |
+
def _get_timestamp_from_message(self, message: Dict) -> str:
|
| 417 |
+
"""
|
| 418 |
+
Extract timestamp from message, checking multiple possible field names.
|
| 419 |
+
Supports: timestamp, createdAt, created_at
|
| 420 |
+
"""
|
| 421 |
+
# Try different field names
|
| 422 |
+
for field in ['timestamp', 'createdAt', 'created_at']:
|
| 423 |
+
if field in message and message[field]:
|
| 424 |
+
return message[field]
|
| 425 |
+
|
| 426 |
+
# Return empty string if no timestamp found
|
| 427 |
+
return ''
|
| 428 |
+
|
| 429 |
+
def _generate_unique_filename(self, base_name: str = "chat_log") -> str:
|
| 430 |
+
"""
|
| 431 |
+
Generate a unique filename with random number.
|
| 432 |
+
|
| 433 |
+
Args:
|
| 434 |
+
base_name: Base name for the file
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
Full path to the PDF file
|
| 438 |
+
"""
|
| 439 |
+
# Generate random 8-digit number
|
| 440 |
+
random_num = ''.join(random.choices(string.digits, k=8))
|
| 441 |
+
|
| 442 |
+
# Get current timestamp
|
| 443 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 444 |
+
|
| 445 |
+
# Create filename
|
| 446 |
+
filename = f"{base_name}_{timestamp}_{random_num}.pdf"
|
| 447 |
+
|
| 448 |
+
# Return full path
|
| 449 |
+
filepath = os.path.join(self.output_dir, filename)
|
| 450 |
+
logger.debug(f"Generated unique filename: {filepath}")
|
| 451 |
+
return filepath
|
| 452 |
+
|
| 453 |
+
def _format_timestamp(self, timestamp: str) -> str:
|
| 454 |
+
"""
|
| 455 |
+
Format ISO timestamp to readable format.
|
| 456 |
+
Handles both with and without timezone info.
|
| 457 |
+
"""
|
| 458 |
+
if not timestamp or timestamp.strip() == '':
|
| 459 |
+
return "No timestamp"
|
| 460 |
+
|
| 461 |
+
try:
|
| 462 |
+
# Try parsing with timezone first
|
| 463 |
+
if '+' in timestamp or timestamp.endswith('Z'):
|
| 464 |
+
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
| 465 |
+
else:
|
| 466 |
+
# Try parsing without timezone
|
| 467 |
+
dt = datetime.fromisoformat(timestamp)
|
| 468 |
+
|
| 469 |
+
return dt.strftime("%B %d, %Y at %I:%M:%S %p")
|
| 470 |
+
except Exception as e:
|
| 471 |
+
logger.warning(f"Failed to parse timestamp: {timestamp} - {e}")
|
| 472 |
+
return timestamp if timestamp else "No timestamp"
|
| 473 |
+
|
| 474 |
+
def _convert_markdown_with_inline_images(self, content: str) -> str:
|
| 475 |
+
"""
|
| 476 |
+
Convert markdown to HTML while preserving inline image positions.
|
| 477 |
+
Images will appear exactly where they're referenced.
|
| 478 |
+
Standalone image URLs are hidden from display.
|
| 479 |
+
Auto-converts URLs to "Click to View" links.
|
| 480 |
+
"""
|
| 481 |
+
# Pattern to match markdown images: 
|
| 482 |
+
image_pattern = r'!\[([^\]]*)\]\(([^\)]+)\)'
|
| 483 |
+
|
| 484 |
+
# Find all image URLs that will be converted to <img> tags
|
| 485 |
+
image_urls = set()
|
| 486 |
+
for match in re.finditer(image_pattern, content):
|
| 487 |
+
image_urls.add(match.group(2))
|
| 488 |
+
|
| 489 |
+
# Split content by images while keeping the images
|
| 490 |
+
parts = re.split(f'({image_pattern})', content)
|
| 491 |
+
|
| 492 |
+
html_output = ""
|
| 493 |
+
i = 0
|
| 494 |
+
|
| 495 |
+
while i < len(parts):
|
| 496 |
+
part = parts[i]
|
| 497 |
+
|
| 498 |
+
# Check if this part is an image (starts with ![)
|
| 499 |
+
if part.startswith('!['):
|
| 500 |
+
# Extract alt text and URL from the matched groups
|
| 501 |
+
match = re.match(image_pattern, part)
|
| 502 |
+
if match:
|
| 503 |
+
alt_text = match.group(1) or "Figure"
|
| 504 |
+
url = match.group(2)
|
| 505 |
+
|
| 506 |
+
# Add the image HTML inline
|
| 507 |
+
html_output += f"""
|
| 508 |
+
<div class="figure-container">
|
| 509 |
+
<img src="{url}" alt="{alt_text}">
|
| 510 |
+
<div class="figure-caption">{alt_text}</div>
|
| 511 |
+
</div>
|
| 512 |
+
"""
|
| 513 |
+
i += 1
|
| 514 |
+
else:
|
| 515 |
+
# Regular text content - process URLs and hide standalone image URLs
|
| 516 |
+
if part.strip():
|
| 517 |
+
# Remove standalone image URLs that appear on their own lines
|
| 518 |
+
processed_part = self._hide_standalone_image_urls(part, image_urls)
|
| 519 |
+
|
| 520 |
+
# Convert URLs to "Click to View" links BEFORE markdown conversion
|
| 521 |
+
processed_part = self._convert_urls_to_links(processed_part)
|
| 522 |
+
|
| 523 |
+
# Convert markdown to HTML
|
| 524 |
+
html_output += markdown.markdown(
|
| 525 |
+
processed_part,
|
| 526 |
+
extensions=['extra', 'sane_lists', 'tables', 'fenced_code', 'nl2br']
|
| 527 |
+
)
|
| 528 |
+
i += 1
|
| 529 |
+
|
| 530 |
+
# Post-process tables to add responsive class for wide tables
|
| 531 |
+
html_output = self._make_tables_responsive(html_output)
|
| 532 |
+
|
| 533 |
+
return html_output
|
| 534 |
+
|
| 535 |
+
def _make_tables_responsive(self, html: str) -> str:
|
| 536 |
+
"""
|
| 537 |
+
Add 'many-columns' class to tables with more than 10 columns.
|
| 538 |
+
This reduces font size and padding for better fit.
|
| 539 |
+
"""
|
| 540 |
+
# Pattern to find tables
|
| 541 |
+
table_pattern = r'<table>(.*?)</table>'
|
| 542 |
+
|
| 543 |
+
def process_table(match):
|
| 544 |
+
table_content = match.group(1)
|
| 545 |
+
|
| 546 |
+
# Count columns by finding <th> tags in the first row
|
| 547 |
+
th_count = table_content.count('<th>')
|
| 548 |
+
|
| 549 |
+
# If more than 10 columns, add the many-columns class
|
| 550 |
+
if th_count > 10:
|
| 551 |
+
return f'<table class="many-columns">{table_content}</table>'
|
| 552 |
+
else:
|
| 553 |
+
return match.group(0)
|
| 554 |
+
|
| 555 |
+
return re.sub(table_pattern, process_table, html, flags=re.DOTALL)
|
| 556 |
+
|
| 557 |
+
def _convert_urls_to_links(self, text: str) -> str:
|
| 558 |
+
"""
|
| 559 |
+
Convert standalone URLs (http:// or https://) to "Click to View" links.
|
| 560 |
+
"""
|
| 561 |
+
lines = text.split('\n')
|
| 562 |
+
processed_lines = []
|
| 563 |
+
|
| 564 |
+
for line in lines:
|
| 565 |
+
stripped = line.strip()
|
| 566 |
+
|
| 567 |
+
# Check if line starts with http:// or https://
|
| 568 |
+
if stripped.startswith('http://') or stripped.startswith('https://'):
|
| 569 |
+
# Check if it's already a markdown link [text](url)
|
| 570 |
+
if not line.strip().startswith('['):
|
| 571 |
+
# Convert to "Click to View" link
|
| 572 |
+
processed_lines.append(f'<a href="{stripped}" style="color: #0055a5; text-decoration: underline; font-weight: bold;">Click to View</a>')
|
| 573 |
+
else:
|
| 574 |
+
processed_lines.append(line)
|
| 575 |
+
else:
|
| 576 |
+
# Check for URLs within the line
|
| 577 |
+
# Pattern to find URLs not already in markdown links
|
| 578 |
+
url_pattern = r'(?<!\])\(?(https?://[^\s\)]+)\)?(?!\])'
|
| 579 |
+
|
| 580 |
+
def replace_url(match):
|
| 581 |
+
url = match.group(1)
|
| 582 |
+
return f'<a href="{url}" style="color: #0055a5; text-decoration: underline; font-weight: bold;">Click to View</a>'
|
| 583 |
+
|
| 584 |
+
processed_line = re.sub(url_pattern, replace_url, line)
|
| 585 |
+
processed_lines.append(processed_line)
|
| 586 |
+
|
| 587 |
+
return '\n'.join(processed_lines)
|
| 588 |
+
|
| 589 |
+
def _hide_standalone_image_urls(self, text: str, image_urls: set) -> str:
|
| 590 |
+
"""
|
| 591 |
+
Hide standalone image URLs that are already displayed as images.
|
| 592 |
+
Keep other HTTP links intact.
|
| 593 |
+
"""
|
| 594 |
+
lines = text.split('\n')
|
| 595 |
+
processed_lines = []
|
| 596 |
+
|
| 597 |
+
for line in lines:
|
| 598 |
+
stripped = line.strip()
|
| 599 |
+
|
| 600 |
+
# Check if this line is ONLY an image URL (already shown as image)
|
| 601 |
+
if stripped in image_urls:
|
| 602 |
+
# Skip this line entirely (don't add it)
|
| 603 |
+
continue
|
| 604 |
+
|
| 605 |
+
# Check if line is just a URL with optional markdown link wrapper
|
| 606 |
+
# Pattern: https://... or [text](https://...)
|
| 607 |
+
url_only_pattern = r'^<?https?://[^\s>]+>?$'
|
| 608 |
+
if re.match(url_only_pattern, stripped):
|
| 609 |
+
# Keep the line as is
|
| 610 |
+
processed_lines.append(line)
|
| 611 |
+
else:
|
| 612 |
+
processed_lines.append(line)
|
| 613 |
+
|
| 614 |
+
return '\n'.join(processed_lines)
|
| 615 |
+
|
| 616 |
+
def _build_files_section(self, files_data: Dict) -> str:
|
| 617 |
+
"""
|
| 618 |
+
Build HTML section for CSV and image files.
|
| 619 |
+
Shows "Click to View" as clickable link for URLs.
|
| 620 |
+
"""
|
| 621 |
+
if not files_data:
|
| 622 |
+
return ""
|
| 623 |
+
|
| 624 |
+
html = '<div style="margin: 30px 0; page-break-inside: avoid;">'
|
| 625 |
+
html += '<h2 style="color: #003366; border-bottom: 2px solid #0055a5; padding-bottom: 8px; margin-bottom: 20px;">Attached Files</h2>'
|
| 626 |
+
|
| 627 |
+
# CSV Files section
|
| 628 |
+
csv_files = files_data.get('csv_files', [])
|
| 629 |
+
if csv_files and len(csv_files) > 0:
|
| 630 |
+
html += '<h3 style="color: #0055a5; margin-top: 20px; margin-bottom: 12px;">CSV Files:</h3>'
|
| 631 |
+
|
| 632 |
+
for csv_file in csv_files:
|
| 633 |
+
file_name = csv_file.get('fileName', 'Unknown')
|
| 634 |
+
file_path = csv_file.get('filePath', 'Unknown')
|
| 635 |
+
|
| 636 |
+
# Check if it's a URL
|
| 637 |
+
is_url = file_path.startswith('http://') or file_path.startswith('https://')
|
| 638 |
+
|
| 639 |
+
html += f'''
|
| 640 |
+
<div class="file-item">
|
| 641 |
+
<div><span class="file-label">File Name:</span> <span class="file-value">{file_name}</span></div>
|
| 642 |
+
<div style="margin-top: 6px;"><span class="file-label">File URL:</span></div>
|
| 643 |
+
'''
|
| 644 |
+
|
| 645 |
+
if is_url:
|
| 646 |
+
# Show clickable "Click to View" link
|
| 647 |
+
html += f'''
|
| 648 |
+
<div style="margin-top: 2px;">
|
| 649 |
+
<a href="{file_path}" style="color: #0055a5; text-decoration: underline; font-weight: bold; font-size: 9pt;">Click to View</a>
|
| 650 |
+
</div>
|
| 651 |
+
'''
|
| 652 |
+
else:
|
| 653 |
+
# Just show the text
|
| 654 |
+
html += f'''
|
| 655 |
+
<div class="file-value" style="margin-top: 2px;">{file_path}</div>
|
| 656 |
+
'''
|
| 657 |
+
|
| 658 |
+
html += f'''
|
| 659 |
+
<div style="margin-top: 6px;"><span class="file-label">File Type:</span> <span class="file-value">CSV</span></div>
|
| 660 |
+
</div>
|
| 661 |
+
'''
|
| 662 |
+
|
| 663 |
+
# Image Files section
|
| 664 |
+
image_files = files_data.get('image_files', [])
|
| 665 |
+
if image_files and len(image_files) > 0:
|
| 666 |
+
html += '<h3 style="color: #0055a5; margin-top: 20px; margin-bottom: 12px;">Image Files:</h3>'
|
| 667 |
+
|
| 668 |
+
for img_file in image_files:
|
| 669 |
+
file_name = img_file.get('fileName', 'Unknown')
|
| 670 |
+
file_path = img_file.get('filePath', 'Unknown')
|
| 671 |
+
|
| 672 |
+
# Check if it's a URL
|
| 673 |
+
is_url = file_path.startswith('http://') or file_path.startswith('https://')
|
| 674 |
+
|
| 675 |
+
html += f'''
|
| 676 |
+
<div class="file-item">
|
| 677 |
+
<div><span class="file-label">File Name:</span> <span class="file-value">{file_name}</span></div>
|
| 678 |
+
<div style="margin-top: 6px;"><span class="file-label">File URL:</span></div>
|
| 679 |
+
'''
|
| 680 |
+
|
| 681 |
+
if is_url:
|
| 682 |
+
# Show clickable "Click to View" link
|
| 683 |
+
html += f'''
|
| 684 |
+
<div style="margin-top: 2px;">
|
| 685 |
+
<a href="{file_path}" style="color: #0055a5; text-decoration: underline; font-weight: bold; font-size: 9pt;">Click to View</a>
|
| 686 |
+
</div>
|
| 687 |
+
'''
|
| 688 |
+
else:
|
| 689 |
+
# Just show the text
|
| 690 |
+
html += f'''
|
| 691 |
+
<div class="file-value" style="margin-top: 2px;">{file_path}</div>
|
| 692 |
+
'''
|
| 693 |
+
|
| 694 |
+
html += f'''
|
| 695 |
+
<div style="margin-top: 6px;"><span class="file-label">File Type:</span> <span class="file-value">Image</span></div>
|
| 696 |
+
</div>
|
| 697 |
+
'''
|
| 698 |
+
|
| 699 |
+
html += '</div>'
|
| 700 |
+
html += '<hr class="section-divider">'
|
| 701 |
+
|
| 702 |
+
return html
|
| 703 |
+
|
| 704 |
+
def _build_message_html(self, message: Dict) -> str:
|
| 705 |
+
"""
|
| 706 |
+
Build HTML for a single message with inline images.
|
| 707 |
+
"""
|
| 708 |
+
role = message.get('role', message.get('Role', 'user'))
|
| 709 |
+
content = message.get('content', message.get('Content', ''))
|
| 710 |
+
|
| 711 |
+
# Get timestamp from any available field
|
| 712 |
+
timestamp = self._get_timestamp_from_message(message)
|
| 713 |
+
|
| 714 |
+
# Get image if present
|
| 715 |
+
direct_image = message.get('image', message.get('Image', ''))
|
| 716 |
+
|
| 717 |
+
# Process markdown with images inline
|
| 718 |
+
html_content = self._convert_markdown_with_inline_images(content)
|
| 719 |
+
|
| 720 |
+
# Determine styling
|
| 721 |
+
container_class = "message-user" if role == "user" else "message-assistant"
|
| 722 |
+
role_class = "role-user" if role == "user" else "role-assistant"
|
| 723 |
+
role_display = "User" if role == "user" else "Assistant"
|
| 724 |
+
|
| 725 |
+
formatted_time = self._format_timestamp(timestamp)
|
| 726 |
+
|
| 727 |
+
# Build message HTML
|
| 728 |
+
html = f"""
|
| 729 |
+
<div class="message-container">
|
| 730 |
+
<div class="{container_class}">
|
| 731 |
+
<div class="message-header">
|
| 732 |
+
<span class="{role_class}">{role_display}</span>
|
| 733 |
+
<span class="timestamp">{formatted_time}</span>
|
| 734 |
+
</div>
|
| 735 |
+
<div class="message-content">
|
| 736 |
+
{html_content}
|
| 737 |
+
</div>
|
| 738 |
+
</div>
|
| 739 |
+
"""
|
| 740 |
+
|
| 741 |
+
# Add direct image attachment if present (at the end)
|
| 742 |
+
if direct_image:
|
| 743 |
+
html += f"""
|
| 744 |
+
<div class="figure-container">
|
| 745 |
+
<img src="{direct_image}" alt="Attached image">
|
| 746 |
+
<div class="figure-caption">Attached Image</div>
|
| 747 |
+
</div>
|
| 748 |
+
"""
|
| 749 |
+
|
| 750 |
+
html += "</div>"
|
| 751 |
+
|
| 752 |
+
return html
|
| 753 |
+
|
| 754 |
+
def _build_header(self) -> str:
|
| 755 |
+
"""Build the report header."""
|
| 756 |
+
header_text = self.title
|
| 757 |
+
if self.chat_id:
|
| 758 |
+
header_text += f" | {self.chat_id}"
|
| 759 |
+
|
| 760 |
+
return f"""
|
| 761 |
+
<div class="header">
|
| 762 |
+
{header_text}
|
| 763 |
+
</div>
|
| 764 |
+
"""
|
| 765 |
+
|
| 766 |
+
def _build_footer(self) -> str:
|
| 767 |
+
"""Build the report footer with page number."""
|
| 768 |
+
return """
|
| 769 |
+
<div class="footer">
|
| 770 |
+
Page <span class="page-number"></span>
|
| 771 |
+
</div>
|
| 772 |
+
"""
|
| 773 |
+
|
| 774 |
+
def _build_cover_page(self, message_count: int) -> str:
|
| 775 |
+
"""Build a professional cover page."""
|
| 776 |
+
current_date = datetime.now().strftime("%B %d, %Y")
|
| 777 |
+
|
| 778 |
+
cover_html = f"""
|
| 779 |
+
<div class="cover-page">
|
| 780 |
+
<div class="cover-title">{self.title}</div>
|
| 781 |
+
"""
|
| 782 |
+
|
| 783 |
+
if self.date_range:
|
| 784 |
+
cover_html += f'<div class="cover-subtitle">{self.date_range}</div>'
|
| 785 |
+
|
| 786 |
+
if self.chat_id:
|
| 787 |
+
cover_html += f'<div class="cover-meta">Chat ID: {self.chat_id}</div>'
|
| 788 |
+
|
| 789 |
+
cover_html += f'<div class="cover-meta">Total Messages: {message_count}</div>'
|
| 790 |
+
|
| 791 |
+
if self.participants and len(self.participants) > 0:
|
| 792 |
+
participants_str = ", ".join(self.participants)
|
| 793 |
+
cover_html += f'<div class="cover-meta">Participants: {participants_str}</div>'
|
| 794 |
+
|
| 795 |
+
cover_html += f'<div class="cover-date">Generated on {current_date}</div>'
|
| 796 |
+
cover_html += '</div>'
|
| 797 |
+
|
| 798 |
+
logger.debug("Built cover page")
|
| 799 |
+
return cover_html
|
| 800 |
+
|
| 801 |
+
def _build_stats_summary(self, messages: List[Dict]) -> str:
|
| 802 |
+
"""Build a compact statistics summary with horizontal line."""
|
| 803 |
+
total = len(messages)
|
| 804 |
+
user_count = sum(1 for m in messages if m.get('role', m.get('Role')) == 'user')
|
| 805 |
+
assistant_count = sum(1 for m in messages if m.get('role', m.get('Role')) == 'assistant')
|
| 806 |
+
|
| 807 |
+
# Get date range from messages - check all possible timestamp fields
|
| 808 |
+
timestamps = []
|
| 809 |
+
for m in messages:
|
| 810 |
+
ts = self._get_timestamp_from_message(m)
|
| 811 |
+
if ts:
|
| 812 |
+
timestamps.append(ts)
|
| 813 |
+
|
| 814 |
+
if timestamps:
|
| 815 |
+
try:
|
| 816 |
+
dates = []
|
| 817 |
+
for ts in timestamps:
|
| 818 |
+
if '+' in ts or ts.endswith('Z'):
|
| 819 |
+
dates.append(datetime.fromisoformat(ts.replace('Z', '+00:00')))
|
| 820 |
+
else:
|
| 821 |
+
dates.append(datetime.fromisoformat(ts))
|
| 822 |
+
|
| 823 |
+
first_date = min(dates).strftime("%B %d, %Y")
|
| 824 |
+
last_date = max(dates).strftime("%B %d, %Y")
|
| 825 |
+
date_info = f"{first_date} to {last_date}"
|
| 826 |
+
except Exception as e:
|
| 827 |
+
logger.warning(f"Failed to parse date range: {e}")
|
| 828 |
+
date_info = "Date unavailable"
|
| 829 |
+
else:
|
| 830 |
+
date_info = "Date unavailable"
|
| 831 |
+
|
| 832 |
+
logger.info(f"Stats - Total: {total}, User: {user_count}, Assistant: {assistant_count}")
|
| 833 |
+
|
| 834 |
+
return f"""
|
| 835 |
+
<div class="stats-summary">
|
| 836 |
+
<p>
|
| 837 |
+
<strong>Total Messages:</strong> {total} |
|
| 838 |
+
<strong>User:</strong> {user_count} |
|
| 839 |
+
<strong>Assistant:</strong> {assistant_count} |
|
| 840 |
+
<strong>Period:</strong> {date_info}
|
| 841 |
+
</p>
|
| 842 |
+
</div>
|
| 843 |
+
<hr class="section-divider">
|
| 844 |
+
"""
|
| 845 |
+
|
| 846 |
+
def generate_from_json(self,
|
| 847 |
+
json_data: str,
|
| 848 |
+
filename: str = None,
|
| 849 |
+
cover_page: bool = True,
|
| 850 |
+
include_stats: bool = True) -> bool:
|
| 851 |
+
"""
|
| 852 |
+
Generate PDF from JSON string.
|
| 853 |
+
"""
|
| 854 |
+
try:
|
| 855 |
+
messages = json.loads(json_data)
|
| 856 |
+
logger.info("Successfully parsed JSON data")
|
| 857 |
+
return self.generate(messages, filename, cover_page, include_stats)
|
| 858 |
+
except json.JSONDecodeError as e:
|
| 859 |
+
logger.error(f"Invalid JSON: {e}")
|
| 860 |
+
return False
|
| 861 |
+
|
| 862 |
+
def generate(self,
|
| 863 |
+
messages: List[Dict],
|
| 864 |
+
filename: str = None,
|
| 865 |
+
cover_page: bool = True,
|
| 866 |
+
include_stats: bool = True,
|
| 867 |
+
files: Dict = None) -> bool:
|
| 868 |
+
"""
|
| 869 |
+
Generate the PDF chat log using WeasyPrint.
|
| 870 |
+
"""
|
| 871 |
+
try:
|
| 872 |
+
if not messages or len(messages) == 0:
|
| 873 |
+
logger.error("No messages to process")
|
| 874 |
+
return False
|
| 875 |
+
|
| 876 |
+
logger.info(f"Starting PDF generation for {len(messages)} messages")
|
| 877 |
+
|
| 878 |
+
# Generate unique filename if not provided
|
| 879 |
+
if filename is None:
|
| 880 |
+
filename = self._generate_unique_filename()
|
| 881 |
+
else:
|
| 882 |
+
# If filename provided without path, put it in output_dir
|
| 883 |
+
if not os.path.dirname(filename):
|
| 884 |
+
filename = os.path.join(self.output_dir, filename)
|
| 885 |
+
|
| 886 |
+
# Build report body
|
| 887 |
+
report_body = ""
|
| 888 |
+
|
| 889 |
+
# Add cover page
|
| 890 |
+
if cover_page:
|
| 891 |
+
report_body += self._build_cover_page(len(messages))
|
| 892 |
+
|
| 893 |
+
# Add statistics summary with horizontal line
|
| 894 |
+
if include_stats:
|
| 895 |
+
report_body += self._build_stats_summary(messages)
|
| 896 |
+
|
| 897 |
+
# Add files section if provided
|
| 898 |
+
if files:
|
| 899 |
+
report_body += self._build_files_section(files)
|
| 900 |
+
|
| 901 |
+
# Add all messages
|
| 902 |
+
for i, message in enumerate(messages):
|
| 903 |
+
report_body += self._build_message_html(message)
|
| 904 |
+
|
| 905 |
+
# Add divider between messages (except last)
|
| 906 |
+
if i < len(messages) - 1:
|
| 907 |
+
report_body += '<div class="message-divider"></div>'
|
| 908 |
+
|
| 909 |
+
# Assemble full HTML
|
| 910 |
+
full_html = f"""
|
| 911 |
+
<!DOCTYPE html>
|
| 912 |
+
<html>
|
| 913 |
+
<head>
|
| 914 |
+
<meta charset="UTF-8">
|
| 915 |
+
<style>
|
| 916 |
+
{CSS_STYLE}
|
| 917 |
+
.page-number:before {{ content: counter(page); }}
|
| 918 |
+
</style>
|
| 919 |
+
</head>
|
| 920 |
+
<body>
|
| 921 |
+
{self._build_header()}
|
| 922 |
+
{self._build_footer()}
|
| 923 |
+
{report_body}
|
| 924 |
+
</body>
|
| 925 |
+
</html>
|
| 926 |
+
"""
|
| 927 |
+
|
| 928 |
+
# Generate PDF with WeasyPrint
|
| 929 |
+
logger.info(f"Writing PDF to: {filename}")
|
| 930 |
+
|
| 931 |
+
# Create HTML object and render to PDF
|
| 932 |
+
html_obj = HTML(string=full_html)
|
| 933 |
+
html_obj.write_pdf(filename)
|
| 934 |
+
|
| 935 |
+
logger.info(f"Success! Chat log generated: {filename}")
|
| 936 |
+
logger.info(f"{len(messages)} messages processed")
|
| 937 |
+
return True
|
| 938 |
+
|
| 939 |
+
except Exception as e:
|
| 940 |
+
logger.exception(f"Error generating PDF: {e}")
|
| 941 |
+
return False
|
pydantic_migration_model.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pydantic_migration_model.py
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from typing import List, Optional, Union, Dict, Any
|
| 4 |
+
|
| 5 |
+
class MigrationRequest(BaseModel):
|
| 6 |
+
db_url: str = Field(..., description="The connection string for the target database")
|
| 7 |
+
db_type: str = Field(..., description="postgres, mysql, or mongodb")
|
| 8 |
+
|
| 9 |
+
# For SQL Databases, this is the raw SQL Script (CREATE + INSERT)
|
| 10 |
+
# For MongoDB, this is ignored (we use migration_data)
|
| 11 |
+
migration_query: Optional[str] = None
|
| 12 |
+
|
| 13 |
+
# For MongoDB, we pass the raw JSON documents to insert
|
| 14 |
+
# For SQL, this is ignored
|
| 15 |
+
migration_data: Optional[List[Dict[str, Any]]] = None
|
| 16 |
+
|
| 17 |
+
# Metadata for MongoDB
|
| 18 |
+
collection_name: Optional[str] = None
|
| 19 |
+
|
| 20 |
+
# Options
|
| 21 |
+
batch_size: int = 1000
|
| 22 |
+
|
| 23 |
+
class MigrationResponse(BaseModel):
|
| 24 |
+
success: bool
|
| 25 |
+
rowsProcessed: int
|
| 26 |
+
errors: Optional[List[str]] = []
|
| 27 |
+
duration: str
|
| 28 |
+
request_id: str
|
requirements.txt
CHANGED
|
@@ -22,3 +22,7 @@ psutil==6.1.1
|
|
| 22 |
asyncpg==0.30.0
|
| 23 |
asyncmy==0.2.10
|
| 24 |
cryptography==43.0.3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
asyncpg==0.30.0
|
| 23 |
asyncmy==0.2.10
|
| 24 |
cryptography==43.0.3
|
| 25 |
+
weasyprint==67.0
|
| 26 |
+
markdown==3.7
|
| 27 |
+
requests==2.32.3
|
| 28 |
+
jinja2==3.1.4
|