Álvaro Valenzuela Valdes commited on
Commit
863be56
·
1 Parent(s): 5642e68

feat: implement Groq integration, robust multi-agent synthesis and UX performance optimization

Browse files
.gitignore CHANGED
@@ -18,3 +18,6 @@ frontend/npm-debug.log*
18
  *.db
19
  *.sqlite
20
  .vscode/
 
 
 
 
18
  *.db
19
  *.sqlite
20
  .vscode/
21
+ backend/output.txt
22
+ backend/scratch_*.py
23
+ backend/scratch_test_analysis.py
backend/app/config.py CHANGED
@@ -6,6 +6,7 @@ class Settings(BaseSettings):
6
  gemini_api_key: str | None = None
7
  gemini_model: str = "gemini-2.5-flash"
8
  featherless_api_key: str | None = None
 
9
  next_public_api_base: str | None = None
10
  database_url: str | None = None
11
 
 
6
  gemini_api_key: str | None = None
7
  gemini_model: str = "gemini-2.5-flash"
8
  featherless_api_key: str | None = None
9
+ groq_api_key: str | None = None
10
  next_public_api_base: str | None = None
11
  database_url: str | None = None
12
 
backend/app/services/agents.py CHANGED
@@ -1,47 +1,40 @@
1
- import json
2
  import asyncio
3
- from typing import List, Dict, Any
4
- from app.schemas.analysis import AnalysisResult, RiskItem, ActionItem
5
  from app.schemas.company import CompanyProfile
6
  from app.schemas.tender import Tender
7
- from app.services.llm import generate_analysis, call_gemini, _parse_gemini_response, call_gemini_with_model
8
  from app.services.report import generate_markdown_report
 
9
 
10
  async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
11
  prompt = (
12
  f"AGENT ROLE: Legal & Compliance Expert (Chilean Public Procurement)\n"
13
  f"GOAL: Analyze administrative bases and compliance risks.\n"
14
  f"TENDER: {tender.name} (Type: {tender.type})\n"
15
- f"STATUS: {tender.status} (Code: {tender.status_code})\n"
16
- f"DATES: Published: {tender.publication_date}, Closing: {tender.closing_date}\n"
17
- f"COMPANY: {company.name} (Docs: {', '.join(company.documents_available)})\n"
18
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
19
- f"TASK: Identify 3 legal gaps. Analyze if the timeline is 'Express' (suspiciously short) for the tender type {tender.type}. Verify if company documents meet common requirements for this tender level."
20
  )
21
  return await call_gemini_with_model(prompt, model)
22
 
23
  async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
24
- items_summary = ", ".join([f"{i.name} ({i.quantity} {i.unit})" for i in (tender.items or [])[:10]])
25
  prompt = (
26
  f"AGENT ROLE: Technical Architect\n"
27
- f"GOAL: Evaluate technical feasibility and product-market fit.\n"
28
  f"TENDER: {tender.name} - {tender.description}\n"
29
- f"LINE ITEMS: {items_summary}\n"
30
  f"COMPANY: {company.industry} - {company.experience}\n"
31
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
32
- f"TASK: Analyze specific LINE ITEMS against company capabilities. Identify 3 technical challenges. Is this a generic 'buy' or a complex project?"
33
  )
34
  return await call_gemini_with_model(prompt, model)
35
 
36
  async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
37
  prompt = (
38
  f"AGENT ROLE: Risk & Strategy Specialist\n"
39
- f"GOAL: Calculate ROI, competitive risks, and overall strategy.\n"
40
  f"TENDER: {tender.name}\n"
41
- f"AMOUNT: {tender.estimated_amount} {tender.currency}\n"
42
- f"DATES: Closing on {tender.closing_date}\n"
43
  f"COMPANY: {company.name}\n"
44
- f"TASK: Identify 3 strategic risks. Pay special attention to CURRENCY RISK if not CLP ({tender.currency}). Suggest a 'Win Strategy' based on the tender type {tender.type}."
45
  )
46
  return await call_gemini_with_model(prompt, model)
47
 
@@ -51,49 +44,58 @@ async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, doc
51
 
52
  # Use selected models or defaults
53
  chosen_models = models or {
54
- "legal": "Gemini 2.5 Flash",
55
- "tech": "DeepSeek-V3.2 (Featherless)",
56
- "risk": "Qwen-2.5 (Featherless)"
57
  }
58
 
59
- audit_log.append(f"👨‍⚖️ Agente Legal ({chosen_models.get('legal')}): Analizando bases...")
60
- audit_log.append(f"👨‍💻 Agente Técnico ({chosen_models.get('tech')}): Evaluando requerimientos...")
61
- audit_log.append(f"🕵️ Agente de Riesgo ({chosen_models.get('risk')}): Escaneando competitividad...")
62
 
63
- legal_task = legal_agent_task(tender, company_profile, doc_text, chosen_models.get("legal"))
64
- tech_task = technical_agent_task(tender, company_profile, doc_text, chosen_models.get("tech"))
65
- strat_task = strategy_agent_task(tender, company_profile, doc_text, chosen_models.get("risk"))
 
 
66
 
67
- responses = await asyncio.gather(legal_task, tech_task, strat_task)
68
  legal_resp, tech_resp, strat_resp = responses
69
 
70
- audit_log.append("💡 Consolidando hallazgos de los expertos...")
71
 
72
- # Final Synthesis
73
  synthesis_prompt = (
74
- f"Eres el ORQUESTADOR DE ANDESOPS AI.\n"
75
- f"Has recibido reportes de tus expertos para la licitación: {tender.name}.\n\n"
76
- f"REPORTE LEGAL: {legal_resp}\n\n"
77
- f"REPORTE TÉCNICO: {tech_resp}\n\n"
78
- f"REPORTE ESTRATÉGICO: {strat_resp}\n\n"
79
- f"DATOS DE LA EMPRESA: {company_profile.model_dump_json()}\n\n"
80
- f"INSTRUCCIONES CRÍTICAS:\n"
81
- f"1. Genera el AnalysisResult final en JSON.\n"
82
- f"2. Identifica 3-5 preguntas o requerimientos críticos del texto de las bases (o del contexto) y genera respuestas en estilo 'Q&A'.\n"
83
- f"3. Cada respuesta debe usar los datos reales de la empresa (RUT, experiencia, etc.) para responder de forma profesional.\n"
84
- f"4. El campo en el JSON debe ser 'requirement_responses' como una lista de {{'question', 'answer'}}.\n"
85
- f"5. Ejemplo: {{'question': '¿Cuenta con RUT vigente?', 'answer': 'SÍ, nuestra empresa posee RUT 77... y está habilitada en el SII.'}}\n"
86
- f"6. Incluye un 'strategic_roadmap' con 3 fases.\n"
87
- f"Responde ÚNICAMENTE con el JSON."
 
 
 
88
  )
89
 
90
- final_output = await call_gemini(synthesis_prompt)
 
 
 
 
 
 
91
  parse_result = _parse_gemini_response(final_output)
92
 
93
  if parse_result:
94
  try:
95
- audit_log.append(f"✅ Síntesis completada con éxito.")
96
-
97
  if not parse_result.get("report_markdown"):
98
  parse_result["report_markdown"] = generate_markdown_report(parse_result)
99
 
@@ -101,8 +103,8 @@ async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, doc
101
  result.audit_log = audit_log + (result.audit_log or [])
102
  return result
103
  except Exception as e:
104
- print(f"Synthesis Error: {e}")
105
 
106
- # Fallback
107
  from app.services.llm import generate_analysis
108
  return await generate_analysis(tender, company_profile, doc_text, models)
 
 
1
  import asyncio
2
+ from app.schemas.analysis import AnalysisResult
 
3
  from app.schemas.company import CompanyProfile
4
  from app.schemas.tender import Tender
5
+ from app.services.llm import call_gemini, _parse_gemini_response, call_gemini_with_model
6
  from app.services.report import generate_markdown_report
7
+ from app.config import settings
8
 
9
  async def legal_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
10
  prompt = (
11
  f"AGENT ROLE: Legal & Compliance Expert (Chilean Public Procurement)\n"
12
  f"GOAL: Analyze administrative bases and compliance risks.\n"
13
  f"TENDER: {tender.name} (Type: {tender.type})\n"
14
+ f"COMPANY: {company.name}\n"
 
 
15
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
16
+ f"TASK: Identify 3 legal gaps/risks. Respond in Spanish."
17
  )
18
  return await call_gemini_with_model(prompt, model)
19
 
20
  async def technical_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
 
21
  prompt = (
22
  f"AGENT ROLE: Technical Architect\n"
23
+ f"GOAL: Evaluate technical feasibility.\n"
24
  f"TENDER: {tender.name} - {tender.description}\n"
 
25
  f"COMPANY: {company.industry} - {company.experience}\n"
26
  f"EXTRACTED TEXT: {document_text[:5000]}\n"
27
+ f"TASK: Identify 3 technical challenges. Respond in Spanish."
28
  )
29
  return await call_gemini_with_model(prompt, model)
30
 
31
  async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
32
  prompt = (
33
  f"AGENT ROLE: Risk & Strategy Specialist\n"
34
+ f"GOAL: Calculate ROI and strategy.\n"
35
  f"TENDER: {tender.name}\n"
 
 
36
  f"COMPANY: {company.name}\n"
37
+ f"TASK: Identify 3 strategic risks and a win strategy. Respond in Spanish."
38
  )
39
  return await call_gemini_with_model(prompt, model)
40
 
 
44
 
45
  # Use selected models or defaults
46
  chosen_models = models or {
47
+ "legal": "Llama-3.3-70B (Groq)" if settings.groq_api_key else "Gemini 2.5 Flash",
48
+ "tech": "Llama-3.1-8B (Groq)" if settings.groq_api_key else "Qwen-2.5 (Featherless)",
49
+ "risk": "Llama-3.3-70B (Groq)" if settings.groq_api_key else "Qwen-2.5 (Featherless)"
50
  }
51
 
52
+ audit_log.append(f"👨‍⚖️ Agente Legal ({chosen_models.get('legal')})")
53
+ audit_log.append(f"👨‍💻 Agente Técnico ({chosen_models.get('tech')})")
54
+ audit_log.append(f"🕵️ Agente de Riesgo ({chosen_models.get('risk')})")
55
 
56
+ tasks = [
57
+ legal_agent_task(tender, company_profile, doc_text, chosen_models.get("legal")),
58
+ technical_agent_task(tender, company_profile, doc_text, chosen_models.get("tech")),
59
+ strategy_agent_task(tender, company_profile, doc_text, chosen_models.get("risk"))
60
+ ]
61
 
62
+ responses = await asyncio.gather(*tasks)
63
  legal_resp, tech_resp, strat_resp = responses
64
 
65
+ audit_log.append("💡 Consolidando hallazgos...")
66
 
 
67
  synthesis_prompt = (
68
+ f"SISTEMA DE CONSENSO ANDESOPS AI\n"
69
+ f"Licitación: {tender.name}\n"
70
+ f"Reporte Legal: {legal_resp}\n"
71
+ f"Reporte Técnico: {tech_resp}\n"
72
+ f"Reporte Estratégico: {strat_resp}\n\n"
73
+ f"Genera un JSON 'AnalysisResult' siguiendo estas reglas:\n"
74
+ f"1. fit_score (int 0-100)\n"
75
+ f"2. decision ('Recommended', 'Review Carefully', 'Not Recommended')\n"
76
+ f"3. executive_summary (string)\n"
77
+ f"4. risks (list of {{title, severity, explanation}})\n"
78
+ f"5. key_requirements (list of strings)\n"
79
+ f"6. compliance_gaps (list of strings)\n"
80
+ f"7. action_plan (list of {{task, priority, owner, timeline}})\n"
81
+ f"8. strategic_roadmap (string Markdown)\n"
82
+ f"9. proposal_draft (string Markdown)\n"
83
+ f"10. report_markdown (string Markdown)\n"
84
+ f"Responde ÚNICAMENTE con el JSON plano."
85
  )
86
 
87
+ final_output = await call_gemini(synthesis_prompt, is_json=True)
88
+
89
+ # Fallback for synthesis if Gemini/Groq failed to return valid JSON
90
+ if not final_output and settings.groq_api_key:
91
+ from app.services.llm import call_groq
92
+ final_output = await call_groq(synthesis_prompt, "llama-3.3-70b-versatile")
93
+
94
  parse_result = _parse_gemini_response(final_output)
95
 
96
  if parse_result:
97
  try:
98
+ # Ensure report_markdown exists
 
99
  if not parse_result.get("report_markdown"):
100
  parse_result["report_markdown"] = generate_markdown_report(parse_result)
101
 
 
103
  result.audit_log = audit_log + (result.audit_log or [])
104
  return result
105
  except Exception as e:
106
+ print(f"Synthesis Validation Error: {e}")
107
 
108
+ # Ultimate fallback to the logic in llm.py
109
  from app.services.llm import generate_analysis
110
  return await generate_analysis(tender, company_profile, doc_text, models)
backend/app/services/llm.py CHANGED
@@ -1,145 +1,187 @@
1
  import hashlib
2
  import json
3
- import re
4
- from typing import Any
5
-
6
- import google.generativeai as genai
7
  import httpx
 
8
  from app.config import settings
9
- from app.schemas.analysis import AnalysisResult
10
- from app.schemas.company import CompanyProfile
11
- from app.schemas.tender import Tender
12
  from app.services.report import generate_markdown_report
13
 
14
  # Configure Gemini
15
- if settings.gemini_api_key:
16
- genai.configure(api_key=settings.gemini_api_key)
17
 
18
- def get_gemini_model():
19
- return genai.GenerativeModel(
20
- model_name=settings.gemini_model,
21
- generation_config={
 
 
22
  "temperature": 0.2,
23
  "top_p": 0.95,
24
- "top_k": 64,
25
  "max_output_tokens": 8192,
26
- "response_mime_type": "application/json",
27
  }
28
- )
29
-
30
- async def call_gemini(prompt: str) -> str:
31
- if not settings.gemini_api_key:
32
- # Fallback to Featherless if Gemini not available
33
- return await call_featherless(prompt, "meta-llama/Llama-3.3-70B-Instruct")
34
-
35
- try:
36
- model = get_gemini_model()
37
- # Use run_in_executor if the library is blocking, or use async generate_content if supported
38
  response = await model.generate_content_async(prompt)
39
  return response.text
40
  except Exception as e:
41
- print(f"Error calling Gemini: {e}, trying Featherless fallback...")
42
- return await call_featherless(prompt, "meta-llama/Llama-3.3-70B-Instruct")
 
 
43
 
44
- async def call_featherless(prompt: str, model: str = "deepseek-ai/DeepSeek-V3.2") -> str:
45
  if not settings.featherless_api_key:
46
  return ""
47
 
48
  try:
49
  async with httpx.AsyncClient(timeout=60.0) as client:
 
 
 
 
 
 
 
 
50
  response = await client.post(
51
  "https://api.featherless.ai/v1/chat/completions",
52
  headers={
53
  "Authorization": f"Bearer {settings.featherless_api_key}",
54
  "Content-Type": "application/json"
55
  },
56
- json={
57
- "model": model,
58
- "messages": [{"role": "user", "content": prompt}],
59
- "response_format": {"type": "json_object"},
60
- "temperature": 0.2
61
- }
62
  )
 
 
 
63
  data = response.json()
64
  return data["choices"][0]["message"]["content"]
65
  except Exception as e:
66
  print(f"Error calling Featherless ({model}): {e}")
67
  return ""
68
 
69
- async def call_gemini_with_model(prompt: str, model_name: str | None = None) -> str:
70
- # Default model mapping
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  model_map = {
72
  "Gemini 2.5 Flash": "gemini",
73
- "DeepSeek-V3.2 (Featherless)": "deepseek-ai/DeepSeek-V3.2",
74
- "Qwen-3-32B (Featherless)": "Qwen/Qwen3-32B",
75
  "Qwen-2.5 (Featherless)": "Qwen/Qwen2.5-72B-Instruct",
76
- "Gemma-4-31B (Featherless)": "google/gemma-4-31B-it",
77
- "Llama-3.1-8B (Featherless)": "meta-llama/Meta-Llama-3.1-8B-Instruct"
 
78
  }
79
 
80
  model_id = model_map.get(model_name, "gemini")
81
 
82
  if model_id == "gemini":
83
- return await call_gemini(prompt)
 
 
 
 
 
84
  else:
85
  return await call_featherless(prompt, model=model_id)
86
 
87
- def _normalize_gemini_output(output: str) -> str:
88
  if not output:
89
- return output
90
-
91
- text = output.strip()
92
- if text.startswith("```"):
93
- parts = text.split("```")
94
- if len(parts) >= 2:
95
- first_part = parts[1].strip()
96
- if first_part.lower().startswith("json"):
97
- text = first_part[4:].strip()
98
- else:
99
- text = first_part
100
 
101
- return text
102
-
103
- def _parse_gemini_response(output: str) -> dict[str, Any] | None:
104
- candidate = _normalize_gemini_output(output)
 
 
 
105
  try:
106
- return json.loads(candidate)
107
- except json.JSONDecodeError:
108
- json_match = re.search(r"\{.*\}", candidate, re.DOTALL)
109
- if json_match:
110
- try:
111
- return json.loads(json_match.group(0))
112
- except:
113
- pass
114
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- def _build_analysis_prompt(tender: Tender, company: CompanyProfile, document_text: str | None = None) -> str:
117
- doc_context = f"\n\nCONTENIDO DE LAS BASES (DOCUMENTO ADJUNTO):\n{document_text}\n" if document_text else ""
118
- return (
119
- "Eres un Sistema Multi-Agente de Élite para análisis de licitaciones públicas en Chile (Mercado Público).\n"
120
- "Tu objetivo es realizar un análisis exhaustivo y colaborativo entre tres expertos virtuales:\n\n"
121
- "1. **Agente Legal & Cumplimiento**: Enfocado en bases administrativas, plazos y requisitos de documentos.\n"
122
- "2. **Agente Técnico**: Enfocado en el encaje de la oferta, arquitectura y capacidades de ejecución.\n"
123
- "3. **Agente de Riesgos & Estrategia**: Enfocado en rentabilidad, riesgos del proyecto y competitividad.\n\n"
124
- "INSTRUCCIONES:\n"
125
- "- Analiza la licitación, el perfil de la empresa y el contenido de las bases adjuntas si están disponibles.\n"
126
- "- Genera una decisión consensuada.\n"
127
- "- Devuelve un objeto JSON con la estructura exacta de 'AnalysisResult'.\n\n"
128
- "CAMPOS REQUERIDOS EN EL JSON:\n"
129
- "- fit_score: (0-100) puntaje de encaje.\n"
130
- "- decision: 'Recommended', 'Review Carefully' o 'Not Recommended'.\n"
131
- "- executive_summary: Resumen ejecutivo profesional en español.\n"
132
- "- key_requirements: Lista de los 5 requisitos más críticos.\n"
133
- "- risks: Lista de objetos {title, severity: 'High'|'Medium'|'Low', explanation}.\n"
134
- "- compliance_gaps: Lista de posibles brechas o documentos faltantes.\n"
135
- "- action_plan: Lista de objetos {task, priority, owner, timeline}.\n"
136
- "- proposal_draft: Un borrador de propuesta comercial (Markdown) convincente.\n"
137
- "- audit_log: Lista de pasos que tomaron los agentes para llegar a esta conclusión.\n\n"
138
- f"DATOS DE LA LICITACIÓN:\n{tender.model_dump_json(indent=2)}\n\n"
139
- f"DATOS DE LA EMPRESA:\n{company.model_dump_json(indent=2)}\n"
140
- f"{doc_context}\n\n"
141
- "RESPONDE ÚNICAMENTE CON EL JSON VÁLIDO."
142
- )
143
 
144
  def generate_mock_analysis(tender: Tender, company: CompanyProfile) -> AnalysisResult:
145
  raw = f"{tender.code}:{tender.name}:{company.name}"
@@ -160,26 +202,15 @@ def generate_mock_analysis(tender: Tender, company: CompanyProfile) -> AnalysisR
160
  )
161
 
162
  async def generate_analysis(tender: Tender, company: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
163
- # Default model mapping
164
- model_map = {
165
- "Gemini 2.5 Flash": "gemini",
166
- "DeepSeek-V3.2 (Featherless)": "deepseek-ai/DeepSeek-V3.2",
167
- "Qwen-3-32B (Featherless)": "Qwen/Qwen3-32B",
168
- "Gemma-4-31B (Featherless)": "google/gemma-4-31B-it",
169
- "Llama-3.1-8B (Featherless)": "meta-llama/Meta-Llama-3.1-8B-Instruct"
170
- }
171
-
172
- # Get selected models or defaults
173
  chosen = models or {
174
- "legal": "Gemini 2.5 Flash",
175
- "tech": "DeepSeek-V3.2 (Featherless)",
176
- "risk": "Qwen-3-32B (Featherless)"
177
  }
178
 
179
  audit_messages = ["🚀 Launching Multi-Agent Orchestration Pipeline."]
180
  agent_outputs = {}
181
 
182
- # Define Agent roles for separate calls
183
  agent_definitions = {
184
  "legal": "Experto Legal & Cumplimiento: Evalúa bases administrativas, multas y garantías. Pon especial atención a los ANEXOS de Sustentabilidad y Admisibilidad.",
185
  "tech": "Ingeniero Técnico: Evalúa arquitectura, stack tecnológico y capacidad de ejecución. Considera si se requieren certificaciones ambientales.",
@@ -188,8 +219,6 @@ async def generate_analysis(tender: Tender, company: CompanyProfile, document_te
188
 
189
  for agent_id, role_desc in agent_definitions.items():
190
  model_name = chosen.get(agent_id, "Gemini 2.5 Flash")
191
- model_id = model_map.get(model_name, "gemini")
192
-
193
  audit_messages.append(f"🤖 Agent {agent_id.upper()} calling {model_name}...")
194
 
195
  agent_prompt = f"""
@@ -201,48 +230,36 @@ async def generate_analysis(tender: Tender, company: CompanyProfile, document_te
201
  PROPORCIONA TU ANÁLISIS ESPECÍFICO (Máx 200 palabras) EN ESPAÑOL.
202
  """
203
 
204
- res = ""
205
- if model_id == "gemini":
206
- res = await call_gemini(agent_prompt)
207
- # Failover to Featherless if Gemini fails (e.g. 429)
208
- if not res and settings.featherless_api_key:
209
- audit_messages.append(f"🔄 Gemini failed/rate-limited. Switching to DeepSeek for {agent_id.upper()}...")
210
- res = await call_featherless(agent_prompt, model="deepseek-ai/DeepSeek-V3.2")
211
- else:
212
- res = await call_featherless(agent_prompt, model=model_id)
213
-
214
- agent_outputs[agent_id] = res or "Análisis no disponible por error de API."
215
 
216
- # FINAL CONSENSUS AGENT (Synthesis + Roadmap)
217
- audit_messages.append("⚖️ Final Consensus Agent synthesizing results & roadmap...")
218
- synthesis_prompt = f"""
219
- Eres el AGENTE DE CONSENSO Y ESTRATEGIA. Debes unificar estos 3 análisis en un único JSON de 'AnalysisResult':
220
-
221
- 1. LEGAL: {agent_outputs.get('legal')}
222
- 2. TECH: {agent_outputs.get('tech')}
223
- 3. RISK: {agent_outputs.get('risk')}
224
 
225
- Genera el JSON final siguiendo estas reglas:
226
- - executive_summary: Resumen integrador en español.
227
- - fit_score: Promedio de encaje (0-100).
228
- - decision: 'Recommended', 'Review Carefully' o 'Not Recommended'.
229
- - risks, key_requirements, compliance_gaps.
230
- - action_plan: Pasos concretos de ejecución.
231
- - strategic_roadmap: (NUEVO) Un roadmap de 3 fases para ganar esta licitación.
232
- - audit_log: Incluye los pasos tomados.
233
 
 
234
  RESPONDE SOLO EL JSON.
235
  """
236
 
237
- final_json = await call_gemini(synthesis_prompt)
238
- if not final_json and settings.featherless_api_key:
239
- final_json = await call_featherless(synthesis_prompt, model="deepseek-ai/DeepSeek-V3.2")
 
 
240
 
241
  parse_result = _parse_gemini_response(final_json)
242
 
243
  if parse_result:
244
  try:
245
- # FORCE PROPOSAL GENERATION
 
 
246
  if not parse_result.get("proposal_draft") or len(parse_result["proposal_draft"]) < 100:
247
  audit_messages.append("📝 Generating specialized proposal draft...")
248
  parse_result["proposal_draft"] = await generate_proposal_draft(parse_result, company)
@@ -251,9 +268,8 @@ async def generate_analysis(tender: Tender, company: CompanyProfile, document_te
251
  result.audit_log = audit_messages + (result.audit_log or [])
252
  return result
253
  except Exception as e:
254
- print(f"Synthesis Mapping Error: {e}")
255
-
256
- # Final Fallback
257
  analysis = generate_mock_analysis(tender, company)
258
  analysis.audit_log = audit_messages + ["⚠️ Synthesis failed, using emergency fallback."]
259
  return analysis
@@ -273,56 +289,4 @@ async def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> st
273
  4. Propuesta de Valor Estratégica
274
  """
275
 
276
- # Use DeepSeek for high-quality drafting if available
277
- if settings.featherless_api_key:
278
- draft = await call_featherless(prompt, model="deepseek-ai/DeepSeek-V3.2")
279
- if draft: return draft
280
-
281
- # Fallback to Gemini
282
- return await call_gemini(prompt) or "Error al generar el borrador de la propuesta."
283
-
284
- async def generate_synthetic_tenders(keyword: str) -> list[Tender]:
285
- """
286
- Generates realistic-looking synthetic tenders using an LLM.
287
- Used as a fallback when the real scraper is blocked.
288
- """
289
- prompt = f"""
290
- Genera 5 oportunidades de licitación (Compra Ágil) realistas en Chile para el rubro: '{keyword}'.
291
-
292
- Cada oportunidad debe tener:
293
- - code: Un código ficticio pero realista (ej: COT26-123-AG24).
294
- - name: Un título profesional (ej: Adquisición de Licencias de Software para RRHH).
295
- - buyer: Nombre de un organismo público real de Chile (ej: Municipalidad de Santiago, Ministerio de Salud).
296
- - status: 'Publicada'.
297
- - closing_date: Una fecha en los próximos 7 días (YYYY-MM-DD).
298
- - description: Una descripción breve de 2 párrafos sobre lo que se necesita.
299
- - estimated_amount: Un monto en pesos chilenos (CLP) razonable (entre 1.000.000 y 30.000.000).
300
- - region: Una región de Chile.
301
- - sector: 'Software y Tecnología' (o el rubro correspondiente).
302
-
303
- Responde ÚNICAMENTE un JSON con una lista de objetos bajo la llave 'tenders'.
304
- """
305
-
306
- res = await call_gemini(prompt)
307
- if not res and settings.featherless_api_key:
308
- res = await call_featherless(prompt)
309
-
310
- data = _parse_gemini_response(res)
311
- results = []
312
- if data and "tenders" in data:
313
- for t in data["tenders"]:
314
- results.append(Tender(
315
- code=t.get("code", "SYN-001"),
316
- name=t.get("name", "Oportunidad Sintética"),
317
- buyer=t.get("buyer", "Organismo Público"),
318
- status=t.get("status", "Publicada"),
319
- closing_date=t.get("closing_date"),
320
- description=t.get("description", ""),
321
- estimated_amount=float(t.get("estimated_amount", 0)),
322
- source="AndesOps AI Synthetic Intelligence",
323
- region=t.get("region", "Nacional"),
324
- sector="Compra Ágil",
325
- items=[],
326
- attachments=[]
327
- ))
328
- return results
 
1
  import hashlib
2
  import json
 
 
 
 
3
  import httpx
4
+ import google.generativeai as genai
5
  from app.config import settings
6
+ from app.schemas.analysis import AnalysisResult, RiskItem, ActionItem, CompanyProfile, Tender
 
 
7
  from app.services.report import generate_markdown_report
8
 
9
  # Configure Gemini
10
+ genai.configure(api_key=settings.gemini_api_key)
 
11
 
12
+ async def call_gemini(prompt: str, is_json: bool = False) -> str:
13
+ if not settings.gemini_api_key:
14
+ return ""
15
+
16
+ try:
17
+ generation_config = {
18
  "temperature": 0.2,
19
  "top_p": 0.95,
20
+ "top_k": 40,
21
  "max_output_tokens": 8192,
 
22
  }
23
+
24
+ if is_json:
25
+ generation_config["response_mime_type"] = "application/json"
26
+
27
+ model = genai.GenerativeModel(
28
+ model_name="gemini-2.0-flash",
29
+ generation_config=generation_config,
30
+ )
31
+
 
32
  response = await model.generate_content_async(prompt)
33
  return response.text
34
  except Exception as e:
35
+ print(f"Error calling Gemini (is_json={is_json}): {e}, trying fallback...")
36
+ if settings.groq_api_key:
37
+ return await call_groq(prompt, "llama-3.3-70b-versatile")
38
+ return await call_featherless(prompt, "Qwen/Qwen2.5-72B-Instruct")
39
 
40
+ async def call_featherless(prompt: str, model: str = "Qwen/Qwen2.5-72B-Instruct") -> str:
41
  if not settings.featherless_api_key:
42
  return ""
43
 
44
  try:
45
  async with httpx.AsyncClient(timeout=60.0) as client:
46
+ payload = {
47
+ "model": model,
48
+ "messages": [{"role": "user", "content": prompt}],
49
+ "temperature": 0.2
50
+ }
51
+ if "json" in prompt.lower():
52
+ payload["response_format"] = {"type": "json_object"}
53
+
54
  response = await client.post(
55
  "https://api.featherless.ai/v1/chat/completions",
56
  headers={
57
  "Authorization": f"Bearer {settings.featherless_api_key}",
58
  "Content-Type": "application/json"
59
  },
60
+ json=payload
 
 
 
 
 
61
  )
62
+ if response.status_code != 200:
63
+ print(f"Featherless Error ({model}): {response.status_code} - {response.text}")
64
+ return ""
65
  data = response.json()
66
  return data["choices"][0]["message"]["content"]
67
  except Exception as e:
68
  print(f"Error calling Featherless ({model}): {e}")
69
  return ""
70
 
71
+ async def call_groq(prompt: str, model: str = "llama-3.3-70b-versatile") -> str:
72
+ if not settings.groq_api_key:
73
+ return ""
74
+
75
+ try:
76
+ async with httpx.AsyncClient(timeout=60.0) as client:
77
+ payload = {
78
+ "model": model,
79
+ "messages": [{"role": "user", "content": prompt}],
80
+ "temperature": 0.2
81
+ }
82
+ if "json" in prompt.lower():
83
+ payload["response_format"] = {"type": "json_object"}
84
+
85
+ response = await client.post(
86
+ "https://api.groq.com/openai/v1/chat/completions",
87
+ headers={
88
+ "Authorization": f"Bearer {settings.groq_api_key}",
89
+ "Content-Type": "application/json"
90
+ },
91
+ json=payload
92
+ )
93
+ if response.status_code != 200:
94
+ print(f"Groq Error ({model}): {response.status_code} - {response.text}")
95
+ return ""
96
+ data = response.json()
97
+ return data["choices"][0]["message"]["content"]
98
+ except Exception as e:
99
+ print(f"Error calling Groq ({model}): {e}")
100
+ return ""
101
+
102
+ async def call_gemini_with_model(prompt: str, model_name: str | None = None, is_json: bool = False) -> str:
103
  model_map = {
104
  "Gemini 2.5 Flash": "gemini",
105
+ "DeepSeek-V3 (Featherless)": "deepseek-ai/DeepSeek-V3",
 
106
  "Qwen-2.5 (Featherless)": "Qwen/Qwen2.5-72B-Instruct",
107
+ "Llama-3.3-70B (Groq)": "groq:llama-3.3-70b-versatile",
108
+ "Llama-3.1-8B (Groq)": "groq:llama-3.1-8b-instant",
109
+ "Llama-3.1-70B (Groq)": "groq:llama-3.1-70b-versatile",
110
  }
111
 
112
  model_id = model_map.get(model_name, "gemini")
113
 
114
  if model_id == "gemini":
115
+ res = await call_gemini(prompt, is_json=is_json)
116
+ if not res and settings.groq_api_key:
117
+ return await call_groq(prompt, "llama-3.3-70b-versatile")
118
+ return res
119
+ elif model_id.startswith("groq:"):
120
+ return await call_groq(prompt, model=model_id[5:])
121
  else:
122
  return await call_featherless(prompt, model=model_id)
123
 
124
+ def _parse_gemini_response(output: str) -> dict | None:
125
  if not output:
126
+ return None
 
 
 
 
 
 
 
 
 
 
127
 
128
+ # Remove Markdown code blocks if present
129
+ clean_output = output.strip()
130
+ if clean_output.startswith("```json"):
131
+ clean_output = clean_output[7:-3].strip()
132
+ elif clean_output.startswith("```"):
133
+ clean_output = clean_output[3:-3].strip()
134
+
135
  try:
136
+ data = json.loads(clean_output)
137
+ except Exception as e:
138
+ print(f"JSON Parsing Error: {e}\nRaw Output: {output[:200]}...")
 
 
 
 
 
139
  return None
140
+
141
+ if data:
142
+ # Handle nesting (LLMs sometimes wrap the result in a key)
143
+ if not all(k in data for k in ["fit_score", "decision", "risks"]):
144
+ for val in data.values():
145
+ if isinstance(val, dict) and any(k in val for k in ["fit_score", "decision", "risks"]):
146
+ data = val
147
+ break
148
+
149
+ # Ensure strategic_roadmap is a string
150
+ if "strategic_roadmap" in data:
151
+ if isinstance(data["strategic_roadmap"], list):
152
+ data["strategic_roadmap"] = "\n".join([str(item) for item in data["strategic_roadmap"]])
153
+ elif isinstance(data["strategic_roadmap"], dict):
154
+ data["strategic_roadmap"] = json.dumps(data["strategic_roadmap"], indent=2, ensure_ascii=False)
155
+
156
+ # Ensure risks is a list of objects
157
+ if "risks" in data and isinstance(data["risks"], list):
158
+ new_risks = []
159
+ for item in data["risks"]:
160
+ if isinstance(item, str):
161
+ new_risks.append({"title": item, "severity": "Medium", "explanation": item})
162
+ elif isinstance(item, dict):
163
+ new_risks.append(item)
164
+ data["risks"] = new_risks
165
 
166
+ # Ensure action_plan is a list of objects
167
+ if "action_plan" in data and isinstance(data["action_plan"], list):
168
+ new_plan = []
169
+ for item in data["action_plan"]:
170
+ if isinstance(item, str):
171
+ new_plan.append({"task": item, "priority": "Medium", "owner": "Team", "timeline": "TBD"})
172
+ elif isinstance(item, dict):
173
+ new_plan.append(item)
174
+ data["action_plan"] = new_plan
175
+
176
+ # Ensure fit_score is int
177
+ if "fit_score" in data:
178
+ try:
179
+ data["fit_score"] = int(data["fit_score"])
180
+ except:
181
+ data["fit_score"] = 0
182
+
183
+ return data
184
+ return None
 
 
 
 
 
 
 
 
185
 
186
  def generate_mock_analysis(tender: Tender, company: CompanyProfile) -> AnalysisResult:
187
  raw = f"{tender.code}:{tender.name}:{company.name}"
 
202
  )
203
 
204
  async def generate_analysis(tender: Tender, company: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
 
 
 
 
 
 
 
 
 
 
205
  chosen = models or {
206
+ "legal": "Llama-3.3-70B (Groq)" if settings.groq_api_key else "Gemini 2.5 Flash",
207
+ "tech": "Llama-3.1-8B (Groq)" if settings.groq_api_key else "Qwen-2.5 (Featherless)",
208
+ "risk": "Llama-3.3-70B (Groq)" if settings.groq_api_key else "Qwen-2.5 (Featherless)"
209
  }
210
 
211
  audit_messages = ["🚀 Launching Multi-Agent Orchestration Pipeline."]
212
  agent_outputs = {}
213
 
 
214
  agent_definitions = {
215
  "legal": "Experto Legal & Cumplimiento: Evalúa bases administrativas, multas y garantías. Pon especial atención a los ANEXOS de Sustentabilidad y Admisibilidad.",
216
  "tech": "Ingeniero Técnico: Evalúa arquitectura, stack tecnológico y capacidad de ejecución. Considera si se requieren certificaciones ambientales.",
 
219
 
220
  for agent_id, role_desc in agent_definitions.items():
221
  model_name = chosen.get(agent_id, "Gemini 2.5 Flash")
 
 
222
  audit_messages.append(f"🤖 Agent {agent_id.upper()} calling {model_name}...")
223
 
224
  agent_prompt = f"""
 
230
  PROPORCIONA TU ANÁLISIS ESPECÍFICO (Máx 200 palabras) EN ESPAÑOL.
231
  """
232
 
233
+ res = await call_gemini_with_model(agent_prompt, model_name=model_name)
234
+ agent_outputs[agent_id] = res or "Análisis no disponible debido a error de conexión."
 
 
 
 
 
 
 
 
 
235
 
236
+ audit_messages.append("🧠 Synthesis phase: Consolidating agent insights...")
 
 
 
 
 
 
 
237
 
238
+ synthesis_prompt = f"""
239
+ SISTEMA DE CONSENSO ANDESOPS AI
240
+ Licitación: {tender.name}
241
+ Resultados de Agentes:
242
+ - LEGAL: {agent_outputs.get('legal')}
243
+ - TECH: {agent_outputs.get('tech')}
244
+ - RISK: {agent_outputs.get('risk')}
 
245
 
246
+ Genera el JSON final AnalysisResult con una decisión fundamentada.
247
  RESPONDE SOLO EL JSON.
248
  """
249
 
250
+ final_json = await call_gemini(synthesis_prompt, is_json=True)
251
+ if not final_json and settings.groq_api_key:
252
+ final_json = await call_groq(synthesis_prompt, model="llama-3.3-70b-versatile")
253
+ elif not final_json and settings.featherless_api_key:
254
+ final_json = await call_featherless(synthesis_prompt, model="Qwen/Qwen2.5-72B-Instruct")
255
 
256
  parse_result = _parse_gemini_response(final_json)
257
 
258
  if parse_result:
259
  try:
260
+ if not parse_result.get("report_markdown"):
261
+ parse_result["report_markdown"] = generate_markdown_report(parse_result)
262
+
263
  if not parse_result.get("proposal_draft") or len(parse_result["proposal_draft"]) < 100:
264
  audit_messages.append("📝 Generating specialized proposal draft...")
265
  parse_result["proposal_draft"] = await generate_proposal_draft(parse_result, company)
 
268
  result.audit_log = audit_messages + (result.audit_log or [])
269
  return result
270
  except Exception as e:
271
+ print(f"Validation Error in generate_analysis: {e}")
272
+
 
273
  analysis = generate_mock_analysis(tender, company)
274
  analysis.audit_log = audit_messages + ["⚠️ Synthesis failed, using emergency fallback."]
275
  return analysis
 
289
  4. Propuesta de Valor Estratégica
290
  """
291
 
292
+ return await call_gemini_with_model(prompt, model_name="Llama-3.3-70B (Groq)" if settings.groq_api_key else "Gemini 2.5 Flash")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/report.py CHANGED
@@ -23,17 +23,23 @@ def generate_markdown_report(analysis: Any) -> str:
23
  lines.append("")
24
  lines.append("## Riesgos")
25
  for risk in _value(analysis, "risks") or []:
26
- lines.append(f"- **{risk['title']}** ({risk['severity']}): {risk['explanation']}")
 
 
 
27
  lines.append("")
28
  lines.append("## Brechas de Cumplimiento")
29
  for gap in _value(analysis, "compliance_gaps") or []:
30
- lines.append(f"- {gap}")
31
  lines.append("")
32
  lines.append("## Plan de Acción")
33
  for item in _value(analysis, "action_plan") or []:
34
- lines.append(
35
- f"- **{item['task']}** | Prioridad: {item['priority']} | Responsable: {item['owner']} | Tiempo: {item['timeline']}"
36
- )
 
 
 
37
  lines.append("")
38
  lines.append("## Borrador de Propuesta")
39
  lines.append(_value(analysis, "proposal_draft"))
 
23
  lines.append("")
24
  lines.append("## Riesgos")
25
  for risk in _value(analysis, "risks") or []:
26
+ if isinstance(risk, dict):
27
+ lines.append(f"- **{risk.get('title', 'Riesgo')}** ({risk.get('severity', 'Medium')}): {risk.get('explanation', '')}")
28
+ else:
29
+ lines.append(f"- {str(risk)}")
30
  lines.append("")
31
  lines.append("## Brechas de Cumplimiento")
32
  for gap in _value(analysis, "compliance_gaps") or []:
33
+ lines.append(f"- {str(gap)}")
34
  lines.append("")
35
  lines.append("## Plan de Acción")
36
  for item in _value(analysis, "action_plan") or []:
37
+ if isinstance(item, dict):
38
+ lines.append(
39
+ f"- **{item.get('task', 'Tarea')}** | Prioridad: {item.get('priority', 'Medium')} | Responsable: {item.get('owner', 'Team')} | Tiempo: {item.get('timeline', 'TBD')}"
40
+ )
41
+ else:
42
+ lines.append(f"- {str(item)}")
43
  lines.append("")
44
  lines.append("## Borrador de Propuesta")
45
  lines.append(_value(analysis, "proposal_draft"))
frontend/components/AgentAnalysis.tsx CHANGED
@@ -99,7 +99,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
99
  }
100
  return prev;
101
  });
102
- }, 3000);
103
 
104
  // We call the parent's onAnalyze but we want the result back locally too
105
  // Actually, since we want multiple analyses, we might need to handle the result here
@@ -136,7 +136,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
136
 
137
  if (!tender && !analysis) {
138
  return (
139
- <div className="flex flex-col items-center justify-center min-h-[60vh] space-y-12 animate-in fade-in duration-1000">
140
  <div className="text-center space-y-4">
141
  <div className="inline-block p-4 rounded-3xl bg-white/5 border border-white/10 mb-6">
142
  <span className="text-5xl">🤖</span>
@@ -173,7 +173,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
173
  }
174
 
175
  return (
176
- <div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
177
  {/* Navigation Header */}
178
  <div className="flex justify-start">
179
  <button
@@ -298,9 +298,11 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
298
  <div className="space-y-1">
299
  {[
300
  "Gemini 2.5 Flash",
301
- "DeepSeek-V3.2 (Featherless)",
302
- "Qwen-3-32B (Featherless)",
303
  "Qwen-2.5 (Featherless)",
 
 
 
304
  "Gemma-4-31B (Featherless)",
305
  "Llama-3.1-8B (Featherless)"
306
  ].map(model => (
@@ -360,7 +362,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
360
 
361
  {/* Analysis Results View */}
362
  {activeAnalysis && (
363
- <div id="analysis-results" className="grid gap-8 lg:grid-cols-12 animate-in fade-in slide-in-from-bottom-8 duration-1000 scroll-mt-20">
364
  <div className="lg:col-span-8 space-y-8">
365
  <div className="glass-card rounded-3xl p-10 bg-white/[0.02]">
366
  <div className="flex items-start justify-between mb-8">
 
99
  }
100
  return prev;
101
  });
102
+ }, 800); // Faster log timing for snappier feel
103
 
104
  // We call the parent's onAnalyze but we want the result back locally too
105
  // Actually, since we want multiple analyses, we might need to handle the result here
 
136
 
137
  if (!tender && !analysis) {
138
  return (
139
+ <div className="flex flex-col items-center justify-center min-h-[60vh] space-y-12 animate-in fade-in duration-300">
140
  <div className="text-center space-y-4">
141
  <div className="inline-block p-4 rounded-3xl bg-white/5 border border-white/10 mb-6">
142
  <span className="text-5xl">🤖</span>
 
173
  }
174
 
175
  return (
176
+ <div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
177
  {/* Navigation Header */}
178
  <div className="flex justify-start">
179
  <button
 
298
  <div className="space-y-1">
299
  {[
300
  "Gemini 2.5 Flash",
301
+ "DeepSeek-V3 (Featherless)",
 
302
  "Qwen-2.5 (Featherless)",
303
+ "Llama-3.3-70B (Groq)",
304
+ "Llama-3.1-8B (Groq)",
305
+ "Mixtral-8x7B (Groq)",
306
  "Gemma-4-31B (Featherless)",
307
  "Llama-3.1-8B (Featherless)"
308
  ].map(model => (
 
362
 
363
  {/* Analysis Results View */}
364
  {activeAnalysis && (
365
+ <div id="analysis-results" className="grid gap-8 lg:grid-cols-12 animate-in fade-in slide-in-from-bottom-8 duration-500 scroll-mt-20">
366
  <div className="lg:col-span-8 space-y-8">
367
  <div className="glass-card rounded-3xl p-10 bg-white/[0.02]">
368
  <div className="flex items-start justify-between mb-8">
frontend/components/ProposalDraft.tsx CHANGED
@@ -4,7 +4,7 @@ type Props = {
4
 
5
  export default function ProposalDraft({ proposal }: Props) {
6
  return (
7
- <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-700">
8
  <div className="glass-card rounded-[2rem] p-8 border border-white/10 relative overflow-hidden">
9
  <div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/10 blur-[60px]" />
10
  <h2 className="text-2xl font-bold text-white mb-2">Technical Proposal Draft</h2>
 
4
 
5
  export default function ProposalDraft({ proposal }: Props) {
6
  return (
7
+ <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-300">
8
  <div className="glass-card rounded-[2rem] p-8 border border-white/10 relative overflow-hidden">
9
  <div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/10 blur-[60px]" />
10
  <h2 className="text-2xl font-bold text-white mb-2">Technical Proposal Draft</h2>
frontend/components/Sidebar.tsx CHANGED
@@ -71,7 +71,7 @@ export default function Sidebar({ tabs, activeTab, onTabSelect, status, lang, fo
71
  onTabSelect(tab);
72
  window.history.pushState({}, '', `?tab=${tabSlug}`);
73
  }}
74
- className={`flex items-center rounded-xl transition-all duration-300 group relative ${
75
  isActive
76
  ? "bg-white/10 text-white shadow-inner"
77
  : "text-slate-400 hover:bg-white/5 hover:text-white"
 
71
  onTabSelect(tab);
72
  window.history.pushState({}, '', `?tab=${tabSlug}`);
73
  }}
74
+ className={`flex items-center rounded-xl transition-all duration-200 active:scale-95 group relative ${
75
  isActive
76
  ? "bg-white/10 text-white shadow-inner"
77
  : "text-slate-400 hover:bg-white/5 hover:text-white"
frontend/globals.css CHANGED
@@ -41,10 +41,10 @@
41
  @layer components {
42
  .glass-card {
43
  background-color: rgba(0, 0, 0, 0.4);
44
- backdrop-filter: blur(12px);
45
  border: 1px solid rgba(255, 255, 255, 0.1);
46
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
47
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
48
  }
49
 
50
  .glass-card:hover {
 
41
  @layer components {
42
  .glass-card {
43
  background-color: rgba(0, 0, 0, 0.4);
44
+ backdrop-filter: blur(8px);
45
  border: 1px solid rgba(255, 255, 255, 0.1);
46
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
47
+ transition: all 0.2s ease-out;
48
  }
49
 
50
  .glass-card:hover {