Álvaro Valenzuela Valdes commited on
Commit ·
589d3b7
1
Parent(s): aeaf3e7
feat: Full multi-agent orchestration workflow with user-defined model selection (Gemma 4, Qwen 3 support)
Browse files- backend/app/services/llm.py +72 -45
- frontend/app/page.tsx +2 -2
- frontend/components/AgentAnalysis.tsx +56 -10
backend/app/services/llm.py
CHANGED
|
@@ -139,62 +139,89 @@ def generate_mock_analysis(tender: Tender, company: CompanyProfile) -> AnalysisR
|
|
| 139 |
audit_log=["Iniciando análisis de respaldo...", "Generando datos mock."]
|
| 140 |
)
|
| 141 |
|
| 142 |
-
def generate_analysis(tender: Tender, company: CompanyProfile, document_text: str | None = None) -> AnalysisResult:
|
| 143 |
-
#
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
audit_messages.append("
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
if parse_result:
|
| 179 |
try:
|
| 180 |
-
if not parse_result.get("proposal_draft")
|
| 181 |
parse_result["proposal_draft"] = generate_proposal_draft(parse_result, company)
|
| 182 |
|
| 183 |
-
if not parse_result.get("report_markdown"):
|
| 184 |
-
parse_result["report_markdown"] = generate_markdown_report(parse_result)
|
| 185 |
-
|
| 186 |
result = AnalysisResult(**parse_result)
|
| 187 |
-
# Add our multimodal logs
|
| 188 |
result.audit_log = audit_messages + (result.audit_log or [])
|
| 189 |
return result
|
| 190 |
except Exception as e:
|
| 191 |
-
print(f"
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
error_msg = "🧩 Format Error: Response was not valid JSON."
|
| 195 |
-
|
| 196 |
analysis = generate_mock_analysis(tender, company)
|
| 197 |
-
analysis.audit_log = audit_messages + [
|
| 198 |
return analysis
|
| 199 |
|
| 200 |
def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> str:
|
|
|
|
| 139 |
audit_log=["Iniciando análisis de respaldo...", "Generando datos mock."]
|
| 140 |
)
|
| 141 |
|
| 142 |
+
def generate_analysis(tender: Tender, company: CompanyProfile, document_text: str | None = None, models: dict | None = None) -> AnalysisResult:
|
| 143 |
+
# Default model mapping
|
| 144 |
+
model_map = {
|
| 145 |
+
"Gemini 2.5 Flash": "gemini",
|
| 146 |
+
"DeepSeek-V3.2 (Featherless)": "deepseek-ai/DeepSeek-V3.2",
|
| 147 |
+
"Qwen-3-32B (Featherless)": "Qwen/Qwen3-32B",
|
| 148 |
+
"Gemma-4-31B (Featherless)": "google/gemma-4-31B-it",
|
| 149 |
+
"Llama-3.1-8B (Featherless)": "meta-llama/Meta-Llama-3.1-8B-Instruct"
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
# Get selected models or defaults
|
| 153 |
+
chosen = models or {
|
| 154 |
+
"legal": "Gemini 2.5 Flash",
|
| 155 |
+
"tech": "DeepSeek-V3.2 (Featherless)",
|
| 156 |
+
"risk": "Qwen-3-32B (Featherless)"
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
audit_messages = ["🚀 Launching Multi-Agent Orchestration Pipeline."]
|
| 160 |
+
agent_outputs = {}
|
| 161 |
+
|
| 162 |
+
# Define Agent roles for separate calls
|
| 163 |
+
agent_definitions = {
|
| 164 |
+
"legal": "Experto Legal & Cumplimiento: Evalúa bases administrativas, multas y garantías.",
|
| 165 |
+
"tech": "Ingeniero Técnico: Evalúa arquitectura, stack tecnológico y capacidad de ejecución.",
|
| 166 |
+
"risk": "Estratega Comercial: Evalúa rentabilidad, competencia y riesgos de mercado."
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
for agent_id, role_desc in agent_definitions.items():
|
| 170 |
+
model_name = chosen.get(agent_id, "Gemini 2.5 Flash")
|
| 171 |
+
model_id = model_map.get(model_name, "gemini")
|
| 172 |
|
| 173 |
+
audit_messages.append(f"🤖 Agent {agent_id.upper()} calling {model_name}...")
|
| 174 |
+
|
| 175 |
+
agent_prompt = f"""
|
| 176 |
+
Actúa como {role_desc}
|
| 177 |
+
Licitación: {tender.name} ({tender.code})
|
| 178 |
+
Empresa: {company.name}
|
| 179 |
+
Contexto Adicional: {document_text[:5000] if document_text else 'No adjunto.'}
|
| 180 |
+
|
| 181 |
+
PROPORCIONA TU ANÁLISIS ESPECÍFICO (Máx 200 palabras) EN ESPAÑOL.
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
if model_id == "gemini":
|
| 185 |
+
agent_outputs[agent_id] = call_gemini(agent_prompt)
|
| 186 |
+
else:
|
| 187 |
+
agent_outputs[agent_id] = call_featherless(agent_prompt, model=model_id)
|
| 188 |
+
|
| 189 |
+
# FINAL CONSENSUS AGENT (Synthesis)
|
| 190 |
+
audit_messages.append("⚖️ Final Consensus Agent synthesizing results...")
|
| 191 |
+
synthesis_prompt = f"""
|
| 192 |
+
Eres el AGENTE DE CONSENSO. Debes unificar estos 3 análisis en un único JSON de 'AnalysisResult':
|
| 193 |
+
|
| 194 |
+
1. LEGAL: {agent_outputs.get('legal')}
|
| 195 |
+
2. TECH: {agent_outputs.get('tech')}
|
| 196 |
+
3. RISK: {agent_outputs.get('risk')}
|
| 197 |
+
|
| 198 |
+
Genera el JSON final siguiendo estas reglas:
|
| 199 |
+
- executive_summary: Resumen integrador en español.
|
| 200 |
+
- fit_score: Promedio de encaje (0-100).
|
| 201 |
+
- decision: 'Recommended', 'Review Carefully' o 'Not Recommended'.
|
| 202 |
+
- risks, key_requirements, compliance_gaps, action_plan.
|
| 203 |
+
- audit_log: Incluye los pasos tomados.
|
| 204 |
+
|
| 205 |
+
RESPONDE SOLO EL JSON.
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
final_json = call_gemini(synthesis_prompt)
|
| 209 |
+
parse_result = _parse_gemini_response(final_json)
|
| 210 |
|
| 211 |
if parse_result:
|
| 212 |
try:
|
| 213 |
+
if not parse_result.get("proposal_draft"):
|
| 214 |
parse_result["proposal_draft"] = generate_proposal_draft(parse_result, company)
|
| 215 |
|
|
|
|
|
|
|
|
|
|
| 216 |
result = AnalysisResult(**parse_result)
|
|
|
|
| 217 |
result.audit_log = audit_messages + (result.audit_log or [])
|
| 218 |
return result
|
| 219 |
except Exception as e:
|
| 220 |
+
print(f"Synthesis Mapping Error: {e}")
|
| 221 |
+
|
| 222 |
+
# Final Fallback
|
|
|
|
|
|
|
| 223 |
analysis = generate_mock_analysis(tender, company)
|
| 224 |
+
analysis.audit_log = audit_messages + ["⚠️ Synthesis failed, using emergency fallback."]
|
| 225 |
return analysis
|
| 226 |
|
| 227 |
def generate_proposal_draft(analysis: dict, company: CompanyProfile) -> str:
|
frontend/app/page.tsx
CHANGED
|
@@ -128,9 +128,9 @@ export default function HomePage() {
|
|
| 128 |
}
|
| 129 |
};
|
| 130 |
|
| 131 |
-
const handleRunAnalysis = async (documentText?: string) => {
|
| 132 |
if (!selectedTender) return;
|
| 133 |
-
const result = await analyzeTender(selectedTender, companyProfile, documentText);
|
| 134 |
setAnalysisResult(result);
|
| 135 |
|
| 136 |
try {
|
|
|
|
| 128 |
}
|
| 129 |
};
|
| 130 |
|
| 131 |
+
const handleRunAnalysis = async (documentText?: string, models?: Record<string, string>) => {
|
| 132 |
if (!selectedTender) return;
|
| 133 |
+
const result = await analyzeTender(selectedTender, companyProfile, documentText, models);
|
| 134 |
setAnalysisResult(result);
|
| 135 |
|
| 136 |
try {
|
frontend/components/AgentAnalysis.tsx
CHANGED
|
@@ -8,7 +8,7 @@ type Props = {
|
|
| 8 |
tender: Tender | null;
|
| 9 |
companyProfile: CompanyProfile;
|
| 10 |
analysis: AnalysisResult | null;
|
| 11 |
-
onAnalyze: (documentText?: string) => Promise<void>;
|
| 12 |
};
|
| 13 |
|
| 14 |
const agents = [
|
|
@@ -23,6 +23,13 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
|
|
| 23 |
const [file, setFile] = useState<File | null>(null);
|
| 24 |
const [isUploading, setIsUploading] = useState(false);
|
| 25 |
const [documentText, setDocumentText] = useState<string | "">("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 28 |
if (event.target.files && event.target.files[0]) {
|
|
@@ -42,7 +49,7 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
|
|
| 42 |
extractedText = uploadResult.text;
|
| 43 |
setDocumentText(extractedText);
|
| 44 |
}
|
| 45 |
-
await onAnalyze(extractedText);
|
| 46 |
} catch (error) {
|
| 47 |
console.error("Error during analysis flow:", error);
|
| 48 |
} finally {
|
|
@@ -151,16 +158,55 @@ export default function AgentAnalysis({ tender, companyProfile, analysis, onAnal
|
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
|
| 154 |
-
{/* Agents Row (Visual feedback) */}
|
| 155 |
<div className="grid gap-6 md:grid-cols-3">
|
| 156 |
{agents.map((agent) => (
|
| 157 |
-
<div key={agent.id} className=
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</div>
|
| 165 |
))}
|
| 166 |
</div>
|
|
|
|
| 8 |
tender: Tender | null;
|
| 9 |
companyProfile: CompanyProfile;
|
| 10 |
analysis: AnalysisResult | null;
|
| 11 |
+
onAnalyze: (documentText?: string, models?: Record<string, string>) => Promise<void>;
|
| 12 |
};
|
| 13 |
|
| 14 |
const agents = [
|
|
|
|
| 23 |
const [file, setFile] = useState<File | null>(null);
|
| 24 |
const [isUploading, setIsUploading] = useState(false);
|
| 25 |
const [documentText, setDocumentText] = useState<string | "">("");
|
| 26 |
+
const [agentModels, setAgentModels] = useState({
|
| 27 |
+
legal: "Gemini 2.5 Flash",
|
| 28 |
+
tech: "DeepSeek-V3.2 (Featherless)",
|
| 29 |
+
risk: "Qwen-2.5 (Featherless)"
|
| 30 |
+
});
|
| 31 |
+
const [activeSettings, setActiveSettings] = useState<string | null>(null);
|
| 32 |
+
|
| 33 |
|
| 34 |
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 35 |
if (event.target.files && event.target.files[0]) {
|
|
|
|
| 49 |
extractedText = uploadResult.text;
|
| 50 |
setDocumentText(extractedText);
|
| 51 |
}
|
| 52 |
+
await onAnalyze(extractedText, agentModels);
|
| 53 |
} catch (error) {
|
| 54 |
console.error("Error during analysis flow:", error);
|
| 55 |
} finally {
|
|
|
|
| 158 |
</div>
|
| 159 |
</div>
|
| 160 |
|
| 161 |
+
{/* Agents Row (Visual feedback & Configuration) */}
|
| 162 |
<div className="grid gap-6 md:grid-cols-3">
|
| 163 |
{agents.map((agent) => (
|
| 164 |
+
<div key={agent.id} className="relative group">
|
| 165 |
+
<div className={`glass-card rounded-3xl p-6 flex items-center gap-4 transition-all duration-700 ${isRunning ? 'ring-2 ring-purple-500/50 animate-pulse' : ''} ${analysis ? 'border-purple-500/30' : 'border-white/5'} hover:border-purple-500/20`}>
|
| 166 |
+
<div className={`text-4xl ${isRunning ? 'animate-bounce' : ''}`}>{agent.avatar}</div>
|
| 167 |
+
<div className="flex-1">
|
| 168 |
+
<div className={`text-[10px] font-bold uppercase tracking-widest ${agent.color}`}>{agent.role}</div>
|
| 169 |
+
<div className="text-sm font-bold text-white">{agent.name}</div>
|
| 170 |
+
<div className="text-[9px] text-slate-500 font-mono mt-1 flex items-center gap-1">
|
| 171 |
+
<span className="w-1 h-1 rounded-full bg-slate-500" />
|
| 172 |
+
{agentModels[agent.id as keyof typeof agentModels]}
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
<button
|
| 176 |
+
onClick={() => setActiveSettings(activeSettings === agent.id ? null : agent.id)}
|
| 177 |
+
className="p-2 rounded-xl bg-white/5 text-slate-500 hover:bg-white/10 hover:text-white transition-all active:scale-90"
|
| 178 |
+
>
|
| 179 |
+
⚙️
|
| 180 |
+
</button>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
{/* Model Selector Popover */}
|
| 184 |
+
{activeSettings === agent.id && (
|
| 185 |
+
<div className="absolute top-full left-0 right-0 mt-2 z-50 glass-card rounded-2xl p-4 border border-purple-500/30 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
| 186 |
+
<p className="text-[9px] font-black uppercase text-purple-400 mb-3 tracking-widest px-1">Select Engine</p>
|
| 187 |
+
<div className="space-y-1">
|
| 188 |
+
{[
|
| 189 |
+
"Gemini 2.5 Flash",
|
| 190 |
+
"DeepSeek-V3.2 (Featherless)",
|
| 191 |
+
"Qwen-3-32B (Featherless)",
|
| 192 |
+
"Gemma-4-31B (Featherless)",
|
| 193 |
+
"Llama-3.1-8B (Featherless)"
|
| 194 |
+
].map(model => (
|
| 195 |
+
<button
|
| 196 |
+
key={model}
|
| 197 |
+
onClick={() => {
|
| 198 |
+
setAgentModels(prev => ({ ...prev, [agent.id]: model }));
|
| 199 |
+
setActiveSettings(null);
|
| 200 |
+
}}
|
| 201 |
+
className={`w-full text-left px-3 py-2 rounded-xl text-xs transition-all flex items-center justify-between ${agentModels[agent.id as keyof typeof agentModels] === model ? 'bg-purple-500/20 text-white border border-purple-500/30' : 'text-slate-400 hover:bg-white/5 hover:text-slate-200'}`}
|
| 202 |
+
>
|
| 203 |
+
<span>{model}</span>
|
| 204 |
+
{agentModels[agent.id as keyof typeof agentModels] === model && <span>✓</span>}
|
| 205 |
+
</button>
|
| 206 |
+
))}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
)}
|
| 210 |
</div>
|
| 211 |
))}
|
| 212 |
</div>
|