Álvaro Valenzuela Valdes commited on
Commit ·
5642e68
1
Parent(s): ef0e7f7
Fix async LLM calls and agents orchestration to resolve chat connectivity error
Browse files- backend/app/services/agents.py +5 -5
- backend/app/services/llm.py +24 -23
- backend/app/services/scraper.py +3 -3
backend/app/services/agents.py
CHANGED
|
@@ -18,7 +18,7 @@ async def legal_agent_task(tender: Tender, company: CompanyProfile, document_tex
|
|
| 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
|
| 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]])
|
|
@@ -31,7 +31,7 @@ async def technical_agent_task(tender: Tender, company: CompanyProfile, document
|
|
| 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
|
| 35 |
|
| 36 |
async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_text: str = "", model: str | None = None) -> str:
|
| 37 |
prompt = (
|
|
@@ -43,7 +43,7 @@ async def strategy_agent_task(tender: Tender, company: CompanyProfile, document_
|
|
| 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
|
| 47 |
|
| 48 |
async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
|
| 49 |
audit_log = ["🚀 Iniciando mesa de expertos agéntica..."]
|
|
@@ -87,7 +87,7 @@ async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, doc
|
|
| 87 |
f"Responde ÚNICAMENTE con el JSON."
|
| 88 |
)
|
| 89 |
|
| 90 |
-
final_output = await
|
| 91 |
parse_result = _parse_gemini_response(final_output)
|
| 92 |
|
| 93 |
if parse_result:
|
|
@@ -105,4 +105,4 @@ async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, doc
|
|
| 105 |
|
| 106 |
# Fallback
|
| 107 |
from app.services.llm import generate_analysis
|
| 108 |
-
return generate_analysis(tender, company_profile, doc_text, models)
|
|
|
|
| 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]])
|
|
|
|
| 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 = (
|
|
|
|
| 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 |
|
| 48 |
async def run_full_analysis(tender: Tender, company_profile: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
|
| 49 |
audit_log = ["🚀 Iniciando mesa de expertos agéntica..."]
|
|
|
|
| 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:
|
|
|
|
| 105 |
|
| 106 |
# Fallback
|
| 107 |
from app.services.llm import generate_analysis
|
| 108 |
+
return await generate_analysis(tender, company_profile, doc_text, models)
|
backend/app/services/llm.py
CHANGED
|
@@ -27,26 +27,27 @@ def get_gemini_model():
|
|
| 27 |
}
|
| 28 |
)
|
| 29 |
|
| 30 |
-
def call_gemini(prompt: str) -> str:
|
| 31 |
if not settings.gemini_api_key:
|
| 32 |
# Fallback to Featherless if Gemini not available
|
| 33 |
-
return call_featherless(prompt, "meta-llama/Llama-3.3-70B-Instruct")
|
| 34 |
|
| 35 |
try:
|
| 36 |
model = get_gemini_model()
|
| 37 |
-
|
|
|
|
| 38 |
return response.text
|
| 39 |
except Exception as e:
|
| 40 |
print(f"Error calling Gemini: {e}, trying Featherless fallback...")
|
| 41 |
-
return call_featherless(prompt, "meta-llama/Llama-3.3-70B-Instruct")
|
| 42 |
|
| 43 |
-
def call_featherless(prompt: str, model: str = "deepseek-ai/DeepSeek-V3.2") -> str:
|
| 44 |
if not settings.featherless_api_key:
|
| 45 |
return ""
|
| 46 |
|
| 47 |
try:
|
| 48 |
-
with httpx.
|
| 49 |
-
response = client.post(
|
| 50 |
"https://api.featherless.ai/v1/chat/completions",
|
| 51 |
headers={
|
| 52 |
"Authorization": f"Bearer {settings.featherless_api_key}",
|
|
@@ -65,7 +66,7 @@ def call_featherless(prompt: str, model: str = "deepseek-ai/DeepSeek-V3.2") -> s
|
|
| 65 |
print(f"Error calling Featherless ({model}): {e}")
|
| 66 |
return ""
|
| 67 |
|
| 68 |
-
def call_gemini_with_model(prompt: str, model_name: str | None = None) -> str:
|
| 69 |
# Default model mapping
|
| 70 |
model_map = {
|
| 71 |
"Gemini 2.5 Flash": "gemini",
|
|
@@ -79,9 +80,9 @@ def call_gemini_with_model(prompt: str, model_name: str | None = None) -> str:
|
|
| 79 |
model_id = model_map.get(model_name, "gemini")
|
| 80 |
|
| 81 |
if model_id == "gemini":
|
| 82 |
-
return call_gemini(prompt)
|
| 83 |
else:
|
| 84 |
-
return call_featherless(prompt, model=model_id)
|
| 85 |
|
| 86 |
def _normalize_gemini_output(output: str) -> str:
|
| 87 |
if not output:
|
|
@@ -158,7 +159,7 @@ def generate_mock_analysis(tender: Tender, company: CompanyProfile) -> AnalysisR
|
|
| 158 |
audit_log=["Iniciando análisis de respaldo...", "Generando datos mock."]
|
| 159 |
)
|
| 160 |
|
| 161 |
-
def generate_analysis(tender: Tender, company: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
|
| 162 |
# Default model mapping
|
| 163 |
model_map = {
|
| 164 |
"Gemini 2.5 Flash": "gemini",
|
|
@@ -202,13 +203,13 @@ def generate_analysis(tender: Tender, company: CompanyProfile, document_text: st
|
|
| 202 |
|
| 203 |
res = ""
|
| 204 |
if model_id == "gemini":
|
| 205 |
-
res = call_gemini(agent_prompt)
|
| 206 |
# Failover to Featherless if Gemini fails (e.g. 429)
|
| 207 |
if not res and settings.featherless_api_key:
|
| 208 |
audit_messages.append(f"🔄 Gemini failed/rate-limited. Switching to DeepSeek for {agent_id.upper()}...")
|
| 209 |
-
res = call_featherless(agent_prompt, model="deepseek-ai/DeepSeek-V3.2")
|
| 210 |
else:
|
| 211 |
-
res = call_featherless(agent_prompt, model=model_id)
|
| 212 |
|
| 213 |
agent_outputs[agent_id] = res or "Análisis no disponible por error de API."
|
| 214 |
|
|
@@ -233,9 +234,9 @@ def generate_analysis(tender: Tender, company: CompanyProfile, document_text: st
|
|
| 233 |
RESPONDE SOLO EL JSON.
|
| 234 |
"""
|
| 235 |
|
| 236 |
-
final_json = call_gemini(synthesis_prompt)
|
| 237 |
if not final_json and settings.featherless_api_key:
|
| 238 |
-
final_json = call_featherless(synthesis_prompt, model="deepseek-ai/DeepSeek-V3.2")
|
| 239 |
|
| 240 |
parse_result = _parse_gemini_response(final_json)
|
| 241 |
|
|
@@ -244,7 +245,7 @@ def generate_analysis(tender: Tender, company: CompanyProfile, document_text: st
|
|
| 244 |
# FORCE PROPOSAL GENERATION
|
| 245 |
if not parse_result.get("proposal_draft") or len(parse_result["proposal_draft"]) < 100:
|
| 246 |
audit_messages.append("📝 Generating specialized proposal draft...")
|
| 247 |
-
parse_result["proposal_draft"] = generate_proposal_draft(parse_result, company)
|
| 248 |
|
| 249 |
result = AnalysisResult(**parse_result)
|
| 250 |
result.audit_log = audit_messages + (result.audit_log or [])
|
|
@@ -257,7 +258,7 @@ def generate_analysis(tender: Tender, company: CompanyProfile, document_text: st
|
|
| 257 |
analysis.audit_log = audit_messages + ["⚠️ Synthesis failed, using emergency fallback."]
|
| 258 |
return analysis
|
| 259 |
|
| 260 |
-
def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> str:
|
| 261 |
prompt = f"""
|
| 262 |
Como experto redactor de propuestas de licitación, genera un borrador profesional (en Markdown) basado en este análisis técnico:
|
| 263 |
{analysis.get('executive_summary', 'Analizar bases adjuntas.')}
|
|
@@ -274,13 +275,13 @@ def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> str:
|
|
| 274 |
|
| 275 |
# Use DeepSeek for high-quality drafting if available
|
| 276 |
if settings.featherless_api_key:
|
| 277 |
-
draft = call_featherless(prompt, model="deepseek-ai/DeepSeek-V3.2")
|
| 278 |
if draft: return draft
|
| 279 |
|
| 280 |
# Fallback to Gemini
|
| 281 |
-
return call_gemini(prompt) or "Error al generar el borrador de la propuesta."
|
| 282 |
|
| 283 |
-
def generate_synthetic_tenders(keyword: str) -> list[Tender]:
|
| 284 |
"""
|
| 285 |
Generates realistic-looking synthetic tenders using an LLM.
|
| 286 |
Used as a fallback when the real scraper is blocked.
|
|
@@ -302,9 +303,9 @@ def generate_synthetic_tenders(keyword: str) -> list[Tender]:
|
|
| 302 |
Responde ÚNICAMENTE un JSON con una lista de objetos bajo la llave 'tenders'.
|
| 303 |
"""
|
| 304 |
|
| 305 |
-
res = call_gemini(prompt)
|
| 306 |
if not res and settings.featherless_api_key:
|
| 307 |
-
res = call_featherless(prompt)
|
| 308 |
|
| 309 |
data = _parse_gemini_response(res)
|
| 310 |
results = []
|
|
|
|
| 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}",
|
|
|
|
| 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",
|
|
|
|
| 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:
|
|
|
|
| 159 |
audit_log=["Iniciando análisis de respaldo...", "Generando datos mock."]
|
| 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",
|
|
|
|
| 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 |
|
|
|
|
| 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 |
|
|
|
|
| 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)
|
| 249 |
|
| 250 |
result = AnalysisResult(**parse_result)
|
| 251 |
result.audit_log = audit_messages + (result.audit_log or [])
|
|
|
|
| 258 |
analysis.audit_log = audit_messages + ["⚠️ Synthesis failed, using emergency fallback."]
|
| 259 |
return analysis
|
| 260 |
|
| 261 |
+
async def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> str:
|
| 262 |
prompt = f"""
|
| 263 |
Como experto redactor de propuestas de licitación, genera un borrador profesional (en Markdown) basado en este análisis técnico:
|
| 264 |
{analysis.get('executive_summary', 'Analizar bases adjuntas.')}
|
|
|
|
| 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.
|
|
|
|
| 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 = []
|
backend/app/services/scraper.py
CHANGED
|
@@ -42,14 +42,14 @@ async def scrape_compra_agil(keywords: str) -> List[Tender]:
|
|
| 42 |
|
| 43 |
if response.status_code != 200:
|
| 44 |
print(f"⚠️ API blocked (Status {response.status_code}). Activating Synthetic Fallback...")
|
| 45 |
-
return generate_synthetic_tenders(keywords)
|
| 46 |
|
| 47 |
raw_data = response.json()
|
| 48 |
items = raw_data.get("data", [])
|
| 49 |
|
| 50 |
if not items:
|
| 51 |
print(f"ℹ️ No real results found for '{keywords}'. Using Synthetic Intelligence to find potential leads.")
|
| 52 |
-
return generate_synthetic_tenders(keywords)
|
| 53 |
|
| 54 |
tenders = []
|
| 55 |
for item in items:
|
|
@@ -85,6 +85,6 @@ async def scrape_compra_agil(keywords: str) -> List[Tender]:
|
|
| 85 |
except Exception as e:
|
| 86 |
print(f"❌ Scraper failure: {e}. Activating emergency fallback.")
|
| 87 |
try:
|
| 88 |
-
return generate_synthetic_tenders(keywords)
|
| 89 |
except:
|
| 90 |
return []
|
|
|
|
| 42 |
|
| 43 |
if response.status_code != 200:
|
| 44 |
print(f"⚠️ API blocked (Status {response.status_code}). Activating Synthetic Fallback...")
|
| 45 |
+
return await generate_synthetic_tenders(keywords)
|
| 46 |
|
| 47 |
raw_data = response.json()
|
| 48 |
items = raw_data.get("data", [])
|
| 49 |
|
| 50 |
if not items:
|
| 51 |
print(f"ℹ️ No real results found for '{keywords}'. Using Synthetic Intelligence to find potential leads.")
|
| 52 |
+
return await generate_synthetic_tenders(keywords)
|
| 53 |
|
| 54 |
tenders = []
|
| 55 |
for item in items:
|
|
|
|
| 85 |
except Exception as e:
|
| 86 |
print(f"❌ Scraper failure: {e}. Activating emergency fallback.")
|
| 87 |
try:
|
| 88 |
+
return await generate_synthetic_tenders(keywords)
|
| 89 |
except:
|
| 90 |
return []
|