脕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