脕lvaro Valenzuela Valdes
feat: implement Llama 3.2 Vision support in chatbot for image analysis
46928cd | import hashlib | |
| import json | |
| import httpx | |
| import google.generativeai as genai | |
| from app.config import settings | |
| from app.schemas.analysis import AnalysisResult, RiskItem, ActionItem, CompanyProfile, Tender | |
| from app.services.report import generate_markdown_report | |
| # Configure Gemini | |
| genai.configure(api_key=settings.gemini_api_key) | |
| async def call_gemini(prompt: str, is_json: bool = False) -> str: | |
| if not settings.gemini_api_key: | |
| return "" | |
| try: | |
| generation_config = { | |
| "temperature": 0.2, | |
| "top_p": 0.95, | |
| "top_k": 40, | |
| "max_output_tokens": 8192, | |
| } | |
| if is_json: | |
| generation_config["response_mime_type"] = "application/json" | |
| model = genai.GenerativeModel( | |
| model_name="gemini-2.0-flash", | |
| generation_config=generation_config, | |
| ) | |
| response = await model.generate_content_async(prompt) | |
| return response.text | |
| except Exception as e: | |
| print(f"Error calling Gemini (is_json={is_json}): {e}, trying fallback...") | |
| if settings.groq_api_key: | |
| return await call_groq(prompt, "llama-3.3-70b-versatile") | |
| return await call_featherless(prompt, "Qwen/Qwen2.5-72B-Instruct") | |
| async def call_featherless(prompt: str, model: str = "Qwen/Qwen2.5-72B-Instruct") -> str: | |
| if not settings.featherless_api_key: | |
| return "" | |
| try: | |
| async with httpx.AsyncClient(timeout=60.0) as client: | |
| payload = { | |
| "model": model, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "temperature": 0.2 | |
| } | |
| if "json" in prompt.lower(): | |
| payload["response_format"] = {"type": "json_object"} | |
| response = await client.post( | |
| "https://api.featherless.ai/v1/chat/completions", | |
| headers={ | |
| "Authorization": f"Bearer {settings.featherless_api_key}", | |
| "Content-Type": "application/json" | |
| }, | |
| json=payload | |
| ) | |
| if response.status_code != 200: | |
| print(f"Featherless Error ({model}): {response.status_code} - {response.text}") | |
| return "" | |
| data = response.json() | |
| return data["choices"][0]["message"]["content"] | |
| except Exception as e: | |
| print(f"Error calling Featherless ({model}): {e}") | |
| return "" | |
| async def call_groq(prompt: str, model: str = "llama-3.3-70b-versatile") -> str: | |
| if not settings.groq_api_key: | |
| return "" | |
| try: | |
| async with httpx.AsyncClient(timeout=60.0) as client: | |
| payload = { | |
| "model": model, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "temperature": 0.2 | |
| } | |
| if "json" in prompt.lower(): | |
| payload["response_format"] = {"type": "json_object"} | |
| response = await client.post( | |
| "https://api.groq.com/openai/v1/chat/completions", | |
| headers={ | |
| "Authorization": f"Bearer {settings.groq_api_key}", | |
| "Content-Type": "application/json" | |
| }, | |
| json=payload | |
| ) | |
| if response.status_code != 200: | |
| print(f"Groq Error ({model}): {response.status_code} - {response.text}") | |
| return "" | |
| data = response.json() | |
| return data["choices"][0]["message"]["content"] | |
| except Exception as e: | |
| print(f"Error calling Groq ({model}): {e}") | |
| return "" | |
| async def call_gemini_with_model(prompt: str, model_name: str | None = None, is_json: bool = False) -> str: | |
| model_map = { | |
| "Gemini 2.5 Flash": "gemini", | |
| "DeepSeek-V3 (Featherless)": "deepseek-ai/DeepSeek-V3", | |
| "Qwen-2.5 (Featherless)": "Qwen/Qwen2.5-72B-Instruct", | |
| "Llama-3.3-70B (Groq)": "groq:llama-3.3-70b-versatile", | |
| "Llama-3.1-8B (Groq)": "groq:llama-3.1-8b-instant", | |
| "Llama-3.1-70B (Groq)": "groq:llama-3.1-70b-versatile", | |
| "Mixtral-8x7B (Groq)": "groq:mixtral-8x7b-32768", | |
| "Gemma-2-9B (Featherless)": "google/gemma-2-9b-it", | |
| "Llama-3.1-8B (Featherless)": "meta-llama/Meta-Llama-3.1-8B-Instruct", | |
| "Llama-3.2-11B-Vision (Groq)": "groq:llama-3.2-11b-vision-preview", | |
| } | |
| model_id = model_map.get(model_name, "gemini") | |
| print(f"DEBUG: Calling LLM with model_name='{model_name}' -> model_id='{model_id}'") | |
| # Check keys | |
| if model_id.startswith("groq:") and not settings.groq_api_key: | |
| print("DEBUG WARNING: GROQ_API_KEY is missing! Falling back to Gemini.") | |
| model_id = "gemini" | |
| if model_id == "gemini": | |
| res = await call_gemini(prompt, is_json=is_json) | |
| if not res and settings.groq_api_key: | |
| print("DEBUG: Gemini failed or returned empty. Trying Groq fallback.") | |
| return await call_groq(prompt, "llama-3.3-70b-versatile") | |
| return res | |
| elif model_id.startswith("groq:"): | |
| # Check if it's a vision call (hacky way for now, but effective) | |
| if "IMAGE_DATA:" in prompt: | |
| parts = prompt.split("IMAGE_DATA:") | |
| text_prompt = parts[0].strip() | |
| image_b64 = parts[1].strip() | |
| res = await call_groq_vision(text_prompt, image_b64, model=model_id[5:]) | |
| else: | |
| res = await call_groq(prompt, model=model_id[5:]) | |
| if not res and settings.gemini_api_key: | |
| print("DEBUG: Groq failed or returned empty. Trying Gemini fallback.") | |
| return await call_gemini(prompt, is_json=is_json) | |
| return res | |
| else: | |
| res = await call_featherless(prompt, model=model_id) | |
| if not res and settings.groq_api_key: | |
| print("DEBUG: Featherless failed. Trying Groq fallback.") | |
| return await call_groq(prompt, "llama-3.3-70b-versatile") | |
| return res | |
| async def call_groq_vision(prompt: str, image_b64: str, model: str = "llama-3.2-11b-vision-preview") -> str: | |
| if not settings.groq_api_key: | |
| return "" | |
| try: | |
| async with httpx.AsyncClient(timeout=60.0) as client: | |
| # Ensure proper data URL format | |
| if not image_b64.startswith("data:image"): | |
| image_b64 = f"data:image/jpeg;base64,{image_b64}" | |
| payload = { | |
| "model": model, | |
| "messages": [ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "text", "text": prompt}, | |
| { | |
| "type": "image_url", | |
| "image_url": {"url": image_b64} | |
| } | |
| ] | |
| } | |
| ], | |
| "temperature": 0.2 | |
| } | |
| response = await client.post( | |
| "https://api.groq.com/openai/v1/chat/completions", | |
| headers={ | |
| "Authorization": f"Bearer {settings.groq_api_key}", | |
| "Content-Type": "application/json" | |
| }, | |
| json=payload | |
| ) | |
| if response.status_code != 200: | |
| print(f"Groq Vision Error ({model}): {response.status_code} - {response.text}") | |
| return "" | |
| data = response.json() | |
| return data["choices"][0]["message"]["content"] | |
| except Exception as e: | |
| print(f"Error calling Groq Vision ({model}): {e}") | |
| return "" | |
| def _parse_gemini_response(output: str) -> dict | None: | |
| if not output: | |
| return None | |
| # Remove Markdown code blocks if present | |
| clean_output = output.strip() | |
| if clean_output.startswith("```json"): | |
| clean_output = clean_output[7:-3].strip() | |
| elif clean_output.startswith("```"): | |
| clean_output = clean_output[3:-3].strip() | |
| try: | |
| data = json.loads(clean_output) | |
| except Exception as e: | |
| print(f"JSON Parsing Error: {e}\nRaw Output: {output[:200]}...") | |
| return None | |
| if data: | |
| # Handle nesting (LLMs sometimes wrap the result in a key) | |
| if not all(k in data for k in ["fit_score", "decision", "risks"]): | |
| for val in data.values(): | |
| if isinstance(val, dict) and any(k in val for k in ["fit_score", "decision", "risks"]): | |
| data = val | |
| break | |
| # Ensure strategic_roadmap is a string | |
| if "strategic_roadmap" in data: | |
| if isinstance(data["strategic_roadmap"], list): | |
| data["strategic_roadmap"] = "\n".join([str(item) for item in data["strategic_roadmap"]]) | |
| elif isinstance(data["strategic_roadmap"], dict): | |
| data["strategic_roadmap"] = json.dumps(data["strategic_roadmap"], indent=2, ensure_ascii=False) | |
| # Ensure risks is a list of objects | |
| if "risks" in data and isinstance(data["risks"], list): | |
| new_risks = [] | |
| for item in data["risks"]: | |
| if isinstance(item, str): | |
| new_risks.append({"title": item, "severity": "Medium", "explanation": item}) | |
| elif isinstance(item, dict): | |
| new_risks.append(item) | |
| data["risks"] = new_risks | |
| # Ensure action_plan is a list of objects | |
| if "action_plan" in data and isinstance(data["action_plan"], list): | |
| new_plan = [] | |
| for item in data["action_plan"]: | |
| if isinstance(item, str): | |
| new_plan.append({"task": item, "priority": "Medium", "owner": "Team", "timeline": "TBD"}) | |
| elif isinstance(item, dict): | |
| new_plan.append(item) | |
| data["action_plan"] = new_plan | |
| # Ensure fit_score is int | |
| if "fit_score" in data: | |
| try: | |
| data["fit_score"] = int(data["fit_score"]) | |
| except: | |
| data["fit_score"] = 0 | |
| return data | |
| return None | |
| def generate_mock_analysis(tender: Tender, company: CompanyProfile) -> AnalysisResult: | |
| raw = f"{tender.code}:{tender.name}:{company.name}" | |
| digest = hashlib.sha256(raw.encode("utf-8")).hexdigest() | |
| score = int(digest[:8], 16) % 41 + 55 | |
| return AnalysisResult( | |
| fit_score=score, | |
| decision="Recommended" if score > 75 else "Review Carefully", | |
| executive_summary=f"An谩lisis autom谩tico para {tender.name}. Se observa un encaje t茅cnico razonable.", | |
| key_requirements=["Documentaci贸n legal", "Experiencia t茅cnica", "Garant铆a de seriedad"], | |
| risks=[{"title": "Plazo ajustado", "severity": "Medium", "explanation": "El tiempo de entrega es cr铆tico."}], | |
| compliance_gaps=["Validar boleta de garant铆a"], | |
| action_plan=[{"task": "Revisar bases", "priority": "High", "owner": "Legal", "timeline": "2 d铆as"}], | |
| proposal_draft="Borrador generado autom谩ticamente...", | |
| report_markdown="# Reporte de Licitaci贸n", | |
| audit_log=["Iniciando an谩lisis de respaldo...", "Generando datos mock."] | |
| ) | |
| async def generate_analysis(tender: Tender, company: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult: | |
| chosen = models or { | |
| "legal": "Llama-3.3-70B (Groq)" if settings.groq_api_key else "Gemini 2.5 Flash", | |
| "tech": "Llama-3.1-8B (Groq)" if settings.groq_api_key else "Qwen-2.5 (Featherless)", | |
| "risk": "Llama-3.3-70B (Groq)" if settings.groq_api_key else "Qwen-2.5 (Featherless)" | |
| } | |
| audit_messages = ["馃殌 Launching Multi-Agent Orchestration Pipeline."] | |
| agent_outputs = {} | |
| agent_definitions = { | |
| "legal": "Experto Legal & Cumplimiento: Eval煤a bases administrativas, multas y garant铆as. Pon especial atenci贸n a los ANEXOS de Sustentabilidad y Admisibilidad.", | |
| "tech": "Ingeniero T茅cnico: Eval煤a arquitectura, stack tecnol贸gico y capacidad de ejecuci贸n. Considera si se requieren certificaciones ambientales.", | |
| "risk": "Estratega Comercial: Eval煤a rentabilidad, competencia y riesgos de mercado. Analiza el impacto de los criterios de evaluaci贸n ESG en el puntaje final." | |
| } | |
| for agent_id, role_desc in agent_definitions.items(): | |
| model_name = chosen.get(agent_id, "Gemini 2.5 Flash") | |
| audit_messages.append(f"馃 Agent {agent_id.upper()} calling {model_name}...") | |
| agent_prompt = f""" | |
| Act煤a como {role_desc} | |
| Licitaci贸n: {tender.name} ({tender.code}) | |
| Empresa: {company.name} | |
| Contexto Adicional: {document_text[:5000] if document_text else 'No adjunto.'} | |
| PROPORCIONA TU AN脕LISIS ESPEC脥FICO (M谩x 200 palabras) EN ESPA脩OL. | |
| """ | |
| res = await call_gemini_with_model(agent_prompt, model_name=model_name) | |
| agent_outputs[agent_id] = res or "An谩lisis no disponible debido a error de conexi贸n." | |
| audit_messages.append("馃 Synthesis phase: Consolidating agent insights...") | |
| synthesis_prompt = f""" | |
| SISTEMA DE CONSENSO ANDESOPS AI | |
| Licitaci贸n: {tender.name} | |
| Resultados de Agentes: | |
| - LEGAL: {agent_outputs.get('legal')} | |
| - TECH: {agent_outputs.get('tech')} | |
| - RISK: {agent_outputs.get('risk')} | |
| Genera el JSON final AnalysisResult con una decisi贸n fundamentada. | |
| RESPONDE SOLO EL JSON. | |
| """ | |
| final_json = await call_gemini(synthesis_prompt, is_json=True) | |
| if not final_json and settings.groq_api_key: | |
| final_json = await call_groq(synthesis_prompt, model="llama-3.3-70b-versatile") | |
| elif not final_json and settings.featherless_api_key: | |
| final_json = await call_featherless(synthesis_prompt, model="Qwen/Qwen2.5-72B-Instruct") | |
| parse_result = _parse_gemini_response(final_json) | |
| if parse_result: | |
| try: | |
| if not parse_result.get("report_markdown"): | |
| parse_result["report_markdown"] = generate_markdown_report(parse_result) | |
| if not parse_result.get("proposal_draft") or len(parse_result["proposal_draft"]) < 100: | |
| audit_messages.append("馃摑 Generating specialized proposal draft...") | |
| parse_result["proposal_draft"] = await generate_proposal_draft(parse_result, company) | |
| result = AnalysisResult(**parse_result) | |
| result.audit_log = audit_messages + (result.audit_log or []) | |
| return result | |
| except Exception as e: | |
| print(f"Validation Error in generate_analysis: {e}") | |
| analysis = generate_mock_analysis(tender, company) | |
| analysis.audit_log = audit_messages + ["鈿狅笍 Synthesis failed, using emergency fallback."] | |
| return analysis | |
| async def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> str: | |
| prompt = f""" | |
| Como experto redactor de propuestas de licitaci贸n, genera un borrador profesional (en Markdown) basado en este an谩lisis t茅cnico: | |
| {analysis.get('executive_summary', 'Analizar bases adjuntas.')} | |
| Perfil de la Empresa: {company.name} - {company.experience} | |
| Requisitos Cr铆ticos a Abordar: {', '.join(analysis.get('key_requirements', []))} | |
| Estructura la propuesta en ESPA脩OL con: | |
| 1. Introducci贸n Ejecutiva | |
| 2. Resumen de la Soluci贸n T茅cnica | |
| 3. Aseguramiento de Cumplimiento (Compliance) | |
| 4. Propuesta de Valor Estrat茅gica | |
| """ | |
| return await call_gemini_with_model(prompt, model_name="Llama-3.3-70B (Groq)" if settings.groq_api_key else "Gemini 2.5 Flash") | |
| async def generate_synthetic_tenders(keyword: str) -> list[Tender]: | |
| """ | |
| Generates realistic synthetic tenders with coherent bidding documents (bases) | |
| when official sources are unavailable or empty. | |
| """ | |
| prompt = f""" | |
| Genera 4 licitaciones de Mercado P煤blico CHILE realistas para el rubro: {keyword} | |
| Para cada licitaci贸n, genera un JSON con: | |
| - code: Formato XXXXX-XX-XX26 | |
| - name: Nombre profesional | |
| - buyer: Una instituci贸n p煤blica chilena real | |
| - description: UN DOCUMENTO EXTENSO de 'Bases Administrativas y T茅cnicas' (m铆nimo 300 palabras) | |
| que incluya: Objeto de licitaci贸n, Requisitos t茅cnicos, Plazos, Multas y Criterios de Evaluaci贸n. | |
| - status: 'Publicada' | |
| - closing_date: ISO date en 2 semanas | |
| - estimated_amount: Monto en CLP entre 5M y 50M | |
| - region: Una regi贸n de Chile | |
| RESPONDE SOLO EL JSON (Lista de objetos). | |
| """ | |
| res = await call_gemini(prompt, is_json=True) | |
| items = [] | |
| try: | |
| data = json.loads(res) | |
| # Handle if LLM wraps in a key | |
| if isinstance(data, dict): | |
| for v in data.values(): | |
| if isinstance(v, list): | |
| data = v | |
| break | |
| for i in data: | |
| items.append(Tender( | |
| code=i.get("code", "000-00-00"), | |
| name=i.get("name", "Licitaci贸n Sint茅tica"), | |
| description=i.get("description", "Documento de bases en proceso..."), | |
| buyer=i.get("buyer", "Organismo P煤blico"), | |
| status=i.get("status", "Publicada"), | |
| closing_date=i.get("closing_date", datetime.now().isoformat()), | |
| estimated_amount=float(i.get("estimated_amount", 0)), | |
| source="AndesOps AI - Intelligent Discovery", | |
| region=i.get("region", "Nacional"), | |
| sector="Privado/P煤blico", | |
| items=[], | |
| attachments=[{ | |
| "name": "Bases_Tecnicas_y_Administrativas.pdf", | |
| "url": "#synthetic-doc", | |
| "type": "pdf" | |
| }] | |
| )) | |
| except Exception as e: | |
| print(f"Error generating synthetic tenders: {e}") | |
| return items | |