Spaces:
Sleeping
Sleeping
| # ============================================================================ | |
| # GROQ AI CLIENT | |
| # ============================================================================ | |
| import asyncio | |
| import json | |
| import traceback | |
| from datetime import datetime | |
| from typing import Dict, Any, List, Optional, Tuple | |
| from fastapi import HTTPException | |
| from groq import Groq | |
| from app.config import Config | |
| from app.utils.logger import logger | |
| from app.utils.document_processor import ( | |
| DocumentProcessor, TokenCounter, ProcessedDocument | |
| ) | |
| from app.utils.api_key_pool import api_key_pool | |
| class GroqClient: | |
| """Groq AI test case generation client""" | |
| # Token limits for different models | |
| MODEL_TOKEN_LIMITS = { | |
| "openai/gpt-oss-120b": 8192, | |
| "llama-3.3-70b-versatile": 8192, | |
| "llama-3.1-70b-versatile": 8192, | |
| "llama3-70b-8192": 8192, | |
| "llama3-8b-8192": 8192, | |
| "mixtral-8x7b-32768": 32768, | |
| } | |
| DEFAULT_MAX_TOKENS = 8192 | |
| def get_model_token_limit(model: str) -> int: | |
| """Get token limit for model""" | |
| for key, limit in GroqClient.MODEL_TOKEN_LIMITS.items(): | |
| if key in model.lower(): | |
| return limit | |
| return GroqClient.DEFAULT_MAX_TOKENS | |
| def build_system_prompt() -> str: | |
| """System prompt for test case generation - Frontend Compatible Format""" | |
| return """You are a Senior QA Engineer specialized in creating comprehensive test cases. | |
| Your task is to generate detailed, actionable test cases from task descriptions. | |
| **OUTPUT FORMAT:** | |
| Return ONLY a valid JSON object with this exact structure (matches PM Tool Task interface): | |
| { | |
| "test_cases": [ | |
| { | |
| "title": "Clear, descriptive test case title", | |
| "description": "Detailed description of what this test case validates", | |
| "status": "Draft", | |
| "priority": "Critical|High|Medium|Low", | |
| "severity": "Blocker|Critical|Major|Minor|Trivial", | |
| "environment": "Development|Testing|Staging|Production", | |
| "stepsToReproduce": "1. First step\n2. Second step\n3. Third step\n4. Fourth step", | |
| "inputs": "Test data and inputs needed (as text)", | |
| "expectedBehavior": "What should happen when steps are executed correctly", | |
| "actualBehavior": "", | |
| "rootCause": "", | |
| "resolution": "", | |
| "version": "", | |
| "url": "" | |
| } | |
| ] | |
| } | |
| **CRITICAL RULES:** | |
| 1. **stepsToReproduce** MUST be a STRING with numbered steps separated by \n: | |
| - Correct: "1. Navigate to login page\n2. Enter email\n3. Click submit" | |
| - WRONG: ["step 1", "step 2"] (NOT an array!) | |
| 2. **priority** values: Critical, High, Medium, Low | |
| 3. **severity** values: Blocker, Critical, Major, Minor, Trivial | |
| 4. **status** MUST be "Draft" for all generated test cases | |
| 5. **environment** SHOULD be "Staging" by default | |
| 6. Leave these EMPTY (filled by testers later): | |
| - actualBehavior | |
| - rootCause | |
| - resolution | |
| 7. Generate diverse test scenarios: | |
| - Positive/Happy path cases (50%) | |
| - Negative/Error cases (30%) | |
| - Edge/Boundary cases (20%) | |
| 8. Each test case must have at least 3-5 detailed steps | |
| 9. Be SPECIFIC to the task description - no generic cases | |
| 10. Include realistic test data in "inputs" field | |
| 11. Return ONLY JSON - no markdown, explanations, or extra text""" | |
| def build_user_prompt( | |
| task: Dict[str, Any], | |
| attachments: List[Dict[str, Any]], | |
| count: int, | |
| document_context: str = None, | |
| existing_test_cases: List[Dict[str, str]] = None, | |
| additional_context: str = None | |
| ) -> str: | |
| """Build user prompt with task context and optional document content""" | |
| # Build attachment summary | |
| att_summary = "" | |
| if attachments: | |
| att_summary = "\n\n📎 **AVAILABLE DOCUMENTATION:**\n" | |
| for att in attachments[:5]: # Limit to avoid token overflow | |
| att_summary += f" • {att.get('fileName', att.get('filename', 'N/A'))} ({att.get('fileType', att.get('file_type', 'N/A'))})" | |
| if att.get('description'): | |
| att_summary += f" - {att['description']}" | |
| att_summary += "\n" | |
| # Build document context section | |
| doc_context_section = "" | |
| if document_context: | |
| doc_context_section = f""" | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| 📄 **DOCUMENT CONTENT (for reference):** | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| {document_context} | |
| **Note:** Use the above document content to understand requirements, specifications, | |
| and business rules. Generate test cases that validate these requirements. | |
| """ | |
| # Build existing test cases section to avoid duplicates | |
| existing_tc_section = "" | |
| if existing_test_cases and len(existing_test_cases) > 0: | |
| existing_tc_list = "\n".join([ | |
| f" {i+1}. {tc.get('title', 'Untitled')}" | |
| for i, tc in enumerate(existing_test_cases[:20]) # Limit to 20 to save tokens | |
| ]) | |
| existing_tc_section = f""" | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| ⚠️ **EXISTING TEST CASES - DO NOT DUPLICATE:** | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| {existing_tc_list} | |
| **CRITICAL:** These test cases already exist. Generate NEW, DIFFERENT test cases | |
| that cover scenarios NOT already covered by the existing test cases above. | |
| """ | |
| # Build additional context section from user input | |
| additional_context_section = "" | |
| if additional_context: | |
| additional_context_section = f""" | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| 📝 **ADDITIONAL CONTEXT (User Provided):** | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| {additional_context} | |
| **Note:** This additional context was provided by the user. Use it to understand | |
| specific requirements, edge cases, or business rules that should be tested. | |
| """ | |
| return f"""Generate {count} comprehensive test cases for this task: | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| 📋 TASK INFORMATION | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| • Task ID : {task.get('id', 'N/A')} | |
| • Task Code : {task.get('taskCode', 'TXXX')} | |
| • Title : {task.get('title', 'Untitled')} | |
| • Description : {task.get('description', 'No description')} | |
| • Project : {task.get('projectName', 'N/A')} | |
| • Issue : {task.get('issueName', 'N/A')} | |
| • Feature : {task.get('deliverableName', 'N/A')} | |
| • Status : {task.get('status', 'N/A')} | |
| • Priority : {task.get('priority', 'Medium')} | |
| • Assigned To : {task.get('assignedTo', 'N/A')} | |
| • Created By : {task.get('createdByName', 'N/A')} | |
| {att_summary} | |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| {doc_context_section}{existing_tc_section}{additional_context_section} | |
| **CONTEXT:** | |
| - Feature: {task.get('deliverableName', 'N/A')} | |
| - Related documentation: {len(attachments)} file(s) | |
| - Existing test cases: {len(existing_test_cases) if existing_test_cases else 0} | |
| - Additional context provided: {'Yes' if additional_context else 'No'} | |
| **REQUIREMENTS:** | |
| Generate exactly {count} test cases that: | |
| 1. Cover positive scenarios (happy path) | |
| 2. Include negative scenarios (error handling) | |
| 3. Test edge cases and boundaries | |
| 4. Validate data inputs/outputs where applicable | |
| 5. Are SPECIFIC to the task/feature described above | |
| 6. Are DIFFERENT from existing test cases listed above (if any) | |
| 7. Incorporate insights from additional context if provided | |
| Return ONLY the JSON object - no additional text.""" | |
| async def generate_test_cases( | |
| task: Dict[str, Any], | |
| attachments: List[Dict[str, Any]], | |
| count: int, | |
| model: str, | |
| temperature: float, | |
| processed_documents: List[ProcessedDocument] = None, | |
| max_context_tokens: int = None, | |
| existing_test_cases: List[Dict[str, str]] = None, | |
| additional_context: str = None | |
| ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: | |
| """Generate test cases using Groq AI with document context | |
| Args: | |
| existing_test_cases: List of existing test case titles to avoid duplicates | |
| additional_context: Extra context provided by user (text or PDF content) | |
| Returns: (test_cases, generation_metadata) | |
| """ | |
| if not api_key_pool: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Groq API key not configured" | |
| ) | |
| # Get model token limit | |
| model_limit = GroqClient.get_model_token_limit(model) | |
| # Reserve tokens for system prompt, response, and overhead | |
| system_prompt_tokens = 500 # Approximate | |
| response_reserve = 3000 # Reserve for response | |
| overhead = 200 | |
| available_for_context = model_limit - system_prompt_tokens - response_reserve - overhead | |
| # Build document context if provided | |
| document_context = "" | |
| doc_stats = { | |
| "documents_processed": 0, | |
| "documents_skipped": 0, | |
| "chunks_used": 0, | |
| "tokens_used": 0, | |
| "context_truncated": False | |
| } | |
| if processed_documents: | |
| max_doc_tokens = min( | |
| max_context_tokens or DocumentProcessor.MAX_CONTEXT_TOKENS, | |
| available_for_context - 1000 # Reserve for task info | |
| ) | |
| document_context, doc_tokens, doc_stats = DocumentProcessor.build_context_from_documents( | |
| documents=processed_documents, | |
| max_total_tokens=max_doc_tokens | |
| ) | |
| try: | |
| # Get available API key from pool (shuffle) | |
| estimated_tokens = 2500 # Approximate for test case generation | |
| api_key = await api_key_pool.get_available_key(estimated_tokens) | |
| api_key_alias = api_key_pool.get_key_alias(api_key) | |
| logger.info(f"🔑 Using API key: {api_key_alias}") | |
| client = Groq(api_key=api_key) | |
| system_prompt = GroqClient.build_system_prompt() | |
| user_prompt = GroqClient.build_user_prompt( | |
| task=task, | |
| attachments=attachments, | |
| count=count, | |
| document_context=document_context if document_context else None, | |
| existing_test_cases=existing_test_cases, | |
| additional_context=additional_context | |
| ) | |
| # Estimate total input tokens | |
| input_tokens = TokenCounter.estimate(system_prompt + user_prompt) | |
| logger.info(f"🤖 Generating {count} test cases for task {task.get('taskCode', 'N/A')}") | |
| logger.info(f"📊 Input tokens: ~{input_tokens} (limit: {model_limit})") | |
| if input_tokens > model_limit: | |
| # Truncate user prompt | |
| excess = input_tokens - model_limit + 500 # Buffer | |
| logger.warning(f"⚠️ Input exceeds limit, truncating by ~{excess} tokens") | |
| # Truncate document context first | |
| if document_context: | |
| truncate_chars = int(excess * TokenCounter.CHARS_PER_TOKEN) | |
| if len(document_context) > truncate_chars: | |
| document_context = document_context[:-truncate_chars] | |
| doc_stats["context_truncated"] = True | |
| user_prompt = GroqClient.build_user_prompt( | |
| task=task, | |
| attachments=attachments, | |
| count=count, | |
| document_context=document_context, | |
| existing_test_cases=existing_test_cases, | |
| additional_context=additional_context | |
| ) | |
| # Call Groq API (synchronous, but run in thread pool to avoid blocking) | |
| loop = asyncio.get_event_loop() | |
| completion = await loop.run_in_executor( | |
| None, | |
| lambda: client.chat.completions.create( | |
| model=model, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt} | |
| ], | |
| temperature=temperature, | |
| max_completion_tokens=Config.GROQ_MAX_TOKENS, | |
| top_p=1, | |
| response_format={"type": "json_object"}, | |
| stop=None | |
| ) | |
| ) | |
| raw_content = completion.choices[0].message.content | |
| tokens_used = completion.usage.total_tokens if completion.usage else 0 | |
| prompt_tokens = completion.usage.prompt_tokens if completion.usage else 0 | |
| completion_tokens = completion.usage.completion_tokens if completion.usage else 0 | |
| logger.info(f"✅ Groq response received | Key: {api_key_alias} | Total: {tokens_used} | Prompt: {prompt_tokens} | Completion: {completion_tokens}") | |
| # Record usage in API key pool | |
| await api_key_pool.record_usage(api_key, tokens_used) | |
| # Parse JSON | |
| try: | |
| parsed = json.loads(raw_content) | |
| except json.JSONDecodeError as e: | |
| logger.error(f"JSON parse error: {e}\nRaw: {raw_content[:500]}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to parse AI response: {str(e)}" | |
| ) | |
| # Extract test cases | |
| test_cases = parsed.get("test_cases", []) | |
| if not test_cases: | |
| logger.warning(f"No test cases generated for task {task.get('id')}") | |
| return [], {"error": "No test cases generated"} | |
| # Add metadata to each test case (Frontend-compatible format) | |
| for tc in test_cases: | |
| # Ensure required fields exist | |
| tc['type'] = 'Test Case' | |
| tc['issuesId'] = task.get('issuesId') # Link to Feature/Issue | |
| tc['projectId'] = task.get('projectId') | |
| # Ensure stepsToReproduce is a string (not array) | |
| if 'stepsToReproduce' not in tc or not tc.get('stepsToReproduce'): | |
| # Convert test_steps array to string if present | |
| if 'test_steps' in tc and isinstance(tc['test_steps'], list): | |
| steps_text = '\n'.join( | |
| f"{step.get('step_number', i+1)}. {step.get('action', '')}" | |
| for i, step in enumerate(tc['test_steps']) | |
| ) | |
| tc['stepsToReproduce'] = steps_text | |
| else: | |
| tc['stepsToReproduce'] = "1. Step to be defined" | |
| # Ensure expectedBehavior exists | |
| if 'expectedBehavior' not in tc or not tc.get('expectedBehavior'): | |
| tc['expectedBehavior'] = tc.get('expected_outcome', '') or tc.get('objective', '') | |
| # Set defaults for empty fields | |
| tc['actualBehavior'] = tc.get('actualBehavior', '') | |
| tc['rootCause'] = tc.get('rootCause', '') | |
| tc['resolution'] = tc.get('resolution', '') | |
| tc['version'] = tc.get('version', '') | |
| tc['url'] = tc.get('url', '') | |
| tc['environment'] = tc.get('environment', 'Staging') | |
| tc['status'] = tc.get('status', 'Draft') | |
| # Map priority/severity to frontend values | |
| priority = tc.get('priority', 'Medium') | |
| if priority not in ['Critical', 'High', 'Medium', 'Low']: | |
| tc['priority'] = 'Medium' | |
| severity = tc.get('severity', 'Minor') | |
| if severity not in ['Blocker', 'Critical', 'Major', 'Minor', 'Trivial']: | |
| tc['severity'] = 'Minor' | |
| # Add reference metadata (for display purposes) | |
| tc['related_task_id'] = task.get('id') | |
| tc['related_task_code'] = task.get('taskCode', 'TXXX') | |
| tc['projectName'] = task.get('projectName', 'N/A') | |
| tc['issueName'] = task.get('issueName', 'N/A') | |
| tc['deliverableName'] = task.get('deliverableName', 'N/A') | |
| tc['createdByName'] = task.get('createdByName', 'N/A') | |
| # Set dates | |
| tc['stateDate'] = datetime.now().isoformat() | |
| tc['endDate'] = None | |
| tc['assignedTo'] = None | |
| # Clean up legacy fields that shouldn't be in final output | |
| for legacy_field in ['test_case_id', 'objective', 'preconditions', 'test_steps', | |
| 'expected_outcome', 'test_type', 'category', 'task_id', | |
| 'task_code', 'issue_name', 'feature_name', 'task_name', | |
| 'task_description', 'created_by']: | |
| tc.pop(legacy_field, None) | |
| # Build generation metadata | |
| gen_metadata = { | |
| "model": model, | |
| "temperature": temperature, | |
| "api_key_alias": api_key_alias, | |
| "tokens_used": tokens_used, | |
| "prompt_tokens": prompt_tokens, | |
| "completion_tokens": completion_tokens, | |
| "document_stats": doc_stats, | |
| "test_cases_count": len(test_cases) | |
| } | |
| logger.info(f"✅ Generated {len(test_cases)} test cases") | |
| return test_cases, gen_metadata | |
| except Exception as e: | |
| logger.error(f"Groq API error: {e}\n{traceback.format_exc()}") | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"AI generation failed: {str(e)}" | |
| ) | |