Files
2026-07-13 13:29:13 +08:00

314 lines
14 KiB
Python

from pydantic import BaseModel
from typing import List, Tuple, Literal
from pydantic_ai import Agent
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.groq import GroqModel
from pydantic_ai.models.openai import OpenAIModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.providers.groq import GroqProvider
from pydantic_ai.providers.anthropic import AnthropicProvider
import logging
import os
from dotenv import load_dotenv
from db import DatabaseManager
from ollama import chat
import asyncio
from ollama import AsyncClient
# Set up logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
)
logger = logging.getLogger(__name__)
load_dotenv() # Load environment variables from .env file
db = DatabaseManager()
class Block(BaseModel):
"""Represents a block of content in a section.
Block types must align with frontend rendering capabilities:
- 'text': Plain text content
- 'bullet': Bulleted list item
- 'heading1': Large section heading
- 'heading2': Medium section heading
Colors currently supported:
- 'gray': Gray text color
- '' or any other value: Default text color
"""
id: str
type: Literal['bullet', 'heading1', 'heading2', 'text']
content: str
color: str # Frontend currently only uses 'gray' or default
class Section(BaseModel):
"""Represents a section in the meeting summary"""
title: str
blocks: List[Block]
class MeetingNotes(BaseModel):
"""Represents the meeting notes"""
meeting_name: str
sections: List[Section]
class People(BaseModel):
"""Represents the people in the meeting. Always have this part in the output. Title - Person Name (Role, Details)"""
title: str
blocks: List[Block]
class SummaryResponse(BaseModel):
"""Represents the meeting summary response based on a section of the transcript"""
MeetingName : str
People : People
SessionSummary : Section
CriticalDeadlines: Section
KeyItemsDecisions: Section
ImmediateActionItems: Section
NextSteps: Section
MeetingNotes: MeetingNotes
# --- Main Class Used by main.py ---
class TranscriptProcessor:
"""Handles the processing of meeting transcripts using AI models."""
def __init__(self):
"""Initialize the transcript processor."""
logger.info("TranscriptProcessor initialized.")
self.db = DatabaseManager()
self.active_clients = [] # Track active Ollama client sessions
async def process_transcript(self, text: str, model: str, model_name: str, chunk_size: int = 5000, overlap: int = 1000, custom_prompt: str = "") -> Tuple[int, List[str]]:
"""
Process transcript text into chunks and generate structured summaries for each chunk using an AI model.
Args:
text: The transcript text.
model: The AI model provider ('claude', 'ollama', 'groq', 'openai').
model_name: The specific model name.
chunk_size: The size of each text chunk.
overlap: The overlap between consecutive chunks.
custom_prompt: A custom prompt to use for the AI model.
Returns:
A tuple containing:
- The number of chunks processed.
- A list of JSON strings, where each string is the summary of a chunk.
"""
logger.info(f"Processing transcript (length {len(text)}) with model provider={model}, model_name={model_name}, chunk_size={chunk_size}, overlap={overlap}")
all_json_data = []
agent = None # Define agent variable
llm = None # Define llm variable
try:
# Select and initialize the AI model and agent
if model == "claude":
api_key = await db.get_api_key("claude")
if not api_key: raise ValueError("ANTHROPIC_API_KEY environment variable not set")
llm = AnthropicModel(model_name, provider=AnthropicProvider(api_key=api_key))
logger.info(f"Using Claude model: {model_name}")
elif model == "ollama":
# Use environment variable for Ollama host configuration
ollama_host = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
ollama_base_url = f"{ollama_host}/v1"
ollama_model = OpenAIModel(
model_name=model_name, provider=OpenAIProvider(base_url=ollama_base_url)
)
llm = ollama_model
if model_name.lower().startswith("phi4") or model_name.lower().startswith("llama"):
chunk_size = 10000
overlap = 1000
else:
chunk_size = 30000
overlap = 1000
logger.info(f"Using Ollama model: {model_name}")
elif model == "groq":
api_key = await db.get_api_key("groq")
if not api_key: raise ValueError("GROQ_API_KEY environment variable not set")
llm = GroqModel(model_name, provider=GroqProvider(api_key=api_key))
logger.info(f"Using Groq model: {model_name}")
# --- ADD OPENAI SUPPORT HERE ---
elif model == "openai":
api_key = await db.get_api_key("openai")
if not api_key: raise ValueError("OPENAI_API_KEY environment variable not set")
llm = OpenAIModel(model_name, provider=OpenAIProvider(api_key=api_key))
logger.info(f"Using OpenAI model: {model_name}")
# --- END OPENAI SUPPORT ---
else:
logger.error(f"Unsupported model provider requested: {model}")
raise ValueError(f"Unsupported model provider: {model}")
# Initialize the agent with the selected LLM
agent = Agent(
llm,
result_type=SummaryResponse,
result_retries=2,
)
logger.info("Pydantic-AI Agent initialized.")
# Split transcript into chunks
step = chunk_size - overlap
if step <= 0:
logger.warning(f"Overlap ({overlap}) >= chunk_size ({chunk_size}). Adjusting overlap.")
overlap = max(0, chunk_size - 100)
step = chunk_size - overlap
chunks = [text[i:i+chunk_size] for i in range(0, len(text), step)]
num_chunks = len(chunks)
logger.info(f"Split transcript into {num_chunks} chunks.")
for i, chunk in enumerate(chunks):
logger.info(f"Processing chunk {i+1}/{num_chunks}...")
try:
# Run the agent to get the structured summary for the chunk
if model != "ollama":
summary_result = await agent.run(
f"""Given the following meeting transcript chunk, extract the relevant information according to the required JSON structure. If a specific section (like Critical Deadlines) has no relevant information in this chunk, return an empty list for its 'blocks'. Ensure the output is only the JSON data.
IMPORTANT: Block types must be one of: 'text', 'bullet', 'heading1', 'heading2'
- Use 'text' for regular paragraphs
- Use 'bullet' for list items
- Use 'heading1' for major headings
- Use 'heading2' for subheadings
For the color field, use 'gray' for less important content or '' (empty string) for default.
Transcript Chunk:
---
{chunk}
---
Please capture all relevant action items. Transcription can have spelling mistakes. correct it if required. context is important.
While generating the summary, please add the following context:
---
{custom_prompt}
---
Make sure the output is only the JSON data.
""",
)
else:
logger.info(f"Using Ollama model: {model_name} and chunk size: {chunk_size} with overlap: {overlap}")
response = await self.chat_ollama_model(model_name, chunk, custom_prompt)
# Check if response is already a SummaryResponse object or a string that needs validation
if isinstance(response, SummaryResponse):
summary_result = response
else:
# If it's a string (JSON), validate it
summary_result = SummaryResponse.model_validate_json(response)
logger.info(f"Summary result for chunk {i+1}: {summary_result}")
logger.info(f"Summary result type for chunk {i+1}: {type(summary_result)}")
if hasattr(summary_result, 'data') and isinstance(summary_result.data, SummaryResponse):
final_summary_pydantic = summary_result.data
elif isinstance(summary_result, SummaryResponse):
final_summary_pydantic = summary_result
else:
logger.error(f"Unexpected result type from agent for chunk {i+1}: {type(summary_result)}")
continue # Skip this chunk
# Convert the Pydantic model to a JSON string
chunk_summary_json = final_summary_pydantic.model_dump_json()
all_json_data.append(chunk_summary_json)
logger.info(f"Successfully generated summary for chunk {i+1}.")
except Exception as chunk_error:
logger.error(f"Error processing chunk {i+1}: {chunk_error}", exc_info=True)
logger.info(f"Finished processing all {num_chunks} chunks.")
return num_chunks, all_json_data
except Exception as e:
logger.error(f"Error during transcript processing: {str(e)}", exc_info=True)
raise
async def chat_ollama_model(self, model_name: str, transcript: str, custom_prompt: str):
message = {
'role': 'system',
'content': f'''
Given the following meeting transcript chunk, extract the relevant information according to the required JSON structure. If a specific section (like Critical Deadlines) has no relevant information in this chunk, return an empty list for its 'blocks'. Ensure the output is only the JSON data.
Transcript Chunk:
---
{transcript}
---
Please capture all relevant action items. Transcription can have spelling mistakes. correct it if required. context is important.
While generating the summary, please add the following context:
---
{custom_prompt}
---
Make sure the output is only the JSON data.
''',
}
# Create a client and track it for cleanup
ollama_host = os.getenv('OLLAMA_HOST', 'http://127.0.0.1:11434')
client = AsyncClient(host=ollama_host)
self.active_clients.append(client)
try:
response = await client.chat(model=model_name, messages=[message], stream=True, format=SummaryResponse.model_json_schema())
full_response = ""
async for part in response:
content = part['message']['content']
print(content, end='', flush=True)
full_response += content
try:
summary = SummaryResponse.model_validate_json(full_response)
print("\n", summary.model_dump_json(indent=2), type(summary))
return summary
except Exception as e:
print(f"\nError parsing response: {e}")
return full_response
except asyncio.CancelledError:
logger.info("Ollama request was cancelled during shutdown")
raise
except Exception as e:
logger.error(f"Error in Ollama chat: {e}")
raise
finally:
# Remove the client from active clients list
if client in self.active_clients:
self.active_clients.remove(client)
def cleanup(self):
"""Clean up resources used by the TranscriptProcessor."""
logger.info("Cleaning up TranscriptProcessor resources")
try:
# Close database connections if any
if hasattr(self, 'db') and self.db is not None:
# self.db.close()
logger.info("Database connection cleanup (using context managers)")
# Cancel any active Ollama client sessions
if hasattr(self, 'active_clients') and self.active_clients:
logger.info(f"Terminating {len(self.active_clients)} active Ollama client sessions")
for client in self.active_clients:
try:
# Close the client's underlying connection
if hasattr(client, '_client') and hasattr(client._client, 'close'):
asyncio.create_task(client._client.aclose())
except Exception as client_error:
logger.error(f"Error closing Ollama client: {client_error}", exc_info=True)
# Clear the list
self.active_clients.clear()
logger.info("All Ollama client sessions terminated")
except Exception as e:
logger.error(f"Error during TranscriptProcessor cleanup: {str(e)}", exc_info=True)