diff --git a/content-gen/src/app/frontend/src/App.tsx b/content-gen/src/app/frontend/src/App.tsx index fd1de0dec..82ec34ad3 100644 --- a/content-gen/src/app/frontend/src/App.tsx +++ b/content-gen/src/app/frontend/src/App.tsx @@ -20,6 +20,7 @@ import ContosoLogo from './styles/images/contoso.svg'; function App() { const [conversationId, setConversationId] = useState(() => uuidv4()); + const [conversationTitle, setConversationTitle] = useState(null); const [userId, setUserId] = useState(''); const [userName, setUserName] = useState(''); const [messages, setMessages] = useState([]); @@ -104,6 +105,7 @@ function App() { if (response.ok) { const data = await response.json(); setConversationId(selectedConversationId); + setConversationTitle(null); // Will use title from conversation list const loadedMessages: ChatMessage[] = (data.messages || []).map((msg: { role: string; content: string; timestamp?: string; agent?: string }, index: number) => ({ id: `${selectedConversationId}-${index}`, role: msg.role as 'user' | 'assistant', @@ -175,6 +177,7 @@ function App() { // Handle starting a new conversation const handleNewConversation = useCallback(() => { setConversationId(uuidv4()); + setConversationTitle(null); setMessages([]); setPendingBrief(null); setAwaitingClarification(false); @@ -216,6 +219,9 @@ function App() { setGenerationStatus('Updating creative brief...'); const parsed = await parseBrief(refinementPrompt, conversationId, userId, signal); + if (parsed.generated_title && !conversationTitle) { + setConversationTitle(parsed.generated_title); + } if (parsed.brief) { setPendingBrief(parsed.brief); } @@ -428,6 +434,11 @@ function App() { setGenerationStatus('Analyzing creative brief...'); const parsed = await parseBrief(content, conversationId, userId, signal); + // Set conversation title from generated title + if (parsed.generated_title && !conversationTitle) { + setConversationTitle(parsed.generated_title); + } + // Check if request was blocked due to harmful content if (parsed.rai_blocked) { // Show the refusal message without any brief UI @@ -799,6 +810,7 @@ function App() {
void; onNewConversation: () => void; @@ -46,6 +47,7 @@ interface ChatHistoryProps { export function ChatHistory({ currentConversationId, + currentConversationTitle, currentMessages = [], onSelectConversation, onNewConversation, @@ -152,13 +154,14 @@ export function ChatHistory({ }, [refreshTrigger]); // Build the current session conversation summary if it has messages - const currentSessionConversation: ConversationSummary | null = currentMessages.length > 0 ? { - id: currentConversationId, - title: currentMessages.find(m => m.role === 'user')?.content?.substring(0, 50) || 'Current Conversation', - lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || '', - timestamp: new Date().toISOString(), - messageCount: currentMessages.length, - } : null; + const currentSessionConversation: ConversationSummary | null = + currentMessages.length > 0 && currentConversationTitle ? { + id: currentConversationId, + title: currentConversationTitle, + lastMessage: currentMessages[currentMessages.length - 1]?.content?.substring(0, 100) || '', + timestamp: new Date().toISOString(), + messageCount: currentMessages.length, + } : null; // Merge current session with saved conversations, updating the current one with live data const displayConversations = (() => { diff --git a/content-gen/src/app/frontend/src/types/index.ts b/content-gen/src/app/frontend/src/types/index.ts index 4d0efd569..91c40c3a3 100644 --- a/content-gen/src/app/frontend/src/types/index.ts +++ b/content-gen/src/app/frontend/src/types/index.ts @@ -92,6 +92,7 @@ export interface ParsedBriefResponse { rai_blocked?: boolean; message: string; conversation_id?: string; + generated_title?: string; } export interface GeneratedContent { diff --git a/content-gen/src/backend/app.py b/content-gen/src/backend/app.py index d6e4dfc7e..eabcccd94 100644 --- a/content-gen/src/backend/app.py +++ b/content-gen/src/backend/app.py @@ -21,6 +21,7 @@ from orchestrator import get_orchestrator from services.cosmos_service import get_cosmos_service from services.blob_service import get_blob_service +from services.title_service import get_title_service from api.admin import admin_bp # In-memory task storage for generation tasks @@ -106,6 +107,16 @@ async def chat(): # Try to save to CosmosDB but don't fail if it's unavailable try: cosmos_service = await get_cosmos_service() + + generated_title = None + existing_conversation = await cosmos_service.get_conversation(conversation_id, user_id) + existing_metadata = existing_conversation.get("metadata", {}) if existing_conversation else {} + has_existing_title = bool(existing_metadata.get("custom_title") or existing_metadata.get("generated_title")) + + if not has_existing_title: + title_service = get_title_service() + generated_title = await title_service.generate_title(message) + await cosmos_service.add_message_to_conversation( conversation_id=conversation_id, user_id=user_id, @@ -113,7 +124,8 @@ async def chat(): "role": "user", "content": message, "timestamp": datetime.now(timezone.utc).isoformat() - } + }, + generated_title=generated_title ) except Exception as e: logger.warning(f"Failed to save message to CosmosDB: {e}") @@ -187,9 +199,22 @@ async def parse_brief(): if not brief_text: return jsonify({"error": "Brief text is required"}), 400 + orchestrator = get_orchestrator() + generated_title = None + # Save the user's brief text as a message to CosmosDB try: cosmos_service = await get_cosmos_service() + + # Generate title for new conversations + existing_conversation = await cosmos_service.get_conversation(conversation_id, user_id) + existing_metadata = existing_conversation.get("metadata", {}) if existing_conversation else {} + has_existing_title = bool(existing_metadata.get("custom_title") or existing_metadata.get("generated_title")) + + if not has_existing_title: + title_service = get_title_service() + generated_title = await title_service.generate_title(brief_text) + await cosmos_service.add_message_to_conversation( conversation_id=conversation_id, user_id=user_id, @@ -197,12 +222,12 @@ async def parse_brief(): "role": "user", "content": brief_text, "timestamp": datetime.now(timezone.utc).isoformat() - } + }, + generated_title=generated_title ) except Exception as e: logger.warning(f"Failed to save brief message to CosmosDB: {e}") - orchestrator = get_orchestrator() parsed_brief, clarifying_questions, rai_blocked = await orchestrator.parse_brief(brief_text) # Check if request was blocked due to harmful content @@ -228,6 +253,7 @@ async def parse_brief(): "requires_clarification": False, "requires_confirmation": False, "conversation_id": conversation_id, + "generated_title": generated_title, "message": clarifying_questions }) @@ -255,6 +281,7 @@ async def parse_brief(): "requires_confirmation": False, "clarifying_questions": clarifying_questions, "conversation_id": conversation_id, + "generated_title": generated_title, "message": clarifying_questions }) @@ -279,6 +306,7 @@ async def parse_brief(): "requires_clarification": False, "requires_confirmation": True, "conversation_id": conversation_id, + "generated_title": generated_title, "message": "Please review and confirm the parsed creative brief" }) @@ -1323,12 +1351,12 @@ async def update_conversation(conversation_id: str): async def delete_all_conversations(): """ Delete all conversations for the current user. - + Uses authenticated user from EasyAuth headers. """ auth_user = get_authenticated_user() user_id = auth_user["user_principal_id"] - + try: cosmos_service = await get_cosmos_service() deleted_count = await cosmos_service.delete_all_conversations(user_id) diff --git a/content-gen/src/backend/services/cosmos_service.py b/content-gen/src/backend/services/cosmos_service.py index c188f06aa..a23a56407 100644 --- a/content-gen/src/backend/services/cosmos_service.py +++ b/content-gen/src/backend/services/cosmos_service.py @@ -343,13 +343,27 @@ async def save_conversation( """ await self.initialize() + # Get existing conversation to preserve important metadata fields + existing = await self.get_conversation(conversation_id, user_id) + existing_metadata = existing.get("metadata", {}) if existing else {} + + # Merge metadata - preserve generated_title and custom_title from existing + merged_metadata = {} + if existing_metadata.get("generated_title"): + merged_metadata["generated_title"] = existing_metadata["generated_title"] + if existing_metadata.get("custom_title"): + merged_metadata["custom_title"] = existing_metadata["custom_title"] + # Add new metadata on top + if metadata: + merged_metadata.update(metadata) + item = { "id": conversation_id, "userId": user_id, # Partition key field (matches container definition /userId) "user_id": user_id, # Keep for backward compatibility "messages": messages, "brief": brief.model_dump() if brief else None, - "metadata": metadata or {}, + "metadata": merged_metadata, "generated_content": generated_content, "updated_at": datetime.now(timezone.utc).isoformat() } @@ -401,7 +415,8 @@ async def add_message_to_conversation( self, conversation_id: str, user_id: str, - message: dict + message: dict, + generated_title: Optional[str] = None ) -> dict: """ Add a message to an existing conversation. @@ -422,6 +437,12 @@ async def add_message_to_conversation( # Ensure userId is set (for partition key) - migrate old documents if not conversation.get("userId"): conversation["userId"] = conversation.get("user_id") or user_id + conversation["metadata"] = conversation.get("metadata", {}) + if generated_title: + has_custom_title = bool(conversation["metadata"].get("custom_title")) + has_generated_title = bool(conversation["metadata"].get("generated_title")) + if not has_custom_title and not has_generated_title: + conversation["metadata"]["generated_title"] = generated_title conversation["messages"].append(message) conversation["updated_at"] = datetime.now(timezone.utc).isoformat() else: @@ -430,6 +451,7 @@ async def add_message_to_conversation( "userId": user_id, # Partition key field "user_id": user_id, # Keep for backward compatibility "messages": [message], + "metadata": {"generated_title": generated_title} if generated_title else {}, "updated_at": datetime.now(timezone.utc).isoformat() } @@ -494,16 +516,21 @@ async def get_user_conversations( custom_title = metadata.get("custom_title") if metadata else None if custom_title: title = custom_title + elif metadata and metadata.get("generated_title"): + title = metadata.get("generated_title") elif brief and brief.get("overview"): - title = brief["overview"][:50] + overview_words = brief["overview"].split()[:4] + title = " ".join(overview_words) if overview_words else "New Conversation" elif messages: - title = "Untitled Conversation" + title = "New Conversation" for msg in messages: if msg.get("role") == "user": - title = msg.get("content", "")[:50] + content = msg.get("content", "") + words = content.split()[:4] + title = " ".join(words) if words else "New Conversation" break else: - title = "Untitled Conversation" + title = "New Conversation" # Get last message preview last_message = "" @@ -597,18 +624,18 @@ async def delete_all_conversations( ) -> int: """ Delete all conversations for a user. - + Args: user_id: User ID to delete conversations for - + Returns: Number of conversations deleted """ await self.initialize() - + # First get all conversations for the user conversations = await self.get_user_conversations(user_id, limit=1000) - + deleted_count = 0 for conv in conversations: try: @@ -616,7 +643,7 @@ async def delete_all_conversations( deleted_count += 1 except Exception as e: logger.warning(f"Failed to delete conversation {conv['id']}: {e}") - + logger.info(f"Deleted {deleted_count} conversations for user {user_id}") return deleted_count diff --git a/content-gen/src/backend/services/title_service.py b/content-gen/src/backend/services/title_service.py new file mode 100644 index 000000000..e849ca22d --- /dev/null +++ b/content-gen/src/backend/services/title_service.py @@ -0,0 +1,149 @@ +""" +Title Generation Service - Generates concise conversation titles using AI. + +This service provides a dedicated agent for generating meaningful, +short titles for chat conversations based on the user's first message. +""" + +import logging +import re +from typing import Optional + +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import DefaultAzureCredential + +from settings import app_settings + +logger = logging.getLogger(__name__) + +# Token endpoint for Azure OpenAI authentication +TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default" + +# Title generation instructions (from MS reference accelerator) +TITLE_INSTRUCTIONS = """Summarize the conversation so far into a 4-word or less title. +Do not use any quotation marks or punctuation. +Do not include any other commentary or description.""" + + +class TitleService: + """Service for generating conversation titles using AI.""" + + def __init__(self): + self._agent = None + self._initialized = False + self._credential = None + + def initialize(self) -> None: + """Initialize the title generation agent.""" + if self._initialized: + return + + try: + self._credential = DefaultAzureCredential() + use_foundry = app_settings.ai_foundry.use_foundry + + if use_foundry: + # Azure AI Foundry mode + endpoint = app_settings.azure_openai.endpoint + deployment = app_settings.ai_foundry.model_deployment or app_settings.azure_openai.gpt_model + else: + # Azure OpenAI Direct mode + endpoint = app_settings.azure_openai.endpoint + deployment = app_settings.azure_openai.gpt_model + + if not endpoint: + logger.warning("Title service: Azure OpenAI endpoint not configured, title generation disabled") + return + + api_version = app_settings.azure_openai.api_version + + # Create token provider function + def get_token() -> str: + """Token provider callable - invoked for each request to ensure fresh tokens.""" + token = self._credential.get_token(TOKEN_ENDPOINT) + return token.token + + chat_client = AzureOpenAIChatClient( + endpoint=endpoint, + deployment_name=deployment, + api_version=api_version, + ad_token_provider=get_token, + ) + + self._agent = chat_client.create_agent( + name="title_agent", + instructions=TITLE_INSTRUCTIONS, + ) + + self._initialized = True + + except Exception as e: + logger.exception(f"Failed to initialize title service: {e}") + self._agent = None + + @staticmethod + def _fallback_title(message: str) -> str: + """Generate a fallback title using first 4 words of the message.""" + if not message or not message.strip(): + return "New Conversation" + words = message.strip().split()[:4] + return " ".join(words) if words else "New Conversation" + + async def generate_title(self, first_user_message: str) -> str: + """ + Generate a concise conversation title from the first user message. + + Args: + first_user_message: The user's first message in the conversation + + Returns: + A short, meaningful title (max 4 words) + """ + if not first_user_message or not first_user_message.strip(): + return "New Conversation" + + if not self._initialized: + self.initialize() + + if self._agent is None: + logger.warning("Title generation: agent not available, using fallback") + return self._fallback_title(first_user_message) + + prompt = ( + "Create a concise chat title for this user request.\n" + "Respond with title only.\n\n" + f"User request: {first_user_message.strip()}" + ) + + try: + response = await self._agent.run(prompt) + + # Clean up the response + title = str(response).strip().splitlines()[0].strip() + title = re.sub(r"\s+", " ", title) + title = re.sub(r"[\"'`]+", "", title) + title = re.sub(r"[.,!?;:]+", "", title).strip() + + if not title: + logger.warning("Title generation: agent returned empty, using fallback") + return self._fallback_title(first_user_message) + + final_title = " ".join(title.split()[:4]) + return final_title + + except Exception as exc: + logger.exception("Failed to generate conversation title: %s", exc) + return self._fallback_title(first_user_message) + + +# Singleton instance +_title_service: Optional[TitleService] = None + + +def get_title_service() -> TitleService: + """Get or create the singleton title service instance.""" + global _title_service + if _title_service is None: + _title_service = TitleService() + _title_service.initialize() + return _title_service