Soumik Bose commited on
Commit
585bc48
·
1 Parent(s): 03993cf
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
- WORKDIR /app
 
 
 
16
 
17
  # Create required directories with permissions
18
- RUN mkdir -p /app/generated_outputs /app/generated_charts /app/cache && \
19
- chmod -R 777 /app/generated_outputs /app/generated_charts /app/cache
 
 
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: ![alt](url)
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} &nbsp;&nbsp;|&nbsp;&nbsp;
838
+ <strong>User:</strong> {user_count} &nbsp;&nbsp;|&nbsp;&nbsp;
839
+ <strong>Assistant:</strong> {assistant_count} &nbsp;&nbsp;|&nbsp;&nbsp;
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